serverless-ircd 0.8.0 → 0.9.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 +4 -0
- package/CHANGELOG.md +245 -0
- package/README.md +160 -200
- package/apps/aws-stack/package.json +1 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/container-server.ts +21 -1
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +94 -31
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/index.html +226 -3
- package/apps/web/package.json +2 -1
- package/apps/web/scripts/build.mjs +25 -2
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +31 -2
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/Services.md +33 -1
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/connection-do.ts +18 -6
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/commands/account-auth.ts +46 -18
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/nickserv.ts +16 -4
- package/packages/irc-core/src/commands/registration.ts +27 -16
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
- package/packages/irc-core/tests/commands/registration.test.ts +227 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -30
- package/packages/irc-server/tests/actor.test.ts +365 -3
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
|
@@ -18,6 +18,7 @@ import { type ChanName, type Nick, createConnection } from '@serverless-ircd/irc
|
|
|
18
18
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
19
19
|
import { cleanupConnection } from '../src/aws-runtime.js';
|
|
20
20
|
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
21
|
+
import { CONNECTION_COUNT_META_ID } from '../src/connection-counter.js';
|
|
21
22
|
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
22
23
|
import { type SweepParams, type SweepResult, handleSweep } from '../src/handlers/sweeper.js';
|
|
23
24
|
import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
|
|
@@ -424,4 +425,23 @@ describe('gone-connection sweeper — scan edge cases', () => {
|
|
|
424
425
|
expect(result.scanned).toBe(1);
|
|
425
426
|
expect(result.swept).toBe(0);
|
|
426
427
|
});
|
|
428
|
+
|
|
429
|
+
it('skips the admission-counter meta row (never swept, never counted)', async () => {
|
|
430
|
+
// The atomic admission counter lives in the Connections table under a
|
|
431
|
+
// sentinel id. The sweeper must not treat it as a stale connection —
|
|
432
|
+
// it carries no idleSince and must never be torn down by the sweeper.
|
|
433
|
+
const client = new FakeScanDynamo([
|
|
434
|
+
{
|
|
435
|
+
items: [
|
|
436
|
+
{ connectionId: CONNECTION_COUNT_META_ID, count: 7 },
|
|
437
|
+
{ connectionId: 'real', idleSince: 0 },
|
|
438
|
+
],
|
|
439
|
+
},
|
|
440
|
+
]);
|
|
441
|
+
const result = await handleSweep(fakeSweepParams(client));
|
|
442
|
+
// The meta row is excluded from both `scanned` and `swept`; only the
|
|
443
|
+
// genuine stale connection is counted.
|
|
444
|
+
expect(result.scanned).toBe(1);
|
|
445
|
+
expect(result.swept).toBe(1);
|
|
446
|
+
});
|
|
427
447
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -827,7 +827,7 @@ const DEFAULT_MOTD: string[] = ['Welcome to the ServerlessIRCd Cloudflare adapte
|
|
|
827
827
|
* synchronous reducers can mutate `members` (a Map) and `banMasks` /
|
|
828
828
|
* `pendingInvites` (Sets) in place.
|
|
829
829
|
*/
|
|
830
|
-
class PassthroughChannelAccess {
|
|
830
|
+
export class PassthroughChannelAccess {
|
|
831
831
|
private readonly cache = new Map<string, ChannelState>();
|
|
832
832
|
constructor(
|
|
833
833
|
private readonly now: number,
|
|
@@ -879,11 +879,23 @@ class PassthroughChannelAccess {
|
|
|
879
879
|
createdAt: number;
|
|
880
880
|
} | null;
|
|
881
881
|
if (dto === null) return;
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
|
|
882
|
+
// When the authoritative ChannelDO reports zero members (the
|
|
883
|
+
// post-deploy state), don't replace the cache with an empty roster —
|
|
884
|
+
// the local reducer should lazily seed memberships via
|
|
885
|
+
// getOrCreateChannel. But DO propagate the persisted topic / modes /
|
|
886
|
+
// ban list onto an existing cache entry so the JOIN reducer sees the
|
|
887
|
+
// survived topic and emits 332/333. Mirrors the AWS adapter's
|
|
888
|
+
// LambdaChannelAccess.refreshChannel empty-members branch.
|
|
889
|
+
if (dto.members.length === 0) {
|
|
890
|
+
const existing = this.cache.get(key);
|
|
891
|
+
if (existing !== undefined) {
|
|
892
|
+
existing.modes = { ...dto.modes };
|
|
893
|
+
existing.banMasks = new Set(dto.banMasks);
|
|
894
|
+
existing.pendingInvites = new Set(dto.pendingInvites);
|
|
895
|
+
if (dto.topic !== undefined) existing.topic = dto.topic;
|
|
896
|
+
}
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
887
899
|
const members = new Map<ConnId, { conn: ConnId; nick: string; op: boolean; voice: boolean }>();
|
|
888
900
|
for (const entry of dto.members) {
|
|
889
901
|
members.set(entry.conn, {
|
|
@@ -89,3 +89,133 @@ describe('wsFrameModeFromTags', () => {
|
|
|
89
89
|
expect(wsFrameModeFromTags(['ws:spec-text', 'ws:legacy'])).toBe('spec-text');
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// PassthroughChannelAccess.refreshChannel
|
|
95
|
+
// ============================================================================
|
|
96
|
+
//
|
|
97
|
+
// refreshChannel pulls the authoritative snapshot from the ChannelDO and
|
|
98
|
+
// rebuilds (or updates) the local cache entry. The zero-members branch is
|
|
99
|
+
// the post-deploy state: the ChannelDO persisted a topic across the
|
|
100
|
+
// deploy but its roster is empty, so the local cache must still absorb
|
|
101
|
+
// the topic/modes/banMasks or the JOIN reducer runs against a topic-less
|
|
102
|
+
// state and never emits 332/333.
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
import { PassthroughChannelAccess } from '../src/connection-do';
|
|
106
|
+
import type { Env } from '../src/env';
|
|
107
|
+
|
|
108
|
+
const EMPTY_MODES = {
|
|
109
|
+
inviteOnly: false,
|
|
110
|
+
topicLock: false,
|
|
111
|
+
noExternal: false,
|
|
112
|
+
moderated: false,
|
|
113
|
+
secret: false,
|
|
114
|
+
private: false,
|
|
115
|
+
registered: false,
|
|
116
|
+
blockUnidentified: false,
|
|
117
|
+
moderatedIdent: false,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Builds a fake Env whose CHANNEL_DO returns `snapshot` from getChannelSnapshot. */
|
|
121
|
+
function makeEnvWithSnapshot(snapshot: unknown): Env {
|
|
122
|
+
const fakeChannelDo = {
|
|
123
|
+
idFromName: (key: string) => `id-${key}`,
|
|
124
|
+
get: () => ({ getChannelSnapshot: async () => snapshot }),
|
|
125
|
+
};
|
|
126
|
+
return { CHANNEL_DO: fakeChannelDo } as unknown as Env;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('PassthroughChannelAccess.refreshChannel', () => {
|
|
130
|
+
it('propagates topic/modes/banMasks onto the cached entry when the snapshot reports zero members', async () => {
|
|
131
|
+
const snap = {
|
|
132
|
+
modes: { ...EMPTY_MODES, topicLock: true },
|
|
133
|
+
banMasks: ['bad!*@*'],
|
|
134
|
+
topic: { text: 'survived deploy', setter: 'alice!alice@example.com', setAt: 1234 },
|
|
135
|
+
members: [],
|
|
136
|
+
pendingInvites: [],
|
|
137
|
+
createdAt: 0,
|
|
138
|
+
};
|
|
139
|
+
const env = makeEnvWithSnapshot(snap);
|
|
140
|
+
const access = new PassthroughChannelAccess(0, env);
|
|
141
|
+
const chan = access.getOrCreateChannel('#foo');
|
|
142
|
+
expect(chan.topic).toBeUndefined();
|
|
143
|
+
|
|
144
|
+
await access.refreshChannel('#foo');
|
|
145
|
+
|
|
146
|
+
expect(chan.topic?.text).toBe('survived deploy');
|
|
147
|
+
expect(chan.topic?.setter).toBe('alice!alice@example.com');
|
|
148
|
+
expect(chan.topic?.setAt).toBe(1234);
|
|
149
|
+
expect(chan.modes.topicLock).toBe(true);
|
|
150
|
+
expect(chan.banMasks.has('bad!*@*')).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('leaves the cached topic unset when a zero-members snapshot carries no topic', async () => {
|
|
154
|
+
const snap = {
|
|
155
|
+
modes: { ...EMPTY_MODES },
|
|
156
|
+
banMasks: [],
|
|
157
|
+
members: [],
|
|
158
|
+
pendingInvites: [],
|
|
159
|
+
createdAt: 0,
|
|
160
|
+
};
|
|
161
|
+
const env = makeEnvWithSnapshot(snap);
|
|
162
|
+
const access = new PassthroughChannelAccess(0, env);
|
|
163
|
+
const chan = access.getOrCreateChannel('#foo');
|
|
164
|
+
|
|
165
|
+
await access.refreshChannel('#foo');
|
|
166
|
+
|
|
167
|
+
expect(chan.topic).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('does not seed a cache entry when the snapshot reports zero members and none exists', async () => {
|
|
171
|
+
const snap = {
|
|
172
|
+
modes: { ...EMPTY_MODES },
|
|
173
|
+
banMasks: [],
|
|
174
|
+
topic: { text: 'x', setter: 's', setAt: 0 },
|
|
175
|
+
members: [],
|
|
176
|
+
pendingInvites: [],
|
|
177
|
+
createdAt: 0,
|
|
178
|
+
};
|
|
179
|
+
const env = makeEnvWithSnapshot(snap);
|
|
180
|
+
const access = new PassthroughChannelAccess(0, env);
|
|
181
|
+
|
|
182
|
+
await access.refreshChannel('#never');
|
|
183
|
+
|
|
184
|
+
// No cache entry was created: getOrCreateChannel now seeds a fresh one
|
|
185
|
+
// with no topic (the zero-members refresh must not seed a new entry).
|
|
186
|
+
const chan = access.getOrCreateChannel('#never');
|
|
187
|
+
expect(chan.topic).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('rebuilds the cached entry from a snapshot with members', async () => {
|
|
191
|
+
const snap = {
|
|
192
|
+
modes: { ...EMPTY_MODES, secret: true },
|
|
193
|
+
banMasks: [],
|
|
194
|
+
topic: { text: 'hi', setter: 's', setAt: 9 },
|
|
195
|
+
members: [{ conn: 'c0', nick: 'alice', op: true, voice: false }],
|
|
196
|
+
pendingInvites: [],
|
|
197
|
+
createdAt: 7,
|
|
198
|
+
};
|
|
199
|
+
const env = makeEnvWithSnapshot(snap);
|
|
200
|
+
const access = new PassthroughChannelAccess(0, env);
|
|
201
|
+
|
|
202
|
+
await access.refreshChannel('#foo');
|
|
203
|
+
|
|
204
|
+
const chan = access.getOrCreateChannel('#foo');
|
|
205
|
+
expect(chan.topic?.text).toBe('hi');
|
|
206
|
+
expect(chan.modes.secret).toBe(true);
|
|
207
|
+
expect(chan.members.size).toBe(1);
|
|
208
|
+
expect(chan.members.get('c0')?.nick).toBe('alice');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('is a no-op when the ChannelDO snapshot is null', async () => {
|
|
212
|
+
const env = makeEnvWithSnapshot(null);
|
|
213
|
+
const access = new PassthroughChannelAccess(0, env);
|
|
214
|
+
const chan = access.getOrCreateChannel('#foo');
|
|
215
|
+
chan.topic = { text: 'keep', setter: 's', setAt: 1 };
|
|
216
|
+
|
|
217
|
+
await access.refreshChannel('#foo');
|
|
218
|
+
|
|
219
|
+
expect(chan.topic?.text).toBe('keep');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -25,6 +25,7 @@ import { hostmaskOf } from '../state/connection.js';
|
|
|
25
25
|
import type { ConnectionState } from '../state/connection.js';
|
|
26
26
|
import type { Ctx } from '../types.js';
|
|
27
27
|
import { emitAccountNotify } from './account-notify.js';
|
|
28
|
+
import { applyAssignedVhost } from './hostserv.js';
|
|
28
29
|
import { deliverUnreadMemos } from './memoserv.js';
|
|
29
30
|
import { nowAwayLine, replayPersistedAway } from './pre-away.js';
|
|
30
31
|
import { seedReadMarkers } from './read-marker.js';
|
|
@@ -35,11 +36,14 @@ import { seedReadMarkers } from './read-marker.js';
|
|
|
35
36
|
*
|
|
36
37
|
* Mutates `state`: sets `state.account`, stamps read-only user mode `r`
|
|
37
38
|
* (`userModes.registered = true`), seeds `lastReadMarkers` from the bound
|
|
38
|
-
* services store, replays the persisted away reason,
|
|
39
|
-
*
|
|
39
|
+
* services store, replays the persisted away reason, re-applies any assigned
|
|
40
|
+
* HostServ vhost (so a reconnecting user does not have to re-run
|
|
41
|
+
* `HostServ ON`), and returns the effects the caller must emit:
|
|
40
42
|
* - `Send` carrying `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`;
|
|
41
43
|
* - an optional `306 RPL_NOWAWAY` when an away reason was replayed;
|
|
42
44
|
* - any queued MemoServ `NOTICE`s delivered from the services store;
|
|
45
|
+
* - an optional `CHGHOST` fanout when a vhost was applied post-JOIN (empty
|
|
46
|
+
* at SASL / PASS-auth time — no channels joined yet);
|
|
43
47
|
* - the `account-notify` `ACCOUNT` broadcasts to cap-enabled peers in
|
|
44
48
|
* shared channels (empty at identify time — no channels joined yet).
|
|
45
49
|
*
|
|
@@ -54,11 +58,17 @@ export function applyAccountSuccess(
|
|
|
54
58
|
// A successful account login is equivalent to NickServ IDENTIFY: stamp
|
|
55
59
|
// read-only user mode `r` so `221`/WHOIS reflect the identified state.
|
|
56
60
|
state.userModes.registered = true;
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
61
|
+
// HostServ vhost: re-apply any assigned vhost so a reconnecting user with
|
|
62
|
+
// a vhost does not have to re-run `HostServ ON` every session. Collected
|
|
63
|
+
// into a local array because the CHGHOST fanout is only non-empty when the
|
|
64
|
+
// connection has joined channels (never at SASL/PASS-auth time).
|
|
65
|
+
const vhostEffects: EffectType[] = [];
|
|
60
66
|
if (ctx.services !== undefined) {
|
|
67
|
+
// IRCv3 draft/read-marker: restore the user's per-channel last-read
|
|
68
|
+
// markers from the persisted store so a reconnect resumes at their last
|
|
69
|
+
// read position. No-op when no services store is bound.
|
|
61
70
|
seedReadMarkers(state, ctx.services, account);
|
|
71
|
+
applyAssignedVhost(state, ctx.services, account, vhostEffects);
|
|
62
72
|
}
|
|
63
73
|
// IRCv3 draft/pre-away: replay the user's persisted away reason onto the
|
|
64
74
|
// fresh connection and emit 306 so the user sees they are still away.
|
|
@@ -78,6 +88,7 @@ export function applyAccountSuccess(
|
|
|
78
88
|
Effect.send(ctx.connId, [loggedInLine(ctx, account), saslSuccessLine(ctx)]),
|
|
79
89
|
...awayEffects,
|
|
80
90
|
...memoEffects,
|
|
91
|
+
...vhostEffects,
|
|
81
92
|
...emitAccountNotify({ conn: state, account }),
|
|
82
93
|
];
|
|
83
94
|
}
|
|
@@ -101,18 +112,31 @@ export function parsePassAccountAttempt(
|
|
|
101
112
|
/**
|
|
102
113
|
* Attempts PASS-based account authentication at registration completion.
|
|
103
114
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
|
|
115
|
+
* Thin wrapper around {@link attemptPassAccountAuth} that reads the stashed
|
|
116
|
+
* `state.passAttempt`. See {@link attemptPassAccountAuth} for the full
|
|
117
|
+
* outcome matrix.
|
|
118
|
+
*/
|
|
119
|
+
export function passBasedAccountAuth(state: ConnectionState, ctx: Ctx): EffectType[] {
|
|
120
|
+
return attemptPassAccountAuth(state, ctx, state.passAttempt);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Shared verify + {@link applyAccountSuccess} pipeline for a raw
|
|
125
|
+
* `<nick>:<password>` attempt string.
|
|
126
|
+
*
|
|
127
|
+
* Used by both {@link passBasedAccountAuth} (at registration completion,
|
|
128
|
+
* reading `state.passAttempt`) and the post-registration late-PASS login
|
|
129
|
+
* in `passReducer` (reading the `PASS` param directly), so the two entry
|
|
130
|
+
* points share one verify+success path and never diverge.
|
|
131
|
+
*
|
|
132
|
+
* Returns the account-login success effects (`900`/`903`/`+r`/read-marker
|
|
133
|
+
* seeding/away replay/memo delivery/`account-notify`), or `[]` for every
|
|
134
|
+
* other outcome:
|
|
108
135
|
* - already identified (`state.account` set, e.g. via SASL) → no-op;
|
|
109
|
-
* - no `<nick>:<password>` form (bare value) →
|
|
110
|
-
*
|
|
111
|
-
* -
|
|
112
|
-
*
|
|
113
|
-
* - no {@link AccountStore} bound → the `<nick>:<password>` form is
|
|
114
|
-
* ignored (collapses to the server-password gate);
|
|
115
|
-
* - verify failure (unknown nick or wrong password) → no account effects.
|
|
136
|
+
* - no `<nick>:<password>` form (bare value or undefined) → no-op;
|
|
137
|
+
* - payload nick does not match `state.nick` (when set) → no-op;
|
|
138
|
+
* - no {@link AccountStore} bound → no-op;
|
|
139
|
+
* - verify failure (unknown nick or wrong password) → no-op.
|
|
116
140
|
*
|
|
117
141
|
* A failed verify is followed by a verify against a fixed dummy entry so
|
|
118
142
|
* the failure path performs the same AccountStore work whether the payload
|
|
@@ -120,9 +144,13 @@ export function parsePassAccountAttempt(
|
|
|
120
144
|
* (no numerics, no disconnect, no state change) is identical for both
|
|
121
145
|
* cases, giving no information to a remote attacker.
|
|
122
146
|
*/
|
|
123
|
-
export function
|
|
147
|
+
export function attemptPassAccountAuth(
|
|
148
|
+
state: ConnectionState,
|
|
149
|
+
ctx: Ctx,
|
|
150
|
+
attempt: string | undefined,
|
|
151
|
+
): EffectType[] {
|
|
124
152
|
if (state.account !== undefined) return [];
|
|
125
|
-
const parsed = parsePassAccountAttempt(
|
|
153
|
+
const parsed = parsePassAccountAttempt(attempt);
|
|
126
154
|
if (parsed === null) return [];
|
|
127
155
|
if (state.nick !== undefined && state.nick !== parsed.nick) return [];
|
|
128
156
|
const store = ctx.accounts;
|
|
@@ -47,8 +47,8 @@ import { caseFold } from '../case-fold.js';
|
|
|
47
47
|
import { Effect } from '../effects.js';
|
|
48
48
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
49
49
|
import type { ChannelLevelOp, ServicesStore } from '../ports.js';
|
|
50
|
-
import type { ChannelDelta } from '../state/channel.js';
|
|
51
|
-
import type { ConnectionState } from '../state/connection.js';
|
|
50
|
+
import type { ChannelDelta, Roster } from '../state/channel.js';
|
|
51
|
+
import type { ConnId, ConnectionState } from '../state/connection.js';
|
|
52
52
|
import type { Ctx, Reducer } from '../types.js';
|
|
53
53
|
|
|
54
54
|
/** Canonical ChanServ pseudo-client nick. */
|
|
@@ -151,6 +151,32 @@ export const chanservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
151
151
|
return handleAccess(state, args, ctx, effects);
|
|
152
152
|
case 'LEVELS':
|
|
153
153
|
return handleLevels(state, args, ctx, effects);
|
|
154
|
+
case 'OP':
|
|
155
|
+
return handlePrefixCommand(state, args, ctx, effects, { verb: 'OP', field: 'op', set: true });
|
|
156
|
+
case 'DEOP':
|
|
157
|
+
return handlePrefixCommand(state, args, ctx, effects, {
|
|
158
|
+
verb: 'DEOP',
|
|
159
|
+
field: 'op',
|
|
160
|
+
set: false,
|
|
161
|
+
});
|
|
162
|
+
case 'VOICE':
|
|
163
|
+
return handlePrefixCommand(state, args, ctx, effects, {
|
|
164
|
+
verb: 'VOICE',
|
|
165
|
+
field: 'voice',
|
|
166
|
+
set: true,
|
|
167
|
+
});
|
|
168
|
+
case 'DEVOICE':
|
|
169
|
+
return handlePrefixCommand(state, args, ctx, effects, {
|
|
170
|
+
verb: 'DEVOICE',
|
|
171
|
+
field: 'voice',
|
|
172
|
+
set: false,
|
|
173
|
+
});
|
|
174
|
+
case 'KICK':
|
|
175
|
+
return handleKick(state, args, ctx, effects);
|
|
176
|
+
case 'BAN':
|
|
177
|
+
return handleBanMask(state, args, ctx, effects, { verb: 'BAN', add: true });
|
|
178
|
+
case 'UNBAN':
|
|
179
|
+
return handleBanMask(state, args, ctx, effects, { verb: 'UNBAN', add: false });
|
|
154
180
|
default:
|
|
155
181
|
if (sub !== undefined && SHORTHAND_VERBS.has(sub)) {
|
|
156
182
|
return handleShorthand(state, sub, args, ctx, effects);
|
|
@@ -736,6 +762,264 @@ function isKnownLevelOp(op: string): op is ChannelLevelOp {
|
|
|
736
762
|
return LEVEL_OPS.includes(op as ChannelLevelOp);
|
|
737
763
|
}
|
|
738
764
|
|
|
765
|
+
// ============================================================================
|
|
766
|
+
// OP / DEOP / VOICE / DEVOICE / KICK / BAN / UNBAN
|
|
767
|
+
// ============================================================================
|
|
768
|
+
//
|
|
769
|
+
// These six commands emit `ApplyChannelDelta` effects the actor layer applies
|
|
770
|
+
// to the authoritative `ChannelState`. They operate on `ConnectionState`
|
|
771
|
+
// (the ChanServ reducer's authority), so they cannot read or mutate the
|
|
772
|
+
// roster directly — prefix/kick deltas carry a `targetNick` hint the actor
|
|
773
|
+
// resolves against the channel roster at apply time. See
|
|
774
|
+
// `resolveMembershipTarget` below and the {@link MembershipDelta.targetNick}
|
|
775
|
+
// docstring in `state/channel.ts` for the resolution + patch contract.
|
|
776
|
+
|
|
777
|
+
/** Roster field touched by a prefix command. */
|
|
778
|
+
type PrefixField = 'op' | 'voice';
|
|
779
|
+
|
|
780
|
+
interface PrefixCommandOpts {
|
|
781
|
+
verb: 'OP' | 'DEOP' | 'VOICE' | 'DEVOICE';
|
|
782
|
+
field: PrefixField;
|
|
783
|
+
set: boolean;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Authorisation gate for the prefix / kick / ban commands. Returns the bound
|
|
788
|
+
* {@link ServicesStore} on success; pushes the matching notice into `effects`
|
|
789
|
+
* and returns `null` on failure.
|
|
790
|
+
*
|
|
791
|
+
* - Unidentified caller → "You must identify …"
|
|
792
|
+
* - Unregistered channel → "Channel <chan> is not registered."
|
|
793
|
+
* - Non-founder caller:
|
|
794
|
+
* - `founderOnly: true` → "Permission denied."
|
|
795
|
+
* - `founderOnly: false` → allowed when caller's access level meets the
|
|
796
|
+
* founder-configured `AUTOOP` threshold, otherwise "Permission denied."
|
|
797
|
+
*
|
|
798
|
+
* Privilege model: OP/DEOP/VOICE/DEVOICE accept the founder OR any
|
|
799
|
+
* AUTOOP-level access entry; KICK/BAN/UNBAN are founder-only.
|
|
800
|
+
*/
|
|
801
|
+
function requirePrivilegedChanServ(
|
|
802
|
+
state: ConnectionState,
|
|
803
|
+
channel: string,
|
|
804
|
+
ctx: Ctx,
|
|
805
|
+
effects: EffectType[],
|
|
806
|
+
opts: { founderOnly: boolean },
|
|
807
|
+
): ServicesStore | null {
|
|
808
|
+
const services = ctx.services as ServicesStore;
|
|
809
|
+
if (state.account === undefined) {
|
|
810
|
+
effects.push(notice(state, 'You must identify before changing channel settings.'));
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
const founder = services.getChannelFounder(channel);
|
|
814
|
+
if (founder === undefined) {
|
|
815
|
+
effects.push(notice(state, `Channel ${channel} is not registered.`));
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
if (caseFold('rfc1459', founder) === caseFold('rfc1459', state.account)) {
|
|
819
|
+
return services;
|
|
820
|
+
}
|
|
821
|
+
if (!opts.founderOnly) {
|
|
822
|
+
const level = services.getChannelAccess(channel, state.account);
|
|
823
|
+
const autoOp = services.getChannelLevel(channel, 'AUTOOP') ?? DEFAULT_CHANNEL_LEVELS.AUTOOP;
|
|
824
|
+
if (level >= autoOp) return services;
|
|
825
|
+
}
|
|
826
|
+
effects.push(notice(state, 'Permission denied.'));
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Handles `OP|DEOP|VOICE|DEVOICE <#channel> <nick>`.
|
|
832
|
+
*
|
|
833
|
+
* Emits a `:ChanServ!ChanServ@services MODE <chan> ±<o|v> <nick>` broadcast
|
|
834
|
+
* (mirroring the ChanServ prefix grant broadcast in `join.ts`) and an
|
|
835
|
+
* `ApplyChannelDelta({ memberships: [{ type: 'add', targetNick, op|voice }] })`
|
|
836
|
+
* carrying a `targetNick` hint. The actor layer resolves the nick against the
|
|
837
|
+
* channel roster at apply time:
|
|
838
|
+
* - resolved → the present `op`/`voice` field is *patched* onto the
|
|
839
|
+
* existing entry (so op-ing a voiced user does not clear voice);
|
|
840
|
+
* - absent → the delta is a no-op and ChanServ NOTICEs the caller.
|
|
841
|
+
*
|
|
842
|
+
* `conn: ''` is a placeholder overwritten by the actor on resolution; the
|
|
843
|
+
* {@link MembershipDelta.targetNick} docstring describes the patch contract.
|
|
844
|
+
*/
|
|
845
|
+
function handlePrefixCommand(
|
|
846
|
+
state: ConnectionState,
|
|
847
|
+
args: string[],
|
|
848
|
+
ctx: Ctx,
|
|
849
|
+
effects: EffectType[],
|
|
850
|
+
opts: PrefixCommandOpts,
|
|
851
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
852
|
+
const channel = args[0];
|
|
853
|
+
const targetNick = args[1];
|
|
854
|
+
if (
|
|
855
|
+
channel === undefined ||
|
|
856
|
+
targetNick === undefined ||
|
|
857
|
+
!isValidChannelName(channel, ctx.serverConfig.channelLen)
|
|
858
|
+
) {
|
|
859
|
+
effects.push(notice(state, `Syntax: ${opts.verb} <#channel> <nick>.`));
|
|
860
|
+
return { state, effects };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
|
|
864
|
+
founderOnly: false,
|
|
865
|
+
});
|
|
866
|
+
if (services === null) return { state, effects };
|
|
867
|
+
|
|
868
|
+
const letter = opts.field === 'op' ? 'o' : 'v';
|
|
869
|
+
const sign = opts.set ? '+' : '-';
|
|
870
|
+
const membership = {
|
|
871
|
+
type: 'add' as const,
|
|
872
|
+
conn: '',
|
|
873
|
+
nick: targetNick,
|
|
874
|
+
targetNick,
|
|
875
|
+
...(opts.field === 'op' ? { op: opts.set } : { voice: opts.set }),
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
effects.push(Effect.applyChannelDelta(channel, { memberships: [membership] }));
|
|
879
|
+
effects.push(
|
|
880
|
+
Effect.broadcast(channel, [
|
|
881
|
+
{ text: `:${CHANSERV_HOSTMASK} MODE ${channel} ${sign}${letter} ${targetNick}` },
|
|
882
|
+
]),
|
|
883
|
+
);
|
|
884
|
+
effects.push(notice(state, `Set mode ${sign}${letter} on ${targetNick} on ${channel}.`));
|
|
885
|
+
return { state, effects };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Handles `KICK <#channel> <nick> [:<reason>]`.
|
|
890
|
+
*
|
|
891
|
+
* Founder-only. Emits a `:ChanServ!ChanServ@services KICK <chan> <nick>
|
|
892
|
+
* [:<reason>]` broadcast and an `ApplyChannelDelta({ memberships: [{ type:
|
|
893
|
+
* 'remove', targetNick }] })`, mirroring `kick.ts:134-150`. The actor layer
|
|
894
|
+
* resolves `targetNick` against the roster and either removes the matching
|
|
895
|
+
* entry or no-ops with a NOTICE to the caller when the nick is absent.
|
|
896
|
+
*
|
|
897
|
+
* `conn: ''` is a placeholder overwritten by the actor on resolution.
|
|
898
|
+
*/
|
|
899
|
+
function handleKick(
|
|
900
|
+
state: ConnectionState,
|
|
901
|
+
args: string[],
|
|
902
|
+
ctx: Ctx,
|
|
903
|
+
effects: EffectType[],
|
|
904
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
905
|
+
const channel = args[0];
|
|
906
|
+
const targetNick = args[1];
|
|
907
|
+
if (
|
|
908
|
+
channel === undefined ||
|
|
909
|
+
targetNick === undefined ||
|
|
910
|
+
!isValidChannelName(channel, ctx.serverConfig.channelLen)
|
|
911
|
+
) {
|
|
912
|
+
effects.push(notice(state, 'Syntax: KICK <#channel> <nick> [:<reason>].'));
|
|
913
|
+
return { state, effects };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
|
|
917
|
+
founderOnly: true,
|
|
918
|
+
});
|
|
919
|
+
if (services === null) return { state, effects };
|
|
920
|
+
|
|
921
|
+
// Reason is everything after the channel + nick, with an optional leading
|
|
922
|
+
// `:` (RFC 2812 trailing form). The trailing splitter already removed the
|
|
923
|
+
// `:` prefix when present in a single token; rebuild from the original
|
|
924
|
+
// args to preserve spaces.
|
|
925
|
+
const reason = args.slice(2).join(' ').replace(/^:/u, '');
|
|
926
|
+
|
|
927
|
+
const line =
|
|
928
|
+
reason.length > 0
|
|
929
|
+
? { text: `:${CHANSERV_HOSTMASK} KICK ${channel} ${targetNick} :${reason}` }
|
|
930
|
+
: { text: `:${CHANSERV_HOSTMASK} KICK ${channel} ${targetNick}` };
|
|
931
|
+
|
|
932
|
+
effects.push(Effect.broadcast(channel, [line]));
|
|
933
|
+
effects.push(
|
|
934
|
+
Effect.applyChannelDelta(channel, {
|
|
935
|
+
memberships: [{ type: 'remove', conn: '', targetNick }],
|
|
936
|
+
}),
|
|
937
|
+
);
|
|
938
|
+
effects.push(notice(state, `Kicked ${targetNick} from ${channel}.`));
|
|
939
|
+
return { state, effects };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
interface BanCommandOpts {
|
|
943
|
+
verb: 'BAN' | 'UNBAN';
|
|
944
|
+
add: boolean;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Handles `BAN <#channel> <mask>` and `UNBAN <#channel> <mask>`.
|
|
949
|
+
*
|
|
950
|
+
* Founder-only. Emits an `ApplyChannelDelta({ banMaskChanges: [{ type, mask }] })`
|
|
951
|
+
* mirroring the ban-mask handling in `mode.ts`, plus a
|
|
952
|
+
* `:ChanServ!ChanServ@services MODE <chan> ±b <mask>` broadcast so clients
|
|
953
|
+
* update their ban-list views. The mask persists in `ChannelState.banMasks`
|
|
954
|
+
* via the actor's delta application.
|
|
955
|
+
*
|
|
956
|
+
* Kick-on-ban (Atheme's default) is intentionally NOT performed: ChanServ
|
|
957
|
+
* cannot enumerate the roster from `ConnectionState`, and a mask-based
|
|
958
|
+
* kick requires an actor-side enforcement pass. Tracked as a follow-up.
|
|
959
|
+
*/
|
|
960
|
+
function handleBanMask(
|
|
961
|
+
state: ConnectionState,
|
|
962
|
+
args: string[],
|
|
963
|
+
ctx: Ctx,
|
|
964
|
+
effects: EffectType[],
|
|
965
|
+
opts: BanCommandOpts,
|
|
966
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
967
|
+
const channel = args[0];
|
|
968
|
+
const mask = args[1];
|
|
969
|
+
if (
|
|
970
|
+
channel === undefined ||
|
|
971
|
+
mask === undefined ||
|
|
972
|
+
!isValidChannelName(channel, ctx.serverConfig.channelLen)
|
|
973
|
+
) {
|
|
974
|
+
effects.push(notice(state, `Syntax: ${opts.verb} <#channel> <mask>.`));
|
|
975
|
+
return { state, effects };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
|
|
979
|
+
founderOnly: true,
|
|
980
|
+
});
|
|
981
|
+
if (services === null) return { state, effects };
|
|
982
|
+
|
|
983
|
+
const sign = opts.add ? '+' : '-';
|
|
984
|
+
const changeType = opts.add ? 'add' : 'remove';
|
|
985
|
+
|
|
986
|
+
effects.push(
|
|
987
|
+
Effect.applyChannelDelta(channel, {
|
|
988
|
+
banMaskChanges: [{ type: changeType, mask }],
|
|
989
|
+
}),
|
|
990
|
+
);
|
|
991
|
+
effects.push(
|
|
992
|
+
Effect.broadcast(channel, [{ text: `:${CHANSERV_HOSTMASK} MODE ${channel} ${sign}b ${mask}` }]),
|
|
993
|
+
);
|
|
994
|
+
const confirmation = opts.add
|
|
995
|
+
? `Set ban ${mask} on ${channel}.`
|
|
996
|
+
: `Removed ban ${mask} from ${channel}.`;
|
|
997
|
+
effects.push(notice(state, confirmation));
|
|
998
|
+
return { state, effects };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Resolves a target nick (case-insensitive under `CASEMAPPING=rfc1459`) to the
|
|
1003
|
+
* connection id of the matching roster entry, or `undefined` when the nick is
|
|
1004
|
+
* not on the channel.
|
|
1005
|
+
*
|
|
1006
|
+
* The actor layer calls this when applying an `ApplyChannelDelta` whose
|
|
1007
|
+
* membership carries a {@link MembershipDelta.targetNick} hint (emitted by
|
|
1008
|
+
* ChanServ OP/DEOP/VOICE/DEVOICE/KICK). A return value of `undefined` signals
|
|
1009
|
+
* "target not on channel" → the actor treats the delta as a no-op and emits a
|
|
1010
|
+
* ChanServ NOTICE back to the caller.
|
|
1011
|
+
*
|
|
1012
|
+
* Exposed so the dispatch layer (irc-server) and the ChanServ tests share a
|
|
1013
|
+
* single resolution function, keeping the nick-folding rules in one place.
|
|
1014
|
+
*/
|
|
1015
|
+
export function resolveMembershipTarget(members: Roster, nick: string): ConnId | undefined {
|
|
1016
|
+
const targetLower = caseFold('rfc1459', nick);
|
|
1017
|
+
for (const entry of members.values()) {
|
|
1018
|
+
if (caseFold('rfc1459', entry.nick) === targetLower) return entry.conn;
|
|
1019
|
+
}
|
|
1020
|
+
return undefined;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
739
1023
|
/**
|
|
740
1024
|
* Capitalises the first character of `value` (display helper). Callers
|
|
741
1025
|
* pre-validate that `value` is non-empty (the token splitter drops empty
|
|
@@ -759,14 +1043,14 @@ function notice(state: ConnectionState, text: string): EffectType {
|
|
|
759
1043
|
function helpNotice(state: ConnectionState): EffectType {
|
|
760
1044
|
return notice(
|
|
761
1045
|
state,
|
|
762
|
-
'Available commands: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP',
|
|
1046
|
+
'Available commands: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP, OP, DEOP, VOICE, DEVOICE, KICK, BAN, UNBAN',
|
|
763
1047
|
);
|
|
764
1048
|
}
|
|
765
1049
|
|
|
766
1050
|
function unknownNotice(state: ConnectionState): EffectType {
|
|
767
1051
|
return notice(
|
|
768
1052
|
state,
|
|
769
|
-
'Unknown command. Available: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP',
|
|
1053
|
+
'Unknown command. Available: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP, OP, DEOP, VOICE, DEVOICE, KICK, BAN, UNBAN',
|
|
770
1054
|
);
|
|
771
1055
|
}
|
|
772
1056
|
|