apnsConfiguration.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import { createPrivateKey, randomUUID } from 'node:crypto'
  2. import {
  3. chmodSync,
  4. closeSync,
  5. constants as fsConstants,
  6. existsSync,
  7. fstatSync,
  8. fsyncSync,
  9. mkdirSync,
  10. openSync,
  11. readFileSync,
  12. realpathSync,
  13. renameSync,
  14. unlinkSync,
  15. writeFileSync,
  16. } from 'node:fs'
  17. import { dirname, isAbsolute, join, resolve } from 'node:path'
  18. import { APNsProvider, type APNsEnvironment, type APNsSendResult } from './apns.js'
  19. import type { APNsProviderConfig } from './config.js'
  20. import { HttpError } from './errors.js'
  21. export type APNsConfigurationSource = 'none' | 'file' | 'environment'
  22. export const DEFAULT_APNS_TOPIC = 'cn.samien.yaoyao.hermes'
  23. export interface APNsConfigurationInput {
  24. keyFile: string
  25. keyId: string
  26. teamId: string
  27. topic: string
  28. environments: APNsEnvironment[]
  29. }
  30. export interface APNsConfigurationWarning {
  31. code: 'apns_key_permissions'
  32. message: string
  33. actualMode: string
  34. recommendedMode: '0600'
  35. }
  36. export interface APNsConfigurationSnapshot {
  37. source: APNsConfigurationSource
  38. editable: boolean
  39. input?: APNsConfigurationInput
  40. config?: APNsProviderConfig
  41. warnings: APNsConfigurationWarning[]
  42. configurationError?: string
  43. }
  44. export interface APNsConfigurationManagerOptions {
  45. probe?: APNsConfigurationProbe
  46. }
  47. export type APNsConfigurationProbe = (
  48. config: APNsProviderConfig,
  49. environments: readonly APNsEnvironment[],
  50. ) => Promise<void>
  51. interface StoredAPNsConfiguration extends APNsConfigurationInput {
  52. schemaVersion: 1
  53. enabled: true
  54. updatedAt: string
  55. }
  56. const MAX_KEY_FILE_BYTES = 64 * 1_024
  57. const MAX_CONFIGURATION_FILE_BYTES = 64 * 1_024
  58. const DEFAULT_ENVIRONMENTS: APNsEnvironment[] = ['development', 'production']
  59. const ACCEPTED_PROBE_REASON = 'BadDeviceToken'
  60. function clean(value: unknown): string {
  61. return typeof value === 'string' ? value.trim() : ''
  62. }
  63. function canonicalEnvironments(value: unknown): APNsEnvironment[] {
  64. const requested = Array.isArray(value) ? value : DEFAULT_ENVIRONMENTS
  65. const environments: APNsEnvironment[] = []
  66. for (const candidate of requested) {
  67. if (candidate !== 'development' && candidate !== 'production') {
  68. throw new HttpError(400, 'APNs environment must be development or production', 'invalid_apns_configuration')
  69. }
  70. if (!environments.includes(candidate)) environments.push(candidate)
  71. }
  72. if (environments.length === 0) {
  73. throw new HttpError(400, 'At least one APNs environment is required', 'invalid_apns_configuration')
  74. }
  75. return environments
  76. }
  77. function canonicalInput(value: Partial<APNsConfigurationInput>): APNsConfigurationInput {
  78. const keyFile = clean(value.keyFile)
  79. const keyId = clean(value.keyId)
  80. const teamId = clean(value.teamId)
  81. const topic = clean(value.topic)
  82. if (!keyFile || !isAbsolute(keyFile) || keyFile.length > 4_096 || /[\u0000-\u001f\u007f]/.test(keyFile)) {
  83. throw new HttpError(400, 'APNs key file must be an absolute local path', 'invalid_apns_key_file')
  84. }
  85. if (!/^[A-Za-z0-9]{1,128}$/.test(keyId)) {
  86. throw new HttpError(400, 'APNs Key ID is invalid', 'invalid_apns_key_id')
  87. }
  88. if (!/^[A-Za-z0-9]{1,128}$/.test(teamId)) {
  89. throw new HttpError(400, 'APNs Team ID is invalid', 'invalid_apns_team_id')
  90. }
  91. if (!topic || topic.length > 255 || !/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(topic)) {
  92. throw new HttpError(400, 'APNs topic is invalid', 'invalid_apns_topic')
  93. }
  94. if (topic !== DEFAULT_APNS_TOPIC) {
  95. throw new HttpError(400, `APNs topic must be ${DEFAULT_APNS_TOPIC}`, 'invalid_apns_topic')
  96. }
  97. return {
  98. keyFile,
  99. keyId,
  100. teamId,
  101. topic,
  102. environments: canonicalEnvironments(value.environments),
  103. }
  104. }
  105. export function validateAPNsConfiguration(
  106. value: Partial<APNsConfigurationInput>,
  107. ): APNsConfigurationSnapshot {
  108. const requested = canonicalInput(value)
  109. let keyFile: string
  110. let encoded: Buffer
  111. let mode: number
  112. let descriptor: number | undefined
  113. try {
  114. keyFile = realpathSync(resolve(requested.keyFile))
  115. descriptor = openSync(keyFile, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK)
  116. const details = fstatSync(descriptor)
  117. if (!details.isFile() || details.size < 1 || details.size > MAX_KEY_FILE_BYTES) {
  118. throw new HttpError(400, 'APNs key path must reference a readable private key file', 'invalid_apns_key_file')
  119. }
  120. encoded = readFileSync(descriptor)
  121. mode = details.mode & 0o777
  122. closeSync(descriptor)
  123. descriptor = undefined
  124. } catch (cause) {
  125. if (descriptor !== undefined) closeSync(descriptor)
  126. if (cause instanceof HttpError) throw cause
  127. throw new HttpError(
  128. 400,
  129. '15300 cannot read the APNs key file; check the local path and service account permissions',
  130. 'invalid_apns_key_file',
  131. )
  132. }
  133. let key
  134. try { key = createPrivateKey(encoded) } catch {
  135. throw new HttpError(400, 'APNs key file must contain an ES256 private key', 'invalid_apns_private_key')
  136. }
  137. if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
  138. throw new HttpError(400, 'APNs key file must contain an ES256 private key', 'invalid_apns_private_key')
  139. }
  140. const actualMode = mode.toString(8).padStart(4, '0')
  141. const warnings: APNsConfigurationWarning[] = (mode & ~0o600) !== 0
  142. ? [{
  143. code: 'apns_key_permissions',
  144. message: `APNs 已启用,但密钥文件权限为 ${actualMode},建议调整为 0600;此提示不会影响当前推送。`,
  145. actualMode,
  146. recommendedMode: '0600',
  147. }]
  148. : []
  149. const input = { ...requested, keyFile }
  150. return {
  151. source: 'file',
  152. editable: true,
  153. input,
  154. config: { ...input },
  155. warnings,
  156. }
  157. }
  158. function environmentInput(env: NodeJS.ProcessEnv): { present: boolean; input: APNsConfigurationInput } {
  159. const raw = {
  160. keyFile: clean(env.HERMES_YAOYAO_APNS_KEY_FILE),
  161. keyId: clean(env.HERMES_YAOYAO_APNS_KEY_ID),
  162. teamId: clean(env.HERMES_YAOYAO_APNS_TEAM_ID),
  163. topic: clean(env.HERMES_YAOYAO_APNS_TOPIC) || DEFAULT_APNS_TOPIC,
  164. environments: DEFAULT_ENVIRONMENTS,
  165. }
  166. return {
  167. present: Boolean(
  168. env.HERMES_YAOYAO_APNS_KEY_FILE?.trim()
  169. || env.HERMES_YAOYAO_APNS_KEY_ID?.trim()
  170. || env.HERMES_YAOYAO_APNS_TEAM_ID?.trim()
  171. || env.HERMES_YAOYAO_APNS_TOPIC?.trim(),
  172. ),
  173. input: raw,
  174. }
  175. }
  176. function storedInput(value: unknown): APNsConfigurationInput {
  177. if (!value || typeof value !== 'object' || Array.isArray(value)) {
  178. throw new Error('APNs configuration file must contain an object')
  179. }
  180. const record = value as Record<string, unknown>
  181. if (record.schemaVersion !== 1 || record.enabled !== true) {
  182. throw new Error('Unsupported APNs configuration file')
  183. }
  184. return {
  185. keyFile: clean(record.keyFile),
  186. keyId: clean(record.keyId),
  187. teamId: clean(record.teamId),
  188. topic: clean(record.topic),
  189. environments: canonicalEnvironments(record.environments),
  190. }
  191. }
  192. export function apnsConfigurationPath(home: string): string {
  193. return join(home, 'push', 'apns-config.json')
  194. }
  195. function readConfigurationFile(path: string): Buffer {
  196. const descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK)
  197. try {
  198. const details = fstatSync(descriptor)
  199. if (!details.isFile() || details.size < 1 || details.size > MAX_CONFIGURATION_FILE_BYTES) {
  200. throw new Error('APNs configuration path is not a regular file')
  201. }
  202. return readFileSync(descriptor)
  203. } finally {
  204. closeSync(descriptor)
  205. }
  206. }
  207. export function loadAPNsConfiguration(
  208. home: string,
  209. env: NodeJS.ProcessEnv = process.env,
  210. ): APNsConfigurationSnapshot {
  211. const fromEnvironment = environmentInput(env)
  212. if (fromEnvironment.present) {
  213. try {
  214. const validated = validateAPNsConfiguration(fromEnvironment.input)
  215. return { ...validated, source: 'environment', editable: false }
  216. } catch (cause) {
  217. return {
  218. source: 'environment',
  219. editable: false,
  220. input: fromEnvironment.input,
  221. warnings: [],
  222. configurationError: cause instanceof Error ? cause.message : 'APNs environment configuration is invalid',
  223. }
  224. }
  225. }
  226. const path = apnsConfigurationPath(home)
  227. if (!existsSync(path)) return { source: 'none', editable: true, warnings: [] }
  228. try {
  229. const input = storedInput(JSON.parse(readConfigurationFile(path).toString('utf8')))
  230. const validated = validateAPNsConfiguration(input)
  231. chmodSync(path, 0o600)
  232. return { ...validated, source: 'file', editable: true }
  233. } catch (cause) {
  234. return {
  235. source: 'file',
  236. editable: true,
  237. warnings: [],
  238. configurationError: cause instanceof Error ? cause.message : 'APNs configuration file is invalid',
  239. }
  240. }
  241. }
  242. function writeAtomic(path: string, value: Buffer | string): void {
  243. const directory = dirname(path)
  244. mkdirSync(directory, { recursive: true, mode: 0o700 })
  245. chmodSync(directory, 0o700)
  246. const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
  247. let descriptor: number | undefined
  248. try {
  249. descriptor = openSync(temporary, 'wx', 0o600)
  250. writeFileSync(descriptor, value)
  251. fsyncSync(descriptor)
  252. closeSync(descriptor)
  253. descriptor = undefined
  254. renameSync(temporary, path)
  255. chmodSync(path, 0o600)
  256. } catch (cause) {
  257. if (descriptor !== undefined) closeSync(descriptor)
  258. try { unlinkSync(temporary) } catch { /* already absent */ }
  259. throw cause
  260. }
  261. }
  262. async function defaultProbe(
  263. config: APNsProviderConfig,
  264. environments: readonly APNsEnvironment[],
  265. ): Promise<void> {
  266. const provider = new APNsProvider(config)
  267. try {
  268. for (const environment of environments) {
  269. const result: APNsSendResult = await provider.send({
  270. deviceToken: '00'.repeat(32),
  271. environment,
  272. payload: { aps: { 'content-available': 1 } },
  273. priority: 5,
  274. pushType: 'background',
  275. })
  276. if (result.reason === ACCEPTED_PROBE_REASON) continue
  277. if (result.disposition === 'retry') {
  278. throw new HttpError(503, 'Unable to connect to APNs; check outbound network access and retry', 'apns_probe_unavailable')
  279. }
  280. throw new HttpError(422, 'APNs rejected the provider credentials', 'apns_credentials_rejected')
  281. }
  282. } finally {
  283. provider.close()
  284. }
  285. }
  286. function cloneSnapshot(value: APNsConfigurationSnapshot): APNsConfigurationSnapshot {
  287. return {
  288. ...value,
  289. ...(value.input ? { input: { ...value.input, environments: [...value.input.environments] } } : {}),
  290. ...(value.config ? { config: { ...value.config, environments: [...(value.config.environments ?? DEFAULT_ENVIRONMENTS)] } } : {}),
  291. warnings: value.warnings.map(warning => ({ ...warning })),
  292. }
  293. }
  294. export class APNsConfigurationManager {
  295. readonly path: string
  296. readonly #probe: APNsConfigurationProbe
  297. #snapshot: APNsConfigurationSnapshot
  298. #operation: Promise<void> = Promise.resolve()
  299. constructor(
  300. home: string,
  301. initial: APNsConfigurationSnapshot,
  302. options: APNsConfigurationManagerOptions = {},
  303. ) {
  304. this.path = apnsConfigurationPath(home)
  305. this.#snapshot = cloneSnapshot(initial)
  306. this.#probe = options.probe ?? defaultProbe
  307. }
  308. snapshot(): APNsConfigurationSnapshot {
  309. return cloneSnapshot(this.#snapshot)
  310. }
  311. update(
  312. input: Partial<APNsConfigurationInput>,
  313. apply: (config: APNsProviderConfig) => void | Promise<void>,
  314. ): Promise<APNsConfigurationSnapshot> {
  315. const operation = this.#operation.then(() => this.#update(input, apply))
  316. this.#operation = operation.then(() => undefined, () => undefined)
  317. return operation
  318. }
  319. async #update(
  320. input: Partial<APNsConfigurationInput>,
  321. apply: (config: APNsProviderConfig) => void | Promise<void>,
  322. ): Promise<APNsConfigurationSnapshot> {
  323. if (this.#snapshot.source === 'environment') {
  324. throw new HttpError(409, 'APNs configuration is managed by service environment variables', 'apns_environment_managed')
  325. }
  326. const validated = validateAPNsConfiguration(input)
  327. const config = validated.config!
  328. await this.#probe(config, validated.input!.environments)
  329. const previous = existsSync(this.path) ? readConfigurationFile(this.path) : undefined
  330. const stored: StoredAPNsConfiguration = {
  331. schemaVersion: 1,
  332. enabled: true,
  333. ...validated.input!,
  334. updatedAt: new Date().toISOString(),
  335. }
  336. try {
  337. writeAtomic(this.path, `${JSON.stringify(stored, null, 2)}\n`)
  338. await apply(config)
  339. } catch (cause) {
  340. try {
  341. if (previous) writeAtomic(this.path, previous)
  342. else if (existsSync(this.path)) unlinkSync(this.path)
  343. } catch { /* preserve the primary configuration error */ }
  344. throw cause
  345. }
  346. this.#snapshot = { ...validated, source: 'file', editable: true }
  347. return this.snapshot()
  348. }
  349. }