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
|
@@ -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,7 +39,9 @@
|
|
|
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
47
|
type AdmissionConfig,
|
|
@@ -37,13 +63,30 @@ import {
|
|
|
37
63
|
} from '@serverless-ircd/irc-core';
|
|
38
64
|
import { ConnectionActor, TcpByteStreamTransport } from '@serverless-ircd/irc-server';
|
|
39
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
|
+
}
|
|
40
77
|
|
|
41
78
|
/** Options accepted by {@link startContainerServer}. */
|
|
42
79
|
export interface StartContainerOptions {
|
|
43
|
-
/**
|
|
44
|
-
|
|
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;
|
|
45
86
|
/** Bind address. Defaults to `0.0.0.0` (Spectrum forwards from the edge). */
|
|
46
87
|
readonly host?: string;
|
|
88
|
+
/** TLS listener: terminates TLS at the origin (Spectrum `full` mode). */
|
|
89
|
+
readonly tls?: TlsListenerOptions;
|
|
47
90
|
readonly serverName: string;
|
|
48
91
|
readonly networkName: string;
|
|
49
92
|
readonly motdLines?: string[];
|
|
@@ -75,7 +118,10 @@ export interface StartContainerOptions {
|
|
|
75
118
|
|
|
76
119
|
/** A running container origin. Call {@link close} to shut down. */
|
|
77
120
|
export interface ContainerServer {
|
|
78
|
-
|
|
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;
|
|
79
125
|
readonly host: string;
|
|
80
126
|
readonly runtime: InMemoryRuntime;
|
|
81
127
|
readonly messages: MessageStore;
|
|
@@ -107,15 +153,34 @@ const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
|
|
|
107
153
|
const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
|
|
108
154
|
const DEFAULT_PER_IP_CONNECTION_RATE = { max: 5, windowMs: 60_000 };
|
|
109
155
|
const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
|
|
156
|
+
const DEFAULT_TLS_PORT = 6697;
|
|
110
157
|
|
|
111
158
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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.
|
|
115
174
|
*/
|
|
116
175
|
export async function startContainerServer(opts: StartContainerOptions): Promise<ContainerServer> {
|
|
117
176
|
const host = opts.host ?? '0.0.0.0';
|
|
118
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
|
+
}
|
|
119
184
|
|
|
120
185
|
const admission: AdmissionConfig | undefined =
|
|
121
186
|
opts.maxConnectionsPerIp !== undefined ||
|
|
@@ -157,43 +222,71 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
157
222
|
const connections = new Map<Socket, ConnectionBindings>();
|
|
158
223
|
const sockets = new Set<Socket>();
|
|
159
224
|
|
|
160
|
-
const
|
|
225
|
+
const onConnection = (socket: Socket): void => {
|
|
161
226
|
sockets.add(socket);
|
|
162
227
|
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
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.
|
|
173
238
|
/* c8 ignore start */
|
|
174
239
|
if (socket.writable) {
|
|
175
|
-
socket.write(`ERROR :Closing link: (
|
|
240
|
+
socket.write(`ERROR :Closing link: (${reason})\r\n`);
|
|
176
241
|
}
|
|
177
242
|
/* c8 ignore stop */
|
|
178
243
|
socket.destroy();
|
|
179
244
|
sockets.delete(socket);
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
const admissionRecordId = decision.recordId;
|
|
183
|
-
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
184
|
-
|
|
185
|
-
const { state, actor } = attachConnection(runtime, opts, messages, services, history, clock, {
|
|
186
|
-
sendText: (text) => {
|
|
187
|
-
if (socket.writable) socket.write(text);
|
|
188
|
-
},
|
|
189
|
-
closeTransport: () => socket.destroy(),
|
|
190
|
-
admissionRecordId,
|
|
191
|
-
sourceHost: ip,
|
|
192
|
-
});
|
|
193
|
-
connections.set(socket, { state, actor });
|
|
245
|
+
};
|
|
194
246
|
|
|
195
247
|
socket.on('data', (chunk: Buffer) => {
|
|
196
|
-
|
|
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) => {
|
|
197
290
|
logError('actor failure', err);
|
|
198
291
|
});
|
|
199
292
|
});
|
|
@@ -201,14 +294,36 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
201
294
|
const cleanup = (): void => {
|
|
202
295
|
connections.delete(socket);
|
|
203
296
|
sockets.delete(socket);
|
|
204
|
-
|
|
297
|
+
// Rejected-before-admission flows never registered a connection.
|
|
298
|
+
if (bindings !== undefined) {
|
|
299
|
+
runtime.unregisterConnection(bindings.state.id);
|
|
300
|
+
}
|
|
205
301
|
};
|
|
206
302
|
socket.on('close', cleanup);
|
|
207
303
|
socket.on('error', (err) => {
|
|
208
304
|
logError('tcp socket error', err);
|
|
209
305
|
cleanup();
|
|
210
306
|
});
|
|
211
|
-
}
|
|
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
|
+
}
|
|
212
327
|
|
|
213
328
|
// Auto-snapshot timer.
|
|
214
329
|
const snapshotIntervalMs = opts.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS;
|
|
@@ -220,41 +335,85 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
220
335
|
: undefined;
|
|
221
336
|
if (timer !== undefined) timer.unref?.();
|
|
222
337
|
|
|
223
|
-
|
|
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) => {
|
|
224
399
|
const onError = (err: Error): void => {
|
|
225
|
-
|
|
400
|
+
server.removeListener('listening', onListening);
|
|
226
401
|
reject(err);
|
|
227
402
|
};
|
|
228
403
|
const onListening = (): void => {
|
|
229
|
-
|
|
230
|
-
const addr =
|
|
404
|
+
server.removeListener('error', onError);
|
|
405
|
+
const addr = server.address();
|
|
231
406
|
// Inside the 'listening' handler `address()` is always an AddressInfo
|
|
232
|
-
// (TCP server, just bound); the null/string returns only occur for
|
|
233
|
-
// 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.
|
|
234
409
|
/* c8 ignore start */
|
|
235
|
-
const
|
|
410
|
+
const bound = typeof addr === 'object' && addr !== null ? addr.port : port;
|
|
236
411
|
/* c8 ignore stop */
|
|
237
|
-
resolve(
|
|
238
|
-
port,
|
|
239
|
-
host,
|
|
240
|
-
runtime,
|
|
241
|
-
messages,
|
|
242
|
-
history,
|
|
243
|
-
testSockets: sockets,
|
|
244
|
-
close: () =>
|
|
245
|
-
closeServer({
|
|
246
|
-
tcpServer,
|
|
247
|
-
connections,
|
|
248
|
-
sockets,
|
|
249
|
-
timer,
|
|
250
|
-
runtime,
|
|
251
|
-
persistencePath: opts.persistencePath,
|
|
252
|
-
}),
|
|
253
|
-
});
|
|
412
|
+
resolve(bound);
|
|
254
413
|
};
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
414
|
+
server.once('listening', onListening);
|
|
415
|
+
server.once('error', onError);
|
|
416
|
+
server.listen(port, host);
|
|
258
417
|
});
|
|
259
418
|
}
|
|
260
419
|
|
|
@@ -274,8 +433,9 @@ function attachConnection(
|
|
|
274
433
|
): ConnectionBindings {
|
|
275
434
|
const id = randomUUID();
|
|
276
435
|
const state = createConnection({ id, connectedSince: clock.now() });
|
|
277
|
-
// `transport.sourceHost` is always supplied (set to the
|
|
278
|
-
// 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.
|
|
279
439
|
/* c8 ignore start */
|
|
280
440
|
if (transport.sourceHost !== undefined) {
|
|
281
441
|
state.host = transport.sourceHost;
|
|
@@ -300,6 +460,11 @@ function attachConnection(
|
|
|
300
460
|
quitMessage: opts.quitMessage ?? 'Client Quit',
|
|
301
461
|
...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
|
|
302
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 },
|
|
303
468
|
},
|
|
304
469
|
clock,
|
|
305
470
|
ids: DEFAULT_ID_FACTORY,
|
|
@@ -308,9 +473,19 @@ function attachConnection(
|
|
|
308
473
|
...(services !== undefined ? { services } : {}),
|
|
309
474
|
history,
|
|
310
475
|
logger,
|
|
311
|
-
transport: new TcpByteStreamTransport(
|
|
312
|
-
|
|
313
|
-
|
|
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`.
|
|
314
489
|
secure: true,
|
|
315
490
|
});
|
|
316
491
|
|
|
@@ -335,14 +510,14 @@ function attachConnection(
|
|
|
335
510
|
}
|
|
336
511
|
|
|
337
512
|
async function closeServer(args: {
|
|
338
|
-
|
|
513
|
+
servers: Server[];
|
|
339
514
|
connections: Map<Socket, ConnectionBindings>;
|
|
340
515
|
sockets: Set<Socket>;
|
|
341
516
|
timer: ReturnType<typeof setInterval> | undefined;
|
|
342
517
|
runtime: InMemoryRuntime;
|
|
343
518
|
persistencePath: string | undefined;
|
|
344
519
|
}): Promise<void> {
|
|
345
|
-
const {
|
|
520
|
+
const { servers, connections, sockets, timer, runtime, persistencePath } = args;
|
|
346
521
|
|
|
347
522
|
if (timer !== undefined) clearInterval(timer);
|
|
348
523
|
|
|
@@ -364,10 +539,12 @@ async function closeServer(args: {
|
|
|
364
539
|
// Stop accepting new connections. We do NOT await the callback because
|
|
365
540
|
// `server.close()` waits for every connection's 'close' event, which can
|
|
366
541
|
// hang if a destroyed socket's TCP cleanup is delayed by the OS. All
|
|
367
|
-
// sockets are already destroyed above; the
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
+
}
|
|
371
548
|
}
|
|
372
549
|
|
|
373
550
|
async function snapshotNow(runtime: InMemoryRuntime, path: string): Promise<void> {
|