serverless-ircd 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pure admission policy for the AWS `$connect` handler.
3
+ *
4
+ * The I/O (counting live rows in DynamoDB) lives in `handlers/connect.ts`;
5
+ * the *decision* — "does this count fit under the caps?" — is a pure
6
+ * function extracted here so it is trivial to unit-test every branch
7
+ * without standing up DynamoDB Local.
8
+ *
9
+ * This is deliberately separate from irc-core's `decideAdmission` /
10
+ * `AdmissionStats`: that helper is an in-memory counter design that
11
+ * assumes a single long-lived process. AWS Lambda invocations are
12
+ * independent processes with no shared memory, so the authoritative
13
+ * counts MUST come from DynamoDB (the shared `Connections` table) and
14
+ * are fed into this pure policy.
15
+ */
16
+
17
+ /**
18
+ * Live connection counts gathered from DynamoDB.
19
+ *
20
+ * - `total` is always supplied (a paginated `Scan COUNT` over
21
+ * `Connections`).
22
+ * - `perIp` is optional: counting connections per source IP needs a
23
+ * GSI on `sourceIp` (see `handlers/connect.ts` for the deferred
24
+ * per-IP follow-up). Until that lands, callers omit `perIp` and the
25
+ * per-IP branch of {@link decideConnectAdmission} is skipped.
26
+ */
27
+ export interface ConnectionCounts {
28
+ readonly total: number;
29
+ readonly perIp?: number;
30
+ }
31
+
32
+ /**
33
+ * Effective caps sourced from {@link ParsedServerConfig}. `perIp` is
34
+ * optional because per-IP enforcement is not yet wired on the AWS path.
35
+ */
36
+ export interface AdmissionLimits {
37
+ readonly maxClients: number;
38
+ readonly maxConnectionsPerIp?: number;
39
+ }
40
+
41
+ /**
42
+ * Outcome of an admission decision. Mirrors the {@link ConnectOutcome}
43
+ * shape returned by `handleConnect` so the handler can return the
44
+ * decision verbatim.
45
+ */
46
+ export type AdmissionOutcome =
47
+ | { readonly admitted: true }
48
+ | { readonly admitted: false; readonly statusCode: number; readonly reason: string };
49
+
50
+ /**
51
+ * Decides whether a new connection should be admitted given the live
52
+ * counts and the configured caps. Pure: same inputs ⇒ same output, no
53
+ * ambient state.
54
+ *
55
+ * Precedence: the global `maxClients` cap is checked first (it is the
56
+ * cheap, always-available gate), then the optional per-IP cap when both
57
+ * the limit and the `perIp` count are present.
58
+ */
59
+ export function decideConnectAdmission(
60
+ counts: ConnectionCounts,
61
+ limits: AdmissionLimits,
62
+ ): AdmissionOutcome {
63
+ if (counts.total >= limits.maxClients) {
64
+ return { admitted: false, statusCode: 429, reason: 'server full' };
65
+ }
66
+ if (
67
+ limits.maxConnectionsPerIp !== undefined &&
68
+ counts.perIp !== undefined &&
69
+ counts.perIp >= limits.maxConnectionsPerIp
70
+ ) {
71
+ return { admitted: false, statusCode: 429, reason: 'too many connections from this IP' };
72
+ }
73
+ return { admitted: true };
74
+ }
@@ -80,7 +80,8 @@ import type { TablesConfig } from './tables.js';
80
80
  * is the in-memory `received` array.
81
81
  * - `disconnect(reason?)` closes the bound transport. In Lambda this
82
82
  * is a no-op — the canonical disconnect path is the APIGW `$disconnect`
83
- * route, which fires after the socket is already gone.
83
+ * route, which fires after the socket is already gone. See
84
+ * `docs/AWS-Deployment.md` §14.1 for the full platform-limit write-up.
84
85
  * - `snapshot()` returns the in-memory ConnectionState the actor is
85
86
  * mutating this frame. Critical so `getConnection(boundId)` returns
86
87
  * the live view (and dispatch's reducer-observable effects see
@@ -185,11 +186,12 @@ export class AwsRuntime implements IrcRuntime {
185
186
  this.handlers.disconnect(reason);
186
187
  return;
187
188
  }
188
- // Cross-connection disconnect is a no-op in 040: the Lambda runtime
189
- // cannot directly close another connection's WebSocket. The canonical
189
+ // Cross-connection disconnect is a no-op: the Lambda runtime cannot
190
+ // directly close another connection's WebSocket. The canonical
190
191
  // disconnect path is the APIGW `$disconnect` route, which fires when
191
192
  // the socket is already gone. KICK/PART remove membership rows but
192
- // do not need to force-close the socket.
193
+ // do not need to force-close the socket. See docs/AWS-Deployment.md
194
+ // §14.1 for the full platform-limit write-up and the mitigations.
193
195
  }
194
196
 
195
197
  async sendToNick(
@@ -19,6 +19,17 @@ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc
19
19
  export interface LambdaConfigEnv {
20
20
  SERVER_NAME?: string;
21
21
  NETWORK_NAME?: string;
22
+ /**
23
+ * Server version surfaced in `002`/`004`/`351`/`371`. When unset the
24
+ * schema default ({@link DEFAULT_SERVER_VERSION}) applies.
25
+ */
26
+ SERVER_VERSION?: string;
27
+ /**
28
+ * "Created" text for `003 RPL_CREATED`. A numeric string is parsed into
29
+ * an epoch-ms number so the reducer formats it as a UTC timestamp; any
30
+ * other value is passed through verbatim.
31
+ */
32
+ CREATED_AT?: string;
22
33
  MOTD?: string;
23
34
  MAX_CLIENTS?: string;
24
35
  CHANNEL_PREFIXES?: string;
@@ -53,6 +64,12 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
53
64
  serverName: env.SERVER_NAME,
54
65
  networkName: env.NETWORK_NAME,
55
66
  };
67
+ if (env.SERVER_VERSION !== undefined) {
68
+ input.serverVersion = env.SERVER_VERSION;
69
+ }
70
+ if (env.CREATED_AT !== undefined) {
71
+ input.createdAt = parseCreatedAt(env.CREATED_AT);
72
+ }
56
73
  if (env.MOTD !== undefined && env.MOTD.length > 0) {
57
74
  input.motdLines = env.MOTD.split('\n');
58
75
  }
@@ -97,6 +114,21 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
97
114
  return input;
98
115
  }
99
116
 
117
+ /**
118
+ * Parses the `CREATED_AT` env var into the schema's `createdAt` field.
119
+ *
120
+ * A purely numeric string is treated as epoch-ms (returned as a number so
121
+ * the reducer formats it as a UTC timestamp); any other value is returned
122
+ * verbatim as a human-readable string.
123
+ */
124
+ function parseCreatedAt(raw: string): string | number {
125
+ if (/^\d+$/.test(raw.trim())) {
126
+ const n = Number(raw.trim());
127
+ if (Number.isFinite(n)) return n;
128
+ }
129
+ return raw;
130
+ }
131
+
100
132
  /**
101
133
  * Parses a newline-delimited `username:password` string into the config's
102
134
  * `saslAccounts` shape. Malformed entries (missing colon, empty fields)
@@ -16,108 +16,37 @@
16
16
  * `irc-core/src/ports.ts` and used by `MotdProvider` / `MessageStore`:
17
17
  * adapters that need an async backend pre-load the data at boot into a
18
18
  * synchronously-readable store.
19
+ *
20
+ * The scrypt hashing helpers (`hashAccountCredential` /
21
+ * `verifyHashedPassword` + the `HashedAccountCredential` type) live in
22
+ * `irc-core` and are shared with the CF adapter's `D1AccountStore`. They
23
+ * are re-exported below so existing call sites importing from this module
24
+ * keep working.
19
25
  */
20
26
 
21
- import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
22
27
  import type { ScanCommandOutput } from '@aws-sdk/client-dynamodb';
23
28
  import { ScanCommand } from '@aws-sdk/lib-dynamodb';
24
29
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
25
- import type { AccountStore, SaslPayload, SaslResult } from '@serverless-ircd/irc-core';
26
-
27
- const SCRYPT_KEY_LEN = 64;
28
- const SALT_LEN = 16;
30
+ import {
31
+ type HashedAccountCredential,
32
+ HashedAccountStore,
33
+ hashAccountCredential,
34
+ verifyHashedPassword,
35
+ } from '@serverless-ircd/irc-core';
29
36
 
30
- /**
31
- * A single SASL PLAIN credential stored as a scrypt hash.
32
- *
33
- * Every field is a DynamoDB attribute — this object IS the table row
34
- * (plus the PK `account`). `algorithm` is retained so future migrations
35
- * to Argon2/bcrypt can be detected and handled gracefully.
36
- */
37
- export interface HashedAccountCredential {
38
- readonly account: string;
39
- readonly algorithm: 'scrypt';
40
- readonly salt: string;
41
- readonly hash: string;
42
- }
43
-
44
- /**
45
- * Hashes a plaintext password into a {@link HashedAccountCredential}
46
- * suitable for writing to the `Accounts` table via `putAccountCredential`.
47
- *
48
- * Uses `scryptSync` (memory-hard, GPU-resistant) with a random salt.
49
- * Pass `{ salt }` for deterministic test scenarios; pass `{ keyLen }` to
50
- * override the default 64-byte derived-key length.
51
- */
52
- export function hashAccountCredential(
53
- username: string,
54
- password: string,
55
- opts?: { salt?: Buffer; keyLen?: number },
56
- ): HashedAccountCredential {
57
- const salt = opts?.salt ?? randomBytes(SALT_LEN);
58
- const keyLen = opts?.keyLen ?? SCRYPT_KEY_LEN;
59
- const hash = scryptSync(password, salt, keyLen);
60
- return {
61
- account: username,
62
- algorithm: 'scrypt',
63
- salt: salt.toString('base64'),
64
- hash: hash.toString('base64'),
65
- };
66
- }
37
+ // Re-export so callers importing these from `@serverless-ircd/aws-adapter`
38
+ // (the historical home of the hashing helpers) keep compiling.
39
+ export { type HashedAccountCredential, hashAccountCredential, verifyHashedPassword };
67
40
 
68
41
  /**
69
- * Verifies a plaintext password against a {@link HashedAccountCredential}
70
- * using `timingSafeEqual` (no short-circuit on first mismatched byte).
42
+ * Synchronous {@link AccountStore} backed by pre-loaded hashed credentials.
71
43
  *
72
- * Returns `false` for malformed entries rather than throwing a corrupt
73
- * row should not crash the SASL exchange.
44
+ * Now a re-export of the shared `HashedAccountStore` from `irc-core`
45
+ * both adapters use one store implementation. The AWS-specific surface
46
+ * (`loadDynamoAccountStore`) lives below; it diverges from CF only at the
47
+ * scan boundary.
74
48
  */
75
- export function verifyHashedPassword(password: string, entry: HashedAccountCredential): boolean {
76
- if (entry.algorithm !== 'scrypt') return false;
77
- let salt: Buffer;
78
- let expected: Buffer;
79
- try {
80
- salt = Buffer.from(entry.salt, 'base64');
81
- expected = Buffer.from(entry.hash, 'base64');
82
- } catch {
83
- return false;
84
- }
85
- if (expected.length === 0) return false;
86
- const computed = scryptSync(password, salt, expected.length);
87
- return computed.length === expected.length && timingSafeEqual(computed, expected);
88
- }
89
-
90
- /**
91
- * Synchronous {@link AccountStore} backed by pre-loaded hashed
92
- * credentials. Construct with entries from {@link loadDynamoAccountStore}
93
- * (or {@link hashAccountCredential} for unit tests).
94
- */
95
- export class DynamoAccountStore implements AccountStore {
96
- private readonly entries: ReadonlyMap<string, HashedAccountCredential>;
97
-
98
- constructor(entries: ReadonlyArray<HashedAccountCredential>) {
99
- this.entries = new Map(entries.map((e) => [e.account, e]));
100
- }
101
-
102
- verify(mech: string, payload: SaslPayload): SaslResult {
103
- if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
104
- return { ok: false, reason: `unsupported mechanism: ${mech}` };
105
- }
106
- const entry = this.entries.get(payload.username);
107
- if (entry === undefined) {
108
- return { ok: false, reason: 'invalid credentials' };
109
- }
110
- if (!verifyHashedPassword(payload.password, entry)) {
111
- return { ok: false, reason: 'invalid credentials' };
112
- }
113
- return { ok: true, account: payload.username };
114
- }
115
-
116
- /** Number of loaded accounts (diagnostics / logging). */
117
- get size(): number {
118
- return this.entries.size;
119
- }
120
- }
49
+ export { HashedAccountStore as DynamoAccountStore };
121
50
 
122
51
  /**
123
52
  * Scans the `Accounts` table and returns a fully-populated
@@ -130,7 +59,7 @@ export class DynamoAccountStore implements AccountStore {
130
59
  export async function loadDynamoAccountStore(
131
60
  docClient: DynamoDBDocumentClient,
132
61
  tableName: string,
133
- ): Promise<DynamoAccountStore | undefined> {
62
+ ): Promise<HashedAccountStore | undefined> {
134
63
  const entries: HashedAccountCredential[] = [];
135
64
  let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
136
65
  do {
@@ -145,12 +74,22 @@ export async function loadDynamoAccountStore(
145
74
  const algorithm = typeof item.algorithm === 'string' ? item.algorithm : undefined;
146
75
  const salt = typeof item.salt === 'string' ? item.salt : undefined;
147
76
  const hash = typeof item.hash === 'string' ? item.hash : undefined;
148
- if (account !== undefined && algorithm !== undefined && salt !== undefined && hash !== undefined) {
149
- entries.push({ account, algorithm: algorithm as HashedAccountCredential['algorithm'], salt, hash });
77
+ if (
78
+ account !== undefined &&
79
+ algorithm !== undefined &&
80
+ salt !== undefined &&
81
+ hash !== undefined
82
+ ) {
83
+ entries.push({
84
+ account,
85
+ algorithm: algorithm as HashedAccountCredential['algorithm'],
86
+ salt,
87
+ hash,
88
+ });
150
89
  }
151
90
  }
152
91
  exclusiveStartKey = result.LastEvaluatedKey;
153
92
  } while (exclusiveStartKey !== undefined);
154
93
  if (entries.length === 0) return undefined;
155
- return new DynamoAccountStore(entries);
94
+ return new HashedAccountStore(entries);
156
95
  }
@@ -4,11 +4,18 @@
4
4
  * Persists a fresh `Connections` row keyed by the APIGW connection id.
5
5
  * Subsequent `$default` invocations load this row, run the actor, and
6
6
  * persist the mutated state back.
7
+ *
8
+ * Admission gate: before writing the row, the handler counts the live
9
+ * `Connections` rows and rejects the upgrade with `429 Too Many Requests`
10
+ * once `serverConfig.maxClients` is reached. A `429` (not `403`) is used
11
+ * because the rejection is a capacity / back-pressure signal — the client
12
+ * is welcome to retry — rather than an authorisation failure.
7
13
  */
8
14
 
9
15
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
10
- import { PutCommand } from '@aws-sdk/lib-dynamodb';
16
+ import { PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
11
17
  import type { ParsedServerConfig } from '@serverless-ircd/irc-core';
18
+ import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
12
19
  import { marshalConnection } from '../serialize.js';
13
20
  import type { TablesConfig } from '../tables.js';
14
21
  import { createInitialConnectionState } from './state.js';
@@ -20,20 +27,43 @@ export interface ConnectParams {
20
27
  connectionId: string;
21
28
  /** Wall clock used to seed `connectedSince` / `idleSince` (default: now). */
22
29
  now?: number;
23
- /** Reserved for future use; required so the signature mirrors `$default`. */
24
- serverConfig?: ParsedServerConfig;
30
+ /** Server config consulted for the `maxClients` admission cap. */
31
+ serverConfig: ParsedServerConfig;
25
32
  }
26
33
 
34
+ /** Re-exported so the dispatcher imports the outcome type from one place. */
35
+ export type { AdmissionOutcome as ConnectOutcome } from '../admission.js';
36
+
27
37
  /**
28
- * Inserts the Connections row for a fresh WebSocket upgrade.
38
+ * Enforces the `maxClients` admission cap, then inserts the Connections
39
+ * row for a fresh WebSocket upgrade.
40
+ *
41
+ * Idempotent on success: APIGW retries `$connect` on transient failures,
42
+ * so the write uses `PutCommand` (overwrites) rather than a conditional
43
+ * insert.
44
+ *
45
+ * TOCTOU note: DynamoDB cannot conditionally write based on a row count,
46
+ * so the count-then-put sequence has a race window. This is an accepted
47
+ * trade-off for an admission cap — it bounds growth under steady load
48
+ * rather than enforcing a hard invariant. Two concurrent upgrades that
49
+ * both observe `count == cap - 1` will both be admitted.
29
50
  *
30
- * Idempotent: APIGW retries `$connect` on transient failures, so we
31
- * use `PutCommand` (overwrites) rather than a conditional insert.
51
+ * Per-IP admission (`maxConnectionsPerIp`) is intentionally NOT enforced
52
+ * here. irc-core's in-memory `AdmissionStats` is unsuitable for Lambda
53
+ * (each invocation is a fresh process with no shared counters), and a
54
+ * filtered full-table `Scan` per `$connect` would double the DynamoDB
55
+ * cost on every connect (and `maxConnectionsPerIp` is on by default).
56
+ * The correct fix is to persist `sourceIp` on the Connections row and
57
+ * add a DynamoDB GSI keyed by `sourceIp`, then feed the GSI's count
58
+ * into {@link decideConnectAdmission}'s `perIp` branch. That schema
59
+ * change is tracked as a follow-up; the pure policy already supports it.
32
60
  */
33
- export async function handleConnect(params: ConnectParams): Promise<void> {
61
+ export async function handleConnect(params: ConnectParams): Promise<AdmissionOutcome> {
34
62
  const now = params.now ?? Date.now();
63
+ const total = await countConnections(params.dynamo, params.tables.Connections);
64
+ const outcome = decideConnectAdmission({ total }, { maxClients: params.serverConfig.maxClients });
65
+ if (!outcome.admitted) return outcome;
35
66
  const state = createInitialConnectionState(params.connectionId, now);
36
- void params.serverConfig; // reserved for max-clients gating (later ticket)
37
67
  const row = marshalConnection(state, now);
38
68
  await params.dynamo.send(
39
69
  new PutCommand({
@@ -41,4 +71,30 @@ export async function handleConnect(params: ConnectParams): Promise<void> {
41
71
  Item: row,
42
72
  }),
43
73
  );
74
+ return outcome;
75
+ }
76
+
77
+ /**
78
+ * Paginated `Scan` with `Select: COUNT` over the `Connections` table.
79
+ * A single Scan page caps at 1MB, so the loop follows
80
+ * `LastEvaluatedKey` until every page has been summed.
81
+ */
82
+ async function countConnections(
83
+ dynamo: DynamoDBDocumentClient,
84
+ tableName: string,
85
+ ): Promise<number> {
86
+ let total = 0;
87
+ let lastKey: unknown = undefined;
88
+ do {
89
+ const res = await dynamo.send(
90
+ new ScanCommand({
91
+ TableName: tableName,
92
+ Select: 'COUNT',
93
+ ...(lastKey !== undefined ? { ExclusiveStartKey: lastKey as never } : {}),
94
+ }),
95
+ );
96
+ total += res.Count ?? 0;
97
+ lastKey = res.LastEvaluatedKey;
98
+ } while (lastKey !== undefined);
99
+ return total;
44
100
  }
@@ -19,6 +19,8 @@ import {
19
19
  type IdFactory,
20
20
  type MessageStore,
21
21
  type MotdProvider,
22
+ type MtlsIdentityProvider,
23
+ type NickHistoryStore,
22
24
  type ParsedServerConfig,
23
25
  SystemClock,
24
26
  UuidIdFactory,
@@ -53,6 +55,30 @@ export interface DefaultParams {
53
55
  * — preserves the behaviour of callers that have not opted in.
54
56
  */
55
57
  accounts?: AccountStore;
58
+ /**
59
+ * mTLS client-cert identity source for SASL EXTERNAL. When omitted the
60
+ * actor runs with `ctx.mtlsIdentity` undefined so `AUTHENTICATE EXTERNAL`
61
+ * fails with `904` — preserves the behaviour of callers that have not
62
+ * opted in.
63
+ *
64
+ * AWS APIGW custom-domain mTLS setup:
65
+ * 1. Upload a PEM trust store to S3 (the CA bundle clients must present).
66
+ * 2. Create a custom domain with `mutualTlsAuthentication` pointing at the
67
+ * S3 trust store URI.
68
+ * 3. Map the custom domain to the WebSocket API stage.
69
+ * 4. The `$connect` event's `requestContext.identity.clientCertSubjectDN`
70
+ * carries the verified cert subject — capture it at connect time and
71
+ * persist it alongside the connection row, then construct an
72
+ * {@link MtlsIdentityProvider} that resolves it by connection ID.
73
+ */
74
+ mtlsIdentity?: MtlsIdentityProvider;
75
+ /**
76
+ * Nick-history source for the `WHOWAS` command. When omitted the actor
77
+ * runs with `ctx.history` undefined so `WHOWAS` always returns
78
+ * `406`+`369` — preserves the behaviour of callers that have not opted
79
+ * in. A DynamoDB-backed persistent variant is a documented follow-up.
80
+ */
81
+ history?: NickHistoryStore;
56
82
  managementApi: ApiGatewayManagementApi | PostToConnection | null;
57
83
  /** Injected for tests; defaults to {@link SystemClock}. */
58
84
  clock?: Clock;
@@ -98,8 +124,13 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
98
124
  for (const l of lines) outbound.push(l.text);
99
125
  },
100
126
  disconnect: () => {
101
- // Lambda cannot close its own socket from inside the handler
102
- // (the `$disconnect` route fires when APIGW observes the close).
127
+ // No-op: a Lambda `$default` invocation cannot close its own APIGW
128
+ // socket. The canonical teardown is the `$disconnect` route (which
129
+ // fires when APIGW observes the close) plus the sweeper/ping-checker
130
+ // for connections that vanish without one. The actor's state
131
+ // mutations (nick release, roster deltas) are persisted below before
132
+ // this invocation returns, so the eventual `$disconnect` completes
133
+ // the fanout. See docs/AWS-Deployment.md §14.1.
103
134
  },
104
135
  snapshot: () => state,
105
136
  };
@@ -124,6 +155,8 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
124
155
  motd: params.motd,
125
156
  ...(params.messages !== undefined ? { messages: params.messages } : {}),
126
157
  ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
158
+ ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
159
+ ...(params.history !== undefined ? { history: params.history } : {}),
127
160
  });
128
161
 
129
162
  try {
@@ -16,10 +16,13 @@ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
16
16
  import {
17
17
  type AccountStore,
18
18
  EmptyMotdProvider,
19
+ InMemoryNickHistoryStore,
19
20
  type MessageStore,
20
21
  type MotdProvider,
22
+ type NickHistoryStore,
21
23
  type ParsedServerConfig,
22
24
  StaticMotdProvider,
25
+ SystemClock,
23
26
  } from '@serverless-ircd/irc-core';
24
27
  import { resolveAccountStore } from '../account-store.js';
25
28
  import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
@@ -29,6 +32,13 @@ import { TABLE_KEYS, type TablesConfig } from '../tables.js';
29
32
  import { handleConnect } from './connect.js';
30
33
  import { handleDefault } from './default.js';
31
34
  import { handleDisconnect } from './disconnect.js';
35
+ import {
36
+ type NlbStreamEvent,
37
+ type NlbStreamParams,
38
+ type NlbStreamResponse,
39
+ deriveNlbConnectionId,
40
+ handleNlbStream,
41
+ } from './nlb-stream.js';
32
42
  import {
33
43
  DEFAULT_PING_INTERVAL_MS,
34
44
  DEFAULT_PONG_TIMEOUT_MS,
@@ -50,6 +60,13 @@ export interface WebSocketEvent {
50
60
  connectionId: string;
51
61
  domainName?: string;
52
62
  stage?: string;
63
+ /**
64
+ * Source IP of the connecting client. APIGW populates
65
+ * `requestContext.identity.sourceIp` on every `$connect`. Carried on
66
+ * the event type so the (currently deferred) per-IP admission gate
67
+ * can consume it without reshaping the handler signature.
68
+ */
69
+ identity?: { sourceIp?: string };
53
70
  };
54
71
  body?: string;
55
72
  }
@@ -81,6 +98,13 @@ export interface HandlerDeps {
81
98
  * configured (the default) so `ctx.accounts` stays unset.
82
99
  */
83
100
  accounts?: AccountStore;
101
+ /**
102
+ * Nick-history source bound to every actor. Constructed once per cold
103
+ * start (memoised via {@link cachedDeps}) as an in-memory store so it
104
+ * survives across warm Lambda invocations. A DynamoDB-backed persistent
105
+ * variant is a documented follow-up.
106
+ */
107
+ history?: NickHistoryStore;
84
108
  }
85
109
 
86
110
  /**
@@ -132,14 +156,16 @@ export const handler = async (event: WebSocketEvent): Promise<LambdaResponse> =>
132
156
  export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promise<LambdaResponse> {
133
157
  const connId = event.requestContext.connectionId;
134
158
  switch (event.requestContext.routeKey) {
135
- case '$connect':
136
- await handleConnect({
159
+ case '$connect': {
160
+ const outcome = await handleConnect({
137
161
  dynamo: deps.dynamo,
138
162
  tables: deps.tables,
139
163
  connectionId: connId,
140
164
  serverConfig: deps.serverConfig,
141
165
  });
142
- return { statusCode: 200 };
166
+ if (outcome.admitted) return { statusCode: 200 };
167
+ return { statusCode: outcome.statusCode, body: outcome.reason };
168
+ }
143
169
 
144
170
  case '$disconnect':
145
171
  await handleDisconnect({
@@ -165,6 +191,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
165
191
  motd: deps.motd,
166
192
  messages: deps.messages,
167
193
  ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
194
+ ...(deps.history !== undefined ? { history: deps.history } : {}),
168
195
  managementApi: deps.managementApi,
169
196
  });
170
197
  return result;
@@ -245,6 +272,43 @@ export {
245
272
  type SweepResult,
246
273
  };
247
274
 
275
+ /**
276
+ * Lambda entry for the NLB TCP+TLS streaming target. Wired
277
+ * to the NLB target group in the CDK stack. Shares the cached
278
+ * {@link HandlerDeps} with the wss {@link handler} — same tables, same
279
+ * config, same management API (when available). Each NLB client chunk
280
+ * arrives as one invocation; the handler returns the outbound bytes for
281
+ * the NLB to stream back over the held-open TCP connection.
282
+ */
283
+ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStreamResponse> {
284
+ if (cachedDeps === undefined) {
285
+ if (cachedDepsPromise === undefined) {
286
+ cachedDepsPromise = buildDepsFromEnv();
287
+ }
288
+ cachedDeps = await cachedDepsPromise;
289
+ cachedDepsPromise = undefined;
290
+ }
291
+ const deps = cachedDeps;
292
+ return handleNlbStream(event, {
293
+ dynamo: deps.dynamo,
294
+ tables: deps.tables,
295
+ serverConfig: deps.serverConfig,
296
+ motd: deps.motd,
297
+ messages: deps.messages,
298
+ managementApi: deps.managementApi,
299
+ ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
300
+ ...(deps.history !== undefined ? { history: deps.history } : {}),
301
+ });
302
+ }
303
+
304
+ export {
305
+ handleNlbStream,
306
+ deriveNlbConnectionId,
307
+ type NlbStreamEvent,
308
+ type NlbStreamParams,
309
+ type NlbStreamResponse,
310
+ };
311
+
248
312
  /**
249
313
  * Builds a {@link HandlerDeps} from the current `process.env`.
250
314
  *
@@ -267,6 +331,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
267
331
  }
268
332
  const motd = cfg.motdLines.length > 0 ? new StaticMotdProvider(cfg.motdLines) : EmptyMotdProvider;
269
333
  const messages = bindMessageStore(cfg);
334
+ const history = new InMemoryNickHistoryStore(SystemClock);
270
335
  const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
271
336
  return {
272
337
  dynamo,
@@ -275,6 +340,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
275
340
  motd,
276
341
  managementApi,
277
342
  messages,
343
+ history,
278
344
  ...(accounts !== undefined ? { accounts } : {}),
279
345
  };
280
346
  }