serverless-ircd 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +67 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +427 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +66 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +162 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/progress.md +107 -0
- package/tickets.md +2485 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -17,12 +17,14 @@
|
|
|
17
17
|
|
|
18
18
|
import { Effect } from '../effects.js';
|
|
19
19
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
20
|
+
import type { StoredMessage } from '../ports.js';
|
|
20
21
|
import { wrapBatch } from '../protocol/batch.js';
|
|
21
22
|
import { Numerics } from '../protocol/numerics.js';
|
|
22
23
|
import { type ChanName, type ChannelState, prefixOf } from '../state/channel.js';
|
|
23
24
|
import { hostmaskOf } from '../state/connection.js';
|
|
24
25
|
import type { ConnectionState } from '../state/connection.js';
|
|
25
26
|
import type { Ctx, Reducer } from '../types.js';
|
|
27
|
+
import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
30
|
* RFC 1459/2812 channel-name grammar. The first character must be a channel
|
|
@@ -64,16 +66,13 @@ function buildNamesList(state: ChannelState): string {
|
|
|
64
66
|
|
|
65
67
|
/**
|
|
66
68
|
* Formats a single numeric error line addressed to the connection's current
|
|
67
|
-
* nick (or `*` when unregistered).
|
|
68
|
-
*
|
|
69
|
+
* nick (or `*` when unregistered). `middle` is emitted before the trailing
|
|
70
|
+
* `:`-prefixed text.
|
|
69
71
|
*/
|
|
70
|
-
function numericErr(ctx: Ctx, code: number, trailing: string, middle
|
|
72
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
71
73
|
const nick = ctx.connection.nick ?? '*';
|
|
72
74
|
const codeStr = code.toString().padStart(3, '0');
|
|
73
|
-
|
|
74
|
-
if (middle !== undefined) parts.push(middle);
|
|
75
|
-
parts.push(`:${trailing}`);
|
|
76
|
-
return { text: parts.join(' ') };
|
|
75
|
+
return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
|
|
77
76
|
}
|
|
78
77
|
|
|
79
78
|
/**
|
|
@@ -107,6 +106,44 @@ function joinerHostmask(conn: ConnectionState): string {
|
|
|
107
106
|
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
108
107
|
}
|
|
109
108
|
|
|
109
|
+
/** Default backlog depth replayed on JOIN when ServerConfig omits a value. */
|
|
110
|
+
const DEFAULT_CHATHISTORY_PLAYBACK_LIMIT = 50;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Builds the IRCv3 `draft/chathistory` auto-playback BATCH for a cap-enabled
|
|
114
|
+
* joiner and computes the next "last read" marker.
|
|
115
|
+
*
|
|
116
|
+
* Returns `{ effects: [], marker: undefined }` when playback is disabled (cap
|
|
117
|
+
* not negotiated, no MessageStore bound, or no messages newer than the
|
|
118
|
+
* marker); otherwise a single `Send` carrying the BATCH frame and the msgid
|
|
119
|
+
* of the newest replayed message. The caller is responsible for prepending
|
|
120
|
+
* the effects before the JOIN broadcast and for advancing the per-connection
|
|
121
|
+
* marker so a PART/re-JOIN cycle with no new activity replays nothing.
|
|
122
|
+
*/
|
|
123
|
+
function buildChathistoryPlayback(
|
|
124
|
+
state: ChannelState,
|
|
125
|
+
ctx: Ctx,
|
|
126
|
+
): { effects: EffectType[]; marker: string | undefined } {
|
|
127
|
+
if (!ctx.connection.caps.has(CHATHISTORY_CAP)) {
|
|
128
|
+
return { effects: [], marker: undefined };
|
|
129
|
+
}
|
|
130
|
+
if (ctx.messages === undefined) {
|
|
131
|
+
return { effects: [], marker: undefined };
|
|
132
|
+
}
|
|
133
|
+
const limit = ctx.serverConfig.chatHistoryPlaybackLimit ?? DEFAULT_CHATHISTORY_PLAYBACK_LIMIT;
|
|
134
|
+
const previousMarker = ctx.connection.lastReadMarkers?.get(state.nameLower);
|
|
135
|
+
const recent = ctx.messages.recent(state.nameLower, previousMarker, limit);
|
|
136
|
+
if (recent.length === 0) {
|
|
137
|
+
return { effects: [], marker: undefined };
|
|
138
|
+
}
|
|
139
|
+
// `recent` is non-empty here, so the last entry is defined; the assertion
|
|
140
|
+
// only satisfies the type-checker under `noUncheckedIndexedAccess` (no
|
|
141
|
+
// runtime branch, no `!` token).
|
|
142
|
+
const newest = recent[recent.length - 1] as StoredMessage;
|
|
143
|
+
const effects = buildChathistoryBatch(ctx, state.name, recent);
|
|
144
|
+
return { effects, marker: newest.msgid };
|
|
145
|
+
}
|
|
146
|
+
|
|
110
147
|
/**
|
|
111
148
|
* Handles `JOIN <chan> [<key>]` for a single channel.
|
|
112
149
|
*
|
|
@@ -244,6 +281,21 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
244
281
|
}),
|
|
245
282
|
);
|
|
246
283
|
|
|
284
|
+
// IRCv3 draft/chathistory auto-playback: a cap-enabled joiner with a
|
|
285
|
+
// bound MessageStore receives the recent backlog as a BATCH *before*
|
|
286
|
+
// the JOIN broadcast, then the per-(connection, channel) "last read"
|
|
287
|
+
// marker advances to the newest replayed msgid. PART/re-JOIN cycles
|
|
288
|
+
// only replay messages newer than the marker, so a re-join with no
|
|
289
|
+
// new activity emits nothing.
|
|
290
|
+
const playback = buildChathistoryPlayback(state, ctx);
|
|
291
|
+
if (playback.marker !== undefined) {
|
|
292
|
+
if (ctx.connection.lastReadMarkers === undefined) {
|
|
293
|
+
ctx.connection.lastReadMarkers = new Map<string, string>();
|
|
294
|
+
}
|
|
295
|
+
ctx.connection.lastReadMarkers.set(state.nameLower, playback.marker);
|
|
296
|
+
}
|
|
297
|
+
effects.push(...playback.effects);
|
|
298
|
+
|
|
247
299
|
const legacyJoin: RawLine = { text: `:${hostmask} JOIN ${state.name}` };
|
|
248
300
|
const realname = ctx.connection.realname;
|
|
249
301
|
if (realname !== undefined) {
|
|
@@ -173,6 +173,30 @@ function buildChannelReducer(command: MessageCommand): Reducer<ChannelState> {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
// All checks passed: persist the message for chathistory playback when a
|
|
177
|
+
// MessageStore is bound. The msgid/time are generated once here and travel
|
|
178
|
+
// on the StoredMessage; the live broadcast line stays bare (per-recipient
|
|
179
|
+
// server-time/msgid decoration is the dispatch layer's job). Recording is
|
|
180
|
+
// gated on ctx.messages so deployments with chathistory disabled see no
|
|
181
|
+
// change whatsoever.
|
|
182
|
+
//
|
|
183
|
+
// By this point the sender's hostmask has already been computed and used
|
|
184
|
+
// in the `+b` ban check above, so the connection's nick/user/host are all
|
|
185
|
+
// defined for any registered PRIVMSG (registration sets all three); the
|
|
186
|
+
// fallbacks mirror `senderHostmask` purely for the type-checker.
|
|
187
|
+
if (ctx.messages !== undefined) {
|
|
188
|
+
ctx.messages.record({
|
|
189
|
+
msgid: ctx.ids.nonce(),
|
|
190
|
+
time: ctx.clock.now(),
|
|
191
|
+
chan: state.nameLower,
|
|
192
|
+
command,
|
|
193
|
+
nick: ctx.connection.nick as string,
|
|
194
|
+
user: ctx.connection.user as string,
|
|
195
|
+
host: ctx.connection.host as string,
|
|
196
|
+
text,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
176
200
|
// All checks passed: broadcast to the channel, excluding the sender.
|
|
177
201
|
const line: RawLine = { text: `:${hostmask} ${command} ${state.name} :${text}` };
|
|
178
202
|
effects.push(Effect.broadcast(state.name, [line], ctx.connId));
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* numeric on `{ ok: false }`.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { cloakHost } from '../cloak.js';
|
|
16
17
|
import { Effect } from '../effects.js';
|
|
17
18
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
18
19
|
import { Numerics } from '../protocol/numerics.js';
|
|
@@ -25,7 +26,7 @@ import { generateIsupport } from './isupport.js';
|
|
|
25
26
|
* Server version reported in `002` / `004`. Placeholder until a config-driven
|
|
26
27
|
* version lands.
|
|
27
28
|
*/
|
|
28
|
-
const SERVER_VERSION = '0.
|
|
29
|
+
const SERVER_VERSION = '0.2.0';
|
|
29
30
|
|
|
30
31
|
/** "Created" text for `003 RPL_CREATED`. Placeholder. */
|
|
31
32
|
const CREATED_TEXT = 'Phase 1';
|
|
@@ -88,21 +89,79 @@ export function buildWelcomeLines(state: ConnectionState, ctx: Ctx): RawLine[] {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
/**
|
|
91
|
-
* Attempts to finalize registration: if both `nick` and `user` are present
|
|
92
|
-
*
|
|
93
|
-
* to `'registered'` and returns
|
|
94
|
-
*
|
|
92
|
+
* Attempts to finalize registration: if both `nick` and `user` are present,
|
|
93
|
+
* the connection is not yet registered, and CAP negotiation is not in
|
|
94
|
+
* flight, transitions `state.registration` to `'registered'` and returns
|
|
95
|
+
* a `Send` effect carrying the welcome block.
|
|
96
|
+
*
|
|
97
|
+
* Side effects are returned as an array so the caller can `push(...)` them
|
|
98
|
+
* onto its own effect list. An empty array means "not ready / no work".
|
|
99
|
+
*
|
|
100
|
+
* Enforces the deployment's server-password gate (configured via
|
|
101
|
+
* `serverConfig.serverPassword`) before completing registration:
|
|
102
|
+
* - No password configured (undefined or empty) → gate skipped.
|
|
103
|
+
* - SASL already authenticated (`state.account` set) → gate satisfied.
|
|
104
|
+
* - Otherwise `state.passAttempt` must equal the configured password.
|
|
105
|
+
*
|
|
106
|
+
* On gate failure the connection is NOT registered; the returned effects
|
|
107
|
+
* are `[Send(464 ERR_PASSWDMISMATCH), Disconnect("Bad Password")]` per
|
|
108
|
+
* RFC 2812 §5.1 (numerics 464 + the canonical close reason used by
|
|
109
|
+
* mainstream IRC daemons).
|
|
95
110
|
*
|
|
96
111
|
* Reused by the CAP `END` handler.
|
|
97
112
|
*/
|
|
98
|
-
export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
|
|
99
|
-
if (state.registration === 'registered') return
|
|
100
|
-
if (state.nick === undefined || state.user === undefined) return
|
|
101
|
-
if (state.capNegotiating) return
|
|
113
|
+
export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType[] {
|
|
114
|
+
if (state.registration === 'registered') return [];
|
|
115
|
+
if (state.nick === undefined || state.user === undefined) return [];
|
|
116
|
+
if (state.capNegotiating) return [];
|
|
117
|
+
|
|
118
|
+
const gateFailure = passwordGateFailure(state, ctx);
|
|
119
|
+
if (gateFailure !== null) return gateFailure;
|
|
120
|
+
|
|
121
|
+
// Apply hostmask cloaking (if enabled) before emitting the welcome block
|
|
122
|
+
// so the `001` hostmask and every downstream message uses the cloak.
|
|
123
|
+
applyCloakIfEnabled(state, ctx);
|
|
124
|
+
|
|
102
125
|
state.registration = 'registered';
|
|
103
|
-
return Effect.send(ctx.connId, buildWelcomeLines(state, ctx));
|
|
126
|
+
return [Effect.send(ctx.connId, buildWelcomeLines(state, ctx))];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Replaces `state.host` with a deterministic cloak derived from the
|
|
131
|
+
* deployment's cloaking secret + the current (real) host. No-op when
|
|
132
|
+
* cloaking is disabled or no host is set yet (e.g. transport didn't supply
|
|
133
|
+
* one, or the connection sets it later via CHGHOST).
|
|
134
|
+
*/
|
|
135
|
+
function applyCloakIfEnabled(state: ConnectionState, ctx: Ctx): void {
|
|
136
|
+
const cloaking = ctx.serverConfig.cloaking;
|
|
137
|
+
if (cloaking === undefined || !cloaking.enabled) return;
|
|
138
|
+
if (state.host === undefined) return;
|
|
139
|
+
state.host = cloakHost(state.host, cloaking, ctx.networkName);
|
|
104
140
|
}
|
|
105
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Returns the password-gate failure effects (`[Send 464, Disconnect]`) when
|
|
144
|
+
* the connection has not satisfied the deployment's server-password, or
|
|
145
|
+
* `null` when the gate is satisfied / disabled.
|
|
146
|
+
*
|
|
147
|
+
* Pulling this out as a named helper keeps {@link emitWelcomeIfReady}
|
|
148
|
+
* readable and gives tests a direct handle on the gate logic.
|
|
149
|
+
*/
|
|
150
|
+
function passwordGateFailure(state: ConnectionState, ctx: Ctx): EffectType[] | null {
|
|
151
|
+
const configured = ctx.serverConfig.serverPassword;
|
|
152
|
+
if (configured === undefined || configured === '') return null;
|
|
153
|
+
// SASL success satisfies the password gate.
|
|
154
|
+
if (state.account !== undefined) return null;
|
|
155
|
+
if (state.passAttempt === configured) return null;
|
|
156
|
+
return [
|
|
157
|
+
Effect.send(ctx.connId, [numericErr(ctx, Numerics.ERR_PASSWDMISMATCH, 'Password mismatch')]),
|
|
158
|
+
Effect.disconnect(ctx.connId, PASSWORD_MISMATCH_REASON),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Canonical disconnect reason paired with the 464 numeric. */
|
|
163
|
+
const PASSWORD_MISMATCH_REASON = 'Bad Password';
|
|
164
|
+
|
|
106
165
|
/**
|
|
107
166
|
* Formats a single numeric error line addressed to the connection's current
|
|
108
167
|
* nick (or `*` when unregistered). Optional `middle` param is emitted before
|
|
@@ -166,8 +225,7 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
166
225
|
}
|
|
167
226
|
effects.push(Effect.reserveNick(nick, ctx.connId));
|
|
168
227
|
|
|
169
|
-
|
|
170
|
-
if (welcome !== null) effects.push(welcome);
|
|
228
|
+
effects.push(...emitWelcomeIfReady(state, ctx));
|
|
171
229
|
|
|
172
230
|
return { state, effects };
|
|
173
231
|
};
|
|
@@ -209,8 +267,7 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
209
267
|
state.registration = 'registering';
|
|
210
268
|
}
|
|
211
269
|
|
|
212
|
-
|
|
213
|
-
if (welcome !== null) effects.push(welcome);
|
|
270
|
+
effects.push(...emitWelcomeIfReady(state, ctx));
|
|
214
271
|
|
|
215
272
|
return { state, effects };
|
|
216
273
|
};
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRCv3 `AUTHENTICATE` command (SASL).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: SASL runs in the Connection entity.
|
|
5
|
+
* The reducer drives the multi-frame SASL exchange purely:
|
|
6
|
+
*
|
|
7
|
+
* 1. `AUTHENTICATE <MECH>` — selects a mechanism. `PLAIN` is supported and
|
|
8
|
+
* answers with `AUTHENTICATE +` (requesting the payload). `EXTERNAL` is
|
|
9
|
+
* recognized but stubbed to `904` until mTLS lands. Anything else gets
|
|
10
|
+
* `908 ERR_SASLMECHS` listing what is available.
|
|
11
|
+
* 2. `AUTHENTICATE <b64>` / `AUTHENTICATE +` — delivers the payload (whole
|
|
12
|
+
* or in `<=400`-byte chunks; `+` marks an empty/final chunk). The
|
|
13
|
+
* decoded bytes are handed to the injected {@link AccountStore}; on
|
|
14
|
+
* success the connection records the account name and the reducer emits
|
|
15
|
+
* `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure `904 ERR_SASLFAIL`.
|
|
16
|
+
* 3. `AUTHENTICATE *` — client abort → `906 ERR_SASLABORT`.
|
|
17
|
+
*
|
|
18
|
+
* The `AccountStore` port is **synchronous** (see {@link AccountStore} docs):
|
|
19
|
+
* adapters pre-load credentials so the reducer stays pure. Async backends
|
|
20
|
+
* resolve their lookups before the actor invokes this reducer.
|
|
21
|
+
*
|
|
22
|
+
* State: {@link ConnectionState.saslMech} tracks the mechanism in flight;
|
|
23
|
+
* {@link ConnectionState.saslBuffer} accumulates chunked payloads. Both are
|
|
24
|
+
* cleared on every terminal outcome.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Effect } from '../effects.js';
|
|
28
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
29
|
+
import { decodeBase64 } from '../protocol/base64.js';
|
|
30
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
31
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
32
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
33
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
34
|
+
|
|
35
|
+
/** Per-ircdocs.horse: a single `AUTHENTICATE` payload chunk is at most 400 bytes. */
|
|
36
|
+
const SASL_CHUNK_BYTES = 400;
|
|
37
|
+
|
|
38
|
+
/** Total decoded-payload cap before `905 ERR_SASLTOOLONG`. */
|
|
39
|
+
const SASL_MAX_BYTES = 8192;
|
|
40
|
+
|
|
41
|
+
/** Mechanisms this server will accept today (advertised verbatim by the cap). */
|
|
42
|
+
const SUPPORTED_MECHS = 'PLAIN,EXTERNAL';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reducer for `AUTHENTICATE [<param>]`. See module docs for the state
|
|
46
|
+
* machine. Authority: the invoking connection's {@link ConnectionState}.
|
|
47
|
+
*/
|
|
48
|
+
export const authenticateReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
49
|
+
state.lastSeen = ctx.clock.now();
|
|
50
|
+
|
|
51
|
+
const param = msg.params[0];
|
|
52
|
+
if (param === undefined) {
|
|
53
|
+
return { state, effects: [Effect.send(ctx.connId, [needMoreParams(ctx)])] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (state.registration === 'registered' || !state.capNegotiating || state.account !== undefined) {
|
|
57
|
+
return { state, effects: [Effect.send(ctx.connId, [saslAlready(ctx)])] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (param === '*') {
|
|
61
|
+
return handleAbort(state, ctx);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (state.saslMech === undefined) {
|
|
65
|
+
return handleMechanism(state, param, ctx);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return handlePayload(state, param, ctx);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** `AUTHENTICATE *` — client-initiated abort. */
|
|
72
|
+
function handleAbort(
|
|
73
|
+
state: ConnectionState,
|
|
74
|
+
ctx: Ctx,
|
|
75
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
76
|
+
if (state.saslMech === undefined) {
|
|
77
|
+
return { state, effects: [] };
|
|
78
|
+
}
|
|
79
|
+
clearSasl(state);
|
|
80
|
+
return { state, effects: [Effect.send(ctx.connId, [saslAbort(ctx)])] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `AUTHENTICATE <MECH>` — first frame of an exchange. */
|
|
84
|
+
function handleMechanism(
|
|
85
|
+
state: ConnectionState,
|
|
86
|
+
param: string,
|
|
87
|
+
ctx: Ctx,
|
|
88
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
89
|
+
const mech = param.toUpperCase();
|
|
90
|
+
if (mech === 'PLAIN') {
|
|
91
|
+
state.saslMech = 'PLAIN';
|
|
92
|
+
state.saslBuffer = '';
|
|
93
|
+
return { state, effects: [Effect.send(ctx.connId, [L('AUTHENTICATE +')])] };
|
|
94
|
+
}
|
|
95
|
+
if (mech === 'EXTERNAL') {
|
|
96
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
97
|
+
}
|
|
98
|
+
return { state, effects: [Effect.send(ctx.connId, [saslMechs(ctx)])] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `AUTHENTICATE <chunk>` / `AUTHENTICATE +` — payload delivery. */
|
|
102
|
+
function handlePayload(
|
|
103
|
+
state: ConnectionState,
|
|
104
|
+
param: string,
|
|
105
|
+
ctx: Ctx,
|
|
106
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
107
|
+
const mech = state.saslMech as string;
|
|
108
|
+
const buffer = state.saslBuffer as string;
|
|
109
|
+
|
|
110
|
+
if (param === '+') {
|
|
111
|
+
return finalize(state, buffer, mech, ctx);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const next = buffer + param;
|
|
115
|
+
if (next.length > SASL_MAX_BYTES) {
|
|
116
|
+
clearSasl(state);
|
|
117
|
+
return { state, effects: [Effect.send(ctx.connId, [saslTooLong(ctx)])] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (param.length === SASL_CHUNK_BYTES) {
|
|
121
|
+
state.saslBuffer = next;
|
|
122
|
+
return { state, effects: [] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return finalize(state, next, mech, ctx);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Decodes the accumulated payload and runs it through the AccountStore. */
|
|
129
|
+
function finalize(
|
|
130
|
+
state: ConnectionState,
|
|
131
|
+
payloadB64: string,
|
|
132
|
+
mech: string,
|
|
133
|
+
ctx: Ctx,
|
|
134
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
135
|
+
const parsed = parsePlain(payloadB64);
|
|
136
|
+
|
|
137
|
+
if (parsed === null) {
|
|
138
|
+
clearSasl(state);
|
|
139
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const store = ctx.accounts;
|
|
143
|
+
if (store === undefined) {
|
|
144
|
+
clearSasl(state);
|
|
145
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const result = store.verify(mech, {
|
|
149
|
+
kind: 'PLAIN',
|
|
150
|
+
username: parsed.username,
|
|
151
|
+
password: parsed.password,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
clearSasl(state);
|
|
155
|
+
|
|
156
|
+
if (result.ok) {
|
|
157
|
+
state.account = result.account;
|
|
158
|
+
return {
|
|
159
|
+
state,
|
|
160
|
+
effects: [Effect.send(ctx.connId, [loggedIn(ctx, result.account), saslSuccess(ctx)])],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Decodes a base64 PLAIN payload (`authzid\0authcid\0passwd`) and returns the
|
|
168
|
+
* authcid/password pair. Returns `null` on any decode or shape failure.
|
|
169
|
+
*/
|
|
170
|
+
function parsePlain(payloadB64: string): { username: string; password: string } | null {
|
|
171
|
+
let decoded: string;
|
|
172
|
+
try {
|
|
173
|
+
decoded = decodeBase64(payloadB64);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const parts = decoded.split('\0');
|
|
178
|
+
if (parts.length !== 3) return null;
|
|
179
|
+
const [, username, password] = parts as [string, string, string];
|
|
180
|
+
return { username, password };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Clears every SASL-related scratch field on the connection. */
|
|
184
|
+
function clearSasl(state: ConnectionState): void {
|
|
185
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
186
|
+
delete state.saslMech;
|
|
187
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
188
|
+
delete state.saslBuffer;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function loggedIn(ctx: Ctx, account: string): RawLine {
|
|
192
|
+
const nick = nickOf(ctx);
|
|
193
|
+
const hostmask = hostmaskOf(ctx.connection) ?? nick;
|
|
194
|
+
return L(
|
|
195
|
+
`:${ctx.serverName} ${code(Numerics.RPL_LOGGEDIN)} ${nick} ${hostmask} ${account} :You are now logged in as ${account}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function saslSuccess(ctx: Ctx): RawLine {
|
|
200
|
+
return L(
|
|
201
|
+
`:${ctx.serverName} ${code(Numerics.RPL_SASLSUCCESS)} ${nickOf(ctx)} :SASL authentication successful`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function saslFail(ctx: Ctx): RawLine {
|
|
206
|
+
return L(
|
|
207
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLFAIL)} ${nickOf(ctx)} :SASL authentication failed`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function saslTooLong(ctx: Ctx): RawLine {
|
|
212
|
+
return L(
|
|
213
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLTOOLONG)} ${nickOf(ctx)} :SASL message too long`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function saslAbort(ctx: Ctx): RawLine {
|
|
218
|
+
return L(
|
|
219
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLABORT)} ${nickOf(ctx)} :SASL authentication aborted`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function saslAlready(ctx: Ctx): RawLine {
|
|
224
|
+
return L(
|
|
225
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLALREADY)} ${nickOf(ctx)} :You have already authenticated using SASL`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function saslMechs(ctx: Ctx): RawLine {
|
|
230
|
+
return L(
|
|
231
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLMECHS)} ${nickOf(ctx)} ${SUPPORTED_MECHS} :are the available SASL mechanisms`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function needMoreParams(ctx: Ctx): RawLine {
|
|
236
|
+
return L(
|
|
237
|
+
`:${ctx.serverName} ${code(Numerics.ERR_NEEDMOREPARAMS)} ${nickOf(ctx)} AUTHENTICATE :Not enough parameters`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function nickOf(ctx: Ctx): string {
|
|
242
|
+
return ctx.connection.nick ?? '*';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function code(n: number): string {
|
|
246
|
+
return n.toString().padStart(3, '0');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function L(text: string): RawLine {
|
|
250
|
+
return { text };
|
|
251
|
+
}
|