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.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- 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 +18 -14
- package/apps/cf-worker/package.json +3 -4
- 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 +95 -77
- 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 +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- 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 +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- 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-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- 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 +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -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/account-migration.ts +140 -0
- 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/account-auth.ts +16 -19
- 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/memoserv.ts +1 -1
- 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 +161 -11
- 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 +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- 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 +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- 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 +9 -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 +529 -190
- 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 +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- 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/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- 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/message-store.test.ts +5 -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 +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- 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 +563 -54
- 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/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- 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 +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -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-aws-oidc.test.ts +96 -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/tests/validate.test.ts +42 -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/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -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/docs/Services.md
CHANGED
|
@@ -46,8 +46,9 @@ ServerlessIRCd chose **integrated services**. The rationale (full detail in
|
|
|
46
46
|
- The pure-reducer + location-of-authority architecture already has the
|
|
47
47
|
right seam: a `ServicesStore` port absorbs the side effects, and reducers
|
|
48
48
|
stay pure and unit-testable.
|
|
49
|
-
- One storage layer is
|
|
50
|
-
|
|
49
|
+
- One storage layer (`ServicesStore`) is the **single credential home**
|
|
50
|
+
for SASL PLAIN, SASL EXTERNAL, PASS-auth, and NickServ IDENTIFY —
|
|
51
|
+
authentication and nick ownership compose, not duplicate.
|
|
51
52
|
- The serverless substrates (Cloudflare Durable Objects, AWS DynamoDB)
|
|
52
53
|
handle persistence natively; a separate long-running services process
|
|
53
54
|
would defeat the deployment model.
|
|
@@ -90,6 +91,12 @@ If no store is bound, every services command replies:
|
|
|
90
91
|
milliseconds, NickServ waits before force-disconnecting a `ghost`-enforced
|
|
91
92
|
client. `0` makes `ghost` behave like `kill` (immediate). Omit for the
|
|
92
93
|
**30 000 ms** default (`DEFAULT_NICK_ENFORCE_GRACE_MS`).
|
|
94
|
+
- **`nickServ.minPasswordLength`** (`integer > 0`, optional) — minimum
|
|
95
|
+
length enforced by NickServ `SET PASSWORD` for the new password. Omit for
|
|
96
|
+
the **8** default (`DEFAULT_MIN_PASSWORD_LENGTH`); a shorter new password
|
|
97
|
+
is rejected before hashing with
|
|
98
|
+
`Password is too short (minimum <n> characters).`. The maximum (256) is
|
|
99
|
+
fixed, not configurable, to bound the scrypt input budget.
|
|
93
100
|
|
|
94
101
|
---
|
|
95
102
|
|
|
@@ -176,6 +183,7 @@ policy, and nick-info queries. A registered nick is an account.
|
|
|
176
183
|
| `DROP` | `PRIVMSG NickServ :DROP [nick]` | Requires prior identify. Drops the registration, clears `state.account` and `+r`, fans `ACCOUNT *`. Reply: `Nickname <nick> has been dropped.` |
|
|
177
184
|
| `INFO` | `PRIVMSG NickServ :INFO [nick]` | Defaults to the current nick. Replies (NOTICE): `Nick:`, `Account:`, and (only if the caller is identified as the owning account **or** is an oper) `Email:`. A non-owner sees `Nick:` / `Account:` only. Unregistered → `Nick <target> is not registered.` |
|
|
178
185
|
| `SET ENFORCE` | `PRIVMSG NickServ :SET ENFORCE none|ghost|kill` | Requires identify as the owning account. See enforcement below. |
|
|
186
|
+
| `SET PASSWORD` | `PRIVMSG NickServ :SET PASSWORD <old-password> <new-password>` | Requires identify as the owning account **and** re-supplies the current password (defence against a hijacked `+r` session). Re-hashes the new password (scrypt) and overwrites the credential. New-password length floor: `nickServ.minPasswordLength` (default **8**); fixed ceiling **256**. Reply: `Password changed.` Wrong old password → `Invalid password.` (same text as a failed `IDENTIFY`; no leak). The current session stays `+r`; **other sessions of the same account are NOT kicked.** |
|
|
179
187
|
|
|
180
188
|
Help / unknown command: `Available commands: REGISTER, IDENTIFY, DROP, INFO, SET`.
|
|
181
189
|
|
|
@@ -431,27 +439,24 @@ Both accept `--accounts alice:s3cret bob:pw …` or `--file accounts.txt`
|
|
|
431
439
|
```
|
|
432
440
|
# Cloudflare
|
|
433
441
|
node --import tsx tools/seed-cf-accounts.ts \
|
|
434
|
-
--database serverless-ircd-accounts
|
|
442
|
+
--database serverless-ircd-accounts --remote \
|
|
435
443
|
--accounts alice:s3cret
|
|
436
444
|
|
|
437
|
-
# AWS
|
|
445
|
+
# AWS (the --table is the Services table, not the legacy Accounts table)
|
|
438
446
|
node --import tsx tools/seed-aws-accounts.ts \
|
|
439
|
-
--table
|
|
447
|
+
--table StagingServices --endpoint http://localhost:8000 \
|
|
440
448
|
--accounts alice:s3cret
|
|
441
449
|
```
|
|
442
450
|
|
|
443
|
-
> **NickServ accounts vs SASL accounts.**
|
|
444
|
-
> the **`ServicesStore`** (`nickserv_accounts`)
|
|
445
|
-
>
|
|
446
|
-
>
|
|
447
|
-
>
|
|
448
|
-
>
|
|
449
|
-
>
|
|
450
|
-
>
|
|
451
|
-
>
|
|
452
|
-
> (see [§11](#11-security-characteristics--known-gaps)), bulk-seeded rows
|
|
453
|
-
> must supply a scrypt `algorithm`/`salt`/`hash` triple rather than a
|
|
454
|
-
> plaintext password.
|
|
451
|
+
> **NickServ accounts vs SASL accounts — unified.** Both SASL PLAIN and
|
|
452
|
+
> NickServ IDENTIFY consult the **`ServicesStore`** (`nickserv_accounts`).
|
|
453
|
+
> There is no separate SASL credential table: a NickServ registration is a
|
|
454
|
+
> SASL account and vice versa, both scrypt-hashed (see the
|
|
455
|
+
> [walkthrough](#13-end-to-end-walkthrough)). The recommended way to create
|
|
456
|
+
> a NickServ account is the live `PRIVMSG NickServ :REGISTER …` flow over
|
|
457
|
+
> an established connection. The seed scripts above populate
|
|
458
|
+
> `nickserv_accounts` directly via the same scrypt-hashed `registerNick`
|
|
459
|
+
> path; the legacy standalone `accounts` / `Accounts` table was dropped.
|
|
455
460
|
|
|
456
461
|
Operators can manage AKILL/JUPE entries via `PRIVMSG OperServ` (oper-only,
|
|
457
462
|
see [§7](#7-operserv--network-bans-jupes-raw-oper-only)), or pre-populate
|
|
@@ -464,13 +469,13 @@ them by writing directly into the `ServicesStore` backing tables.
|
|
|
464
469
|
Be aware of the following when operating or contributing to services:
|
|
465
470
|
|
|
466
471
|
- **NickServ passwords are stored scrypt-hashed** in the `ServicesStore`
|
|
467
|
-
(
|
|
468
|
-
`InMemoryServicesStore.registerNick` hashes via `hashAccountCredential`
|
|
472
|
+
(`InMemoryServicesStore.registerNick` hashes via `hashAccountCredential`
|
|
469
473
|
before persisting; `verifyNick` re-derives via `verifyHashedPassword`;
|
|
470
474
|
the D1 `nickserv_accounts` table stores `algorithm`/`salt`/`hash`
|
|
471
|
-
columns, never the plaintext password).
|
|
472
|
-
|
|
473
|
-
|
|
475
|
+
columns, never the plaintext password). The `ServicesStore` is the
|
|
476
|
+
**single credential home**: SASL PLAIN, SASL EXTERNAL, PASS-auth
|
|
477
|
+
(`PASS <nick>:<password>`), and NickServ `IDENTIFY` all verify through
|
|
478
|
+
the same `verifyNick` / `verifyCertFP` surface. Treat the services backing store (D1 database / DynamoDB table) as
|
|
474
479
|
sensitive regardless: restrict access and enable at-rest encryption
|
|
475
480
|
(DynamoDB encryption-at-rest is on by default).
|
|
476
481
|
- **HostServ `REQUEST` approval mode is configurable** — the default
|
|
@@ -576,3 +581,45 @@ When `bob` later identifies and joins, ChanServ auto-ops them:
|
|
|
576
581
|
|
|
577
582
|
That completes the core nick + channel registration flow. From here, the
|
|
578
583
|
per-service references in §4–§8 cover every shipped command.
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## 14. Unified credential verification — four entry points
|
|
588
|
+
|
|
589
|
+
The `ServicesStore` is the **single credential home**. Every
|
|
590
|
+
account-setting path reduces to "this connection is now account X" via
|
|
591
|
+
one of four entry points, all of which consult the same scrypt-hashed
|
|
592
|
+
credential on the `nickserv_accounts` row:
|
|
593
|
+
|
|
594
|
+
| Entry point | Mechanism | Credential verify call | Reducer |
|
|
595
|
+
|-------------|-----------|------------------------|---------|
|
|
596
|
+
| **SASL PLAIN** | `AUTHENTICATE PLAIN` (IRCv3 `sasl`) | `services.verifyNick(username, password)` | `commands/sasl.ts` |
|
|
597
|
+
| **SASL EXTERNAL** (CertFP) | `AUTHENTICATE EXTERNAL` (IRCv3 `sasl` + mTLS) | `services.verifyCertFP(certSubject)` (subject resolved by the unchanged `MtlsIdentityProvider`) | `commands/sasl.ts` |
|
|
598
|
+
| **NickServ `IDENTIFY`** | `PRIVMSG NickServ :IDENTIFY <password>` | `services.verifyNick(state.nick, password)` | `commands/nickserv.ts` |
|
|
599
|
+
| **PASS-auth** | `PASS <nick>:<password>` at or after registration | `services.verifyNick(nick, password)` | `commands/account-auth.ts` (`attemptPassAccountAuth`) |
|
|
600
|
+
|
|
601
|
+
All four share the same `applyAccountSuccess` downstream chain: stamp
|
|
602
|
+
`state.account`, set read-only `+r`, seed read-markers, replay persisted
|
|
603
|
+
away, deliver queued memos, re-apply assigned vhost, and emit `900
|
|
604
|
+
RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS` + the `account-notify` `ACCOUNT`
|
|
605
|
+
broadcast.
|
|
606
|
+
|
|
607
|
+
A NickServ-registered nick authenticates through any of the four entry
|
|
608
|
+
points without a second registration — the credential set by
|
|
609
|
+
`PRIVMSG NickServ :REGISTER` IS the SASL/PASS credential.
|
|
610
|
+
|
|
611
|
+
### SASL EXTERNAL (CertFP)
|
|
612
|
+
|
|
613
|
+
SASL EXTERNAL maps a verified client-certificate subject to an account.
|
|
614
|
+
The edge platform (CF API Shield mTLS, AWS API Gateway custom-domain
|
|
615
|
+
mTLS) terminates TLS and validates the client cert; the verified subject
|
|
616
|
+
is surfaced to the Worker / Lambda via the unchanged
|
|
617
|
+
`MtlsIdentityProvider` port. The subject→account lookup lives in
|
|
618
|
+
`ServicesStore.verifyCertFP`, which scans `certSubjects: string[]` on
|
|
619
|
+
each registered nick (byte-exact, case-sensitive comparison — cert
|
|
620
|
+
subjects are NOT case-folded). `addCertFP(account, subject)` /
|
|
621
|
+
`removeCertFP(account, subject)` mutators are shipped for an upcoming
|
|
622
|
+
NickServ `CERT ADD`/`LIST`/`DEL` command (Atheme parity); the
|
|
623
|
+
`cert_subjects` column on `nickserv_accounts` / the `certSubjects`
|
|
624
|
+
attribute on the DynamoDB `Services` item carries the array (JSON-encoded
|
|
625
|
+
`'[]'` by default).
|
package/docs/WebClientGuide.md
CHANGED
|
@@ -11,9 +11,19 @@ Cross-reference: `PLAN.md` §6.1 (CF mapping), `docs/PlanWebClient.md`
|
|
|
11
11
|
(design), `docs/Cloudflare-Deployment-Guide.md` (Worker deploy),
|
|
12
12
|
`apps/cf-worker/wrangler.toml`, `apps/web/`.
|
|
13
13
|
|
|
14
|
+
> **AWS counterpart.** This doc covers the **Cloudflare** web-client
|
|
15
|
+
> path, where the Worker serves both the SPA and the WebSocket from
|
|
16
|
+
> one origin. The AWS deployment **cannot** unify them — the SPA
|
|
17
|
+
> lives on CloudFront (S3 + OAC) and the WebSocket endpoint on API
|
|
18
|
+
> Gateway, so the baked config carries the API Gateway wss URL as a
|
|
19
|
+
> literal and the deploy is a separate two-phase pipeline (stack →
|
|
20
|
+
> bake → s3 sync → invalidate). For the AWS web client — construct,
|
|
21
|
+
> build, deploy pipeline, the `WEB_ORIGINS` CSWSH defence, and custom
|
|
22
|
+
> domain — see **`docs/AWS-Deployment.md` §16 (Web client)**.
|
|
23
|
+
|
|
14
24
|
**Acceptance criterion (TICKET-139):** a new contributor can
|
|
15
25
|
`git submodule update --init`, `pnpm install`,
|
|
16
|
-
`pnpm --filter web build
|
|
26
|
+
`pnpm --filter web build`, and `wrangler deploy`
|
|
17
27
|
following only this doc.
|
|
18
28
|
|
|
19
29
|
---
|
|
@@ -82,7 +92,7 @@ pnpm --version # 11.x
|
|
|
82
92
|
|
|
83
93
|
---
|
|
84
94
|
|
|
85
|
-
## 3. Quickstart (clean checkout → deployed
|
|
95
|
+
## 3. Quickstart (clean checkout → deployed SPA)
|
|
86
96
|
|
|
87
97
|
```bash
|
|
88
98
|
# 1. Pull submodules (docs wiki + Kiwi IRC upstream sources).
|
|
@@ -91,9 +101,9 @@ git submodule update --init
|
|
|
91
101
|
# 2. Install workspace deps.
|
|
92
102
|
pnpm install
|
|
93
103
|
|
|
94
|
-
# 3. Build the SPA + landing page (default config =
|
|
95
|
-
pnpm --filter web build
|
|
96
|
-
# Equivalent: pnpm --filter @serverless-ircd/web build
|
|
104
|
+
# 3. Build the SPA + landing page (default config = dev target).
|
|
105
|
+
pnpm --filter web build
|
|
106
|
+
# Equivalent: pnpm --filter @serverless-ircd/web build
|
|
97
107
|
# → apps/web/dist/index.html (landing page)
|
|
98
108
|
# → apps/web/dist/webclient/index.html (Kiwi SPA)
|
|
99
109
|
# → apps/web/dist/webclient/static/config.json (baked, env-specific)
|
|
@@ -107,9 +117,9 @@ pnpm --filter @serverless-ircd/cf-worker dev
|
|
|
107
117
|
# → http://localhost:8787/webclient/ Kiwi SPA
|
|
108
118
|
# → ws://localhost:8787/ IRC-over-WebSocket (same endpoint)
|
|
109
119
|
|
|
110
|
-
# 6. Deploy
|
|
111
|
-
pnpm deploy:cf
|
|
112
|
-
# → wrangler deploy
|
|
120
|
+
# 6. Deploy (Worker + assets in one command).
|
|
121
|
+
pnpm deploy:cf
|
|
122
|
+
# → wrangler deploy
|
|
113
123
|
```
|
|
114
124
|
|
|
115
125
|
Open `http://localhost:8787/webclient/` in a browser — Kiwi boots, reads
|
|
@@ -131,14 +141,14 @@ The build is an orchestrated layering over the upstream Kiwi build —
|
|
|
131
141
|
|
|
132
142
|
| `pnpm ...` command | Reads | Bakes into `dist/webclient/static/config.json` |
|
|
133
143
|
|---|---|---|
|
|
134
|
-
| `--filter web build` | `static/config.json` | default
|
|
135
|
-
| `--filter web build:staging` | `static/config.staging.json` | staging config |
|
|
144
|
+
| `--filter web build` | `static/config.json` | dev default config |
|
|
136
145
|
| `--filter web build:prod` | `static/config.prod.json` | prod config |
|
|
137
146
|
|
|
138
|
-
`--env` is parsed by `src/build-env.ts`; an unknown value
|
|
139
|
-
build with a readable error. The selected
|
|
140
|
-
Zod schema (`src/config-schema.ts`)
|
|
141
|
-
malformed config fails the build rather
|
|
147
|
+
`--env` is parsed by `src/build-env.ts`; an unknown value (including the
|
|
148
|
+
retired `staging`) aborts the build with a readable error. The selected
|
|
149
|
+
file is validated against the Zod schema (`src/config-schema.ts`)
|
|
150
|
+
**before** it is baked, so a malformed config fails the build rather
|
|
151
|
+
than shipping a broken SPA.
|
|
142
152
|
|
|
143
153
|
### 4.2 What the build does, in order
|
|
144
154
|
|
|
@@ -172,9 +182,9 @@ The schema models only the fields ServerlessIRCd cares about; the rest
|
|
|
172
182
|
of Kiwi's surface is passed through untouched (`.passthrough()`).
|
|
173
183
|
|
|
174
184
|
```jsonc
|
|
175
|
-
// static/config.
|
|
185
|
+
// static/config.prod.json
|
|
176
186
|
{
|
|
177
|
-
"windowTitle": "ServerlessIRCd
|
|
187
|
+
"windowTitle": "ServerlessIRCd",
|
|
178
188
|
"startupOptions": {
|
|
179
189
|
"server": "{{hostname}}", // resolved to window.location.hostname at boot
|
|
180
190
|
"direct": true, // MUST be true — direct WS, no kiwiServer gateway
|
|
@@ -191,7 +201,7 @@ Field notes (see the schema docstrings for the full rationale):
|
|
|
191
201
|
- **`startupOptions.server`** — `{{hostname}}` (or `{{host}}` to keep
|
|
192
202
|
a non-default port) is a Kiwi template resolved by the browser to
|
|
193
203
|
`window.location.hostname` at load time. This is what lets one build
|
|
194
|
-
artifact target
|
|
204
|
+
artifact target prod, preview, and PR deploys without a
|
|
195
205
|
per-env hostname. A literal hostname is also accepted.
|
|
196
206
|
- **`startupOptions.direct`** — MUST be `true`. ServerlessIRCd is a
|
|
197
207
|
direct IRC-over-WebSocket server; the Kiwi default (`false`) routes
|
|
@@ -209,7 +219,7 @@ Field notes (see the schema docstrings for the full rationale):
|
|
|
209
219
|
A malformed config aborts the build with an aggregated error, e.g.:
|
|
210
220
|
|
|
211
221
|
```
|
|
212
|
-
config.
|
|
222
|
+
config.prod.json: Invalid Kiwi web config:
|
|
213
223
|
- startupOptions.port: port must be a valid TCP port (1-65535)
|
|
214
224
|
- startupOptions.channel: must begin with '#'
|
|
215
225
|
```
|
|
@@ -275,7 +285,7 @@ WEB_ORIGINS = "https://irc.example.com,https://web.example.com"
|
|
|
275
285
|
|
|
276
286
|
```bash
|
|
277
287
|
# or per-deploy via the secret store / dashboard env vars:
|
|
278
|
-
wrangler secret put WEB_ORIGINS
|
|
288
|
+
wrangler secret put WEB_ORIGINS
|
|
279
289
|
```
|
|
280
290
|
|
|
281
291
|
### 5.3 Responses
|
|
@@ -323,8 +333,8 @@ node apps/cf-worker/scripts/smoke.mjs
|
|
|
323
333
|
### 6.2 Iterate on the SPA config without rebuilding Kiwi
|
|
324
334
|
|
|
325
335
|
The Kiwi upstream build is the slow step. For a config-only change,
|
|
326
|
-
edit `static/config.
|
|
327
|
-
`pnpm --filter web build:
|
|
336
|
+
edit `static/config.prod.json`, then re-run
|
|
337
|
+
`pnpm --filter web build:prod` — step 2 of the build reuses
|
|
328
338
|
`upstream/node_modules` and skips straight to re-baking the config
|
|
329
339
|
(steps 4–6 are fast).
|
|
330
340
|
|
|
@@ -502,8 +512,7 @@ git submodule update --init # docs + apps/web/upstream (Kiwi)
|
|
|
502
512
|
pnpm install
|
|
503
513
|
|
|
504
514
|
# Build the SPA + landing page (pick the env)
|
|
505
|
-
pnpm --filter web build # default config (
|
|
506
|
-
pnpm --filter web build:staging # static/config.staging.json
|
|
515
|
+
pnpm --filter web build # default config (dev target)
|
|
507
516
|
pnpm --filter web build:prod # static/config.prod.json
|
|
508
517
|
|
|
509
518
|
# Local dev (Worker serves SPA + landing page + WS on one origin)
|
|
@@ -511,8 +520,8 @@ pnpm build # build the rest of the workspace
|
|
|
511
520
|
pnpm --filter @serverless-ircd/cf-worker dev # http://localhost:8787
|
|
512
521
|
node apps/cf-worker/scripts/smoke.mjs # WS smoke
|
|
513
522
|
|
|
514
|
-
# Deploy
|
|
515
|
-
pnpm deploy:cf
|
|
523
|
+
# Deploy (Worker + assets in one command)
|
|
524
|
+
pnpm deploy:cf # wrangler deploy
|
|
516
525
|
|
|
517
526
|
# Tests / lint
|
|
518
527
|
pnpm --filter web test
|
|
@@ -527,7 +536,7 @@ Key files:
|
|
|
527
536
|
| `apps/web/scripts/build.mjs` | Orchestrates the upstream Kiwi build + layers our config. |
|
|
528
537
|
| `apps/web/src/config-schema.ts` | Zod schema for `static/config.*.json` (the only TS we own). |
|
|
529
538
|
| `apps/web/src/build-env.ts` | `--env` → config-file resolution (unit-tested). |
|
|
530
|
-
| `apps/web/static/config.{,
|
|
539
|
+
| `apps/web/static/config.{,prod}.json` | Kiwi configs (validated at build time). |
|
|
531
540
|
| `apps/web/landing/index.html` | Static landing page (project front door), copied to `dist/index.html`. |
|
|
532
541
|
| `apps/web/tests/build-smoke.test.ts` | Landing-page + build-output assertions. |
|
|
533
542
|
| `apps/cf-worker/src/worker.ts` | Edge handler: WS → `ConnectionDO`, `/health`, else `env.ASSETS`. |
|
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,13 +32,10 @@
|
|
|
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
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"deploy:
|
|
39
|
-
"
|
|
40
|
-
"deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
|
|
41
|
-
"deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
|
|
42
|
-
"smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
|
|
35
|
+
"deploy:cf": "pnpm build && pnpm --filter @serverless-ircd/cf-worker run deploy",
|
|
36
|
+
"smoke:cf": "pnpm --filter @serverless-ircd/cf-worker run smoke",
|
|
37
|
+
"deploy:cf-tcp": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container run deploy",
|
|
38
|
+
"deploy:aws": "pnpm build && pnpm --filter @serverless-ircd/aws-stack run deploy",
|
|
39
|
+
"smoke:aws": "pnpm --filter @serverless-ircd/aws-stack run smoke"
|
|
43
40
|
}
|
|
44
41
|
}
|
|
@@ -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
|
}
|
|
@@ -239,15 +239,42 @@ export class AwsRuntime implements IrcRuntime {
|
|
|
239
239
|
* phase — the APIGW call count is bounded by the genuine recipient set.
|
|
240
240
|
*/
|
|
241
241
|
async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
242
|
+
await this.fanOutByUserMode(lines, except, 'wallops');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Oper-only notice fanout (the OPER reducer's per-IP lockout notice).
|
|
247
|
+
* Identical enumeration to {@link broadcastWallops} but the recipient
|
|
248
|
+
* gate is user mode `+o`: a filtered `Scan` of the `Connections` table
|
|
249
|
+
* followed by a `PostToConnection` per oper. The same cost note applies
|
|
250
|
+
* — the scan reads the whole table — but lockout notices only fire when
|
|
251
|
+
* a host trips the failed-OPER budget, so the rate is bounded by
|
|
252
|
+
* attacker behaviour, not by legitimate traffic.
|
|
253
|
+
*/
|
|
254
|
+
async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
255
|
+
await this.fanOutByUserMode(lines, except, 'oper');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Shared fan-out for {@link broadcastWallops} / {@link broadcastOperNotice}:
|
|
260
|
+
* paginated filtered `Scan` over `userModes.<mode>`, skipping `except`,
|
|
261
|
+
* delivering via `send` (which short-cuts the bound connection through
|
|
262
|
+
* the in-process handlers and tolerates vanished sockets).
|
|
263
|
+
*/
|
|
264
|
+
private async fanOutByUserMode(
|
|
265
|
+
lines: RawLine[],
|
|
266
|
+
except: ConnId | undefined,
|
|
267
|
+
mode: 'wallops' | 'oper',
|
|
268
|
+
): Promise<void> {
|
|
242
269
|
let startKey: Record<string, NativeAttributeValue> | undefined;
|
|
243
270
|
// Paginate the scan: DynamoDB caps a single Scan at 1 MB. Loop until
|
|
244
|
-
// no `LastEvaluatedKey` remains so every
|
|
271
|
+
// no `LastEvaluatedKey` remains so every matching connection is reached.
|
|
245
272
|
do {
|
|
246
273
|
const result = await this.dynamo.send(
|
|
247
274
|
new ScanCommand({
|
|
248
275
|
TableName: this.tables.Connections,
|
|
249
276
|
FilterExpression: 'userModes.#m = :true',
|
|
250
|
-
ExpressionAttributeNames: { '#m':
|
|
277
|
+
ExpressionAttributeNames: { '#m': mode },
|
|
251
278
|
ExpressionAttributeValues: { ':true': true },
|
|
252
279
|
...(startKey !== undefined ? { ExclusiveStartKey: startKey } : {}),
|
|
253
280
|
}),
|
|
@@ -259,7 +286,7 @@ export class AwsRuntime implements IrcRuntime {
|
|
|
259
286
|
// `send` short-cuts the bound connection through the in-process
|
|
260
287
|
// handlers and catches GoneException so vanished sockets are
|
|
261
288
|
// cleaned up rather than aborting the fan-out mid-loop.
|
|
262
|
-
if (row.userModes.wallops) {
|
|
289
|
+
if (mode === 'wallops' ? row.userModes.wallops : row.userModes.oper) {
|
|
263
290
|
await this.send(connId, lines);
|
|
264
291
|
}
|
|
265
292
|
}
|