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,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-cli config loader.
|
|
3
|
+
*
|
|
4
|
+
* Both adapters (Cloudflare Workers via `loadServerConfigFromCfEnv`, and
|
|
5
|
+
* this local harness via `loadServerConfigFromCliArgs`) consume the
|
|
6
|
+
* single `ServerConfigSchema` shipped from `irc-core`. The local-cli
|
|
7
|
+
* accepts already-typed values (no env-string coercion needed) and
|
|
8
|
+
* validates them through the shared schema so misconfigurations are
|
|
9
|
+
* surfaced at boot rather than mid-session.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* CLI / programmatic input for `loadServerConfigFromCliArgs`. Every
|
|
16
|
+
* field is optional except the two required schema fields
|
|
17
|
+
* (`serverName`, `networkName`); the schema applies defaults to the
|
|
18
|
+
* rest. Field types match the schema's input shape (numbers stay
|
|
19
|
+
* numbers, etc.) so callers don't need to coerce strings.
|
|
20
|
+
*/
|
|
21
|
+
export interface CliConfigInput {
|
|
22
|
+
serverName: string;
|
|
23
|
+
networkName: string;
|
|
24
|
+
motdLines?: string[];
|
|
25
|
+
channelPrefixes?: string;
|
|
26
|
+
maxClients?: number;
|
|
27
|
+
operCreds?: Array<{ user: string; password: string }>;
|
|
28
|
+
maxChannelsPerUser?: number;
|
|
29
|
+
maxTargetsPerCommand?: number;
|
|
30
|
+
maxListEntries?: number;
|
|
31
|
+
nickLen?: number;
|
|
32
|
+
channelLen?: number;
|
|
33
|
+
topicLen?: number;
|
|
34
|
+
quitMessage?: string;
|
|
35
|
+
saslAccounts?: Array<{ username: string; password: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validates CLI-supplied config against the shared
|
|
40
|
+
* {@link ServerConfigSchema} from `irc-core`.
|
|
41
|
+
*
|
|
42
|
+
* Throws a single readable `Error` listing every invalid field if the
|
|
43
|
+
* input fails validation. Adapters MUST call this once at boot —
|
|
44
|
+
* `startLocalServer` already calls it via its options normalisation.
|
|
45
|
+
*/
|
|
46
|
+
export function loadServerConfigFromCliArgs(input: CliConfigInput): ParsedServerConfig {
|
|
47
|
+
return parseServerConfig(stripUndefined(input));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Returns a shallow copy of `obj` with `undefined` values dropped. */
|
|
51
|
+
function stripUndefined(obj: CliConfigInput): Record<string, unknown> {
|
|
52
|
+
const out: Record<string, unknown> = {};
|
|
53
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
54
|
+
if (v !== undefined) out[k] = v;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Both listeners share one runtime, so a TCP client and a WS client can
|
|
15
15
|
* see each other (cross-transport channel broadcast). Pass `--no-tcp` to
|
|
16
16
|
* run WebSocket-only; the WebSocket port / hostname / MOTD are tunable via
|
|
17
|
-
* flags
|
|
17
|
+
* flags.
|
|
18
18
|
*
|
|
19
19
|
* Usage:
|
|
20
20
|
* pnpm --filter local-cli start -- --port 6667 --tcp-port 6668 --host 127.0.0.1
|
|
@@ -28,15 +28,54 @@ import { randomUUID } from 'node:crypto';
|
|
|
28
28
|
import { type Server, type Socket, createServer } from 'node:net';
|
|
29
29
|
import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
|
|
30
30
|
import {
|
|
31
|
+
type AccountStore,
|
|
32
|
+
type AdmissionConfig,
|
|
31
33
|
type ChanName,
|
|
32
34
|
type ConnectionState,
|
|
35
|
+
ConsoleLogger,
|
|
36
|
+
InMemoryAccountStore,
|
|
37
|
+
InMemoryMessageStore,
|
|
38
|
+
LogLevel,
|
|
39
|
+
type Logger,
|
|
40
|
+
type MessageStore,
|
|
33
41
|
type RawLine,
|
|
34
42
|
createConnection,
|
|
35
43
|
} from '@serverless-ircd/irc-core';
|
|
36
44
|
import { ConnectionActor } from '@serverless-ircd/irc-server';
|
|
37
45
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
46
|
+
import { loadServerConfigFromCliArgs } from './config-loader.js';
|
|
38
47
|
import { LineScanner } from './line-scanner.js';
|
|
39
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The fully-resolved config object passed to `attachConnection`. Legacy
|
|
51
|
+
* fields always have values (sane defaults); the security knobs remain
|
|
52
|
+
* optional because `undefined` is meaningful (= feature disabled).
|
|
53
|
+
*/
|
|
54
|
+
type ResolvedServerConfig = Omit<
|
|
55
|
+
Required<LocalServerConfig>,
|
|
56
|
+
| 'serverPassword'
|
|
57
|
+
| 'cloaking'
|
|
58
|
+
| 'maxConnectionsPerIp'
|
|
59
|
+
| 'maxConnectionsPerUser'
|
|
60
|
+
| 'perIpConnectionRate'
|
|
61
|
+
| 'historyMaxPerChannel'
|
|
62
|
+
| 'saslAccounts'
|
|
63
|
+
> &
|
|
64
|
+
Pick<
|
|
65
|
+
LocalServerConfig,
|
|
66
|
+
| 'serverPassword'
|
|
67
|
+
| 'cloaking'
|
|
68
|
+
| 'maxConnectionsPerIp'
|
|
69
|
+
| 'maxConnectionsPerUser'
|
|
70
|
+
| 'perIpConnectionRate'
|
|
71
|
+
| 'saslAccounts'
|
|
72
|
+
>;
|
|
73
|
+
|
|
74
|
+
/** Default per-IP / per-user caps used when callers don't override. */
|
|
75
|
+
const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
|
|
76
|
+
const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
|
|
77
|
+
const DEFAULT_PER_IP_CONNECTION_RATE = { max: 5, windowMs: 60_000 };
|
|
78
|
+
|
|
40
79
|
/** Default server config; `startLocalServer` accepts overrides. */
|
|
41
80
|
export interface LocalServerConfig {
|
|
42
81
|
serverName?: string;
|
|
@@ -49,6 +88,50 @@ export interface LocalServerConfig {
|
|
|
49
88
|
channelLen?: number;
|
|
50
89
|
topicLen?: number;
|
|
51
90
|
quitMessage?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Server password gate. Undefined / empty disables enforcement;
|
|
93
|
+
* otherwise every connection must supply the matching value via
|
|
94
|
+
* `PASS <password>` (or SASL PLAIN) before registration completes.
|
|
95
|
+
*/
|
|
96
|
+
readonly serverPassword?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Hostmask cloaking policy. Undefined disables cloaking; passing
|
|
99
|
+
* `{ enabled: true, secret: '...' }` replaces every connection's
|
|
100
|
+
* visible host with a deterministic cloak derived from the secret
|
|
101
|
+
* and the connection's source identifier.
|
|
102
|
+
*/
|
|
103
|
+
readonly cloaking?: { enabled: boolean; secret: string; cloakedSuffix?: string } | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Per-IP simultaneous-connection cap. The local server enforces this
|
|
106
|
+
* at WebSocket upgrade time. Defaults to disabled (no cap) when
|
|
107
|
+
* omitted; pass a positive integer to enforce.
|
|
108
|
+
*/
|
|
109
|
+
readonly maxConnectionsPerIp?: number;
|
|
110
|
+
/**
|
|
111
|
+
* Per-user (SASL account, falling back to source IP) simultaneous-
|
|
112
|
+
* connection cap. Same enforcement path as `maxConnectionsPerIp`.
|
|
113
|
+
*/
|
|
114
|
+
readonly maxConnectionsPerUser?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Per-IP connection-rate limit (sliding window). Caps how fast one
|
|
117
|
+
* source IP may open new connections.
|
|
118
|
+
*/
|
|
119
|
+
readonly perIpConnectionRate?: { max: number; windowMs: number };
|
|
120
|
+
/**
|
|
121
|
+
* Maximum back-log messages retained per channel for IRCv3
|
|
122
|
+
* `draft/chathistory` playback. When omitted, the shared
|
|
123
|
+
* {@link InMemoryMessageStore} default (100) applies. Only consulted
|
|
124
|
+
* when chathistory playback is enabled (it always is for the local
|
|
125
|
+
* CLI — the store is bound unconditionally).
|
|
126
|
+
*/
|
|
127
|
+
historyMaxPerChannel?: number;
|
|
128
|
+
/**
|
|
129
|
+
* SASL PLAIN account credentials. When non-empty the server seeds an
|
|
130
|
+
* in-memory {@link AccountStore} so `AUTHENTICATE PLAIN` succeeds for the
|
|
131
|
+
* listed accounts. When omitted/empty, SASL account verification is
|
|
132
|
+
* disabled (`ctx.accounts` undefined → `904 ERR_SASLFAIL`).
|
|
133
|
+
*/
|
|
134
|
+
saslAccounts?: Array<{ username: string; password: string }>;
|
|
52
135
|
}
|
|
53
136
|
|
|
54
137
|
export interface StartServerOptions extends LocalServerConfig {
|
|
@@ -73,6 +156,12 @@ export interface LocalServer {
|
|
|
73
156
|
*/
|
|
74
157
|
readonly tcpPort?: number | undefined;
|
|
75
158
|
readonly runtime: InMemoryRuntime;
|
|
159
|
+
/**
|
|
160
|
+
* The per-process chat-history store backing `draft/chathistory`
|
|
161
|
+
* playback. Shared across every connection so a message recorded by
|
|
162
|
+
* one client is replayable by another. Exposed for test assertions.
|
|
163
|
+
*/
|
|
164
|
+
readonly messages: MessageStore;
|
|
76
165
|
close(): Promise<void>;
|
|
77
166
|
/**
|
|
78
167
|
* Test-only seam: live server-side WebSockets. Exposed so tests can
|
|
@@ -92,7 +181,16 @@ interface ConnectionBindings {
|
|
|
92
181
|
actor: ConnectionActor;
|
|
93
182
|
}
|
|
94
183
|
|
|
95
|
-
const DEFAULT_CONFIG:
|
|
184
|
+
const DEFAULT_CONFIG: Omit<
|
|
185
|
+
Required<LocalServerConfig>,
|
|
186
|
+
| 'serverPassword'
|
|
187
|
+
| 'cloaking'
|
|
188
|
+
| 'maxConnectionsPerIp'
|
|
189
|
+
| 'maxConnectionsPerUser'
|
|
190
|
+
| 'perIpConnectionRate'
|
|
191
|
+
| 'historyMaxPerChannel'
|
|
192
|
+
| 'saslAccounts'
|
|
193
|
+
> = {
|
|
96
194
|
serverName: 'irc.example.com',
|
|
97
195
|
networkName: 'ExampleNet',
|
|
98
196
|
motdLines: ['Welcome to the local-cli ServerlessIRCd instance.'],
|
|
@@ -120,6 +218,7 @@ export const DEFAULT_ID_FACTORY = {
|
|
|
120
218
|
batchId: () => randomUUID(),
|
|
121
219
|
nonce: () => randomUUID(),
|
|
122
220
|
sessionId: () => randomUUID(),
|
|
221
|
+
traceId: () => randomUUID(),
|
|
123
222
|
};
|
|
124
223
|
|
|
125
224
|
/**
|
|
@@ -132,20 +231,118 @@ export const DEFAULT_ID_FACTORY = {
|
|
|
132
231
|
* don't leak a half-started server.
|
|
133
232
|
*/
|
|
134
233
|
export function startLocalServer(opts: StartServerOptions): Promise<LocalServer> {
|
|
135
|
-
|
|
234
|
+
// Validate the user-supplied config through the shared ServerConfigSchema
|
|
235
|
+
// (sourced from `irc-core`). Both adapters consume the same schema; here
|
|
236
|
+
// we discard the parsed result and only retain the side-effect of
|
|
237
|
+
// throwing on a malformed config at boot. The actor still consumes the
|
|
238
|
+
// `LocalServerConfig` projection so existing callers (and the e2e
|
|
239
|
+
// harness) keep their permissive defaults.
|
|
240
|
+
let parsed: ReturnType<typeof loadServerConfigFromCliArgs>;
|
|
241
|
+
try {
|
|
242
|
+
parsed = loadServerConfigFromCliArgs({
|
|
243
|
+
serverName: opts.serverName ?? DEFAULT_CONFIG.serverName,
|
|
244
|
+
networkName: opts.networkName ?? DEFAULT_CONFIG.networkName,
|
|
245
|
+
...(opts.motdLines !== undefined ? { motdLines: opts.motdLines } : {}),
|
|
246
|
+
...(opts.maxChannelsPerUser !== undefined
|
|
247
|
+
? { maxChannelsPerUser: opts.maxChannelsPerUser }
|
|
248
|
+
: {}),
|
|
249
|
+
...(opts.maxTargetsPerCommand !== undefined
|
|
250
|
+
? { maxTargetsPerCommand: opts.maxTargetsPerCommand }
|
|
251
|
+
: {}),
|
|
252
|
+
...(opts.maxListEntries !== undefined ? { maxListEntries: opts.maxListEntries } : {}),
|
|
253
|
+
...(opts.nickLen !== undefined ? { nickLen: opts.nickLen } : {}),
|
|
254
|
+
...(opts.channelLen !== undefined ? { channelLen: opts.channelLen } : {}),
|
|
255
|
+
...(opts.topicLen !== undefined ? { topicLen: opts.topicLen } : {}),
|
|
256
|
+
...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
|
|
257
|
+
...(opts.saslAccounts !== undefined ? { saslAccounts: opts.saslAccounts } : {}),
|
|
258
|
+
});
|
|
259
|
+
} catch (err) {
|
|
260
|
+
return Promise.reject(err);
|
|
261
|
+
}
|
|
262
|
+
const cfg: ResolvedServerConfig = {
|
|
263
|
+
serverName: parsed.serverName,
|
|
264
|
+
networkName: parsed.networkName,
|
|
265
|
+
motdLines: parsed.motdLines.length > 0 ? parsed.motdLines : DEFAULT_CONFIG.motdLines,
|
|
266
|
+
maxChannelsPerUser: parsed.maxChannelsPerUser,
|
|
267
|
+
maxTargetsPerCommand: parsed.maxTargetsPerCommand,
|
|
268
|
+
maxListEntries: parsed.maxListEntries,
|
|
269
|
+
nickLen: parsed.nickLen,
|
|
270
|
+
channelLen: parsed.channelLen,
|
|
271
|
+
topicLen: parsed.topicLen,
|
|
272
|
+
quitMessage: parsed.quitMessage,
|
|
273
|
+
...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
|
|
274
|
+
...(opts.cloaking !== undefined ? { cloaking: opts.cloaking } : {}),
|
|
275
|
+
...(opts.maxConnectionsPerIp !== undefined
|
|
276
|
+
? { maxConnectionsPerIp: opts.maxConnectionsPerIp }
|
|
277
|
+
: {}),
|
|
278
|
+
...(opts.maxConnectionsPerUser !== undefined
|
|
279
|
+
? { maxConnectionsPerUser: opts.maxConnectionsPerUser }
|
|
280
|
+
: {}),
|
|
281
|
+
...(opts.perIpConnectionRate !== undefined
|
|
282
|
+
? { perIpConnectionRate: opts.perIpConnectionRate }
|
|
283
|
+
: {}),
|
|
284
|
+
...(opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
|
|
285
|
+
? { saslAccounts: opts.saslAccounts }
|
|
286
|
+
: {}),
|
|
287
|
+
};
|
|
136
288
|
const hostname = opts.hostname ?? '127.0.0.1';
|
|
137
289
|
|
|
138
|
-
|
|
290
|
+
// Admission gate: enabled as soon as any cap or rate limit is configured.
|
|
291
|
+
// Defaults mirror irc-core's ServerConfigSchema so local-CLI behaviour
|
|
292
|
+
// matches the production adapters.
|
|
293
|
+
const admission: AdmissionConfig | undefined =
|
|
294
|
+
opts.maxConnectionsPerIp !== undefined ||
|
|
295
|
+
opts.maxConnectionsPerUser !== undefined ||
|
|
296
|
+
opts.perIpConnectionRate !== undefined
|
|
297
|
+
? {
|
|
298
|
+
maxConnectionsPerIp: opts.maxConnectionsPerIp ?? DEFAULT_MAX_CONNECTIONS_PER_IP,
|
|
299
|
+
maxConnectionsPerUser: opts.maxConnectionsPerUser ?? DEFAULT_MAX_CONNECTIONS_PER_USER,
|
|
300
|
+
perIpConnectionRate: opts.perIpConnectionRate ?? DEFAULT_PER_IP_CONNECTION_RATE,
|
|
301
|
+
}
|
|
302
|
+
: undefined;
|
|
303
|
+
|
|
304
|
+
const runtime = new InMemoryRuntime({
|
|
305
|
+
clock: { now: () => Date.now() },
|
|
306
|
+
...(admission !== undefined ? { admission } : {}),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Per-process chat-history store: one ring buffer shared by every
|
|
310
|
+
// connection so cross-connection playback works (a message alice sends
|
|
311
|
+
// is replayable when bob queries CHATHISTORY). The cap is config-driven.
|
|
312
|
+
const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
|
|
313
|
+
|
|
314
|
+
// SASL account store: seeded from config so `AUTHENTICATE PLAIN` works
|
|
315
|
+
// end-to-end. `undefined` (no accounts configured) leaves the actor's
|
|
316
|
+
// `ctx.accounts` unset, preserving the no-store behaviour.
|
|
317
|
+
const accounts =
|
|
318
|
+
cfg.saslAccounts !== undefined && cfg.saslAccounts.length > 0
|
|
319
|
+
? new InMemoryAccountStore(cfg.saslAccounts)
|
|
320
|
+
: undefined;
|
|
139
321
|
|
|
140
322
|
const wss = new WebSocketServer({ port: opts.port, host: hostname });
|
|
141
323
|
const wsConnections = new Map<WebSocket, ConnectionBindings>();
|
|
142
324
|
|
|
143
|
-
wss.on('connection', (ws) => {
|
|
144
|
-
|
|
325
|
+
wss.on('connection', (ws, req) => {
|
|
326
|
+
// Admission gate: refuse the connection BEFORE we attach an actor / emit
|
|
327
|
+
// any IRC state. Keeps per-IP / per-user counters authoritative and
|
|
328
|
+
// avoids burning a nick or runtime slot on a denied client.
|
|
329
|
+
const ip = req.socket.remoteAddress ?? 'unknown';
|
|
330
|
+
const decision = runtime.admitConnection(ip, undefined);
|
|
331
|
+
if (!decision.ok) {
|
|
332
|
+
ws.send(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
|
|
333
|
+
ws.close();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const admissionRecordId = decision.recordId;
|
|
337
|
+
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
338
|
+
|
|
339
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
|
|
145
340
|
sendText: (text) => {
|
|
146
341
|
if (ws.readyState === WebSocket.OPEN) ws.send(text);
|
|
147
342
|
},
|
|
148
343
|
closeTransport: () => ws.close(),
|
|
344
|
+
admissionRecordId,
|
|
345
|
+
sourceHost: ip,
|
|
149
346
|
});
|
|
150
347
|
wsConnections.set(ws, { state, actor });
|
|
151
348
|
|
|
@@ -161,7 +358,8 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
161
358
|
|
|
162
359
|
// 'close' and 'error' both end in the same cleanup: drop the connection
|
|
163
360
|
// map entry and unregister the conn from the runtime so it stops
|
|
164
|
-
// receiving broadcasts.
|
|
361
|
+
// receiving broadcasts. unregisterConnection also releases the
|
|
362
|
+
// admission record (if any) so per-IP / per-user counters stay accurate.
|
|
165
363
|
const cleanup = (): void => {
|
|
166
364
|
wsConnections.delete(ws);
|
|
167
365
|
runtime.unregisterConnection(state.id);
|
|
@@ -199,7 +397,23 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
199
397
|
const tcpServer = createServer((socket) => {
|
|
200
398
|
tcpSockets.add(socket);
|
|
201
399
|
const scanner = new LineScanner();
|
|
202
|
-
|
|
400
|
+
|
|
401
|
+
// Admission gate — same policy as the WS path: refuse before the
|
|
402
|
+
// actor is attached so per-IP / per-user counters stay authoritative.
|
|
403
|
+
const ip = socket.remoteAddress ?? 'unknown';
|
|
404
|
+
const decision = runtime.admitConnection(ip, undefined);
|
|
405
|
+
if (!decision.ok) {
|
|
406
|
+
if (socket.writable) {
|
|
407
|
+
socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
|
|
408
|
+
}
|
|
409
|
+
socket.destroy();
|
|
410
|
+
tcpSockets.delete(socket);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const admissionRecordId = decision.recordId;
|
|
414
|
+
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
415
|
+
|
|
416
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
|
|
203
417
|
sendText: (text) => {
|
|
204
418
|
if (socket.writable) socket.write(text);
|
|
205
419
|
},
|
|
@@ -209,6 +423,8 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
209
423
|
// callers go through `ws.close()`; TCP callers go through here.
|
|
210
424
|
socket.destroy();
|
|
211
425
|
},
|
|
426
|
+
admissionRecordId,
|
|
427
|
+
sourceHost: ip,
|
|
212
428
|
});
|
|
213
429
|
tcpConnections.set(socket, { state, actor });
|
|
214
430
|
|
|
@@ -261,6 +477,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
261
477
|
tcpPort,
|
|
262
478
|
hostname,
|
|
263
479
|
runtime,
|
|
480
|
+
messages,
|
|
264
481
|
testSockets: wss.clients,
|
|
265
482
|
testTcpSockets: tcpSockets,
|
|
266
483
|
close: () =>
|
|
@@ -286,11 +503,29 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
286
503
|
*/
|
|
287
504
|
function attachConnection(
|
|
288
505
|
runtime: InMemoryRuntime,
|
|
289
|
-
cfg:
|
|
290
|
-
|
|
506
|
+
cfg: ResolvedServerConfig,
|
|
507
|
+
messages: MessageStore,
|
|
508
|
+
accounts: AccountStore | undefined,
|
|
509
|
+
transport: {
|
|
510
|
+
sendText: (text: string) => void;
|
|
511
|
+
closeTransport: () => void;
|
|
512
|
+
admissionRecordId?: string;
|
|
513
|
+
sourceHost?: string;
|
|
514
|
+
},
|
|
291
515
|
): ConnectionBindings {
|
|
292
516
|
const id = randomUUID();
|
|
293
517
|
const state = createConnection({ id, connectedSince: Date.now() });
|
|
518
|
+
// Record the transport-visible source host up front. Used as the cloak
|
|
519
|
+
// input when cloaking is enabled, and as the visible host otherwise.
|
|
520
|
+
if (transport.sourceHost !== undefined) {
|
|
521
|
+
state.host = transport.sourceHost;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Per-connection logger: every record the actor emits
|
|
525
|
+
// carries this connection's id so the local-cli log stream can be
|
|
526
|
+
// filtered per client. traceId is generated per inbound frame by the
|
|
527
|
+
// actor itself.
|
|
528
|
+
const logger: Logger = new ConsoleLogger({ connectionId: id }, undefined, LogLevel.Info);
|
|
294
529
|
|
|
295
530
|
const actor = new ConnectionActor({
|
|
296
531
|
state,
|
|
@@ -306,27 +541,36 @@ function attachConnection(
|
|
|
306
541
|
channelLen: cfg.channelLen,
|
|
307
542
|
topicLen: cfg.topicLen,
|
|
308
543
|
quitMessage: cfg.quitMessage,
|
|
544
|
+
...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
|
|
545
|
+
...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
|
|
309
546
|
},
|
|
310
547
|
clock: { now: () => Date.now() },
|
|
311
548
|
ids: DEFAULT_ID_FACTORY,
|
|
312
549
|
motd: { lines: () => cfg.motdLines },
|
|
550
|
+
messages,
|
|
551
|
+
...(accounts !== undefined ? { accounts } : {}),
|
|
552
|
+
logger,
|
|
313
553
|
});
|
|
314
554
|
|
|
315
|
-
runtime.registerConnection(
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
555
|
+
runtime.registerConnection(
|
|
556
|
+
state,
|
|
557
|
+
{
|
|
558
|
+
send: (lines: RawLine[]) => {
|
|
559
|
+
const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
|
|
560
|
+
transport.sendText(text);
|
|
561
|
+
},
|
|
562
|
+
disconnect: (reason?: string) => {
|
|
563
|
+
// If a reason was supplied, send the RFC-style ERROR notice before
|
|
564
|
+
// closing. (QUIT does not supply one; future paths like flood
|
|
565
|
+
// control will.)
|
|
566
|
+
if (reason !== undefined) {
|
|
567
|
+
transport.sendText(`ERROR :Closing link: (${reason})\r\n`);
|
|
568
|
+
}
|
|
569
|
+
transport.closeTransport();
|
|
570
|
+
},
|
|
328
571
|
},
|
|
329
|
-
|
|
572
|
+
transport.admissionRecordId,
|
|
573
|
+
);
|
|
330
574
|
|
|
331
575
|
return { state, actor };
|
|
332
576
|
}
|
|
@@ -382,15 +626,6 @@ function closeEverything(args: {
|
|
|
382
626
|
return Promise.all([wsClosed, tcpClosed]).then(() => undefined);
|
|
383
627
|
}
|
|
384
628
|
|
|
385
|
-
function stripUndefined<T extends object>(obj: T): Partial<T> {
|
|
386
|
-
const out: Partial<T> = {};
|
|
387
|
-
for (const key of Object.keys(obj) as Array<keyof T>) {
|
|
388
|
-
const v = obj[key];
|
|
389
|
-
if (v !== undefined) out[key] = v;
|
|
390
|
-
}
|
|
391
|
-
return out;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
629
|
function logError(msg: string, err: unknown): void {
|
|
395
630
|
// Structured JSON per acceptance criteria.
|
|
396
631
|
const payload = { ts: new Date().toISOString(), level: 'error', msg, err: stringifyErr(err) };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loading for the local-cli app.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the shared `ServerConfigSchema` (from `irc-core`) is also
|
|
5
|
+
* consumed by the local-cli harness — both adapters consume the same
|
|
6
|
+
* schema, only the input plumbing differs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { loadServerConfigFromCliArgs } from '../src/config-loader';
|
|
11
|
+
|
|
12
|
+
describe('loadServerConfigFromCliArgs — minimal input', () => {
|
|
13
|
+
it('returns a parsed config from serverName + networkName alone', () => {
|
|
14
|
+
const cfg = loadServerConfigFromCliArgs({
|
|
15
|
+
serverName: 'irc.local.example.com',
|
|
16
|
+
networkName: 'LocalNet',
|
|
17
|
+
});
|
|
18
|
+
expect(cfg.serverName).toBe('irc.local.example.com');
|
|
19
|
+
expect(cfg.networkName).toBe('LocalNet');
|
|
20
|
+
expect(cfg.channelPrefixes.length).toBeGreaterThan(0);
|
|
21
|
+
expect(cfg.maxClients).toBeGreaterThan(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('accepts motdLines and threads them through to the parsed config', () => {
|
|
25
|
+
const cfg = loadServerConfigFromCliArgs({
|
|
26
|
+
serverName: 's',
|
|
27
|
+
networkName: 'n',
|
|
28
|
+
motdLines: ['hello', 'world'],
|
|
29
|
+
});
|
|
30
|
+
expect(cfg.motdLines).toEqual(['hello', 'world']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('passes numeric limit overrides through', () => {
|
|
34
|
+
const cfg = loadServerConfigFromCliArgs({
|
|
35
|
+
serverName: 's',
|
|
36
|
+
networkName: 'n',
|
|
37
|
+
maxChannelsPerUser: 5,
|
|
38
|
+
nickLen: 12,
|
|
39
|
+
channelLen: 32,
|
|
40
|
+
});
|
|
41
|
+
expect(cfg.maxChannelsPerUser).toBe(5);
|
|
42
|
+
expect(cfg.nickLen).toBe(12);
|
|
43
|
+
expect(cfg.channelLen).toBe(32);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('accepts channelPrefixes, maxClients and operCreds', () => {
|
|
47
|
+
const cfg = loadServerConfigFromCliArgs({
|
|
48
|
+
serverName: 's',
|
|
49
|
+
networkName: 'n',
|
|
50
|
+
channelPrefixes: '#&',
|
|
51
|
+
maxClients: 50,
|
|
52
|
+
operCreds: [{ user: 'op', password: 'p' }],
|
|
53
|
+
});
|
|
54
|
+
expect(cfg.channelPrefixes).toBe('#&');
|
|
55
|
+
expect(cfg.maxClients).toBe(50);
|
|
56
|
+
expect(cfg.operCreds).toEqual([{ user: 'op', password: 'p' }]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('loadServerConfigFromCliArgs — failure modes', () => {
|
|
61
|
+
it('throws when serverName is missing', () => {
|
|
62
|
+
expect(() => loadServerConfigFromCliArgs({ serverName: '', networkName: 'n' })).toThrowError(
|
|
63
|
+
/serverName/u,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('throws when networkName is empty', () => {
|
|
68
|
+
expect(() => loadServerConfigFromCliArgs({ serverName: 's', networkName: '' })).toThrowError(
|
|
69
|
+
/networkName/u,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('throws when maxClients is non-positive', () => {
|
|
74
|
+
expect(() =>
|
|
75
|
+
loadServerConfigFromCliArgs({ serverName: 's', networkName: 'n', maxClients: 0 }),
|
|
76
|
+
).toThrowError(/maxClients/u);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws when operCreds entries lack password', () => {
|
|
80
|
+
expect(() =>
|
|
81
|
+
loadServerConfigFromCliArgs({
|
|
82
|
+
serverName: 's',
|
|
83
|
+
networkName: 'n',
|
|
84
|
+
operCreds: [{ user: 'op', password: '' }],
|
|
85
|
+
}),
|
|
86
|
+
).toThrowError(/password/u);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('aggregates multiple errors into a single readable Error', () => {
|
|
90
|
+
try {
|
|
91
|
+
loadServerConfigFromCliArgs({
|
|
92
|
+
serverName: '',
|
|
93
|
+
networkName: '',
|
|
94
|
+
maxClients: -1,
|
|
95
|
+
});
|
|
96
|
+
expect.fail('expected throw');
|
|
97
|
+
} catch (err) {
|
|
98
|
+
expect(err).toBeInstanceOf(Error);
|
|
99
|
+
const msg = (err as Error).message;
|
|
100
|
+
expect(msg).toMatch(/invalid server config/iu);
|
|
101
|
+
const hits = ['serverName', 'networkName', 'maxClients'].filter((f) =>
|
|
102
|
+
msg.includes(f),
|
|
103
|
+
).length;
|
|
104
|
+
expect(hits).toBeGreaterThanOrEqual(2);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|