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,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `SETNAME` command (RFC 2812 §4.1.7).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: SETNAME runs in the Connection entity.
|
|
5
|
+
* The reducer mutates `state.realname` (the GECOS field originally set by
|
|
6
|
+
* the `USER` reducer) and performs no IO.
|
|
7
|
+
*
|
|
8
|
+
* Wire format:
|
|
9
|
+
* - `SETNAME :<realname>` — replaces `state.realname`.
|
|
10
|
+
* - `461 ERR_NEEDMOREPARAMS` when the realname is missing/empty.
|
|
11
|
+
* - `451 ERR_NOTREGISTERED` when issued before registration completes.
|
|
12
|
+
*
|
|
13
|
+
* Broadcast decision: there is no standard IRCv3 cap for relaying a
|
|
14
|
+
* realname change (the `chghost` cap covers user/host, not realname), so
|
|
15
|
+
* v1 updates `state.realname` silently. A subsequent `WHOIS` reflects the
|
|
16
|
+
* new value in `311 RPL_WHOISUSER`. A `draft/setname` relay cap is a
|
|
17
|
+
* documented follow-up.
|
|
18
|
+
*
|
|
19
|
+
* Length cap: registration-time `USER` does not enforce a realname length
|
|
20
|
+
* cap, so this command applies its own via `serverConfig.realnameLen`
|
|
21
|
+
* (falling back to {@link DEFAULT_REALNAME_LEN}). An over-cap value is
|
|
22
|
+
* rejected with `432 ERR_ERRONEUSNICKNAME` — the same numeric registration
|
|
23
|
+
* uses for an over-length `NICK` (the codebase's only registration-time
|
|
24
|
+
* "value rejected for length" precedent). The offending value is NOT
|
|
25
|
+
* echoed back so a client cannot amplify a multi-kilobyte payload via the
|
|
26
|
+
* error reply. Truncation (as `TOPIC` does) is deliberately not used: the
|
|
27
|
+
* spec frames an over-long realname as a rejection, not a silent trim.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Effect } from '../effects.js';
|
|
31
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
32
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
33
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
34
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Built-in realname length cap when `serverConfig.realnameLen` is unset.
|
|
38
|
+
* Matches the schema default and the common ircd-seven GECOS ceiling.
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_REALNAME_LEN = 50;
|
|
41
|
+
|
|
42
|
+
/** Fixed trailing text for `461 ERR_NEEDMOREPARAMS` (matches `USER`/`PASS`). */
|
|
43
|
+
const NEEDMOREPARAMS_TRAILING = 'Not enough parameters';
|
|
44
|
+
|
|
45
|
+
/** Fixed trailing text for `451 ERR_NOTREGISTERED`. */
|
|
46
|
+
const NOTREGISTERED_TRAILING = 'You have not registered';
|
|
47
|
+
|
|
48
|
+
/** Fixed trailing text for the over-cap `432` rejection. */
|
|
49
|
+
const ERRONEOUS_REALNAME_TRAILING = 'Erroneous realname';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Handles `SETNAME :<realname>`.
|
|
53
|
+
*
|
|
54
|
+
* Stamps `lastSeen`, enforces the registration + length gates, and on
|
|
55
|
+
* success replaces `state.realname` with no broadcast. The reducer never
|
|
56
|
+
* performs IO.
|
|
57
|
+
*/
|
|
58
|
+
export const setnameReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
59
|
+
state.lastSeen = ctx.clock.now();
|
|
60
|
+
|
|
61
|
+
const effects: EffectType[] = [];
|
|
62
|
+
|
|
63
|
+
if (state.registration !== 'registered') {
|
|
64
|
+
effects.push(
|
|
65
|
+
Effect.send(ctx.connId, [
|
|
66
|
+
numericLine(ctx, Numerics.ERR_NOTREGISTERED, NOTREGISTERED_TRAILING),
|
|
67
|
+
]),
|
|
68
|
+
);
|
|
69
|
+
return { state, effects };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const realname = msg.params[0];
|
|
73
|
+
if (realname === undefined || realname.length === 0) {
|
|
74
|
+
effects.push(
|
|
75
|
+
Effect.send(ctx.connId, [
|
|
76
|
+
numericLine(ctx, Numerics.ERR_NEEDMOREPARAMS, NEEDMOREPARAMS_TRAILING, 'SETNAME'),
|
|
77
|
+
]),
|
|
78
|
+
);
|
|
79
|
+
return { state, effects };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const cap = ctx.serverConfig.realnameLen ?? DEFAULT_REALNAME_LEN;
|
|
83
|
+
if (realname.length > cap) {
|
|
84
|
+
effects.push(
|
|
85
|
+
Effect.send(ctx.connId, [
|
|
86
|
+
numericLine(ctx, Numerics.ERR_ERRONEUSNICKNAME, ERRONEOUS_REALNAME_TRAILING),
|
|
87
|
+
]),
|
|
88
|
+
);
|
|
89
|
+
return { state, effects };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
state.realname = realname;
|
|
93
|
+
return { state, effects };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Builds a `:<server> <code> <nick> [:<middle>] :<trailing>` line. The nick
|
|
98
|
+
* falls back to `*` when the connection has not yet registered one, and the
|
|
99
|
+
* optional `middle` is omitted when not supplied — matching the numeric
|
|
100
|
+
* formatting used by the registration reducers.
|
|
101
|
+
*/
|
|
102
|
+
function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
|
|
103
|
+
const nick = ctx.connection.nick ?? '*';
|
|
104
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
105
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
106
|
+
if (middle !== undefined) parts.push(middle);
|
|
107
|
+
parts.push(`:${trailing}`);
|
|
108
|
+
return { text: parts.join(' ') };
|
|
109
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `STATS` command (RFC 2812 §4.6.3).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: STATS is a read-only diagnostic that
|
|
5
|
+
* reports server statistics keyed by a single-letter query. The reducer
|
|
6
|
+
* takes an already-aggregated {@link ServerStatsSnapshot} (the actor
|
|
7
|
+
* awaits a {@link ServerStats} backend before calling here). The reducer
|
|
8
|
+
* never mutates anything but the requester's `lastSeen`.
|
|
9
|
+
*
|
|
10
|
+
* Wire format (RFC 2812 §5.1 + modern practice):
|
|
11
|
+
* - `STATS <query> [<server>]`
|
|
12
|
+
* - `211 RPL_STATSLINKINFO` (the `l` query): per-link sendq/byte/msg
|
|
13
|
+
* counters. Single-server deployment collapses this to one line for
|
|
14
|
+
* the bound server.
|
|
15
|
+
* - `242 RPL_STATSUPTIME` (the `u` query):
|
|
16
|
+
* `:<server> 242 <nick> :Server Up <d> days, <h>:<mm>:<ss>`
|
|
17
|
+
* - `219 RPL_ENDOFSTATS`:
|
|
18
|
+
* `:<server> 219 <nick> <query> :End of /STATS report`
|
|
19
|
+
* - `402 ERR_NOSUCHSERVER`:
|
|
20
|
+
* `:<server> 402 <nick> <server> :No such server`
|
|
21
|
+
*
|
|
22
|
+
* Implemented query letters: `u` (uptime), `l` (link info). Unknown letters
|
|
23
|
+
* follow charybdis semantics: the reducer emits ONLY the `219` terminator
|
|
24
|
+
* with an empty body (no 461/401/etc). The remaining RFC letters
|
|
25
|
+
* (`k`/`K`/`q`/`g`/`x`/`z`/…) are deferred behind per-letter tests; the
|
|
26
|
+
* actor routes them here regardless so a future implementation is just
|
|
27
|
+
* another case branch.
|
|
28
|
+
*
|
|
29
|
+
* `<server>` resolves at the actor layer: a non-local name yields
|
|
30
|
+
* `402 ERR_NOSUCHSERVER` (single-server network — S2S routing is a PLAN
|
|
31
|
+
* non-goal). When the actor passes `serverTarget`, the reducer emits ONLY
|
|
32
|
+
* the 402 and nothing else.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { Effect } from '../effects.js';
|
|
36
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
37
|
+
import type { ServerStatsSnapshot } from '../ports.js';
|
|
38
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
39
|
+
import type { Ctx } from '../types.js';
|
|
40
|
+
|
|
41
|
+
/** Fixed text emitted for `402 ERR_NOSUCHSERVER`. */
|
|
42
|
+
const NOSUCHSERVER_TRAILING_TEXT = 'No such server';
|
|
43
|
+
|
|
44
|
+
/** Fixed text emitted for `219 RPL_ENDOFSTATS`. */
|
|
45
|
+
const ENDOFSTATS_TRAILING_TEXT = 'End of /STATS report';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Builds a `:<server> <code> <nick> [<middle>] :<trailing>` numeric line
|
|
49
|
+
* addressed to the requester. When `middle` is empty the middle field is
|
|
50
|
+
* still emitted as a bare space-prefixed token (matches charybdis output
|
|
51
|
+
* for STATS with no query letter).
|
|
52
|
+
*/
|
|
53
|
+
function numericLine(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
54
|
+
const nick = ctx.connection.nick ?? '*';
|
|
55
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
56
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
57
|
+
parts.push(middle);
|
|
58
|
+
parts.push(`:${trailing}`);
|
|
59
|
+
return { text: parts.join(' ') };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Formats an uptime delta (ms) as `Server Up <d> days, <h>:<mm>:<ss>`,
|
|
64
|
+
* matching charybdis's `RPL_STATSUPTIME` text. Days and hours are not
|
|
65
|
+
* zero-padded; minutes and seconds are.
|
|
66
|
+
*/
|
|
67
|
+
function formatUptime(deltaMs: number): string {
|
|
68
|
+
let totalSec = Math.floor(deltaMs / 1000);
|
|
69
|
+
const days = Math.floor(totalSec / 86_400);
|
|
70
|
+
totalSec -= days * 86_400;
|
|
71
|
+
const hours = Math.floor(totalSec / 3_600);
|
|
72
|
+
totalSec -= hours * 3_600;
|
|
73
|
+
const minutes = Math.floor(totalSec / 60);
|
|
74
|
+
totalSec -= minutes * 60;
|
|
75
|
+
const seconds = totalSec;
|
|
76
|
+
const mm = minutes.toString().padStart(2, '0');
|
|
77
|
+
const ss = seconds.toString().padStart(2, '0');
|
|
78
|
+
return `Server Up ${days} days, ${hours}:${mm}:${ss}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handles `STATS <query> [<server>]`.
|
|
83
|
+
*
|
|
84
|
+
* @param snapshot The aggregated network statistics (only `uptimeStartedAt`
|
|
85
|
+
* is consulted here; full counts are surfaced by LUSERS).
|
|
86
|
+
* @param msg The parsed `STATS` message.
|
|
87
|
+
* @param ctx The per-invocation context.
|
|
88
|
+
* @param serverTarget When set, the actor determined `<server>` names a
|
|
89
|
+
* non-local server; the reducer emits `402 ERR_NOSUCHSERVER` against this
|
|
90
|
+
* name.
|
|
91
|
+
*/
|
|
92
|
+
export function statsReducer(
|
|
93
|
+
snapshot: ServerStatsSnapshot,
|
|
94
|
+
msg: { command: string; params: readonly string[] },
|
|
95
|
+
ctx: Ctx,
|
|
96
|
+
serverTarget?: string,
|
|
97
|
+
): { effects: EffectType[] } {
|
|
98
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
99
|
+
|
|
100
|
+
const effects: EffectType[] = [];
|
|
101
|
+
|
|
102
|
+
if (serverTarget !== undefined) {
|
|
103
|
+
effects.push(
|
|
104
|
+
Effect.send(ctx.connId, [
|
|
105
|
+
numericLine(ctx, Numerics.ERR_NOSUCHSERVER, NOSUCHSERVER_TRAILING_TEXT, serverTarget),
|
|
106
|
+
]),
|
|
107
|
+
);
|
|
108
|
+
return { effects };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Normalise: STATS with no query letter is treated as the empty string;
|
|
112
|
+
// case is folded to lower so the terminator matches the canonical charybdis
|
|
113
|
+
// output (`STATS U` and `STATS u` both terminate with `219 <nick> u`).
|
|
114
|
+
const rawQuery = msg.params[0] ?? '';
|
|
115
|
+
const query = rawQuery.toLowerCase();
|
|
116
|
+
|
|
117
|
+
if (query === 'u') {
|
|
118
|
+
const uptimeMs = ctx.clock.now() - snapshot.uptimeStartedAt;
|
|
119
|
+
const code = Numerics.RPL_STATSUPTIME.toString().padStart(3, '0');
|
|
120
|
+
effects.push(
|
|
121
|
+
Effect.send(ctx.connId, [
|
|
122
|
+
{
|
|
123
|
+
text: `:${ctx.serverName} ${code} ${ctx.connection.nick ?? '*'} :${formatUptime(uptimeMs)}`,
|
|
124
|
+
},
|
|
125
|
+
]),
|
|
126
|
+
);
|
|
127
|
+
} else if (query === 'l') {
|
|
128
|
+
// Single-server: one link-info line for the bound server. All sendq /
|
|
129
|
+
// byte / message counters are zero for a stateless serverless runtime
|
|
130
|
+
// (no persistent S2S link); the trailing text is the configured server
|
|
131
|
+
// version so the client sees a self-describing entry.
|
|
132
|
+
const code = Numerics.RPL_STATSLINKINFO.toString().padStart(3, '0');
|
|
133
|
+
const requester = ctx.connection.nick ?? '*';
|
|
134
|
+
const version = ctx.serverConfig.serverVersion ?? '';
|
|
135
|
+
effects.push(
|
|
136
|
+
Effect.send(ctx.connId, [
|
|
137
|
+
{
|
|
138
|
+
text: `:${ctx.serverName} ${code} ${requester} ${ctx.serverName} 0 0 0 0 0 0 :${version}`,
|
|
139
|
+
},
|
|
140
|
+
]),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
// Terminator always emitted, even for unknown letters (charybdis
|
|
144
|
+
// semantics: unknown STATS letters produce no body lines, just 219).
|
|
145
|
+
effects.push(
|
|
146
|
+
Effect.send(ctx.connId, [
|
|
147
|
+
numericLine(ctx, Numerics.RPL_ENDOFSTATS, ENDOFSTATS_TRAILING_TEXT, query),
|
|
148
|
+
]),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return { effects };
|
|
152
|
+
}
|
|
@@ -35,12 +35,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** Formats a numeric error/reply line addressed to the connection's nick. */
|
|
38
|
-
function numericErr(ctx: Ctx, code: number, trailing: string, middle
|
|
38
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
39
39
|
const nick = ctx.connection.nick ?? '*';
|
|
40
40
|
const codeStr = code.toString().padStart(3, '0');
|
|
41
|
-
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
42
|
-
if (middle !== undefined) parts.push(middle);
|
|
43
|
-
parts.push(`:${trailing}`);
|
|
41
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
|
|
44
42
|
return { text: parts.join(' ') };
|
|
45
43
|
}
|
|
46
44
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `TRACE` command (RFC 2812 §4.6.3).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: TRACE is a query-only diagnostic. The
|
|
5
|
+
* actor layer resolves the optional `<target>` and — when it is a nick —
|
|
6
|
+
* hands the resolved {@link ConnectionState} here. The reducer never
|
|
7
|
+
* mutates anything but the requester's `lastSeen`.
|
|
8
|
+
*
|
|
9
|
+
* In a single-server serverless IRCd, multi-hop routing collapses to the
|
|
10
|
+
* local server. The classic `200 RPL_TRACELINK` / `206 RPL_TRACESERVER`
|
|
11
|
+
* server-link dump is therefore trivial (one server, zero hops); modern
|
|
12
|
+
* daemons emit just the per-connection `205`/`204` lines and the
|
|
13
|
+
* terminating `262 RPL_ENDOFTRACE`. This reducer follows that convention.
|
|
14
|
+
*
|
|
15
|
+
* Wire format (RFC 2812 §4.6.3 + modern practice):
|
|
16
|
+
* - `TRACE [<target>]`
|
|
17
|
+
* - `205 RPL_TRACEUSER`:
|
|
18
|
+
* `:<server> 205 <nick> User <hops> <user> <host>` (per matched conn)
|
|
19
|
+
* - `204 RPL_TRACEOPERATOR`:
|
|
20
|
+
* `:<server> 204 <nick> Oper <hops> <user> <host>` (oper conns only)
|
|
21
|
+
* - `262 RPL_ENDOFTRACE`:
|
|
22
|
+
* `:<server> 262 <nick> <target> :End of TRACE`
|
|
23
|
+
* - `402 ERR_NOSUCHSERVER`:
|
|
24
|
+
* `:<server> 402 <nick> <server> :No such server`
|
|
25
|
+
*
|
|
26
|
+
* Detail visibility is oper-gated:
|
|
27
|
+
* - Non-opers see only the `262` terminator (no per-connection detail),
|
|
28
|
+
* so a non-oper cannot use `TRACE` to enumerate online users. This
|
|
29
|
+
* also implicitly enforces `+i` invisibility for non-opers.
|
|
30
|
+
* - Opers see one `205`/`204` line per matched connection and bypass
|
|
31
|
+
* the `+i` filter (RFC 2812 §4.6.3).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { Effect } from '../effects.js';
|
|
35
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
36
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
37
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
38
|
+
import type { Ctx } from '../types.js';
|
|
39
|
+
|
|
40
|
+
/** Fixed text emitted for `402 ERR_NOSUCHSERVER`. */
|
|
41
|
+
const NOSUCHSERVER_TRAILING_TEXT = 'No such server';
|
|
42
|
+
|
|
43
|
+
/** Fixed text emitted for `262 RPL_ENDOFTRACE`. */
|
|
44
|
+
const ENDOFTRACE_TRAILING_TEXT = 'End of TRACE';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Builds a `:<server> <code> <nick> <Class> <hops> <nick> <user> <host>`
|
|
48
|
+
* line for one matched connection. `<Class>` is `Oper` for an oper,
|
|
49
|
+
* otherwise `User` (matching RFC 2812 §4.6.3 + common client rendering).
|
|
50
|
+
* `<hops>` is always 0 in a single-server deployment.
|
|
51
|
+
*/
|
|
52
|
+
function traceDetailLine(ctx: Ctx, code: number, cls: string, target: ConnectionState): RawLine {
|
|
53
|
+
const requesterNick = ctx.connection.nick ?? '*';
|
|
54
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
55
|
+
const tNick = target.nick ?? '*';
|
|
56
|
+
const tUser = target.user ?? '?';
|
|
57
|
+
const tHost = target.host ?? '?';
|
|
58
|
+
return {
|
|
59
|
+
text: `:${ctx.serverName} ${codeStr} ${requesterNick} ${cls} 0 ${tNick} ${tUser} ${tHost}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds a `:<server> <code> <nick> [<middle>] :<trailing>` numeric line
|
|
65
|
+
* addressed to the requester. When `middle` is empty the middle field is
|
|
66
|
+
* omitted entirely.
|
|
67
|
+
*/
|
|
68
|
+
function numericLine(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
69
|
+
const nick = ctx.connection.nick ?? '*';
|
|
70
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
71
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
72
|
+
if (middle !== '') parts.push(middle);
|
|
73
|
+
parts.push(`:${trailing}`);
|
|
74
|
+
return { text: parts.join(' ') };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Handles `TRACE [<target>]`.
|
|
79
|
+
*
|
|
80
|
+
* The actor layer resolves `<target>`:
|
|
81
|
+
* - Empty / absent / equal to the local server name → `target` is
|
|
82
|
+
* `undefined` and `serverTarget` is unset; the reducer emits only the
|
|
83
|
+
* terminating `262`.
|
|
84
|
+
* - A nick that resolves → `target` is the live
|
|
85
|
+
* {@link ConnectionState}; the reducer emits `205`/`204` (oper
|
|
86
|
+
* requester only) then `262`.
|
|
87
|
+
* - A nick that does not resolve → `target` is `undefined`; the reducer
|
|
88
|
+
* emits only the `262` (carrying the requested nick).
|
|
89
|
+
* - A name that is not the local server (treated as a remote server) →
|
|
90
|
+
* the actor passes `serverTarget = '<name>'`; the reducer emits
|
|
91
|
+
* `402 ERR_NOSUCHSERVER`.
|
|
92
|
+
*
|
|
93
|
+
* @param target The resolved target connection, or `undefined`.
|
|
94
|
+
* @param msg The parsed `TRACE` message.
|
|
95
|
+
* @param ctx The per-invocation context.
|
|
96
|
+
* @param serverTarget When set, the actor determined `<target>` names a
|
|
97
|
+
* non-local server; the reducer emits `402` against this name.
|
|
98
|
+
*/
|
|
99
|
+
export function traceReducer(
|
|
100
|
+
target: ConnectionState | undefined,
|
|
101
|
+
msg: { command: string; params: readonly string[] },
|
|
102
|
+
ctx: Ctx,
|
|
103
|
+
serverTarget?: string,
|
|
104
|
+
): { effects: EffectType[] } {
|
|
105
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
106
|
+
|
|
107
|
+
const effects: EffectType[] = [];
|
|
108
|
+
const rawTarget = msg.params[0] ?? '';
|
|
109
|
+
|
|
110
|
+
if (serverTarget !== undefined) {
|
|
111
|
+
effects.push(
|
|
112
|
+
Effect.send(ctx.connId, [
|
|
113
|
+
numericLine(ctx, Numerics.ERR_NOSUCHSERVER, NOSUCHSERVER_TRAILING_TEXT, serverTarget),
|
|
114
|
+
]),
|
|
115
|
+
);
|
|
116
|
+
return { effects };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const isOper = ctx.connection.userModes.oper;
|
|
120
|
+
if (target !== undefined && isOper) {
|
|
121
|
+
// Per-connection detail is oper-gated, which also implicitly enforces
|
|
122
|
+
// `+i` invisibility for non-opers (they never receive detail). Opers
|
|
123
|
+
// bypass `+i` per RFC 2812 §4.6.3 and always see the matched conn.
|
|
124
|
+
const code = target.userModes.oper ? Numerics.RPL_TRACEOPERATOR : Numerics.RPL_TRACEUSER;
|
|
125
|
+
const cls = target.userModes.oper ? 'Oper' : 'User';
|
|
126
|
+
effects.push(Effect.send(ctx.connId, [traceDetailLine(ctx, code, cls, target)]));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const endMiddle = rawTarget.length > 0 ? rawTarget : '';
|
|
130
|
+
effects.push(
|
|
131
|
+
Effect.send(ctx.connId, [
|
|
132
|
+
numericLine(ctx, Numerics.RPL_ENDOFTRACE, ENDOFTRACE_TRAILING_TEXT, endMiddle),
|
|
133
|
+
]),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return { effects };
|
|
137
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `WALLOPS` command (oper-only global broadcast).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: WALLOPS runs in the Connection entity.
|
|
5
|
+
* The reducer is pure — it enforces the oper gate, validates the message,
|
|
6
|
+
* and emits a single {@link BroadcastWallopsEffect}. The bound runtime's
|
|
7
|
+
* `broadcastWallops` method owns the cross-connection enumeration that
|
|
8
|
+
* the gate cannot observe purely (it does not see which other
|
|
9
|
+
* connections carry the `+w` user mode).
|
|
10
|
+
*
|
|
11
|
+
* Wire format (RFC 2812 §4.7):
|
|
12
|
+
* - `WALLOPS :<message>`
|
|
13
|
+
* - `481 ERR_NOPRIVILEGES`:
|
|
14
|
+
* `:<server> 481 <nick> :Permission Denied - You're not an IRC operator`
|
|
15
|
+
* - `412 ERR_NOTEXTTOSEND`:
|
|
16
|
+
* `:<server> 412 <nick> :No text to send`
|
|
17
|
+
*
|
|
18
|
+
* Behaviour:
|
|
19
|
+
* - Non-oper sender → `481` (checked before the message is inspected, so
|
|
20
|
+
* a non-oper cannot probe the command shape).
|
|
21
|
+
* - Empty / missing message → `412 ERR_NOTEXTTOSEND`. We pick `412` over
|
|
22
|
+
* `461 ERR_NEEDMOREPARAMS` to match the message-bearing commands
|
|
23
|
+
* PRIVMSG/NOTICE, which use `412` for "no text to send". WALLOPS is a
|
|
24
|
+
* single-param message command, so the semantically closer numeric wins.
|
|
25
|
+
* - Otherwise emit `BroadcastWallops([:<oper> WALLOPS :<message>], sender)`
|
|
26
|
+
* so the runtime fans the line out to every `+w` connection.
|
|
27
|
+
*
|
|
28
|
+
* Delivery / skip-self: the originator is ALWAYS passed as `except`. This
|
|
29
|
+
* matches NOTICE/PRIVMSG channel-broadcast semantics (the sender never
|
|
30
|
+
* receives their own message back) and avoids a redundant echo to the
|
|
31
|
+
* oper who just typed it. The `+w` user mode is the *recipient* gate,
|
|
32
|
+
* owned by the runtime — the sender's own `+w` is therefore irrelevant
|
|
33
|
+
* under this default. An oper who wants a delivery receipt can query
|
|
34
|
+
* logs. (RFC leaves delivery to `+w` regardless of oper; we deliberately
|
|
35
|
+
* diverge to match the rest of the codebase's broadcast primitives.)
|
|
36
|
+
*
|
|
37
|
+
* The `+w` user mode is accepted by the MODE reducer (`w → 'wallops'`)
|
|
38
|
+
* and advertised in `004 RPL_MYINFO`, but no command consumed it before
|
|
39
|
+
* this one — setting `+w` used to be a silent no-op. This reducer lights
|
|
40
|
+
* up the consumer.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { Effect } from '../effects.js';
|
|
44
|
+
import type { RawLine } from '../effects.js';
|
|
45
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
46
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
47
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
48
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
49
|
+
|
|
50
|
+
/** Fixed text emitted for `481 ERR_NOPRIVILEGES` (matches `kill.ts` / `mode.ts`). */
|
|
51
|
+
const NOPRIVILEGES_TRAILING_TEXT = "Permission Denied - You're not an IRC operator";
|
|
52
|
+
|
|
53
|
+
/** Fixed text emitted for `412 ERR_NOTEXTTOSEND` (matches `privmsg.ts`). */
|
|
54
|
+
const NOTEXTTOSEND_TRAILING_TEXT = 'No text to send';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Renders the `:<source> WALLOPS :<message>` wire line that every `+w`
|
|
58
|
+
* connection receives. `<source>` is the oper's full hostmask
|
|
59
|
+
* (`nick!user@host`), consistent with how PRIVMSG/NOTICE/QUIT attribute
|
|
60
|
+
* user-originated messages. When the hostmask is incomplete (e.g. no
|
|
61
|
+
* `user`/`host` yet, which should not happen for a registered oper but is
|
|
62
|
+
* defended against) the bare nick is used, falling back to `*` only when
|
|
63
|
+
* no nick is set at all.
|
|
64
|
+
*/
|
|
65
|
+
export function wallopsLine(state: ConnectionState, message: string): RawLine {
|
|
66
|
+
const source = hostmaskOf(state) ?? state.nick ?? '*';
|
|
67
|
+
return { text: `:${source} WALLOPS :${message}` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Handles `WALLOPS :<message>`.
|
|
72
|
+
*
|
|
73
|
+
* Enforces the oper gate, stamps `lastSeen`, and on success emits a single
|
|
74
|
+
* {@link BroadcastWallopsEffect} addressed to every `+w` connection. The
|
|
75
|
+
* sender is always excluded (skip-self). The reducer never performs IO.
|
|
76
|
+
*/
|
|
77
|
+
export const wallopsReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
78
|
+
state.lastSeen = ctx.clock.now();
|
|
79
|
+
|
|
80
|
+
if (!state.userModes.oper) {
|
|
81
|
+
return {
|
|
82
|
+
state,
|
|
83
|
+
effects: [
|
|
84
|
+
Effect.send(ctx.connId, [
|
|
85
|
+
numericLine(ctx, Numerics.ERR_NOPRIVILEGES, NOPRIVILEGES_TRAILING_TEXT),
|
|
86
|
+
]),
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const message = msg.params[0];
|
|
92
|
+
if (message === undefined || message.length === 0) {
|
|
93
|
+
return {
|
|
94
|
+
state,
|
|
95
|
+
effects: [
|
|
96
|
+
Effect.send(ctx.connId, [
|
|
97
|
+
numericLine(ctx, Numerics.ERR_NOTEXTTOSEND, NOTEXTTOSEND_TRAILING_TEXT),
|
|
98
|
+
]),
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
state,
|
|
105
|
+
effects: [Effect.broadcastWallops([wallopsLine(state, message)], ctx.connId)],
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Builds a `:<server> <code> <nick> :<trailing>` line. The nick falls back
|
|
111
|
+
* to `*` when the connection has not yet registered one, matching the rest
|
|
112
|
+
* of the codebase's numeric formatting.
|
|
113
|
+
*/
|
|
114
|
+
function numericLine(ctx: Ctx, code: number, trailing: string): RawLine {
|
|
115
|
+
const nick = ctx.connection.nick ?? '*';
|
|
116
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
117
|
+
return { text: [`:${ctx.serverName}`, codeStr, nick, `:${trailing}`].join(' ') };
|
|
118
|
+
}
|
|
@@ -256,6 +256,13 @@ export const ServerConfigSchema = z.object({
|
|
|
256
256
|
channelLen: z.number().int().positive().default(50),
|
|
257
257
|
/** Maximum topic length (advertised as `TOPICLEN=`). */
|
|
258
258
|
topicLen: z.number().int().nonnegative().default(390),
|
|
259
|
+
/**
|
|
260
|
+
* Maximum realname (GECOS) length enforced by `SETNAME`. There is no
|
|
261
|
+
* standard ISUPPORT token for this; registration-time `USER` does not
|
|
262
|
+
* enforce a cap, so the knob is optional on {@link ServerConfig} and the
|
|
263
|
+
* reducer falls back to a built-in default when unset.
|
|
264
|
+
*/
|
|
265
|
+
realnameLen: z.number().int().nonnegative().default(50),
|
|
259
266
|
/** Default QUIT reason when the client disconnects without one. */
|
|
260
267
|
quitMessage: z.string().default('Client Quit'),
|
|
261
268
|
|
|
@@ -89,6 +89,26 @@ export interface SendToNickEffect {
|
|
|
89
89
|
notFoundLines?: RawLine[];
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Global cross-connection fanout for `WALLOPS`. Unlike
|
|
94
|
+
* {@link BroadcastEffect} (channel-scoped via the roster), this reaches
|
|
95
|
+
* every live connection whose `+w` user mode is set, regardless of shared
|
|
96
|
+
* channel membership. `except` skips a single connection — by convention
|
|
97
|
+
* the originator, so an oper never receives their own wallops back
|
|
98
|
+
* (matching NOTICE/PRIVMSG channel-broadcast semantics).
|
|
99
|
+
*
|
|
100
|
+
* The reducer does not know which connections carry `+w`; the bound
|
|
101
|
+
* runtime's `broadcastWallops` method owns that enumeration. On the
|
|
102
|
+
* in-memory runtime that is a scan of its connection map; on CF it walks
|
|
103
|
+
* the registry shards; on AWS it scans the `Connections` table. Each
|
|
104
|
+
* adapter applies its own cost cap (documented at the method).
|
|
105
|
+
*/
|
|
106
|
+
export interface BroadcastWallopsEffect {
|
|
107
|
+
tag: 'BroadcastWallops';
|
|
108
|
+
lines: RawLine[];
|
|
109
|
+
except?: ConnId;
|
|
110
|
+
}
|
|
111
|
+
|
|
92
112
|
/** Discriminated union of every effect the dispatch interpreter knows. */
|
|
93
113
|
export type Effect =
|
|
94
114
|
| SendEffect
|
|
@@ -98,7 +118,8 @@ export type Effect =
|
|
|
98
118
|
| ChangeNickEffect
|
|
99
119
|
| ReleaseNickEffect
|
|
100
120
|
| ApplyChannelDeltaEffect
|
|
101
|
-
| SendToNickEffect
|
|
121
|
+
| SendToNickEffect
|
|
122
|
+
| BroadcastWallopsEffect;
|
|
102
123
|
|
|
103
124
|
/** Literal string tag of every {@link Effect} variant. */
|
|
104
125
|
export type EffectTag = Effect['tag'];
|
|
@@ -158,4 +179,9 @@ export const Effect = {
|
|
|
158
179
|
if (notFoundLines !== undefined) e.notFoundLines = notFoundLines;
|
|
159
180
|
return e;
|
|
160
181
|
},
|
|
182
|
+
broadcastWallops(lines: RawLine[], except?: ConnId): Effect {
|
|
183
|
+
const e: BroadcastWallopsEffect = { tag: 'BroadcastWallops', lines };
|
|
184
|
+
if (except !== undefined) e.except = except;
|
|
185
|
+
return e;
|
|
186
|
+
},
|
|
161
187
|
} as const;
|
|
@@ -14,6 +14,8 @@ export * from './credential-hashing.js';
|
|
|
14
14
|
export * from './flood-control.js';
|
|
15
15
|
export * from './cloak.js';
|
|
16
16
|
export * from './case-fold.js';
|
|
17
|
+
export * from './ws-subprotocol.js';
|
|
18
|
+
export * from './ws-framing.js';
|
|
17
19
|
export * from './admission.js';
|
|
18
20
|
export * from './config.js';
|
|
19
21
|
export type {
|