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
@@ -2,13 +2,18 @@
2
2
  /**
3
3
  * CF TCP container origin entry point.
4
4
  *
5
- * Boots the IRC core as a plaintext TCP server behind Cloudflare Spectrum.
6
- * Spectrum terminates TLS at the edge and forwards plaintext TCP to this
7
- * process. Configuration comes from environment variables (set by the
8
- * Cloudflare Container runtime, Docker, or Kubernetes).
5
+ * Boots the IRC core as a TLS-terminating origin behind Cloudflare
6
+ * Spectrum (`tls_mode = "full"`): the process presents its own
7
+ * certificate on :6697 (TLS_CERT_PATH / TLS_KEY_PATH) and Spectrum
8
+ * re-encrypts edge→origin, so no plaintext IRC crosses the network.
9
+ * A plaintext listener is available only for localhost debugging
10
+ * (DEBUG_PLAINTEXT=localhost, bound to 127.0.0.1). Configuration comes
11
+ * from environment variables (set by the Cloudflare Container runtime,
12
+ * Docker, or Kubernetes).
9
13
  *
10
14
  * Usage:
11
- * TCP_PORT=6667 SERVER_NAME=irc.example.com node dist/main.js
15
+ * TLS_CERT_PATH=/certs/tls.pem TLS_KEY_PATH=/certs/tls.key \
16
+ * SERVER_NAME=irc.example.com node dist/main.js
12
17
  *
13
18
  * The process runs until SIGINT/SIGTERM. On shutdown it writes a final
14
19
  * persistence snapshot (when PERSISTENCE_PATH is set) so channel state
@@ -21,9 +26,17 @@ import { startContainerServer } from './container-server.js';
21
26
  async function main(): Promise<void> {
22
27
  const cfg = loadContainerConfig(process.env as Record<string, string | undefined>);
23
28
 
29
+ if (!cfg.plaintextDebug && cfg.tls === undefined) {
30
+ throw new Error(
31
+ 'no listener configured: set TLS_CERT_PATH + TLS_KEY_PATH to serve TLS on :6697, ' +
32
+ 'or DEBUG_PLAINTEXT=localhost for a localhost-only plaintext debug listener',
33
+ );
34
+ }
35
+
24
36
  const server = await startContainerServer({
25
- port: cfg.tcpPort,
26
37
  host: cfg.tcpHost,
38
+ ...(cfg.plaintextDebug ? { port: cfg.tcpPort } : {}),
39
+ ...(cfg.tls !== undefined ? { tls: cfg.tls } : {}),
27
40
  serverName: cfg.server.serverName,
28
41
  networkName: cfg.server.networkName,
29
42
  motdLines: cfg.server.motdLines,
@@ -47,7 +60,9 @@ async function main(): Promise<void> {
47
60
  level: 'info',
48
61
  msg: 'cf-tcp-container listening',
49
62
  host: server.host,
50
- port: server.port,
63
+ port: server.port ?? null,
64
+ tlsPort: server.tlsPort ?? null,
65
+ plaintextDebug: cfg.plaintextDebug,
51
66
  persistence: cfg.persistencePath ?? null,
52
67
  serverName: cfg.server.serverName,
53
68
  })}\n`,
@@ -0,0 +1,112 @@
1
+ /**
2
+ * PROXY protocol v1 header parsing.
3
+ *
4
+ * Cloudflare Spectrum fronts this origin with `proxy_protocol = "v1"`:
5
+ * every forwarded TCP flow begins with the ASCII line
6
+ *
7
+ * PROXY TCP4 <src_ip> <dst_ip> <src_port> <dst_port>\r\n
8
+ * PROXY TCP6 <src_ip> <dst_ip> <src_port> <dst_port>\r\n
9
+ *
10
+ * (spec: the header is at most 107 bytes including the trailing CRLF).
11
+ * The source IP recovered here is the real client address — the TCP
12
+ * peer address seen by this origin is always a Spectrum edge IP.
13
+ *
14
+ * Dependency decision: hand-rolled parser (no npm `proxy-protocol`
15
+ * dependency). The v1 header is one fixed-format, ASCII-only line; the
16
+ * ~40 lines below fully validate it while avoiding the supply-chain
17
+ * surface of a third-party package on the connection hot path. (The
18
+ * decision is also recorded in this package's package.json.)
19
+ *
20
+ * The header may arrive split across TCP chunks, so
21
+ * {@link consumeProxyV1Header} is incremental: feed it the bytes
22
+ * buffered so far and it reports `incomplete` until the trailing CRLF
23
+ * is available, `invalid` for anything that can no longer be a legal
24
+ * header (wrong signature, over-length, unknown family, malformed
25
+ * address/port), or `complete` with the recovered source IP plus any
26
+ * bytes that followed the header.
27
+ */
28
+
29
+ import { isIP } from 'node:net';
30
+
31
+ /** Max PROXY v1 header length in bytes, including the trailing CRLF. */
32
+ export const PROXY_V1_MAX_HEADER_BYTES = 107;
33
+
34
+ const SIGNATURE = 'PROXY ';
35
+ const CRLF = '\r\n';
36
+
37
+ /** Outcome of {@link consumeProxyV1Header}. */
38
+ export type ProxyV1HeaderResult =
39
+ | { readonly status: 'complete'; readonly sourceIp: string; readonly remainder: Buffer }
40
+ | { readonly status: 'incomplete' }
41
+ | { readonly status: 'invalid' };
42
+
43
+ /**
44
+ * Consumes a PROXY protocol v1 header from `buffer` (the bytes received
45
+ * on the connection so far). Pure function: the caller keeps
46
+ * concatenating chunks and re-invoking until the result is no longer
47
+ * `incomplete`.
48
+ */
49
+ export function consumeProxyV1Header(buffer: Buffer): ProxyV1HeaderResult {
50
+ const crlf = buffer.indexOf(CRLF);
51
+ if (crlf === -1) {
52
+ // No terminator yet. Reject as soon as the bytes can no longer be a
53
+ // header prefix: over the 107-byte budget, or not spelling "PROXY ".
54
+ if (buffer.length > PROXY_V1_MAX_HEADER_BYTES) {
55
+ return { status: 'invalid' };
56
+ }
57
+ const prefix = buffer.subarray(0, SIGNATURE.length).toString('ascii');
58
+ if (!SIGNATURE.startsWith(prefix)) {
59
+ return { status: 'invalid' };
60
+ }
61
+ return { status: 'incomplete' };
62
+ }
63
+
64
+ const headerEnd = crlf + CRLF.length;
65
+ if (headerEnd > PROXY_V1_MAX_HEADER_BYTES) {
66
+ return { status: 'invalid' };
67
+ }
68
+
69
+ // One slot per expected token plus a trailing slot that must stay
70
+ // empty: fewer than 6 tokens surfaces as `undefined`, more than 6
71
+ // populates `extra`.
72
+ const [signature, family, srcIp, dstIp, srcPort, dstPort, extra] = buffer
73
+ .subarray(0, crlf)
74
+ .toString('ascii')
75
+ .split(' ');
76
+ if (
77
+ family === undefined ||
78
+ srcIp === undefined ||
79
+ dstIp === undefined ||
80
+ srcPort === undefined ||
81
+ dstPort === undefined ||
82
+ extra !== undefined ||
83
+ signature !== 'PROXY'
84
+ ) {
85
+ return { status: 'invalid' };
86
+ }
87
+ const expectedFamily = family === 'TCP4' ? 4 : family === 'TCP6' ? 6 : 0;
88
+ if (expectedFamily === 0) {
89
+ // Spectrum always announces TCP4/TCP6; UNKNOWN (and anything else)
90
+ // is rejected so a header cannot be used to hide the source address.
91
+ return { status: 'invalid' };
92
+ }
93
+ if (isIP(srcIp) !== expectedFamily || isIP(dstIp) !== expectedFamily) {
94
+ return { status: 'invalid' };
95
+ }
96
+ if (!isValidPort(srcPort) || !isValidPort(dstPort)) {
97
+ return { status: 'invalid' };
98
+ }
99
+ return {
100
+ status: 'complete',
101
+ sourceIp: srcIp,
102
+ remainder: buffer.subarray(headerEnd),
103
+ };
104
+ }
105
+
106
+ function isValidPort(token: string): boolean {
107
+ if (!/^\d{1,5}$/u.test(token)) {
108
+ return false;
109
+ }
110
+ const value = Number.parseInt(token, 10);
111
+ return value >= 0 && value <= 65_535;
112
+ }
@@ -1,9 +1,11 @@
1
1
  ###############################################################################
2
2
  # Cloudflare Spectrum config for the ServerlessIRCd TCP+TLS transport.
3
3
  #
4
- # Spectrum terminates TLS on port 6697 and forwards plaintext TCP to the
5
- # container origin. The origin is the Cloudflare Container (or external
6
- # compute) running the IRC core on port 6667.
4
+ # TLS is end-to-end: clients terminate TLS with Spectrum on :6697 and
5
+ # Spectrum re-encrypts to the origin, which runs its own TLS listener on
6
+ # :6697 (see apps/cf-tcp-container/src/container-server.ts). No plaintext
7
+ # IRC bytes ever cross the origin network, so a direct-reach attacker can
8
+ # neither read PASS/AUTHENTICATE/PRIVMSG off the wire nor spoof them.
7
9
  #
8
10
  # Usage:
9
11
  # terraform init
@@ -12,10 +14,26 @@
12
14
  # -var origin_address=container-origin.example.com \
13
15
  # -var hostname=irc.example.com
14
16
  #
15
- # The `tls = "flexible"` mode means TLS is terminated by Spectrum and the
16
- # connection to the origin is plaintext (matching the container's plaintext
17
- # TCP listener). For mTLS passthrough (SASL EXTERNAL via client cert), set
18
- # `tls = "full"` and configure the origin to present its own certificate.
17
+ # The `tls = "full"` default means the origin must present a certificate
18
+ # the TLS listener accepts (Let's Encrypt, BYO, or a Cloudflare Origin
19
+ # Cert — see docs/Cloudflare-TCP-Deployment.md). For strict origin-cert
20
+ # validation at the edge, set `tls_mode = "strict"`.
21
+ #
22
+ # Origin ingress lockdown (operator-side step): restrict ingress to the
23
+ # origin's :6697 to Cloudflare's published IP ranges so the origin is only
24
+ # reachable through Spectrum. On platforms with a firewall / security-group
25
+ # primitive, source the ranges from the current Cloudflare IP list, e.g.:
26
+ #
27
+ # data "cloudflare_lists" "all" {}
28
+ #
29
+ # resource "cloudflare_list" "cf_ips" {
30
+ # account_id = var.cloudflare_account_id
31
+ # name = "cloudflare-ips"
32
+ # kind = "IP"
33
+ # }
34
+ #
35
+ # (Maintained list source: https://www.cloudflare.com/ips/ — keep the
36
+ # firewall entries in sync when Cloudflare publishes range changes.)
19
37
  ###############################################################################
20
38
 
21
39
  variable "zone_id" {
@@ -30,8 +48,8 @@ variable "origin_address" {
30
48
 
31
49
  variable "origin_port" {
32
50
  type = number
33
- default = 6667
34
- description = "Port the TCP origin listens on (plaintext IRC)."
51
+ default = 6697
52
+ description = "Port the origin's TLS listener serves (Spectrum re-encrypts to it)."
35
53
  }
36
54
 
37
55
  variable "hostname" {
@@ -48,8 +66,8 @@ variable "spectrum_port" {
48
66
 
49
67
  variable "tls_mode" {
50
68
  type = string
51
- default = "flexible"
52
- description = "Spectrum TLS mode: flexible (TLS→CF, plain→origin) or full (TLS end-to-end)."
69
+ default = "full"
70
+ description = "Spectrum TLS mode: full (TLS end-to-end, the secure default) or strict (edge validates the origin cert)."
53
71
 
54
72
  validation {
55
73
  condition = contains(["flexible", "full", "strict"], var.tls_mode)
@@ -75,6 +93,17 @@ resource "cloudflare_spectrum_application" "irc_tls" {
75
93
  ip_firewall = true
76
94
  argo_smart_routing = true
77
95
 
96
+ # NOTE on TLS version pinning (1.2 / 1.3 only): Spectrum TCP
97
+ # applications CANNOT pin per-app TLS versions — the Cloudflare API
98
+ # and the terraform provider expose no `tls_versions` field for them
99
+ # (setting one fails `terraform plan` with "Unsupported argument"),
100
+ # and Cloudflare's fixed defaults apply at the edge: SSLv3 and TLS 1.0
101
+ # are never negotiable, TLS 1.1 is the floor. The explicit TLS 1.2/1.3
102
+ # pin for this deployment is therefore enforced on the origin leg in
103
+ # `apps/cf-tcp-container/src/container-server.ts`: the origin TLS
104
+ # listener terminates the re-encrypted Spectrum→origin connection
105
+ # with `minVersion: 'TLSv1.2'` / `maxVersion: 'TLSv1.3'`.
106
+
78
107
  # PROXY protocol v1 forwards the real client IP to the origin so the
79
108
  # container's admission gate (per-IP limits) sees the actual source.
80
109
  proxy_protocol = "v1"
@@ -161,6 +161,29 @@ describe('config-loader — container-specific knobs', () => {
161
161
  expect(cfg.server.operCreds).toEqual([{ user: 'admin', password: 'secret' }]);
162
162
  });
163
163
 
164
+ it('parses OPER_USER + OPER_SALT + OPER_HASH into a hashed operCred', () => {
165
+ const cfg = loadContainerConfig({
166
+ SERVER_NAME: 'irc.test',
167
+ NETWORK_NAME: 'TestNet',
168
+ OPER_USER: 'admin',
169
+ OPER_SALT: 'c2FsdA==',
170
+ OPER_HASH: 'aGFzaA==',
171
+ });
172
+ expect(cfg.server.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
173
+ });
174
+
175
+ it('prefers the hashed operCred form when both plaintext and hashed env vars are set', () => {
176
+ const cfg = loadContainerConfig({
177
+ SERVER_NAME: 'irc.test',
178
+ NETWORK_NAME: 'TestNet',
179
+ OPER_USER: 'admin',
180
+ OPER_PASSWORD: 'legacy',
181
+ OPER_SALT: 'c2FsdA==',
182
+ OPER_HASH: 'aGFzaA==',
183
+ });
184
+ expect(cfg.server.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
185
+ });
186
+
164
187
  it('exercises the OPER_PASSWORD ?? "" default then rejects partial OPER_USER-only creds', () => {
165
188
  // The `?? ''` default for the missing OPER_PASSWORD is exercised, but
166
189
  // parseServerConfig then rejects the empty password — partial oper
@@ -265,3 +288,150 @@ describe('config-loader — container-specific knobs', () => {
265
288
  expect(cfg.server.serverName).toBe('irc.test');
266
289
  });
267
290
  });
291
+
292
+ describe('config-loader — TLS termination at the origin', () => {
293
+ it('tls is undefined when no cert env is set (non-prod)', () => {
294
+ const cfg = loadContainerConfig({ SERVER_NAME: 'irc.test', NETWORK_NAME: 'TestNet' });
295
+ expect(cfg.tls).toBeUndefined();
296
+ });
297
+
298
+ it('reads TLS_CERT_PATH + TLS_KEY_PATH into a tls config with default port 6697', () => {
299
+ const cfg = loadContainerConfig({
300
+ SERVER_NAME: 'irc.test',
301
+ NETWORK_NAME: 'TestNet',
302
+ TLS_CERT_PATH: '/data/certs/tls.pem',
303
+ TLS_KEY_PATH: '/data/certs/tls.key',
304
+ });
305
+ expect(cfg.tls).toEqual({
306
+ certPath: '/data/certs/tls.pem',
307
+ keyPath: '/data/certs/tls.key',
308
+ port: 6697,
309
+ });
310
+ });
311
+
312
+ it('reads TLS_PORT override', () => {
313
+ const cfg = loadContainerConfig({
314
+ SERVER_NAME: 'irc.test',
315
+ NETWORK_NAME: 'TestNet',
316
+ TLS_CERT_PATH: '/certs/tls.pem',
317
+ TLS_KEY_PATH: '/certs/tls.key',
318
+ TLS_PORT: '7000',
319
+ });
320
+ expect(cfg.tls?.port).toBe(7000);
321
+ });
322
+
323
+ it('defaults TLS_PORT when TLS_PORT is empty or invalid', () => {
324
+ for (const bad of ['', 'not-a-number']) {
325
+ const cfg = loadContainerConfig({
326
+ SERVER_NAME: 'irc.test',
327
+ NETWORK_NAME: 'TestNet',
328
+ TLS_CERT_PATH: '/certs/tls.pem',
329
+ TLS_KEY_PATH: '/certs/tls.key',
330
+ TLS_PORT: bad,
331
+ });
332
+ expect(cfg.tls?.port).toBe(6697);
333
+ }
334
+ });
335
+
336
+ it('throws naming TLS_KEY_PATH when only TLS_CERT_PATH is set', () => {
337
+ expect(() =>
338
+ loadContainerConfig({
339
+ SERVER_NAME: 'irc.test',
340
+ NETWORK_NAME: 'TestNet',
341
+ TLS_CERT_PATH: '/certs/tls.pem',
342
+ }),
343
+ ).toThrowError(/TLS_KEY_PATH/u);
344
+ });
345
+
346
+ it('throws naming TLS_CERT_PATH when only TLS_KEY_PATH is set', () => {
347
+ expect(() =>
348
+ loadContainerConfig({
349
+ SERVER_NAME: 'irc.test',
350
+ NETWORK_NAME: 'TestNet',
351
+ TLS_KEY_PATH: '/certs/tls.key',
352
+ }),
353
+ ).toThrowError(/TLS_CERT_PATH/u);
354
+ });
355
+
356
+ it('refuses to start in prod-classified mode without TLS_CERT_PATH', () => {
357
+ expect(() =>
358
+ loadContainerConfig({
359
+ SERVER_NAME: 'irc.test',
360
+ NETWORK_NAME: 'TestNet',
361
+ NODE_ENV: 'production',
362
+ }),
363
+ ).toThrowError(/TLS_CERT_PATH/u);
364
+ });
365
+
366
+ it('refuses DEBUG_PLAINTEXT in prod-classified mode even with TLS configured', () => {
367
+ expect(() =>
368
+ loadContainerConfig({
369
+ SERVER_NAME: 'irc.test',
370
+ NETWORK_NAME: 'TestNet',
371
+ NODE_ENV: 'production',
372
+ TLS_CERT_PATH: '/certs/tls.pem',
373
+ TLS_KEY_PATH: '/certs/tls.key',
374
+ DEBUG_PLAINTEXT: 'localhost',
375
+ }),
376
+ ).toThrowError(/DEBUG_PLAINTEXT/u);
377
+ });
378
+
379
+ it('the prod no-TLS guard fires before the DEBUG_PLAINTEXT guard', () => {
380
+ expect(() =>
381
+ loadContainerConfig({
382
+ SERVER_NAME: 'irc.test',
383
+ NETWORK_NAME: 'TestNet',
384
+ NODE_ENV: 'production',
385
+ DEBUG_PLAINTEXT: 'localhost',
386
+ }),
387
+ ).toThrowError(/TLS_CERT_PATH/u);
388
+ });
389
+ });
390
+
391
+ describe('config-loader — plaintext debug listener gate', () => {
392
+ it('plaintextDebug is false when DEBUG_PLAINTEXT is unset', () => {
393
+ const cfg = loadContainerConfig({ SERVER_NAME: 'irc.test', NETWORK_NAME: 'TestNet' });
394
+ expect(cfg.plaintextDebug).toBe(false);
395
+ });
396
+
397
+ it('enables plaintextDebug for the exact flag value DEBUG_PLAINTEXT=localhost', () => {
398
+ const cfg = loadContainerConfig({
399
+ SERVER_NAME: 'irc.test',
400
+ NETWORK_NAME: 'TestNet',
401
+ DEBUG_PLAINTEXT: 'localhost',
402
+ });
403
+ expect(cfg.plaintextDebug).toBe(true);
404
+ });
405
+
406
+ it('forces the plaintext bind host to 127.0.0.1 when plaintextDebug is enabled', () => {
407
+ const cfg = loadContainerConfig({
408
+ SERVER_NAME: 'irc.test',
409
+ NETWORK_NAME: 'TestNet',
410
+ DEBUG_PLAINTEXT: 'localhost',
411
+ TCP_HOST: '0.0.0.0',
412
+ });
413
+ expect(cfg.plaintextDebug).toBe(true);
414
+ expect(cfg.tcpHost).toBe('127.0.0.1');
415
+ });
416
+
417
+ it('rejects DEBUG_PLAINTEXT values other than localhost', () => {
418
+ expect(() =>
419
+ loadContainerConfig({
420
+ SERVER_NAME: 'irc.test',
421
+ NETWORK_NAME: 'TestNet',
422
+ DEBUG_PLAINTEXT: '0.0.0.0',
423
+ }),
424
+ ).toThrowError(/localhost/u);
425
+ });
426
+
427
+ it('keeps the normal TCP_HOST default when plaintextDebug is disabled', () => {
428
+ const cfg = loadContainerConfig({
429
+ SERVER_NAME: 'irc.test',
430
+ NETWORK_NAME: 'TestNet',
431
+ TLS_CERT_PATH: '/certs/tls.pem',
432
+ TLS_KEY_PATH: '/certs/tls.key',
433
+ });
434
+ expect(cfg.plaintextDebug).toBe(false);
435
+ expect(cfg.tcpHost).toBe('0.0.0.0');
436
+ });
437
+ });