serverless-ircd 0.1.0 → 0.2.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/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- 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/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -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 +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -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 +67 -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/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 +158 -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 +427 -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/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- 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/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -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/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 +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -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 +66 -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/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -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/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -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 +162 -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 +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -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 +1 -1
- 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/progress.md +107 -0
- package/tickets.md +2485 -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/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/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -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
|
@@ -28,15 +28,50 @@ 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 AdmissionConfig,
|
|
31
32
|
type ChanName,
|
|
32
33
|
type ConnectionState,
|
|
34
|
+
ConsoleLogger,
|
|
35
|
+
InMemoryMessageStore,
|
|
36
|
+
LogLevel,
|
|
37
|
+
type Logger,
|
|
38
|
+
type MessageStore,
|
|
33
39
|
type RawLine,
|
|
34
40
|
createConnection,
|
|
35
41
|
} from '@serverless-ircd/irc-core';
|
|
36
42
|
import { ConnectionActor } from '@serverless-ircd/irc-server';
|
|
37
43
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
44
|
+
import { loadServerConfigFromCliArgs } from './config-loader.js';
|
|
38
45
|
import { LineScanner } from './line-scanner.js';
|
|
39
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The fully-resolved config object passed to `attachConnection`. Legacy
|
|
49
|
+
* fields always have values (sane defaults); the security knobs remain
|
|
50
|
+
* optional because `undefined` is meaningful (= feature disabled).
|
|
51
|
+
*/
|
|
52
|
+
type ResolvedServerConfig = Omit<
|
|
53
|
+
Required<LocalServerConfig>,
|
|
54
|
+
| 'serverPassword'
|
|
55
|
+
| 'cloaking'
|
|
56
|
+
| 'maxConnectionsPerIp'
|
|
57
|
+
| 'maxConnectionsPerUser'
|
|
58
|
+
| 'perIpConnectionRate'
|
|
59
|
+
| 'historyMaxPerChannel'
|
|
60
|
+
> &
|
|
61
|
+
Pick<
|
|
62
|
+
LocalServerConfig,
|
|
63
|
+
| 'serverPassword'
|
|
64
|
+
| 'cloaking'
|
|
65
|
+
| 'maxConnectionsPerIp'
|
|
66
|
+
| 'maxConnectionsPerUser'
|
|
67
|
+
| 'perIpConnectionRate'
|
|
68
|
+
>;
|
|
69
|
+
|
|
70
|
+
/** Default per-IP / per-user caps used when callers don't override. */
|
|
71
|
+
const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
|
|
72
|
+
const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
|
|
73
|
+
const DEFAULT_PER_IP_CONNECTION_RATE = { max: 5, windowMs: 60_000 };
|
|
74
|
+
|
|
40
75
|
/** Default server config; `startLocalServer` accepts overrides. */
|
|
41
76
|
export interface LocalServerConfig {
|
|
42
77
|
serverName?: string;
|
|
@@ -49,6 +84,43 @@ export interface LocalServerConfig {
|
|
|
49
84
|
channelLen?: number;
|
|
50
85
|
topicLen?: number;
|
|
51
86
|
quitMessage?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Server password gate. Undefined / empty disables enforcement;
|
|
89
|
+
* otherwise every connection must supply the matching value via
|
|
90
|
+
* `PASS <password>` (or SASL PLAIN) before registration completes.
|
|
91
|
+
*/
|
|
92
|
+
readonly serverPassword?: string;
|
|
93
|
+
/**
|
|
94
|
+
* Hostmask cloaking policy. Undefined disables cloaking; passing
|
|
95
|
+
* `{ enabled: true, secret: '...' }` replaces every connection's
|
|
96
|
+
* visible host with a deterministic cloak derived from the secret
|
|
97
|
+
* and the connection's source identifier.
|
|
98
|
+
*/
|
|
99
|
+
readonly cloaking?: { enabled: boolean; secret: string; cloakedSuffix?: string } | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* Per-IP simultaneous-connection cap. The local server enforces this
|
|
102
|
+
* at WebSocket upgrade time. Defaults to disabled (no cap) when
|
|
103
|
+
* omitted; pass a positive integer to enforce.
|
|
104
|
+
*/
|
|
105
|
+
readonly maxConnectionsPerIp?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Per-user (SASL account, falling back to source IP) simultaneous-
|
|
108
|
+
* connection cap. Same enforcement path as `maxConnectionsPerIp`.
|
|
109
|
+
*/
|
|
110
|
+
readonly maxConnectionsPerUser?: number;
|
|
111
|
+
/**
|
|
112
|
+
* Per-IP connection-rate limit (sliding window). Caps how fast one
|
|
113
|
+
* source IP may open new connections.
|
|
114
|
+
*/
|
|
115
|
+
readonly perIpConnectionRate?: { max: number; windowMs: number };
|
|
116
|
+
/**
|
|
117
|
+
* Maximum back-log messages retained per channel for IRCv3
|
|
118
|
+
* `draft/chathistory` playback. When omitted, the shared
|
|
119
|
+
* {@link InMemoryMessageStore} default (100) applies. Only consulted
|
|
120
|
+
* when chathistory playback is enabled (it always is for the local
|
|
121
|
+
* CLI — the store is bound unconditionally).
|
|
122
|
+
*/
|
|
123
|
+
historyMaxPerChannel?: number;
|
|
52
124
|
}
|
|
53
125
|
|
|
54
126
|
export interface StartServerOptions extends LocalServerConfig {
|
|
@@ -73,6 +145,12 @@ export interface LocalServer {
|
|
|
73
145
|
*/
|
|
74
146
|
readonly tcpPort?: number | undefined;
|
|
75
147
|
readonly runtime: InMemoryRuntime;
|
|
148
|
+
/**
|
|
149
|
+
* The per-process chat-history store backing `draft/chathistory`
|
|
150
|
+
* playback. Shared across every connection so a message recorded by
|
|
151
|
+
* one client is replayable by another. Exposed for test assertions.
|
|
152
|
+
*/
|
|
153
|
+
readonly messages: MessageStore;
|
|
76
154
|
close(): Promise<void>;
|
|
77
155
|
/**
|
|
78
156
|
* Test-only seam: live server-side WebSockets. Exposed so tests can
|
|
@@ -92,7 +170,15 @@ interface ConnectionBindings {
|
|
|
92
170
|
actor: ConnectionActor;
|
|
93
171
|
}
|
|
94
172
|
|
|
95
|
-
const DEFAULT_CONFIG:
|
|
173
|
+
const DEFAULT_CONFIG: Omit<
|
|
174
|
+
Required<LocalServerConfig>,
|
|
175
|
+
| 'serverPassword'
|
|
176
|
+
| 'cloaking'
|
|
177
|
+
| 'maxConnectionsPerIp'
|
|
178
|
+
| 'maxConnectionsPerUser'
|
|
179
|
+
| 'perIpConnectionRate'
|
|
180
|
+
| 'historyMaxPerChannel'
|
|
181
|
+
> = {
|
|
96
182
|
serverName: 'irc.example.com',
|
|
97
183
|
networkName: 'ExampleNet',
|
|
98
184
|
motdLines: ['Welcome to the local-cli ServerlessIRCd instance.'],
|
|
@@ -120,6 +206,7 @@ export const DEFAULT_ID_FACTORY = {
|
|
|
120
206
|
batchId: () => randomUUID(),
|
|
121
207
|
nonce: () => randomUUID(),
|
|
122
208
|
sessionId: () => randomUUID(),
|
|
209
|
+
traceId: () => randomUUID(),
|
|
123
210
|
};
|
|
124
211
|
|
|
125
212
|
/**
|
|
@@ -132,20 +219,106 @@ export const DEFAULT_ID_FACTORY = {
|
|
|
132
219
|
* don't leak a half-started server.
|
|
133
220
|
*/
|
|
134
221
|
export function startLocalServer(opts: StartServerOptions): Promise<LocalServer> {
|
|
135
|
-
|
|
222
|
+
// Validate the user-supplied config through the shared ServerConfigSchema
|
|
223
|
+
// (sourced from `irc-core`). Both adapters consume the same schema; here
|
|
224
|
+
// we discard the parsed result and only retain the side-effect of
|
|
225
|
+
// throwing on a malformed config at boot. The actor still consumes the
|
|
226
|
+
// `LocalServerConfig` projection so existing callers (and the e2e
|
|
227
|
+
// harness) keep their permissive defaults.
|
|
228
|
+
let parsed: ReturnType<typeof loadServerConfigFromCliArgs>;
|
|
229
|
+
try {
|
|
230
|
+
parsed = loadServerConfigFromCliArgs({
|
|
231
|
+
serverName: opts.serverName ?? DEFAULT_CONFIG.serverName,
|
|
232
|
+
networkName: opts.networkName ?? DEFAULT_CONFIG.networkName,
|
|
233
|
+
...(opts.motdLines !== undefined ? { motdLines: opts.motdLines } : {}),
|
|
234
|
+
...(opts.maxChannelsPerUser !== undefined
|
|
235
|
+
? { maxChannelsPerUser: opts.maxChannelsPerUser }
|
|
236
|
+
: {}),
|
|
237
|
+
...(opts.maxTargetsPerCommand !== undefined
|
|
238
|
+
? { maxTargetsPerCommand: opts.maxTargetsPerCommand }
|
|
239
|
+
: {}),
|
|
240
|
+
...(opts.maxListEntries !== undefined ? { maxListEntries: opts.maxListEntries } : {}),
|
|
241
|
+
...(opts.nickLen !== undefined ? { nickLen: opts.nickLen } : {}),
|
|
242
|
+
...(opts.channelLen !== undefined ? { channelLen: opts.channelLen } : {}),
|
|
243
|
+
...(opts.topicLen !== undefined ? { topicLen: opts.topicLen } : {}),
|
|
244
|
+
...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
|
|
245
|
+
});
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return Promise.reject(err);
|
|
248
|
+
}
|
|
249
|
+
const cfg: ResolvedServerConfig = {
|
|
250
|
+
serverName: parsed.serverName,
|
|
251
|
+
networkName: parsed.networkName,
|
|
252
|
+
motdLines: parsed.motdLines.length > 0 ? parsed.motdLines : DEFAULT_CONFIG.motdLines,
|
|
253
|
+
maxChannelsPerUser: parsed.maxChannelsPerUser,
|
|
254
|
+
maxTargetsPerCommand: parsed.maxTargetsPerCommand,
|
|
255
|
+
maxListEntries: parsed.maxListEntries,
|
|
256
|
+
nickLen: parsed.nickLen,
|
|
257
|
+
channelLen: parsed.channelLen,
|
|
258
|
+
topicLen: parsed.topicLen,
|
|
259
|
+
quitMessage: parsed.quitMessage,
|
|
260
|
+
...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
|
|
261
|
+
...(opts.cloaking !== undefined ? { cloaking: opts.cloaking } : {}),
|
|
262
|
+
...(opts.maxConnectionsPerIp !== undefined
|
|
263
|
+
? { maxConnectionsPerIp: opts.maxConnectionsPerIp }
|
|
264
|
+
: {}),
|
|
265
|
+
...(opts.maxConnectionsPerUser !== undefined
|
|
266
|
+
? { maxConnectionsPerUser: opts.maxConnectionsPerUser }
|
|
267
|
+
: {}),
|
|
268
|
+
...(opts.perIpConnectionRate !== undefined
|
|
269
|
+
? { perIpConnectionRate: opts.perIpConnectionRate }
|
|
270
|
+
: {}),
|
|
271
|
+
};
|
|
136
272
|
const hostname = opts.hostname ?? '127.0.0.1';
|
|
137
273
|
|
|
138
|
-
|
|
274
|
+
// Admission gate: enabled as soon as any cap or rate limit is configured.
|
|
275
|
+
// Defaults mirror irc-core's ServerConfigSchema so local-CLI behaviour
|
|
276
|
+
// matches the production adapters.
|
|
277
|
+
const admission: AdmissionConfig | undefined =
|
|
278
|
+
opts.maxConnectionsPerIp !== undefined ||
|
|
279
|
+
opts.maxConnectionsPerUser !== undefined ||
|
|
280
|
+
opts.perIpConnectionRate !== undefined
|
|
281
|
+
? {
|
|
282
|
+
maxConnectionsPerIp: opts.maxConnectionsPerIp ?? DEFAULT_MAX_CONNECTIONS_PER_IP,
|
|
283
|
+
maxConnectionsPerUser: opts.maxConnectionsPerUser ?? DEFAULT_MAX_CONNECTIONS_PER_USER,
|
|
284
|
+
perIpConnectionRate: opts.perIpConnectionRate ?? DEFAULT_PER_IP_CONNECTION_RATE,
|
|
285
|
+
}
|
|
286
|
+
: undefined;
|
|
287
|
+
|
|
288
|
+
const runtime = new InMemoryRuntime({
|
|
289
|
+
clock: { now: () => Date.now() },
|
|
290
|
+
...(admission !== undefined ? { admission } : {}),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Per-process chat-history store: one ring buffer shared by every
|
|
294
|
+
// connection so cross-connection playback works (a message alice sends
|
|
295
|
+
// is replayable when bob queries CHATHISTORY). The cap is config-driven.
|
|
296
|
+
const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
|
|
139
297
|
|
|
140
298
|
const wss = new WebSocketServer({ port: opts.port, host: hostname });
|
|
141
299
|
const wsConnections = new Map<WebSocket, ConnectionBindings>();
|
|
142
300
|
|
|
143
|
-
wss.on('connection', (ws) => {
|
|
144
|
-
|
|
301
|
+
wss.on('connection', (ws, req) => {
|
|
302
|
+
// Admission gate: refuse the connection BEFORE we attach an actor / emit
|
|
303
|
+
// any IRC state. Keeps per-IP / per-user counters authoritative and
|
|
304
|
+
// avoids burning a nick or runtime slot on a denied client.
|
|
305
|
+
const ip = req.socket.remoteAddress ?? 'unknown';
|
|
306
|
+
const decision = runtime.admitConnection(ip, undefined);
|
|
307
|
+
if (!decision.ok) {
|
|
308
|
+
ws.send(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
|
|
309
|
+
ws.close();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const admissionRecordId = decision.recordId;
|
|
313
|
+
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
314
|
+
|
|
315
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, {
|
|
145
316
|
sendText: (text) => {
|
|
146
317
|
if (ws.readyState === WebSocket.OPEN) ws.send(text);
|
|
147
318
|
},
|
|
148
319
|
closeTransport: () => ws.close(),
|
|
320
|
+
admissionRecordId,
|
|
321
|
+
sourceHost: ip,
|
|
149
322
|
});
|
|
150
323
|
wsConnections.set(ws, { state, actor });
|
|
151
324
|
|
|
@@ -161,7 +334,8 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
161
334
|
|
|
162
335
|
// 'close' and 'error' both end in the same cleanup: drop the connection
|
|
163
336
|
// map entry and unregister the conn from the runtime so it stops
|
|
164
|
-
// receiving broadcasts.
|
|
337
|
+
// receiving broadcasts. unregisterConnection also releases the
|
|
338
|
+
// admission record (if any) so per-IP / per-user counters stay accurate.
|
|
165
339
|
const cleanup = (): void => {
|
|
166
340
|
wsConnections.delete(ws);
|
|
167
341
|
runtime.unregisterConnection(state.id);
|
|
@@ -199,7 +373,23 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
199
373
|
const tcpServer = createServer((socket) => {
|
|
200
374
|
tcpSockets.add(socket);
|
|
201
375
|
const scanner = new LineScanner();
|
|
202
|
-
|
|
376
|
+
|
|
377
|
+
// Admission gate — same policy as the WS path: refuse before the
|
|
378
|
+
// actor is attached so per-IP / per-user counters stay authoritative.
|
|
379
|
+
const ip = socket.remoteAddress ?? 'unknown';
|
|
380
|
+
const decision = runtime.admitConnection(ip, undefined);
|
|
381
|
+
if (!decision.ok) {
|
|
382
|
+
if (socket.writable) {
|
|
383
|
+
socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
|
|
384
|
+
}
|
|
385
|
+
socket.destroy();
|
|
386
|
+
tcpSockets.delete(socket);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const admissionRecordId = decision.recordId;
|
|
390
|
+
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
391
|
+
|
|
392
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, {
|
|
203
393
|
sendText: (text) => {
|
|
204
394
|
if (socket.writable) socket.write(text);
|
|
205
395
|
},
|
|
@@ -209,6 +399,8 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
209
399
|
// callers go through `ws.close()`; TCP callers go through here.
|
|
210
400
|
socket.destroy();
|
|
211
401
|
},
|
|
402
|
+
admissionRecordId,
|
|
403
|
+
sourceHost: ip,
|
|
212
404
|
});
|
|
213
405
|
tcpConnections.set(socket, { state, actor });
|
|
214
406
|
|
|
@@ -261,6 +453,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
261
453
|
tcpPort,
|
|
262
454
|
hostname,
|
|
263
455
|
runtime,
|
|
456
|
+
messages,
|
|
264
457
|
testSockets: wss.clients,
|
|
265
458
|
testTcpSockets: tcpSockets,
|
|
266
459
|
close: () =>
|
|
@@ -286,11 +479,28 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
286
479
|
*/
|
|
287
480
|
function attachConnection(
|
|
288
481
|
runtime: InMemoryRuntime,
|
|
289
|
-
cfg:
|
|
290
|
-
|
|
482
|
+
cfg: ResolvedServerConfig,
|
|
483
|
+
messages: MessageStore,
|
|
484
|
+
transport: {
|
|
485
|
+
sendText: (text: string) => void;
|
|
486
|
+
closeTransport: () => void;
|
|
487
|
+
admissionRecordId?: string;
|
|
488
|
+
sourceHost?: string;
|
|
489
|
+
},
|
|
291
490
|
): ConnectionBindings {
|
|
292
491
|
const id = randomUUID();
|
|
293
492
|
const state = createConnection({ id, connectedSince: Date.now() });
|
|
493
|
+
// Record the transport-visible source host up front. Used as the cloak
|
|
494
|
+
// input when cloaking is enabled, and as the visible host otherwise.
|
|
495
|
+
if (transport.sourceHost !== undefined) {
|
|
496
|
+
state.host = transport.sourceHost;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Per-connection logger: every record the actor emits
|
|
500
|
+
// carries this connection's id so the local-cli log stream can be
|
|
501
|
+
// filtered per client. traceId is generated per inbound frame by the
|
|
502
|
+
// actor itself.
|
|
503
|
+
const logger: Logger = new ConsoleLogger({ connectionId: id }, undefined, LogLevel.Info);
|
|
294
504
|
|
|
295
505
|
const actor = new ConnectionActor({
|
|
296
506
|
state,
|
|
@@ -306,27 +516,35 @@ function attachConnection(
|
|
|
306
516
|
channelLen: cfg.channelLen,
|
|
307
517
|
topicLen: cfg.topicLen,
|
|
308
518
|
quitMessage: cfg.quitMessage,
|
|
519
|
+
...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
|
|
520
|
+
...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
|
|
309
521
|
},
|
|
310
522
|
clock: { now: () => Date.now() },
|
|
311
523
|
ids: DEFAULT_ID_FACTORY,
|
|
312
524
|
motd: { lines: () => cfg.motdLines },
|
|
525
|
+
messages,
|
|
526
|
+
logger,
|
|
313
527
|
});
|
|
314
528
|
|
|
315
|
-
runtime.registerConnection(
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
529
|
+
runtime.registerConnection(
|
|
530
|
+
state,
|
|
531
|
+
{
|
|
532
|
+
send: (lines: RawLine[]) => {
|
|
533
|
+
const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
|
|
534
|
+
transport.sendText(text);
|
|
535
|
+
},
|
|
536
|
+
disconnect: (reason?: string) => {
|
|
537
|
+
// If a reason was supplied, send the RFC-style ERROR notice before
|
|
538
|
+
// closing. (QUIT does not supply one; future paths like flood
|
|
539
|
+
// control will.)
|
|
540
|
+
if (reason !== undefined) {
|
|
541
|
+
transport.sendText(`ERROR :Closing link: (${reason})\r\n`);
|
|
542
|
+
}
|
|
543
|
+
transport.closeTransport();
|
|
544
|
+
},
|
|
328
545
|
},
|
|
329
|
-
|
|
546
|
+
transport.admissionRecordId,
|
|
547
|
+
);
|
|
330
548
|
|
|
331
549
|
return { state, actor };
|
|
332
550
|
}
|
|
@@ -382,15 +600,6 @@ function closeEverything(args: {
|
|
|
382
600
|
return Promise.all([wsClosed, tcpClosed]).then(() => undefined);
|
|
383
601
|
}
|
|
384
602
|
|
|
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
603
|
function logError(msg: string, err: unknown): void {
|
|
395
604
|
// Structured JSON per acceptance criteria.
|
|
396
605
|
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
|
+
});
|
|
@@ -178,6 +178,19 @@ describe('local-cli error paths', () => {
|
|
|
178
178
|
await first.close();
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
+
it('validates the supplied config through the shared ServerConfigSchema at boot', async () => {
|
|
182
|
+
// nickLen=0 is rejected by the schema (must be a positive integer).
|
|
183
|
+
await expect(
|
|
184
|
+
startLocalServer({
|
|
185
|
+
port: 0,
|
|
186
|
+
hostname: '127.0.0.1',
|
|
187
|
+
serverName: 'irc.local.example.com',
|
|
188
|
+
networkName: 'LocalNet',
|
|
189
|
+
nickLen: 0,
|
|
190
|
+
}),
|
|
191
|
+
).rejects.toThrowError(/invalid server config/iu);
|
|
192
|
+
});
|
|
193
|
+
|
|
181
194
|
it('logs and recovers from a per-socket error without crashing the server', async () => {
|
|
182
195
|
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
183
196
|
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
@@ -344,3 +357,64 @@ describe('local-cli error paths', () => {
|
|
|
344
357
|
await srv.close();
|
|
345
358
|
});
|
|
346
359
|
});
|
|
360
|
+
|
|
361
|
+
describe('local-cli chathistory end-to-end', () => {
|
|
362
|
+
const CHATHISTORY_CAPS = 'draft/chathistory server-time message-tags batch';
|
|
363
|
+
|
|
364
|
+
/** Registers a client that has negotiated the chathistory cap set. */
|
|
365
|
+
async function registerWithCaps(srv: LocalServer, nick: string): Promise<TestClient> {
|
|
366
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
367
|
+
await client.opened();
|
|
368
|
+
client.send(`CAP REQ :${CHATHISTORY_CAPS}\r\n`);
|
|
369
|
+
await client.waitFor((l) => / CAP \S+ ACK :/.test(l));
|
|
370
|
+
client.send(`NICK ${nick}\r\n`);
|
|
371
|
+
client.send(`USER ${nick} 0 * :${nick}\r\n`);
|
|
372
|
+
client.send('CAP END\r\n');
|
|
373
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
374
|
+
return client;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
|
|
378
|
+
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
379
|
+
const alice = await registerWithCaps(srv, 'ch-alice');
|
|
380
|
+
|
|
381
|
+
alice.send('JOIN #chathist-e2e\r\n');
|
|
382
|
+
await alice.waitFor((l) => l.includes(' 366 '));
|
|
383
|
+
|
|
384
|
+
alice.send('PRIVMSG #chathist-e2e :backlog hello\r\n');
|
|
385
|
+
|
|
386
|
+
alice.send('CHATHISTORY LATEST #chathist-e2e * 10\r\n');
|
|
387
|
+
const batchOpen = await alice.waitFor((l) => /^BATCH \+\S+ chathistory #chathist-e2e/.test(l));
|
|
388
|
+
expect(batchOpen).toMatch(/^BATCH \+\S+ chathistory #chathist-e2e/);
|
|
389
|
+
|
|
390
|
+
const replay = await alice.waitFor((l) => l.includes('PRIVMSG #chathist-e2e :backlog hello'));
|
|
391
|
+
expect(replay).toContain('PRIVMSG #chathist-e2e :backlog hello');
|
|
392
|
+
|
|
393
|
+
await alice.close();
|
|
394
|
+
await srv.close();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('prepends a chathistory BATCH on rejoin after messages were sent', async () => {
|
|
398
|
+
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
399
|
+
|
|
400
|
+
// Seed history as alice: join + send a message that bob has missed.
|
|
401
|
+
const alice = await registerWithCaps(srv, 'ch-seed');
|
|
402
|
+
alice.send('JOIN #rejoin-e2e\r\n');
|
|
403
|
+
await alice.waitFor((l) => l.includes(' 366 ') && l.includes('#rejoin-e2e'));
|
|
404
|
+
alice.send('PRIVMSG #rejoin-e2e :while you were away\r\n');
|
|
405
|
+
// Give the frame a moment to record before bob joins.
|
|
406
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
407
|
+
|
|
408
|
+
// A second cap-enabled client joins the same channel and must receive
|
|
409
|
+
// the auto-playback BATCH before the normal JOIN/353/366 sequence.
|
|
410
|
+
const bob = await registerWithCaps(srv, 'ch-rejoin');
|
|
411
|
+
bob.send('JOIN #rejoin-e2e\r\n');
|
|
412
|
+
const playback = await bob.waitFor((l) => /^BATCH \+\S+ chathistory #rejoin-e2e/.test(l));
|
|
413
|
+
expect(playback).toMatch(/^BATCH \+\S+ chathistory #rejoin-e2e/);
|
|
414
|
+
await bob.waitFor((l) => l.includes('PRIVMSG #rejoin-e2e :while you were away'));
|
|
415
|
+
|
|
416
|
+
await alice.close();
|
|
417
|
+
await bob.close();
|
|
418
|
+
await srv.close();
|
|
419
|
+
});
|
|
420
|
+
});
|