serverless-ircd 0.9.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-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- 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 +18 -14
- package/apps/cf-worker/package.json +3 -4
- 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 +95 -77
- 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 +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- 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 +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- 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-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- 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 +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -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/account-migration.ts +140 -0
- 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/account-auth.ts +16 -19
- 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/memoserv.ts +1 -1
- 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 +161 -11
- 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 +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- 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 +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- 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 +9 -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 +529 -190
- 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 +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- 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/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- 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/message-store.test.ts +5 -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 +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- 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 +563 -54
- 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/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- 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 +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -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-aws-oidc.test.ts +96 -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/tests/validate.test.ts +42 -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/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -27,6 +27,20 @@ export interface ContainerConfigEnv {
|
|
|
27
27
|
CHANNEL_PREFIXES?: string;
|
|
28
28
|
OPER_USER?: string;
|
|
29
29
|
OPER_PASSWORD?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Scrypt salt (base64) for the hashed oper credential form. When set
|
|
32
|
+
* alongside `OPER_USER` + `OPER_HASH`, the loader builds a hashed
|
|
33
|
+
* `{user, salt, hash}` cred instead of the legacy plaintext
|
|
34
|
+
* `{user, password}` shape. The hashed form is preferred when both
|
|
35
|
+
* are present so deployments can rotate without removing the legacy
|
|
36
|
+
* vars in the same step. Generate via `tools/hash-oper-cred.ts`.
|
|
37
|
+
*/
|
|
38
|
+
OPER_SALT?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Scrypt hash (base64) for the hashed oper credential form. See
|
|
41
|
+
* `OPER_SALT` for the full set of vars.
|
|
42
|
+
*/
|
|
43
|
+
OPER_HASH?: string;
|
|
30
44
|
MAX_CHANNELS_PER_USER?: string;
|
|
31
45
|
MAX_TARGETS_PER_COMMAND?: string;
|
|
32
46
|
NICK_LEN?: string;
|
|
@@ -39,6 +53,26 @@ export interface ContainerConfigEnv {
|
|
|
39
53
|
TCP_PORT?: string;
|
|
40
54
|
/** Container-specific: bind address (default 0.0.0.0 for Spectrum origin). */
|
|
41
55
|
TCP_HOST?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Container-specific: path to the PEM certificate the origin TLS
|
|
58
|
+
* listener (default port 6697) presents. Must be set together with
|
|
59
|
+
* `TLS_KEY_PATH`. Prod-classified boots (`NODE_ENV=production`) refuse
|
|
60
|
+
* to start without it — the origin terminates TLS end-to-end.
|
|
61
|
+
*/
|
|
62
|
+
TLS_CERT_PATH?: string;
|
|
63
|
+
/** Container-specific: path to the PEM private key (see `TLS_CERT_PATH`). */
|
|
64
|
+
TLS_KEY_PATH?: string;
|
|
65
|
+
/** Container-specific: TLS listener port (default 6697). */
|
|
66
|
+
TLS_PORT?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Container-specific: exact value `localhost` opts into the plaintext
|
|
69
|
+
* debug listener (bound to 127.0.0.1 only, for local development).
|
|
70
|
+
* Any other value is rejected; forbidden entirely in prod-classified
|
|
71
|
+
* mode.
|
|
72
|
+
*/
|
|
73
|
+
DEBUG_PLAINTEXT?: string;
|
|
74
|
+
/** Platform convention: `production` classifies the boot as prod. */
|
|
75
|
+
NODE_ENV?: string;
|
|
42
76
|
/** Container-specific: path to the persistence snapshot file. */
|
|
43
77
|
PERSISTENCE_PATH?: string;
|
|
44
78
|
/** Container-specific: auto-snapshot interval in milliseconds. */
|
|
@@ -50,13 +84,26 @@ export interface ContainerConfig {
|
|
|
50
84
|
server: ParsedServerConfig;
|
|
51
85
|
tcpPort: number;
|
|
52
86
|
tcpHost: string;
|
|
87
|
+
/**
|
|
88
|
+
* TLS listener config. Present when `TLS_CERT_PATH` + `TLS_KEY_PATH`
|
|
89
|
+
* are set — the origin then terminates TLS itself (Spectrum
|
|
90
|
+
* `tls_mode = "full"` topology).
|
|
91
|
+
*/
|
|
92
|
+
tls: { certPath: string; keyPath: string; port: number } | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* True only when `DEBUG_PLAINTEXT=localhost`: a plaintext listener may
|
|
95
|
+
* bind — forced to 127.0.0.1 — strictly for local debugging.
|
|
96
|
+
*/
|
|
97
|
+
plaintextDebug: boolean;
|
|
53
98
|
persistencePath: string | undefined;
|
|
54
99
|
snapshotIntervalMs: number;
|
|
55
100
|
}
|
|
56
101
|
|
|
57
102
|
const DEFAULT_TCP_PORT = 6667;
|
|
58
103
|
const DEFAULT_TCP_HOST = '0.0.0.0';
|
|
104
|
+
const DEFAULT_TLS_PORT = 6697;
|
|
59
105
|
const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
|
|
106
|
+
const PLAINTEXT_DEBUG_FLAG = 'localhost';
|
|
60
107
|
|
|
61
108
|
/**
|
|
62
109
|
* Loads and validates the IRC server config plus container-specific knobs
|
|
@@ -68,10 +115,33 @@ const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
|
|
|
68
115
|
*/
|
|
69
116
|
export function loadContainerConfig(env: ContainerConfigEnv): ContainerConfig {
|
|
70
117
|
const server = parseServerConfig(buildServerInput(env));
|
|
118
|
+
const tls = resolveTls(env);
|
|
119
|
+
const prod = env.NODE_ENV === 'production';
|
|
120
|
+
if (prod && tls === undefined) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
'refusing to start a prod-classified origin without TLS: set TLS_CERT_PATH and TLS_KEY_PATH ' +
|
|
123
|
+
'(Spectrum tls_mode=full requires the origin to terminate TLS itself)',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const plaintextDebug = env.DEBUG_PLAINTEXT !== undefined;
|
|
127
|
+
if (plaintextDebug && prod) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
'refusing to enable DEBUG_PLAINTEXT in a prod-classified origin (plaintext IRC is debug-only)',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (plaintextDebug && env.DEBUG_PLAINTEXT !== PLAINTEXT_DEBUG_FLAG) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`invalid DEBUG_PLAINTEXT value ${JSON.stringify(env.DEBUG_PLAINTEXT)}: the only allowed value is 'localhost'`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
71
137
|
return {
|
|
72
138
|
server,
|
|
73
139
|
tcpPort: parseIntOrDefault(env.TCP_PORT, DEFAULT_TCP_PORT),
|
|
74
|
-
|
|
140
|
+
// Debug plaintext is localhost-only by construction; the operator
|
|
141
|
+
// cannot widen the bind even by setting TCP_HOST alongside the flag.
|
|
142
|
+
tcpHost: plaintextDebug ? '127.0.0.1' : env.TCP_HOST?.trim() || DEFAULT_TCP_HOST,
|
|
143
|
+
tls,
|
|
144
|
+
plaintextDebug,
|
|
75
145
|
persistencePath:
|
|
76
146
|
env.PERSISTENCE_PATH !== undefined && env.PERSISTENCE_PATH.length > 0
|
|
77
147
|
? env.PERSISTENCE_PATH
|
|
@@ -80,6 +150,23 @@ export function loadContainerConfig(env: ContainerConfigEnv): ContainerConfig {
|
|
|
80
150
|
};
|
|
81
151
|
}
|
|
82
152
|
|
|
153
|
+
function resolveTls(env: ContainerConfigEnv): ContainerConfig['tls'] {
|
|
154
|
+
const hasCert = env.TLS_CERT_PATH !== undefined && env.TLS_CERT_PATH.length > 0;
|
|
155
|
+
const hasKey = env.TLS_KEY_PATH !== undefined && env.TLS_KEY_PATH.length > 0;
|
|
156
|
+
if (!hasCert && !hasKey) return undefined;
|
|
157
|
+
if (!hasCert) {
|
|
158
|
+
throw new Error('TLS_KEY_PATH is set but TLS_CERT_PATH is not — provide both or neither');
|
|
159
|
+
}
|
|
160
|
+
if (!hasKey) {
|
|
161
|
+
throw new Error('TLS_CERT_PATH is set but TLS_KEY_PATH is not — provide both or neither');
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
certPath: env.TLS_CERT_PATH as string,
|
|
165
|
+
keyPath: env.TLS_KEY_PATH as string,
|
|
166
|
+
port: parseIntOrDefault(env.TLS_PORT, DEFAULT_TLS_PORT),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
83
170
|
function parseIntOrDefault(raw: string | undefined, def: number): number {
|
|
84
171
|
if (raw === undefined || raw.trim().length === 0) return def;
|
|
85
172
|
const n = Number.parseInt(raw, 10);
|
|
@@ -99,7 +186,31 @@ function buildServerInput(env: ContainerConfigEnv): Record<string, unknown> {
|
|
|
99
186
|
if (env.MAX_CLIENTS !== undefined) input.maxClients = Number.parseInt(env.MAX_CLIENTS, 10);
|
|
100
187
|
if (env.CHANNEL_PREFIXES !== undefined) input.channelPrefixes = env.CHANNEL_PREFIXES;
|
|
101
188
|
if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
|
|
102
|
-
|
|
189
|
+
// Hashed form (OPER_USER + OPER_SALT + OPER_HASH) is preferred when
|
|
190
|
+
// both salt + hash are supplied — even if OPER_PASSWORD is also set,
|
|
191
|
+
// so a deployment can rotate to the hashed form without dropping the
|
|
192
|
+
// legacy var in the same release. The legacy plaintext
|
|
193
|
+
// `{user, password}` shape is retained for a single deprecation-
|
|
194
|
+
// cycle window.
|
|
195
|
+
const hasHashed = env.OPER_SALT !== undefined && env.OPER_HASH !== undefined;
|
|
196
|
+
if (hasHashed) {
|
|
197
|
+
input.operCreds = [
|
|
198
|
+
{
|
|
199
|
+
user: env.OPER_USER ?? '',
|
|
200
|
+
salt: env.OPER_SALT ?? '',
|
|
201
|
+
hash: env.OPER_HASH ?? '',
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
} else {
|
|
205
|
+
input.operCreds = [
|
|
206
|
+
{
|
|
207
|
+
user: env.OPER_USER ?? '',
|
|
208
|
+
password: env.OPER_PASSWORD ?? '',
|
|
209
|
+
...(env.OPER_SALT !== undefined ? { salt: env.OPER_SALT } : {}),
|
|
210
|
+
...(env.OPER_HASH !== undefined ? { hash: env.OPER_HASH } : {}),
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
}
|
|
103
214
|
}
|
|
104
215
|
if (env.MAX_CHANNELS_PER_USER !== undefined) {
|
|
105
216
|
input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
|
|
@@ -2,12 +2,36 @@
|
|
|
2
2
|
* CF TCP container origin — the stateful IRC server behind Cloudflare
|
|
3
3
|
* Spectrum.
|
|
4
4
|
*
|
|
5
|
-
* Spectrum
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Spectrum forwards TLS end-to-end (`tls_mode = "full"`): this origin
|
|
6
|
+
* terminates TLS itself on :6697 with the certificate configured via
|
|
7
|
+
* `TLS_CERT_PATH` / `TLS_KEY_PATH` (see `config-loader.ts`). The
|
|
8
|
+
* container runs the shared IRC core (`ConnectionActor` +
|
|
9
|
+
* `InMemoryRuntime`) over the `TcpByteStreamTransport` seam, which
|
|
10
|
+
* reassembles `\r\n`-terminated IRC lines across arbitrary TCP chunks.
|
|
11
|
+
* The actor/reducer/dispatch pipeline is identical to the wss path —
|
|
12
|
+
* only the line-framing transport differs.
|
|
13
|
+
*
|
|
14
|
+
* Spectrum is configured with `proxy_protocol = "v1"`, so every flow
|
|
15
|
+
* (TLS and plaintext alike) must begin with a PROXY v1 header
|
|
16
|
+
* (`PROXY TCP4 <src_ip> ... \r\n`, see `proxy-protocol.ts`). The header
|
|
17
|
+
* is peeled off before any byte reaches the actor; the recovered source
|
|
18
|
+
* IP — the real client address, not the Spectrum edge IP — feeds
|
|
19
|
+
* `state.host` and the per-IP admission gate. Connections whose first
|
|
20
|
+
* bytes do not yield a valid header are closed with
|
|
21
|
+
* `ERROR :Closing link` (direct-connection bypass defense); the
|
|
22
|
+
* localhost debug listener requires the header too, e.g.:
|
|
23
|
+
* printf 'PROXY TCP4 127.0.0.1 127.0.0.1 40000 6667\r\n...' | nc …
|
|
24
|
+
*
|
|
25
|
+
* A plaintext listener is available strictly for localhost-only local
|
|
26
|
+
* debugging; in a prod-classified boot the config loader refuses to
|
|
27
|
+
* start without TLS material.
|
|
28
|
+
*
|
|
29
|
+
* SASL EXTERNAL is explicitly unsupported on this transport: Spectrum's
|
|
30
|
+
* PROXY v1 forwarding carries no client-cert subject, so no mTLS
|
|
31
|
+
* identity source exists here and `AUTHENTICATE EXTERNAL` is rejected
|
|
32
|
+
* up front with `904 ERR_SASLFAIL` ("SASL EXTERNAL not supported on
|
|
33
|
+
* this transport"). Cert-backed SASL lives on the wss and NLB+TLS
|
|
34
|
+
* paths; see docs/SASL-EXTERNAL.md.
|
|
11
35
|
*
|
|
12
36
|
* When `persistencePath` is supplied, channel state (topics, modes, bans)
|
|
13
37
|
* is restored from a snapshot file on boot and auto-saved on an interval
|
|
@@ -15,34 +39,54 @@
|
|
|
15
39
|
*/
|
|
16
40
|
|
|
17
41
|
import { randomUUID } from 'node:crypto';
|
|
42
|
+
import { readFile } from 'node:fs/promises';
|
|
18
43
|
import { type Server, type Socket, createServer } from 'node:net';
|
|
44
|
+
import { createServer as tlsCreateServer } from 'node:tls';
|
|
19
45
|
import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
|
|
20
46
|
import {
|
|
21
|
-
type AccountStore,
|
|
22
47
|
type AdmissionConfig,
|
|
23
48
|
type ChanName,
|
|
24
49
|
type Clock,
|
|
25
50
|
type ConnectionState,
|
|
26
51
|
ConsoleLogger,
|
|
27
|
-
InMemoryAccountStore,
|
|
28
52
|
InMemoryMessageStore,
|
|
29
53
|
InMemoryNickHistoryStore,
|
|
54
|
+
InMemoryServicesStore,
|
|
30
55
|
LogLevel,
|
|
31
56
|
type Logger,
|
|
32
57
|
type MessageStore,
|
|
33
58
|
type NickHistoryStore,
|
|
34
59
|
type RawLine,
|
|
60
|
+
type ServicesStore,
|
|
61
|
+
SystemClock,
|
|
35
62
|
createConnection,
|
|
36
63
|
} from '@serverless-ircd/irc-core';
|
|
37
64
|
import { ConnectionActor, TcpByteStreamTransport } from '@serverless-ircd/irc-server';
|
|
38
65
|
import { loadSnapshot, restoreRuntime, saveSnapshot, snapshotRuntime } from './persistence.js';
|
|
66
|
+
import { consumeProxyV1Header } from './proxy-protocol.js';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* TLS listener configuration: the origin presents `certPath` / `keyPath`
|
|
70
|
+
* (PEM) on `port` (default 6697).
|
|
71
|
+
*/
|
|
72
|
+
export interface TlsListenerOptions {
|
|
73
|
+
readonly certPath: string;
|
|
74
|
+
readonly keyPath: string;
|
|
75
|
+
readonly port?: number | undefined;
|
|
76
|
+
}
|
|
39
77
|
|
|
40
78
|
/** Options accepted by {@link startContainerServer}. */
|
|
41
79
|
export interface StartContainerOptions {
|
|
42
|
-
/**
|
|
43
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Plaintext TCP port — localhost debug only; a prod boot must serve
|
|
82
|
+
* TLS instead. Use 0 for an ephemeral port; omit to disable the
|
|
83
|
+
* plaintext listener entirely.
|
|
84
|
+
*/
|
|
85
|
+
readonly port?: number | undefined;
|
|
44
86
|
/** Bind address. Defaults to `0.0.0.0` (Spectrum forwards from the edge). */
|
|
45
87
|
readonly host?: string;
|
|
88
|
+
/** TLS listener: terminates TLS at the origin (Spectrum `full` mode). */
|
|
89
|
+
readonly tls?: TlsListenerOptions;
|
|
46
90
|
readonly serverName: string;
|
|
47
91
|
readonly networkName: string;
|
|
48
92
|
readonly motdLines?: string[];
|
|
@@ -74,7 +118,10 @@ export interface StartContainerOptions {
|
|
|
74
118
|
|
|
75
119
|
/** A running container origin. Call {@link close} to shut down. */
|
|
76
120
|
export interface ContainerServer {
|
|
77
|
-
|
|
121
|
+
/** Bound plaintext (debug) port; undefined when TLS-only. */
|
|
122
|
+
readonly port: number | undefined;
|
|
123
|
+
/** Bound TLS port; undefined when no TLS listener is configured. */
|
|
124
|
+
readonly tlsPort: number | undefined;
|
|
78
125
|
readonly host: string;
|
|
79
126
|
readonly runtime: InMemoryRuntime;
|
|
80
127
|
readonly messages: MessageStore;
|
|
@@ -106,15 +153,34 @@ const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
|
|
|
106
153
|
const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
|
|
107
154
|
const DEFAULT_PER_IP_CONNECTION_RATE = { max: 5, windowMs: 60_000 };
|
|
108
155
|
const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
|
|
156
|
+
const DEFAULT_TLS_PORT = 6697;
|
|
109
157
|
|
|
110
158
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
159
|
+
* Stable refusal for `AUTHENTICATE EXTERNAL` on this transport. Spectrum
|
|
160
|
+
* forwards with PROXY protocol v1, which carries no client-cert subject,
|
|
161
|
+
* so this origin has no mTLS identity source and SASL EXTERNAL is
|
|
162
|
+
* explicitly unsupported: the mechanism is pinned off via
|
|
163
|
+
* `sasl.externalUnsupportedMessage` and rejected up front with a
|
|
164
|
+
* transport-specific `904 ERR_SASLFAIL` carrying this exact text
|
|
165
|
+
* (asserted verbatim by the container tests — change with care).
|
|
166
|
+
*/
|
|
167
|
+
const SASL_EXTERNAL_UNSUPPORTED_MESSAGE = 'SASL EXTERNAL not supported on this transport';
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Starts the TCP container origin. Resolves once every configured
|
|
171
|
+
* listener (TLS on :6697 by default; optional plaintext debug listener)
|
|
172
|
+
* is ready. When `persistencePath` is set and a snapshot file exists,
|
|
173
|
+
* channel state is restored before any connection is accepted.
|
|
114
174
|
*/
|
|
115
175
|
export async function startContainerServer(opts: StartContainerOptions): Promise<ContainerServer> {
|
|
116
176
|
const host = opts.host ?? '0.0.0.0';
|
|
117
177
|
const clock: Clock = opts.clock ?? { now: () => Date.now() };
|
|
178
|
+
if (opts.port === undefined && opts.tls === undefined) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
'no listener configured: pass tls (cert/key paths) for the TLS listener, ' +
|
|
181
|
+
'or port for the localhost-only plaintext debug listener',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
118
184
|
|
|
119
185
|
const admission: AdmissionConfig | undefined =
|
|
120
186
|
opts.maxConnectionsPerIp !== undefined ||
|
|
@@ -134,10 +200,12 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
134
200
|
|
|
135
201
|
const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
|
|
136
202
|
const history = new InMemoryNickHistoryStore(clock);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
203
|
+
// Services store: the single credential home. Seeded from `saslAccounts`
|
|
204
|
+
// so SASL PLAIN / PASS-auth / NickServ IDENTIFY all verify.
|
|
205
|
+
const services = new InMemoryServicesStore({ clock: SystemClock });
|
|
206
|
+
for (const acct of opts.saslAccounts ?? []) {
|
|
207
|
+
services.registerNick(acct.username, acct.password, '');
|
|
208
|
+
}
|
|
141
209
|
|
|
142
210
|
// Restore persisted channel state before accepting connections.
|
|
143
211
|
if (opts.persistencePath !== undefined) {
|
|
@@ -154,43 +222,71 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
154
222
|
const connections = new Map<Socket, ConnectionBindings>();
|
|
155
223
|
const sockets = new Set<Socket>();
|
|
156
224
|
|
|
157
|
-
const
|
|
225
|
+
const onConnection = (socket: Socket): void => {
|
|
158
226
|
sockets.add(socket);
|
|
159
227
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
228
|
+
// Set once the PROXY v1 header has been parsed and the connection
|
|
229
|
+
// admitted; until then inbound bytes are buffered as header bytes.
|
|
230
|
+
let bindings: ConnectionBindings | undefined;
|
|
231
|
+
let headerBuffer: Buffer = Buffer.alloc(0);
|
|
232
|
+
|
|
233
|
+
const reject = (reason: string): void => {
|
|
234
|
+
// Data-triggered rejects fire on a live, just-read-from socket;
|
|
235
|
+
// the else branch (skip the ERROR write) is a defensive guard
|
|
236
|
+
// against an impossible race and cannot be exercised over a real
|
|
237
|
+
// socket.
|
|
170
238
|
/* c8 ignore start */
|
|
171
239
|
if (socket.writable) {
|
|
172
|
-
socket.write(`ERROR :Closing link: (
|
|
240
|
+
socket.write(`ERROR :Closing link: (${reason})\r\n`);
|
|
173
241
|
}
|
|
174
242
|
/* c8 ignore stop */
|
|
175
243
|
socket.destroy();
|
|
176
244
|
sockets.delete(socket);
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
const admissionRecordId = decision.recordId;
|
|
180
|
-
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
181
|
-
|
|
182
|
-
const { state, actor } = attachConnection(runtime, opts, messages, accounts, history, clock, {
|
|
183
|
-
sendText: (text) => {
|
|
184
|
-
if (socket.writable) socket.write(text);
|
|
185
|
-
},
|
|
186
|
-
closeTransport: () => socket.destroy(),
|
|
187
|
-
admissionRecordId,
|
|
188
|
-
sourceHost: ip,
|
|
189
|
-
});
|
|
190
|
-
connections.set(socket, { state, actor });
|
|
245
|
+
};
|
|
191
246
|
|
|
192
247
|
socket.on('data', (chunk: Buffer) => {
|
|
193
|
-
|
|
248
|
+
if (bindings === undefined) {
|
|
249
|
+
const buffered = headerBuffer.length === 0 ? chunk : Buffer.concat([headerBuffer, chunk]);
|
|
250
|
+
const header = consumeProxyV1Header(buffered);
|
|
251
|
+
if (header.status === 'invalid') {
|
|
252
|
+
// The peer did not open with a valid PROXY v1 header: either a
|
|
253
|
+
// direct connection bypassing Spectrum, or a malformed/spoofed
|
|
254
|
+
// header. Either way the flow is closed before any byte
|
|
255
|
+
// reaches the IRC actor.
|
|
256
|
+
reject('invalid PROXY protocol v1 header');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (header.status === 'incomplete') {
|
|
260
|
+
// Header split across reads: wait for the trailing CRLF.
|
|
261
|
+
headerBuffer = buffered;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const ip = header.sourceIp;
|
|
265
|
+
const decision = runtime.admitConnection(ip, undefined);
|
|
266
|
+
if (!decision.ok) {
|
|
267
|
+
reject(`connection limit reached: ${decision.reason}`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const admissionRecordId = decision.recordId;
|
|
271
|
+
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
272
|
+
|
|
273
|
+
bindings = attachConnection(runtime, opts, messages, services, history, clock, {
|
|
274
|
+
sendText: (text) => {
|
|
275
|
+
if (socket.writable) socket.write(text);
|
|
276
|
+
},
|
|
277
|
+
closeTransport: () => socket.destroy(),
|
|
278
|
+
admissionRecordId,
|
|
279
|
+
sourceHost: ip,
|
|
280
|
+
});
|
|
281
|
+
connections.set(socket, bindings);
|
|
282
|
+
if (header.remainder.length > 0) {
|
|
283
|
+
bindings.actor.receive(header.remainder.toString('utf8')).catch((err) => {
|
|
284
|
+
logError('actor failure', err);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
bindings.actor.receive(chunk.toString('utf8')).catch((err) => {
|
|
194
290
|
logError('actor failure', err);
|
|
195
291
|
});
|
|
196
292
|
});
|
|
@@ -198,14 +294,36 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
198
294
|
const cleanup = (): void => {
|
|
199
295
|
connections.delete(socket);
|
|
200
296
|
sockets.delete(socket);
|
|
201
|
-
|
|
297
|
+
// Rejected-before-admission flows never registered a connection.
|
|
298
|
+
if (bindings !== undefined) {
|
|
299
|
+
runtime.unregisterConnection(bindings.state.id);
|
|
300
|
+
}
|
|
202
301
|
};
|
|
203
302
|
socket.on('close', cleanup);
|
|
204
303
|
socket.on('error', (err) => {
|
|
205
304
|
logError('tcp socket error', err);
|
|
206
305
|
cleanup();
|
|
207
306
|
});
|
|
208
|
-
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const binds: Array<{ server: Server; port: number }> = [];
|
|
310
|
+
|
|
311
|
+
if (opts.port !== undefined) {
|
|
312
|
+
binds.push({ server: createServer(onConnection), port: opts.port });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (opts.tls !== undefined) {
|
|
316
|
+
const { cert, key } = await loadTlsMaterial(opts.tls);
|
|
317
|
+
// Same connection pipeline as plaintext: a TLSSocket is a Socket,
|
|
318
|
+
// and the actor sees the decrypted byte stream identically.
|
|
319
|
+
binds.push({
|
|
320
|
+
server: tlsCreateServer(
|
|
321
|
+
{ cert, key, minVersion: 'TLSv1.2', maxVersion: 'TLSv1.3' },
|
|
322
|
+
onConnection,
|
|
323
|
+
),
|
|
324
|
+
port: opts.tls.port ?? DEFAULT_TLS_PORT,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
209
327
|
|
|
210
328
|
// Auto-snapshot timer.
|
|
211
329
|
const snapshotIntervalMs = opts.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS;
|
|
@@ -217,41 +335,85 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
217
335
|
: undefined;
|
|
218
336
|
if (timer !== undefined) timer.unref?.();
|
|
219
337
|
|
|
220
|
-
|
|
338
|
+
// Listening order matches construction: plaintext debug listener first
|
|
339
|
+
// (if any), TLS second. `binds` is never empty — the no-listener guard
|
|
340
|
+
// threw above.
|
|
341
|
+
const boundPorts = await Promise.all(binds.map((b) => listenOnce(b.server, b.port, host))).catch(
|
|
342
|
+
async (err: Error) => {
|
|
343
|
+
// Partial failure: unbind any listener that already made it so a
|
|
344
|
+
// rejected boot never leaves a stray port behind.
|
|
345
|
+
await Promise.all(binds.map((b) => new Promise<void>((res) => b.server.close(() => res()))));
|
|
346
|
+
throw err;
|
|
347
|
+
},
|
|
348
|
+
);
|
|
349
|
+
const plainPort = opts.port !== undefined ? boundPorts[0] : undefined;
|
|
350
|
+
const tlsPort = opts.tls !== undefined ? boundPorts[boundPorts.length - 1] : undefined;
|
|
351
|
+
return {
|
|
352
|
+
port: plainPort,
|
|
353
|
+
tlsPort,
|
|
354
|
+
host,
|
|
355
|
+
runtime,
|
|
356
|
+
messages,
|
|
357
|
+
history,
|
|
358
|
+
testSockets: sockets,
|
|
359
|
+
close: () =>
|
|
360
|
+
closeServer({
|
|
361
|
+
servers: binds.map((b) => b.server),
|
|
362
|
+
connections,
|
|
363
|
+
sockets,
|
|
364
|
+
timer,
|
|
365
|
+
runtime,
|
|
366
|
+
persistencePath: opts.persistencePath,
|
|
367
|
+
}),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Reads + validates the PEM pair, naming the offending path on failure. */
|
|
372
|
+
async function loadTlsMaterial(tls: TlsListenerOptions): Promise<{ cert: Buffer; key: Buffer }> {
|
|
373
|
+
let cert: Buffer;
|
|
374
|
+
let key: Buffer;
|
|
375
|
+
try {
|
|
376
|
+
cert = await readFile(tls.certPath);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`failed to read TLS certificate at ${tls.certPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
key = await readFile(tls.keyPath);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`failed to read TLS key at ${tls.keyPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return { cert, key };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Binds `server` on `port`/`host` and resolves with the actually-bound
|
|
394
|
+
* port (the listener's own AddressInfo, so ephemeral `0` binds resolve
|
|
395
|
+
* to the real port).
|
|
396
|
+
*/
|
|
397
|
+
function listenOnce(server: Server, port: number, host: string): Promise<number> {
|
|
398
|
+
return new Promise((resolve, reject) => {
|
|
221
399
|
const onError = (err: Error): void => {
|
|
222
|
-
|
|
400
|
+
server.removeListener('listening', onListening);
|
|
223
401
|
reject(err);
|
|
224
402
|
};
|
|
225
403
|
const onListening = (): void => {
|
|
226
|
-
|
|
227
|
-
const addr =
|
|
404
|
+
server.removeListener('error', onError);
|
|
405
|
+
const addr = server.address();
|
|
228
406
|
// Inside the 'listening' handler `address()` is always an AddressInfo
|
|
229
|
-
// (TCP server, just bound); the null/string returns only occur for
|
|
230
|
-
// sockets or before listening. The `
|
|
407
|
+
// (TCP server, just bound); the null/string returns only occur for
|
|
408
|
+
// IPC sockets or before listening. The `port` fallback is unreachable.
|
|
231
409
|
/* c8 ignore start */
|
|
232
|
-
const
|
|
410
|
+
const bound = typeof addr === 'object' && addr !== null ? addr.port : port;
|
|
233
411
|
/* c8 ignore stop */
|
|
234
|
-
resolve(
|
|
235
|
-
port,
|
|
236
|
-
host,
|
|
237
|
-
runtime,
|
|
238
|
-
messages,
|
|
239
|
-
history,
|
|
240
|
-
testSockets: sockets,
|
|
241
|
-
close: () =>
|
|
242
|
-
closeServer({
|
|
243
|
-
tcpServer,
|
|
244
|
-
connections,
|
|
245
|
-
sockets,
|
|
246
|
-
timer,
|
|
247
|
-
runtime,
|
|
248
|
-
persistencePath: opts.persistencePath,
|
|
249
|
-
}),
|
|
250
|
-
});
|
|
412
|
+
resolve(bound);
|
|
251
413
|
};
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
414
|
+
server.once('listening', onListening);
|
|
415
|
+
server.once('error', onError);
|
|
416
|
+
server.listen(port, host);
|
|
255
417
|
});
|
|
256
418
|
}
|
|
257
419
|
|
|
@@ -259,7 +421,7 @@ function attachConnection(
|
|
|
259
421
|
runtime: InMemoryRuntime,
|
|
260
422
|
opts: StartContainerOptions,
|
|
261
423
|
messages: MessageStore,
|
|
262
|
-
|
|
424
|
+
services: ServicesStore,
|
|
263
425
|
history: NickHistoryStore,
|
|
264
426
|
clock: Clock,
|
|
265
427
|
transport: {
|
|
@@ -271,8 +433,9 @@ function attachConnection(
|
|
|
271
433
|
): ConnectionBindings {
|
|
272
434
|
const id = randomUUID();
|
|
273
435
|
const state = createConnection({ id, connectedSince: clock.now() });
|
|
274
|
-
// `transport.sourceHost` is always supplied (set to the
|
|
275
|
-
// only call site above; the else branch is defensive
|
|
436
|
+
// `transport.sourceHost` is always supplied (set to the PROXY-header
|
|
437
|
+
// source IP) by the only call site above; the else branch is defensive
|
|
438
|
+
// and unreachable.
|
|
276
439
|
/* c8 ignore start */
|
|
277
440
|
if (transport.sourceHost !== undefined) {
|
|
278
441
|
state.host = transport.sourceHost;
|
|
@@ -297,17 +460,32 @@ function attachConnection(
|
|
|
297
460
|
quitMessage: opts.quitMessage ?? 'Client Quit',
|
|
298
461
|
...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
|
|
299
462
|
...(opts.cloaking !== undefined ? { cloaking: opts.cloaking } : {}),
|
|
463
|
+
// SASL EXTERNAL non-support: PROXY v1 carries no client-cert
|
|
464
|
+
// subject, so the mechanism is pinned off with the transport-
|
|
465
|
+
// specific refusal (see SASL_EXTERNAL_UNSUPPORTED_MESSAGE) instead
|
|
466
|
+
// of failing generically at the three-way gate.
|
|
467
|
+
sasl: { externalUnsupportedMessage: SASL_EXTERNAL_UNSUPPORTED_MESSAGE },
|
|
300
468
|
},
|
|
301
469
|
clock,
|
|
302
470
|
ids: DEFAULT_ID_FACTORY,
|
|
303
471
|
motd: { lines: () => opts.motdLines ?? [] },
|
|
304
472
|
messages,
|
|
305
|
-
...(
|
|
473
|
+
...(services !== undefined ? { services } : {}),
|
|
306
474
|
history,
|
|
307
475
|
logger,
|
|
308
|
-
transport: new TcpByteStreamTransport(
|
|
309
|
-
|
|
310
|
-
|
|
476
|
+
transport: new TcpByteStreamTransport({
|
|
477
|
+
// A > 8 KiB unterminated tail is pure attacker-controlled memory (no
|
|
478
|
+
// legal IRC line can exceed 512 bytes): answer with the RFC-style
|
|
479
|
+
// ERROR notice and destroy the socket. The 'close' handler performs
|
|
480
|
+
// the bookkeeping teardown (connections map + runtime unregister).
|
|
481
|
+
onOverflow: () => {
|
|
482
|
+
transport.sendText('ERROR :Closing link: input buffer overflow\r\n');
|
|
483
|
+
transport.closeTransport();
|
|
484
|
+
},
|
|
485
|
+
}),
|
|
486
|
+
// TLS terminates at this origin (Spectrum `full` mode re-encrypts to
|
|
487
|
+
// the origin's own TLS listener), so every connection is secure →
|
|
488
|
+
// user mode `S`.
|
|
311
489
|
secure: true,
|
|
312
490
|
});
|
|
313
491
|
|
|
@@ -332,14 +510,14 @@ function attachConnection(
|
|
|
332
510
|
}
|
|
333
511
|
|
|
334
512
|
async function closeServer(args: {
|
|
335
|
-
|
|
513
|
+
servers: Server[];
|
|
336
514
|
connections: Map<Socket, ConnectionBindings>;
|
|
337
515
|
sockets: Set<Socket>;
|
|
338
516
|
timer: ReturnType<typeof setInterval> | undefined;
|
|
339
517
|
runtime: InMemoryRuntime;
|
|
340
518
|
persistencePath: string | undefined;
|
|
341
519
|
}): Promise<void> {
|
|
342
|
-
const {
|
|
520
|
+
const { servers, connections, sockets, timer, runtime, persistencePath } = args;
|
|
343
521
|
|
|
344
522
|
if (timer !== undefined) clearInterval(timer);
|
|
345
523
|
|
|
@@ -361,10 +539,12 @@ async function closeServer(args: {
|
|
|
361
539
|
// Stop accepting new connections. We do NOT await the callback because
|
|
362
540
|
// `server.close()` waits for every connection's 'close' event, which can
|
|
363
541
|
// hang if a destroyed socket's TCP cleanup is delayed by the OS. All
|
|
364
|
-
// sockets are already destroyed above; the
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
542
|
+
// sockets are already destroyed above; the servers are safe to abandon.
|
|
543
|
+
for (const server of servers) {
|
|
544
|
+
server.close();
|
|
545
|
+
(server as Server & { closeAllConnections?: () => void }).closeAllConnections?.();
|
|
546
|
+
server.unref();
|
|
547
|
+
}
|
|
368
548
|
}
|
|
369
549
|
|
|
370
550
|
async function snapshotNow(runtime: InMemoryRuntime, path: string): Promise<void> {
|