serverless-ircd 0.4.0 → 0.5.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/CHANGELOG.md +175 -0
- package/README.md +88 -19
- package/apps/aws-stack/README.md +4 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +32 -4
- package/apps/aws-stack/tests/stack.test.ts +47 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +129 -21
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +63 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
- package/packages/aws-adapter/tests/connect.test.ts +97 -1
- package/packages/aws-adapter/tests/handlers.test.ts +148 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +182 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +1 -1
- package/packages/irc-core/src/commands/index.ts +8 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +6 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +4 -8
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/config.ts +7 -0
- package/packages/irc-core/src/effects.ts +27 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +179 -0
- package/packages/irc-core/src/protocol/numerics.ts +42 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +8 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +57 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +249 -16
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +1089 -1
- package/packages/irc-server/tests/dispatch.test.ts +37 -0
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
WS_SUBPROTO_BINARY,
|
|
4
|
+
WS_SUBPROTO_TEXT,
|
|
5
|
+
type WsSubprotocol,
|
|
6
|
+
parseSecWsProtocolOffers,
|
|
7
|
+
selectSubprotocol,
|
|
8
|
+
} from '../src/ws-subprotocol';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// constants & type
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
describe('WS subprotocol constants', () => {
|
|
15
|
+
it('exposes the IRCv3 text subprotocol name', () => {
|
|
16
|
+
expect(WS_SUBPROTO_TEXT).toBe('text.ircv3.net');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('exposes the IRCv3 binary subprotocol name', () => {
|
|
20
|
+
expect(WS_SUBPROTO_BINARY).toBe('binary.ircv3.net');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('the WsSubprotocol type resolves to the two literal names', () => {
|
|
24
|
+
const a: WsSubprotocol = WS_SUBPROTO_TEXT;
|
|
25
|
+
const b: WsSubprotocol = WS_SUBPROTO_BINARY;
|
|
26
|
+
expect([a, b]).toEqual([WS_SUBPROTO_TEXT, WS_SUBPROTO_BINARY]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// parseSecWsProtocolOffers — header parsing
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
describe('parseSecWsProtocolOffers', () => {
|
|
35
|
+
it('splits a comma-separated list into ordered offers', () => {
|
|
36
|
+
expect(parseSecWsProtocolOffers('text.ircv3.net, binary.ircv3.net')).toEqual([
|
|
37
|
+
'text.ircv3.net',
|
|
38
|
+
'binary.ircv3.net',
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('trims whitespace around each item', () => {
|
|
43
|
+
expect(parseSecWsProtocolOffers(' text.ircv3.net , binary.ircv3.net ')).toEqual([
|
|
44
|
+
'text.ircv3.net',
|
|
45
|
+
'binary.ircv3.net',
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('preserves client-preference order', () => {
|
|
50
|
+
expect(parseSecWsProtocolOffers('binary.ircv3.net, text.ircv3.net')).toEqual([
|
|
51
|
+
'binary.ircv3.net',
|
|
52
|
+
'text.ircv3.net',
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns an empty array for null', () => {
|
|
57
|
+
expect(parseSecWsProtocolOffers(null)).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns an empty array for undefined', () => {
|
|
61
|
+
expect(parseSecWsProtocolOffers(undefined)).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns an empty array for an empty string', () => {
|
|
65
|
+
expect(parseSecWsProtocolOffers('')).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('drops empty entries produced by stray commas', () => {
|
|
69
|
+
expect(parseSecWsProtocolOffers('text.ircv3.net,, ,binary.ircv3.net')).toEqual([
|
|
70
|
+
'text.ircv3.net',
|
|
71
|
+
'binary.ircv3.net',
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('preserves duplicate entries in order', () => {
|
|
76
|
+
expect(parseSecWsProtocolOffers('text.ircv3.net, text.ircv3.net')).toEqual([
|
|
77
|
+
'text.ircv3.net',
|
|
78
|
+
'text.ircv3.net',
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// selectSubprotocol — preference-ordered selection
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
describe('selectSubprotocol', () => {
|
|
88
|
+
it('returns the first supported offer in client-preference order (text first)', () => {
|
|
89
|
+
expect(selectSubprotocol(['text.ircv3.net', 'binary.ircv3.net'])).toBe('text.ircv3.net');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('honours client preference when binary is offered first', () => {
|
|
93
|
+
expect(selectSubprotocol(['binary.ircv3.net', 'text.ircv3.net'])).toBe('binary.ircv3.net');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns null when only unsupported protocols are offered', () => {
|
|
97
|
+
expect(selectSubprotocol(['foo', 'bar'])).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns null for an empty offer list', () => {
|
|
101
|
+
expect(selectSubprotocol([])).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('skips unsupported offers and selects the first supported one', () => {
|
|
105
|
+
expect(selectSubprotocol(['foo', 'binary.ircv3.net'])).toBe('binary.ircv3.net');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns the first occurrence for duplicate supported offers', () => {
|
|
109
|
+
expect(selectSubprotocol(['text.ircv3.net', 'text.ircv3.net'])).toBe('text.ircv3.net');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -46,15 +46,23 @@ import {
|
|
|
46
46
|
Numerics,
|
|
47
47
|
type RawLine,
|
|
48
48
|
type ServerConfig,
|
|
49
|
+
type ServerStats,
|
|
49
50
|
buildCtx,
|
|
50
51
|
chathistoryReducer,
|
|
51
52
|
handleJoinZero,
|
|
52
53
|
isChannelTarget,
|
|
53
54
|
isonReducer,
|
|
55
|
+
killReducer,
|
|
54
56
|
listReducer,
|
|
57
|
+
lusersReducer,
|
|
55
58
|
parse,
|
|
59
|
+
rehashLine,
|
|
60
|
+
rehashReducer,
|
|
61
|
+
statsReducer,
|
|
56
62
|
toSnapshot,
|
|
63
|
+
traceReducer,
|
|
57
64
|
userhostReducer,
|
|
65
|
+
wallopsReducer,
|
|
58
66
|
whoReducer,
|
|
59
67
|
whoisReducer,
|
|
60
68
|
whowasReducer,
|
|
@@ -100,6 +108,13 @@ export interface ConnectionActorOptions {
|
|
|
100
108
|
runtime: IrcRuntime;
|
|
101
109
|
channels: ActorChannelAccess;
|
|
102
110
|
serverConfig: ServerConfig;
|
|
111
|
+
/**
|
|
112
|
+
* Short label identifying the adapter's bound config source (e.g.
|
|
113
|
+
* `"KV"`, `"Secrets Manager"`, `"config file"`). Surfaced in the
|
|
114
|
+
* `382 RPL_REHASHING` reply's trailing text so the oper can see where the
|
|
115
|
+
* reload was attempted from. Defaults to `"server config"` when omitted.
|
|
116
|
+
*/
|
|
117
|
+
readonly configSource?: string;
|
|
103
118
|
clock: Clock;
|
|
104
119
|
ids: IdFactory;
|
|
105
120
|
motd?: MotdProvider;
|
|
@@ -138,6 +153,17 @@ export interface ConnectionActorOptions {
|
|
|
138
153
|
* preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
|
|
139
154
|
*/
|
|
140
155
|
readonly history?: NickHistoryStore;
|
|
156
|
+
/**
|
|
157
|
+
* Network-statistics source for the `LUSERS` and `STATS` commands
|
|
158
|
+
* (absent when no stats backend is wired). When bound, the actor
|
|
159
|
+
* awaits `stats.getStats()` once per LUSERS/STATS invocation and hands
|
|
160
|
+
* the resulting snapshot to the pure reducers — same pattern as
|
|
161
|
+
* `listChannels` → `listReducer`. Omitted (→ a zero-count fallback)
|
|
162
|
+
* preserves graceful behaviour for deployments/tests that have not
|
|
163
|
+
* opted in: `LUSERS` reports zero users/channels, `STATS u` reports
|
|
164
|
+
* zero uptime.
|
|
165
|
+
*/
|
|
166
|
+
readonly stats?: ServerStats;
|
|
141
167
|
/**
|
|
142
168
|
* Structured-logger port. When omitted the actor runs
|
|
143
169
|
* silently — observability is purely opt-in. When supplied, every
|
|
@@ -166,7 +192,8 @@ export class ConnectionActor {
|
|
|
166
192
|
private readonly state: ConnectionState;
|
|
167
193
|
private readonly runtime: IrcRuntime;
|
|
168
194
|
private readonly channels: ActorChannelAccess;
|
|
169
|
-
private
|
|
195
|
+
private serverConfig: ServerConfig;
|
|
196
|
+
private readonly configSource: string;
|
|
170
197
|
private readonly clock: Clock;
|
|
171
198
|
private readonly ids: IdFactory;
|
|
172
199
|
private readonly motd: MotdProvider;
|
|
@@ -174,6 +201,7 @@ export class ConnectionActor {
|
|
|
174
201
|
private readonly accounts: AccountStore | undefined;
|
|
175
202
|
private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
|
|
176
203
|
private readonly history: NickHistoryStore | undefined;
|
|
204
|
+
private readonly stats: ServerStats | undefined;
|
|
177
205
|
private readonly reducers: RoutedReducers;
|
|
178
206
|
/**
|
|
179
207
|
* Line-framing seam. Turns inbound input (a WS text frame or a raw TCP
|
|
@@ -200,6 +228,7 @@ export class ConnectionActor {
|
|
|
200
228
|
this.runtime = opts.runtime;
|
|
201
229
|
this.channels = opts.channels;
|
|
202
230
|
this.serverConfig = opts.serverConfig;
|
|
231
|
+
this.configSource = opts.configSource ?? 'server config';
|
|
203
232
|
this.clock = opts.clock;
|
|
204
233
|
this.ids = opts.ids;
|
|
205
234
|
this.motd = opts.motd ?? EmptyMotdProvider;
|
|
@@ -207,6 +236,7 @@ export class ConnectionActor {
|
|
|
207
236
|
this.accounts = opts.accounts;
|
|
208
237
|
this.mtlsIdentity = opts.mtlsIdentity;
|
|
209
238
|
this.history = opts.history;
|
|
239
|
+
this.stats = opts.stats;
|
|
210
240
|
this.logger = opts.logger ?? NoopLoggerInstance;
|
|
211
241
|
this.pinnedTraceId = opts.traceId;
|
|
212
242
|
// Bind the connection id once so every record the actor emits carries
|
|
@@ -337,12 +367,16 @@ export class ConnectionActor {
|
|
|
337
367
|
return this.reducers.motd(this.state, msg, ctx).effects;
|
|
338
368
|
case 'OPER':
|
|
339
369
|
return this.reducers.oper(this.state, msg, ctx).effects;
|
|
370
|
+
case 'REHASH':
|
|
371
|
+
return this.routeRehash(msg, ctx);
|
|
340
372
|
case 'CAP':
|
|
341
373
|
return this.reducers.cap(this.state, msg, ctx).effects;
|
|
342
374
|
case 'AUTHENTICATE':
|
|
343
375
|
return this.reducers.authenticate(this.state, msg, ctx).effects;
|
|
344
376
|
case 'AWAY':
|
|
345
377
|
return this.reducers.away(this.state, msg, ctx).effects;
|
|
378
|
+
case 'SETNAME':
|
|
379
|
+
return this.reducers.setname(this.state, msg, ctx).effects;
|
|
346
380
|
|
|
347
381
|
// -------- Server-info query commands --------
|
|
348
382
|
case 'VERSION':
|
|
@@ -382,6 +416,16 @@ export class ConnectionActor {
|
|
|
382
416
|
return this.routeWhois(msg, ctx);
|
|
383
417
|
case 'WHOWAS':
|
|
384
418
|
return this.routeWhowas(msg, ctx);
|
|
419
|
+
case 'KILL':
|
|
420
|
+
return this.routeKill(msg, ctx);
|
|
421
|
+
case 'TRACE':
|
|
422
|
+
return this.routeTrace(msg, ctx);
|
|
423
|
+
case 'WALLOPS':
|
|
424
|
+
return this.routeWallops(msg, ctx);
|
|
425
|
+
case 'LUSERS':
|
|
426
|
+
return this.routeLusers(msg, ctx);
|
|
427
|
+
case 'STATS':
|
|
428
|
+
return this.routeStats(msg, ctx);
|
|
385
429
|
case 'CHATHISTORY':
|
|
386
430
|
return this.routeChathistory(msg, ctx);
|
|
387
431
|
|
|
@@ -418,14 +462,17 @@ export class ConnectionActor {
|
|
|
418
462
|
default:
|
|
419
463
|
// Remaining standard IRC verbs not yet implemented. They fall through
|
|
420
464
|
// to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
|
|
421
|
-
// - Deferred (serverless-incompatible /
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
// -
|
|
428
|
-
// USERS.
|
|
465
|
+
// - Deferred (serverless-incompatible / needs new infra):
|
|
466
|
+
// (none — LUSERS/STATS are now implemented).
|
|
467
|
+
// - Deliberately dropped (S2S — PLAN non-goal):
|
|
468
|
+
// CONNECT, SQUIT, LINKS. There is no server mesh to connect,
|
|
469
|
+
// split, or list; these stay 421. Their reserved numerics
|
|
470
|
+
// (RPL_LINKS/RPL_ENDOFLINKS/ERR_CANTKILLSERVER) were removed.
|
|
471
|
+
// - Deliberately dropped (RFC 2812 obsolete, never widely
|
|
472
|
+
// implemented): SERVICE, SUMMON, USERS. These
|
|
473
|
+
// stay 421; their reserved numerics
|
|
474
|
+
// (RPL_YOURESERVICE/ERR_NOSUCHSERVICE/ERR_SUMMONDISABLED/
|
|
475
|
+
// ERR_USERSDISABLED) were removed.
|
|
429
476
|
return [this.unknownCommandEffect(msg.command)];
|
|
430
477
|
}
|
|
431
478
|
}
|
|
@@ -438,6 +485,40 @@ export class ConnectionActor {
|
|
|
438
485
|
return listReducer(snapshots, msg, ctx).effects;
|
|
439
486
|
}
|
|
440
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Routes `REHASH` (oper-gated config reload). The pure reducer enforces the
|
|
490
|
+
* oper gate and emits `382 RPL_REHASHING`; when the gate passes the actor
|
|
491
|
+
* re-invokes the runtime's bound config loader and swaps its live
|
|
492
|
+
* `serverConfig` reference so subsequent commands observe the refreshed
|
|
493
|
+
* value (rotated oper creds, new MOTD source, etc.).
|
|
494
|
+
*
|
|
495
|
+
* On reload failure the previous config stays in effect and a graceful
|
|
496
|
+
* error-suffixed `382` follows — the oper is NOT disconnected (per the
|
|
497
|
+
* serverless reload contract: a bad store read must never sever a live
|
|
498
|
+
* connection).
|
|
499
|
+
*/
|
|
500
|
+
private async routeRehash(
|
|
501
|
+
msg: IrcMessage,
|
|
502
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
503
|
+
): Promise<EffectType[]> {
|
|
504
|
+
const result = rehashReducer(this.state, msg, ctx, this.configSource);
|
|
505
|
+
if (!result.shouldReload) {
|
|
506
|
+
return result.effects;
|
|
507
|
+
}
|
|
508
|
+
const effects = [...result.effects];
|
|
509
|
+
try {
|
|
510
|
+
this.serverConfig = await this.runtime.reloadConfig();
|
|
511
|
+
} catch (err) {
|
|
512
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
513
|
+
this.logger.warn('rehash.reload-failed', { reason });
|
|
514
|
+
const nick = this.state.nick ?? '*';
|
|
515
|
+
effects.push(
|
|
516
|
+
Effect.send(this.state.id, [rehashLine(ctx.serverName, nick, this.configSource, reason)]),
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
return effects;
|
|
520
|
+
}
|
|
521
|
+
|
|
441
522
|
private async routeWho(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): Promise<EffectType[]> {
|
|
442
523
|
const target = msg.params[0] ?? '';
|
|
443
524
|
if (target.length === 0) {
|
|
@@ -474,6 +555,78 @@ export class ConnectionActor {
|
|
|
474
555
|
return whoisReducer(target, channels, msg, ctx).effects;
|
|
475
556
|
}
|
|
476
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Routes `KILL <nick> <comment>` to the kill reducer. The actor resolves
|
|
560
|
+
* the target via the nick registry + a live connection fetch (mirroring
|
|
561
|
+
* {@link routeWhois}); no channel is created as a side effect. The
|
|
562
|
+
* reducer performs the oper gate and, on success, emits a
|
|
563
|
+
* {@link DisconnectEffect} for the target — the runtime's `disconnect`
|
|
564
|
+
* closes the target transport and fans QUIT out to shared-channel peers
|
|
565
|
+
* (peer fanout is the runtime's responsibility, not the reducer's).
|
|
566
|
+
*/
|
|
567
|
+
private async routeKill(
|
|
568
|
+
msg: IrcMessage,
|
|
569
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
570
|
+
): Promise<EffectType[]> {
|
|
571
|
+
const targetNick = msg.params[0] ?? '';
|
|
572
|
+
let target: ConnectionState | undefined;
|
|
573
|
+
if (targetNick.length > 0) {
|
|
574
|
+
const connId = await this.runtime.lookupNick(targetNick);
|
|
575
|
+
if (connId !== null) {
|
|
576
|
+
target = (await this.runtime.getConnection(connId)) ?? undefined;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return killReducer(target, msg, ctx).effects;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Routes `TRACE [<target>]` to the trace reducer. Single-server
|
|
584
|
+
* deployment: when `<target>` is absent or equals the local server name
|
|
585
|
+
* the reducer emits only the `262 RPL_ENDOFTRACE` terminator; when
|
|
586
|
+
* `<target>` resolves to a nick the reducer emits `205`/`204` per-conn
|
|
587
|
+
* detail (oper requester only) plus `262`; any other `<target>` is
|
|
588
|
+
* treated as a non-local server and yields `402 ERR_NOSUCHSERVER`.
|
|
589
|
+
*
|
|
590
|
+
* The actor resolves the target via `lookupNick` + `getConnection`
|
|
591
|
+
* (mirroring {@link routeWhois}). The local-server check is
|
|
592
|
+
* case-insensitive against `serverConfig.serverName`. Multi-hop S2S
|
|
593
|
+
* tracing is a PLAN non-goal.
|
|
594
|
+
*/
|
|
595
|
+
private async routeTrace(
|
|
596
|
+
msg: IrcMessage,
|
|
597
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
598
|
+
): Promise<EffectType[]> {
|
|
599
|
+
const rawTarget = msg.params[0] ?? '';
|
|
600
|
+
if (rawTarget.length === 0) {
|
|
601
|
+
return traceReducer(undefined, msg, ctx).effects;
|
|
602
|
+
}
|
|
603
|
+
// Local server name (case-insensitive) → collapse to the no-target path.
|
|
604
|
+
if (rawTarget.toLowerCase() === ctx.serverName.toLowerCase()) {
|
|
605
|
+
return traceReducer(undefined, msg, ctx).effects;
|
|
606
|
+
}
|
|
607
|
+
// Try to resolve as a nick.
|
|
608
|
+
const connId = await this.runtime.lookupNick(rawTarget);
|
|
609
|
+
if (connId !== null) {
|
|
610
|
+
const target = (await this.runtime.getConnection(connId)) ?? undefined;
|
|
611
|
+
return traceReducer(target, msg, ctx).effects;
|
|
612
|
+
}
|
|
613
|
+
// Unresolved: treat as a remote server → 402 ERR_NOSUCHSERVER.
|
|
614
|
+
return traceReducer(undefined, msg, ctx, rawTarget).effects;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Routes `WALLOPS :<message>` to the wallops reducer. The pure reducer
|
|
619
|
+
* enforces the oper gate (`481`) and the non-empty-message check
|
|
620
|
+
* (`412`); on success it emits a single {@link BroadcastWallopsEffect}
|
|
621
|
+
* that the dispatch layer interprets via the runtime's
|
|
622
|
+
* `broadcastWallops` method — a global fan-out to every `+w`
|
|
623
|
+
* connection (not channel-scoped). The originator is always excluded
|
|
624
|
+
* (skip-self, matching NOTICE/PRIVMSG broadcast semantics).
|
|
625
|
+
*/
|
|
626
|
+
private routeWallops(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): EffectType[] {
|
|
627
|
+
return wallopsReducer(this.state, msg, ctx).effects;
|
|
628
|
+
}
|
|
629
|
+
|
|
477
630
|
/**
|
|
478
631
|
* Routes `WHOWAS <nick>{,<nick>} [<count>]` to the whowas reducer. The
|
|
479
632
|
* command is query-only: it reads the bound nick-history store
|
|
@@ -488,6 +641,70 @@ export class ConnectionActor {
|
|
|
488
641
|
return whowasReducer(this.history, msg, ctx).effects;
|
|
489
642
|
}
|
|
490
643
|
|
|
644
|
+
/**
|
|
645
|
+
* Routes `LUSERS [<mask> [<server>]]` to the lusers reducer. Awaits the
|
|
646
|
+
* bound {@link ServerStats} backend (or falls back to a zero-count
|
|
647
|
+
* snapshot when no backend is wired) and hands the result to the pure
|
|
648
|
+
* reducer, which emits the `251`–`255`/`265`/`266` numerics.
|
|
649
|
+
*
|
|
650
|
+
* Single-server deployment: a `<server>` argument that does not match
|
|
651
|
+
* the local server name (case-insensitive) is treated as a remote
|
|
652
|
+
* server and yields `402 ERR_NOSUCHSERVER`. S2S aggregation is a PLAN
|
|
653
|
+
* non-goal.
|
|
654
|
+
*/
|
|
655
|
+
private async routeLusers(
|
|
656
|
+
msg: IrcMessage,
|
|
657
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
658
|
+
): Promise<EffectType[]> {
|
|
659
|
+
const serverTarget = serverTargetOf(msg.params[1] ?? '', ctx.serverName);
|
|
660
|
+
const snapshot = await this.fetchStats();
|
|
661
|
+
return lusersReducer(snapshot, msg, ctx, serverTarget).effects;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Routes `STATS <query> [<server>]` to the stats reducer. Awaits the
|
|
666
|
+
* bound {@link ServerStats} backend (or zero-count fallback) and hands
|
|
667
|
+
* the result to the pure reducer. The reducer handles the implemented
|
|
668
|
+
* query letters (`u`, `l`), the unknown-letter charybdis-style empty
|
|
669
|
+
* body, and the `219` terminator; non-local `<server>` arguments
|
|
670
|
+
* produce `402 ERR_NOSUCHSERVER`.
|
|
671
|
+
*/
|
|
672
|
+
private async routeStats(
|
|
673
|
+
msg: IrcMessage,
|
|
674
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
675
|
+
): Promise<EffectType[]> {
|
|
676
|
+
const serverTarget = serverTargetOf(msg.params[1] ?? '', ctx.serverName);
|
|
677
|
+
const snapshot = await this.fetchStats();
|
|
678
|
+
return statsReducer(snapshot, msg, ctx, serverTarget).effects;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Fetches the current {@link ServerStatsSnapshot} from the bound backend.
|
|
683
|
+
* When no backend is wired, returns a zero-count snapshot so LUSERS/STATS
|
|
684
|
+
* still emit a well-formed reply rather than a 421/empty stream — the
|
|
685
|
+
* uptime anchor defaults to the actor's wall clock so `STATS u` reports
|
|
686
|
+
* zero uptime instead of a negative duration.
|
|
687
|
+
*/
|
|
688
|
+
private async fetchStats(): Promise<import('@serverless-ircd/irc-core').ServerStatsSnapshot> {
|
|
689
|
+
if (this.stats !== undefined) {
|
|
690
|
+
return this.stats.getStats();
|
|
691
|
+
}
|
|
692
|
+
const now = this.clock.now();
|
|
693
|
+
return {
|
|
694
|
+
users: 0,
|
|
695
|
+
invisible: 0,
|
|
696
|
+
opers: 0,
|
|
697
|
+
unknownConnections: 0,
|
|
698
|
+
channels: 0,
|
|
699
|
+
servers: 1,
|
|
700
|
+
localConns: 0,
|
|
701
|
+
globalConns: 0,
|
|
702
|
+
maxLocalConns: 0,
|
|
703
|
+
maxGlobalConns: 0,
|
|
704
|
+
uptimeStartedAt: now,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
491
708
|
/**
|
|
492
709
|
* Routes `USERHOST <nick>{ <nick>}` to the userhost reducer. Resolves each
|
|
493
710
|
* requested nick via the nick registry + connection lookup, passing the
|
|
@@ -500,10 +717,8 @@ export class ConnectionActor {
|
|
|
500
717
|
ctx: ReturnType<typeof buildCtx>,
|
|
501
718
|
): Promise<EffectType[]> {
|
|
502
719
|
const cap = ctx.serverConfig.maxTargetsPerCommand;
|
|
503
|
-
const nicks = msg.params.slice(0, cap);
|
|
504
720
|
const resolved = new Map<string, ConnSnapshot | null>();
|
|
505
|
-
for (const nick of
|
|
506
|
-
if (nick.length === 0) continue;
|
|
721
|
+
for (const nick of msg.params.slice(0, cap)) {
|
|
507
722
|
const connId = await this.runtime.lookupNick(nick);
|
|
508
723
|
if (connId === null) {
|
|
509
724
|
resolved.set(nick, null);
|
|
@@ -526,13 +741,14 @@ export class ConnectionActor {
|
|
|
526
741
|
ctx: ReturnType<typeof buildCtx>,
|
|
527
742
|
): Promise<EffectType[]> {
|
|
528
743
|
const cap = ctx.serverConfig.maxTargetsPerCommand;
|
|
529
|
-
const nicks = msg.params.slice(0, cap);
|
|
530
744
|
const online = new Map<string, string>();
|
|
531
|
-
for (const nick of
|
|
532
|
-
if (nick.length === 0) continue;
|
|
745
|
+
for (const nick of msg.params.slice(0, cap)) {
|
|
533
746
|
const connId = await this.runtime.lookupNick(nick);
|
|
534
747
|
if (connId === null) continue;
|
|
535
748
|
const conn = await this.runtime.getConnection(connId);
|
|
749
|
+
// `conn` can be null in the race where lookupNick resolved but the
|
|
750
|
+
// connection has since torn down (the nick map lags the connection
|
|
751
|
+
// map). Skip those — they are no longer online.
|
|
536
752
|
if (conn?.nick !== undefined) {
|
|
537
753
|
online.set(conn.nick.toLowerCase(), conn.nick);
|
|
538
754
|
}
|
|
@@ -699,6 +915,20 @@ function splitCsv(param: string): string[] {
|
|
|
699
915
|
return param.split(',').filter((p) => p.length > 0);
|
|
700
916
|
}
|
|
701
917
|
|
|
918
|
+
/**
|
|
919
|
+
* Resolves the `<server>` parameter shared by `LUSERS` / `STATS` (and any
|
|
920
|
+
* future single-server-aware query verb). Returns `undefined` when the
|
|
921
|
+
* argument is absent OR names the local server (case-insensitive) — the
|
|
922
|
+
* caller then proceeds with the local-server code path. Returns the raw
|
|
923
|
+
* argument verbatim when it names a non-local server, which the reducer
|
|
924
|
+
* surfaces as `402 ERR_NOSUCHSERVER`.
|
|
925
|
+
*/
|
|
926
|
+
function serverTargetOf(rawServer: string, localServerName: string): string | undefined {
|
|
927
|
+
if (rawServer.length === 0) return undefined;
|
|
928
|
+
if (rawServer.toLowerCase() === localServerName.toLowerCase()) return undefined;
|
|
929
|
+
return rawServer;
|
|
930
|
+
}
|
|
931
|
+
|
|
702
932
|
/**
|
|
703
933
|
* Extracts the channel names a reducer will read for the supplied command.
|
|
704
934
|
* Used by `prefetchChannelState` to refresh only the channels the upcoming
|
|
@@ -708,8 +938,11 @@ function splitCsv(param: string): string[] {
|
|
|
708
938
|
* Commands not listed here either have no channel target (PING, NICK, …)
|
|
709
939
|
* or read channel membership from `ctx.connection.joinedChannels` directly
|
|
710
940
|
* (QUIT), so they don't need a prefetch.
|
|
941
|
+
*
|
|
942
|
+
* Exported so the per-command dispatch table can be unit-tested without
|
|
943
|
+
* standing up a full {@link ConnectionActor} + runtime.
|
|
711
944
|
*/
|
|
712
|
-
function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
|
|
945
|
+
export function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
|
|
713
946
|
const cmd = msg.command;
|
|
714
947
|
switch (cmd) {
|
|
715
948
|
case 'JOIN':
|
|
@@ -33,6 +33,7 @@ const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } =
|
|
|
33
33
|
ChangeNick: (e, r) => r.changeNick(e.conn, e.oldNick, e.newNick).then(noop),
|
|
34
34
|
ReleaseNick: (e, r) => r.releaseNick(e.nick),
|
|
35
35
|
ApplyChannelDelta: (e, r) => r.applyChannelDelta(e.chan, e.delta),
|
|
36
|
+
BroadcastWallops: (e, r) => r.broadcastWallops(e.lines, e.except),
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
function noop(): void {}
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
privmsgChannelReducer,
|
|
53
53
|
privmsgUserReducer,
|
|
54
54
|
quitReducer,
|
|
55
|
+
setnameReducer,
|
|
55
56
|
tagmsgChannelReducer,
|
|
56
57
|
tagmsgUserReducer,
|
|
57
58
|
timeReducer,
|
|
@@ -80,6 +81,7 @@ export interface RoutedReducers {
|
|
|
80
81
|
readonly cap: Reducer<ConnectionState>;
|
|
81
82
|
readonly authenticate: Reducer<ConnectionState>;
|
|
82
83
|
readonly away: Reducer<ConnectionState>;
|
|
84
|
+
readonly setname: Reducer<ConnectionState>;
|
|
83
85
|
readonly version: Reducer<ConnectionState>;
|
|
84
86
|
readonly time: Reducer<ConnectionState>;
|
|
85
87
|
readonly admin: Reducer<ConnectionState>;
|
|
@@ -150,6 +152,7 @@ export function buildRoutedReducers(serverConfig: ServerConfig): RoutedReducers
|
|
|
150
152
|
cap: wrap(capReducer),
|
|
151
153
|
authenticate: wrap(authenticateReducer),
|
|
152
154
|
away: wrap(awayReducer),
|
|
155
|
+
setname: wrap(setnameReducer),
|
|
153
156
|
version: wrap(versionReducer),
|
|
154
157
|
time: wrap(timeReducer),
|
|
155
158
|
admin: wrap(adminReducer),
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
ConnectionState,
|
|
18
18
|
Nick,
|
|
19
19
|
RawLine,
|
|
20
|
+
ServerConfig,
|
|
20
21
|
} from '@serverless-ircd/irc-core';
|
|
21
22
|
|
|
22
23
|
export interface IrcRuntime {
|
|
@@ -35,6 +36,24 @@ export interface IrcRuntime {
|
|
|
35
36
|
lines: RawLine[],
|
|
36
37
|
notFoundLines?: RawLine[],
|
|
37
38
|
): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Global cross-connection fanout for `WALLOPS`. Delivers `lines` to every
|
|
41
|
+
* live connection whose `+w` user mode is set, skipping `except` (the
|
|
42
|
+
* originator, so an oper never receives their own wallops). Unlike
|
|
43
|
+
* {@link broadcast} this is NOT channel-scoped — it reaches connections
|
|
44
|
+
* with no shared channel membership.
|
|
45
|
+
*
|
|
46
|
+
* Each adapter applies its own cost cap and enumeration strategy:
|
|
47
|
+
* - in-memory: scans its connection map, filters `+w`.
|
|
48
|
+
* - Cloudflare: walks the sharded registry to enumerate connIds, then
|
|
49
|
+
* fetches each state to check `+w`. Capped to bound the fan-out RPC
|
|
50
|
+
* count; document a ceiling for very large fleets.
|
|
51
|
+
* - AWS: `Scan`s the `Connections` table and `PostToConnection`s each
|
|
52
|
+
* `+w` row. DynamoDB `Scan` reads scale with table size, so this is
|
|
53
|
+
* expensive at high connection counts — callers should bound the
|
|
54
|
+
* oper's WALLOPS rate.
|
|
55
|
+
*/
|
|
56
|
+
broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void>;
|
|
38
57
|
|
|
39
58
|
// nickname registry
|
|
40
59
|
reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }>;
|
|
@@ -58,4 +77,16 @@ export interface IrcRuntime {
|
|
|
58
77
|
getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null>;
|
|
59
78
|
getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>>;
|
|
60
79
|
listChannels(): Promise<ChanSnapshot[]>;
|
|
80
|
+
/**
|
|
81
|
+
* Re-fetches the deployment's {@link ServerConfig} from the adapter's bound
|
|
82
|
+
* source (CF: KV/secret env; AWS: Secrets Manager/SSM env; local-cli: the
|
|
83
|
+
* config file/options) and returns the fresh value. The caller (the
|
|
84
|
+
* ConnectionActor's `REHASH` route) swaps its live config reference with
|
|
85
|
+
* the result; on rejection the previous config stays in effect.
|
|
86
|
+
*
|
|
87
|
+
* Modelled as a request/response runtime method — like `lookupNick` /
|
|
88
|
+
* `getConnection` — rather than a fire-and-forget `Effect`, because the
|
|
89
|
+
* refreshed value must come back to the actor.
|
|
90
|
+
*/
|
|
91
|
+
reloadConfig(): Promise<ServerConfig>;
|
|
61
92
|
}
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* parser/reducer/dispatch pipeline.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { splitFrameLines } from '@serverless-ircd/irc-core';
|
|
19
|
+
|
|
20
|
+
export { splitFrameLines };
|
|
21
|
+
|
|
18
22
|
/**
|
|
19
23
|
* The line-framing contract a {@link ConnectionActor} consumes. `feed`
|
|
20
24
|
* returns the complete IRC lines terminated within `chunk`; a stateful
|
|
@@ -25,14 +29,13 @@ export interface Transport {
|
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
|
-
* Splits a WebSocket text frame into IRC lines.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
32
|
+
* Splits a WebSocket text frame into IRC lines. Re-exported from
|
|
33
|
+
* `@serverless-ircd/irc-core` (`ws-framing.ts`), which owns the canonical
|
|
34
|
+
* legacy-tolerant framing so the IRCv3 spec-mode framing and this legacy
|
|
35
|
+
* behaviour never diverge. Tolerates `\r\n`, bare `\n`, and a missing
|
|
36
|
+
* trailing terminator (a WS frame is a complete unit). Returns each line
|
|
37
|
+
* bare (no CRLF). This is the legacy-tolerant WS contract.
|
|
31
38
|
*/
|
|
32
|
-
export function splitFrameLines(text: string): string[] {
|
|
33
|
-
if (text.length === 0) return [];
|
|
34
|
-
return text.split(/\r?\n/u);
|
|
35
|
-
}
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
41
|
* Stateless WS-frame transport: one {@link feed} call per inbound text frame.
|