serverless-ircd 0.10.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 (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -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,7 +39,9 @@
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
47
  type AdmissionConfig,
@@ -37,13 +63,30 @@ import {
37
63
  } from '@serverless-ircd/irc-core';
38
64
  import { ConnectionActor, TcpByteStreamTransport } from '@serverless-ircd/irc-server';
39
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
+ }
40
77
 
41
78
  /** Options accepted by {@link startContainerServer}. */
42
79
  export interface StartContainerOptions {
43
- /** Plaintext TCP port. Use 0 for an ephemeral port. */
44
- 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;
45
86
  /** Bind address. Defaults to `0.0.0.0` (Spectrum forwards from the edge). */
46
87
  readonly host?: string;
88
+ /** TLS listener: terminates TLS at the origin (Spectrum `full` mode). */
89
+ readonly tls?: TlsListenerOptions;
47
90
  readonly serverName: string;
48
91
  readonly networkName: string;
49
92
  readonly motdLines?: string[];
@@ -75,7 +118,10 @@ export interface StartContainerOptions {
75
118
 
76
119
  /** A running container origin. Call {@link close} to shut down. */
77
120
  export interface ContainerServer {
78
- 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;
79
125
  readonly host: string;
80
126
  readonly runtime: InMemoryRuntime;
81
127
  readonly messages: MessageStore;
@@ -107,15 +153,34 @@ const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
107
153
  const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
108
154
  const DEFAULT_PER_IP_CONNECTION_RATE = { max: 5, windowMs: 60_000 };
109
155
  const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
156
+ const DEFAULT_TLS_PORT = 6697;
110
157
 
111
158
  /**
112
- * Starts the TCP container origin. Resolves once the TCP listener is
113
- * ready. When `persistencePath` is set and a snapshot file exists, channel
114
- * 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.
115
174
  */
116
175
  export async function startContainerServer(opts: StartContainerOptions): Promise<ContainerServer> {
117
176
  const host = opts.host ?? '0.0.0.0';
118
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
+ }
119
184
 
120
185
  const admission: AdmissionConfig | undefined =
121
186
  opts.maxConnectionsPerIp !== undefined ||
@@ -157,43 +222,71 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
157
222
  const connections = new Map<Socket, ConnectionBindings>();
158
223
  const sockets = new Set<Socket>();
159
224
 
160
- const tcpServer = createServer((socket) => {
225
+ const onConnection = (socket: Socket): void => {
161
226
  sockets.add(socket);
162
227
 
163
- // `remoteAddress` is always set on a TCP server's connection socket;
164
- // the 'unknown' fallback is defensive and unreachable in practice.
165
- /* c8 ignore start */
166
- const ip = socket.remoteAddress ?? 'unknown';
167
- /* c8 ignore stop */
168
- const decision = runtime.admitConnection(ip, undefined);
169
- if (!decision.ok) {
170
- // A freshly-accepted TCP socket is always writable; the else branch
171
- // (skip the ERROR write) is a defensive guard against an impossible
172
- // 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.
173
238
  /* c8 ignore start */
174
239
  if (socket.writable) {
175
- socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
240
+ socket.write(`ERROR :Closing link: (${reason})\r\n`);
176
241
  }
177
242
  /* c8 ignore stop */
178
243
  socket.destroy();
179
244
  sockets.delete(socket);
180
- return;
181
- }
182
- const admissionRecordId = decision.recordId;
183
- runtime.commitAdmission(ip, undefined, admissionRecordId);
184
-
185
- const { state, actor } = attachConnection(runtime, opts, messages, services, history, clock, {
186
- sendText: (text) => {
187
- if (socket.writable) socket.write(text);
188
- },
189
- closeTransport: () => socket.destroy(),
190
- admissionRecordId,
191
- sourceHost: ip,
192
- });
193
- connections.set(socket, { state, actor });
245
+ };
194
246
 
195
247
  socket.on('data', (chunk: Buffer) => {
196
- 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) => {
197
290
  logError('actor failure', err);
198
291
  });
199
292
  });
@@ -201,14 +294,36 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
201
294
  const cleanup = (): void => {
202
295
  connections.delete(socket);
203
296
  sockets.delete(socket);
204
- runtime.unregisterConnection(state.id);
297
+ // Rejected-before-admission flows never registered a connection.
298
+ if (bindings !== undefined) {
299
+ runtime.unregisterConnection(bindings.state.id);
300
+ }
205
301
  };
206
302
  socket.on('close', cleanup);
207
303
  socket.on('error', (err) => {
208
304
  logError('tcp socket error', err);
209
305
  cleanup();
210
306
  });
211
- });
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
+ }
212
327
 
213
328
  // Auto-snapshot timer.
214
329
  const snapshotIntervalMs = opts.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS;
@@ -220,41 +335,85 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
220
335
  : undefined;
221
336
  if (timer !== undefined) timer.unref?.();
222
337
 
223
- 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) => {
224
399
  const onError = (err: Error): void => {
225
- tcpServer.removeListener('listening', onListening);
400
+ server.removeListener('listening', onListening);
226
401
  reject(err);
227
402
  };
228
403
  const onListening = (): void => {
229
- tcpServer.removeListener('error', onError);
230
- const addr = tcpServer.address();
404
+ server.removeListener('error', onError);
405
+ const addr = server.address();
231
406
  // Inside the 'listening' handler `address()` is always an AddressInfo
232
- // (TCP server, just bound); the null/string returns only occur for IPC
233
- // 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.
234
409
  /* c8 ignore start */
235
- const port = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
410
+ const bound = typeof addr === 'object' && addr !== null ? addr.port : port;
236
411
  /* c8 ignore stop */
237
- resolve({
238
- port,
239
- host,
240
- runtime,
241
- messages,
242
- history,
243
- testSockets: sockets,
244
- close: () =>
245
- closeServer({
246
- tcpServer,
247
- connections,
248
- sockets,
249
- timer,
250
- runtime,
251
- persistencePath: opts.persistencePath,
252
- }),
253
- });
412
+ resolve(bound);
254
413
  };
255
- tcpServer.once('listening', onListening);
256
- tcpServer.once('error', onError);
257
- tcpServer.listen(opts.port, host);
414
+ server.once('listening', onListening);
415
+ server.once('error', onError);
416
+ server.listen(port, host);
258
417
  });
259
418
  }
260
419
 
@@ -274,8 +433,9 @@ function attachConnection(
274
433
  ): ConnectionBindings {
275
434
  const id = randomUUID();
276
435
  const state = createConnection({ id, connectedSince: clock.now() });
277
- // `transport.sourceHost` is always supplied (set to the peer IP) by the
278
- // 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.
279
439
  /* c8 ignore start */
280
440
  if (transport.sourceHost !== undefined) {
281
441
  state.host = transport.sourceHost;
@@ -300,6 +460,11 @@ function attachConnection(
300
460
  quitMessage: opts.quitMessage ?? 'Client Quit',
301
461
  ...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
302
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 },
303
468
  },
304
469
  clock,
305
470
  ids: DEFAULT_ID_FACTORY,
@@ -308,9 +473,19 @@ function attachConnection(
308
473
  ...(services !== undefined ? { services } : {}),
309
474
  history,
310
475
  logger,
311
- transport: new TcpByteStreamTransport(),
312
- // Spectrum terminates TLS at the edge before forwarding plaintext TCP
313
- // 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`.
314
489
  secure: true,
315
490
  });
316
491
 
@@ -335,14 +510,14 @@ function attachConnection(
335
510
  }
336
511
 
337
512
  async function closeServer(args: {
338
- tcpServer: Server;
513
+ servers: Server[];
339
514
  connections: Map<Socket, ConnectionBindings>;
340
515
  sockets: Set<Socket>;
341
516
  timer: ReturnType<typeof setInterval> | undefined;
342
517
  runtime: InMemoryRuntime;
343
518
  persistencePath: string | undefined;
344
519
  }): Promise<void> {
345
- const { tcpServer, connections, sockets, timer, runtime, persistencePath } = args;
520
+ const { servers, connections, sockets, timer, runtime, persistencePath } = args;
346
521
 
347
522
  if (timer !== undefined) clearInterval(timer);
348
523
 
@@ -364,10 +539,12 @@ async function closeServer(args: {
364
539
  // Stop accepting new connections. We do NOT await the callback because
365
540
  // `server.close()` waits for every connection's 'close' event, which can
366
541
  // hang if a destroyed socket's TCP cleanup is delayed by the OS. All
367
- // sockets are already destroyed above; the server is safe to abandon.
368
- tcpServer.close();
369
- (tcpServer as Server & { closeAllConnections?: () => void }).closeAllConnections?.();
370
- 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
+ }
371
548
  }
372
549
 
373
550
  async function snapshotNow(runtime: InMemoryRuntime, path: string): Promise<void> {