allowedHostsConfiguration.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import { randomUUID } from 'node:crypto'
  2. import {
  3. chmodSync,
  4. closeSync,
  5. existsSync,
  6. fstatSync,
  7. fsyncSync,
  8. mkdirSync,
  9. openSync,
  10. readFileSync,
  11. renameSync,
  12. unlinkSync,
  13. writeFileSync,
  14. } from 'node:fs'
  15. import { isIP } from 'node:net'
  16. import { dirname, join } from 'node:path'
  17. import { domainToASCII } from 'node:url'
  18. import { HttpError } from './errors.js'
  19. export type AllowedHostsConfigurationSource = 'none' | 'file' | 'environment'
  20. export interface AllowedHostsConfigurationSnapshot {
  21. source: AllowedHostsConfigurationSource
  22. hosts: string[]
  23. editableHosts: string[]
  24. environmentHosts: string[]
  25. configurationError?: string
  26. }
  27. interface StoredAllowedHostsConfiguration {
  28. schemaVersion: 1
  29. hosts: string[]
  30. updatedAt: string
  31. }
  32. const MAX_CONFIGURATION_FILE_BYTES = 64 * 1_024
  33. const MAX_ALLOWED_HOSTS = 128
  34. export function normalizeAllowedHost(raw: string): string {
  35. let value = raw.trim().toLowerCase().replace(/\.$/, '')
  36. if (value.startsWith('[') && value.endsWith(']')) value = value.slice(1, -1)
  37. if (!value || value.length > 253 || /[\u0000-\u0020\u007f/@\\?#]/.test(value)) {
  38. throw new HttpError(400, '允许的访问地址必须是域名或 IP,不能包含协议、端口或路径', 'invalid_allowed_host')
  39. }
  40. if (isIP(value)) return value
  41. if (value.includes(':')) {
  42. throw new HttpError(400, '地址格式无效;域名和 IP 均不能附加端口', 'invalid_allowed_host')
  43. }
  44. const ascii = domainToASCII(value).toLowerCase().replace(/\.$/, '')
  45. const labels = ascii.split('.')
  46. if (!ascii || ascii.length > 253 || labels.some(label => (
  47. !label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
  48. ))) {
  49. throw new HttpError(400, '允许的访问域名格式无效', 'invalid_allowed_host')
  50. }
  51. return ascii
  52. }
  53. export function canonicalAllowedHosts(values: readonly string[]): string[] {
  54. if (values.length > MAX_ALLOWED_HOSTS) {
  55. throw new HttpError(400, `允许的访问地址最多 ${MAX_ALLOWED_HOSTS} 个`, 'too_many_allowed_hosts')
  56. }
  57. return [...new Set(values.map(normalizeAllowedHost))].sort()
  58. }
  59. export function allowedHostsConfigurationPath(home: string): string {
  60. return join(home, 'network', 'allowed-hosts.json')
  61. }
  62. function readStoredHosts(path: string): string[] {
  63. const descriptor = openSync(path, 'r')
  64. try {
  65. const file = fstatSync(descriptor)
  66. if (!file.isFile() || file.size < 1 || file.size > MAX_CONFIGURATION_FILE_BYTES) {
  67. throw new Error('Allowed hosts configuration file has an invalid size')
  68. }
  69. const value = JSON.parse(readFileSync(descriptor).toString('utf8')) as Partial<StoredAllowedHostsConfiguration>
  70. if (value.schemaVersion !== 1 || !Array.isArray(value.hosts)
  71. || value.hosts.some(host => typeof host !== 'string')) {
  72. throw new Error('Unsupported allowed hosts configuration file')
  73. }
  74. return canonicalAllowedHosts(value.hosts as string[])
  75. } finally {
  76. closeSync(descriptor)
  77. }
  78. }
  79. export function loadAllowedHostsConfiguration(
  80. home: string,
  81. env: NodeJS.ProcessEnv = process.env,
  82. ): AllowedHostsConfigurationSnapshot {
  83. let environmentHosts: string[] = []
  84. try {
  85. environmentHosts = canonicalAllowedHosts(
  86. (env.HERMES_YAOYAO_ALLOWED_HOSTS ?? '').split(',').filter(value => value.trim()),
  87. )
  88. } catch (cause) {
  89. return {
  90. source: 'environment',
  91. hosts: [],
  92. editableHosts: [],
  93. environmentHosts: [],
  94. configurationError: cause instanceof Error ? cause.message : '允许的访问地址环境变量无效',
  95. }
  96. }
  97. const path = allowedHostsConfigurationPath(home)
  98. if (!existsSync(path)) {
  99. return {
  100. source: environmentHosts.length ? 'environment' : 'none',
  101. hosts: [...environmentHosts],
  102. editableHosts: [],
  103. environmentHosts,
  104. }
  105. }
  106. try {
  107. const editableHosts = readStoredHosts(path)
  108. chmodSync(path, 0o600)
  109. return {
  110. source: 'file',
  111. hosts: canonicalAllowedHosts([...environmentHosts, ...editableHosts]),
  112. editableHosts,
  113. environmentHosts,
  114. }
  115. } catch (cause) {
  116. return {
  117. source: 'file',
  118. hosts: [...environmentHosts],
  119. editableHosts: [],
  120. environmentHosts,
  121. configurationError: cause instanceof Error ? cause.message : '允许的访问地址配置文件无效',
  122. }
  123. }
  124. }
  125. function writeAtomic(path: string, value: string): void {
  126. const directory = dirname(path)
  127. mkdirSync(directory, { recursive: true, mode: 0o700 })
  128. chmodSync(directory, 0o700)
  129. const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
  130. let descriptor: number | undefined
  131. try {
  132. descriptor = openSync(temporary, 'wx', 0o600)
  133. writeFileSync(descriptor, value)
  134. fsyncSync(descriptor)
  135. closeSync(descriptor)
  136. descriptor = undefined
  137. renameSync(temporary, path)
  138. chmodSync(path, 0o600)
  139. } catch (cause) {
  140. if (descriptor !== undefined) closeSync(descriptor)
  141. try { unlinkSync(temporary) } catch { /* already absent */ }
  142. throw cause
  143. }
  144. }
  145. function cloneSnapshot(value: AllowedHostsConfigurationSnapshot): AllowedHostsConfigurationSnapshot {
  146. return {
  147. ...value,
  148. hosts: [...value.hosts],
  149. editableHosts: [...value.editableHosts],
  150. environmentHosts: [...value.environmentHosts],
  151. }
  152. }
  153. export class AllowedHostsConfigurationManager {
  154. readonly path: string
  155. readonly #runtimeHosts: Set<string>
  156. #snapshot: AllowedHostsConfigurationSnapshot
  157. constructor(home: string, initial: AllowedHostsConfigurationSnapshot, runtimeHosts: Set<string>) {
  158. this.path = allowedHostsConfigurationPath(home)
  159. this.#snapshot = cloneSnapshot(initial)
  160. this.#runtimeHosts = runtimeHosts
  161. this.#applyRuntimeHosts(initial.hosts)
  162. }
  163. snapshot(): AllowedHostsConfigurationSnapshot {
  164. return cloneSnapshot(this.#snapshot)
  165. }
  166. update(hosts: readonly string[]): AllowedHostsConfigurationSnapshot {
  167. const editableHosts = canonicalAllowedHosts(hosts)
  168. const effectiveHosts = canonicalAllowedHosts([
  169. ...this.#snapshot.environmentHosts,
  170. ...editableHosts,
  171. ])
  172. const stored: StoredAllowedHostsConfiguration = {
  173. schemaVersion: 1,
  174. hosts: editableHosts,
  175. updatedAt: new Date().toISOString(),
  176. }
  177. writeAtomic(this.path, `${JSON.stringify(stored, null, 2)}\n`)
  178. this.#snapshot = {
  179. source: 'file',
  180. hosts: effectiveHosts,
  181. editableHosts,
  182. environmentHosts: [...this.#snapshot.environmentHosts],
  183. }
  184. this.#applyRuntimeHosts(effectiveHosts)
  185. return this.snapshot()
  186. }
  187. #applyRuntimeHosts(hosts: readonly string[]): void {
  188. this.#runtimeHosts.clear()
  189. for (const host of hosts) this.#runtimeHosts.add(host)
  190. }
  191. }