serverless-ircd 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -27,6 +27,20 @@ export interface ContainerConfigEnv {
27
27
  CHANNEL_PREFIXES?: string;
28
28
  OPER_USER?: string;
29
29
  OPER_PASSWORD?: string;
30
+ /**
31
+ * Scrypt salt (base64) for the hashed oper credential form. When set
32
+ * alongside `OPER_USER` + `OPER_HASH`, the loader builds a hashed
33
+ * `{user, salt, hash}` cred instead of the legacy plaintext
34
+ * `{user, password}` shape. The hashed form is preferred when both
35
+ * are present so deployments can rotate without removing the legacy
36
+ * vars in the same step. Generate via `tools/hash-oper-cred.ts`.
37
+ */
38
+ OPER_SALT?: string;
39
+ /**
40
+ * Scrypt hash (base64) for the hashed oper credential form. See
41
+ * `OPER_SALT` for the full set of vars.
42
+ */
43
+ OPER_HASH?: string;
30
44
  MAX_CHANNELS_PER_USER?: string;
31
45
  MAX_TARGETS_PER_COMMAND?: string;
32
46
  NICK_LEN?: string;
@@ -39,6 +53,26 @@ export interface ContainerConfigEnv {
39
53
  TCP_PORT?: string;
40
54
  /** Container-specific: bind address (default 0.0.0.0 for Spectrum origin). */
41
55
  TCP_HOST?: string;
56
+ /**
57
+ * Container-specific: path to the PEM certificate the origin TLS
58
+ * listener (default port 6697) presents. Must be set together with
59
+ * `TLS_KEY_PATH`. Prod-classified boots (`NODE_ENV=production`) refuse
60
+ * to start without it — the origin terminates TLS end-to-end.
61
+ */
62
+ TLS_CERT_PATH?: string;
63
+ /** Container-specific: path to the PEM private key (see `TLS_CERT_PATH`). */
64
+ TLS_KEY_PATH?: string;
65
+ /** Container-specific: TLS listener port (default 6697). */
66
+ TLS_PORT?: string;
67
+ /**
68
+ * Container-specific: exact value `localhost` opts into the plaintext
69
+ * debug listener (bound to 127.0.0.1 only, for local development).
70
+ * Any other value is rejected; forbidden entirely in prod-classified
71
+ * mode.
72
+ */
73
+ DEBUG_PLAINTEXT?: string;
74
+ /** Platform convention: `production` classifies the boot as prod. */
75
+ NODE_ENV?: string;
42
76
  /** Container-specific: path to the persistence snapshot file. */
43
77
  PERSISTENCE_PATH?: string;
44
78
  /** Container-specific: auto-snapshot interval in milliseconds. */
@@ -50,13 +84,26 @@ export interface ContainerConfig {
50
84
  server: ParsedServerConfig;
51
85
  tcpPort: number;
52
86
  tcpHost: string;
87
+ /**
88
+ * TLS listener config. Present when `TLS_CERT_PATH` + `TLS_KEY_PATH`
89
+ * are set — the origin then terminates TLS itself (Spectrum
90
+ * `tls_mode = "full"` topology).
91
+ */
92
+ tls: { certPath: string; keyPath: string; port: number } | undefined;
93
+ /**
94
+ * True only when `DEBUG_PLAINTEXT=localhost`: a plaintext listener may
95
+ * bind — forced to 127.0.0.1 — strictly for local debugging.
96
+ */
97
+ plaintextDebug: boolean;
53
98
  persistencePath: string | undefined;
54
99
  snapshotIntervalMs: number;
55
100
  }
56
101
 
57
102
  const DEFAULT_TCP_PORT = 6667;
58
103
  const DEFAULT_TCP_HOST = '0.0.0.0';
104
+ const DEFAULT_TLS_PORT = 6697;
59
105
  const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
106
+ const PLAINTEXT_DEBUG_FLAG = 'localhost';
60
107
 
61
108
  /**
62
109
  * Loads and validates the IRC server config plus container-specific knobs
@@ -68,10 +115,33 @@ const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
68
115
  */
69
116
  export function loadContainerConfig(env: ContainerConfigEnv): ContainerConfig {
70
117
  const server = parseServerConfig(buildServerInput(env));
118
+ const tls = resolveTls(env);
119
+ const prod = env.NODE_ENV === 'production';
120
+ if (prod && tls === undefined) {
121
+ throw new Error(
122
+ 'refusing to start a prod-classified origin without TLS: set TLS_CERT_PATH and TLS_KEY_PATH ' +
123
+ '(Spectrum tls_mode=full requires the origin to terminate TLS itself)',
124
+ );
125
+ }
126
+ const plaintextDebug = env.DEBUG_PLAINTEXT !== undefined;
127
+ if (plaintextDebug && prod) {
128
+ throw new Error(
129
+ 'refusing to enable DEBUG_PLAINTEXT in a prod-classified origin (plaintext IRC is debug-only)',
130
+ );
131
+ }
132
+ if (plaintextDebug && env.DEBUG_PLAINTEXT !== PLAINTEXT_DEBUG_FLAG) {
133
+ throw new Error(
134
+ `invalid DEBUG_PLAINTEXT value ${JSON.stringify(env.DEBUG_PLAINTEXT)}: the only allowed value is 'localhost'`,
135
+ );
136
+ }
71
137
  return {
72
138
  server,
73
139
  tcpPort: parseIntOrDefault(env.TCP_PORT, DEFAULT_TCP_PORT),
74
- tcpHost: env.TCP_HOST?.trim() || DEFAULT_TCP_HOST,
140
+ // Debug plaintext is localhost-only by construction; the operator
141
+ // cannot widen the bind even by setting TCP_HOST alongside the flag.
142
+ tcpHost: plaintextDebug ? '127.0.0.1' : env.TCP_HOST?.trim() || DEFAULT_TCP_HOST,
143
+ tls,
144
+ plaintextDebug,
75
145
  persistencePath:
76
146
  env.PERSISTENCE_PATH !== undefined && env.PERSISTENCE_PATH.length > 0
77
147
  ? env.PERSISTENCE_PATH
@@ -80,6 +150,23 @@ export function loadContainerConfig(env: ContainerConfigEnv): ContainerConfig {
80
150
  };
81
151
  }
82
152
 
153
+ function resolveTls(env: ContainerConfigEnv): ContainerConfig['tls'] {
154
+ const hasCert = env.TLS_CERT_PATH !== undefined && env.TLS_CERT_PATH.length > 0;
155
+ const hasKey = env.TLS_KEY_PATH !== undefined && env.TLS_KEY_PATH.length > 0;
156
+ if (!hasCert && !hasKey) return undefined;
157
+ if (!hasCert) {
158
+ throw new Error('TLS_KEY_PATH is set but TLS_CERT_PATH is not — provide both or neither');
159
+ }
160
+ if (!hasKey) {
161
+ throw new Error('TLS_CERT_PATH is set but TLS_KEY_PATH is not — provide both or neither');
162
+ }
163
+ return {
164
+ certPath: env.TLS_CERT_PATH as string,
165
+ keyPath: env.TLS_KEY_PATH as string,
166
+ port: parseIntOrDefault(env.TLS_PORT, DEFAULT_TLS_PORT),
167
+ };
168
+ }
169
+
83
170
  function parseIntOrDefault(raw: string | undefined, def: number): number {
84
171
  if (raw === undefined || raw.trim().length === 0) return def;
85
172
  const n = Number.parseInt(raw, 10);
@@ -99,7 +186,31 @@ function buildServerInput(env: ContainerConfigEnv): Record<string, unknown> {
99
186
  if (env.MAX_CLIENTS !== undefined) input.maxClients = Number.parseInt(env.MAX_CLIENTS, 10);
100
187
  if (env.CHANNEL_PREFIXES !== undefined) input.channelPrefixes = env.CHANNEL_PREFIXES;
101
188
  if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
102
- input.operCreds = [{ user: env.OPER_USER ?? '', password: env.OPER_PASSWORD ?? '' }];
189
+ // Hashed form (OPER_USER + OPER_SALT + OPER_HASH) is preferred when
190
+ // both salt + hash are supplied — even if OPER_PASSWORD is also set,
191
+ // so a deployment can rotate to the hashed form without dropping the
192
+ // legacy var in the same release. The legacy plaintext
193
+ // `{user, password}` shape is retained for a single deprecation-
194
+ // cycle window.
195
+ const hasHashed = env.OPER_SALT !== undefined && env.OPER_HASH !== undefined;
196
+ if (hasHashed) {
197
+ input.operCreds = [
198
+ {
199
+ user: env.OPER_USER ?? '',
200
+ salt: env.OPER_SALT ?? '',
201
+ hash: env.OPER_HASH ?? '',
202
+ },
203
+ ];
204
+ } else {
205
+ input.operCreds = [
206
+ {
207
+ user: env.OPER_USER ?? '',
208
+ password: env.OPER_PASSWORD ?? '',
209
+ ...(env.OPER_SALT !== undefined ? { salt: env.OPER_SALT } : {}),
210
+ ...(env.OPER_HASH !== undefined ? { hash: env.OPER_HASH } : {}),
211
+ },
212
+ ];
213
+ }
103
214
  }
104
215
  if (env.MAX_CHANNELS_PER_USER !== undefined) {
105
216
  input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
@@ -2,12 +2,36 @@
2
2
  * CF TCP container origin — the stateful IRC server behind Cloudflare
3
3
  * Spectrum.
4
4
  *
5
- * Spectrum terminates TLS at the edge and forwards plaintext TCP to this
6
- * origin. The container runs the shared IRC core (`ConnectionActor` +
7
- * `InMemoryRuntime`) over the `TcpByteStreamTransport` seam,
8
- * which reassembles `\r\n`-terminated IRC lines across arbitrary TCP
9
- * chunks. The actor/reducer/dispatch pipeline is identical to the wss
10
- * path only the line-framing transport differs.
5
+ * Spectrum forwards TLS end-to-end (`tls_mode = "full"`): this origin
6
+ * terminates TLS itself on :6697 with the certificate configured via
7
+ * `TLS_CERT_PATH` / `TLS_KEY_PATH` (see `config-loader.ts`). The
8
+ * container runs the shared IRC core (`ConnectionActor` +
9
+ * `InMemoryRuntime`) over the `TcpByteStreamTransport` seam, which
10
+ * reassembles `\r\n`-terminated IRC lines across arbitrary TCP chunks.
11
+ * The actor/reducer/dispatch pipeline is identical to the wss path —
12
+ * only the line-framing transport differs.
13
+ *
14
+ * Spectrum is configured with `proxy_protocol = "v1"`, so every flow
15
+ * (TLS and plaintext alike) must begin with a PROXY v1 header
16
+ * (`PROXY TCP4 <src_ip> ... \r\n`, see `proxy-protocol.ts`). The header
17
+ * is peeled off before any byte reaches the actor; the recovered source
18
+ * IP — the real client address, not the Spectrum edge IP — feeds
19
+ * `state.host` and the per-IP admission gate. Connections whose first
20
+ * bytes do not yield a valid header are closed with
21
+ * `ERROR :Closing link` (direct-connection bypass defense); the
22
+ * localhost debug listener requires the header too, e.g.:
23
+ * printf 'PROXY TCP4 127.0.0.1 127.0.0.1 40000 6667\r\n...' | nc …
24
+ *
25
+ * A plaintext listener is available strictly for localhost-only local
26
+ * debugging; in a prod-classified boot the config loader refuses to
27
+ * start without TLS material.
28
+ *
29
+ * SASL EXTERNAL is explicitly unsupported on this transport: Spectrum's
30
+ * PROXY v1 forwarding carries no client-cert subject, so no mTLS
31
+ * identity source exists here and `AUTHENTICATE EXTERNAL` is rejected
32
+ * up front with `904 ERR_SASLFAIL` ("SASL EXTERNAL not supported on
33
+ * this transport"). Cert-backed SASL lives on the wss and NLB+TLS
34
+ * paths; see docs/SASL-EXTERNAL.md.
11
35
  *
12
36
  * When `persistencePath` is supplied, channel state (topics, modes, bans)
13
37
  * is restored from a snapshot file on boot and auto-saved on an interval
@@ -15,34 +39,54 @@
15
39
  */
16
40
 
17
41
  import { randomUUID } from 'node:crypto';
42
+ import { readFile } from 'node:fs/promises';
18
43
  import { type Server, type Socket, createServer } from 'node:net';
44
+ import { createServer as tlsCreateServer } from 'node:tls';
19
45
  import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
20
46
  import {
21
- type AccountStore,
22
47
  type AdmissionConfig,
23
48
  type ChanName,
24
49
  type Clock,
25
50
  type ConnectionState,
26
51
  ConsoleLogger,
27
- InMemoryAccountStore,
28
52
  InMemoryMessageStore,
29
53
  InMemoryNickHistoryStore,
54
+ InMemoryServicesStore,
30
55
  LogLevel,
31
56
  type Logger,
32
57
  type MessageStore,
33
58
  type NickHistoryStore,
34
59
  type RawLine,
60
+ type ServicesStore,
61
+ SystemClock,
35
62
  createConnection,
36
63
  } from '@serverless-ircd/irc-core';
37
64
  import { ConnectionActor, TcpByteStreamTransport } from '@serverless-ircd/irc-server';
38
65
  import { loadSnapshot, restoreRuntime, saveSnapshot, snapshotRuntime } from './persistence.js';
66
+ import { consumeProxyV1Header } from './proxy-protocol.js';
67
+
68
+ /**
69
+ * TLS listener configuration: the origin presents `certPath` / `keyPath`
70
+ * (PEM) on `port` (default 6697).
71
+ */
72
+ export interface TlsListenerOptions {
73
+ readonly certPath: string;
74
+ readonly keyPath: string;
75
+ readonly port?: number | undefined;
76
+ }
39
77
 
40
78
  /** Options accepted by {@link startContainerServer}. */
41
79
  export interface StartContainerOptions {
42
- /** Plaintext TCP port. Use 0 for an ephemeral port. */
43
- readonly port: number;
80
+ /**
81
+ * Plaintext TCP port — localhost debug only; a prod boot must serve
82
+ * TLS instead. Use 0 for an ephemeral port; omit to disable the
83
+ * plaintext listener entirely.
84
+ */
85
+ readonly port?: number | undefined;
44
86
  /** Bind address. Defaults to `0.0.0.0` (Spectrum forwards from the edge). */
45
87
  readonly host?: string;
88
+ /** TLS listener: terminates TLS at the origin (Spectrum `full` mode). */
89
+ readonly tls?: TlsListenerOptions;
46
90
  readonly serverName: string;
47
91
  readonly networkName: string;
48
92
  readonly motdLines?: string[];
@@ -74,7 +118,10 @@ export interface StartContainerOptions {
74
118
 
75
119
  /** A running container origin. Call {@link close} to shut down. */
76
120
  export interface ContainerServer {
77
- readonly port: number;
121
+ /** Bound plaintext (debug) port; undefined when TLS-only. */
122
+ readonly port: number | undefined;
123
+ /** Bound TLS port; undefined when no TLS listener is configured. */
124
+ readonly tlsPort: number | undefined;
78
125
  readonly host: string;
79
126
  readonly runtime: InMemoryRuntime;
80
127
  readonly messages: MessageStore;
@@ -106,15 +153,34 @@ const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
106
153
  const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
107
154
  const DEFAULT_PER_IP_CONNECTION_RATE = { max: 5, windowMs: 60_000 };
108
155
  const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
156
+ const DEFAULT_TLS_PORT = 6697;
109
157
 
110
158
  /**
111
- * Starts the TCP container origin. Resolves once the TCP listener is
112
- * ready. When `persistencePath` is set and a snapshot file exists, channel
113
- * state is restored before any connection is accepted.
159
+ * Stable refusal for `AUTHENTICATE EXTERNAL` on this transport. Spectrum
160
+ * forwards with PROXY protocol v1, which carries no client-cert subject,
161
+ * so this origin has no mTLS identity source and SASL EXTERNAL is
162
+ * explicitly unsupported: the mechanism is pinned off via
163
+ * `sasl.externalUnsupportedMessage` and rejected up front with a
164
+ * transport-specific `904 ERR_SASLFAIL` carrying this exact text
165
+ * (asserted verbatim by the container tests — change with care).
166
+ */
167
+ const SASL_EXTERNAL_UNSUPPORTED_MESSAGE = 'SASL EXTERNAL not supported on this transport';
168
+
169
+ /**
170
+ * Starts the TCP container origin. Resolves once every configured
171
+ * listener (TLS on :6697 by default; optional plaintext debug listener)
172
+ * is ready. When `persistencePath` is set and a snapshot file exists,
173
+ * channel state is restored before any connection is accepted.
114
174
  */
115
175
  export async function startContainerServer(opts: StartContainerOptions): Promise<ContainerServer> {
116
176
  const host = opts.host ?? '0.0.0.0';
117
177
  const clock: Clock = opts.clock ?? { now: () => Date.now() };
178
+ if (opts.port === undefined && opts.tls === undefined) {
179
+ throw new Error(
180
+ 'no listener configured: pass tls (cert/key paths) for the TLS listener, ' +
181
+ 'or port for the localhost-only plaintext debug listener',
182
+ );
183
+ }
118
184
 
119
185
  const admission: AdmissionConfig | undefined =
120
186
  opts.maxConnectionsPerIp !== undefined ||
@@ -134,10 +200,12 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
134
200
 
135
201
  const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
136
202
  const history = new InMemoryNickHistoryStore(clock);
137
- const accounts: AccountStore | undefined =
138
- opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
139
- ? new InMemoryAccountStore(opts.saslAccounts)
140
- : undefined;
203
+ // Services store: the single credential home. Seeded from `saslAccounts`
204
+ // so SASL PLAIN / PASS-auth / NickServ IDENTIFY all verify.
205
+ const services = new InMemoryServicesStore({ clock: SystemClock });
206
+ for (const acct of opts.saslAccounts ?? []) {
207
+ services.registerNick(acct.username, acct.password, '');
208
+ }
141
209
 
142
210
  // Restore persisted channel state before accepting connections.
143
211
  if (opts.persistencePath !== undefined) {
@@ -154,43 +222,71 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
154
222
  const connections = new Map<Socket, ConnectionBindings>();
155
223
  const sockets = new Set<Socket>();
156
224
 
157
- const tcpServer = createServer((socket) => {
225
+ const onConnection = (socket: Socket): void => {
158
226
  sockets.add(socket);
159
227
 
160
- // `remoteAddress` is always set on a TCP server's connection socket;
161
- // the 'unknown' fallback is defensive and unreachable in practice.
162
- /* c8 ignore start */
163
- const ip = socket.remoteAddress ?? 'unknown';
164
- /* c8 ignore stop */
165
- const decision = runtime.admitConnection(ip, undefined);
166
- if (!decision.ok) {
167
- // A freshly-accepted TCP socket is always writable; the else branch
168
- // (skip the ERROR write) is a defensive guard against an impossible
169
- // race and cannot be exercised over a real socket.
228
+ // Set once the PROXY v1 header has been parsed and the connection
229
+ // admitted; until then inbound bytes are buffered as header bytes.
230
+ let bindings: ConnectionBindings | undefined;
231
+ let headerBuffer: Buffer = Buffer.alloc(0);
232
+
233
+ const reject = (reason: string): void => {
234
+ // Data-triggered rejects fire on a live, just-read-from socket;
235
+ // the else branch (skip the ERROR write) is a defensive guard
236
+ // against an impossible race and cannot be exercised over a real
237
+ // socket.
170
238
  /* c8 ignore start */
171
239
  if (socket.writable) {
172
- socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
240
+ socket.write(`ERROR :Closing link: (${reason})\r\n`);
173
241
  }
174
242
  /* c8 ignore stop */
175
243
  socket.destroy();
176
244
  sockets.delete(socket);
177
- return;
178
- }
179
- const admissionRecordId = decision.recordId;
180
- runtime.commitAdmission(ip, undefined, admissionRecordId);
181
-
182
- const { state, actor } = attachConnection(runtime, opts, messages, accounts, history, clock, {
183
- sendText: (text) => {
184
- if (socket.writable) socket.write(text);
185
- },
186
- closeTransport: () => socket.destroy(),
187
- admissionRecordId,
188
- sourceHost: ip,
189
- });
190
- connections.set(socket, { state, actor });
245
+ };
191
246
 
192
247
  socket.on('data', (chunk: Buffer) => {
193
- actor.receive(chunk.toString('utf8')).catch((err) => {
248
+ if (bindings === undefined) {
249
+ const buffered = headerBuffer.length === 0 ? chunk : Buffer.concat([headerBuffer, chunk]);
250
+ const header = consumeProxyV1Header(buffered);
251
+ if (header.status === 'invalid') {
252
+ // The peer did not open with a valid PROXY v1 header: either a
253
+ // direct connection bypassing Spectrum, or a malformed/spoofed
254
+ // header. Either way the flow is closed before any byte
255
+ // reaches the IRC actor.
256
+ reject('invalid PROXY protocol v1 header');
257
+ return;
258
+ }
259
+ if (header.status === 'incomplete') {
260
+ // Header split across reads: wait for the trailing CRLF.
261
+ headerBuffer = buffered;
262
+ return;
263
+ }
264
+ const ip = header.sourceIp;
265
+ const decision = runtime.admitConnection(ip, undefined);
266
+ if (!decision.ok) {
267
+ reject(`connection limit reached: ${decision.reason}`);
268
+ return;
269
+ }
270
+ const admissionRecordId = decision.recordId;
271
+ runtime.commitAdmission(ip, undefined, admissionRecordId);
272
+
273
+ bindings = attachConnection(runtime, opts, messages, services, history, clock, {
274
+ sendText: (text) => {
275
+ if (socket.writable) socket.write(text);
276
+ },
277
+ closeTransport: () => socket.destroy(),
278
+ admissionRecordId,
279
+ sourceHost: ip,
280
+ });
281
+ connections.set(socket, bindings);
282
+ if (header.remainder.length > 0) {
283
+ bindings.actor.receive(header.remainder.toString('utf8')).catch((err) => {
284
+ logError('actor failure', err);
285
+ });
286
+ }
287
+ return;
288
+ }
289
+ bindings.actor.receive(chunk.toString('utf8')).catch((err) => {
194
290
  logError('actor failure', err);
195
291
  });
196
292
  });
@@ -198,14 +294,36 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
198
294
  const cleanup = (): void => {
199
295
  connections.delete(socket);
200
296
  sockets.delete(socket);
201
- runtime.unregisterConnection(state.id);
297
+ // Rejected-before-admission flows never registered a connection.
298
+ if (bindings !== undefined) {
299
+ runtime.unregisterConnection(bindings.state.id);
300
+ }
202
301
  };
203
302
  socket.on('close', cleanup);
204
303
  socket.on('error', (err) => {
205
304
  logError('tcp socket error', err);
206
305
  cleanup();
207
306
  });
208
- });
307
+ };
308
+
309
+ const binds: Array<{ server: Server; port: number }> = [];
310
+
311
+ if (opts.port !== undefined) {
312
+ binds.push({ server: createServer(onConnection), port: opts.port });
313
+ }
314
+
315
+ if (opts.tls !== undefined) {
316
+ const { cert, key } = await loadTlsMaterial(opts.tls);
317
+ // Same connection pipeline as plaintext: a TLSSocket is a Socket,
318
+ // and the actor sees the decrypted byte stream identically.
319
+ binds.push({
320
+ server: tlsCreateServer(
321
+ { cert, key, minVersion: 'TLSv1.2', maxVersion: 'TLSv1.3' },
322
+ onConnection,
323
+ ),
324
+ port: opts.tls.port ?? DEFAULT_TLS_PORT,
325
+ });
326
+ }
209
327
 
210
328
  // Auto-snapshot timer.
211
329
  const snapshotIntervalMs = opts.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS;
@@ -217,41 +335,85 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
217
335
  : undefined;
218
336
  if (timer !== undefined) timer.unref?.();
219
337
 
220
- return new Promise<ContainerServer>((resolve, reject) => {
338
+ // Listening order matches construction: plaintext debug listener first
339
+ // (if any), TLS second. `binds` is never empty — the no-listener guard
340
+ // threw above.
341
+ const boundPorts = await Promise.all(binds.map((b) => listenOnce(b.server, b.port, host))).catch(
342
+ async (err: Error) => {
343
+ // Partial failure: unbind any listener that already made it so a
344
+ // rejected boot never leaves a stray port behind.
345
+ await Promise.all(binds.map((b) => new Promise<void>((res) => b.server.close(() => res()))));
346
+ throw err;
347
+ },
348
+ );
349
+ const plainPort = opts.port !== undefined ? boundPorts[0] : undefined;
350
+ const tlsPort = opts.tls !== undefined ? boundPorts[boundPorts.length - 1] : undefined;
351
+ return {
352
+ port: plainPort,
353
+ tlsPort,
354
+ host,
355
+ runtime,
356
+ messages,
357
+ history,
358
+ testSockets: sockets,
359
+ close: () =>
360
+ closeServer({
361
+ servers: binds.map((b) => b.server),
362
+ connections,
363
+ sockets,
364
+ timer,
365
+ runtime,
366
+ persistencePath: opts.persistencePath,
367
+ }),
368
+ };
369
+ }
370
+
371
+ /** Reads + validates the PEM pair, naming the offending path on failure. */
372
+ async function loadTlsMaterial(tls: TlsListenerOptions): Promise<{ cert: Buffer; key: Buffer }> {
373
+ let cert: Buffer;
374
+ let key: Buffer;
375
+ try {
376
+ cert = await readFile(tls.certPath);
377
+ } catch (err) {
378
+ throw new Error(
379
+ `failed to read TLS certificate at ${tls.certPath}: ${err instanceof Error ? err.message : String(err)}`,
380
+ );
381
+ }
382
+ try {
383
+ key = await readFile(tls.keyPath);
384
+ } catch (err) {
385
+ throw new Error(
386
+ `failed to read TLS key at ${tls.keyPath}: ${err instanceof Error ? err.message : String(err)}`,
387
+ );
388
+ }
389
+ return { cert, key };
390
+ }
391
+
392
+ /**
393
+ * Binds `server` on `port`/`host` and resolves with the actually-bound
394
+ * port (the listener's own AddressInfo, so ephemeral `0` binds resolve
395
+ * to the real port).
396
+ */
397
+ function listenOnce(server: Server, port: number, host: string): Promise<number> {
398
+ return new Promise((resolve, reject) => {
221
399
  const onError = (err: Error): void => {
222
- tcpServer.removeListener('listening', onListening);
400
+ server.removeListener('listening', onListening);
223
401
  reject(err);
224
402
  };
225
403
  const onListening = (): void => {
226
- tcpServer.removeListener('error', onError);
227
- const addr = tcpServer.address();
404
+ server.removeListener('error', onError);
405
+ const addr = server.address();
228
406
  // Inside the 'listening' handler `address()` is always an AddressInfo
229
- // (TCP server, just bound); the null/string returns only occur for IPC
230
- // sockets or before listening. The `opts.port` fallback is unreachable.
407
+ // (TCP server, just bound); the null/string returns only occur for
408
+ // IPC sockets or before listening. The `port` fallback is unreachable.
231
409
  /* c8 ignore start */
232
- const port = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
410
+ const bound = typeof addr === 'object' && addr !== null ? addr.port : port;
233
411
  /* c8 ignore stop */
234
- resolve({
235
- port,
236
- host,
237
- runtime,
238
- messages,
239
- history,
240
- testSockets: sockets,
241
- close: () =>
242
- closeServer({
243
- tcpServer,
244
- connections,
245
- sockets,
246
- timer,
247
- runtime,
248
- persistencePath: opts.persistencePath,
249
- }),
250
- });
412
+ resolve(bound);
251
413
  };
252
- tcpServer.once('listening', onListening);
253
- tcpServer.once('error', onError);
254
- tcpServer.listen(opts.port, host);
414
+ server.once('listening', onListening);
415
+ server.once('error', onError);
416
+ server.listen(port, host);
255
417
  });
256
418
  }
257
419
 
@@ -259,7 +421,7 @@ function attachConnection(
259
421
  runtime: InMemoryRuntime,
260
422
  opts: StartContainerOptions,
261
423
  messages: MessageStore,
262
- accounts: AccountStore | undefined,
424
+ services: ServicesStore,
263
425
  history: NickHistoryStore,
264
426
  clock: Clock,
265
427
  transport: {
@@ -271,8 +433,9 @@ function attachConnection(
271
433
  ): ConnectionBindings {
272
434
  const id = randomUUID();
273
435
  const state = createConnection({ id, connectedSince: clock.now() });
274
- // `transport.sourceHost` is always supplied (set to the peer IP) by the
275
- // only call site above; the else branch is defensive and unreachable.
436
+ // `transport.sourceHost` is always supplied (set to the PROXY-header
437
+ // source IP) by the only call site above; the else branch is defensive
438
+ // and unreachable.
276
439
  /* c8 ignore start */
277
440
  if (transport.sourceHost !== undefined) {
278
441
  state.host = transport.sourceHost;
@@ -297,17 +460,32 @@ function attachConnection(
297
460
  quitMessage: opts.quitMessage ?? 'Client Quit',
298
461
  ...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
299
462
  ...(opts.cloaking !== undefined ? { cloaking: opts.cloaking } : {}),
463
+ // SASL EXTERNAL non-support: PROXY v1 carries no client-cert
464
+ // subject, so the mechanism is pinned off with the transport-
465
+ // specific refusal (see SASL_EXTERNAL_UNSUPPORTED_MESSAGE) instead
466
+ // of failing generically at the three-way gate.
467
+ sasl: { externalUnsupportedMessage: SASL_EXTERNAL_UNSUPPORTED_MESSAGE },
300
468
  },
301
469
  clock,
302
470
  ids: DEFAULT_ID_FACTORY,
303
471
  motd: { lines: () => opts.motdLines ?? [] },
304
472
  messages,
305
- ...(accounts !== undefined ? { accounts } : {}),
473
+ ...(services !== undefined ? { services } : {}),
306
474
  history,
307
475
  logger,
308
- transport: new TcpByteStreamTransport(),
309
- // Spectrum terminates TLS at the edge before forwarding plaintext TCP
310
- // to this origin, so every connection is secure user mode `S`.
476
+ transport: new TcpByteStreamTransport({
477
+ // A > 8 KiB unterminated tail is pure attacker-controlled memory (no
478
+ // legal IRC line can exceed 512 bytes): answer with the RFC-style
479
+ // ERROR notice and destroy the socket. The 'close' handler performs
480
+ // the bookkeeping teardown (connections map + runtime unregister).
481
+ onOverflow: () => {
482
+ transport.sendText('ERROR :Closing link: input buffer overflow\r\n');
483
+ transport.closeTransport();
484
+ },
485
+ }),
486
+ // TLS terminates at this origin (Spectrum `full` mode re-encrypts to
487
+ // the origin's own TLS listener), so every connection is secure →
488
+ // user mode `S`.
311
489
  secure: true,
312
490
  });
313
491
 
@@ -332,14 +510,14 @@ function attachConnection(
332
510
  }
333
511
 
334
512
  async function closeServer(args: {
335
- tcpServer: Server;
513
+ servers: Server[];
336
514
  connections: Map<Socket, ConnectionBindings>;
337
515
  sockets: Set<Socket>;
338
516
  timer: ReturnType<typeof setInterval> | undefined;
339
517
  runtime: InMemoryRuntime;
340
518
  persistencePath: string | undefined;
341
519
  }): Promise<void> {
342
- const { tcpServer, connections, sockets, timer, runtime, persistencePath } = args;
520
+ const { servers, connections, sockets, timer, runtime, persistencePath } = args;
343
521
 
344
522
  if (timer !== undefined) clearInterval(timer);
345
523
 
@@ -361,10 +539,12 @@ async function closeServer(args: {
361
539
  // Stop accepting new connections. We do NOT await the callback because
362
540
  // `server.close()` waits for every connection's 'close' event, which can
363
541
  // hang if a destroyed socket's TCP cleanup is delayed by the OS. All
364
- // sockets are already destroyed above; the server is safe to abandon.
365
- tcpServer.close();
366
- (tcpServer as Server & { closeAllConnections?: () => void }).closeAllConnections?.();
367
- tcpServer.unref();
542
+ // sockets are already destroyed above; the servers are safe to abandon.
543
+ for (const server of servers) {
544
+ server.close();
545
+ (server as Server & { closeAllConnections?: () => void }).closeAllConnections?.();
546
+ server.unref();
547
+ }
368
548
  }
369
549
 
370
550
  async function snapshotNow(runtime: InMemoryRuntime, path: string): Promise<void> {