config.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. import { isIP } from 'node:net'
  2. import { homedir } from 'node:os'
  3. import { basename, resolve } from 'node:path'
  4. export interface ServerConfig {
  5. host: string
  6. port: number
  7. upstream: URL
  8. allowedHosts: ReadonlySet<string>
  9. home: string
  10. mediaRoot: string
  11. attachmentsRoot: string
  12. imagesRoot: string
  13. mediaOwner: string
  14. tlsCert?: string
  15. tlsKey?: string
  16. allowInsecureLan: boolean
  17. insecureLan: boolean
  18. production: boolean
  19. superviseDashboard?: boolean
  20. yaoyaoPluginSource?: string
  21. }
  22. export const DEFAULT_YAOYAO_PLUGIN_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao'
  23. function flag(value: string | undefined): boolean {
  24. return value === '1' || value?.toLowerCase() === 'true'
  25. }
  26. function parsePort(value: string | undefined): number {
  27. const port = Number(value ?? '8800')
  28. if (!Number.isInteger(port) || port < 1 || port > 65_535) {
  29. throw new Error('HERMES_YAOYAO_PORT must be an integer between 1 and 65535')
  30. }
  31. return port
  32. }
  33. function parseUpstream(value: string | undefined): URL {
  34. const upstream = new URL(value ?? 'http://127.0.0.1:9119')
  35. if (!['http:', 'https:'].includes(upstream.protocol)) {
  36. throw new Error('HERMES_YAOYAO_UPSTREAM must use http or https')
  37. }
  38. if (upstream.username || upstream.password || upstream.search || upstream.hash) {
  39. throw new Error('HERMES_YAOYAO_UPSTREAM must not contain credentials, query, or fragment')
  40. }
  41. upstream.pathname = upstream.pathname.replace(/\/+$/, '') || '/'
  42. return upstream
  43. }
  44. function parsePluginSource(value: string | undefined): string {
  45. const source = value?.trim() || DEFAULT_YAOYAO_PLUGIN_SOURCE
  46. if (source.length > 2_048 || /[\u0000-\u001f\u007f]/.test(source)) {
  47. throw new Error('HERMES_YAOYAO_PLUGIN_SOURCE is invalid')
  48. }
  49. if (source.startsWith('https://') || source.startsWith('http://')) {
  50. const url = new URL(source)
  51. if (url.username || url.password) {
  52. throw new Error('HERMES_YAOYAO_PLUGIN_SOURCE must not contain credentials')
  53. }
  54. } else if (!source.startsWith('git@') && !source.startsWith('ssh://')) {
  55. throw new Error('HERMES_YAOYAO_PLUGIN_SOURCE must be an HTTPS or SSH Git source')
  56. }
  57. return source
  58. }
  59. function normalizeConfiguredHost(raw: string): string {
  60. const value = raw.trim().toLowerCase()
  61. if (!value) return ''
  62. if (value.startsWith('[') && value.endsWith(']')) return value.slice(1, -1)
  63. return value.replace(/\.$/, '')
  64. }
  65. export function isLoopbackHost(host: string): boolean {
  66. const normalized = normalizeConfiguredHost(host)
  67. if (normalized === 'localhost' || normalized === '::1') return true
  68. if (isIP(normalized) === 4) return normalized.split('.')[0] === '127'
  69. return false
  70. }
  71. export function isPrivateHost(host: string): boolean {
  72. const normalized = normalizeConfiguredHost(host)
  73. if (isLoopbackHost(normalized)) return true
  74. if (isIP(normalized) === 4) {
  75. const parts = normalized.split('.').map(Number)
  76. return parts[0] === 10
  77. || (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127)
  78. || (parts[0] === 169 && parts[1] === 254)
  79. || (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31)
  80. || (parts[0] === 192 && parts[1] === 168)
  81. }
  82. if (isIP(normalized) === 6) {
  83. return normalized.startsWith('fc')
  84. || normalized.startsWith('fd')
  85. || /^fe[89ab]/.test(normalized)
  86. }
  87. return false
  88. }
  89. export function isLoopbackUpstream(upstream: URL): boolean {
  90. return isLoopbackHost(upstream.hostname)
  91. }
  92. export function loadServerConfig(
  93. env: NodeJS.ProcessEnv = process.env,
  94. ): ServerConfig {
  95. const host = env.HERMES_YAOYAO_HOST?.trim() || '127.0.0.1'
  96. const port = parsePort(env.HERMES_YAOYAO_PORT)
  97. const upstream = parseUpstream(env.HERMES_YAOYAO_UPSTREAM)
  98. const home = resolve(env.HERMES_YAOYAO_HOME?.trim() || `${homedir()}/.hermes-yaoyao`)
  99. const mediaRoot = resolve(env.HERMES_YAOYAO_MEDIA_ROOT?.trim() || `${homedir()}/Agents`)
  100. const attachmentsRoot = resolve(`${homedir()}/.hermes/attachments`)
  101. const imagesRoot = resolve(`${homedir()}/.hermes/images`)
  102. const tlsCert = env.HERMES_YAOYAO_TLS_CERT?.trim() || undefined
  103. const tlsKey = env.HERMES_YAOYAO_TLS_KEY?.trim() || undefined
  104. if (Boolean(tlsCert) !== Boolean(tlsKey)) {
  105. throw new Error('HERMES_YAOYAO_TLS_CERT and HERMES_YAOYAO_TLS_KEY must be set together')
  106. }
  107. const allowInsecureLan = flag(env.HERMES_YAOYAO_ALLOW_INSECURE_LAN)
  108. const production = env.NODE_ENV === 'production'
  109. const superviseDashboard = flag(env.HERMES_YAOYAO_SUPERVISE_DASHBOARD)
  110. const yaoyaoPluginSource = parsePluginSource(env.HERMES_YAOYAO_PLUGIN_SOURCE)
  111. const insecureLan = !tlsCert && !isLoopbackHost(host)
  112. if (production && insecureLan && !allowInsecureLan) {
  113. throw new Error(
  114. 'Plain HTTP on a non-loopback host requires HERMES_YAOYAO_ALLOW_INSECURE_LAN=1',
  115. )
  116. }
  117. const allowedHosts = new Set(
  118. (env.HERMES_YAOYAO_ALLOWED_HOSTS ?? '')
  119. .split(',')
  120. .map(normalizeConfiguredHost)
  121. .filter(Boolean),
  122. )
  123. return {
  124. host,
  125. port,
  126. upstream,
  127. allowedHosts,
  128. home,
  129. mediaRoot,
  130. attachmentsRoot,
  131. imagesRoot,
  132. mediaOwner: basename(homedir()),
  133. tlsCert,
  134. tlsKey,
  135. allowInsecureLan,
  136. insecureLan,
  137. production,
  138. superviseDashboard,
  139. yaoyaoPluginSource,
  140. }
  141. }