serverless-ircd 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -18,22 +18,34 @@
18
18
  * so a future `TransactWriteItems` can span Connections ↔ membership
19
19
  * atomically. (Atomicity across Connections/ChannelMembers lands in the
20
20
  * membership-transaction follow-up.)
21
- * • `Accounts` stores SASL PLAIN credentials as scrypt
22
- * `HashedAccountCredential` rows (`{ account, algorithm, salt,
23
- * hash }`, base64) never plaintext. Loaded at Lambda cold start
24
- * by `loadDynamoAccountStore` and seeded via
25
- * `putAccountCredential`.
21
+ * • SASL / NickServ credentials live in the `Services` table under the
22
+ * `NICK:<fold>` partition. The standalone `Accounts` table was dropped
23
+ * — `ServicesStore` (`registerNick` / `verifyNick` / `verifyCertFP`)
24
+ * is the single credential home for SASL PLAIN, SASL EXTERNAL,
25
+ * PASS-auth, and NickServ IDENTIFY.
26
26
  * • `Connections.idleSince` is the DynamoDB TTL attribute; idle connections
27
27
  * are reaped automatically in addition to the explicit sweeper.
28
28
  */
29
29
 
30
- import { AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
31
- import type { TableProps } from 'aws-cdk-lib/aws-dynamodb';
32
- import type { TableName } from './tables.js';
30
+ import { AttributeType, BillingMode, ProjectionType } from 'aws-cdk-lib/aws-dynamodb';
31
+ import type { GlobalSecondaryIndexProps, TableProps } from 'aws-cdk-lib/aws-dynamodb';
32
+ import { SOURCE_IP_INDEX_NAME, type TableName } from './tables.js';
33
33
 
34
34
  /**
35
- * All five tables keyed by their logical construct id. The id doubles as the
36
- * CloudFormation logical id; the physical `TableName` is set on each entry.
35
+ * A table definition: the CDK `TableProps` plus an optional side-channel
36
+ * for secondary indexes. Current aws-cdk-lib moved GSI declaration out
37
+ * of `TableProps` (indexes are added via `Table.addGlobalSecondaryIndex`
38
+ * after construction), so the index list rides alongside the construct
39
+ * props here — the stack pops it before constructing the `Table` and the
40
+ * DynamoDB Local fixtures read it when issuing raw `CreateTableCommand`s.
41
+ */
42
+ export interface TableDef extends TableProps {
43
+ globalSecondaryIndexes?: GlobalSecondaryIndexProps[];
44
+ }
45
+
46
+ /**
47
+ * All four tables keyed by their logical construct id. The id doubles as
48
+ * the CloudFormation logical id; the physical `TableName` is set on each entry.
37
49
  *
38
50
  * NOTE: the deployed physical name is **environment-prefixed** by
39
51
  * `IrcAwsStack` (`<Env><LogicalId>`, e.g. `StagingConnections`) — the
@@ -42,12 +54,28 @@ import type { TableName } from './tables.js';
42
54
  * every physical name (to isolate parallel runs) consume the
43
55
  * `TABLE_DEFS` entries' key/attribute names only.
44
56
  */
45
- export const TABLE_DEFS: Record<TableName, TableProps> = {
57
+ export const TABLE_DEFS: Record<TableName, TableDef> = {
46
58
  Connections: {
47
59
  tableName: 'Connections',
48
60
  partitionKey: { name: 'connectionId', type: AttributeType.STRING },
49
61
  billingMode: BillingMode.PAY_PER_REQUEST,
50
62
  timeToLiveAttribute: 'idleSince',
63
+ // Per-IP admission index: `$connect` counts an IP's live connections
64
+ // (maxConnectionsPerIp) and its in-window establishments
65
+ // (perIpConnectionRate) against this GSI; the NLB new-flow gate
66
+ // counts the window. Rows surface in the index only when both key
67
+ // attributes are present (handlers stamp `sourceIp` at
68
+ // connect/flow-establishment; legacy rows are invisible — the caps
69
+ // apply from this change forward). TTL-reaped rows leave the index
70
+ // with them, so the index self-cleans.
71
+ globalSecondaryIndexes: [
72
+ {
73
+ indexName: SOURCE_IP_INDEX_NAME,
74
+ partitionKey: { name: 'sourceIp', type: AttributeType.STRING },
75
+ sortKey: { name: 'connectedSince', type: AttributeType.NUMBER },
76
+ projectionType: ProjectionType.ALL,
77
+ },
78
+ ],
51
79
  },
52
80
  ChannelMeta: {
53
81
  tableName: 'ChannelMeta',
@@ -65,11 +93,6 @@ export const TABLE_DEFS: Record<TableName, TableProps> = {
65
93
  partitionKey: { name: 'nickLower', type: AttributeType.STRING },
66
94
  billingMode: BillingMode.PAY_PER_REQUEST,
67
95
  },
68
- Accounts: {
69
- tableName: 'Accounts',
70
- partitionKey: { name: 'account', type: AttributeType.STRING },
71
- billingMode: BillingMode.PAY_PER_REQUEST,
72
- },
73
96
  Services: {
74
97
  tableName: 'Services',
75
98
  partitionKey: { name: 'pk', type: AttributeType.STRING },
@@ -11,6 +11,15 @@
11
11
 
12
12
  import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
13
13
 
14
+ /**
15
+ * Documented schema defaults for `perIpConnectionRate`. Duplicated here
16
+ * (the schema inlines its `.default(...)`) so an operator setting only
17
+ * one half of the knob via env gets the documented default for the other
18
+ * half rather than a parse error.
19
+ */
20
+ const DEFAULT_PER_IP_RATE_MAX = 5;
21
+ const DEFAULT_PER_IP_RATE_WINDOW_MS = 60_000;
22
+
14
23
  /**
15
24
  * The subset of `process.env` this loader reads. Optional members are
16
25
  * omitted from the input object when undefined so the schema's defaults
@@ -35,6 +44,20 @@ export interface LambdaConfigEnv {
35
44
  CHANNEL_PREFIXES?: string;
36
45
  OPER_USER?: string;
37
46
  OPER_PASSWORD?: string;
47
+ /**
48
+ * Scrypt salt (base64) for the hashed oper credential form. When set
49
+ * alongside `OPER_USER` + `OPER_HASH`, the loader builds a hashed
50
+ * `{user, salt, hash}` cred instead of the legacy plaintext
51
+ * `{user, password}` shape. The hashed form is preferred when both
52
+ * are present so deployments can rotate without removing the legacy
53
+ * vars in the same step. Generate via `tools/hash-oper-cred.ts`.
54
+ */
55
+ OPER_SALT?: string;
56
+ /**
57
+ * Scrypt hash (base64) for the hashed oper credential form. See
58
+ * `OPER_SALT` for the full set of vars.
59
+ */
60
+ OPER_HASH?: string;
38
61
  /**
39
62
  * Server-password gate. Treat as an AWS secret (Secrets Manager or
40
63
  * SSM `SecureString`); the loader threads it through to
@@ -53,10 +76,62 @@ export interface LambdaConfigEnv {
53
76
  /**
54
77
  * SASL PLAIN accounts. Newline-delimited `username:password` pairs
55
78
  * (same convention as the CF adapter's `SASL_ACCOUNTS`). Parsed into
56
- * the `saslAccounts` config field so {@link bindAccountStore} can seed
57
- * an `InMemoryAccountStore` at boot.
79
+ * the `saslAccounts` config field so the boot path can seed the
80
+ * `ServicesStore` via `registerNick`.
58
81
  */
59
82
  SASL_ACCOUNTS?: string;
83
+ /**
84
+ * Comma-separated allowlist of web origins permitted to open
85
+ * WebSocket upgrades (Cross-Site WebSocket Hijacking defence —
86
+ * browsers send `Origin` on every cross-origin upgrade; WebSocket
87
+ * upgrades do not otherwise enforce same-origin, and API Gateway
88
+ * WebSocket has no built-in Origin check). Mirrors the CF Worker's
89
+ * `WEB_ORIGINS` env.
90
+ *
91
+ * Only read by the `$connect` handler at
92
+ * {@link buildDepsFromEnv} — it is NOT threaded through the shared
93
+ * `ServerConfig` schema (it is an adapter-level operational concern,
94
+ * not a server-config knob). When unset/empty, the defence is
95
+ * disabled and every Origin proceeds (the default — opt-in).
96
+ * Non-browser clients (curl, WeeChat, the `tcp-ws-forwarder`) never
97
+ * send `Origin` and always pass through regardless.
98
+ */
99
+ WEB_ORIGINS?: string;
100
+ /**
101
+ * Per-connection inbound frame window ceiling enforced by the
102
+ * `$default` handler BEFORE the actor / DynamoDB write. Parsed into
103
+ * `ServerConfig.adapter.maxFramesPerWindow`.
104
+ */
105
+ MAX_FRAMES_PER_WINDOW?: string;
106
+ /**
107
+ * Sliding-window length (seconds) for the inbound frame limit; frames
108
+ * older than the window stop counting. Parsed into
109
+ * `ServerConfig.adapter.frameWindowSeconds`.
110
+ */
111
+ FRAME_WINDOW_SECONDS?: string;
112
+ /**
113
+ * Per-IP simultaneous-connection cap (`maxConnectionsPerIp`). Enforced
114
+ * by the `$connect` admission gate against the `sourceIp` GSI; mirrors
115
+ * the CF loader's var of the same name.
116
+ */
117
+ MAX_CONNECTIONS_PER_IP?: string;
118
+ /**
119
+ * Per-IP connection-rate budget (`perIpConnectionRate.max`) — how many
120
+ * new connections one source IP may open per sliding window. Enforced
121
+ * by `$connect` and the NLB new-flow gate. The unset half of the knob
122
+ * falls back to the schema default.
123
+ */
124
+ PER_IP_CONNECTION_RATE_MAX?: string;
125
+ /** Sliding window length in ms (`perIpConnectionRate.windowMs`). */
126
+ PER_IP_CONNECTION_RATE_WINDOW_MS?: string;
127
+ /**
128
+ * Operator opt-in for certificate-backed SASL EXTERNAL. 'true'/'1'
129
+ * (case-insensitive) map to `sasl.externalEnabled: true`; any other
130
+ * value (and unset) leaves the mechanism off. EXTERNAL additionally
131
+ * requires edge mTLS (APIGW client certs on the custom domain) and a
132
+ * secure (TLS) connection — the flag alone advertises nothing.
133
+ */
134
+ EXTERNAL_ENABLED?: string;
60
135
  }
61
136
 
62
137
  /**
@@ -88,12 +163,36 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
88
163
  input.channelPrefixes = env.CHANNEL_PREFIXES;
89
164
  }
90
165
  if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
91
- input.operCreds = [
92
- {
93
- user: env.OPER_USER ?? '',
94
- password: env.OPER_PASSWORD ?? '',
95
- },
96
- ];
166
+ // Hashed form (OPER_USER + OPER_SALT + OPER_HASH) is preferred when
167
+ // both salt + hash are supplied — even if OPER_PASSWORD is also set,
168
+ // so a deployment can rotate to the hashed form without dropping the
169
+ // legacy var in the same release. The legacy plaintext
170
+ // `{user, password}` shape is retained for a single deprecation-
171
+ // cycle window.
172
+ const hasHashed = env.OPER_SALT !== undefined && env.OPER_HASH !== undefined;
173
+ if (hasHashed) {
174
+ input.operCreds = [
175
+ {
176
+ user: env.OPER_USER ?? '',
177
+ salt: env.OPER_SALT ?? '',
178
+ hash: env.OPER_HASH ?? '',
179
+ },
180
+ ];
181
+ } else {
182
+ // Build the legacy cred object from whatever was supplied; the
183
+ // schema surfaces a `user`/`password` error if only one half was
184
+ // provided. If only OPER_SALT/OPER_HASH (partial) are set, the
185
+ // schema will surface the missing hashed-form field via the
186
+ // superRefine error path.
187
+ input.operCreds = [
188
+ {
189
+ user: env.OPER_USER ?? '',
190
+ password: env.OPER_PASSWORD ?? '',
191
+ ...(env.OPER_SALT !== undefined ? { salt: env.OPER_SALT } : {}),
192
+ ...(env.OPER_HASH !== undefined ? { hash: env.OPER_HASH } : {}),
193
+ },
194
+ ];
195
+ }
97
196
  }
98
197
  if (env.SERVER_PASSWORD !== undefined) {
99
198
  input.serverPassword = env.SERVER_PASSWORD;
@@ -122,9 +221,55 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
122
221
  if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
123
222
  input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
124
223
  }
224
+ if (env.MAX_FRAMES_PER_WINDOW !== undefined || env.FRAME_WINDOW_SECONDS !== undefined) {
225
+ // Partial sections are fine: the per-field schema defaults fill the
226
+ // knob(s) the env did not supply.
227
+ input.adapter = {
228
+ ...(env.MAX_FRAMES_PER_WINDOW !== undefined
229
+ ? { maxFramesPerWindow: Number.parseInt(env.MAX_FRAMES_PER_WINDOW, 10) }
230
+ : {}),
231
+ ...(env.FRAME_WINDOW_SECONDS !== undefined
232
+ ? { frameWindowSeconds: Number.parseInt(env.FRAME_WINDOW_SECONDS, 10) }
233
+ : {}),
234
+ };
235
+ }
236
+ if (env.MAX_CONNECTIONS_PER_IP !== undefined) {
237
+ input.maxConnectionsPerIp = Number.parseInt(env.MAX_CONNECTIONS_PER_IP, 10);
238
+ }
239
+ if (
240
+ env.PER_IP_CONNECTION_RATE_MAX !== undefined ||
241
+ env.PER_IP_CONNECTION_RATE_WINDOW_MS !== undefined
242
+ ) {
243
+ // The schema requires both halves; the unset one falls back to the
244
+ // documented schema defaults (mirrors the CF loader's vars of the
245
+ // same names so operators tune both platforms identically).
246
+ input.perIpConnectionRate = {
247
+ max:
248
+ env.PER_IP_CONNECTION_RATE_MAX !== undefined
249
+ ? Number.parseInt(env.PER_IP_CONNECTION_RATE_MAX, 10)
250
+ : DEFAULT_PER_IP_RATE_MAX,
251
+ windowMs:
252
+ env.PER_IP_CONNECTION_RATE_WINDOW_MS !== undefined
253
+ ? Number.parseInt(env.PER_IP_CONNECTION_RATE_WINDOW_MS, 10)
254
+ : DEFAULT_PER_IP_RATE_WINDOW_MS,
255
+ };
256
+ }
257
+ if (env.EXTERNAL_ENABLED !== undefined) {
258
+ input.sasl = { externalEnabled: parseEnabledFlag(env.EXTERNAL_ENABLED) };
259
+ }
125
260
  return input;
126
261
  }
127
262
 
263
+ /**
264
+ * Parses a boolean feature-flag env var: 'true' / '1' (case-insensitive,
265
+ * whitespace-trimmed) are truthy; every other value is false. Flags
266
+ * default off, so a typo must never silently enable the feature.
267
+ */
268
+ function parseEnabledFlag(raw: string): boolean {
269
+ const normalized = raw.trim().toLowerCase();
270
+ return normalized === 'true' || normalized === '1';
271
+ }
272
+
128
273
  /**
129
274
  * Parses the `CREATED_AT` env var into the schema's `createdAt` field.
130
275
  *
@@ -198,6 +198,9 @@ export class DynamoServicesStore extends PersistentServicesStore {
198
198
  email: row.email,
199
199
  createdAt: row.createdAt,
200
200
  enforce: row.enforce,
201
+ certSubjects: row.certSubjects ?? [],
202
+ identifyFailures: row.identifyFailures ?? [],
203
+ pendingIdentifyFailureNotice: row.pendingIdentifyFailureNotice ?? 0,
201
204
  algorithm: row.credential.algorithm,
202
205
  salt: row.credential.salt,
203
206
  hash: row.credential.hash,
@@ -536,12 +539,28 @@ function coerceNick(item: Record<string, unknown>): ServicesNickRow | undefined
536
539
  ) {
537
540
  return undefined;
538
541
  }
542
+ // certSubjects is additive — older rows written before the column existed
543
+ // load as []. Accepts a JSON-array or a string[]; anything else → [].
544
+ const certSubjects = Array.isArray(item.certSubjects)
545
+ ? item.certSubjects.filter((s): s is string => typeof s === 'string')
546
+ : [];
547
+ // The identify-throttle fields are additive in the same way: absent (a
548
+ // pre-throttle item) or ill-typed attributes collapse to the neutral
549
+ // unfrozen defaults.
550
+ const identifyFailures = Array.isArray(item.identifyFailures)
551
+ ? item.identifyFailures.filter((t): t is number => typeof t === 'number')
552
+ : [];
553
+ const pendingIdentifyFailureNotice =
554
+ typeof item.pendingIdentifyFailureNotice === 'number' ? item.pendingIdentifyFailureNotice : 0;
539
555
  return {
540
556
  nick: item.nick,
541
557
  account: item.account,
542
558
  email: item.email,
543
559
  createdAt: item.createdAt,
544
560
  enforce: item.enforce as NickEnforcePolicy,
561
+ certSubjects,
562
+ identifyFailures,
563
+ pendingIdentifyFailureNotice,
545
564
  credential: {
546
565
  account: item.account,
547
566
  algorithm: 'scrypt',
@@ -24,6 +24,8 @@ import {
24
24
  } from '@serverless-ircd/irc-core';
25
25
  import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
26
26
  import { decrementConnectionCount, incrementConnectionCount } from '../connection-counter.js';
27
+ import { countConnectionsForIp } from '../ip-admission.js';
28
+ import { decideConnectOrigin } from '../origin-allowlist.js';
27
29
  import { marshalConnection } from '../serialize.js';
28
30
  import type { TablesConfig } from '../tables.js';
29
31
  import { createInitialConnectionState } from './state.js';
@@ -44,6 +46,32 @@ export interface ConnectParams {
44
46
  * none match or the header is absent).
45
47
  */
46
48
  secWebSocketProtocol?: string | null;
49
+ /**
50
+ * Raw `Origin` header from `event.headers.Origin` (case-insensitive).
51
+ * When {@link ConnectParams.webOrigins} is non-empty and this is a
52
+ * browser-sent origin that is not on the allowlist, the upgrade is
53
+ * rejected with `403` (Cross-Site WebSocket Hijacking defence —
54
+ * API Gateway has no built-in Origin check). `null` / `undefined`
55
+ * (non-browser clients: curl, WeeChat, the `tcp-ws-forwarder`)
56
+ * always proceed; the defence is opt-in (only fires when the
57
+ * allowlist is non-empty).
58
+ */
59
+ originHeader?: string | null | undefined;
60
+ /**
61
+ * Parsed `WEB_ORIGINS` allowlist. Empty set (or `undefined`) disables
62
+ * the Origin check (the default — defence is opt-in).
63
+ */
64
+ webOrigins?: Set<string> | undefined;
65
+ /**
66
+ * Source IP of the connecting client (`event.requestContext.identity
67
+ * .sourceIp` on APIGW). When present, the per-IP admission gates run:
68
+ * a paginated Query against the `sourceIp` GSI supplies the IP's live
69
+ * count (`maxConnectionsPerIp`) and its in-window count
70
+ * (`perIpConnectionRate`), and the previously-dormant per-IP branch
71
+ * of {@link decideConnectAdmission} goes live. Absent (older event
72
+ * shapes) the per-IP checks are skipped entirely.
73
+ */
74
+ sourceIp?: string | null | undefined;
47
75
  }
48
76
 
49
77
  /**
@@ -83,11 +111,49 @@ export async function handleConnect(params: ConnectParams): Promise<ConnectOutco
83
111
  const now = params.now ?? Date.now();
84
112
  const maxClients = params.serverConfig.maxClients;
85
113
 
114
+ // CSWSH defence — Origin allowlist. Runs before the admission counter
115
+ // so a hostile-origin flood cannot consume `maxClients` slots. Only
116
+ // fires when `WEB_ORIGINS` is set (defence is opt-in); a missing
117
+ // `Origin` header (non-browser clients) always proceeds.
118
+ const originDecision = decideConnectOrigin(params.originHeader, params.webOrigins ?? new Set());
119
+ if (originDecision === 'deny') {
120
+ return { admitted: false, statusCode: 403, reason: 'origin not allowed' };
121
+ }
122
+
123
+ // Per-IP admission counts (GSI walk). Runs before the global counter
124
+ // reservation so an over-budget IP never churns the meta-row counter.
125
+ // An empty/absent source IP skips the per-IP gates (branch stays
126
+ // dormant, matching pre-GSI event shapes).
127
+ let perIp: number | undefined;
128
+ let recentPerIp: number | undefined;
129
+ if (typeof params.sourceIp === 'string' && params.sourceIp.length > 0) {
130
+ const ipCounts = await countConnectionsForIp(
131
+ params.dynamo,
132
+ params.tables.Connections,
133
+ params.sourceIp,
134
+ now,
135
+ params.serverConfig.perIpConnectionRate.windowMs,
136
+ );
137
+ perIp = ipCounts.total;
138
+ recentPerIp = ipCounts.recent;
139
+ }
140
+
86
141
  // Reserve a slot atomically. The returned value includes this reservation,
87
142
  // so the pre-existing count (`total`, as expected by the policy) is one
88
143
  // less than the post-increment value.
89
144
  const reservedCount = await incrementConnectionCount(params.dynamo, params.tables.Connections);
90
- const outcome = decideConnectAdmission({ total: reservedCount - 1 }, { maxClients });
145
+ const outcome = decideConnectAdmission(
146
+ {
147
+ total: reservedCount - 1,
148
+ ...(perIp !== undefined ? { perIp } : {}),
149
+ ...(recentPerIp !== undefined ? { recentPerIp } : {}),
150
+ },
151
+ {
152
+ maxClients,
153
+ maxConnectionsPerIp: params.serverConfig.maxConnectionsPerIp,
154
+ perIpRate: params.serverConfig.perIpConnectionRate,
155
+ },
156
+ );
91
157
  if (!outcome.admitted) {
92
158
  // Over capacity — release the reservation so the counter does not drift
93
159
  // upward on every rejected connect.
@@ -118,6 +184,12 @@ export async function handleConnect(params: ConnectParams): Promise<ConnectOutco
118
184
  if (mode !== 'legacy') {
119
185
  row.wsMode = mode;
120
186
  }
187
+ // Persist the source IP so the `sourceIp` GSI carries this connection
188
+ // for future per-IP admission walks (rows without it are invisible to
189
+ // the index — see ip-admission.ts).
190
+ if (typeof params.sourceIp === 'string' && params.sourceIp.length > 0) {
191
+ row.sourceIp = params.sourceIp;
192
+ }
121
193
  try {
122
194
  await params.dynamo.send(
123
195
  new PutCommand({