serverless-ircd 0.2.0 → 0.4.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 +258 -18
- package/README.md +72 -30
- package/apps/aws-stack/README.md +2 -2
- 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 +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- 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/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +11 -1
- package/apps/cf-worker/wrangler.toml +69 -19
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +11 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +46 -3
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/package.json +19 -16
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +124 -34
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +62 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +43 -2
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +120 -9
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +13 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
- package/packages/aws-adapter/tests/account-store.test.ts +279 -0
- 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/config-loader.test.ts +75 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +194 -47
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- 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 +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +40 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +62 -0
- package/packages/cf-adapter/src/connection-do.ts +181 -31
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +58 -8
- package/packages/cf-adapter/src/index.ts +11 -9
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- 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.test.ts +40 -0
- 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/worker/main.ts +9 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +8 -2
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- 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 +11 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +26 -19
- 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/tagmsg.ts +205 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +84 -3
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +3 -0
- package/packages/irc-core/src/ports.ts +240 -7
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +60 -1
- package/packages/irc-core/tests/account-store.test.ts +131 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- 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/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +256 -19
- 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/tagmsg.test.ts +662 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +170 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- 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 +182 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +21 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +617 -1
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.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 +78 -13
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- 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 +9 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +79 -0
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/docs/AWS-Adapter-Architecture.md +0 -494
- package/docs/AWS-Deployment.md +0 -1107
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -1
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -440
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- package/webircgateway/webircgateway.svg +0 -3
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `OPER` command.
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: OPER runs in the Connection entity — it
|
|
5
|
+
* authenticates the caller against the deployment's configured operator
|
|
6
|
+
* credentials and, on success, grants the `o` user mode by setting
|
|
7
|
+
* `state.userModes.oper = true`. The reducer's `state` argument is the
|
|
8
|
+
* caller's authoritative {@link ConnectionState}.
|
|
9
|
+
*
|
|
10
|
+
* Oper is the *only* path that sets `userModes.oper`: a user `MODE +o` is
|
|
11
|
+
* hard-rejected with `481 ERR_NOPRIVILEGES` (see `mode.ts`). Once oper is
|
|
12
|
+
* granted here, the `RPL_WHOISOPERATOR` (313) branch of WHOIS and the `*`
|
|
13
|
+
* oper flag of WHO become reachable.
|
|
14
|
+
*
|
|
15
|
+
* Wire format (RFC 2812 §5.4.1, §4.1):
|
|
16
|
+
* - `OPER <name> <password>` — authenticates and (on success) grants oper.
|
|
17
|
+
* - `381 RPL_YOUREOPER`:
|
|
18
|
+
* `:<server> 381 <nick> :You are now an IRC operator`
|
|
19
|
+
* - `461 ERR_NEEDMOREPARAMS`:
|
|
20
|
+
* `:<server> 461 <nick> OPER :Not enough parameters`
|
|
21
|
+
* - `464 ERR_PASSWDMISMATCH`:
|
|
22
|
+
* `:<server> 464 <nick> :Password Incorrect`
|
|
23
|
+
* - `491 ERR_NOOPERHOST`:
|
|
24
|
+
* `:<server> 491 <nick> :No O-lines for your host`
|
|
25
|
+
*
|
|
26
|
+
* Behaviour:
|
|
27
|
+
* - Missing `<name>` or `<password>` → `461 ERR_NEEDMOREPARAMS`.
|
|
28
|
+
* - Already an operator → graceful idempotent no-op that re-confirms with
|
|
29
|
+
* `381` (no re-authentication required; the connection is already privy).
|
|
30
|
+
* - No operator credentials configured (absent or empty) →
|
|
31
|
+
* `491 ERR_NOOPERHOST`.
|
|
32
|
+
* - Matching credential pair → grant oper, emit `381`.
|
|
33
|
+
* - Wrong password / unknown name → `464 ERR_PASSWDMISMATCH`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { Effect } from '../effects.js';
|
|
37
|
+
import type { RawLine } from '../effects.js';
|
|
38
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
39
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
40
|
+
import type { OperCred } from '../types.js';
|
|
41
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
42
|
+
|
|
43
|
+
/** Fixed text emitted for `381 RPL_YOUREOPER`. */
|
|
44
|
+
const YOUREOPER_TRAILING_TEXT = 'You are now an IRC operator';
|
|
45
|
+
|
|
46
|
+
/** Fixed text emitted for `464 ERR_PASSWDMISMATCH`. */
|
|
47
|
+
const PASSWDMISMATCH_TRAILING_TEXT = 'Password Incorrect';
|
|
48
|
+
|
|
49
|
+
/** Fixed text emitted for `491 ERR_NOOPERHOST`. */
|
|
50
|
+
const NOOPERHOST_TRAILING_TEXT = 'No O-lines for your host';
|
|
51
|
+
|
|
52
|
+
/** Fixed text emitted for `461 ERR_NEEDMOREPARAMS`. */
|
|
53
|
+
const NEEDMOREPARAMS_TRAILING_TEXT = 'Not enough parameters';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns true iff a credential pair in `creds` matches `name` / `password`
|
|
57
|
+
* exactly. Extracted as a pure helper so the match logic is testable in
|
|
58
|
+
* isolation and independent of connection state. Comparison is case
|
|
59
|
+
* sensitive and byte-exact (case-mapping is owned elsewhere).
|
|
60
|
+
*/
|
|
61
|
+
export function matchOperCred(
|
|
62
|
+
creds: ReadonlyArray<OperCred>,
|
|
63
|
+
name: string,
|
|
64
|
+
password: string,
|
|
65
|
+
): boolean {
|
|
66
|
+
return creds.some((c) => c.user === name && c.password === password);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Handles `OPER <name> <password>`.
|
|
71
|
+
*
|
|
72
|
+
* Verifies the supplied pair against `serverConfig.operCreds` and, on
|
|
73
|
+
* success, grants the oper user mode. Emits the appropriate numeric reply
|
|
74
|
+
* in every branch. See the file header for the full decision table.
|
|
75
|
+
*/
|
|
76
|
+
export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
77
|
+
state.lastSeen = ctx.clock.now();
|
|
78
|
+
|
|
79
|
+
const name = msg.params[0];
|
|
80
|
+
const password = msg.params[1];
|
|
81
|
+
|
|
82
|
+
if (name === undefined || password === undefined) {
|
|
83
|
+
return {
|
|
84
|
+
state,
|
|
85
|
+
effects: [
|
|
86
|
+
Effect.send(ctx.connId, [
|
|
87
|
+
numericLine(ctx, Numerics.ERR_NEEDMOREPARAMS, NEEDMOREPARAMS_TRAILING_TEXT, 'OPER'),
|
|
88
|
+
]),
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Already an operator: graceful idempotent re-confirm, no re-auth needed.
|
|
94
|
+
if (state.userModes.oper) {
|
|
95
|
+
return {
|
|
96
|
+
state,
|
|
97
|
+
effects: [
|
|
98
|
+
Effect.send(ctx.connId, [
|
|
99
|
+
numericLine(ctx, Numerics.RPL_YOUREOPER, YOUREOPER_TRAILING_TEXT),
|
|
100
|
+
]),
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const creds = ctx.serverConfig.operCreds;
|
|
106
|
+
if (creds === undefined || creds.length === 0) {
|
|
107
|
+
return {
|
|
108
|
+
state,
|
|
109
|
+
effects: [
|
|
110
|
+
Effect.send(ctx.connId, [
|
|
111
|
+
numericLine(ctx, Numerics.ERR_NOOPERHOST, NOOPERHOST_TRAILING_TEXT),
|
|
112
|
+
]),
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (matchOperCred(creds, name, password)) {
|
|
118
|
+
state.userModes.oper = true;
|
|
119
|
+
return {
|
|
120
|
+
state,
|
|
121
|
+
effects: [
|
|
122
|
+
Effect.send(ctx.connId, [
|
|
123
|
+
numericLine(ctx, Numerics.RPL_YOUREOPER, YOUREOPER_TRAILING_TEXT),
|
|
124
|
+
]),
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
state,
|
|
131
|
+
effects: [
|
|
132
|
+
Effect.send(ctx.connId, [
|
|
133
|
+
numericLine(ctx, Numerics.ERR_PASSWDMISMATCH, PASSWDMISMATCH_TRAILING_TEXT),
|
|
134
|
+
]),
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Builds a `:<server> <code> <nick> [<middle>] :<trailing>` line. The nick
|
|
141
|
+
* falls back to `*` when the connection has not yet registered one, matching
|
|
142
|
+
* the rest of the codebase's numeric formatting.
|
|
143
|
+
*/
|
|
144
|
+
function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
|
|
145
|
+
const nick = ctx.connection.nick ?? '*';
|
|
146
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
147
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
148
|
+
if (middle !== undefined) parts.push(middle);
|
|
149
|
+
parts.push(`:${trailing}`);
|
|
150
|
+
return { text: parts.join(' ') };
|
|
151
|
+
}
|
|
@@ -52,6 +52,18 @@ export const quitReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
54
|
effects.push(Effect.releaseNick(nick));
|
|
55
|
+
// Record the sign-off into nick history so WHOWAS can replay it. Only
|
|
56
|
+
// consulted when a NickHistoryStore is bound; absent → no-op.
|
|
57
|
+
if (ctx.history !== undefined) {
|
|
58
|
+
ctx.history.record({
|
|
59
|
+
nick,
|
|
60
|
+
connId: ctx.connId,
|
|
61
|
+
signoffTime: ctx.clock.now(),
|
|
62
|
+
...(state.user !== undefined ? { username: state.user } : {}),
|
|
63
|
+
...(state.host !== undefined ? { hostname: state.host } : {}),
|
|
64
|
+
...(state.realname !== undefined ? { realname: state.realname } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
effects.push(Effect.disconnect(ctx.connId));
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { cloakHost } from '../cloak.js';
|
|
17
|
+
import { formatCreatedAt, resolveServerVersion } from '../config.js';
|
|
17
18
|
import { Effect } from '../effects.js';
|
|
18
19
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
19
20
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -21,15 +22,7 @@ import { hostmaskOf } from '../state/connection.js';
|
|
|
21
22
|
import type { ConnectionState } from '../state/connection.js';
|
|
22
23
|
import type { Ctx, Reducer } from '../types.js';
|
|
23
24
|
import { generateIsupport } from './isupport.js';
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Server version reported in `002` / `004`. Placeholder until a config-driven
|
|
27
|
-
* version lands.
|
|
28
|
-
*/
|
|
29
|
-
const SERVER_VERSION = '0.2.0';
|
|
30
|
-
|
|
31
|
-
/** "Created" text for `003 RPL_CREATED`. Placeholder. */
|
|
32
|
-
const CREATED_TEXT = 'Phase 1';
|
|
25
|
+
import { buildMotdNumerics } from './motd-lines.js';
|
|
33
26
|
|
|
34
27
|
/** User mode letters for `004 RPL_MYINFO` (invisible, oper, wallops, server-notices). */
|
|
35
28
|
const USER_MODES_004 = 'iosw';
|
|
@@ -55,9 +48,9 @@ export function isValidNick(nick: string, maxLen: number): boolean {
|
|
|
55
48
|
|
|
56
49
|
/**
|
|
57
50
|
* Builds the welcome numeric block emitted once registration completes:
|
|
58
|
-
* `001`–`005` (with `005`
|
|
59
|
-
* the
|
|
60
|
-
* `
|
|
51
|
+
* `001`–`005` (with `005` generated from the server config) followed by
|
|
52
|
+
* the MOTD numerics rendered from {@link Ctx.motd} — `375`/`372`×N/`376`
|
|
53
|
+
* for a configured MOTD, or `422 ERR_NOMOTD` when none is configured.
|
|
61
54
|
*
|
|
62
55
|
* Caller guarantees `state.nick` and `state.user` are set.
|
|
63
56
|
*/
|
|
@@ -69,22 +62,24 @@ export function buildWelcomeLines(state: ConnectionState, ctx: Ctx): RawLine[] {
|
|
|
69
62
|
// always defined, so hostmaskOf always returns at least `nick`. The cast
|
|
70
63
|
// avoids a dead `??` branch that v8 coverage cannot satisfy.
|
|
71
64
|
const hostmask = hostmaskOf(state) as string;
|
|
65
|
+
const version = resolveServerVersion(ctx.serverConfig);
|
|
66
|
+
const created = formatCreatedAt(ctx.serverConfig.createdAt);
|
|
72
67
|
|
|
73
68
|
return [
|
|
74
69
|
{ text: `${prefix} 001 ${nick} :Welcome to the ${ctx.networkName} IRC Network, ${hostmask}` },
|
|
75
70
|
{
|
|
76
|
-
text: `${prefix} 002 ${nick} :Your host is ${ctx.serverName}, running version ${
|
|
71
|
+
text: `${prefix} 002 ${nick} :Your host is ${ctx.serverName}, running version ${version}`,
|
|
77
72
|
},
|
|
78
|
-
{ text: `${prefix} 003 ${nick} :This server was created in ${
|
|
73
|
+
{ text: `${prefix} 003 ${nick} :This server was created in ${created}` },
|
|
79
74
|
{
|
|
80
|
-
text: `${prefix} 004 ${nick} ${ctx.serverName} ${
|
|
75
|
+
text: `${prefix} 004 ${nick} ${ctx.serverName} ${version} ${USER_MODES_004} ${CHANNEL_MODES_004}`,
|
|
81
76
|
},
|
|
82
77
|
// 005 RPL_ISUPPORT — real tokens generated from serverConfig.
|
|
83
78
|
...generateIsupport(ctx.serverConfig, state.caps, nick),
|
|
84
|
-
// MOTD
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
// MOTD — rendered from the configured MotdProvider (375/372/376, or
|
|
80
|
+
// 422 ERR_NOMOTD when no MOTD is configured). Shares the exact rendering
|
|
81
|
+
// path with the standalone MOTD command.
|
|
82
|
+
...buildMotdNumerics(ctx.serverName, nick, ctx.motd.lines()),
|
|
88
83
|
];
|
|
89
84
|
}
|
|
90
85
|
|
|
@@ -213,6 +208,18 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
213
208
|
}
|
|
214
209
|
// hostmaskOf is guaranteed non-undefined here because oldNick is defined.
|
|
215
210
|
const source = hostmaskOf(state) as string;
|
|
211
|
+
// Record the old nick into history so WHOWAS can replay it. Only
|
|
212
|
+
// consulted when a NickHistoryStore is bound; absent → no-op.
|
|
213
|
+
if (ctx.history !== undefined) {
|
|
214
|
+
ctx.history.record({
|
|
215
|
+
nick: oldNick,
|
|
216
|
+
connId: ctx.connId,
|
|
217
|
+
signoffTime: ctx.clock.now(),
|
|
218
|
+
...(state.user !== undefined ? { username: state.user } : {}),
|
|
219
|
+
...(state.host !== undefined ? { hostname: state.host } : {}),
|
|
220
|
+
...(state.realname !== undefined ? { realname: state.realname } : {}),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
216
223
|
state.nick = nick;
|
|
217
224
|
effects.push(Effect.changeNick(ctx.connId, oldNick, nick));
|
|
218
225
|
effects.push(Effect.send(ctx.connId, [{ text: `:${source} NICK ${nick}` }]));
|
|
@@ -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
|
+
}
|