serverless-ircd 0.1.0 → 0.3.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 +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +436 -0
- package/README.md +127 -84
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +5 -1
- package/apps/cf-worker/wrangler.toml +66 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +57 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +267 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +126 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1186 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +11 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +19 -14
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +871 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
- package/packages/aws-adapter/src/config-loader.ts +126 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +330 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
- package/packages/aws-adapter/src/handlers/index.ts +293 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +74 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +473 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +135 -0
- package/packages/cf-adapter/src/connection-do.ts +119 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
- package/packages/irc-core/package.json +13 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +63 -10
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +79 -21
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +270 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +7 -0
- package/packages/irc-core/src/ports.ts +719 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +88 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +380 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/config.test.ts +721 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +186 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +160 -0
- package/packages/irc-server/tests/actor.test.ts +690 -15
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +204 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server configuration schema (Zod) — the single source of truth for what
|
|
3
|
+
* the deployment supplies at boot time.
|
|
4
|
+
*
|
|
5
|
+
* Adapters load this from their platform-native secret/config store
|
|
6
|
+
* (Cloudflare: KV/vars in `wrangler.toml`; AWS: Secrets Manager/SSM) and
|
|
7
|
+
* pass it through {@link parseServerConfig} before the runtime boots.
|
|
8
|
+
* Invalid config fails fast at boot with a readable error instead of
|
|
9
|
+
* corrupting connection state mid-flight.
|
|
10
|
+
*
|
|
11
|
+
* The reducer-facing {@link ServerConfig} (in `types.ts`) is a strict
|
|
12
|
+
* subset of this schema — the reducers do not care about MOTD text,
|
|
13
|
+
* channel-prefix policy, or max-clients (those are consumed by
|
|
14
|
+
* adapter-side plumbing: the MOTD provider, the JOIN validator's prefix
|
|
15
|
+
* table, and the connection admission gate). Oper credentials ARE surfaced
|
|
16
|
+
* on the reducer-facing config so the OPER reducer can authenticate.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Schema for a single IRC operator credential.
|
|
23
|
+
*
|
|
24
|
+
* Both fields are non-empty strings; the adapter is responsible for any
|
|
25
|
+
* hashing/constant-time comparison at the OPER auth boundary.
|
|
26
|
+
*/
|
|
27
|
+
export const OperCredSchema = z.object({
|
|
28
|
+
user: z.string().min(1),
|
|
29
|
+
password: z.string().min(1),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Schema for a single SASL PLAIN account credential. The `username` is also
|
|
34
|
+
* the canonical account name recorded on the connection (for `extended-join`
|
|
35
|
+
* / `account-tag`). Both fields are non-empty strings; adapters seed an
|
|
36
|
+
* {@link InMemoryAccountStore} from this list at boot.
|
|
37
|
+
*/
|
|
38
|
+
export const SaslAccountSchema = z.object({
|
|
39
|
+
username: z.string().min(1),
|
|
40
|
+
password: z.string().min(1),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cloaking configuration. When enabled, the visible hostmask of every
|
|
45
|
+
* connecting client is replaced by `<cloak>.<suffix>` where `<cloak>` is a
|
|
46
|
+
* short deterministic hash of the client's real IP + the deployment
|
|
47
|
+
* secret. The real IP is never revealed on the wire.
|
|
48
|
+
*
|
|
49
|
+
* The `secret` MUST be set when `enabled` is true; the schema rejects a
|
|
50
|
+
* cloaking config that turns on cloaking without one.
|
|
51
|
+
*/
|
|
52
|
+
export const CloakingConfigSchema = z.object({
|
|
53
|
+
enabled: z.boolean(),
|
|
54
|
+
/** Strong per-deployment secret. Required when `enabled` is true. */
|
|
55
|
+
secret: z.string().min(1),
|
|
56
|
+
/**
|
|
57
|
+
* Suffix appended to every cloak (e.g. `"irc.example.com"` yields
|
|
58
|
+
* `<cloak>.irc.example.com`). Defaults to the deployment's `networkName`.
|
|
59
|
+
*/
|
|
60
|
+
cloakedSuffix: z.string().min(1).optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Per-IP connection-rate limit. Caps how many new connections a single
|
|
65
|
+
* source IP may open within a sliding `windowMs` window before the
|
|
66
|
+
* admission gate starts refusing them. Distinct from per-connection
|
|
67
|
+
* message-rate flood control: this guards the connection acceptance path
|
|
68
|
+
* rather than individual command flow.
|
|
69
|
+
*/
|
|
70
|
+
export const PerIpConnectionRateSchema = z.object({
|
|
71
|
+
/** Maximum connections from one IP within `windowMs`. */
|
|
72
|
+
max: z.number().int().positive(),
|
|
73
|
+
/** Sliding window length in milliseconds. */
|
|
74
|
+
windowMs: z.number().int().positive(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Per-connection message-rate flood control (token bucket). Caps how many
|
|
79
|
+
* commands a single established connection may send in a burst before the
|
|
80
|
+
* server disconnects it with the canonical "Excess Flood" reason. Distinct
|
|
81
|
+
* from the per-IP connection admission rate: this bounds individual command
|
|
82
|
+
* flow on an already-admitted connection.
|
|
83
|
+
*
|
|
84
|
+
* Only the numeric knobs live here. The per-command cost is a function and
|
|
85
|
+
* cannot be serialized from a config store, so the actor wires the
|
|
86
|
+
* control-plane exemption (`PING`/`PONG`/`CAP`/`AUTHENTICATE`/`QUIT`) in
|
|
87
|
+
* code at construction time.
|
|
88
|
+
*/
|
|
89
|
+
export const FloodControlConfigSchema = z.object({
|
|
90
|
+
/** Bucket ceiling; the largest burst a fresh connection can sustain. */
|
|
91
|
+
capacity: z.number().int().positive(),
|
|
92
|
+
/** Tokens added per wall-clock second. */
|
|
93
|
+
refillRatePerSecond: z.number().int().nonnegative(),
|
|
94
|
+
/**
|
|
95
|
+
* How far the bucket may go negative before the wrapper disconnects.
|
|
96
|
+
* `0` kills on the first over-rate message; larger values grant slack.
|
|
97
|
+
*/
|
|
98
|
+
disconnectThreshold: z.number().int().nonnegative(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Documented default bucket. Tolerates a short interactive burst while
|
|
103
|
+
* killing an obvious flood. Matches the canonical fixture used throughout
|
|
104
|
+
* the flood-control test suite (capacity 10, 1 token/sec, no slack).
|
|
105
|
+
*/
|
|
106
|
+
export const DEFAULT_FLOOD_CONTROL_CONFIG: {
|
|
107
|
+
capacity: number;
|
|
108
|
+
refillRatePerSecond: number;
|
|
109
|
+
disconnectThreshold: number;
|
|
110
|
+
} = {
|
|
111
|
+
capacity: 10,
|
|
112
|
+
refillRatePerSecond: 1,
|
|
113
|
+
disconnectThreshold: 0,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The full server-config schema. Optional fields default to deployment-
|
|
118
|
+
* sensible values so a minimal `{ serverName, networkName }` config is
|
|
119
|
+
* enough to boot; production deployments override the defaults.
|
|
120
|
+
*/
|
|
121
|
+
export const ServerConfigSchema = z.object({
|
|
122
|
+
/** Server hostname shown in numerics (`:serverName 001 nick ...`). */
|
|
123
|
+
serverName: z.string().min(1),
|
|
124
|
+
/** Network name advertised in `005 NETWORK=...` and WHOIS replies. */
|
|
125
|
+
networkName: z.string().min(1),
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Server password. Empty string or undefined disables the password
|
|
129
|
+
* gate. When set, every connection must supply the same value via
|
|
130
|
+
* `PASS <password>` (or SASL PLAIN) before registration completes.
|
|
131
|
+
*/
|
|
132
|
+
serverPassword: z.string().optional(),
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Hostmask cloaking. When enabled, real client IPs are replaced by a
|
|
136
|
+
* deterministic cloak derived from `secret` + the source IP. Omit to
|
|
137
|
+
* disable cloaking entirely (the default).
|
|
138
|
+
*/
|
|
139
|
+
cloaking: CloakingConfigSchema.optional(),
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Maximum simultaneous connections from a single source IP. Enforced at
|
|
143
|
+
* the admission boundary (the in-memory runtime and CF / AWS adapters).
|
|
144
|
+
*/
|
|
145
|
+
maxConnectionsPerIp: z.number().int().positive().default(10),
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Maximum simultaneous connections from a single authenticated user
|
|
149
|
+
* (identified by SASL account, falling back to source IP when no SASL
|
|
150
|
+
* identity is established). Enforced at the admission boundary.
|
|
151
|
+
*/
|
|
152
|
+
maxConnectionsPerUser: z.number().int().positive().default(5),
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Per-IP connection-rate limit (sliding window). Caps how fast a single
|
|
156
|
+
* source IP may open new connections. Distinct from per-connection
|
|
157
|
+
* message-rate flood control.
|
|
158
|
+
*/
|
|
159
|
+
perIpConnectionRate: PerIpConnectionRateSchema.default({
|
|
160
|
+
max: 5,
|
|
161
|
+
windowMs: 60_000,
|
|
162
|
+
}),
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Per-connection message-rate flood control. Omit to apply the documented
|
|
166
|
+
* default bucket ({@link DEFAULT_FLOOD_CONTROL_CONFIG}); set to `null`
|
|
167
|
+
* explicitly to disable flood control for this deployment (e.g. a trusted
|
|
168
|
+
* loopback relay that enforces its own limits upstream).
|
|
169
|
+
*/
|
|
170
|
+
floodControl: FloodControlConfigSchema.nullable().default(DEFAULT_FLOOD_CONTROL_CONFIG),
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Message-of-the-day lines. Empty array => `422 ERR_NOMOTD`.
|
|
174
|
+
* Adapters typically feed these into a `StaticMotdProvider` at boot.
|
|
175
|
+
*/
|
|
176
|
+
motdLines: z.array(z.string()).default([]),
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Allowed channel-name prefix sigils (e.g. `#`, `#&+`). The first
|
|
180
|
+
* character is the canonical prefix advertised via `CHANTYPES` in
|
|
181
|
+
* the `005` reply.
|
|
182
|
+
*/
|
|
183
|
+
channelPrefixes: z.string().min(1).default('#'),
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Maximum simultaneous client connections this deployment will admit.
|
|
187
|
+
* Adapters enforce the cap at the `$connect` / WS-upgrade boundary.
|
|
188
|
+
*/
|
|
189
|
+
maxClients: z.number().int().positive().default(10_000),
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* IRC operator credentials. The adapter's OPER auth path consults
|
|
193
|
+
* these; empty array disables OPER entirely.
|
|
194
|
+
*/
|
|
195
|
+
operCreds: z.array(OperCredSchema).default([]),
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* SASL PLAIN account credentials. The adapter seeds an
|
|
199
|
+
* `InMemoryAccountStore` from this list at boot so `AUTHENTICATE PLAIN`
|
|
200
|
+
* succeeds end-to-end for the listed accounts. Empty array (the default)
|
|
201
|
+
* disables SASL account verification: `ctx.accounts` is then omitted and
|
|
202
|
+
* a client completing `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL`.
|
|
203
|
+
* A persistent variant (DynamoDB `Accounts`, D1) is a documented
|
|
204
|
+
* follow-up; swapping it in means replacing the construction call.
|
|
205
|
+
*/
|
|
206
|
+
saslAccounts: z.array(SaslAccountSchema).default([]),
|
|
207
|
+
|
|
208
|
+
// --- Reducer-facing limit knobs (mirrored on `ServerConfig`) ---
|
|
209
|
+
|
|
210
|
+
/** Maximum channels a single connection may JOIN. */
|
|
211
|
+
maxChannelsPerUser: z.number().int().positive().default(30),
|
|
212
|
+
/** Maximum targets per PRIVMSG/NOTICE/KICK command. */
|
|
213
|
+
maxTargetsPerCommand: z.number().int().positive().default(10),
|
|
214
|
+
/** Maximum entries returned by LIST/NAMES/ban-list queries. */
|
|
215
|
+
maxListEntries: z.number().int().nonnegative().default(50),
|
|
216
|
+
/** Maximum nickname length (advertised as `NICKLEN=`). */
|
|
217
|
+
nickLen: z.number().int().positive().default(30),
|
|
218
|
+
/** Maximum channel-name length (advertised as `CHANNELLEN=`). */
|
|
219
|
+
channelLen: z.number().int().positive().default(50),
|
|
220
|
+
/** Maximum topic length (advertised as `TOPICLEN=`). */
|
|
221
|
+
topicLen: z.number().int().nonnegative().default(390),
|
|
222
|
+
/** Default QUIT reason when the client disconnects without one. */
|
|
223
|
+
quitMessage: z.string().default('Client Quit'),
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Maximum number of back-log messages replayed on JOIN for clients that
|
|
227
|
+
* negotiated `draft/chathistory`. The IRCv3 spec recommends 50; raise or
|
|
228
|
+
* lower per deployment. Only consulted when a MessageStore is bound.
|
|
229
|
+
*/
|
|
230
|
+
chatHistoryPlaybackLimit: z.number().int().positive().default(50),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
/** Input type: what adapters supply (fields with defaults may be omitted). */
|
|
234
|
+
export type ServerConfigInput = z.input<typeof ServerConfigSchema>;
|
|
235
|
+
|
|
236
|
+
/** Output type: what the schema yields post-parse (all fields populated). */
|
|
237
|
+
export type ParsedServerConfig = z.output<typeof ServerConfigSchema>;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Parses a raw config value against {@link ServerConfigSchema}, applying
|
|
241
|
+
* defaults and throwing a single aggregated {@link Error} on failure.
|
|
242
|
+
*
|
|
243
|
+
* Adapters call this exactly once at boot. The thrown message names every
|
|
244
|
+
* invalid field so operators can fix the config without round-tripping
|
|
245
|
+
* through Zod's error tree.
|
|
246
|
+
*
|
|
247
|
+
* @throws {Error} when `input` fails schema validation. The message
|
|
248
|
+
* starts with "Invalid server config" and lists every offending field.
|
|
249
|
+
*/
|
|
250
|
+
export function parseServerConfig(input: unknown): ParsedServerConfig {
|
|
251
|
+
const result = ServerConfigSchema.safeParse(input);
|
|
252
|
+
if (!result.success) {
|
|
253
|
+
const lines = result.error.issues.map((issue) => {
|
|
254
|
+
const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
|
|
255
|
+
return ` - ${path}: ${issue.message}`;
|
|
256
|
+
});
|
|
257
|
+
throw new Error(`Invalid server config:\n${lines.join('\n')}`);
|
|
258
|
+
}
|
|
259
|
+
return result.data;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Default server config: the result of parsing an empty input. Useful as a
|
|
264
|
+
* known-good baseline for tests and for adapters that want to overlay
|
|
265
|
+
* per-deployment overrides via object spread.
|
|
266
|
+
*/
|
|
267
|
+
export const DEFAULT_SERVER_CONFIG: ParsedServerConfig = ServerConfigSchema.parse({
|
|
268
|
+
serverName: 'irc.example.com',
|
|
269
|
+
networkName: 'ServerlessIRCd',
|
|
270
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flood control as a pure higher-order reducer wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Wraps any {@link Reducer} with a per-connection token bucket. The bucket is
|
|
5
|
+
* refilled using the injected {@link Clock} (deterministic — never
|
|
6
|
+
* `Date.now()`), stored on `ctx.connection.floodBucket`, and consulted
|
|
7
|
+
* before the inner reducer is invoked.
|
|
8
|
+
*
|
|
9
|
+
* On a healthy message the inner reducer is called and its
|
|
10
|
+
* `{ state, effects }` is passed through untouched. When the bucket crosses
|
|
11
|
+
* the configured negative threshold the wrapper short-circuits with a
|
|
12
|
+
* `Disconnect("Excess Flood")` effect (RFC 2812 §6 canonical reason) and
|
|
13
|
+
* does NOT call the inner reducer — matching the behaviour of mainstream
|
|
14
|
+
* IRC daemons (hybrid, charybdis, Unreal).
|
|
15
|
+
*
|
|
16
|
+
* The wrapper is generic over the inner reducer's state type: the bucket
|
|
17
|
+
* lives on `ctx.connection`, so this works for connection-authority
|
|
18
|
+
* reducers (`Reducer<ConnectionState>`) and channel-authority reducers
|
|
19
|
+
* (`Reducer<ChannelState>`) alike.
|
|
20
|
+
*
|
|
21
|
+
* Determinism invariant: given the same `Clock` readings and the same
|
|
22
|
+
* message stream the pass-through vs. disconnect decision is identical.
|
|
23
|
+
* There are no ambient inputs.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Effect } from './effects.js';
|
|
27
|
+
import type { FloodBucketState } from './state/connection.js';
|
|
28
|
+
import type { Reducer } from './types.js';
|
|
29
|
+
|
|
30
|
+
/** Canonical IRC quit reason for flooding (RFC 2812 §6). */
|
|
31
|
+
export const EXCESS_FLOOD_REASON = 'Excess Flood';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Token-bucket parameters. Tune per deployment via the wrapper factory; the
|
|
35
|
+
* wrapper does not read `ServerConfig` so existing config literals are
|
|
36
|
+
* untouched.
|
|
37
|
+
*/
|
|
38
|
+
export interface FloodControlConfig {
|
|
39
|
+
/** Bucket ceiling; the largest burst a fresh connection can sustain. */
|
|
40
|
+
capacity: number;
|
|
41
|
+
/** Tokens added per wall-clock second. */
|
|
42
|
+
refillRatePerSecond: number;
|
|
43
|
+
/**
|
|
44
|
+
* How far the bucket may go negative before the wrapper disconnects.
|
|
45
|
+
* `0` means the first over-rate message kills; larger values grant slack
|
|
46
|
+
* (e.g. `2` tolerates two messages past capacity before disconnecting).
|
|
47
|
+
*/
|
|
48
|
+
disconnectThreshold: number;
|
|
49
|
+
/**
|
|
50
|
+
* Optional pure function returning the token cost of a command. The
|
|
51
|
+
* command is the parsed `IrcMessage.command`, already uppercased by the
|
|
52
|
+
* parser, so case-insensitivity is handled once upstream.
|
|
53
|
+
*
|
|
54
|
+
* When omitted, every command is charged the flat {@link MESSAGE_COST}
|
|
55
|
+
* (`1`) — i.e. the original wrapper behaviour is preserved bit-for-bit.
|
|
56
|
+
* Compose with {@link defaultCommandCost} to exempt control-plane
|
|
57
|
+
* traffic (`PING`/`PONG`/`CAP`/`AUTHENTICATE`/`QUIT`).
|
|
58
|
+
*
|
|
59
|
+
* A cost of `0` neither decrements the bucket nor triggers the disconnect
|
|
60
|
+
* path; the inner reducer is called as normal. Negative costs are rejected
|
|
61
|
+
* at wrapper-construction time — they would invert the bucket.
|
|
62
|
+
*/
|
|
63
|
+
commandCost?: (command: string) => number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Control-plane commands that mainstream ircds (charybdis, hybrid, Unreal) exempt
|
|
68
|
+
* from flood control. A client exchanging `PING`/`PONG` with the server or
|
|
69
|
+
* negotiating `CAP` / `AUTHENTICATE` during SASL must not flood itself out
|
|
70
|
+
* through no fault of its own.
|
|
71
|
+
*
|
|
72
|
+
* Override the set by composing your own cost function on top of this one.
|
|
73
|
+
*/
|
|
74
|
+
const EXEMPT_COMMANDS: ReadonlySet<string> = new Set([
|
|
75
|
+
'PING',
|
|
76
|
+
'PONG',
|
|
77
|
+
'CAP',
|
|
78
|
+
'AUTHENTICATE',
|
|
79
|
+
'QUIT',
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Ready-made per-command cost: `0` for the exempt control-plane set, `1`
|
|
84
|
+
* otherwise. Pass this as `FloodControlConfig.commandCost` to get sensible
|
|
85
|
+
* mainstream-ircd behaviour, or compose your own function to override the
|
|
86
|
+
* exempt set.
|
|
87
|
+
*/
|
|
88
|
+
export function defaultCommandCost(command: string): number {
|
|
89
|
+
return EXEMPT_COMMANDS.has(command) ? 0 : 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Cost charged for every message when no `commandCost` override is given. */
|
|
93
|
+
const MESSAGE_COST = 1;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Command used to probe a caller-supplied `commandCost` at wrapper-construction
|
|
97
|
+
* time for the negative-cost rejection. The parser uppercases commands, so the
|
|
98
|
+
* probe uses a canonical charged command.
|
|
99
|
+
*/
|
|
100
|
+
const COST_PROBE_COMMAND = 'PRIVMSG';
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Returns the post-refill token count at a given moment, capped at
|
|
104
|
+
* `capacity`. Pure — does not mutate the bucket.
|
|
105
|
+
*/
|
|
106
|
+
function refillTokens(bucket: FloodBucketState, now: number, config: FloodControlConfig): number {
|
|
107
|
+
const elapsedSec = Math.max(0, (now - bucket.lastRefill) / 1_000);
|
|
108
|
+
return Math.min(config.capacity, bucket.tokens + elapsedSec * config.refillRatePerSecond);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pure computation of the next bucket balance after refilling and deducting
|
|
113
|
+
* the per-command cost. Shared by the pass-through and disconnect branches so
|
|
114
|
+
* they can never diverge. Lazily initializes the bucket to `capacity` tokens
|
|
115
|
+
* on the first observed message.
|
|
116
|
+
*/
|
|
117
|
+
function computeNextBalance(
|
|
118
|
+
previous: FloodBucketState | undefined,
|
|
119
|
+
now: number,
|
|
120
|
+
cost: number,
|
|
121
|
+
config: FloodControlConfig,
|
|
122
|
+
): FloodBucketState {
|
|
123
|
+
const refilled = previous === undefined ? config.capacity : refillTokens(previous, now, config);
|
|
124
|
+
return { tokens: refilled - cost, lastRefill: now };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Wraps `inner` with a per-connection token-bucket flood-control check.
|
|
129
|
+
*
|
|
130
|
+
* The returned reducer:
|
|
131
|
+
* 1. Derives the per-command cost via `config.commandCost` (or the flat
|
|
132
|
+
* {@link MESSAGE_COST} when omitted).
|
|
133
|
+
* 2. Lazily initializes `ctx.connection.floodBucket` to `capacity` tokens
|
|
134
|
+
* on the first observed message.
|
|
135
|
+
* 3. Refills the bucket using `ctx.clock.now()` and deducts the cost.
|
|
136
|
+
* 4. Decides: if the new balance is at or above `-disconnectThreshold`,
|
|
137
|
+
* delegates to `inner` (pass-through). Otherwise emits
|
|
138
|
+
* `Disconnect("Excess Flood")` and skips `inner`.
|
|
139
|
+
*
|
|
140
|
+
* A command whose cost is `0` never trips the disconnect branch (the balance
|
|
141
|
+
* is unchanged after refill) and the inner reducer runs as normal.
|
|
142
|
+
*
|
|
143
|
+
* The bucket is updated in place on `ctx.connection` so subsequent invocations
|
|
144
|
+
* observe the new balance — this is the same in-place mutation pattern other
|
|
145
|
+
* connection-authority reducers use (e.g. `lastSeen` updates).
|
|
146
|
+
*
|
|
147
|
+
* Throws synchronously if `config.commandCost` returns a negative value for
|
|
148
|
+
* the probe command, since negative costs would invert the bucket.
|
|
149
|
+
*/
|
|
150
|
+
export function wrapWithFloodControl<S>(inner: Reducer<S>, config: FloodControlConfig): Reducer<S> {
|
|
151
|
+
const costOf = config.commandCost ?? (() => MESSAGE_COST);
|
|
152
|
+
if (config.commandCost !== undefined && config.commandCost(COST_PROBE_COMMAND) < 0) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
'FloodControlConfig.commandCost returned a negative cost; costs must be non-negative',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (state, msg, ctx) => {
|
|
159
|
+
const now = ctx.clock.now();
|
|
160
|
+
const conn = ctx.connection;
|
|
161
|
+
|
|
162
|
+
const cost = costOf(msg.command);
|
|
163
|
+
const next = computeNextBalance(conn.floodBucket, now, cost, config);
|
|
164
|
+
conn.floodBucket = next;
|
|
165
|
+
|
|
166
|
+
if (next.tokens < -config.disconnectThreshold) {
|
|
167
|
+
return {
|
|
168
|
+
state,
|
|
169
|
+
effects: [Effect.disconnect(conn.id, EXCESS_FLOOD_REASON)],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return inner(state, msg, ctx);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -10,9 +10,16 @@ export * from './caps/index.js';
|
|
|
10
10
|
export * from './commands/index.js';
|
|
11
11
|
export * from './effects.js';
|
|
12
12
|
export * from './ports.js';
|
|
13
|
+
export * from './flood-control.js';
|
|
14
|
+
export * from './cloak.js';
|
|
15
|
+
export * from './case-fold.js';
|
|
16
|
+
export * from './admission.js';
|
|
17
|
+
export * from './config.js';
|
|
13
18
|
export type {
|
|
14
19
|
BuildCtxOptions,
|
|
20
|
+
CloakingConfig,
|
|
15
21
|
Ctx,
|
|
22
|
+
OperCred,
|
|
16
23
|
Reducer,
|
|
17
24
|
ReducerResult,
|
|
18
25
|
ServerConfig,
|