config.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import { isIP } from 'node:net'
  2. import { normalizeReleaseSource } from '../../bin/lib/release-source.mjs'
  3. import { resolveDataHome } from '../../bin/lib/data-home.mjs'
  4. import { homedir } from 'node:os'
  5. import { basename, resolve, isAbsolute } from 'node:path'
  6. import { readFileSync } from 'node:fs'
  7. import type { APNsEnvironment } from './apns.js'
  8. import { loadAPNsConfiguration, type APNsConfigurationSnapshot } from './apnsConfiguration.js'
  9. import { loadFCMConfiguration, type FCMConfigurationSnapshot } from './fcmConfiguration.js'
  10. import {
  11. loadAllowedHostsConfiguration,
  12. type AllowedHostsConfigurationSnapshot,
  13. } from './allowedHostsConfiguration.js'
  14. import type { ChatCacheMode } from './chatCache.js'
  15. import {parseComposeDesktops} from './composeDesktops.js'
  16. import type {ComposeDesktop} from '../shared/composeDesktops.js'
  17. export { DEFAULT_APNS_TOPIC } from './apnsConfiguration.js'
  18. export interface APNsProviderConfig {
  19. keyFile: string
  20. keyId: string
  21. teamId: string
  22. topic: string
  23. environments?: APNsEnvironment[]
  24. }
  25. export interface FCMProviderConfig {
  26. serviceAccountFile: string
  27. projectId: string
  28. packageName: string
  29. }
  30. export interface ServerConfig {
  31. credentialVault?: { socket: string; tokenFile: string; hermesUid: number }
  32. host: string
  33. port: number
  34. upstream: URL
  35. allowedHosts: ReadonlySet<string>
  36. home: string
  37. mediaRoot: string
  38. attachmentsRoot: string
  39. imagesRoot: string
  40. mediaOwner: string
  41. tlsCert?: string
  42. tlsKey?: string
  43. allowInsecureLan: boolean
  44. insecureLan: boolean
  45. production: boolean
  46. superviseDashboard?: boolean
  47. localVmHost?: 'server' | 'runner'
  48. hermesBridgeMount?: { home: string; python: string }
  49. composeDesktops?: ComposeDesktop[]
  50. releaseSource?: string
  51. releaseRoot?: string
  52. allowRemoteUpdate?: boolean
  53. upstreamUsername?: string
  54. upstreamPassword?: string
  55. apns?: APNsProviderConfig
  56. apnsConfigurationError?: string
  57. apnsSettings?: APNsConfigurationSnapshot
  58. fcm?: FCMProviderConfig
  59. fcmConfigurationError?: string
  60. fcmSettings?: FCMConfigurationSnapshot
  61. allowedHostsSettings?: AllowedHostsConfigurationSnapshot
  62. chatCacheMode?: ChatCacheMode
  63. }
  64. export { DEFAULT_RELEASE_SOURCE as DEFAULT_YAOYAO_RELEASE_SOURCE } from '../../bin/lib/release-source.mjs'
  65. function flag(value: string | undefined): boolean {
  66. return value === '1' || value?.toLowerCase() === 'true'
  67. }
  68. function parsePort(value: string | undefined): number {
  69. const port = Number(value ?? '15300')
  70. if (!Number.isInteger(port) || port < 1 || port > 65_535) {
  71. throw new Error('HERMES_YAOYAO_PORT must be an integer between 1 and 65535')
  72. }
  73. return port
  74. }
  75. function parseChatCacheMode(value: string | undefined): ChatCacheMode {
  76. const mode = value?.trim() || 'prefer-local'
  77. if (!['upstream-only', 'shadow', 'prefer-local'].includes(mode)) {
  78. throw new Error('HERMES_YAOYAO_CHAT_CACHE_MODE must be upstream-only, shadow, or prefer-local')
  79. }
  80. return mode as ChatCacheMode
  81. }
  82. function parseUpstream(value: string | undefined): URL {
  83. const upstream = new URL(value ?? 'http://127.0.0.1:9119')
  84. if (!['http:', 'https:'].includes(upstream.protocol)) {
  85. throw new Error('HERMES_YAOYAO_UPSTREAM must use http or https')
  86. }
  87. if (upstream.username || upstream.password || upstream.search || upstream.hash) {
  88. throw new Error('HERMES_YAOYAO_UPSTREAM must not contain credentials, query, or fragment')
  89. }
  90. upstream.pathname = upstream.pathname.replace(/\/+$/, '') || '/'
  91. return upstream
  92. }
  93. function parseReleaseSource(value: string | undefined): string {
  94. const source = normalizeReleaseSource(value)
  95. if (source.length > 2_048 || /[\u0000-\u001f\u007f]/.test(source)) {
  96. throw new Error('HERMES_YAOYAO_RELEASE_SOURCE is invalid')
  97. }
  98. if (source.startsWith('https://') || source.startsWith('ssh://')) {
  99. const url = new URL(source)
  100. if ((url.protocol === 'https:' && url.username) || url.password || url.search || url.hash) {
  101. throw new Error('HERMES_YAOYAO_RELEASE_SOURCE must not contain credentials, query, or fragment')
  102. }
  103. } else if (!source.startsWith('git@') && !source.startsWith('ssh://')) {
  104. throw new Error('HERMES_YAOYAO_RELEASE_SOURCE must be an HTTPS or SSH Git source')
  105. }
  106. return source
  107. }
  108. function normalizeConfiguredHost(raw: string): string {
  109. const value = raw.trim().toLowerCase()
  110. if (!value) return ''
  111. if (value.startsWith('[') && value.endsWith(']')) return value.slice(1, -1)
  112. return value.replace(/\.$/, '')
  113. }
  114. export function isLoopbackHost(host: string): boolean {
  115. const normalized = normalizeConfiguredHost(host)
  116. if (normalized === 'localhost' || normalized === '::1') return true
  117. if (isIP(normalized) === 4) return normalized.split('.')[0] === '127'
  118. return false
  119. }
  120. export function isPrivateHost(host: string): boolean {
  121. const normalized = normalizeConfiguredHost(host)
  122. if (isLoopbackHost(normalized)) return true
  123. if (isIP(normalized) === 4) {
  124. const parts = normalized.split('.').map(Number)
  125. return parts[0] === 10
  126. || (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127)
  127. || (parts[0] === 169 && parts[1] === 254)
  128. || (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31)
  129. || (parts[0] === 192 && parts[1] === 168)
  130. }
  131. if (isIP(normalized) === 6) {
  132. return normalized.startsWith('fc')
  133. || normalized.startsWith('fd')
  134. || /^fe[89ab]/.test(normalized)
  135. }
  136. return false
  137. }
  138. export function isLoopbackUpstream(upstream: URL): boolean {
  139. return isLoopbackHost(upstream.hostname)
  140. }
  141. export function loadServerConfig(
  142. env: NodeJS.ProcessEnv = process.env,
  143. ): ServerConfig {
  144. const host = env.HERMES_YAOYAO_HOST?.trim() || '127.0.0.1'
  145. const port = parsePort(env.HERMES_YAOYAO_PORT)
  146. const upstream = parseUpstream(env.HERMES_YAOYAO_UPSTREAM)
  147. const home = resolveDataHome(env.YAOYAO_HOME || env.HERMES_YAOYAO_HOME, { preserveActiveUpdate: true })
  148. const mediaRoot = resolve(env.HERMES_YAOYAO_MEDIA_ROOT?.trim() || `${homedir()}/Agents`)
  149. const attachmentsRoot = resolve(`${homedir()}/.hermes/attachments`)
  150. const imagesRoot = resolve(`${homedir()}/.hermes/images`)
  151. const tlsCert = env.HERMES_YAOYAO_TLS_CERT?.trim() || undefined
  152. const tlsKey = env.HERMES_YAOYAO_TLS_KEY?.trim() || undefined
  153. if (Boolean(tlsCert) !== Boolean(tlsKey)) {
  154. throw new Error('HERMES_YAOYAO_TLS_CERT and HERMES_YAOYAO_TLS_KEY must be set together')
  155. }
  156. const allowInsecureLan = flag(env.HERMES_YAOYAO_ALLOW_INSECURE_LAN)
  157. const production = env.NODE_ENV === 'production'
  158. const superviseDashboard = flag(env.HERMES_YAOYAO_SUPERVISE_DASHBOARD)
  159. const localVmHost = env.HERMES_YAOYAO_LOCAL_VM_HOST?.trim() || 'server'
  160. if(localVmHost!=='server'&&localVmHost!=='runner')throw new Error('HERMES_YAOYAO_LOCAL_VM_HOST must be server or runner')
  161. let hermesBridgeMount: ServerConfig['hermesBridgeMount']
  162. if(flag(env.HERMES_YAOYAO_BRIDGE_MOUNTED)){
  163. const home=env.HERMES_YAOYAO_BRIDGE_HOME?.trim()??''
  164. const python=env.HERMES_YAOYAO_BRIDGE_PYTHON?.trim()||'/usr/bin/python3'
  165. if(!home||!isAbsolute(home)||/[\0\r\n]/.test(home))throw new Error('HERMES_YAOYAO_BRIDGE_HOME must be an absolute mounted Hermes directory')
  166. if(!isAbsolute(python)||/[\0\r\n]/.test(python))throw new Error('HERMES_YAOYAO_BRIDGE_PYTHON must be an absolute container Python path')
  167. hermesBridgeMount={home:resolve(home),python}
  168. }
  169. const chatCacheMode = parseChatCacheMode(env.HERMES_YAOYAO_CHAT_CACHE_MODE)
  170. const releaseSource = parseReleaseSource(env.HERMES_YAOYAO_RELEASE_SOURCE)
  171. const releaseRoot = resolve(env.HERMES_YAOYAO_RELEASE_ROOT?.trim() || `${homedir()}/.local/share/hermes-yaoyao`)
  172. const allowRemoteUpdate = flag(env.HERMES_YAOYAO_ALLOW_REMOTE_UPDATE)
  173. const upstreamUsername = env.HERMES_YAOYAO_UPSTREAM_USERNAME?.trim() || undefined
  174. const upstreamPasswordFile = env.HERMES_YAOYAO_UPSTREAM_PASSWORD_FILE?.trim()
  175. if (env.HERMES_YAOYAO_UPSTREAM_PASSWORD && upstreamPasswordFile) {
  176. throw new Error('Set only one of HERMES_YAOYAO_UPSTREAM_PASSWORD or HERMES_YAOYAO_UPSTREAM_PASSWORD_FILE')
  177. }
  178. const upstreamPassword = env.HERMES_YAOYAO_UPSTREAM_PASSWORD
  179. || (upstreamPasswordFile ? readFileSync(resolve(upstreamPasswordFile), 'utf8').replace(/[\r\n]+$/, '') : undefined)
  180. const apnsSettings = loadAPNsConfiguration(home, env)
  181. const fcmSettings = loadFCMConfiguration(home, env)
  182. const insecureLan = !tlsCert && !isLoopbackHost(host)
  183. if (production && insecureLan && !allowInsecureLan) {
  184. throw new Error(
  185. 'Plain HTTP on a non-loopback host requires HERMES_YAOYAO_ALLOW_INSECURE_LAN=1',
  186. )
  187. }
  188. const allowedHostsSettings = loadAllowedHostsConfiguration(home, env)
  189. const allowedHosts = new Set(allowedHostsSettings.hosts)
  190. return {
  191. host,
  192. port,
  193. upstream,
  194. allowedHosts,
  195. allowedHostsSettings,
  196. home,
  197. mediaRoot,
  198. attachmentsRoot,
  199. imagesRoot,
  200. mediaOwner: basename(homedir()),
  201. ...(env.HERMES_YAOYAO_VAULT_SOCKET || env.HERMES_YAOYAO_VAULT_TOKEN_FILE ? { credentialVault: {
  202. socket: env.HERMES_YAOYAO_VAULT_SOCKET || '', tokenFile: env.HERMES_YAOYAO_VAULT_TOKEN_FILE || '', hermesUid: Number(env.HERMES_YAOYAO_VAULT_HERMES_UID),
  203. } } : {}),
  204. tlsCert,
  205. tlsKey,
  206. allowInsecureLan,
  207. insecureLan,
  208. production,
  209. superviseDashboard,
  210. localVmHost,
  211. ...(hermesBridgeMount?{hermesBridgeMount}:{}),
  212. composeDesktops:parseComposeDesktops(env.HERMES_YAOYAO_COMPOSE_DESKTOPS),
  213. chatCacheMode,
  214. releaseSource,
  215. releaseRoot,
  216. allowRemoteUpdate,
  217. upstreamUsername,
  218. upstreamPassword,
  219. ...(apnsSettings.config ? { apns: apnsSettings.config } : {}),
  220. ...(apnsSettings.configurationError ? { apnsConfigurationError: apnsSettings.configurationError } : {}),
  221. apnsSettings,
  222. ...(fcmSettings.config ? { fcm: fcmSettings.config } : {}),
  223. ...(fcmSettings.configurationError ? { fcmConfigurationError: fcmSettings.configurationError } : {}),
  224. fcmSettings,
  225. }
  226. }