serverless-ircd 0.10.0 → 0.11.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 +28 -0
- package/.github/workflows/deploy-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +17 -4
- package/apps/cf-worker/package.json +2 -2
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +86 -9
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- 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 +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +40 -1
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +393 -20
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +8 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +395 -36
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +544 -7
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/scripts/package.json +1 -1
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +17 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
|
@@ -15,10 +15,14 @@
|
|
|
15
15
|
* peers see the revert. Requires identification.
|
|
16
16
|
* - `REQUEST <vhost>` — proposes a vhost for the account. Behaviour
|
|
17
17
|
* depends on {@link ServerConfig.hostservAutoApproveVhosts}: when the
|
|
18
|
-
* knob is
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* knob is off (the default) the request is queued for oper review via
|
|
19
|
+
* {@link ServicesStore.requestVhost} and the requester is told it is
|
|
20
|
+
* pending. When the knob is on (deprecated legacy auto-approve) the
|
|
21
|
+
* vhost is recorded against the account immediately, but only after
|
|
22
|
+
* clearing a built-in denylist (reserved labels such as `admin` /
|
|
23
|
+
* `staff` at any position, plus this server's own hostname) and
|
|
24
|
+
* matching the mandatory {@link ServerConfig.hostservAutoApproveAllowlist}
|
|
25
|
+
* (exact or `*.suffix` wildcard entries). Requires identification.
|
|
22
26
|
* - `SET <account> <vhost>` — oper-only. Records (or, with the literal
|
|
23
27
|
* `*` argument, clears) the vhost for `account` directly without
|
|
24
28
|
* going through REQUEST. The target account must be a registered nick.
|
|
@@ -67,6 +71,59 @@ function isValidVhost(vhost: string): boolean {
|
|
|
67
71
|
return VHOST_RE.test(vhost);
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Labels a user may never self-assign via auto-approved REQUEST, at any
|
|
76
|
+
* dot-separated position of the vhost (case-insensitive — DNS labels are).
|
|
77
|
+
* These names impersonate staff, operators, or off-network services, so the
|
|
78
|
+
* denylist is enforced even when the operator explicitly allowlists them.
|
|
79
|
+
*/
|
|
80
|
+
const RESERVED_VHOST_LABELS = new Set(['admin', 'oper', 'staff', 'irc', 'www', 'root', 'security']);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Built-in denylist for auto-approved vhosts. Rejects any vhost containing
|
|
84
|
+
* a {@link RESERVED_VHOST_LABELS} label, plus the deployment's own hostname
|
|
85
|
+
* (exactly or as a suffix) so a user cannot cloak as
|
|
86
|
+
* `<anything>.<this-server>` and phish off its identity. Oper-vetted paths
|
|
87
|
+
* (SET, queue-mode APPROVE) intentionally bypass this — opers are trusted.
|
|
88
|
+
*/
|
|
89
|
+
function isDeniedVhost(vhost: string, serverName: string): boolean {
|
|
90
|
+
const lower = vhost.toLowerCase();
|
|
91
|
+
for (const label of lower.split('.')) {
|
|
92
|
+
if (RESERVED_VHOST_LABELS.has(label)) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const server = serverName.toLowerCase();
|
|
97
|
+
return lower === server || lower.endsWith(`.${server}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Allowlist check for auto-approved vhosts. An entry matches when it is
|
|
102
|
+
* exactly the vhost (`users.example.net`) or a wildcard suffix
|
|
103
|
+
* (`*.users.example.com` matches `a.users.example.com` and any depth
|
|
104
|
+
* beneath it, but NOT the bare suffix). Matching is case-insensitive
|
|
105
|
+
* (DNS semantics). A missing allowlist matches nothing — the reducer
|
|
106
|
+
* fails closed; the config layer separately refuses to boot auto-approve
|
|
107
|
+
* mode without a non-empty allowlist.
|
|
108
|
+
*/
|
|
109
|
+
function isAllowlistedVhost(vhost: string, allowlist: ReadonlyArray<string> | undefined): boolean {
|
|
110
|
+
if (allowlist === undefined) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const lower = vhost.toLowerCase();
|
|
114
|
+
for (const entry of allowlist) {
|
|
115
|
+
const normalized = entry.toLowerCase();
|
|
116
|
+
if (normalized.startsWith('*.')) {
|
|
117
|
+
if (lower.endsWith(normalized.slice(1))) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
} else if (lower === normalized) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
70
127
|
/**
|
|
71
128
|
* Reducer for `PRIVMSG HostServ :<subcommand> <args…>`.
|
|
72
129
|
* Authority: the invoking connection's {@link ConnectionState}.
|
|
@@ -185,13 +242,20 @@ function handleOff(
|
|
|
185
242
|
/**
|
|
186
243
|
* Handles `REQUEST <vhost>`. Behaviour is selected by
|
|
187
244
|
* {@link ServerConfig.hostservAutoApproveVhosts}:
|
|
188
|
-
* - `true` (auto-approve) — the vhost is recorded against the account
|
|
189
|
-
* immediately (the path that shipped before the oper queue landed). The
|
|
190
|
-
* user is notified that the vhost has been requested and approved.
|
|
191
245
|
* - `false` or omitted (the default — queue mode) — the request is
|
|
192
246
|
* queued for oper review via {@link ServicesStore.requestVhost}; the
|
|
193
247
|
* vhost is NOT activated and no `CHGHOST` is emitted. The user is
|
|
194
|
-
* notified that the request is pending oper approval.
|
|
248
|
+
* notified that the request is pending oper approval. Oper-vetted
|
|
249
|
+
* vhosts are exempt from the denylist/allowlist below.
|
|
250
|
+
* - `true` (auto-approve, deprecated) — the vhost is recorded against
|
|
251
|
+
* the account immediately, but only if it survives hardening: the
|
|
252
|
+
* built-in denylist ({@link isDeniedVhost}: reserved labels at any
|
|
253
|
+
* position + this deployment's own hostname) rejects impersonation
|
|
254
|
+
* vectors even when the operator allowlisted them, and the mandatory
|
|
255
|
+
* allowlist ({@link isAllowlistedVhost}) must match the vhost exactly
|
|
256
|
+
* or via a `*.suffix` wildcard entry. Config parsing refuses to boot
|
|
257
|
+
* auto-approve mode without a non-empty allowlist; the reducer fails
|
|
258
|
+
* closed regardless.
|
|
195
259
|
*
|
|
196
260
|
* Identified-only — otherwise a guest could plant a vhost (or a pending
|
|
197
261
|
* request) against a registered account they do not own.
|
|
@@ -225,6 +289,18 @@ function handleRequest(
|
|
|
225
289
|
return { state, effects };
|
|
226
290
|
}
|
|
227
291
|
|
|
292
|
+
if (isDeniedVhost(vhost, ctx.serverName)) {
|
|
293
|
+
effects.push(
|
|
294
|
+
notice(state, `Vhost ${vhost} is not permitted (reserved label or this server's hostname).`),
|
|
295
|
+
);
|
|
296
|
+
return { state, effects };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!isAllowlistedVhost(vhost, ctx.serverConfig.hostservAutoApproveAllowlist)) {
|
|
300
|
+
effects.push(notice(state, `Vhost ${vhost} is not in the auto-approve allowlist.`));
|
|
301
|
+
return { state, effects };
|
|
302
|
+
}
|
|
303
|
+
|
|
228
304
|
services.setVhost(state.account, vhost);
|
|
229
305
|
effects.push(notice(state, `Vhost ${vhost} has been requested and approved.`));
|
|
230
306
|
return { state, effects };
|
|
@@ -15,7 +15,7 @@ export {
|
|
|
15
15
|
userReducer,
|
|
16
16
|
} from './registration.js';
|
|
17
17
|
export { quitReducer } from './quit.js';
|
|
18
|
-
export { handleJoinZero,
|
|
18
|
+
export { handleJoinZero, joinReducer } from './join.js';
|
|
19
19
|
export { partReducer } from './part.js';
|
|
20
20
|
export { motdReducer } from './motd.js';
|
|
21
21
|
export { operReducer, matchOperCred } from './oper.js';
|
|
@@ -61,6 +61,7 @@ export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
|
|
|
61
61
|
export { markreadChannelReducer } from './markread.js';
|
|
62
62
|
export {
|
|
63
63
|
DEFAULT_MULTILINE_MAX_BYTES,
|
|
64
|
+
DEFAULT_MULTILINE_MAX_ENTRIES,
|
|
64
65
|
MULTILINE_CAP,
|
|
65
66
|
type FinalizeMultilineResult,
|
|
66
67
|
type MultilineEntry,
|
|
@@ -23,19 +23,13 @@
|
|
|
23
23
|
import { caseFold } from '../case-fold.js';
|
|
24
24
|
import { Effect } from '../effects.js';
|
|
25
25
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
26
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
26
27
|
import { Numerics } from '../protocol/numerics.js';
|
|
27
28
|
import type { ChannelState } from '../state/channel.js';
|
|
28
29
|
import { hostmaskOf } from '../state/connection.js';
|
|
29
30
|
import type { ConnectionState } from '../state/connection.js';
|
|
30
31
|
import type { Ctx, Reducer } from '../types.js';
|
|
31
32
|
|
|
32
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
33
|
-
|
|
34
|
-
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
35
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
36
|
-
return CHANNEL_NAME_RE.test(name);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
33
|
/** Returns the caller's hostmask, falling back to nick then `?`. */
|
|
40
34
|
function callerHostmask(conn: ConnectionState): string {
|
|
41
35
|
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
@@ -20,6 +20,7 @@ import { Effect } from '../effects.js';
|
|
|
20
20
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
21
21
|
import type { PersistedTopic, StoredMessage } from '../ports.js';
|
|
22
22
|
import { wrapBatch } from '../protocol/batch.js';
|
|
23
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
23
24
|
import { Numerics } from '../protocol/numerics.js';
|
|
24
25
|
import { type ChanName, type ChannelState, type ChannelTopic, prefixOf } from '../state/channel.js';
|
|
25
26
|
import { hostmaskOf } from '../state/connection.js';
|
|
@@ -29,22 +30,6 @@ import { CHANSERV_HOSTMASK, DEFAULT_CHANNEL_LEVELS } from './chanserv.js';
|
|
|
29
30
|
import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
|
|
30
31
|
import { topicNumerics } from './topic.js';
|
|
31
32
|
|
|
32
|
-
/**
|
|
33
|
-
* RFC 1459/2812 channel-name grammar. The first character must be a channel
|
|
34
|
-
* prefix (`#` or `&`); the rest may be any printable char except space, comma,
|
|
35
|
-
* or colon (which all carry protocol meaning).
|
|
36
|
-
*/
|
|
37
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Returns true iff `name` matches the IRC channel-name grammar and is within
|
|
41
|
-
* the configured length cap.
|
|
42
|
-
*/
|
|
43
|
-
export function isValidChannelName(name: string, maxLen: number): boolean {
|
|
44
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
45
|
-
return CHANNEL_NAME_RE.test(name);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
33
|
/**
|
|
49
34
|
* Returns the 353 NAMES sigil for a channel given its modes:
|
|
50
35
|
* `@` for `+p` (private), `*` for `+s` (secret), `=` otherwise (public).
|
|
@@ -16,20 +16,13 @@
|
|
|
16
16
|
import { caseFold } from '../case-fold.js';
|
|
17
17
|
import { Effect } from '../effects.js';
|
|
18
18
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
19
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
19
20
|
import { Numerics } from '../protocol/numerics.js';
|
|
20
21
|
import type { ChannelState } from '../state/channel.js';
|
|
21
22
|
import { hostmaskOf } from '../state/connection.js';
|
|
22
23
|
import type { ConnectionState } from '../state/connection.js';
|
|
23
24
|
import type { Ctx, Reducer } from '../types.js';
|
|
24
25
|
|
|
25
|
-
/** Channel-name validation mirrors JOIN: same grammar, same length cap. */
|
|
26
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
27
|
-
|
|
28
|
-
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
29
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
30
|
-
return CHANNEL_NAME_RE.test(name);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
26
|
/**
|
|
34
27
|
* Formats a single numeric error line addressed to the connection's current
|
|
35
28
|
* nick (or `*` when unregistered). Optional `middle` is emitted before the
|
|
@@ -29,18 +29,11 @@
|
|
|
29
29
|
import { caseFold } from '../case-fold.js';
|
|
30
30
|
import { Effect } from '../effects.js';
|
|
31
31
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
32
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
32
33
|
import { Numerics } from '../protocol/numerics.js';
|
|
33
34
|
import type { ChanSnapshot } from '../state/channel.js';
|
|
34
35
|
import type { Ctx, Reducer } from '../types.js';
|
|
35
36
|
|
|
36
|
-
/** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
|
|
37
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
38
|
-
|
|
39
|
-
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
40
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
41
|
-
return CHANNEL_NAME_RE.test(name);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
37
|
/**
|
|
45
38
|
* Returns true iff the requester is permitted to see `chan` in LIST output.
|
|
46
39
|
* Secret channels require membership; everything else is public.
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { caseFold } from '../case-fold.js';
|
|
32
32
|
import { Effect } from '../effects.js';
|
|
33
33
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
34
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
34
35
|
import { Numerics } from '../protocol/numerics.js';
|
|
35
36
|
import type { BooleanChannelMode, ChannelState } from '../state/channel.js';
|
|
36
37
|
import type {
|
|
@@ -45,14 +46,6 @@ import type { ConnectionState } from '../state/connection.js';
|
|
|
45
46
|
import type { Ctx, Reducer } from '../types.js';
|
|
46
47
|
import { positiveMlockLetters } from './chanserv.js';
|
|
47
48
|
|
|
48
|
-
/** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
|
|
49
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
50
|
-
|
|
51
|
-
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
52
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
53
|
-
return CHANNEL_NAME_RE.test(name);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
49
|
/** Returns the caller's hostmask, falling back to nick then `?`. */
|
|
57
50
|
function callerHostmask(conn: ConnectionState): string {
|
|
58
51
|
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
@@ -26,11 +26,13 @@
|
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
28
|
DEFAULT_MULTILINE_MAX_BYTES,
|
|
29
|
+
DEFAULT_MULTILINE_MAX_ENTRIES,
|
|
29
30
|
MULTILINE_CAP_NAME,
|
|
30
31
|
multilineCapValue,
|
|
31
32
|
} from '../caps/capabilities.js';
|
|
32
33
|
import { Effect } from '../effects.js';
|
|
33
34
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
35
|
+
import { byteLength } from '../protocol/bytes.js';
|
|
34
36
|
import { hostmaskOf } from '../state/connection.js';
|
|
35
37
|
import type { Ctx } from '../types.js';
|
|
36
38
|
|
|
@@ -41,7 +43,7 @@ import type { Ctx } from '../types.js';
|
|
|
41
43
|
*/
|
|
42
44
|
export const MULTILINE_CAP = MULTILINE_CAP_NAME;
|
|
43
45
|
|
|
44
|
-
export { DEFAULT_MULTILINE_MAX_BYTES, multilineCapValue };
|
|
46
|
+
export { DEFAULT_MULTILINE_MAX_BYTES, DEFAULT_MULTILINE_MAX_ENTRIES, multilineCapValue };
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
49
|
* One accumulated inner line of a multi-line batch. The actor collects these
|
|
@@ -79,21 +81,13 @@ export function multilineByteLength(entries: readonly MultilineEntry[]): number
|
|
|
79
81
|
if (entries.length === 0) return 0;
|
|
80
82
|
let total = 0;
|
|
81
83
|
for (const entry of entries) {
|
|
82
|
-
total +=
|
|
84
|
+
total += byteLength(entry.text);
|
|
83
85
|
}
|
|
84
86
|
// `(n - 1)` separators join `n` entries.
|
|
85
87
|
total += entries.length - 1;
|
|
86
88
|
return total;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
/** Shared `TextEncoder` for UTF-8 byte-length accounting (Web standard). */
|
|
90
|
-
const TEXT_ENCODER = new TextEncoder();
|
|
91
|
-
|
|
92
|
-
/** Returns the UTF-8 byte length of `s` (the wire encoding for IRC content). */
|
|
93
|
-
function utf8ByteLength(s: string): number {
|
|
94
|
-
return TEXT_ENCODER.encode(s).length;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
91
|
/** Resolves the sender's hostmask, falling back to nick then `?`. */
|
|
98
92
|
function senderHostmask(conn: Ctx['connection']): string {
|
|
99
93
|
return hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
@@ -31,17 +31,22 @@
|
|
|
31
31
|
import { Effect } from '../effects.js';
|
|
32
32
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
33
33
|
import { wrapBatch } from '../protocol/batch.js';
|
|
34
|
+
import { byteLength } from '../protocol/bytes.js';
|
|
35
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
34
36
|
import { Numerics } from '../protocol/numerics.js';
|
|
35
37
|
import type { ChannelState, RosterEntry } from '../state/channel.js';
|
|
36
38
|
import type { Ctx, Reducer } from '../types.js';
|
|
37
39
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
+
/**
|
|
41
|
+
* RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing
|
|
42
|
+
* CR-LF. Transports append `\r\n` after the text produced here, so the
|
|
43
|
+
* budget for the line text itself is 512 − 2 = 510 bytes. Declared locally
|
|
44
|
+
* (mirroring `cap.ts` / `motd-lines.ts`) to keep this module self-contained.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_LINE_BYTES = 512;
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return CHANNEL_NAME_RE.test(name);
|
|
44
|
-
}
|
|
48
|
+
/** Length of the trailing CR-LF the wire layer appends to each line. */
|
|
49
|
+
const TRAILING_CRLF_LEN = 2;
|
|
45
50
|
|
|
46
51
|
/** Formats a numeric error/reply line addressed to the connection's nick. */
|
|
47
52
|
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
@@ -90,14 +95,50 @@ function prefixFormatter(ctx: Ctx): (entry: RosterEntry) => string {
|
|
|
90
95
|
return ctx.connection.caps.has('multi-prefix') ? allPrefixes : highestPrefix;
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Greedy-packs the formatted member list into one or more `353 RPL_NAMREPLY`
|
|
100
|
+
* lines so every emitted line fits the 510-byte text budget (RFC 2812 §3.2.5:
|
|
101
|
+
* long NAMES lists are split across multiple `353` replies rather than
|
|
102
|
+
* truncated mid-nick by the outbound line limiter).
|
|
103
|
+
*
|
|
104
|
+
* The full line is measured: the fixed `:{server} 353 {nick} {sigil} {chan} :`
|
|
105
|
+
* header, each member's mode-prefix bytes (`@`/`+`), the nicks themselves
|
|
106
|
+
* (UTF-8 bytes, not UTF-16 code units), and the separating spaces. A member
|
|
107
|
+
* that alone overflows the budget (only possible with pathological config —
|
|
108
|
+
* nicks are capped at `nickLen`) still gets its own line rather than being
|
|
109
|
+
* dropped, leaving any last-resort truncation to the outbound limiter.
|
|
110
|
+
*
|
|
111
|
+
* A channel with no members still yields exactly one `353` with an empty
|
|
112
|
+
* trailing parameter, so the reply shape (`353`…`366`) is stable for clients.
|
|
113
|
+
*/
|
|
114
|
+
function packNamesLines(state: ChannelState, ctx: Ctx, nick: string, sigil: string): RawLine[] {
|
|
95
115
|
const format = prefixFormatter(ctx);
|
|
96
|
-
const
|
|
116
|
+
const header = `:${ctx.serverName} 353 ${nick} ${sigil} ${state.name} :`;
|
|
117
|
+
const budget = MAX_LINE_BYTES - TRAILING_CRLF_LEN;
|
|
118
|
+
const headerBytes = byteLength(header);
|
|
119
|
+
|
|
120
|
+
const lines: RawLine[] = [];
|
|
121
|
+
let current = '';
|
|
122
|
+
let usedBytes = 0;
|
|
123
|
+
let empty = true;
|
|
124
|
+
|
|
97
125
|
for (const entry of state.members.values()) {
|
|
98
|
-
|
|
126
|
+
const token = format(entry) + entry.nick;
|
|
127
|
+
const tokenBytes = byteLength(token);
|
|
128
|
+
// One extra byte for the space separator when appending to a non-empty list.
|
|
129
|
+
const costBytes = empty ? tokenBytes : tokenBytes + 1;
|
|
130
|
+
if (!empty && headerBytes + usedBytes + costBytes > budget) {
|
|
131
|
+
lines.push({ text: `${header}${current}` });
|
|
132
|
+
current = token;
|
|
133
|
+
usedBytes = tokenBytes;
|
|
134
|
+
} else {
|
|
135
|
+
current = empty ? token : `${current} ${token}`;
|
|
136
|
+
usedBytes += costBytes;
|
|
137
|
+
}
|
|
138
|
+
empty = false;
|
|
99
139
|
}
|
|
100
|
-
|
|
140
|
+
lines.push({ text: `${header}${current}` });
|
|
141
|
+
return lines;
|
|
101
142
|
}
|
|
102
143
|
|
|
103
144
|
/**
|
|
@@ -143,9 +184,8 @@ export const namesReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
143
184
|
|
|
144
185
|
const nick = ctx.connection.nick ?? '*';
|
|
145
186
|
const sigil = namesSigil(state);
|
|
146
|
-
const names = buildNamesList(state, ctx);
|
|
147
187
|
const replyLines: RawLine[] = [
|
|
148
|
-
|
|
188
|
+
...packNamesLines(state, ctx, nick, sigil),
|
|
149
189
|
{ text: `:${ctx.serverName} 366 ${nick} ${state.name} :End of /NAMES list.` },
|
|
150
190
|
];
|
|
151
191
|
|
|
@@ -29,7 +29,12 @@
|
|
|
29
29
|
* the {@link MessageStore} pattern).
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_IDENTIFY_FREEZE_SECONDS,
|
|
34
|
+
DEFAULT_MAX_IDENTIFY_FAILURES,
|
|
35
|
+
DEFAULT_MIN_PASSWORD_LENGTH,
|
|
36
|
+
DEFAULT_NICK_ENFORCE_GRACE_MS,
|
|
37
|
+
} from '../config.js';
|
|
33
38
|
import { Effect } from '../effects.js';
|
|
34
39
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
35
40
|
import type { NickEnforcePolicy, ServicesStore } from '../ports.js';
|
|
@@ -72,6 +77,17 @@ const ENFORCE_GATE_NOTICE = 'You must identify before changing enforcement.';
|
|
|
72
77
|
*/
|
|
73
78
|
const PASSWORD_GATE_NOTICE = 'You must identify before changing your password.';
|
|
74
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Formats the owner NOTICE delivered on the first successful identify
|
|
82
|
+
* after failed identifies were recorded against the account. Delivered
|
|
83
|
+
* once (the queued count is cleared with the delivery), so the owner
|
|
84
|
+
* learns their account was targeted even though every attempt failed.
|
|
85
|
+
*/
|
|
86
|
+
function identifyFailureNoticeText(count: number): string {
|
|
87
|
+
const plural = count === 1 ? 'attempt' : 'attempts';
|
|
88
|
+
return `Warning: ${count} failed identify ${plural} recorded against your account since your last successful login.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
75
91
|
/**
|
|
76
92
|
* Reducer for `PRIVMSG NickServ :<subcommand> <args…>`.
|
|
77
93
|
* Authority: the invoking connection's {@link ConnectionState}.
|
|
@@ -181,13 +197,36 @@ function handleIdentify(
|
|
|
181
197
|
return { state, effects };
|
|
182
198
|
}
|
|
183
199
|
|
|
200
|
+
// Per-account freeze: once maxIdentifyFailures failed attempts were
|
|
201
|
+
// recorded within the sliding freeze window, reject BEFORE running
|
|
202
|
+
// scrypt (the freeze is the cheap rejection path; verifyNick is the
|
|
203
|
+
// expensive one an attacker would otherwise keep hitting).
|
|
204
|
+
const maxFailures =
|
|
205
|
+
ctx.serverConfig.nickServ?.maxIdentifyFailures ?? DEFAULT_MAX_IDENTIFY_FAILURES;
|
|
206
|
+
const freezeWindowMs =
|
|
207
|
+
(ctx.serverConfig.nickServ?.identifyFreezeSeconds ?? DEFAULT_IDENTIFY_FREEZE_SECONDS) * 1000;
|
|
208
|
+
if (services.isNickIdentifyFrozen(nick, { maxFailures, windowMs: freezeWindowMs })) {
|
|
209
|
+
effects.push(
|
|
210
|
+
notice(state, `Nick ${nick} is temporarily frozen due to too many failed identify attempts.`),
|
|
211
|
+
);
|
|
212
|
+
return { state, effects };
|
|
213
|
+
}
|
|
214
|
+
|
|
184
215
|
const result = services.verifyNick(nick, password);
|
|
185
216
|
if (!result.ok) {
|
|
217
|
+
services.recordIdentifyFailure(nick);
|
|
186
218
|
effects.push(notice(state, 'Invalid password.'));
|
|
187
219
|
return { state, effects };
|
|
188
220
|
}
|
|
189
221
|
|
|
222
|
+
// A successful login delivers the queued owner notice (if any failed
|
|
223
|
+
// identifies were recorded since the last successful login) and resets
|
|
224
|
+
// the per-account failure accounting.
|
|
225
|
+
const failedSinceLogin = services.clearIdentifyFailures(nick);
|
|
190
226
|
applyIdentify(state, result.account, services, effects);
|
|
227
|
+
if (failedSinceLogin > 0) {
|
|
228
|
+
effects.push(notice(state, identifyFailureNoticeText(failedSinceLogin)));
|
|
229
|
+
}
|
|
191
230
|
return { state, effects };
|
|
192
231
|
}
|
|
193
232
|
|