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
package/README.md
CHANGED
|
@@ -54,7 +54,28 @@ One TypeScript codebase. Two serverless substrates.
|
|
|
54
54
|
> (`DataTraceEnabled`) is **off by default and hard-locked**, so IRC
|
|
55
55
|
> frames (`PASS`, `AUTHENTICATE <SASL-PLAIN>`, channel keys,
|
|
56
56
|
> `PRIVMSG`/`NOTICE`) are never written to CloudWatch; the only way
|
|
57
|
-
> back on is an explicit two-flag sandbox escape hatch.
|
|
57
|
+
> back on is an explicit two-flag sandbox escape hatch. The CF deploy
|
|
58
|
+
> workflow refuses placeholder hostnames (`irc.example.com` /
|
|
59
|
+
> `irc.your-domain.invalid`), and a CI **env-var drift guard** keeps
|
|
60
|
+
> the `consumed-env-vars` block of `apps/cf-worker/wrangler.toml`
|
|
61
|
+
> exactly in sync with the config loader.
|
|
62
|
+
>
|
|
63
|
+
> **Abuse controls & hardening.** Layered admission control on both
|
|
64
|
+
> platforms: a global `MAX_CLIENTS` cap (Cloudflare reserves a
|
|
65
|
+
> `CounterDO` slot per upgrade; AWS keeps an atomic connection counter
|
|
66
|
+
> — over-cap connects get `429`), per-IP simultaneous-connection caps
|
|
67
|
+
> and sliding-window connect-rate limits (a `RateLimitDO` at the CF
|
|
68
|
+
> edge; a source-IP GSI count + APIGW stage throttling + an opt-in
|
|
69
|
+
> WAFv2 per-IP rate rule on AWS), and a per-connection inbound
|
|
70
|
+
> frame-rate window at every adapter boundary. Credential handling is
|
|
71
|
+
> hardened end-to-end: scrypt-hashed oper credentials, constant-time
|
|
72
|
+
> server-password comparison, timing-equalized nick verification (no
|
|
73
|
+
> account enumeration), SASL/OPER/IDENTIFY brute-force lockouts, and
|
|
74
|
+
> credential redaction from parse-error logs. SASL `EXTERNAL` is
|
|
75
|
+
> **operator opt-in** (`EXTERNAL_ENABLED`, default off), gated on a
|
|
76
|
+
> bound mTLS identity source **and** a TLS transport, and binds
|
|
77
|
+
> accounts to the client cert's **DER SHA-256 fingerprint**
|
|
78
|
+
> (`fp:<hex>`, with a canonical-DN fallback).
|
|
58
79
|
>
|
|
59
80
|
> **Integrated IRC services.** NickServ, ChanServ, HostServ,
|
|
60
81
|
> OperServ, and MemoServ run inside the daemon (no separate services
|
|
@@ -121,12 +142,18 @@ Hexagonal / ports-and-adapters. The core implements the IRC protocol; adapters h
|
|
|
121
142
|
**Two transports, one core.** Both adapters accept WebSocket text frames
|
|
122
143
|
(the serverless default) **and** a raw `irc+tls :6697` (TLS-over-TCP)
|
|
123
144
|
surface for stock IRC clients. The TCP+TLS edge is platform-specific —
|
|
124
|
-
Cloudflare **Spectrum**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
Cloudflare **Spectrum** fronts a stateful **Container** origin
|
|
146
|
+
(`apps/cf-tcp-container`) with `proxy_protocol = "v1"` and
|
|
147
|
+
`tls_mode = "full"`: the container itself terminates TLS (pinned to
|
|
148
|
+
1.2/1.3), requires and parses a PROXY v1 header on every flow to recover
|
|
149
|
+
the real client IP, and runs as a non-root user. AWS terminates TLS at a
|
|
150
|
+
**Network Load Balancer** — the streaming handler asserts the flow is
|
|
151
|
+
TLS-secured (surfacing user mode `S`), rejects missing or malformed flow
|
|
152
|
+
headers, and enforces the same per-IP connect-rate budget — and invokes a
|
|
153
|
+
**Lambda streaming** function. Both feed the same `ConnectionActor`
|
|
154
|
+
through a `Transport` seam (`WsTextFrameTransport` vs.
|
|
155
|
+
`TcpByteStreamTransport`); the parser/reducer/dispatch pipeline is
|
|
156
|
+
identical.
|
|
130
157
|
|
|
131
158
|
### Design: pure reducers + location-of-authority
|
|
132
159
|
|
|
@@ -177,9 +204,11 @@ ServerlessIRCd/
|
|
|
177
204
|
├── tools/
|
|
178
205
|
│ ├── tcp-ws-forwarder/ local TCP↔ws/wss bridge for stock IRC clients
|
|
179
206
|
│ ├── load-test/ synthetic WebSocket IRC client pool (10k conns, p50/p95/p99, drop rate)
|
|
180
|
-
│ ├── ci-hardening/ coverage-gate + mutation-config validators
|
|
207
|
+
│ ├── ci-hardening/ coverage-gate + mutation-config + env-var-drift validators
|
|
208
|
+
│ ├── hash-oper-cred.ts scrypt oper-credential generator (OPER_SALT + OPER_HASH)
|
|
181
209
|
│ ├── seed-aws-accounts.ts scrypt-hash SASL PLAIN accounts into DynamoDB
|
|
182
|
-
│
|
|
210
|
+
│ ├── seed-cf-accounts.ts scrypt-hash SASL PLAIN accounts into Cloudflare D1
|
|
211
|
+
│ └── migrate-accounts-to-services.ts one-shot AccountStore→ServicesStore credential backfill
|
|
183
212
|
├── scripts/
|
|
184
213
|
│ └── deploy-web-aws.mjs stack-output-driven AWS web client deploy (describe → bake → s3 sync → invalidate)
|
|
185
214
|
├── pnpm-workspace.yaml turbo.json tsconfig.base.json
|
|
@@ -241,8 +270,8 @@ pnpm --filter web build # builds the Kiwi SPA into apps/web/dist/webclient/ (/
|
|
|
241
270
|
Coverage reports are written to `packages/*/coverage/`. CI (`.github/workflows/ci.yml`)
|
|
242
271
|
runs lint, typecheck, the coverage gate, the parametrized contract suite, and a
|
|
243
272
|
Stryker mutation spot-check on every push and pull request. Coverage thresholds
|
|
244
|
-
enforce 100% on `irc-core` / `irc-server` / `in-memory-runtime`
|
|
245
|
-
other package.
|
|
273
|
+
enforce 100% on `irc-core` / `irc-server` / `in-memory-runtime` / `ci-hardening`
|
|
274
|
+
and ≥90% on every other package.
|
|
246
275
|
|
|
247
276
|
---
|
|
248
277
|
|
|
@@ -522,6 +551,27 @@ Deploy the Worker (Worker + assets in one command):
|
|
|
522
551
|
pnpm deploy:cf # wrangler deploy
|
|
523
552
|
```
|
|
524
553
|
|
|
554
|
+
#### Configuration vars & secrets (Cloudflare)
|
|
555
|
+
|
|
556
|
+
Every env var the Worker consumes is enumerated — and
|
|
557
|
+
**drift-guarded in CI** — in the `consumed-env-vars` block of
|
|
558
|
+
`apps/cf-worker/wrangler.toml`, classified as a plaintext `[vars]`
|
|
559
|
+
knob or a `[secret]`. Credential material must be set as Workers
|
|
560
|
+
secrets, never `[vars]`:
|
|
561
|
+
|
|
562
|
+
| Secret | Purpose |
|
|
563
|
+
|--------|---------|
|
|
564
|
+
| `SERVER_PASSWORD` | server-wide PASS gate (see [above](#server-password-server_password---server-password)) |
|
|
565
|
+
| `OPER_PASSWORD` | legacy plaintext oper credential |
|
|
566
|
+
| `OPER_SALT` + `OPER_HASH` | hashed oper credential — generate with `node --import tsx tools/hash-oper-cred.ts --user admin --stdin` |
|
|
567
|
+
| `SASL_ACCOUNTS` | newline-delimited `user:password` SASL seed list (see `tools/seed-cf-accounts.ts`) |
|
|
568
|
+
|
|
569
|
+
Set each with `wrangler secret put <NAME>`. Everything else
|
|
570
|
+
(`SERVER_NAME`, `MAX_CLIENTS`, `MAX_CONNECTIONS_PER_IP`,
|
|
571
|
+
`PER_IP_CONNECTION_RATE_*`, `MAX_FRAMES_PER_WINDOW`,
|
|
572
|
+
`FRAME_WINDOW_SECONDS`, `EXTERNAL_ENABLED`, …) is a non-sensitive
|
|
573
|
+
`[vars]` knob.
|
|
574
|
+
|
|
525
575
|
### Deploying on AWS
|
|
526
576
|
|
|
527
577
|
The web client is **opt-in**: provision the `StaticSite` construct by
|
|
@@ -630,6 +680,73 @@ deployed stack exercises the spec path end-to-end.
|
|
|
630
680
|
|
|
631
681
|
---
|
|
632
682
|
|
|
683
|
+
## Abuse controls & credential hardening
|
|
684
|
+
|
|
685
|
+
Every transport edge enforces layered admission and rate limits, and
|
|
686
|
+
every credential path verifies through hardened, timing-equalized
|
|
687
|
+
comparisons. The knobs below are `[vars]` on the Cloudflare Worker and
|
|
688
|
+
mirrored as Lambda env vars on AWS.
|
|
689
|
+
|
|
690
|
+
### Connection admission & rate limiting
|
|
691
|
+
|
|
692
|
+
| Layer | Knobs | Enforcement |
|
|
693
|
+
|------------------------|---------------------------------------------------|-------------|
|
|
694
|
+
| Global cap | `MAX_CLIENTS` | CF reserves a `CounterDO` slot before each upgrade and answers `429` (`ERROR :Closing link: server full`) at the cap (slots released on close, TTL-reaped if a DO dies); AWS keeps an atomic connection counter in DynamoDB. |
|
|
695
|
+
| Per-IP simultaneous | `MAX_CONNECTIONS_PER_IP` | Admission gates on both platforms reject over-budget source IPs with `429`. |
|
|
696
|
+
| Per-IP connect rate | `PER_IP_CONNECTION_RATE_MAX` / `PER_IP_CONNECTION_RATE_WINDOW_MS` | CF: a `RateLimitDO` sliding window keyed on `CF-Connecting-IP`, checked at the worker edge (rejections never consume budget, so a blocked IP recovers after window decay). AWS: `$connect` counts in-window establishments via a `sourceIp+connectedSince` GSI, the NLB path applies the same budget to new flows, APIGW stage throttling backstops globally, and an opt-in WAFv2 per-IP rate rule (`-c wafConnectRateLimit=<n>`) sits at the edge. |
|
|
697
|
+
| Per-connection frames | `MAX_FRAMES_PER_WINDOW` / `FRAME_WINDOW_SECONDS` | Inbound frame budget enforced at the adapter boundary before the actor / storage write. |
|
|
698
|
+
| Line-buffer memory | (fixed) | TCP input buffers capped at 8 KiB at every transport edge (local CLI, container origin, NLB handler, forwarder). |
|
|
699
|
+
|
|
700
|
+
### Protocol budgets
|
|
701
|
+
|
|
702
|
+
All length limits are enforced in **UTF-8 bytes**, not UTF-16 code
|
|
703
|
+
units: the 510-byte WS frame / 512-byte TCP line budgets, `TOPICLEN`
|
|
704
|
+
enforced on character boundaries, `draft/multiline` batch byte budgets
|
|
705
|
+
enforced incrementally as lines arrive, at most 15 tags and an
|
|
706
|
+
8192-byte tag section per message, long NAMES rosters split across
|
|
707
|
+
multiple `353` replies within the 510-byte budget, `CHATHISTORY`
|
|
708
|
+
limits capped at a configurable ceiling (default 100), and
|
|
709
|
+
`MAX_TARGETS_PER_COMMAND` capping comma-split targets. The parser
|
|
710
|
+
rejects bare-`CR` smuggling and NUL/control characters in channel
|
|
711
|
+
names, builds tag maps with a null prototype (blocking prototype
|
|
712
|
+
pollution), and `OPER` requires a registered connection (`451
|
|
713
|
+
ERR_NOTREGISTERED`).
|
|
714
|
+
|
|
715
|
+
### Credentials & anti-abuse
|
|
716
|
+
|
|
717
|
+
Oper credentials verify against scrypt hashes (`OPER_USER` +
|
|
718
|
+
`OPER_SALT`/`OPER_HASH`, generated with `tools/hash-oper-cred.ts`);
|
|
719
|
+
the server password compares in constant time; `verifyNick` runs a
|
|
720
|
+
dummy scrypt verify on unknown nicks so response timings cannot
|
|
721
|
+
enumerate accounts. Brute force is throttled at every auth surface: a
|
|
722
|
+
per-connection SASL failure lockout, a per-IP `OPER` failure lockout,
|
|
723
|
+
and a per-account NickServ `IDENTIFY` freeze. HostServ auto-approve
|
|
724
|
+
honours a vhost denylist/allowlist, and ChanServ `DEOP`/`KICK` protect
|
|
725
|
+
founders and enforce caller rank. Parse-error logs carry only
|
|
726
|
+
token/length/reason — `PASS` / `AUTHENTICATE` payloads are redacted —
|
|
727
|
+
and the Worker's log sampling defaults to 10%.
|
|
728
|
+
|
|
729
|
+
### SASL EXTERNAL (mTLS)
|
|
730
|
+
|
|
731
|
+
`EXTERNAL` is advertised and accepted only when **all** of the
|
|
732
|
+
following hold: an mTLS identity source is bound for the connection
|
|
733
|
+
(CF API Shield / AWS API Gateway client certs), the operator opt-in
|
|
734
|
+
`EXTERNAL_ENABLED` is set (default **off**), and the transport is
|
|
735
|
+
TLS-secured. Otherwise the `sasl` cap and `908 ERR_SASLMECHS` list
|
|
736
|
+
`PLAIN` only — refusals do not count toward the SASL lockout.
|
|
737
|
+
Accounts bind to the client cert's **DER SHA-256 fingerprint**
|
|
738
|
+
(`fp:<hex>` entries — what Cloudflare surfaces from
|
|
739
|
+
`request.cf.tlsClientAuth`; the preferred binding) or a **canonical
|
|
740
|
+
subject DN** (the only identifier API Gateway exposes; DNs are
|
|
741
|
+
canonicalised — types lower-cased, whitespace collapsed, RDN/AVA order
|
|
742
|
+
sorted — so re-issued or differently-ordered certificates still match).
|
|
743
|
+
Transports without any client-cert surface — the Spectrum container
|
|
744
|
+
origin, and the NLB stream handler unless an mTLS provider is injected
|
|
745
|
+
— pin the mechanism off and reject `AUTHENTICATE EXTERNAL` with a
|
|
746
|
+
transport-specific `904`.
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
633
750
|
## Testing strategy
|
|
634
751
|
|
|
635
752
|
This project follows strict TDD (Red → Green → Refactor) — every reducer is
|
|
@@ -666,12 +783,17 @@ Active follow-ups:
|
|
|
666
783
|
surfaces.
|
|
667
784
|
- **Web client e2e** — Playwright headless-browser e2e exercising the
|
|
668
785
|
vendored Kiwi IRC SPA against a deployed stack.
|
|
669
|
-
- **Coverage hardening** — `
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
`
|
|
673
|
-
|
|
674
|
-
|
|
786
|
+
- **Coverage hardening** — `irc-core`, `irc-server`,
|
|
787
|
+
`in-memory-runtime`, `local-cli`, `cf-worker`, `aws-stack`,
|
|
788
|
+
`load-test`, `cf-tcp-container`, `tcp-ws-forwarder`,
|
|
789
|
+
`irc-test-support`, and `ci-hardening` sit at 100% line coverage;
|
|
790
|
+
`cf-adapter` (~99.7%) and `aws-adapter` (~98%) clear the 90% gate
|
|
791
|
+
with follow-ups driving each to full coverage.
|
|
792
|
+
- **Protocol follow-ups** — `WHOX` (`WHO <mask> %<fields>` /
|
|
793
|
+
`354 RPL_WHOSPCRPL`), `LIST` search masks + `ELIST=MNTU` filters,
|
|
794
|
+
`cap-notify` capability-change push, services data lifecycle
|
|
795
|
+
(last-used tracking + expiry sweep), and a persisted oper audit
|
|
796
|
+
trail.
|
|
675
797
|
- **Persistent ChanServ ban list** — ban masks currently live on
|
|
676
798
|
`ChannelState.banMasks` and do not survive an empty-recreate of a
|
|
677
799
|
channel. Extending `ServicesStore` with a persistent ban list is the
|
|
@@ -747,17 +869,28 @@ The `ServicesStore` is the **single credential home**: SASL PLAIN, SASL
|
|
|
747
869
|
EXTERNAL (CertFP), NickServ `IDENTIFY`, and `PASS <nick>:<password>` all
|
|
748
870
|
verify through the same scrypt-hashed `verifyNick` / `verifyCertFP`
|
|
749
871
|
surface, so a registered nick is also a SASL login and vice versa.
|
|
872
|
+
The credential env vars that seed and unlock these paths
|
|
873
|
+
(`SASL_ACCOUNTS`, `OPER_*`, `SERVER_PASSWORD`) are Workers secrets on
|
|
874
|
+
Cloudflare — the full consumed-var list lives in the drift-guarded
|
|
875
|
+
`consumed-env-vars` block of `apps/cf-worker/wrangler.toml` (see
|
|
876
|
+
[Configuration vars & secrets](#configuration-vars-secrets-cloudflare)).
|
|
750
877
|
Backends: D1 on Cloudflare, DynamoDB on AWS, in-memory for the
|
|
751
878
|
local CLI / tests (all write-behind; registrations survive redeploys).
|
|
752
879
|
When no store is bound, services commands reply `501` and the rest of
|
|
753
880
|
the daemon is unaffected. See `docs/Services.md` for the full
|
|
754
881
|
reference.
|
|
755
|
-
|
|
756
882
|
**IRCv3 extensions (negotiated via `CAP`):** `message-tags` (incl. the
|
|
757
883
|
`TAGMSG` command), `server-time`, `account-tag`, `account-notify`
|
|
758
884
|
(pushes `ACCOUNT` on SASL login/logout), `echo-message`, `batch`,
|
|
759
|
-
`sasl` (`PLAIN` always; `EXTERNAL`
|
|
760
|
-
|
|
885
|
+
`sasl` (`PLAIN` always; `EXTERNAL` only under the triple gate of
|
|
886
|
+
operator opt-in (`EXTERNAL_ENABLED`, default off) + a bound edge-mTLS
|
|
887
|
+
identity source + a TLS connection — accounts bind to the client
|
|
888
|
+
cert's DER SHA-256 fingerprint or a canonical subject DN, refusals
|
|
889
|
+
answer `908 ERR_SASLMECHS` listing `PLAIN` only, and the
|
|
890
|
+
`cf-tcp-container` Spectrum origin rejects `AUTHENTICATE EXTERNAL`
|
|
891
|
+
with a transport-specific `904`; see
|
|
892
|
+
[Abuse controls & credential hardening](#abuse-controls--credential-hardening)),
|
|
893
|
+
`multi-prefix`, `away-notify`, `chghost`, `invite-notify`,
|
|
761
894
|
`extended-join`, `msgid` (`@+msgid=<id>` on every PRIVMSG/NOTICE/TAGMSG,
|
|
762
895
|
shared between live and `draft/chathistory` replay), `standard-replies`
|
|
763
896
|
(`FAIL`/`WARN`/`NOTE` replacements for a curated numeric subset),
|
|
@@ -69,6 +69,26 @@ const iUnderstandThisLeaksCredentials = app.node.tryGetContext('iUnderstandThisL
|
|
|
69
69
|
const allowDataTraceBool = parseContextBool(allowDataTrace);
|
|
70
70
|
const acknowledgeLeakBool = parseContextBool(iUnderstandThisLeaksCredentials);
|
|
71
71
|
|
|
72
|
+
// DynamoDB table-deletion escape hatch (state-table protection). Safe-by-
|
|
73
|
+
// default: omitted → the stack keeps state tables RETAIN + deletion-protected
|
|
74
|
+
// + PITR-on. `-c allowTableDeletion=true` flips state tables to DESTROY +
|
|
75
|
+
// deletion-protection OFF so a deliberate `cdk destroy` teardown can delete
|
|
76
|
+
// them (the data is irreversibly lost — DynamoDB tables do not snapshot on
|
|
77
|
+
// delete). `Connections` is always DESTROY regardless. See §9.4 / §11.
|
|
78
|
+
const allowTableDeletion = app.node.tryGetContext('allowTableDeletion') as
|
|
79
|
+
| boolean
|
|
80
|
+
| string
|
|
81
|
+
| undefined;
|
|
82
|
+
const allowTableDeletionBool = parseContextBool(allowTableDeletion);
|
|
83
|
+
|
|
84
|
+
// DynamoDB point-in-time recovery toggle. Defaults to ON (the stack's own
|
|
85
|
+
// default when omitted); `-c enablePitr=false` disables PITR on every state
|
|
86
|
+
// table (PITR is billed continuously at ~$0.20/GB-month per table; RETAIN
|
|
87
|
+
// and deletionProtection are free). `Connections` never carries PITR. The
|
|
88
|
+
// flag is independent of `allowTableDeletion` — a teardown keeps PITR on.
|
|
89
|
+
const enablePitr = app.node.tryGetContext('enablePitr') as boolean | string | undefined;
|
|
90
|
+
const enablePitrBool = parseContextBool(enablePitr);
|
|
91
|
+
|
|
72
92
|
// Web client hosting (S3 + CloudFront + OAC) is opt-in. Supplying
|
|
73
93
|
// `webSiteCustomDomain` provisions the static-site construct; prod passes
|
|
74
94
|
// the custom domain + cert + hosted zone, staging leaves them unset to use
|
|
@@ -87,6 +107,19 @@ const webSite =
|
|
|
87
107
|
}
|
|
88
108
|
: undefined;
|
|
89
109
|
|
|
110
|
+
// Optional WAFv2 per-IP edge rate limit on the WebSocket API's `$connect`
|
|
111
|
+
// (requests per 5-minute window per client IP). Omitted → no WAF
|
|
112
|
+
// resources; `-c wafConnectRateLimit=2000` provisions the WebACL with a
|
|
113
|
+
// rate-based rule + the stage association. CDK context values arrive as
|
|
114
|
+
// strings, so parse to an integer and drop non-numeric values.
|
|
115
|
+
const rawWafConnectRateLimit = app.node.tryGetContext('wafConnectRateLimit');
|
|
116
|
+
const wafConnectRateLimit =
|
|
117
|
+
typeof rawWafConnectRateLimit === 'number'
|
|
118
|
+
? rawWafConnectRateLimit
|
|
119
|
+
: typeof rawWafConnectRateLimit === 'string' && /^\d+$/u.test(rawWafConnectRateLimit.trim())
|
|
120
|
+
? Number.parseInt(rawWafConnectRateLimit.trim(), 10)
|
|
121
|
+
: undefined;
|
|
122
|
+
|
|
90
123
|
const stackProps: IrcStackProps = {
|
|
91
124
|
...(serverName !== undefined ? { serverName } : {}),
|
|
92
125
|
...(networkName !== undefined ? { networkName } : {}),
|
|
@@ -98,6 +131,9 @@ const stackProps: IrcStackProps = {
|
|
|
98
131
|
...(acknowledgeLeakBool !== undefined
|
|
99
132
|
? { iUnderstandThisLeaksCredentials: acknowledgeLeakBool }
|
|
100
133
|
: {}),
|
|
134
|
+
...(allowTableDeletionBool !== undefined ? { allowTableDeletion: allowTableDeletionBool } : {}),
|
|
135
|
+
...(enablePitrBool !== undefined ? { enablePitr: enablePitrBool } : {}),
|
|
136
|
+
...(wafConnectRateLimit !== undefined ? { wafConnectRateLimit } : {}),
|
|
101
137
|
...(account && region ? { env: { account, region } } : {}),
|
|
102
138
|
};
|
|
103
139
|
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from 'aws-cdk-lib/aws-apigatewayv2';
|
|
35
35
|
import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
|
36
36
|
import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
|
|
37
|
-
import { Table } from 'aws-cdk-lib/aws-dynamodb';
|
|
37
|
+
import { Table, type TableProps } from 'aws-cdk-lib/aws-dynamodb';
|
|
38
38
|
import { Vpc } from 'aws-cdk-lib/aws-ec2';
|
|
39
39
|
import {
|
|
40
40
|
NetworkListener,
|
|
@@ -50,6 +50,7 @@ import { PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
|
|
|
50
50
|
import { Code, Function as Lambda, Runtime } from 'aws-cdk-lib/aws-lambda';
|
|
51
51
|
import { NodejsFunction, type NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
|
|
52
52
|
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
|
|
53
|
+
import { CfnWebACL, CfnWebACLAssociation } from 'aws-cdk-lib/aws-wafv2';
|
|
53
54
|
import type { Construct } from 'constructs';
|
|
54
55
|
import { StaticSite, type StaticSiteProps } from './static-site.js';
|
|
55
56
|
|
|
@@ -61,7 +62,14 @@ const IRC_TLS_PORT = 6697;
|
|
|
61
62
|
|
|
62
63
|
/** Fixed rate for the gone-connection sweeper (matches the architecture doc). */
|
|
63
64
|
const SWEEPER_SCHEDULE_RATE = Duration.minutes(5);
|
|
64
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Logical id of the one ephemeral DynamoDB table. `Connections` holds
|
|
67
|
+
* per-connection rows (TTL-reaped, sweeper-cleaned) and is the ONLY table
|
|
68
|
+
* kept on `RemovalPolicy.DESTROY` with no PITR and no deletion protection;
|
|
69
|
+
* every other table is state-bearing and protected (see the table-creation
|
|
70
|
+
* block below).
|
|
71
|
+
*/
|
|
72
|
+
const EPHEMERAL_TABLE_LOGICAL_ID = 'Connections';
|
|
65
73
|
/**
|
|
66
74
|
* Fixed rate for the idle / PING checker. One minute is the finest
|
|
67
75
|
* granularity EventBridge `rate(...)` expressions support, which
|
|
@@ -211,6 +219,65 @@ export interface IrcStackProps extends StackProps {
|
|
|
211
219
|
* Set via CDK context: `-c iUnderstandThisLeaksCredentials=true`.
|
|
212
220
|
*/
|
|
213
221
|
readonly iUnderstandThisLeaksCredentials?: boolean;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Escape hatch that allows `cdk destroy` to tear down the state-bearing
|
|
225
|
+
* DynamoDB tables (Nicks, Services, ChannelMeta, ChannelMembers).
|
|
226
|
+
*
|
|
227
|
+
* Defaults to `false` (safe): state tables are `RemovalPolicy.RETAIN`'d
|
|
228
|
+
* and carry `deletionProtection: true`, so a mistaken `cdk destroy`, a
|
|
229
|
+
* stack-name collision, or a stray `aws dynamodb delete-table` from a
|
|
230
|
+
* compromised credential CANNOT wipe user accounts. The Connections
|
|
231
|
+
* table is always DESTROY regardless (its rows are ephemeral per-
|
|
232
|
+
* connection records, TTL-reaped and sweeper-cleaned).
|
|
233
|
+
*
|
|
234
|
+
* Set to `true` ONLY for a deliberate teardown (`-c allowTableDeletion=true`):
|
|
235
|
+
* every state table flips to `RemovalPolicy.DESTROY` with
|
|
236
|
+
* `deletionProtection: false` so CloudFormation can delete them. The data
|
|
237
|
+
* is irreversibly lost — there is no DynamoDB snapshot-on-delete (tables
|
|
238
|
+
* do not snapshot); `RETAIN` + PITR is the only protection, so flipping
|
|
239
|
+
* this off is a one-way operation. {@link enablePitr} is independent and
|
|
240
|
+
* stays on by default even in teardown mode.
|
|
241
|
+
*/
|
|
242
|
+
readonly allowTableDeletion?: boolean;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Toggles point-in-time recovery (PITR) on the state-bearing DynamoDB
|
|
246
|
+
* tables (Nicks, Services, ChannelMeta, ChannelMembers). Defaults to
|
|
247
|
+
* `true` — PITR enables continuous, per-table point-in-time restore
|
|
248
|
+
* (within the last ~35 days) against accidental writes or deletes that
|
|
249
|
+
* RETAIN + deletion protection do not cover (e.g. a buggy deploy that
|
|
250
|
+
* overwrites rows, or an `UpdateItem` with the wrong key).
|
|
251
|
+
*
|
|
252
|
+
* The Connections table NEVER carries PITR regardless of this flag: its
|
|
253
|
+
* rows are ephemeral and the restore cost is unjustified.
|
|
254
|
+
*
|
|
255
|
+
* Set `-c enablePitr=false` to disable on all tables. PITR is billed
|
|
256
|
+
* continuously at ~$0.20/GB-month per table (see docs/AWS-Deployment.md);
|
|
257
|
+
* RETAIN and deletionProtection are free.
|
|
258
|
+
*/
|
|
259
|
+
readonly enablePitr?: boolean;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Optional WAFv2 per-IP edge rate limit for the WebSocket API's
|
|
263
|
+
* `$connect` route (requests per 5-minute window per client IP).
|
|
264
|
+
*
|
|
265
|
+
* When set, the stack emits a `AWS::WAFv2::WebACL` (REGIONAL scope,
|
|
266
|
+
* default action Allow) whose single rate-based rule BLOCKS an IP once
|
|
267
|
+
* it exceeds the limit, associated with the WebSocket stage. WAF on a
|
|
268
|
+
* WebSocket API only inspects the initial HTTP upgrade — i.e. the
|
|
269
|
+
* `$connect` route; established-connection frames ride the upgrade and
|
|
270
|
+
* are never re-inspected — so the rule is effectively scoped to
|
|
271
|
+
* connection establishment without any route matching.
|
|
272
|
+
*
|
|
273
|
+
* This is the EDGE tier of the three-layer connect throttling (WAF at
|
|
274
|
+
* the edge → APIGW stage throttling → the per-IP admission gates in
|
|
275
|
+
* the `$connect` handler); it blocks a flood before a Lambda is
|
|
276
|
+
* invoked at all. Unset (the default) provisions no WAF resources.
|
|
277
|
+
*
|
|
278
|
+
* Set via CDK context: `-c wafConnectRateLimit=2000`.
|
|
279
|
+
*/
|
|
280
|
+
readonly wafConnectRateLimit?: number;
|
|
214
281
|
}
|
|
215
282
|
|
|
216
283
|
export class IrcAwsStack extends Stack {
|
|
@@ -268,6 +335,34 @@ export class IrcAwsStack extends Stack {
|
|
|
268
335
|
default: 'INFO',
|
|
269
336
|
allowedValues: ['OFF', 'INFO', 'ERROR'],
|
|
270
337
|
});
|
|
338
|
+
|
|
339
|
+
// Stage-level throttling: the global backstop BEHIND the per-IP
|
|
340
|
+
// admission gates (the `sourceIp` GSI counts in the `$connect`
|
|
341
|
+
// handler and the per-IP rate knobs). Caps the aggregate
|
|
342
|
+
// requests/second (and burst) APIGW forwards to ANY route — a
|
|
343
|
+
// connect flood from many IPs hits this even when each single IP
|
|
344
|
+
// stays under its per-IP budget. Defaults (100 rps / burst 200) are
|
|
345
|
+
// generous for an IRC deployment sized by `maxClients`; tune at
|
|
346
|
+
// deploy time via `--parameters ApiThrottlingRateLimit=...`. For
|
|
347
|
+
// per-IP rate limiting at the EDGE, deploy a WAFv2 rate-based rule
|
|
348
|
+
// scoped to the `$connect` route (operator-side; see
|
|
349
|
+
// docs/AWS-Deployment.md § throttling).
|
|
350
|
+
const apiThrottlingRateParam = new CfnParameter(this, 'ApiThrottlingRateLimit', {
|
|
351
|
+
type: 'Number',
|
|
352
|
+
description:
|
|
353
|
+
'API Gateway stage throttling: max requests per second across all routes ' +
|
|
354
|
+
'(global backstop behind the per-IP admission gates). ' +
|
|
355
|
+
'Override at deploy time via `--parameters ApiThrottlingRateLimit=...`.',
|
|
356
|
+
default: 100,
|
|
357
|
+
});
|
|
358
|
+
const apiThrottlingBurstParam = new CfnParameter(this, 'ApiThrottlingBurstLimit', {
|
|
359
|
+
type: 'Number',
|
|
360
|
+
description:
|
|
361
|
+
'API Gateway stage throttling: burst capacity (concurrent requests allowed momentarily) ' +
|
|
362
|
+
'paired with ApiThrottlingRateLimit. ' +
|
|
363
|
+
'Override at deploy time via `--parameters ApiThrottlingBurstLimit=...`.',
|
|
364
|
+
default: 200,
|
|
365
|
+
});
|
|
271
366
|
const motd = (props.motdLines ?? DEFAULT_MOTD_LINES).join('\n');
|
|
272
367
|
|
|
273
368
|
// EventBridge schedule expressions are surfaced as CloudFormation
|
|
@@ -302,13 +397,61 @@ export class IrcAwsStack extends Stack {
|
|
|
302
397
|
// staging/prod split was collapsed); staging vs production isolation
|
|
303
398
|
// is driven by which AWS account + region the deploy credentials
|
|
304
399
|
// target, not by table-name prefixing.
|
|
400
|
+
//
|
|
401
|
+
// State-bearing tables (every table EXCEPT `Connections`) are protected
|
|
402
|
+
// from accidental data loss by THREE independent mechanisms, each
|
|
403
|
+
// toggleable so a deliberate teardown can still proceed:
|
|
404
|
+
// • `RemovalPolicy.RETAIN` — orphans the table (keeps its data) when
|
|
405
|
+
// the stack is deleted, instead of CloudFormation deleting it.
|
|
406
|
+
// • `deletionProtection: true` — DynamoDB refuses a `DeleteTable`
|
|
407
|
+
// API call while this is on, blocking a stray
|
|
408
|
+
// `aws dynamodb delete-table` even from an admin credential.
|
|
409
|
+
// • `pointInTimeRecovery` — continuous, per-table restore (within
|
|
410
|
+
// ~35 days) against accidental writes/deletes that RETAIN +
|
|
411
|
+
// deletion protection cannot stop (e.g. a buggy `UpdateItem`).
|
|
412
|
+
//
|
|
413
|
+
// NOTE: SNAPSHOT semantics are deliberately NOT used. DynamoDB tables
|
|
414
|
+
// do NOT snapshot on stack delete (`RemovalPolicy.SNAPSHOT` is a no-op
|
|
415
|
+
// here and would lull an operator into a false sense of backup). The
|
|
416
|
+
// correct combination is RETAIN (orphan + keep) + PITR (continuous,
|
|
417
|
+
// in-place restore). `Connections` is the sole exception: its rows are
|
|
418
|
+
// ephemeral per-connection records (TTL-reaped, sweeper-cleaned), so it
|
|
419
|
+
// stays DESTROY with no PITR and no deletion protection at all times.
|
|
420
|
+
//
|
|
421
|
+
// Cost: RETAIN and deletionProtection are free; PITR is billed
|
|
422
|
+
// continuously at ~$0.20/GB-month per table (see docs/AWS-Deployment.md).
|
|
423
|
+
const allowTableDeletion = props.allowTableDeletion === true;
|
|
424
|
+
const enablePitr = props.enablePitr !== false;
|
|
305
425
|
const tables = Object.fromEntries(
|
|
306
|
-
Object.entries(TABLE_DEFS).map(([logicalId,
|
|
426
|
+
Object.entries(TABLE_DEFS).map(([logicalId, tableDef]) => {
|
|
427
|
+
const isStateTable = logicalId !== EPHEMERAL_TABLE_LOGICAL_ID;
|
|
428
|
+
// `exactOptionalPropertyTypes` forbids passing `undefined` for
|
|
429
|
+
// optional props, so the protection knobs are spread in
|
|
430
|
+
// conditionally: Connections omits all three (always DESTROY,
|
|
431
|
+
// no PITR, no deletion protection); state tables set all three
|
|
432
|
+
// based on the flags.
|
|
433
|
+
const protectionProps: Partial<
|
|
434
|
+
Pick<TableProps, 'deletionProtection' | 'pointInTimeRecovery'>
|
|
435
|
+
> = isStateTable
|
|
436
|
+
? {
|
|
437
|
+
deletionProtection: !allowTableDeletion,
|
|
438
|
+
pointInTimeRecovery: enablePitr,
|
|
439
|
+
}
|
|
440
|
+
: {};
|
|
441
|
+
// The GSI side-channel rides alongside the construct props in
|
|
442
|
+
// TABLE_DEFS; pop it before constructing (current CDK declares
|
|
443
|
+
// indexes via addGlobalSecondaryIndex, not TableProps).
|
|
444
|
+
const { globalSecondaryIndexes, ...tableProps } = tableDef;
|
|
307
445
|
const table = new Table(this, logicalId, {
|
|
308
446
|
...tableProps,
|
|
309
447
|
tableName: logicalId,
|
|
310
|
-
removalPolicy:
|
|
448
|
+
removalPolicy:
|
|
449
|
+
isStateTable && !allowTableDeletion ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
|
|
450
|
+
...protectionProps,
|
|
311
451
|
});
|
|
452
|
+
for (const gsi of globalSecondaryIndexes ?? []) {
|
|
453
|
+
table.addGlobalSecondaryIndex(gsi);
|
|
454
|
+
}
|
|
312
455
|
return [logicalId, table];
|
|
313
456
|
}),
|
|
314
457
|
);
|
|
@@ -412,19 +555,25 @@ export class IrcAwsStack extends Stack {
|
|
|
412
555
|
// APIGW validates this role when it is set as the account CloudWatch
|
|
413
556
|
// role, and rejects one scoped to a single log-group ARN: execution
|
|
414
557
|
// logs land in APIGW's OWN log groups (AWS/ApiGateway...), separate
|
|
415
|
-
// from this stack's access-log group. Grant the
|
|
416
|
-
// across all log groups
|
|
417
|
-
//
|
|
418
|
-
//
|
|
558
|
+
// from this stack's access-log group. Grant ONLY the three write
|
|
559
|
+
// actions APIGW needs, across all log groups (the wildcard is an
|
|
560
|
+
// inherent APIGW constraint — those group ARNs are not predictable at
|
|
561
|
+
// synth time). The legacy policy also carried Describe* actions; they
|
|
562
|
+
// were dropped so a compromised role cannot enumerate (or, given the
|
|
563
|
+
// read actions AWS's own managed policy for this purpose includes,
|
|
564
|
+
// exfiltrate) arbitrary log groups in the account.
|
|
565
|
+
//
|
|
566
|
+
// Managed-policy decision: the AWS-managed
|
|
567
|
+
// AmazonAPIGatewayPushToCloudWatchLogs policy was considered and
|
|
568
|
+
// REJECTED. Its fixed grant set is broader than needed — it grants
|
|
569
|
+
// DescribeLogGroups/DescribeLogStreams (deliberately dropped here)
|
|
570
|
+
// plus GetLogEvents/FilterLogEvents, all on '*' — and AWS can widen
|
|
571
|
+
// its contents without this stack being re-reviewed. It is also not
|
|
572
|
+
// available in every partition. The inline policy keeps the grant set
|
|
573
|
+
// exact and auditable at synth time.
|
|
419
574
|
apiLoggingRole.addToPolicy(
|
|
420
575
|
new PolicyStatement({
|
|
421
|
-
actions: [
|
|
422
|
-
'logs:CreateLogGroup',
|
|
423
|
-
'logs:CreateLogStream',
|
|
424
|
-
'logs:DescribeLogGroups',
|
|
425
|
-
'logs:DescribeLogStreams',
|
|
426
|
-
'logs:PutLogEvents',
|
|
427
|
-
],
|
|
576
|
+
actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
|
|
428
577
|
resources: ['*'],
|
|
429
578
|
}),
|
|
430
579
|
);
|
|
@@ -487,6 +636,10 @@ export class IrcAwsStack extends Stack {
|
|
|
487
636
|
cfnStage.defaultRouteSettings = {
|
|
488
637
|
loggingLevel: apiLoggingLevelParam.valueAsString,
|
|
489
638
|
dataTraceEnabled,
|
|
639
|
+
// Stage throttling (see the parameter definitions above): global
|
|
640
|
+
// rate + burst backstop behind the per-IP admission gates.
|
|
641
|
+
throttlingRateLimit: apiThrottlingRateParam.valueAsNumber,
|
|
642
|
+
throttlingBurstLimit: apiThrottlingBurstParam.valueAsNumber,
|
|
490
643
|
};
|
|
491
644
|
|
|
492
645
|
stage.grantManagementApiAccess(handler);
|
|
@@ -494,6 +647,53 @@ export class IrcAwsStack extends Stack {
|
|
|
494
647
|
handler.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
|
|
495
648
|
pingChecker.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
|
|
496
649
|
|
|
650
|
+
// Optional WAFv2 rate-based rule on the WebSocket API's `$connect`
|
|
651
|
+
// (see the `wafConnectRateLimit` prop doc): WAF only inspects the
|
|
652
|
+
// initial HTTP upgrade on a WebSocket API, so the rate statement
|
|
653
|
+
// applies exactly where credential brute-force has to spend its
|
|
654
|
+
// budget — connection establishment — and never to established
|
|
655
|
+
// frames. Blocks happen at the edge, before a Lambda invocation is
|
|
656
|
+
// billed. Unset → no WAF resources at all (the default).
|
|
657
|
+
if (props.wafConnectRateLimit !== undefined) {
|
|
658
|
+
const webAcl = new CfnWebACL(this, 'ConnectRateWebAcl', {
|
|
659
|
+
scope: 'REGIONAL',
|
|
660
|
+
defaultAction: { allow: {} },
|
|
661
|
+
// CFN requires a VisibilityConfig on both the ACL and the rule;
|
|
662
|
+
// sampled requests + the CloudWatch metric keep operator
|
|
663
|
+
// visibility without full request logging.
|
|
664
|
+
visibilityConfig: {
|
|
665
|
+
cloudWatchMetricsEnabled: true,
|
|
666
|
+
metricName: 'IrcConnectWebAcl',
|
|
667
|
+
sampledRequestsEnabled: true,
|
|
668
|
+
},
|
|
669
|
+
rules: [
|
|
670
|
+
{
|
|
671
|
+
name: 'ConnectRateLimit',
|
|
672
|
+
priority: 0,
|
|
673
|
+
action: { block: {} },
|
|
674
|
+
visibilityConfig: {
|
|
675
|
+
cloudWatchMetricsEnabled: true,
|
|
676
|
+
metricName: 'IrcConnectRateLimit',
|
|
677
|
+
sampledRequestsEnabled: true,
|
|
678
|
+
},
|
|
679
|
+
statement: {
|
|
680
|
+
rateBasedStatement: {
|
|
681
|
+
limit: props.wafConnectRateLimit,
|
|
682
|
+
aggregateKeyType: 'IP',
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
],
|
|
687
|
+
});
|
|
688
|
+
new CfnWebACLAssociation(this, 'ConnectRateWebAclAssociation', {
|
|
689
|
+
webAclArn: webAcl.attrArn,
|
|
690
|
+
// WAF associates with API Gateway stages via the v1-style stage
|
|
691
|
+
// ARN namespace (`/restapis/…`) — WebSocket APIs use the same
|
|
692
|
+
// format (there is no `WebSocketStage.stageArn` L2 helper).
|
|
693
|
+
resourceArn: `arn:${this.partition}:apigateway:${this.region}::/restapis/${webSocketApi.apiId}/stages/${stage.stageName}`,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
497
697
|
// Inject each table's physical name as an env var so every Lambda can
|
|
498
698
|
// construct a `TablesConfig` at cold start via `buildDepsFromEnv`. The
|
|
499
699
|
// env-var value is the literal physical table name (the bare logical
|
|
@@ -607,6 +807,12 @@ export class IrcAwsStack extends Stack {
|
|
|
607
807
|
nlbHandler.addEnvironment('NETWORK_NAME', networkName);
|
|
608
808
|
nlbHandler.addEnvironment('MOTD', motd);
|
|
609
809
|
nlbHandler.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
|
|
810
|
+
// The NLB handler fans channel messages out to OTHER connections via
|
|
811
|
+
// the management API — including wss clients on the API Gateway
|
|
812
|
+
// transport — so it needs the same `execute-api:ManageConnections`
|
|
813
|
+
// grant as the wss handler and ping checker. Without it every
|
|
814
|
+
// cross-transport `postToConnection` fails with 403.
|
|
815
|
+
stage.grantManagementApiAccess(nlbHandler);
|
|
610
816
|
|
|
611
817
|
const nlb = new NetworkLoadBalancer(this, 'IrcNlb', {
|
|
612
818
|
vpc,
|