serverless-ircd 0.3.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/.github/workflows/ci.yml +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +349 -18
- package/README.md +132 -30
- package/apps/aws-stack/README.md +6 -5
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +150 -10
- package/apps/aws-stack/tests/stack.test.ts +145 -4
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +149 -24
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +75 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +96 -9
- package/packages/aws-adapter/src/handlers/default.ts +98 -7
- package/packages/aws-adapter/src/handlers/index.ts +109 -4
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +8 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- 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/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +174 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +302 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +68 -5
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +278 -85
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +54 -11
- package/packages/cf-adapter/src/index.ts +11 -8
- 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 +12 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
- 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/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- 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/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- 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 +15 -8
- 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 +15 -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 +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +24 -3
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +17 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/ison.ts +61 -0
- 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/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -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/userhost.ts +84 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +65 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +33 -30
- package/packages/irc-core/src/index.ts +3 -0
- package/packages/irc-core/src/ports.ts +360 -12
- package/packages/irc-core/src/protocol/numerics.ts +48 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +46 -2
- 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/account-store.test.ts +45 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -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/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -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/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +102 -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/types.test.ts +35 -1
- 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-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +393 -16
- package/packages/irc-server/src/dispatch.ts +1 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +15 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +104 -0
- package/packages/irc-server/tests/actor.test.ts +1489 -4
- package/packages/irc-server/tests/dispatch.test.ts +37 -17
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +5 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +141 -3
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +10 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +9 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +107 -0
- 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
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `REHASH` command (oper-gated config reload).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: REHASH runs in the Connection entity. The
|
|
5
|
+
* reducer is pure — it only enforces the oper gate and emits the
|
|
6
|
+
* `382 RPL_REHASHING` reply. The actual config re-fetch is a request/response
|
|
7
|
+
* side effect (re-invoke the adapter's bound config loader and swap the live
|
|
8
|
+
* {@link ServerConfig} reference on the actor), so — exactly like
|
|
9
|
+
* `lookupNick` / `getConnection` — it is NOT modelled as a fire-and-forget
|
|
10
|
+
* `Effect`; the actor layer performs it and surfaces success/failure back to
|
|
11
|
+
* the oper via a second `382` line on failure.
|
|
12
|
+
*
|
|
13
|
+
* Wire format:
|
|
14
|
+
* - `REHASH` (no params).
|
|
15
|
+
* - `382 RPL_REHASHING`:
|
|
16
|
+
* `:<server> 382 <nick> :Rehashing from <source>`
|
|
17
|
+
* - `481 ERR_NOPRIVILEGES`:
|
|
18
|
+
* `:<server> 481 <nick> :Permission Denied - You're not an IRC operator`
|
|
19
|
+
*
|
|
20
|
+
* Behaviour:
|
|
21
|
+
* - Non-oper → `481 ERR_NOPRIVILEGES`; no reload is signalled.
|
|
22
|
+
* - Oper → emits `382` noting the bound config source and signals
|
|
23
|
+
* `shouldReload: true`. The actor re-invokes its loader and swaps the
|
|
24
|
+
* live config; on failure the previous config stays in effect and a
|
|
25
|
+
* graceful error-suffixed `382` follows (handled by the actor).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Effect } from '../effects.js';
|
|
29
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
30
|
+
import type { IrcMessage } from '../protocol/messages.js';
|
|
31
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
32
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
33
|
+
import type { Ctx } from '../types.js';
|
|
34
|
+
|
|
35
|
+
/** Fixed text emitted for `481 ERR_NOPRIVILEGES` (matches `mode.ts`). */
|
|
36
|
+
const NOPRIVILEGES_TRAILING_TEXT = "Permission Denied - You're not an IRC operator";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Result of {@link rehashReducer}. Adds a `shouldReload` intent flag on top of
|
|
40
|
+
* the standard reducer shape so the actor layer knows whether to perform the
|
|
41
|
+
* async config re-fetch (oper gate passed) without re-deriving the gate.
|
|
42
|
+
*/
|
|
43
|
+
export interface RehashResult {
|
|
44
|
+
state: ConnectionState;
|
|
45
|
+
effects: EffectType[];
|
|
46
|
+
shouldReload: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Composes the trailing text for the `382 RPL_REHASHING` line. The success
|
|
51
|
+
* form notes the bound config source; the failure form (used by the actor on
|
|
52
|
+
* reload rejection) appends the error so the oper sees what went wrong
|
|
53
|
+
* without being disconnected.
|
|
54
|
+
*/
|
|
55
|
+
export function buildRehashTrailing(configSource: string, error?: string): string {
|
|
56
|
+
const base = `Rehashing from ${configSource}`;
|
|
57
|
+
return error === undefined ? base : `${base} failed: ${error}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Renders the full `:<server> 382 <nick> :<trailing>` wire line. Shared by
|
|
62
|
+
* the reducer (optimistic success line) and the actor (error-suffixed line
|
|
63
|
+
* on reload failure) so the two paths cannot drift in formatting.
|
|
64
|
+
*/
|
|
65
|
+
export function rehashLine(
|
|
66
|
+
serverName: string,
|
|
67
|
+
nick: string,
|
|
68
|
+
configSource: string,
|
|
69
|
+
error?: string,
|
|
70
|
+
): RawLine {
|
|
71
|
+
const code = Numerics.RPL_REHASHING.toString().padStart(3, '0');
|
|
72
|
+
return { text: `:${serverName} ${code} ${nick} :${buildRehashTrailing(configSource, error)}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handles `REHASH`.
|
|
77
|
+
*
|
|
78
|
+
* Enforces the oper gate, stamps `lastSeen`, and — for an oper — emits the
|
|
79
|
+
* `382 RPL_REHASHING` reply and signals the actor to perform the reload via
|
|
80
|
+
* `shouldReload`. The reducer never performs IO.
|
|
81
|
+
*/
|
|
82
|
+
export function rehashReducer(
|
|
83
|
+
state: ConnectionState,
|
|
84
|
+
_msg: IrcMessage,
|
|
85
|
+
ctx: Ctx,
|
|
86
|
+
configSource: string,
|
|
87
|
+
): RehashResult {
|
|
88
|
+
state.lastSeen = ctx.clock.now();
|
|
89
|
+
|
|
90
|
+
if (!state.userModes.oper) {
|
|
91
|
+
return {
|
|
92
|
+
state,
|
|
93
|
+
effects: [
|
|
94
|
+
Effect.send(ctx.connId, [
|
|
95
|
+
numericLine(ctx, Numerics.ERR_NOPRIVILEGES, NOPRIVILEGES_TRAILING_TEXT),
|
|
96
|
+
]),
|
|
97
|
+
],
|
|
98
|
+
shouldReload: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const nick = ctx.connection.nick ?? '*';
|
|
103
|
+
return {
|
|
104
|
+
state,
|
|
105
|
+
effects: [Effect.send(ctx.connId, [rehashLine(ctx.serverName, nick, configSource)])],
|
|
106
|
+
shouldReload: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Builds a `:<server> <code> <nick> :<trailing>` line. The nick falls back to
|
|
112
|
+
* `*` for an unregistered connection, matching the rest of the codebase's
|
|
113
|
+
* numeric formatting.
|
|
114
|
+
*/
|
|
115
|
+
function numericLine(ctx: Ctx, code: number, trailing: string): RawLine {
|
|
116
|
+
const nick = ctx.connection.nick ?? '*';
|
|
117
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
118
|
+
return { text: [`:${ctx.serverName}`, codeStr, nick, `:${trailing}`].join(' ') };
|
|
119
|
+
}
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
* PLAN §2.1 location-of-authority: SASL runs in the Connection entity.
|
|
5
5
|
* The reducer drives the multi-frame SASL exchange purely:
|
|
6
6
|
*
|
|
7
|
-
* 1. `AUTHENTICATE <MECH>` — selects a mechanism. `PLAIN` is
|
|
8
|
-
* answers with `AUTHENTICATE +` (requesting the payload).
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 1. `AUTHENTICATE <MECH>` — selects a mechanism. `PLAIN` is always
|
|
8
|
+
* supported and answers with `AUTHENTICATE +` (requesting the payload).
|
|
9
|
+
* `EXTERNAL` is supported only when an mTLS identity provider is
|
|
10
|
+
* configured for the connection; without one, EXTERNAL falls through
|
|
11
|
+
* to `908 ERR_SASLMECHS` (mechanism not available) listing what is.
|
|
12
|
+
* The verified client-cert subject is resolved at finalise time.
|
|
13
|
+
* Anything else gets `908 ERR_SASLMECHS` listing what is available.
|
|
11
14
|
* 2. `AUTHENTICATE <b64>` / `AUTHENTICATE +` — delivers the payload (whole
|
|
12
15
|
* or in `<=400`-byte chunks; `+` marks an empty/final chunk). The
|
|
13
16
|
* decoded bytes are handed to the injected {@link AccountStore}; on
|
|
@@ -26,6 +29,7 @@
|
|
|
26
29
|
|
|
27
30
|
import { Effect } from '../effects.js';
|
|
28
31
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
32
|
+
import type { SaslResult } from '../ports.js';
|
|
29
33
|
import { decodeBase64 } from '../protocol/base64.js';
|
|
30
34
|
import { Numerics } from '../protocol/numerics.js';
|
|
31
35
|
import { hostmaskOf } from '../state/connection.js';
|
|
@@ -38,8 +42,17 @@ const SASL_CHUNK_BYTES = 400;
|
|
|
38
42
|
/** Total decoded-payload cap before `905 ERR_SASLTOOLONG`. */
|
|
39
43
|
const SASL_MAX_BYTES = 8192;
|
|
40
44
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Mechanisms advertised in `908 ERR_SASLMECHS` and the `sasl` cap value,
|
|
47
|
+
* derived from whether mTLS is configured for this connection.
|
|
48
|
+
*
|
|
49
|
+
* PLAIN is always available; EXTERNAL only when an `MtlsIdentityProvider`
|
|
50
|
+
* is bound. Operators enable EXTERNAL by configuring mTLS at the edge
|
|
51
|
+
* — no code change required.
|
|
52
|
+
*/
|
|
53
|
+
function supportedMechs(ctx: Ctx): string {
|
|
54
|
+
return ctx.mtlsIdentity !== undefined ? 'PLAIN,EXTERNAL' : 'PLAIN';
|
|
55
|
+
}
|
|
43
56
|
|
|
44
57
|
/**
|
|
45
58
|
* Reducer for `AUTHENTICATE [<param>]`. See module docs for the state
|
|
@@ -93,7 +106,12 @@ function handleMechanism(
|
|
|
93
106
|
return { state, effects: [Effect.send(ctx.connId, [L('AUTHENTICATE +')])] };
|
|
94
107
|
}
|
|
95
108
|
if (mech === 'EXTERNAL') {
|
|
96
|
-
|
|
109
|
+
if (ctx.mtlsIdentity === undefined) {
|
|
110
|
+
return { state, effects: [Effect.send(ctx.connId, [saslMechs(ctx)])] };
|
|
111
|
+
}
|
|
112
|
+
state.saslMech = 'EXTERNAL';
|
|
113
|
+
state.saslBuffer = '';
|
|
114
|
+
return { state, effects: [Effect.send(ctx.connId, [L('AUTHENTICATE +')])] };
|
|
97
115
|
}
|
|
98
116
|
return { state, effects: [Effect.send(ctx.connId, [saslMechs(ctx)])] };
|
|
99
117
|
}
|
|
@@ -131,6 +149,18 @@ function finalize(
|
|
|
131
149
|
payloadB64: string,
|
|
132
150
|
mech: string,
|
|
133
151
|
ctx: Ctx,
|
|
152
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
153
|
+
if (mech === 'EXTERNAL') {
|
|
154
|
+
return finalizeExternal(state, ctx);
|
|
155
|
+
}
|
|
156
|
+
return finalizePlain(state, payloadB64, ctx);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** SASL PLAIN: decode base64 payload, verify username/password. */
|
|
160
|
+
function finalizePlain(
|
|
161
|
+
state: ConnectionState,
|
|
162
|
+
payloadB64: string,
|
|
163
|
+
ctx: Ctx,
|
|
134
164
|
): { state: ConnectionState; effects: EffectType[] } {
|
|
135
165
|
const parsed = parsePlain(payloadB64);
|
|
136
166
|
|
|
@@ -145,12 +175,45 @@ function finalize(
|
|
|
145
175
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
146
176
|
}
|
|
147
177
|
|
|
148
|
-
const result = store.verify(
|
|
178
|
+
const result = store.verify('PLAIN', {
|
|
149
179
|
kind: 'PLAIN',
|
|
150
180
|
username: parsed.username,
|
|
151
181
|
password: parsed.password,
|
|
152
182
|
});
|
|
153
183
|
|
|
184
|
+
return applyResult(state, result, ctx);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** SASL EXTERNAL: resolve the mTLS cert subject and verify it. */
|
|
188
|
+
function finalizeExternal(
|
|
189
|
+
state: ConnectionState,
|
|
190
|
+
ctx: Ctx,
|
|
191
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
192
|
+
const provider = ctx.mtlsIdentity;
|
|
193
|
+
const identity = provider?.getIdentity(ctx.connId);
|
|
194
|
+
|
|
195
|
+
if (identity === undefined) {
|
|
196
|
+
clearSasl(state);
|
|
197
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const store = ctx.accounts;
|
|
201
|
+
if (store === undefined) {
|
|
202
|
+
clearSasl(state);
|
|
203
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const result = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity });
|
|
207
|
+
|
|
208
|
+
return applyResult(state, result, ctx);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Shared success/failure handling after a `verify` call. */
|
|
212
|
+
function applyResult(
|
|
213
|
+
state: ConnectionState,
|
|
214
|
+
result: SaslResult,
|
|
215
|
+
ctx: Ctx,
|
|
216
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
154
217
|
clearSasl(state);
|
|
155
218
|
|
|
156
219
|
if (result.ok) {
|
|
@@ -228,7 +291,7 @@ function saslAlready(ctx: Ctx): RawLine {
|
|
|
228
291
|
|
|
229
292
|
function saslMechs(ctx: Ctx): RawLine {
|
|
230
293
|
return L(
|
|
231
|
-
`:${ctx.serverName} ${code(Numerics.ERR_SASLMECHS)} ${nickOf(ctx)} ${
|
|
294
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLMECHS)} ${nickOf(ctx)} ${supportedMechs(ctx)} :are the available SASL mechanisms`,
|
|
232
295
|
);
|
|
233
296
|
}
|
|
234
297
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducers for the server-information query commands `VERSION`, `TIME`,
|
|
3
|
+
* `ADMIN`, and `INFO`.
|
|
4
|
+
*
|
|
5
|
+
* PLAN §2.1 location-of-authority: these are read-only Connection-entity
|
|
6
|
+
* commands — they do not mutate connection or channel state, only emit
|
|
7
|
+
* `Send` numeric replies addressed to the requester. Each is a thin
|
|
8
|
+
* config/clock-derived reply, so they share a numeric-formatting helper.
|
|
9
|
+
*
|
|
10
|
+
* Wire format (RFC 1459/2812):
|
|
11
|
+
* - `VERSION [server]` → `351 RPL_VERSION`:
|
|
12
|
+
* `:<server> 351 <nick> <version> <server> :<comments>`
|
|
13
|
+
* - `TIME [server]` → `391 RPL_TIME`:
|
|
14
|
+
* `:<server> 391 <nick> <server> :<human-readable time>`
|
|
15
|
+
* - `ADMIN [server]` → `256 RPL_ADMINME`:
|
|
16
|
+
* `:<server> 256 <nick> <server> :Administrative info`
|
|
17
|
+
* - `INFO [server]` → `371 RPL_INFO` ×N + `374 RPL_ENDOFINFO`:
|
|
18
|
+
* `:<server> 371 <nick> :<info line>`
|
|
19
|
+
* `:<server> 374 <nick> :End of INFO list`
|
|
20
|
+
*
|
|
21
|
+
* The optional `<server>` target parameter is ignored on all four: this is a
|
|
22
|
+
* single-server implementation (PLAN §1 non-goal: no S2S linking), so a
|
|
23
|
+
* request addressed to another server still gets the local info.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { resolveServerVersion } from '../config.js';
|
|
27
|
+
import { Effect } from '../effects.js';
|
|
28
|
+
import type { RawLine } from '../effects.js';
|
|
29
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
30
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
31
|
+
import type { Ctx, Reducer, ReducerResult } from '../types.js';
|
|
32
|
+
|
|
33
|
+
/** Fixed trailing text for `256 RPL_ADMINME`. */
|
|
34
|
+
const ADMINME_TRAILING_TEXT = 'Administrative info';
|
|
35
|
+
|
|
36
|
+
/** Fixed trailing text for `374 RPL_ENDOFINFO`. */
|
|
37
|
+
const ENDOFINFO_TRAILING_TEXT = 'End of INFO list';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handles `VERSION [server]`. Emits `351 RPL_VERSION` with the server version,
|
|
41
|
+
* server name, and network name as the comment.
|
|
42
|
+
*/
|
|
43
|
+
export const versionReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
|
|
44
|
+
state.lastSeen = ctx.clock.now();
|
|
45
|
+
|
|
46
|
+
const nick = ctx.connection.nick ?? '*';
|
|
47
|
+
const version = resolveServerVersion(ctx.serverConfig);
|
|
48
|
+
const line: RawLine = numericLine(
|
|
49
|
+
ctx,
|
|
50
|
+
Numerics.RPL_VERSION,
|
|
51
|
+
nick,
|
|
52
|
+
`${version} ${ctx.serverName} :${ctx.networkName}`,
|
|
53
|
+
);
|
|
54
|
+
return {
|
|
55
|
+
state,
|
|
56
|
+
effects: [Effect.send(ctx.connId, [line])],
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Handles `TIME [server]`. Emits `391 RPL_TIME` with an ISO-8601 timestamp
|
|
62
|
+
* derived from the injected {@link Clock} (deterministic under the fake clock).
|
|
63
|
+
*/
|
|
64
|
+
export const timeReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
|
|
65
|
+
state.lastSeen = ctx.clock.now();
|
|
66
|
+
|
|
67
|
+
const nick = ctx.connection.nick ?? '*';
|
|
68
|
+
const iso = new Date(ctx.clock.now()).toISOString();
|
|
69
|
+
const line = numericLine(ctx, Numerics.RPL_TIME, nick, `${ctx.serverName} :${iso}`);
|
|
70
|
+
return {
|
|
71
|
+
state,
|
|
72
|
+
effects: [Effect.send(ctx.connId, [line])],
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Handles `ADMIN [server]`. Emits `256 RPL_ADMINME` with the server name.
|
|
78
|
+
* No admin contact info is configured in v1, so the deeper `257`/`258`/`259`
|
|
79
|
+
* numerics are omitted (the server has no admin-email data to report).
|
|
80
|
+
*/
|
|
81
|
+
export const adminReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
|
|
82
|
+
state.lastSeen = ctx.clock.now();
|
|
83
|
+
|
|
84
|
+
const nick = ctx.connection.nick ?? '*';
|
|
85
|
+
const line = numericLine(
|
|
86
|
+
ctx,
|
|
87
|
+
Numerics.RPL_ADMINME,
|
|
88
|
+
nick,
|
|
89
|
+
`${ctx.serverName} :${ADMINME_TRAILING_TEXT}`,
|
|
90
|
+
);
|
|
91
|
+
return {
|
|
92
|
+
state,
|
|
93
|
+
effects: [Effect.send(ctx.connId, [line])],
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Handles `INFO [server]`. Emits one or more `371 RPL_INFO` lines with
|
|
99
|
+
* server metadata followed by the `374 RPL_ENDOFINFO` terminator.
|
|
100
|
+
*/
|
|
101
|
+
export const infoReducer: Reducer<ConnectionState> = (
|
|
102
|
+
state,
|
|
103
|
+
_msg,
|
|
104
|
+
ctx,
|
|
105
|
+
): ReducerResult<ConnectionState> => {
|
|
106
|
+
state.lastSeen = ctx.clock.now();
|
|
107
|
+
|
|
108
|
+
const nick = ctx.connection.nick ?? '*';
|
|
109
|
+
const version = resolveServerVersion(ctx.serverConfig);
|
|
110
|
+
const out: RawLine[] = [
|
|
111
|
+
numericLine(ctx, Numerics.RPL_INFO, nick, `:ServerlessIRCd - version ${version}`),
|
|
112
|
+
numericLine(ctx, Numerics.RPL_INFO, nick, `:Running on network ${ctx.networkName}`),
|
|
113
|
+
numericLine(ctx, Numerics.RPL_ENDOFINFO, nick, `:${ENDOFINFO_TRAILING_TEXT}`),
|
|
114
|
+
];
|
|
115
|
+
return {
|
|
116
|
+
state,
|
|
117
|
+
effects: [Effect.send(ctx.connId, out)],
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Builds `:<server> <code> <nick> <rest>` where `rest` already carries its
|
|
123
|
+
* own leading colon / spacing for the trailing parameter. This keeps the
|
|
124
|
+
* three reducers DRY without duplicating the prefix logic.
|
|
125
|
+
*/
|
|
126
|
+
function numericLine(ctx: Ctx, code: number, nick: string, rest: string): RawLine {
|
|
127
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
128
|
+
return { text: `:${ctx.serverName} ${codeStr} ${nick} ${rest}` };
|
|
129
|
+
}
|
|
@@ -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
|
|