serverless-ircd 0.8.0 → 0.10.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 +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -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 +177 -52
- 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 +267 -92
- 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/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- 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 +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- 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 +20 -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 +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- 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/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -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 +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- 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 +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- 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/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -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
package/docs/AWS-Deployment.md
CHANGED
|
@@ -26,6 +26,8 @@ Key in-repo paths cited below:
|
|
|
26
26
|
| `packages/aws-adapter/src/cdk-table-defs.ts` | Five DynamoDB table shapes. |
|
|
27
27
|
| `packages/aws-adapter/src/config-loader.ts` | Lambda env → `ServerConfig` mapping. |
|
|
28
28
|
| `packages/aws-adapter/src/handlers/index.ts` | Lambda entry (`handler`, `sweeperHandler`, `pingCheckerHandler`). |
|
|
29
|
+
| `apps/aws-stack/src/static-site.ts` | CDK construct for the optional web client (S3 + CloudFront + OAC). See §16. |
|
|
30
|
+
| `scripts/deploy-web-aws.mjs` | Two-phase web client deploy (describe → bake → s3 sync → invalidate). See §16. |
|
|
29
31
|
| `.github/workflows/deploy-aws.yml` | Staging deploy + smoke e2e CI. |
|
|
30
32
|
|
|
31
33
|
**Acceptance criterion:** a new contributor can deploy their own
|
|
@@ -36,8 +38,11 @@ staging instance following only this doc.
|
|
|
36
38
|
## 1. What gets deployed
|
|
37
39
|
|
|
38
40
|
A single CDK v2 stack (`IrcAwsStack`) provisions the entire AWS
|
|
39
|
-
substrate.
|
|
40
|
-
|
|
41
|
+
substrate. Every resource is regional. The IRC runtime itself is
|
|
42
|
+
stateful compute (Lambda + DynamoDB + API Gateway WebSocket); the
|
|
43
|
+
optional **web client** (S3 + CloudFront + OAC) is provisioned only
|
|
44
|
+
when the `webSite`/`webSiteCustomDomain` CDK context is supplied. See
|
|
45
|
+
§16 for the web-serving path.
|
|
41
46
|
|
|
42
47
|
```
|
|
43
48
|
┌──────────────────────────────────────────────────────────────────┐
|
|
@@ -191,19 +196,30 @@ If you skip this, the first `cdk deploy` fails with a bucket-not-found
|
|
|
191
196
|
error (see §13.1). Bootstrap is idempotent — re-running on an
|
|
192
197
|
already-bootstrapped account is a no-op.
|
|
193
198
|
|
|
194
|
-
### 3.4
|
|
199
|
+
### 3.4 Set up OIDC for CI (required)
|
|
195
200
|
|
|
196
|
-
|
|
197
|
-
|
|
201
|
+
> **Mandatory.** Long-lived access keys (`AWS_ACCESS_KEY_ID` /
|
|
202
|
+
> `AWS_SECRET_ACCESS_KEY`) are NOT accepted by the deploy workflow.
|
|
203
|
+
> The workflow assumes a role via GitHub OIDC web identity and asserts
|
|
204
|
+
> the assumed role matches `AWS_DEPLOY_ROLE_ARN` before any `cdk
|
|
205
|
+
> deploy`. The full trust-policy snippet, condition keys, and
|
|
206
|
+
> verification steps are in **§17**.
|
|
198
207
|
|
|
199
|
-
|
|
208
|
+
To deploy via the GitHub Actions workflow (§12) you MUST configure
|
|
209
|
+
GitHub OIDC — there is no static-key fallback. Summary of the one-time
|
|
210
|
+
setup (full snippet and verification in §17):
|
|
211
|
+
|
|
212
|
+
1. Create an IAM identity provider for
|
|
213
|
+
`token.actions.githubusercontent.com` (one per AWS account).
|
|
200
214
|
2. Create a role with a trust policy granting
|
|
201
|
-
`sts:AssumeRoleWithWebIdentity` to your repo's
|
|
215
|
+
`sts:AssumeRoleWithWebIdentity` to your repo's
|
|
216
|
+
`ref:refs/heads/main` claim (the workflow is
|
|
217
|
+
`workflow_dispatch`-only on `main`).
|
|
202
218
|
3. Attach a permission policy broad enough to manage the stack (IAM,
|
|
203
|
-
API Gateway v2, Lambda, DynamoDB, EventBridge, CloudFormation)
|
|
219
|
+
API Gateway v2, Lambda, DynamoDB, EventBridge, CloudFormation) —
|
|
220
|
+
ideally the same surface `cdk deploy` needs.
|
|
204
221
|
4. Note the role ARN — it becomes the `AWS_DEPLOY_ROLE_ARN` secret in
|
|
205
|
-
CI.
|
|
206
|
-
the fallback; the workflow supports both.
|
|
222
|
+
CI.
|
|
207
223
|
|
|
208
224
|
---
|
|
209
225
|
|
|
@@ -283,18 +299,18 @@ shared protocol layer.
|
|
|
283
299
|
|
|
284
300
|
---
|
|
285
301
|
|
|
286
|
-
## 5. Deploy
|
|
302
|
+
## 5. Deploy
|
|
287
303
|
|
|
288
|
-
|
|
289
|
-
also deploy
|
|
304
|
+
The deploy is `workflow_dispatch`-only (a maintainer runs it by hand);
|
|
305
|
+
you can also deploy manually from a clean checkout.
|
|
290
306
|
|
|
291
307
|
### 5.1 Deploy from your machine
|
|
292
308
|
|
|
293
309
|
```bash
|
|
294
310
|
# From the repo root:
|
|
295
|
-
pnpm deploy:aws
|
|
311
|
+
pnpm deploy:aws
|
|
296
312
|
# Equivalent to:
|
|
297
|
-
# pnpm --filter @serverless-ircd/aws-stack deploy
|
|
313
|
+
# pnpm --filter @serverless-ircd/aws-stack run deploy
|
|
298
314
|
# → cdk deploy --all --require-approval never
|
|
299
315
|
```
|
|
300
316
|
|
|
@@ -349,25 +365,18 @@ TCP IRC client to a WebSocket endpoint.
|
|
|
349
365
|
|
|
350
366
|
---
|
|
351
367
|
|
|
352
|
-
## 6.
|
|
353
|
-
|
|
354
|
-
The repo-level shortcut is:
|
|
355
|
-
|
|
356
|
-
```bash
|
|
357
|
-
pnpm deploy:aws:prod
|
|
358
|
-
# Equivalent to:
|
|
359
|
-
# pnpm --filter @serverless-ircd/aws-stack deploy:prod
|
|
360
|
-
# → cdk deploy --all --require-approval never
|
|
361
|
-
```
|
|
368
|
+
## 6. Production hardening
|
|
362
369
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
stack
|
|
368
|
-
`
|
|
370
|
+
There is a single deploy target per platform (`pnpm deploy:aws`). The
|
|
371
|
+
staging/prod split was collapsed — there is no `-staging` / `-prod`
|
|
372
|
+
stack-name suffix, no `environmentName` CDK context, and no per-env
|
|
373
|
+
table-name prefixing. The stack is always named `IrcAwsStack` (hardcoded
|
|
374
|
+
in `apps/aws-stack/bin/aws.ts`), and table names are the bare logical
|
|
375
|
+
ids (`Connections`, `Nicks`, …). The staging/production distinction is
|
|
376
|
+
**which AWS account and region the stack is deployed into**, controlled
|
|
377
|
+
by the credentials and `AWS_REGION` in the environment.
|
|
369
378
|
|
|
370
|
-
For a real production deployment, before your first `pnpm deploy:aws
|
|
379
|
+
For a real production deployment, before your first `pnpm deploy:aws`:
|
|
371
380
|
|
|
372
381
|
1. **Use a dedicated AWS account** (or at least a dedicated region) for
|
|
373
382
|
production. Do not point production credentials at the same stack
|
|
@@ -381,6 +390,12 @@ For a real production deployment, before your first `pnpm deploy:aws:prod`:
|
|
|
381
390
|
falls back to `irc.localhost` when the prop is omitted, which is
|
|
382
391
|
intentionally not production-ready.
|
|
383
392
|
4. **Pick a region close to your users** (see §10).
|
|
393
|
+
5. **Do NOT re-enable API Gateway `DataTraceEnabled`.** It is OFF by default
|
|
394
|
+
(safe). Turning it on logs the full body of every IRC frame (`PASS`,
|
|
395
|
+
`AUTHENTICATE <SASL-PLAIN>`, channel keys, all `PRIVMSG`/`NOTICE`) to
|
|
396
|
+
CloudWatch, leaking credentials to anyone with `logs:GetLogEvents`. The
|
|
397
|
+
two-step sandbox-only escape hatch is documented in §7.6; never use it
|
|
398
|
+
against a production account.
|
|
384
399
|
|
|
385
400
|
There is **no CI auto-deploy to production**. The deploy workflow
|
|
386
401
|
(`deploy-aws.yml`) only triggers on push to `main` and targets
|
|
@@ -418,8 +433,7 @@ These three values are injected into the `IrcHandler` and
|
|
|
418
433
|
pass the construct props when creating the stack:
|
|
419
434
|
|
|
420
435
|
```ts
|
|
421
|
-
new IrcAwsStack(app, 'IrcAwsStack
|
|
422
|
-
environmentName: 'production',
|
|
436
|
+
new IrcAwsStack(app, 'IrcAwsStack', {
|
|
423
437
|
serverName: 'irc.my.net',
|
|
424
438
|
networkName: 'MyNet',
|
|
425
439
|
motdLines: ['Welcome to my network.', 'Be excellent to each other.'],
|
|
@@ -493,18 +507,25 @@ Invalid values cause a readable boot-time error — the Lambda will
|
|
|
493
507
|
throw on the first invocation (cold start). Test changes locally with
|
|
494
508
|
`pnpm --filter @serverless-ircd/aws-adapter test`.
|
|
495
509
|
|
|
510
|
+
> **Note:** `WEB_ORIGINS` is intentionally NOT in this table. It is an
|
|
511
|
+
> adapter-level operational concern (the `$connect` CSWSH defence), not
|
|
512
|
+
> a `ServerConfig` knob — the env var is parsed directly by
|
|
513
|
+
> `buildDepsFromEnv` via `parseWebOrigins` rather than threaded through
|
|
514
|
+
> the shared Zod schema. See §8.2.
|
|
515
|
+
|
|
496
516
|
### 7.4 No per-environment stack parameter
|
|
497
517
|
|
|
498
|
-
There is no `stageName` or `
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
518
|
+
There is no `stageName`, `environmentType`, or `environmentName`
|
|
519
|
+
parameter on the CDK stack (the former staging/prod split was
|
|
520
|
+
collapsed into a single target). The stack name (`IrcAwsStack`), table
|
|
521
|
+
names (`Connections`, `ChannelMeta`, `ChannelMembers`, `Nicks`,
|
|
522
|
+
`Accounts`, `Services`), API Gateway stage name (`prod`), and Lambda
|
|
523
|
+
construct ids are all hardcoded. To run staging and production in
|
|
524
|
+
**the same AWS account**, you would need to fork the stack to
|
|
525
|
+
parameterize these names; the recommended path is to use separate
|
|
526
|
+
accounts (or separate regions) for staging vs production. This is the
|
|
527
|
+
same architectural choice the Cloudflare adapter makes (one Worker
|
|
528
|
+
namespace; staging vs production isolation is by account).
|
|
508
529
|
|
|
509
530
|
### 7.5 Lambda runtime and bundling
|
|
510
531
|
|
|
@@ -525,6 +546,46 @@ namespaces).
|
|
|
525
546
|
| Auto-deploy | `true` (changes propagate without a manual deployment). |
|
|
526
547
|
| Auth on `$connect`| None today (no authorizer). See §13.5. |
|
|
527
548
|
| Route responses | Default (`$connect` returns 200 → upgrade; `$default` and `$disconnect` are fire-and-forget). |
|
|
549
|
+
| Logging level | `INFO` by default; override at deploy time via `--parameters ApiGatewayLoggingLevel=OFF\|INFO\|ERROR`. Emits request/response metadata (route, status, source IP, …) **without** frame bodies. |
|
|
550
|
+
| Full-frame body tracing (`DataTraceEnabled`) | **OFF by default and hard-locked.** See the warning below. |
|
|
551
|
+
|
|
552
|
+
#### ⚠️ Re-enabling `DataTraceEnabled` leaks IRC credentials
|
|
553
|
+
|
|
554
|
+
API Gateway execution-log body tracing (`DataTraceEnabled = true`) writes
|
|
555
|
+
the FULL payload of every WebSocket frame to the APIGW CloudWatch log group
|
|
556
|
+
(`AWS/ApiGateway…`, separate from the stack's access-log group). For an IRC
|
|
557
|
+
endpoint that includes, verbatim:
|
|
558
|
+
|
|
559
|
+
- `PASS <server password>`
|
|
560
|
+
- `AUTHENTICATE <base64-SASL-PLAIN>` (decodes to `account\0account\0password`)
|
|
561
|
+
- `JOIN #channel <channel-key>`
|
|
562
|
+
- every `PRIVMSG` / `NOTICE` (private messages)
|
|
563
|
+
|
|
564
|
+
Anyone with `logs:GetLogEvents` — operators, SREs, a Logpush destination, or
|
|
565
|
+
compromised CI credentials — recovers server passwords, SASL accounts,
|
|
566
|
+
channel keys, and private messages. Retention is account-default (unbounded).
|
|
567
|
+
|
|
568
|
+
For this reason `DataTraceEnabled` defaults to `false` and is the single
|
|
569
|
+
highest-impact logging control on the stack. INFO-level execution logging
|
|
570
|
+
alone is enough to diagnose whether API Gateway received and routed a frame
|
|
571
|
+
or dropped it pre-Lambda.
|
|
572
|
+
|
|
573
|
+
Because the staging/prod split was collapsed (every synth is effectively a
|
|
574
|
+
prod synth), there is no quiet "non-prod" path to re-enable it. The only way
|
|
575
|
+
back on is an explicit, **two-step opt-in** that exists solely for live
|
|
576
|
+
debugging in a throwaway sandbox:
|
|
577
|
+
|
|
578
|
+
```sh
|
|
579
|
+
pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- \
|
|
580
|
+
-c allowDataTrace=true \
|
|
581
|
+
-c iUnderstandThisLeaksCredentials=true
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Setting only `-c allowDataTrace=true` **refuses synthesis** with a loud error
|
|
585
|
+
that names the credentials it would leak. The awkward second flag is
|
|
586
|
+
intentional — it cannot be set by accident. Tear the sandbox stack down
|
|
587
|
+
immediately after debugging; never deploy this combination to a shared
|
|
588
|
+
account.
|
|
528
589
|
|
|
529
590
|
---
|
|
530
591
|
|
|
@@ -599,7 +660,7 @@ root):
|
|
|
599
660
|
node --input-type=module -e '
|
|
600
661
|
import { createDynamoDocumentClient, putAccountCredential } from "@serverless-ircd/aws-adapter";
|
|
601
662
|
const dynamo = createDynamoDocumentClient({ region: "us-east-1" });
|
|
602
|
-
await putAccountCredential(dynamo, "
|
|
663
|
+
await putAccountCredential(dynamo, "Accounts", "alice", "change-me");
|
|
603
664
|
console.log("seeded alice");
|
|
604
665
|
'
|
|
605
666
|
```
|
|
@@ -610,13 +671,13 @@ calls for batch provisioning:
|
|
|
610
671
|
```bash
|
|
611
672
|
# Seed against DynamoDB Local:
|
|
612
673
|
npx tsx tools/seed-aws-accounts.ts \
|
|
613
|
-
--table
|
|
674
|
+
--table Accounts \
|
|
614
675
|
--endpoint http://localhost:8000 \
|
|
615
676
|
--accounts alice:s3cret bob:password2
|
|
616
677
|
|
|
617
678
|
# Or from a file (newline-delimited username:password):
|
|
618
679
|
npx tsx tools/seed-aws-accounts.ts \
|
|
619
|
-
--table
|
|
680
|
+
--table Accounts \
|
|
620
681
|
--region us-east-1 \
|
|
621
682
|
--file accounts.txt
|
|
622
683
|
```
|
|
@@ -627,7 +688,7 @@ row directly:
|
|
|
627
688
|
```bash
|
|
628
689
|
# Hash with the adapter, then write the row yourself:
|
|
629
690
|
aws dynamodb put-item \
|
|
630
|
-
--table-name
|
|
691
|
+
--table-name Accounts \
|
|
631
692
|
--item '{"account":{"S":"alice"},"algorithm":{"S":"scrypt"},"salt":{"S":"<base64-salt>"},"hash":{"S":"<base64-hash>"}}'
|
|
632
693
|
```
|
|
633
694
|
|
|
@@ -641,14 +702,80 @@ aws dynamodb put-item \
|
|
|
641
702
|
vars are visible in the console and in CloudFormation describe output;
|
|
642
703
|
they are not secret.
|
|
643
704
|
|
|
705
|
+
### 8.2 `$connect` Origin allowlist (`WEB_ORIGINS`, CSWSH defence)
|
|
706
|
+
|
|
707
|
+
WebSocket upgrades do not follow the same-origin policy, so a malicious
|
|
708
|
+
web page can open a WebSocket to the IRC server from the victim's
|
|
709
|
+
browser and drive the session with the victim's credentials (Cross-Site
|
|
710
|
+
WebSocket Hijacking, CSWSH). API Gateway WebSocket has **no built-in
|
|
711
|
+
`Origin` validation** — the `$connect` Lambda receives
|
|
712
|
+
`event.headers.Origin` and must enforce the allowlist itself.
|
|
713
|
+
|
|
714
|
+
**Knob:** `WEB_ORIGINS` — a comma-separated list of allowed web origins
|
|
715
|
+
(e.g. `https://app.example.com, https://staging.app.example.com`).
|
|
716
|
+
Mirror of the CF Worker's `WEB_ORIGINS` (TICKET-137).
|
|
717
|
+
|
|
718
|
+
**Behaviour** (`packages/aws-adapter/src/origin-allowlist.ts` and
|
|
719
|
+
`packages/aws-adapter/src/handlers/connect.ts`):
|
|
720
|
+
|
|
721
|
+
| `WEB_ORIGINS` | `Origin` header on the upgrade | Outcome |
|
|
722
|
+
|---------------|--------------------------------|----------------------------------------|
|
|
723
|
+
| unset / empty | (any) | **Allow** — defence disabled (default) |
|
|
724
|
+
| set | absent (non-browser client) | **Allow** — curl / WeeChat / `tcp-ws-forwarder` / NLB TCP+TLS pass through |
|
|
725
|
+
| set | present, in allowlist | **Allow** |
|
|
726
|
+
| set | present, NOT in allowlist | **Deny** — `$connect` returns `403`; API Gateway closes the upgrade |
|
|
727
|
+
|
|
728
|
+
**The defence is opt-in.** Unset `WEB_ORIGINS` skips the check entirely
|
|
729
|
+
— existing AWS deployments without a web frontend are unchanged on
|
|
730
|
+
upgrade. The check only matters once you ship a web client (TICKET-164)
|
|
731
|
+
and want to admit the SPA's origin while rejecting hostile ones.
|
|
732
|
+
|
|
733
|
+
**Matching semantics** (mirror the CF Worker):
|
|
734
|
+
- entries are trimmed of surrounding whitespace and lowercased
|
|
735
|
+
(`https://APP.Example.COM` → `https://app.example.com`);
|
|
736
|
+
- the incoming `Origin` header is normalised the same way before the
|
|
737
|
+
membership test, so the match is case-insensitive on scheme+host
|
|
738
|
+
(per RFC 3986);
|
|
739
|
+
- origins are otherwise case-sensitive — `http://app.example.com` is
|
|
740
|
+
a different origin from `https://app.example.com` and is denied if
|
|
741
|
+
only the `https://` form is listed (defends against a stripped-TLS
|
|
742
|
+
attacker on the same hostname).
|
|
743
|
+
|
|
744
|
+
**Deliberate divergence from the CF policy:** the CF Worker has a
|
|
745
|
+
same-origin auto-derive mode (when `WEB_ORIGINS` is unset, it compares
|
|
746
|
+
`Origin` against the request's own `Host`). On AWS the SPA is served
|
|
747
|
+
from a different CloudFront origin than the API Gateway WebSocket
|
|
748
|
+
endpoint (`wss://{api-id}.execute-api.…`), so the request's own host is
|
|
749
|
+
never the SPA's origin — auto-derive does not apply. The AWS defence is
|
|
750
|
+
therefore explicit-allowlist only.
|
|
751
|
+
|
|
752
|
+
**Wiring.** Inject `WEB_ORIGINS` on the `$connect` Lambda via the CDK
|
|
753
|
+
stack prop:
|
|
754
|
+
|
|
755
|
+
```typescript
|
|
756
|
+
new IrcAwsStack(app, 'IrcAwsStack', {
|
|
757
|
+
webOrigins: 'https://app.example.com, https://staging.app.example.com',
|
|
758
|
+
// ...other props
|
|
759
|
+
});
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
…or at deploy time via CDK context (`-c webOrigins=…`). The stack
|
|
763
|
+
injects the env var only on the wss `$connect` handler (the sweeper,
|
|
764
|
+
ping-checker, and NLB handler never run `$connect`). The handler parses
|
|
765
|
+
the value once per cold start via `parseWebOrigins` and stores the
|
|
766
|
+
resulting `Set<string>` on `HandlerDeps.webOrigins`.
|
|
767
|
+
|
|
768
|
+
`WEB_ORIGINS` is **not** a secret — it is a public allowlist of origin
|
|
769
|
+
strings. Do not put it in Secrets Manager / SSM; set it as a plain
|
|
770
|
+
Lambda env var (the CDK stack does this directly via
|
|
771
|
+
`addEnvironment`).
|
|
772
|
+
|
|
644
773
|
The CI deploy uses **GitHub Actions secrets** plus an optional repo
|
|
645
774
|
variable (see §12):
|
|
646
775
|
|
|
647
776
|
| Secret | Used by | Purpose |
|
|
648
777
|
|-------------------------|-------------------|---------------------------------------------------------------|
|
|
649
|
-
| `
|
|
650
|
-
| `AWS_SECRET_ACCESS_KEY` | `deploy-aws.yml` | Companion secret key. Required unless OIDC is used. |
|
|
651
|
-
| `AWS_DEPLOY_ROLE_ARN` | `deploy-aws.yml` | Optional. When set, the job assumes this role via OIDC web identity instead of using static keys. |
|
|
778
|
+
| `AWS_DEPLOY_ROLE_ARN` | `deploy-aws.yml` | **Required.** ARN of the IAM role the workflow assumes via GitHub OIDC web identity. No static-key fallback. |
|
|
652
779
|
| `AWS_REGION` | `deploy-aws.yml` | Region to deploy into (e.g. `us-east-1`). |
|
|
653
780
|
|
|
654
781
|
| Variable | Purpose |
|
|
@@ -819,9 +946,10 @@ see ~10 ms. **Pick the region closest to your user base.**
|
|
|
819
946
|
|
|
820
947
|
### 10.2 Recommendation: single-region for v1
|
|
821
948
|
|
|
822
|
-
For v1, deploy in one region.
|
|
823
|
-
|
|
824
|
-
|
|
949
|
+
For v1, deploy in one region. The IRC runtime is regional stateful
|
|
950
|
+
compute (Lambda + DynamoDB + APIGW); CloudFront only enters the picture
|
|
951
|
+
for the optional web client (§16) and is global-edge, not a region
|
|
952
|
+
picker. Single-region keeps the data model simple: the cross-table
|
|
825
953
|
`TransactWriteItems` flows (§9.2) only work within one region.
|
|
826
954
|
|
|
827
955
|
### 10.3 Multi-region is out of scope for v1
|
|
@@ -922,10 +1050,10 @@ single 1k-member announcement channel can multiply the fanout cost by
|
|
|
922
1050
|
|
|
923
1051
|
## 12. CI/CD
|
|
924
1052
|
|
|
925
|
-
The GitHub Actions workflow at `.github/workflows/deploy-aws.yml`
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1053
|
+
The GitHub Actions workflow at `.github/workflows/deploy-aws.yml` is
|
|
1054
|
+
`workflow_dispatch`-only (a maintainer triggers it by hand); there is
|
|
1055
|
+
no automatic push-to-main deploy. It deploys the single AWS target and
|
|
1056
|
+
replays the smoke e2e.
|
|
929
1057
|
|
|
930
1058
|
### 12.1 What the workflow does
|
|
931
1059
|
|
|
@@ -936,60 +1064,91 @@ Steps performed (in order):
|
|
|
936
1064
|
3. `pnpm install --frozen-lockfile`.
|
|
937
1065
|
4. `pnpm build` — builds all workspace packages.
|
|
938
1066
|
5. `pnpm typecheck` and `pnpm test` — gate the deploy.
|
|
939
|
-
6.
|
|
940
|
-
|
|
941
|
-
7.
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
`
|
|
1067
|
+
6. **Assert `AWS_DEPLOY_ROLE_ARN` is non-empty** (fail-fast before any
|
|
1068
|
+
AWS call).
|
|
1069
|
+
7. Configure AWS credentials via OIDC (`aws-actions/configure-aws-credentials@v4`
|
|
1070
|
+
with `role-to-assume` only — no static keys).
|
|
1071
|
+
8. **Assert `aws sts get-caller-identity` returns the configured role**
|
|
1072
|
+
(defence-in-depth against silent fallbacks).
|
|
1073
|
+
9. `pnpm deploy:aws` → `cdk deploy --all --require-approval never`.
|
|
1074
|
+
10. Resolve the smoke URL: repo variable `AWS_SMOKE_URL` first, else
|
|
1075
|
+
the stack's `ConnectUrl` CloudFormation output via
|
|
1076
|
+
`aws cloudformation describe-stacks --stack-name IrcAwsStack`, else
|
|
1077
|
+
skip the smoke step with a warning.
|
|
1078
|
+
11. `pnpm smoke:aws -- --url "$SMOKE_URL"` — failure fails the
|
|
1079
|
+
build.
|
|
1080
|
+
|
|
1081
|
+
Concurrency is serialized via `concurrency.group: aws-deploy`,
|
|
1082
|
+
`cancel-in-progress: false`, so two runs cannot race the same
|
|
951
1083
|
CloudFormation stack.
|
|
952
1084
|
|
|
953
1085
|
### 12.2 Required repo configuration
|
|
954
1086
|
|
|
955
1087
|
Under **Settings → Secrets and variables → Actions**:
|
|
956
1088
|
|
|
957
|
-
- **Secret `
|
|
958
|
-
|
|
959
|
-
|
|
1089
|
+
- **Secret `AWS_DEPLOY_ROLE_ARN`** — **required**. ARN of the IAM
|
|
1090
|
+
role the workflow assumes via GitHub OIDC. There is no static-key
|
|
1091
|
+
fallback. Set this up once per AWS account (see §17 for the trust
|
|
1092
|
+
policy).
|
|
960
1093
|
- **Secret `AWS_REGION`** — required (e.g. `us-east-1`).
|
|
961
1094
|
- **Variable `AWS_SMOKE_URL`** — optional but recommended; the
|
|
962
1095
|
workflow can fall back to the CFN output, but setting this variable
|
|
963
1096
|
explicitly avoids the `describe-stacks` round-trip and the
|
|
964
1097
|
possibility of a stale cached value after a redeploy.
|
|
965
1098
|
|
|
966
|
-
|
|
1099
|
+
> **Removed:** `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are no
|
|
1100
|
+
> longer read by the workflow. Long-lived access keys powerful enough
|
|
1101
|
+
> to deploy CloudFormation + IAM + Lambda + DynamoDB are effectively
|
|
1102
|
+
> account-admin; a single exfil vector yields full AWS account
|
|
1103
|
+
> takeover. The H1 security-hardening ticket dropped them. If you have
|
|
1104
|
+
> stale `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` secrets in the
|
|
1105
|
+
> repo, delete them and invalidate the underlying IAM access key in
|
|
1106
|
+
> IAM → Users → Security credentials.
|
|
1107
|
+
|
|
1108
|
+
### 12.3 OIDC is mandatory
|
|
967
1109
|
|
|
968
|
-
The workflow
|
|
1110
|
+
The workflow accepts **only** GitHub OIDC web-identity credentials:
|
|
969
1111
|
|
|
970
1112
|
```yaml
|
|
1113
|
+
permissions:
|
|
1114
|
+
id-token: write
|
|
1115
|
+
contents: read
|
|
1116
|
+
|
|
1117
|
+
# …
|
|
1118
|
+
|
|
1119
|
+
- name: Assert OIDC deploy role ARN is set
|
|
1120
|
+
if: env.AWS_DEPLOY_ROLE_ARN == ''
|
|
1121
|
+
env:
|
|
1122
|
+
AWS_DEPLOY_ROLE_ARN: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
|
1123
|
+
run: |
|
|
1124
|
+
echo "::error::AWS_DEPLOY_ROLE_ARN secret is empty …"
|
|
1125
|
+
exit 1
|
|
1126
|
+
|
|
971
1127
|
- name: Configure AWS credentials
|
|
972
1128
|
uses: aws-actions/configure-aws-credentials@v4
|
|
973
1129
|
with:
|
|
974
1130
|
aws-region: ${{ secrets.AWS_REGION }}
|
|
975
1131
|
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
|
976
|
-
|
|
977
|
-
|
|
1132
|
+
|
|
1133
|
+
- name: Assert assumed role matches AWS_DEPLOY_ROLE_ARN
|
|
1134
|
+
# aws sts get-caller-identity + ARN-shape check; aborts before cdk deploy.
|
|
1135
|
+
run: …
|
|
978
1136
|
```
|
|
979
1137
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
workflow
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1138
|
+
The `id-token: write` permission lets Actions mint the OIDC JWT that
|
|
1139
|
+
`sts:AssumeRoleWithWebIdentity` consumes. After the assume-role step
|
|
1140
|
+
the workflow calls `aws sts get-caller-identity` and asserts the
|
|
1141
|
+
returned ARN's role-name + account-id match `AWS_DEPLOY_ROLE_ARN`, so
|
|
1142
|
+
a silent fallback to ambient static credentials cannot ship a deploy.
|
|
1143
|
+
See §17 for the matching IAM trust policy and verification.
|
|
986
1144
|
|
|
987
|
-
### 12.4
|
|
1145
|
+
### 12.4 Manual-only deploy trigger
|
|
988
1146
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1147
|
+
The workflow's `on:` block is `workflow_dispatch`-only — there is no
|
|
1148
|
+
automatic push-to-main trigger, and no separate staging/prod target.
|
|
1149
|
+
A maintainer runs the deploy by hand against whichever AWS account
|
|
1150
|
+
and region the secrets point at; staging vs production isolation is
|
|
1151
|
+
driven by account/region, not by a workflow-per-env split.
|
|
993
1152
|
|
|
994
1153
|
---
|
|
995
1154
|
|
|
@@ -1005,7 +1164,7 @@ The CDK bootstrap stack hasn't been created in this account/region.
|
|
|
1005
1164
|
```bash
|
|
1006
1165
|
cd apps/aws-stack
|
|
1007
1166
|
pnpm cdk bootstrap
|
|
1008
|
-
pnpm deploy:aws
|
|
1167
|
+
pnpm deploy:aws
|
|
1009
1168
|
```
|
|
1010
1169
|
|
|
1011
1170
|
Bootstrap is idempotent and per-region — re-run it if you change
|
|
@@ -1024,7 +1183,7 @@ injects these — if one is missing in the console, somebody hand-edited
|
|
|
1024
1183
|
the Lambda config. Fix by re-deploying:
|
|
1025
1184
|
|
|
1026
1185
|
```bash
|
|
1027
|
-
pnpm deploy:aws
|
|
1186
|
+
pnpm deploy:aws
|
|
1028
1187
|
```
|
|
1029
1188
|
|
|
1030
1189
|
If the error mentions `MANAGEMENT_URL` being absent: that env var is
|
|
@@ -1073,7 +1232,7 @@ to resolve the smoke URL. If this returns nothing, either:
|
|
|
1073
1232
|
name, the workflow's `--stack-name IrcAwsStack` won't find it.
|
|
1074
1233
|
Update the workflow or rename back.
|
|
1075
1234
|
- **The deploy failed silently.** Check the prior `pnpm
|
|
1076
|
-
deploy:aws
|
|
1235
|
+
deploy:aws` step's exit code. `cdk deploy` with
|
|
1077
1236
|
`--require-approval never` can still fail on IAM or
|
|
1078
1237
|
CloudFormation errors.
|
|
1079
1238
|
- **Wrong region.** The `describe-stacks` call uses `AWS_REGION` from
|
|
@@ -1234,14 +1393,18 @@ npm install -g aws-cdk-local
|
|
|
1234
1393
|
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1
|
|
1235
1394
|
LOCALSTACK=1 pnpm --filter @serverless-ircd/aws-stack test
|
|
1236
1395
|
|
|
1237
|
-
#
|
|
1238
|
-
pnpm deploy:aws
|
|
1396
|
+
# Deploy (single target — staging vs prod is which account/region you point at)
|
|
1397
|
+
pnpm deploy:aws
|
|
1239
1398
|
node apps/aws-stack/scripts/smoke.mjs \
|
|
1240
1399
|
--url wss://<id>.execute-api.<region>.amazonaws.com/prod
|
|
1241
1400
|
aws logs tail /aws/lambda/IrcHandler --follow # live logs
|
|
1242
1401
|
|
|
1243
|
-
#
|
|
1244
|
-
pnpm deploy:aws
|
|
1402
|
+
# Web client (opt-in; provision via webSite/webSiteCustomDomain context)
|
|
1403
|
+
pnpm deploy:aws -- -c webSiteCustomDomain=app.example.com \
|
|
1404
|
+
-c webSiteCertificateArn=arn:aws:acm:us-east-1:... \
|
|
1405
|
+
-c webSiteHostedZoneName=example.com. -c webSiteHostedZoneId=... \
|
|
1406
|
+
-c webOrigins=https://app.example.com # CSWSH (§8.2, §16.4)
|
|
1407
|
+
node scripts/deploy-web-aws.mjs # bake + s3 sync + invalidate (§16.3)
|
|
1245
1408
|
|
|
1246
1409
|
# Tests / lint
|
|
1247
1410
|
pnpm test # full workspace
|
|
@@ -1263,9 +1426,420 @@ Key files:
|
|
|
1263
1426
|
| `packages/aws-adapter/src/tables.ts` | Runtime table-name + key-column constants. |
|
|
1264
1427
|
| `packages/aws-adapter/src/config-loader.ts` | Lambda env → `ServerConfig` schema mapping. |
|
|
1265
1428
|
| `packages/aws-adapter/src/handlers/index.ts` | Lambda entry points (`handler`, `sweeperHandler`, `pingCheckerHandler`). |
|
|
1429
|
+
| `apps/aws-stack/src/static-site.ts` | CDK construct for the optional web client (S3 + CloudFront + OAC). See §16. |
|
|
1430
|
+
| `scripts/deploy-web-aws.mjs` | Two-phase web client deploy helper. See §16. |
|
|
1266
1431
|
| `.github/workflows/deploy-aws.yml` | Staging deploy + smoke e2e CI. |
|
|
1267
1432
|
|
|
1268
|
-
|
|
1433
|
+
---
|
|
1434
|
+
|
|
1435
|
+
## 16. Web client (S3 + CloudFront + OAC)
|
|
1436
|
+
|
|
1437
|
+
The CF Worker serves the SPA from the same origin as the WebSocket
|
|
1438
|
+
(one Worker, one domain). AWS **cannot** do this — API Gateway
|
|
1439
|
+
WebSocket and a static SPA host are different AWS services on
|
|
1440
|
+
different endpoints — so the AWS web client splits in two: the wss
|
|
1441
|
+
IRC endpoint stays on API Gateway, and the SPA is served from an S3
|
|
1442
|
+
bucket fronted by CloudFront. This § documents the architecture, the
|
|
1443
|
+
build, the deploy pipeline, the CSWSH defence (mandatory once the
|
|
1444
|
+
SPA ships), and the optional custom-domain setup.
|
|
1445
|
+
|
|
1446
|
+
> **Cross-reference:** `docs/WebClientGuide.md` covers the
|
|
1447
|
+
> Cloudflare path (one origin for HTTP + WebSocket). The CF-vs-AWS
|
|
1448
|
+
> architectural difference is called out in §16.5 below.
|
|
1449
|
+
|
|
1450
|
+
### 16.1 What gets provisioned
|
|
1451
|
+
|
|
1452
|
+
The `StaticSite` construct (`apps/aws-stack/src/static-site.ts`,
|
|
1453
|
+
provisioned only when `IrcStackProps.webSite` is supplied) creates:
|
|
1454
|
+
|
|
1455
|
+
- a **private S3 bucket** holding the `apps/web/dist` build artifact
|
|
1456
|
+
(`index.html` for the landing page, `webclient/index.html` for the
|
|
1457
|
+
Kiwi SPA, `webclient/static/*` for hashed JS/CSS) — the same layout
|
|
1458
|
+
the CF Worker's `[assets]` binding serves, so one artifact ships on
|
|
1459
|
+
both platforms unchanged;
|
|
1460
|
+
- a **CloudFront distribution** with an S3 origin pointing at the
|
|
1461
|
+
bucket via **Origin Access Control (OAC)** — the modern replacement
|
|
1462
|
+
for the legacy OAI. Direct S3 access (`https://<bucket>.s3.…/…`)
|
|
1463
|
+
is denied by the bucket policy; only CloudFront URLs resolve;
|
|
1464
|
+
- `defaultRootObject = index.html` so requests to `/` serve the
|
|
1465
|
+
project landing page (the static HTML front door with links to the
|
|
1466
|
+
SPA, the rendered docs, and the source repo);
|
|
1467
|
+
- a **custom error response** mapping S3's `403`/`404` → `200` with
|
|
1468
|
+
response page `/webclient/index.html`, so client-side routes under
|
|
1469
|
+
`/webclient/*` resolve to the SPA shell (Kiwi is hash/history-routed);
|
|
1470
|
+
- three CFN outputs at the parent-stack level (stable logical IDs):
|
|
1471
|
+
`WebsiteURL` (the `*.cloudfront.net` default OR the custom domain),
|
|
1472
|
+
`WebsiteBucketName`, `WebsiteDistributionId`. The deploy pipeline
|
|
1473
|
+
(§16.3) consumes the latter two.
|
|
1474
|
+
|
|
1475
|
+
**Why not Amplify / why not S3-only:** Amplify Hosting adds a managed
|
|
1476
|
+
build step the project doesn't need (the SPA build is already
|
|
1477
|
+
`pnpm --filter web build:prod-aws`); S3-only (no CloudFront) forces
|
|
1478
|
+
either a public bucket (anti-pattern) or a signed-URL-per-asset
|
|
1479
|
+
scheme (heavy). CloudFront + OAC is the AWS-recommended static-site
|
|
1480
|
+
shape and gives HTTPS on `*.cloudfront.net` for free.
|
|
1481
|
+
|
|
1482
|
+
### 16.2 Opt-in: provision the web client
|
|
1483
|
+
|
|
1484
|
+
The web client is **opt-in**. To provision it, pass `webSite` props
|
|
1485
|
+
on the stack. From CDK context (the easiest path), set
|
|
1486
|
+
`webSiteCustomDomain`:
|
|
1487
|
+
|
|
1488
|
+
```bash
|
|
1489
|
+
pnpm deploy:aws -- \
|
|
1490
|
+
-c webSiteCustomDomain=app.example.com \
|
|
1491
|
+
-c webSiteCertificateArn=arn:aws:acm:us-east-1:111122223333:certificate/abc-def-... \
|
|
1492
|
+
-c webSiteHostedZoneName=example.com. \
|
|
1493
|
+
-c webSiteHostedZoneId=Z2KVMGOMGMOU2
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
The ACM certificate **must** be in `us-east-1` (CloudFront requirement).
|
|
1497
|
+
Hosted-zone name + ID are both required because CDK's
|
|
1498
|
+
`fromHostedZoneAttributes` needs both; retrieve them once via
|
|
1499
|
+
`aws route53 list-hosted-zones-by-name --dns-name <zone>`.
|
|
1500
|
+
|
|
1501
|
+
> **Default-`*.cloudfront.net` only (no custom domain) gap:**
|
|
1502
|
+
> `bin/aws.ts` currently keys the `webSite` prop's existence on
|
|
1503
|
+
> `webSiteCustomDomain` being set, so provisioning the construct
|
|
1504
|
+
> without a custom domain requires editing `bin/aws.ts` to pass
|
|
1505
|
+
> `webSite: {}` directly. Tracked as a follow-up. For v1 deploys,
|
|
1506
|
+
> supply a custom domain (prod-style) or patch `bin/aws.ts`.
|
|
1507
|
+
|
|
1508
|
+
### 16.3 Two-phase deploy (stack → bake → s3 sync → invalidate)
|
|
1509
|
+
|
|
1510
|
+
Unlike the CF Worker (where `wrangler deploy` ships the SPA in the
|
|
1511
|
+
same command as the runtime), the AWS SPA deploy is **two-phase** and
|
|
1512
|
+
**stack-output-driven**: the CloudFormation stack must exist before
|
|
1513
|
+
the SPA can be baked, because the baked `config.json` carries the
|
|
1514
|
+
API Gateway WebSocket URL as a literal. The deploy pipeline
|
|
1515
|
+
(`scripts/deploy-web-aws.mjs`) runs:
|
|
1516
|
+
|
|
1517
|
+
1. `aws cloudformation describe-stacks --stack-name IrcAwsStack`
|
|
1518
|
+
reads the `ConnectUrl`, `WebsiteBucketName`, and
|
|
1519
|
+
`WebsiteDistributionId` outputs. If any are missing the script
|
|
1520
|
+
exits non-zero ("stack not deployed" or "web client construct not
|
|
1521
|
+
provisioned").
|
|
1522
|
+
2. `pnpm --filter @serverless-ircd/web run build:prod-aws -- --api-url <ConnectUrl>`
|
|
1523
|
+
bakes the SPA with the real API Gateway URL. The `--api-url` flag is
|
|
1524
|
+
overridden **in memory** after Zod parse — the source
|
|
1525
|
+
`apps/web/static/config.prod-aws.json` is never mutated, so the
|
|
1526
|
+
templated `{{API_ID}}.execute-api.{{REGION}}.amazonaws.com` host and
|
|
1527
|
+
`/{{STAGE}}` path placeholders stay in the repo and no concrete URL
|
|
1528
|
+
is committed. The build rejects any leftover `{{...}}` placeholders.
|
|
1529
|
+
|
|
1530
|
+
The flag value (`wss://<id>.execute-api.<region>.amazonaws.com/<stage>`)
|
|
1531
|
+
is **split** by `parseApiUrlForKiwi` into Kiwi's two URL fields:
|
|
1532
|
+
`server` ← `<id>.execute-api.<region>.amazonaws.com` (hostname only,
|
|
1533
|
+
no scheme) and `direct_path` ← `/<stage>`. irc-framework builds the
|
|
1534
|
+
WS URL itself as `wss://<server>:<port><direct_path>` — it prepends
|
|
1535
|
+
`wss://` whenever `tls: true`, so passing the full URL through to
|
|
1536
|
+
`server` would double the scheme (`wss://wss//<host>...`) and Firefox
|
|
1537
|
+
would reject the connection. The split keeps `server` host-only and
|
|
1538
|
+
routes the API Gateway stage name through `direct_path`.
|
|
1539
|
+
3. `aws s3 sync apps/web/dist s3://<WebsiteBucketName> --delete`
|
|
1540
|
+
uploads the build. The CLI skips unchanged objects via etag
|
|
1541
|
+
comparison, so a re-deploy with no SPA changes uploads nothing.
|
|
1542
|
+
(This is why the deploy uses the AWS CLI rather than the SDK —
|
|
1543
|
+
`s3 sync`'s etag-based unchanged-skip is non-trivial to reproduce
|
|
1544
|
+
on `@aws-sdk/client-s3`.)
|
|
1545
|
+
4. `aws cloudfront create-invalidation --distribution-id <WebsiteDistributionId> --paths /*`
|
|
1546
|
+
busts the edge cache so the next visitor gets the new build.
|
|
1547
|
+
|
|
1548
|
+
**CI wiring:** the `deploy-web` job in `.github/workflows/deploy-aws.yml`
|
|
1549
|
+
runs `node scripts/deploy-web-aws.mjs` with `needs: deploy` (after the
|
|
1550
|
+
CDK stack applies). Both jobs are `workflow_dispatch`-only — a
|
|
1551
|
+
maintainer runs the workflow by hand (T170 collapsed the staging/prod
|
|
1552
|
+
split; there is no push trigger and no `staging` branch).
|
|
1553
|
+
|
|
1554
|
+
**Why a script instead of CDK `BucketDeployment`:** `BucketDeployment`
|
|
1555
|
+
re-synths on every deploy, is slower, and — critically — cannot bake
|
|
1556
|
+
a post-deploy stack output (the API Gateway URL) into the SPA config,
|
|
1557
|
+
because at synth time the URL does not exist yet. The two-phase
|
|
1558
|
+
script reads the output, then bakes.
|
|
1559
|
+
|
|
1560
|
+
### 16.4 CSWSH defence (`WEB_ORIGINS`) — mandatory once the SPA ships
|
|
1561
|
+
|
|
1562
|
+
**Once you deploy the web client, set `WEB_ORIGINS`.** WebSocket
|
|
1563
|
+
upgrades do not follow the same-origin policy, so a malicious web
|
|
1564
|
+
page can otherwise open a wss to the IRC endpoint from a victim's
|
|
1565
|
+
browser and drive the session with the victim's credentials
|
|
1566
|
+
(Cross-Site WebSocket Hijacking, CSWSH). API Gateway WebSocket has
|
|
1567
|
+
no built-in Origin validation — the `$connect` Lambda enforces it.
|
|
1568
|
+
|
|
1569
|
+
Pass the SPA's origin alongside the `webSite` props:
|
|
1570
|
+
|
|
1571
|
+
```bash
|
|
1572
|
+
pnpm deploy:aws -- \
|
|
1573
|
+
-c webSiteCustomDomain=app.example.com \
|
|
1574
|
+
-c webSiteCertificateArn=... \
|
|
1575
|
+
-c webSiteHostedZoneName=example.com. \
|
|
1576
|
+
-c webSiteHostedZoneId=... \
|
|
1577
|
+
-c webOrigins=https://app.example.com
|
|
1578
|
+
```
|
|
1579
|
+
|
|
1580
|
+
The full behaviour matrix, matching semantics, and the deliberate
|
|
1581
|
+
divergence from the CF Worker policy (no same-origin auto-derive on
|
|
1582
|
+
AWS — the SPA and the wss endpoint are on different origins) are in
|
|
1583
|
+
**§8.2**. Unset `WEB_ORIGINS` disables the check; this is why the
|
|
1584
|
+
defence is documented as "mandatory-once-the-SPA-ships" rather than
|
|
1585
|
+
"mandatory always" — bare-IRC deployments without a web frontend
|
|
1586
|
+
(`curl`, WeeChat, `tcp-ws-forwarder`) never send `Origin` and are
|
|
1587
|
+
correctly served without the knob.
|
|
1588
|
+
|
|
1589
|
+
### 16.5 CF-vs-AWS: one origin vs two origins
|
|
1590
|
+
|
|
1591
|
+
The architectural difference is the reason this § exists:
|
|
1592
|
+
|
|
1593
|
+
| Platform | HTTP (SPA) origin | WebSocket origin | Same-origin? |
|
|
1594
|
+
|----------|---------------------------------|-----------------------------------|--------------|
|
|
1595
|
+
| CF | the Worker (`*.workers.dev` / custom domain) | the same Worker | **Yes** — one domain serves both |
|
|
1596
|
+
| AWS | CloudFront (`*.cloudfront.net` / custom domain) | API Gateway (`*.execute-api.*.amazonaws.com`) | **No** — two different endpoints |
|
|
1597
|
+
|
|
1598
|
+
Two practical consequences:
|
|
1599
|
+
|
|
1600
|
+
1. **The baked SPA config (`config.prod-aws.json`) carries a templated
|
|
1601
|
+
host + stage path** because the browser cannot infer either from
|
|
1602
|
+
`window.location`. The CF config (`config.prod.json`) uses Kiwi's
|
|
1603
|
+
`{{hostname}}` template resolved at load time and no `direct_path`
|
|
1604
|
+
(the Worker's WS upgrade is at root). The AWS config templates
|
|
1605
|
+
`server` = `{{API_ID}}.execute-api.{{REGION}}.amazonaws.com` (host
|
|
1606
|
+
only — irc-framework prepends `wss://` from `tls: true`) and
|
|
1607
|
+
`direct_path` = `/{{STAGE}}` (the API Gateway stage segment). The
|
|
1608
|
+
AWS deploy pipeline splits the stack-output URL into these two
|
|
1609
|
+
fields at bake time (§16.3 step 2).
|
|
1610
|
+
2. **The CF Worker's CSWSH defence has a same-origin auto-derive
|
|
1611
|
+
mode** (when `WEB_ORIGINS` is unset, compare `Origin` against the
|
|
1612
|
+
request's own `Host`). AWS cannot use auto-derive — the request's
|
|
1613
|
+
own host is the API Gateway domain, never the SPA's — so the AWS
|
|
1614
|
+
defence is explicit-allowlist only (§8.2).
|
|
1615
|
+
|
|
1616
|
+
### 16.6 Custom domain (ACM + CloudFront alias + Route53)
|
|
1617
|
+
|
|
1618
|
+
`StaticSite` accepts four optional props for the custom domain (see
|
|
1619
|
+
`apps/aws-stack/src/static-site.ts`):
|
|
1620
|
+
|
|
1621
|
+
| Prop | Purpose |
|
|
1622
|
+
|-------------------|----------------------------------------------------------------------|
|
|
1623
|
+
| `customDomain` | The hostname to serve the SPA on (e.g. `app.example.com`). |
|
|
1624
|
+
| `certificateArn` | ARN of an ACM certificate covering `customDomain`. Must be in `us-east-1`. |
|
|
1625
|
+
| `hostedZoneName` | Public Route53 hosted zone name (e.g. `example.com.`). |
|
|
1626
|
+
| `hostedZoneId` | Public Route53 hosted zone ID for the above. |
|
|
1627
|
+
|
|
1628
|
+
When all four are supplied (the CDK context wiring in `bin/aws.ts`
|
|
1629
|
+
handles the optional spread), the construct adds the alias to the
|
|
1630
|
+
CloudFront distribution and creates a Route53 `ARecord` aliasing the
|
|
1631
|
+
distribution. When `customDomain` is omitted, the distribution serves
|
|
1632
|
+
on its default `*.cloudfront.net` URL — but see the gap note in
|
|
1633
|
+
§16.2 (the current `bin/aws.ts` wiring only provisions the construct
|
|
1634
|
+
at all when `webSiteCustomDomain` is set).
|
|
1635
|
+
|
|
1636
|
+
ACM certificate validation: the certificate must be DNS-validated
|
|
1637
|
+
and in `ISSUED` status before `cdk deploy` runs — CloudFront will
|
|
1638
|
+
not deploy an alias to a pending certificate. Create it ahead of time
|
|
1639
|
+
in `us-east-1` via the console or:
|
|
1640
|
+
|
|
1641
|
+
```bash
|
|
1642
|
+
aws acm request-certificate \
|
|
1643
|
+
--domain-name app.example.com \
|
|
1644
|
+
--validation-method DNS \
|
|
1645
|
+
--region us-east-1
|
|
1646
|
+
# add the returned CNAME to Route53, wait for ISSUED
|
|
1647
|
+
```
|
|
1648
|
+
|
|
1649
|
+
### 16.7 Verification (manual smoke check)
|
|
1650
|
+
|
|
1651
|
+
After a `deploy-web` job completes:
|
|
1652
|
+
|
|
1653
|
+
```bash
|
|
1654
|
+
# Stack outputs
|
|
1655
|
+
aws cloudformation describe-stacks --stack-name IrcAwsStack \
|
|
1656
|
+
--query 'Stacks[0].Outputs[?OutputKey==`WebsiteURL`].OutputValue' --output text
|
|
1657
|
+
|
|
1658
|
+
# Landing page
|
|
1659
|
+
curl -sI https://<distribution-or-custom-domain>/ | head -1
|
|
1660
|
+
# Landing page (the project front door — links to SPA + docs + source repo)
|
|
1661
|
+
curl -sI https://<distribution-or-custom-domain>/ | head -1
|
|
1662
|
+
# SPA shell (200, served via the 404→/webclient/index.html error mapping for /webclient/* routes)
|
|
1663
|
+
curl -sI https://<distribution-or-custom-domain>/webclient/ | head -1
|
|
1664
|
+
# Baked config — server MUST be the real wss URL, not a {{...}} template
|
|
1665
|
+
curl -s https://<distribution-or-custom-domain>/webclient/static/config.json | jq .startupOptions.server
|
|
1666
|
+
```
|
|
1667
|
+
|
|
1668
|
+
Open `https://<distribution-or-custom-domain>/webclient/` in a browser,
|
|
1669
|
+
register, join `#welcome`, send a `PRIVMSG`, see the echo. (Visiting the
|
|
1670
|
+
root `https://<distribution-or-custom-domain>/` serves the landing page,
|
|
1671
|
+
which links to the SPA at `/webclient/`.) If the SPA loads but never
|
|
1672
|
+
connects, check (1) the baked `config.json` `server` matches the stack's
|
|
1673
|
+
`ConnectUrl` output, (2) `WEB_ORIGINS` (§16.4) includes the CloudFront
|
|
1674
|
+
origin you're loading the SPA from, and (3) the `$connect` route is
|
|
1675
|
+
reachable.
|
|
1676
|
+
|
|
1677
|
+
---
|
|
1678
|
+
|
|
1679
|
+
## 17. OIDC deploy role (CI)
|
|
1680
|
+
|
|
1681
|
+
The GitHub Actions deploy workflow (§12) accepts **only** GitHub OIDC
|
|
1682
|
+
web-identity credentials. Long-lived access keys
|
|
1683
|
+
(`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) are NOT supported — a
|
|
1684
|
+
single exfiltration vector against them (rogue PR `printenv`,
|
|
1685
|
+
compromised third-party Action, poisoned runner) yields full AWS
|
|
1686
|
+
account takeover because the deploy role can manage CloudFormation,
|
|
1687
|
+
IAM, Lambda, and DynamoDB. This section documents the operator-side
|
|
1688
|
+
setup that the `AWS_DEPLOY_ROLE_ARN` repo secret points at.
|
|
1689
|
+
|
|
1690
|
+
### 17.1 One-time AWS account setup
|
|
1691
|
+
|
|
1692
|
+
1. **Create the GitHub OIDC identity provider** (one per AWS account,
|
|
1693
|
+
idempotent):
|
|
1694
|
+
|
|
1695
|
+
```bash
|
|
1696
|
+
aws iam create-open-id-connect-provider \
|
|
1697
|
+
--url https://token.actions.githubusercontent.com \
|
|
1698
|
+
--client-id-list sts.amazonaws.com \
|
|
1699
|
+
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 \
|
|
1700
|
+
--tags Key=managed-by,Value=github-actions
|
|
1701
|
+
```
|
|
1702
|
+
|
|
1703
|
+
> The `--thumbprint-list` above is the well-known root CA
|
|
1704
|
+
> thumbprint GitHub's OIDC tokens chain to. AWS now also accepts
|
|
1705
|
+
> the trust-root automatically, but specifying it keeps the
|
|
1706
|
+
> provider deterministic across accounts.
|
|
1707
|
+
|
|
1708
|
+
2. **Create the deploy role** with the trust policy below (save as
|
|
1709
|
+
`trust-policy.json` then `create-role`). The single condition key
|
|
1710
|
+
binds the role to this repository's `main` branch — the only ref
|
|
1711
|
+
the `workflow_dispatch` deploy workflow can run from:
|
|
1712
|
+
|
|
1713
|
+
```json
|
|
1714
|
+
{
|
|
1715
|
+
"Version": "2012-10-17",
|
|
1716
|
+
"Statement": [
|
|
1717
|
+
{
|
|
1718
|
+
"Effect": "Allow",
|
|
1719
|
+
"Principal": {
|
|
1720
|
+
"Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
|
|
1721
|
+
},
|
|
1722
|
+
"Action": "sts:AssumeRoleWithWebIdentity",
|
|
1723
|
+
"Condition": {
|
|
1724
|
+
"StringEquals": {
|
|
1725
|
+
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
|
1726
|
+
"token.actions.githubusercontent.com:sub": "repo:<owner>/<repo>:ref:refs/heads/main"
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
]
|
|
1731
|
+
}
|
|
1732
|
+
```
|
|
1733
|
+
|
|
1734
|
+
Replace `<ACCOUNT_ID>` with the AWS account id and `<owner>/<repo>`
|
|
1735
|
+
with the GitHub owner/repo (e.g. `ServerlessIRCd/ServerlessIRCd`).
|
|
1736
|
+
The `sub` condition is the security boundary: only workflows
|
|
1737
|
+
running on a ref matching `refs/heads/main` in this exact repo can
|
|
1738
|
+
assume the role. Do NOT widen it to `StringLike` with a wildcard —
|
|
1739
|
+
that lets forked PRs mint credentials.
|
|
1740
|
+
|
|
1741
|
+
```bash
|
|
1742
|
+
aws iam create-role \
|
|
1743
|
+
--role-name IrcDeployRole \
|
|
1744
|
+
--assume-role-policy-document file://trust-policy.json \
|
|
1745
|
+
--description "GitHub Actions OIDC role for IrcAwsStack deploys"
|
|
1746
|
+
|
|
1747
|
+
# Note the returned ARN — this becomes AWS_DEPLOY_ROLE_ARN.
|
|
1748
|
+
aws iam get-role --role-name IrcDeployRole --query 'Role.Arn' --output text
|
|
1749
|
+
```
|
|
1750
|
+
|
|
1751
|
+
3. **Attach a permission policy.** The role needs the same surface
|
|
1752
|
+
`cdk deploy` uses: IAM (pass-role + role creation), API Gateway v2,
|
|
1753
|
+
Lambda, DynamoDB, EventBridge, CloudFormation, S3 (the CDK
|
|
1754
|
+
bootstrap bucket), and logs. The CDK bootstrap already creates a
|
|
1755
|
+
`cdk-hnb659fds-deploy-role-<account>-<region>` role with the right
|
|
1756
|
+
shape — for a v1 deployment, the simplest pattern is to let the
|
|
1757
|
+
GitHub-OIDC role `sts:AssumeRole` the CDK deploy role:
|
|
1758
|
+
|
|
1759
|
+
```json
|
|
1760
|
+
{
|
|
1761
|
+
"Version": "2012-10-17",
|
|
1762
|
+
"Statement": [
|
|
1763
|
+
{
|
|
1764
|
+
"Effect": "Allow",
|
|
1765
|
+
"Action": "sts:AssumeRole",
|
|
1766
|
+
"Resource": "arn:aws:iam::<ACCOUNT_ID>:role/cdk-hnb659fds-deploy-role-<ACCOUNT_ID>-<REGION>"
|
|
1767
|
+
}
|
|
1768
|
+
]
|
|
1769
|
+
}
|
|
1770
|
+
```
|
|
1771
|
+
|
|
1772
|
+
Attach this as an inline policy on `IrcDeployRole`. (If you would
|
|
1773
|
+
rather grant the deploy permissions directly on `IrcDeployRole`,
|
|
1774
|
+
mirror the statement list from the CDK deploy role's
|
|
1775
|
+
`DefaultDeploymentRole`.)
|
|
1776
|
+
|
|
1777
|
+
4. **Store the role ARN as the `AWS_DEPLOY_ROLE_ARN` repo secret**
|
|
1778
|
+
(Settings → Secrets and variables → Actions → New repository
|
|
1779
|
+
secret). This is the **only** AWS-shaped secret the workflow reads.
|
|
1780
|
+
|
|
1781
|
+
### 17.2 Condition key reference
|
|
1782
|
+
|
|
1783
|
+
| JWT claim (`token.actions.githubusercontent.com:<key>`) | Value to bind | Meaning |
|
|
1784
|
+
|---------------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------------|
|
|
1785
|
+
| `aud` | `sts.amazonaws.com` | Required. AWS is the intended audience of the OIDC token. |
|
|
1786
|
+
| `sub` | `repo:<owner>/<repo>:ref:refs/heads/main` | Required. Pinning to `main` matches the `workflow_dispatch` trigger. |
|
|
1787
|
+
|
|
1788
|
+
The workflow's `on:` block is `workflow_dispatch` only, and a
|
|
1789
|
+
maintainer runs it from the `main` branch in the GitHub UI, so the
|
|
1790
|
+
`sub` claim always carries `:ref:refs/heads/main`. If you later add a
|
|
1791
|
+
push trigger on another branch, add a second condition entry rather
|
|
1792
|
+
than loosening this one.
|
|
1793
|
+
|
|
1794
|
+
### 17.3 CI verification (operator-run, out-of-band)
|
|
1795
|
+
|
|
1796
|
+
Once the role and secret are configured, verify end-to-end by running
|
|
1797
|
+
the workflow from the Actions UI against a sandbox account. The
|
|
1798
|
+
workflow's `Assert assumed role matches AWS_DEPLOY_ROLE_ARN` step
|
|
1799
|
+
prints the assumed role-name + account on success:
|
|
1800
|
+
|
|
1801
|
+
```
|
|
1802
|
+
OIDC role assertion passed: IrcDeployRole in account 111122223333
|
|
1803
|
+
```
|
|
1804
|
+
|
|
1805
|
+
If that step fails, the trust policy condition keys do not match the
|
|
1806
|
+
workflow's `sub` claim — re-check `repo:<owner>/<repo>` and the branch
|
|
1807
|
+
ref. The workflow aborts BEFORE `cdk deploy`, so a misconfigured trust
|
|
1808
|
+
policy cannot mutate the stack.
|
|
1809
|
+
|
|
1810
|
+
Authoritative reference: GitHub's "Configuring OpenID Connect in
|
|
1811
|
+
Amazon Web Services" guide
|
|
1812
|
+
(<https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services>)
|
|
1813
|
+
and the AWS "Creating OpenID Connect (OIDC) identity providers" guide
|
|
1814
|
+
walk through the IAM-side setup in more detail.
|
|
1815
|
+
|
|
1816
|
+
### 17.4 What the workflow enforces in-repo
|
|
1817
|
+
|
|
1818
|
+
The CI-hardening test suite (`tools/ci-hardening/tests/deploy-aws-oidc.test.ts`)
|
|
1819
|
+
asserts the workflow shape so the security property cannot regress
|
|
1820
|
+
silently:
|
|
1821
|
+
|
|
1822
|
+
- No `aws-access-key-id` / `aws-secret-access-key` inputs to
|
|
1823
|
+
`configure-aws-credentials@v4` anywhere in the file.
|
|
1824
|
+
- Top-level `permissions:` block grants exactly
|
|
1825
|
+
`{ id-token: write, contents: read }` (least-privilege).
|
|
1826
|
+
- Every `configure-aws-credentials` step has a `role-to-assume` input.
|
|
1827
|
+
- The `deploy` and `deploy-web` jobs both have an
|
|
1828
|
+
`if: env.AWS_DEPLOY_ROLE_ARN == ''` guard that runs `exit 1` before
|
|
1829
|
+
any AWS call.
|
|
1830
|
+
- The `deploy` job runs `aws sts get-caller-identity` and references
|
|
1831
|
+
`AWS_DEPLOY_ROLE_ARN` before any `cdk deploy` (defence-in-depth
|
|
1832
|
+
against silent OIDC fallback).
|
|
1833
|
+
|
|
1834
|
+
The `deploy-web` job (TICKET-167, §16) inherits the same OIDC model:
|
|
1835
|
+
it has its own empty-ARN guard before `configure-aws-credentials` and
|
|
1836
|
+
assumes the same role via web identity — no static keys, no ambient
|
|
1837
|
+
credential inheritance from the parent `deploy` job. The two jobs are
|
|
1838
|
+
independent `workflow_dispatch` runs against the same role ARN.
|
|
1839
|
+
|
|
1840
|
+
---
|
|
1841
|
+
|
|
1842
|
+
## 18. Open questions / future work
|
|
1269
1843
|
|
|
1270
1844
|
These are facts the **code does not yet have an answer for**; the
|
|
1271
1845
|
recommendations above are deployer guidance, not built-in knobs:
|