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.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +17 -4
- package/apps/cf-worker/package.json +2 -2
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +86 -9
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +40 -1
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +393 -20
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +8 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +395 -36
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +544 -7
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/scripts/package.json +1 -1
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +17 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
|
@@ -36,22 +36,32 @@ path adds two stateful pieces in front of the shared IRC core:
|
|
|
36
36
|
┌──────────────────────────────────────────────────────────────┐
|
|
37
37
|
│ Cloudflare Spectrum (Enterprise) │
|
|
38
38
|
│ • terminates TLS on :6697 │
|
|
39
|
-
│ •
|
|
39
|
+
│ • re-encrypts to the origin (tls_mode = "full") │
|
|
40
40
|
│ • PROXY protocol v1 carries the real client IP │
|
|
41
41
|
└──────────────────────────┬───────────────────────────────────┘
|
|
42
|
-
│
|
|
42
|
+
│ TLS (:6697, origin cert)
|
|
43
43
|
▼
|
|
44
44
|
┌──────────────────────────────────────────────────────────────┐
|
|
45
45
|
│ Cloudflare Container (apps/cf-tcp-container) │
|
|
46
|
-
│ • node:24 process, EXPOSE
|
|
46
|
+
│ • node:24 process, EXPOSE 6697 — terminates TLS itself │
|
|
47
|
+
│ (TLS_CERT_PATH / TLS_KEY_PATH, mounted at /data/certs) │
|
|
47
48
|
│ • ConnectionActor + InMemoryRuntime + TcpByteStream- │
|
|
48
49
|
│ Transport (the shared transport seam, TICKET-053) │
|
|
49
50
|
│ • persistence snapshot at /data/state.json │
|
|
50
51
|
│ (channel topology survives restart; live connections │
|
|
51
52
|
│ do not — clients reconnect) │
|
|
53
|
+
│ • plaintext :6667 = localhost-only debug listener │
|
|
54
|
+
│ (DEBUG_PLAINTEXT=localhost; never routed, never in │
|
|
55
|
+
│ prod — a prod-classified boot refuses to start │
|
|
56
|
+
│ without TLS material) │
|
|
52
57
|
└──────────────────────────────────────────────────────────────┘
|
|
53
58
|
```
|
|
54
59
|
|
|
60
|
+
TLS is **end-to-end**: no plaintext IRC bytes ever cross the origin
|
|
61
|
+
network, so an attacker who can reach the origin address directly can
|
|
62
|
+
neither read `PASS`/`AUTHENTICATE`/`PRIVMSG` off the wire nor spoof
|
|
63
|
+
them.
|
|
64
|
+
|
|
55
65
|
The **parser → reducer → dispatch pipeline is shared verbatim** with the
|
|
56
66
|
wss path. Only the line-framing transport differs: a `TcpByteStreamTransport`
|
|
57
67
|
reassembles `\r\n`-terminated IRC lines across arbitrary TCP chunks
|
|
@@ -61,8 +71,8 @@ Two artifacts are deployed and configured:
|
|
|
61
71
|
|
|
62
72
|
| Artifact | Tool | What it does |
|
|
63
73
|
|---------------------------------------|----------------|------------------------------------------------|
|
|
64
|
-
| `sirc-tcp-origin` Cloudflare Container| `wrangler` | Builds + runs the IRC core container image.
|
|
65
|
-
| `cloudflare_spectrum_application` | `terraform` | TLS
|
|
74
|
+
| `sirc-tcp-origin` Cloudflare Container| `wrangler` | Builds + runs the IRC core container image (TLS origin). |
|
|
75
|
+
| `cloudflare_spectrum_application` | `terraform` | TLS on :6697 + re-encrypted origin forwarding (`tls_mode = "full"`). |
|
|
66
76
|
|
|
67
77
|
---
|
|
68
78
|
|
|
@@ -133,17 +143,19 @@ export CLOUDFLARE_ACCOUNT_ID=...
|
|
|
133
143
|
|
|
134
144
|
## 4. Local development
|
|
135
145
|
|
|
136
|
-
Run the container origin locally without Spectrum
|
|
137
|
-
|
|
138
|
-
`
|
|
146
|
+
Run the container origin locally without Spectrum. The plaintext TCP
|
|
147
|
+
listener is **debug-only and localhost-only**: it starts solely when
|
|
148
|
+
`DEBUG_PLAINTEXT=localhost` is set (the config loader then forces the
|
|
149
|
+
bind to `127.0.0.1` — `TCP_HOST` cannot widen it). This exercises the
|
|
150
|
+
exact `ConnectionActor` + `TcpByteStreamTransport` pipeline.
|
|
139
151
|
|
|
140
152
|
```bash
|
|
141
153
|
# Build the workspace + the container package.
|
|
142
154
|
pnpm build
|
|
143
155
|
|
|
144
|
-
# Run the origin
|
|
145
|
-
|
|
146
|
-
--
|
|
156
|
+
# Run the origin with the localhost-only plaintext debug listener.
|
|
157
|
+
DEBUG_PLAINTEXT=localhost SERVER_NAME=irc.local TCP_PORT=6667 \
|
|
158
|
+
pnpm --filter @serverless-ircd/cf-tcp-container start
|
|
147
159
|
```
|
|
148
160
|
|
|
149
161
|
Connect a plaintext client (no TLS) to `localhost:6667`:
|
|
@@ -156,12 +168,19 @@ JOIN #test
|
|
|
156
168
|
PRIVMSG #test :hello over plaintext TCP
|
|
157
169
|
```
|
|
158
170
|
|
|
159
|
-
For a TLS
|
|
160
|
-
|
|
171
|
+
For a TLS local loop that mirrors production (TLS terminating *at the
|
|
172
|
+
origin*), point the origin at a local cert pair instead — no debug
|
|
173
|
+
flag needed:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
TLS_CERT_PATH=./certs/tls.pem TLS_KEY_PATH=./certs/tls.key SERVER_NAME=irc.local \
|
|
177
|
+
pnpm --filter @serverless-ircd/cf-tcp-container start
|
|
178
|
+
# → TLS listener on :6697; plaintext stays closed.
|
|
179
|
+
```
|
|
161
180
|
|
|
162
181
|
The unit + the TLS e2e suites live in `apps/cf-tcp-container/tests/`
|
|
163
|
-
(including `tls-e2e.test.ts`, which
|
|
164
|
-
origin). Run them with:
|
|
182
|
+
(including `tls-e2e.test.ts` and `container-server-tls.test.ts`, which
|
|
183
|
+
run real TLS handshakes against the origin). Run them with:
|
|
165
184
|
|
|
166
185
|
```bash
|
|
167
186
|
pnpm --filter @serverless-ircd/cf-tcp-container test
|
|
@@ -189,7 +208,38 @@ and deploys the `sirc-tcp-origin` Container. The first deploy prints
|
|
|
189
208
|
the **origin address** — copy it into `CF_TCP_ORIGIN_ADDRESS` (used by
|
|
190
209
|
the Spectrum step next).
|
|
191
210
|
|
|
192
|
-
### 5.2
|
|
211
|
+
### 5.2 Provision the origin certificate
|
|
212
|
+
|
|
213
|
+
`tls_mode = "full"` means the **origin terminates TLS itself**, so it
|
|
214
|
+
needs a PEM cert/key pair. Mount it at `/data/certs/` (the image's
|
|
215
|
+
documented default: `TLS_CERT_PATH=/data/certs/tls.pem`,
|
|
216
|
+
`TLS_KEY_PATH=/data/certs/tls.key`; override via env if the pair lives
|
|
217
|
+
elsewhere). Cert files must be readable by UID 1000 (`node`) — the
|
|
218
|
+
container drops privileges before reading them. Any of these work:
|
|
219
|
+
|
|
220
|
+
| Option | How | Notes |
|
|
221
|
+
|------------------------|----------------------------------------------------------------|-------|
|
|
222
|
+
| **Cloudflare Origin Cert** | Dashboard → SSL/TLS → Origin Server → Create Certificate. | Free, long-lived, issued for your zone; the easiest option when the origin is only reached via Spectrum. Not publicly trusted (fine — clients validate the *edge* cert). |
|
|
223
|
+
| **Let's Encrypt** | `certbot certonly --dns-plugin … -d origin.example.com`, then copy `fullchain.pem`/`privkey.pem` to `/data/certs/tls.{pem,key}` and renew on a cron/sidecar. | Publicly trusted; needs automation for the 90-day rotation. |
|
|
224
|
+
| **BYO cert** | Any PEM pair from your CA. | Your compliance rules apply. |
|
|
225
|
+
|
|
226
|
+
> A prod-classified boot (`NODE_ENV=production`, set by the Dockerfile)
|
|
227
|
+
> **refuses to start** without `TLS_CERT_PATH`/`TLS_KEY_PATH` — there is
|
|
228
|
+
> no silent plaintext fallback. A missing/unreadable cert file also
|
|
229
|
+
> fails fast at boot with the offending path in the error.
|
|
230
|
+
|
|
231
|
+
### 5.3 Lock origin ingress down to Cloudflare IPs
|
|
232
|
+
|
|
233
|
+
Spectrum is the only thing that should ever reach the origin's :6697.
|
|
234
|
+
On any platform with a firewall / security-group primitive, restrict
|
|
235
|
+
ingress to Cloudflare's published ranges (https://www.cloudflare.com/ips/)
|
|
236
|
+
— otherwise a direct-reach attacker skips the edge entirely.
|
|
237
|
+
`spectrum.tf` ships a commented `cloudflare_list` reference for keeping
|
|
238
|
+
a managed list of the current ranges; Cloudflare Containers origins
|
|
239
|
+
rely on Spectrum `ip_firewall` + the platform's own network isolation,
|
|
240
|
+
but external-compute origins must apply the firewall on their side.
|
|
241
|
+
|
|
242
|
+
### 5.4 Apply the Spectrum TLS config
|
|
193
243
|
|
|
194
244
|
```bash
|
|
195
245
|
cd apps/cf-tcp-container/terraform
|
|
@@ -201,9 +251,10 @@ terraform apply \
|
|
|
201
251
|
```
|
|
202
252
|
|
|
203
253
|
This creates the `cloudflare_spectrum_application.irc_tls` resource:
|
|
204
|
-
TLS on :6697
|
|
254
|
+
TLS on :6697 at the edge, **re-encrypted** to the container origin's
|
|
255
|
+
TLS listener on :6697 (`tls_mode = "full"` — the secure default).
|
|
205
256
|
|
|
206
|
-
### 5.
|
|
257
|
+
### 5.5 Smoke test over TLS
|
|
207
258
|
|
|
208
259
|
```bash
|
|
209
260
|
# Minimal smoke: register + quit over the real TLS endpoint.
|
|
@@ -215,7 +266,7 @@ printf 'NICK smoke\r\nUSER smoke 0 * :Smoke\r\nQUIT :smoke\r\n' | \
|
|
|
215
266
|
CI does the same — see `.github/workflows/deploy-cf-tcp.yml` step
|
|
216
267
|
"Smoke e2e (irc+tls)", gated on the `CF_TCP_SMOKE_HOST` repo variable.
|
|
217
268
|
|
|
218
|
-
### 5.
|
|
269
|
+
### 5.6 Connect a real IRC client
|
|
219
270
|
|
|
220
271
|
```
|
|
221
272
|
/server add ircd irc.example.com/6697
|
|
@@ -240,8 +291,13 @@ Top-level `[vars]` map directly to the container config loader
|
|
|
240
291
|
| `SERVER_VERSION` | Version string in `002`. | (from package version) |
|
|
241
292
|
| `CREATED_AT` | Creation text/time in `003`. | build/deploy timestamp |
|
|
242
293
|
| `MOTD_LINES` | Message-of-the-day, `\n`-delimited. | welcome banner |
|
|
243
|
-
| `
|
|
244
|
-
| `
|
|
294
|
+
| `NODE_ENV` | `production` classifies the boot as prod: the loader then refuses to start without TLS material and rejects `DEBUG_PLAINTEXT`. The Dockerfile sets it. | (unset = dev) |
|
|
295
|
+
| `TLS_CERT_PATH` | PEM certificate the origin TLS listener presents. **Required in prod** (with `TLS_KEY_PATH`). | image: `/data/certs/tls.pem` |
|
|
296
|
+
| `TLS_KEY_PATH` | PEM private key for the origin TLS listener. **Required in prod** (with `TLS_CERT_PATH`). | image: `/data/certs/tls.key` |
|
|
297
|
+
| `TLS_PORT` | Origin TLS listener port. | `6697` |
|
|
298
|
+
| `TCP_PORT` | Plaintext debug-listener port — only used when `DEBUG_PLAINTEXT=localhost` is set. | `6667` |
|
|
299
|
+
| `TCP_HOST` | Bind address. Forced to `127.0.0.1` whenever the plaintext debug listener is enabled. | `0.0.0.0` |
|
|
300
|
+
| `DEBUG_PLAINTEXT` | Exact value `localhost` opts into the localhost-only plaintext debug listener. Any other value is rejected; forbidden in prod. | (unset = TLS-only) |
|
|
245
301
|
| `PERSISTENCE_PATH` | Snapshot file path; enables channel-state survival. | `/data/state.json` |
|
|
246
302
|
| `SNAPSHOT_INTERVAL_MS`| Auto-snapshot cadence (ms). | `60000` |
|
|
247
303
|
| `OPER_USER` / `OPER_PASSWORD` | Oper credentials for `OPER`. | — |
|
|
@@ -273,10 +329,10 @@ sleep_after = "2h"
|
|
|
273
329
|
|-------------------|---------------------------------------------------|---------------------|
|
|
274
330
|
| `zone_id` | Cloudflare zone for the hostname. | *(required)* |
|
|
275
331
|
| `origin_address` | Hostname/IP of the container origin. | *(required)* |
|
|
276
|
-
| `origin_port` | Port the origin
|
|
332
|
+
| `origin_port` | Port the origin's **TLS** listener serves. | `6697` |
|
|
277
333
|
| `hostname` | Public hostname clients connect to. | `irc.example.com` |
|
|
278
334
|
| `spectrum_port` | Public port Spectrum listens on. | `6697` |
|
|
279
|
-
| `tls_mode` | `
|
|
335
|
+
| `tls_mode` | `full` (TLS end-to-end — the secure default) / `strict` (edge also validates the origin cert). | `full` |
|
|
280
336
|
|
|
281
337
|
`ip_firewall = true` (Cloudflare's L3/L4 DDoS) and
|
|
282
338
|
`argo_smart_routing = true` are on by default. `proxy_protocol = "v1"`
|
|
@@ -311,24 +367,39 @@ path (Durable Objects) does not have. Two layers of survival:
|
|
|
311
367
|
|
|
312
368
|
---
|
|
313
369
|
|
|
314
|
-
## 9. mTLS (SASL EXTERNAL)
|
|
370
|
+
## 9. mTLS (SASL EXTERNAL) — not supported on this transport
|
|
371
|
+
|
|
372
|
+
SASL `EXTERNAL` requires the verified client certificate to reach the
|
|
373
|
+
application layer. On the TCP container path it cannot: Spectrum
|
|
374
|
+
forwards to the origin with **PROXY protocol v1**, which carries no
|
|
375
|
+
client-cert subject, and although `tls_mode = "full"` terminates TLS at
|
|
376
|
+
the origin (`TLS_CERT_PATH`/`TLS_KEY_PATH`), no client-certificate
|
|
377
|
+
trust pool is wired through to the IRC core — there is no
|
|
378
|
+
`MtlsIdentityProvider` to feed, in either TLS mode.
|
|
379
|
+
|
|
380
|
+
The container therefore pins the mechanism off explicitly via the
|
|
381
|
+
`sasl.externalUnsupportedMessage` transport override:
|
|
315
382
|
|
|
316
|
-
|
|
317
|
-
|
|
383
|
+
- the `sasl` capability advertises `PLAIN` only, and
|
|
384
|
+
- a client sending `AUTHENTICATE EXTERNAL` is rejected up front with
|
|
385
|
+
`904 ERR_SASLFAIL` carrying the stable message
|
|
386
|
+
`SASL EXTERNAL not supported on this transport`.
|
|
318
387
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
origin, feeding the `MtlsIdentityProvider` port → `AccountStore` →
|
|
327
|
-
`AUTHENTICATE EXTERNAL` succeeds (`903`) or fails (`904`).
|
|
388
|
+
The refusal does not count toward the per-connection SASL failure
|
|
389
|
+
lockout. Clients that need certificate-backed SASL should use the wss
|
|
390
|
+
path (CF API Shield mTLS — see `Cloudflare-Deployment-Guide.md`) or the
|
|
391
|
+
AWS NLB+TLS path (API Gateway custom-domain mTLS — see
|
|
392
|
+
`AWS-TCP-Deployment.md`). Forwarding the client-cert subject via
|
|
393
|
+
PROXY-protocol-v2 TLVs (which Spectrum can emit) is the documented
|
|
394
|
+
follow-up if demand materialises; see `SASL-EXTERNAL.md`.
|
|
328
395
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
396
|
+
- **Flexible mode (explicit downgrade):** Spectrum terminates TLS and
|
|
397
|
+
forwards plaintext TCP to the origin. EXTERNAL is equally unavailable
|
|
398
|
+
there, and plaintext origin traffic reintroduces the direct-reach
|
|
399
|
+
exposure this topology closed. Avoid it.
|
|
400
|
+
|
|
401
|
+
Cross-reference: ADR-006 (SASL scope), `SASL-EXTERNAL.md` (mechanism
|
|
402
|
+
scope + transport availability).
|
|
332
403
|
|
|
333
404
|
---
|
|
334
405
|
|
|
@@ -389,7 +460,7 @@ because of the stateful origin:
|
|
|
389
460
|
|------------------------|------------------------------------|--------------------------------------------|
|
|
390
461
|
| **Plan tier** | Workers Paid ($5/mo) suffices. | **Enterprise required** (Spectrum). |
|
|
391
462
|
| **Idle connections** | Hibernation → ~zero memory cost. | Origin holds every socket; `sleep_after` only helps when *all* connections are idle. |
|
|
392
|
-
| **Compute** | Per-request (WebSocket frames). | **GB-second** of running container time.
|
|
463
|
+
| **Compute** | Per-request (WebSocket frames). | **GB-second** of running container time. `tls_mode = "full"` moves TLS termination into the origin container, so it now pays the per-connection handshake CPU — budget handshakes/sec (each reconnect = one handshake) against the container's compute allocation when sizing `instance_type`. |
|
|
393
464
|
| **State durability** | DO storage (managed). | Container volume snapshot (self-managed). |
|
|
394
465
|
| **DDoS** | Cloudflare built-in. | Spectrum `ip_firewall` + Argo. |
|
|
395
466
|
|
|
@@ -417,10 +488,19 @@ respond. Causes:
|
|
|
417
488
|
- `CF_TCP_ORIGIN_ADDRESS` points at the wrong origin. It must match the
|
|
418
489
|
address printed by `wrangler deploy` (§5.1).
|
|
419
490
|
- The origin crashed on boot. Check `wrangler tail` for the container
|
|
420
|
-
(or container logs in the dashboard).
|
|
421
|
-
|
|
491
|
+
(or container logs in the dashboard). Common causes: the cert pair is
|
|
492
|
+
missing/unreadable at `TLS_CERT_PATH`/`TLS_KEY_PATH` (prod boots
|
|
493
|
+
refuse to start without it — the error names the offending path), an
|
|
494
|
+
invalid `MOTD_LINES`, or a malformed `SASL_ACCOUNTS` line.
|
|
495
|
+
|
|
496
|
+
### 13.3 Origin refuses to boot: "refusing to start a prod-classified origin without TLS"
|
|
497
|
+
|
|
498
|
+
`NODE_ENV=production` is set (the Dockerfile sets it by default) but
|
|
499
|
+
`TLS_CERT_PATH`/`TLS_KEY_PATH` are not. Provision a cert pair (§5.2),
|
|
500
|
+
mount it at `/data/certs/`, and restart. There is deliberately no
|
|
501
|
+
plaintext fallback in prod.
|
|
422
502
|
|
|
423
|
-
### 13.
|
|
503
|
+
### 13.4 Channel topic/modes lost after a restart
|
|
424
504
|
|
|
425
505
|
`PERSISTENCE_PATH` is unset or the volume is not attached. Confirm
|
|
426
506
|
`PERSISTENCE_PATH=/data/state.json` in `[vars]` and that the `/data`
|
|
@@ -428,13 +508,13 @@ volume (`VOLUME ["/data"]` in the `Dockerfile`) is mounted on the
|
|
|
428
508
|
container platform. Without it the snapshot is written to ephemeral
|
|
429
509
|
storage and lost.
|
|
430
510
|
|
|
431
|
-
### 13.
|
|
511
|
+
### 13.5 Client IP seen as Spectrum's address
|
|
432
512
|
|
|
433
513
|
`proxy_protocol = "v1"` must be set in `spectrum.tf` (it is by default).
|
|
434
514
|
If you overrode it, the origin's per-IP admission gate sees Spectrum's
|
|
435
515
|
egress IP for every client and will wrongly throttle/cap.
|
|
436
516
|
|
|
437
|
-
### 13.
|
|
517
|
+
### 13.6 Messages split across chunks get lost
|
|
438
518
|
|
|
439
519
|
This is a transport-layer bug, not a config issue — the
|
|
440
520
|
`TcpByteStreamTransport` is contract-tested for chunk boundaries
|
|
@@ -442,7 +522,7 @@ This is a transport-layer bug, not a config issue — the
|
|
|
442
522
|
`pnpm --filter @serverless-ircd/cf-tcp-container test` and the shared
|
|
443
523
|
transport suite; a regression there will fail.
|
|
444
524
|
|
|
445
|
-
### 13.
|
|
525
|
+
### 13.7 `wrangler deploy` fails: `Containers beta not enabled`
|
|
446
526
|
|
|
447
527
|
Cloudflare Containers is in beta. Request access in the dashboard
|
|
448
528
|
(Workers & Pages → Containers). Until granted, only the local Docker
|
|
@@ -457,16 +537,19 @@ path (§4) and the wss transport work.
|
|
|
457
537
|
npx wrangler login # or set CLOUDFLARE_API_TOKEN
|
|
458
538
|
terraform version # >= 1.5
|
|
459
539
|
|
|
460
|
-
# Local dev (
|
|
540
|
+
# Local dev (TLS origin on :6697 with a local cert pair)
|
|
461
541
|
pnpm build
|
|
462
|
-
|
|
542
|
+
TLS_CERT_PATH=./certs/tls.pem TLS_KEY_PATH=./certs/tls.key \
|
|
543
|
+
pnpm --filter @serverless-ircd/cf-tcp-container start
|
|
544
|
+
# …or the localhost-only plaintext debug listener:
|
|
545
|
+
DEBUG_PLAINTEXT=localhost pnpm --filter @serverless-ircd/cf-tcp-container start
|
|
463
546
|
|
|
464
547
|
# Deploy (single target — staging vs prod is which account you point at)
|
|
465
548
|
pnpm deploy:cf-tcp # container
|
|
466
549
|
cd apps/cf-tcp-container/terraform && \
|
|
467
550
|
terraform apply -var zone_id=$CF_ZONE_ID \
|
|
468
551
|
-var origin_address=$CF_TCP_ORIGIN_ADDRESS \
|
|
469
|
-
-var hostname=irc.example.com # Spectrum
|
|
552
|
+
-var hostname=irc.example.com # Spectrum (tls_mode=full)
|
|
470
553
|
|
|
471
554
|
# Tests
|
|
472
555
|
pnpm --filter @serverless-ircd/cf-tcp-container test
|
|
@@ -477,10 +560,10 @@ Key files:
|
|
|
477
560
|
| Path | What |
|
|
478
561
|
|-------------------------------------------------|--------------------------------------------|
|
|
479
562
|
| `apps/cf-tcp-container/src/main.ts` | Container entry point. |
|
|
480
|
-
| `apps/cf-tcp-container/src/container-server.ts` | TCP
|
|
481
|
-
| `apps/cf-tcp-container/src/config-loader.ts` | Env → `ServerConfig` + container knobs.
|
|
563
|
+
| `apps/cf-tcp-container/src/container-server.ts` | TCP/TLS listeners + actor wiring + persistence. |
|
|
564
|
+
| `apps/cf-tcp-container/src/config-loader.ts` | Env → `ServerConfig` + container knobs (TLS + plaintext-debug gates). |
|
|
482
565
|
| `apps/cf-tcp-container/src/persistence.ts` | Channel-topology snapshot/restore. |
|
|
483
566
|
| `apps/cf-tcp-container/wrangler.toml` | Container deploy config. |
|
|
484
|
-
| `apps/cf-tcp-container/Dockerfile` | Image (node:24, `/data` volume, :
|
|
485
|
-
| `apps/cf-tcp-container/terraform/spectrum.tf` | Spectrum TLS :6697 → origin :
|
|
567
|
+
| `apps/cf-tcp-container/Dockerfile` | Image (node:24, `/data` volume, :6697, UID 1000). |
|
|
568
|
+
| `apps/cf-tcp-container/terraform/spectrum.tf` | Spectrum TLS :6697 → origin TLS :6697 (`full`). |
|
|
486
569
|
| `.github/workflows/deploy-cf-tcp.yml` | Staging deploy + Spectrum + smoke CI. |
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# SASL EXTERNAL (CertFP) — certificate-to-account binding
|
|
2
|
+
|
|
3
|
+
Operator and contributor reference for how `AUTHENTICATE EXTERNAL`
|
|
4
|
+
(ServerlessIRCd's mTLS-backed SASL mechanism) turns a verified client
|
|
5
|
+
certificate into a logged-in NickServ account.
|
|
6
|
+
|
|
7
|
+
Cross-reference: the `MtlsIdentityProvider` port and
|
|
8
|
+
`ServicesStore.verifyCertFP` in `packages/irc-core/src/ports.ts`, the
|
|
9
|
+
canonicaliser in `packages/irc-core/src/certfp.ts`, the SASL EXTERNAL
|
|
10
|
+
reducer in `packages/irc-core/src/commands/sasl.ts`, and
|
|
11
|
+
`extractMtlsSubject` in `packages/cf-adapter/src/connection-do.ts`.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. The chosen identifier
|
|
16
|
+
|
|
17
|
+
Account binding uses, in preference order:
|
|
18
|
+
|
|
19
|
+
1. **DER SHA-256 fingerprint (primary).** When the platform surfaces a
|
|
20
|
+
SHA-256 fingerprint of the certificate's DER encoding, it is the
|
|
21
|
+
account binding. Stored as `fp:<hex>` (lowercase, colon-free) in the
|
|
22
|
+
nick row's `certSubjects` list. A fingerprint binds **this exact
|
|
23
|
+
certificate** — a CA re-issuing a different cert with the same subject
|
|
24
|
+
does NOT map to the account, and two semantically-identical certs with
|
|
25
|
+
different DER do not collide.
|
|
26
|
+
2. **Canonical DN (fallback).** On platforms that surface only the
|
|
27
|
+
certificate subject DN, the DN is canonicalised (see §3) and compared
|
|
28
|
+
as the binding. Subject-bound accounts accept any certificate whose
|
|
29
|
+
canonical DN matches — the Atheme-style `CertFP` behaviour.
|
|
30
|
+
|
|
31
|
+
### Platform availability (verified)
|
|
32
|
+
|
|
33
|
+
| Platform | Fields surfaced | Identifier used |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| Cloudflare Workers (API Shield mTLS) | `request.cf.tlsClientAuthCertSubject` (legacy documented field) and `request.cf.tlsClientAuth.certSubjectDN`, **plus** `request.cf.tlsClientAuth.certFingerprintSHA256` (lowercase-hex SHA-256 of the DER; `""` on non-mTLS connections) | **Fingerprint** when non-empty, canonical DN otherwise |
|
|
36
|
+
| AWS API Gateway (custom-domain mTLS) | `requestContext.identity.clientCertSubjectDN` only — no fingerprint, no DER | **Canonical DN** |
|
|
37
|
+
|
|
38
|
+
Neither platform hands the raw DER to the WebSocket handler directly
|
|
39
|
+
(Cloudflare does expose the leaf cert RFC 9440-encoded under
|
|
40
|
+
`cf.tlsClientAuth.certRFC9440`, but the platform-computed
|
|
41
|
+
`certFingerprintSHA256` is strictly simpler and equivalent for binding,
|
|
42
|
+
and is preferred). The fingerprint therefore never needs to be computed
|
|
43
|
+
in-process; `certfp.ts` only normalises the platform-supplied hex
|
|
44
|
+
(lowercase, colon-stripping) so `AA:11` and `aa11` are the same binding.
|
|
45
|
+
|
|
46
|
+
## 2. Flow
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
TLS handshake (edge terminates, validates client cert against CA store)
|
|
50
|
+
└─ adapter admission: extractMtlsSubject(request) → CertIdentity
|
|
51
|
+
{ subject: raw string,
|
|
52
|
+
canonicalDn: canonicalizeCertSubject(subject),
|
|
53
|
+
fingerprint?: platform SHA-256 hex }
|
|
54
|
+
└─ stored per-connection, surfaced via MtlsIdentityProvider.getIdentity(connId)
|
|
55
|
+
client: CAP REQ sasl → AUTHENTICATE EXTERNAL → AUTHENTICATE +
|
|
56
|
+
└─ sasl reducer: identity = provider.getIdentity(connId)
|
|
57
|
+
└─ ServicesStore.verifyCertFP(identity):
|
|
58
|
+
arm 1: fp:<hex> byte-exact match against certSubjects (primary)
|
|
59
|
+
arm 2: canonical-DN match against DN entries (fallback)
|
|
60
|
+
└─ 900 RPL_LOGGEDIN + 903 RPL_SASLSUCCESS, or 904 ERR_SASLFAIL
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 2a. Operator opt-in — the three-way gate
|
|
64
|
+
|
|
65
|
+
EXTERNAL is only advertised (in the `sasl` cap value and the `908
|
|
66
|
+
ERR_SASLMECHS` list) and only accepted when **all three** of these hold:
|
|
67
|
+
|
|
68
|
+
1. **An mTLS identity source is bound** for the deployment (CF API Shield
|
|
69
|
+
client certs / AWS APIGW custom-domain mTLS — the
|
|
70
|
+
`MtlsIdentityProvider` port returns an identity for the connection).
|
|
71
|
+
2. **The operator opted in**: set `EXTERNAL_ENABLED=true` (or `1`) in the
|
|
72
|
+
CF Worker vars / Lambda environment. The adapters map it to
|
|
73
|
+
`sasl.externalEnabled` in the parsed server config; unset or any other
|
|
74
|
+
value keeps the mechanism off (a typo never silently enables it).
|
|
75
|
+
3. **The connection is secure (TLS)** — the client-cert identity only
|
|
76
|
+
carries meaning over a transport the server trusts (the NLB+TLS
|
|
77
|
+
secure-connection flag on the AWS path, the always-TLS edge on CF).
|
|
78
|
+
|
|
79
|
+
If any one of the three is missing, `AUTHENTICATE EXTERNAL` is refused
|
|
80
|
+
with `908 ERR_SASLMECHS` listing `PLAIN` only, and the refusal does not
|
|
81
|
+
count toward the per-connection SASL failure lockout (it is a
|
|
82
|
+
protocol-level mechanism rejection, not a failed credential attempt).
|
|
83
|
+
|
|
84
|
+
### Transport availability — the cf-tcp-container exclusion
|
|
85
|
+
|
|
86
|
+
The three-way gate is the *generic* refusal path. One transport
|
|
87
|
+
additionally pins the mechanism off explicitly: the **cf-tcp-container**
|
|
88
|
+
origin (Cloudflare Spectrum → Container, the Cloudflare
|
|
89
|
+
`irc+tls :6697` path). Spectrum forwards with PROXY protocol v1, which
|
|
90
|
+
carries no client-cert subject, and although `tls_mode = "full"`
|
|
91
|
+
terminates TLS at the origin itself, no client-certificate trust pool
|
|
92
|
+
is wired through to the IRC core — there is no `MtlsIdentityProvider`
|
|
93
|
+
to feed, so EXTERNAL can never succeed there regardless of the Spectrum
|
|
94
|
+
TLS mode. Decision (documented, option *explicit non-support*): rather
|
|
95
|
+
than letting `AUTHENTICATE EXTERNAL` fail generically at the gate, the
|
|
96
|
+
container sets the `sasl.externalUnsupportedMessage` transport
|
|
97
|
+
override, so:
|
|
98
|
+
|
|
99
|
+
- the `sasl` cap value and the `908 ERR_SASLMECHS` list advertise
|
|
100
|
+
`PLAIN` only, and
|
|
101
|
+
- `AUTHENTICATE EXTERNAL` is rejected up front with `904 ERR_SASLFAIL`
|
|
102
|
+
carrying the stable message
|
|
103
|
+
`SASL EXTERNAL not supported on this transport`.
|
|
104
|
+
|
|
105
|
+
The refusal does not count toward the per-connection SASL failure
|
|
106
|
+
lockout (a transport capability statement, not a failed credential
|
|
107
|
+
attempt). The override wins even if `EXTERNAL_ENABLED` is set — a
|
|
108
|
+
transport without an mTLS identity source must not half-accept the
|
|
109
|
+
exchange. Extracting the client-cert subject from PROXY-protocol-v2
|
|
110
|
+
TLVs (which Spectrum can emit) is the documented follow-up if demand
|
|
111
|
+
materialises; until then SASL EXTERNAL is available on the **wss**
|
|
112
|
+
(Cloudflare Workers + API Shield mTLS) and **NLB+TLS** (AWS API Gateway
|
|
113
|
+
custom-domain mTLS) paths only.
|
|
114
|
+
|
|
115
|
+
## 3. DN canonicalisation (RFC 4514-grounded)
|
|
116
|
+
|
|
117
|
+
Subject DNs are compared after canonicalisation
|
|
118
|
+
(`canonicalizeCertSubject` in `packages/irc-core/src/certfp.ts`):
|
|
119
|
+
|
|
120
|
+
1. Attribute **types** are lowercased (`CN=` → `cn=`); attribute
|
|
121
|
+
**values** keep their case and compare **case-sensitively**.
|
|
122
|
+
2. Whitespace around types/values is trimmed; runs of unescaped
|
|
123
|
+
whitespace inside a value collapse to a single space. Escaped
|
|
124
|
+
whitespace (e.g. a trailing `\ `) is preserved verbatim.
|
|
125
|
+
3. RDNs (`a,b`) and the AVAs of a multi-valued RDN (`a+b`) are sorted, so
|
|
126
|
+
two semantically-equal DNs written in different orders or spacing
|
|
127
|
+
collapse to one canonical spelling:
|
|
128
|
+
`CN=alice , O = Example Inc` ≡ `O=Example Inc,CN=alice`.
|
|
129
|
+
4. RFC 4514 escapes (`\,`, `\+`, `\=` …) pass through intact — the
|
|
130
|
+
canonicaliser never splits on an escaped separator.
|
|
131
|
+
|
|
132
|
+
Two certs whose DNs differ only in spacing/ordering therefore map to the
|
|
133
|
+
same account; DNs that differ in **value case** do not.
|
|
134
|
+
|
|
135
|
+
## 4. First-use migration of legacy bindings
|
|
136
|
+
|
|
137
|
+
Accounts created before this scheme stored the platform subject string
|
|
138
|
+
verbatim. `verifyCertFP` handles them transparently:
|
|
139
|
+
|
|
140
|
+
- On a canonical-DN arm match, a stored legacy spelling is **rewritten**
|
|
141
|
+
to its canonical form in the nick row.
|
|
142
|
+
- When the platform supplied a fingerprint, the `fp:<hex>` binding is
|
|
143
|
+
**pinned** alongside the DN entry, so subsequent verifies hit the
|
|
144
|
+
fingerprint arm.
|
|
145
|
+
- The `PersistentServicesStore` subclass re-persists the nick row on
|
|
146
|
+
both rewrites (write-behind `upsertNick`), so the migration survives a
|
|
147
|
+
backend reload. No operator action is required; there is no separate
|
|
148
|
+
migration script.
|
|
149
|
+
|
|
150
|
+
## 5. Attaching bindings
|
|
151
|
+
|
|
152
|
+
- `ServicesStore.addCertFingerprint(nick, fp)` — attach a fingerprint
|
|
153
|
+
binding (normalised to `fp:<hex>`; idempotent).
|
|
154
|
+
- `ServicesStore.addCertFP(nick, dn)` — attach a subject-DN binding
|
|
155
|
+
(canonicalised at verify time; idempotent).
|
|
156
|
+
- **These are the binding seam.** Both are programmatic store APIs
|
|
157
|
+
today — persistence is the `certSubjects` JSON column on the D1
|
|
158
|
+
`nickserv_accounts` row (added by the store's idempotent
|
|
159
|
+
`ALTER TABLE ... ADD COLUMN cert_subjects` runtime migration) and the
|
|
160
|
+
`certSubjects` list attribute on the DynamoDB `Services` item
|
|
161
|
+
(schemaless; no table migration needed). The NickServ `CERT` command
|
|
162
|
+
(Atheme parity) that surfaces them to users is the documented
|
|
163
|
+
follow-up: it will call exactly these two mutators, nothing else.
|
|
164
|
+
|
|
165
|
+
## 6. Failure modes
|
|
166
|
+
|
|
167
|
+
| Condition | Result |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| `EXTERNAL_ENABLED` unset/false (default) | EXTERNAL refused at mechanism phase (`908`, `PLAIN` only) — flag off means off |
|
|
170
|
+
| Insecure (non-TLS) connection | EXTERNAL refused at mechanism phase (`908`) even with mTLS + flag on |
|
|
171
|
+
| No mTLS configured (no `MtlsIdentityProvider` bound) | EXTERNAL refused at mechanism phase (`908`) |
|
|
172
|
+
| cf-tcp-container transport (`sasl.externalUnsupportedMessage` override) | up-front `904 ERR_SASLFAIL` — "SASL EXTERNAL not supported on this transport" (not counted toward the lockout) |
|
|
173
|
+
| Cert presented but no account carries a matching fingerprint or canonical DN | `904 ERR_SASLFAIL` ("untrusted certificate") |
|
|
174
|
+
| Fingerprint bound to account A, DN bound to account B | fingerprint arm wins — the verify returns account A |
|
|
175
|
+
| Platform surfaces no fingerprint (API Gateway) | DN arm only; binding is DN-scoped |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "serverless-ircd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"typescript": "^5.9.3",
|
|
18
18
|
"vite": "^7.3.6",
|
|
19
19
|
"vitest": "^4.1.10",
|
|
20
|
-
"@serverless-ircd/aws-adapter": "0.
|
|
20
|
+
"@serverless-ircd/aws-adapter": "0.11.0"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"build": "turbo run build",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"mutation:commands": "pnpm --filter @serverless-ircd/irc-core mutation:commands",
|
|
33
33
|
"mutation": "pnpm run mutation:protocol && pnpm run mutation:commands",
|
|
34
34
|
"clean": "turbo run clean && rimraf node_modules",
|
|
35
|
-
"deploy:cf": "pnpm --filter @serverless-ircd/cf-worker run deploy",
|
|
35
|
+
"deploy:cf": "pnpm build && pnpm --filter @serverless-ircd/cf-worker run deploy",
|
|
36
36
|
"smoke:cf": "pnpm --filter @serverless-ircd/cf-worker run smoke",
|
|
37
37
|
"deploy:cf-tcp": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container run deploy",
|
|
38
38
|
"deploy:aws": "pnpm build && pnpm --filter @serverless-ircd/aws-stack run deploy",
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure admission policy for the AWS `$connect` handler.
|
|
3
3
|
*
|
|
4
|
-
* The I/O (counting live rows in DynamoDB) lives in `handlers/connect.ts
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The I/O (counting live rows in DynamoDB) lives in `handlers/connect.ts`
|
|
5
|
+
* (`ip-admission.ts` for the per-IP walk, `connection-counter.ts` for the
|
|
6
|
+
* global counter); the *decision* — "do these counts fit under the caps?"
|
|
7
|
+
* — is a pure function extracted here so it is trivial to unit-test every
|
|
8
|
+
* branch without standing up DynamoDB Local.
|
|
8
9
|
*
|
|
9
10
|
* This is deliberately separate from irc-core's `decideAdmission` /
|
|
10
11
|
* `AdmissionStats`: that helper is an in-memory counter design that
|
|
@@ -17,25 +18,30 @@
|
|
|
17
18
|
/**
|
|
18
19
|
* Live connection counts gathered from DynamoDB.
|
|
19
20
|
*
|
|
20
|
-
* - `total` is always supplied (
|
|
21
|
+
* - `total` is always supplied (the atomic meta-row counter over all
|
|
21
22
|
* `Connections`).
|
|
22
|
-
* - `perIp`
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* - `perIp` / `recentPerIp` are supplied when the connecting client's
|
|
24
|
+
* source IP is known: `perIp` counts the IP's live connections (the
|
|
25
|
+
* `sourceIp` GSI) and `recentPerIp` counts those established inside
|
|
26
|
+
* the `perIpConnectionRate` window. Callers omit both when the
|
|
27
|
+
* event carries no source IP; the per-IP branches are then skipped.
|
|
26
28
|
*/
|
|
27
29
|
export interface ConnectionCounts {
|
|
28
30
|
readonly total: number;
|
|
29
31
|
readonly perIp?: number;
|
|
32
|
+
readonly recentPerIp?: number;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/**
|
|
33
|
-
* Effective caps sourced from {@link ParsedServerConfig}.
|
|
34
|
-
* optional because
|
|
36
|
+
* Effective caps sourced from {@link ParsedServerConfig}. The per-IP
|
|
37
|
+
* caps are optional because they only bind when the corresponding count
|
|
38
|
+
* is also available (see {@link ConnectionCounts}).
|
|
35
39
|
*/
|
|
36
40
|
export interface AdmissionLimits {
|
|
37
41
|
readonly maxClients: number;
|
|
38
42
|
readonly maxConnectionsPerIp?: number;
|
|
43
|
+
/** Per-IP connection-rate budget (`perIpConnectionRate`). */
|
|
44
|
+
readonly perIpRate?: { readonly max: number; readonly windowMs: number };
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
/**
|
|
@@ -53,8 +59,10 @@ export type AdmissionOutcome =
|
|
|
53
59
|
* ambient state.
|
|
54
60
|
*
|
|
55
61
|
* Precedence: the global `maxClients` cap is checked first (it is the
|
|
56
|
-
* cheap, always-available gate), then the optional per-IP
|
|
57
|
-
* the
|
|
62
|
+
* cheap, always-available gate), then the optional per-IP simultaneous
|
|
63
|
+
* cap, then the optional per-IP rate window — matching the per-cap
|
|
64
|
+
* ordering of irc-core's single-process `decideAdmission` (global first
|
|
65
|
+
* here because its count is O(1)-available, unlike there).
|
|
58
66
|
*/
|
|
59
67
|
export function decideConnectAdmission(
|
|
60
68
|
counts: ConnectionCounts,
|
|
@@ -70,5 +78,12 @@ export function decideConnectAdmission(
|
|
|
70
78
|
) {
|
|
71
79
|
return { admitted: false, statusCode: 429, reason: 'too many connections from this IP' };
|
|
72
80
|
}
|
|
81
|
+
if (
|
|
82
|
+
limits.perIpRate !== undefined &&
|
|
83
|
+
counts.recentPerIp !== undefined &&
|
|
84
|
+
counts.recentPerIp >= limits.perIpRate.max
|
|
85
|
+
) {
|
|
86
|
+
return { admitted: false, statusCode: 429, reason: 'connection rate exceeded for this IP' };
|
|
87
|
+
}
|
|
73
88
|
return { admitted: true };
|
|
74
89
|
}
|