serverless-ircd 0.3.0 → 0.4.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +174 -18
- package/README.md +63 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +20 -3
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +6 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +35 -2
- package/packages/aws-adapter/src/handlers/index.ts +69 -3
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +6 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +154 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +2 -4
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +111 -46
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +33 -9
- package/packages/cf-adapter/src/index.ts +9 -8
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +8 -1
- package/packages/cf-adapter/wrangler.test.toml +8 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +58 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +181 -12
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +38 -1
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +158 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +12 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +400 -3
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +4 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { type Socket, createConnection } from 'node:net';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
type Server,
|
|
9
|
+
type TLSSocket,
|
|
10
|
+
connect as tlsConnect,
|
|
11
|
+
createServer as tlsCreateServer,
|
|
12
|
+
} from 'node:tls';
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
14
|
+
import { type ContainerServer, startContainerServer } from '../src/container-server.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generates a self-signed TLS certificate for the test proxy using the
|
|
18
|
+
* system `openssl` binary. This mirrors what Cloudflare Spectrum does in
|
|
19
|
+
* production: terminate TLS at the edge with a certificate, then forward
|
|
20
|
+
* plaintext TCP to the origin.
|
|
21
|
+
*
|
|
22
|
+
* If `openssl` is not available, the calling test is skipped — the TLS
|
|
23
|
+
* e2e requires it to synthesise a trust anchor.
|
|
24
|
+
*/
|
|
25
|
+
async function generateSelfSignedCert(dir: string): Promise<{ key: string; cert: string }> {
|
|
26
|
+
const keyPath = join(dir, 'key.pem');
|
|
27
|
+
const certPath = join(dir, 'cert.pem');
|
|
28
|
+
try {
|
|
29
|
+
execFileSync(
|
|
30
|
+
'openssl',
|
|
31
|
+
[
|
|
32
|
+
'req',
|
|
33
|
+
'-x509',
|
|
34
|
+
'-newkey',
|
|
35
|
+
'rsa:2048',
|
|
36
|
+
'-keyout',
|
|
37
|
+
keyPath,
|
|
38
|
+
'-out',
|
|
39
|
+
certPath,
|
|
40
|
+
'-days',
|
|
41
|
+
'1',
|
|
42
|
+
'-nodes',
|
|
43
|
+
'-subj',
|
|
44
|
+
'/CN=localhost',
|
|
45
|
+
],
|
|
46
|
+
{ stdio: 'pipe' },
|
|
47
|
+
);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error('openssl not available — required to generate test TLS cert');
|
|
50
|
+
}
|
|
51
|
+
const key = await readFile(keyPath, 'utf8');
|
|
52
|
+
const cert = await readFile(certPath, 'utf8');
|
|
53
|
+
return { key, cert };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* IRC test client that speaks TLS (mirrors the Spectrum client path).
|
|
58
|
+
* Connects via `tls.connect`, sends lines with CRLF, parses inbound bytes.
|
|
59
|
+
*/
|
|
60
|
+
class TlsIrcClient extends EventEmitter {
|
|
61
|
+
readonly socket: TLSSocket;
|
|
62
|
+
readonly received: string[] = [];
|
|
63
|
+
|
|
64
|
+
constructor(host: string, port: number, opts: { key: string; cert: string }) {
|
|
65
|
+
super();
|
|
66
|
+
this.socket = tlsConnect({
|
|
67
|
+
host,
|
|
68
|
+
port,
|
|
69
|
+
rejectUnauthorized: false,
|
|
70
|
+
ca: opts.cert,
|
|
71
|
+
});
|
|
72
|
+
this.socket.setEncoding('utf8');
|
|
73
|
+
this.socket.on('data', (text: string) => {
|
|
74
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
75
|
+
if (line.length > 0) {
|
|
76
|
+
this.received.push(line);
|
|
77
|
+
this.emit('line', line);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
secured(): Promise<void> {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
if (this.socket.authorized !== undefined) resolve();
|
|
86
|
+
else {
|
|
87
|
+
this.socket.once('secure', () => resolve());
|
|
88
|
+
this.socket.once('error', reject);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
send(line: string): Promise<void> {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
this.socket.write(`${line}\r\n`, (err) => (err ? reject(err) : resolve()));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
waitFor(predicate: (line: string) => boolean, timeoutMs = 3000): Promise<string> {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const timeout = setTimeout(() => {
|
|
102
|
+
reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`));
|
|
103
|
+
}, timeoutMs);
|
|
104
|
+
const check = (line: string): void => {
|
|
105
|
+
if (predicate(line)) {
|
|
106
|
+
clearTimeout(timeout);
|
|
107
|
+
this.off('line', check);
|
|
108
|
+
resolve(line);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
for (const line of this.received) {
|
|
112
|
+
if (predicate(line)) {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
resolve(line);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
this.on('line', check);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
close(): Promise<void> {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
this.socket.once('close', () => resolve());
|
|
125
|
+
this.socket.end();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Starts an in-process TLS-terminating proxy (the "stunnel" replacement).
|
|
132
|
+
* Each TLS connection from a client gets a plaintext TCP connection piped
|
|
133
|
+
* to the container origin. This is the local analogue of Cloudflare
|
|
134
|
+
* Spectrum: TLS at the edge, plaintext to the origin.
|
|
135
|
+
*/
|
|
136
|
+
function startTlsProxy(opts: {
|
|
137
|
+
key: string;
|
|
138
|
+
cert: string;
|
|
139
|
+
originHost: string;
|
|
140
|
+
originPort: number;
|
|
141
|
+
}): Promise<{ server: Server; port: number; close: () => Promise<void> }> {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const originSockets = new Set<Socket>();
|
|
144
|
+
const server = tlsCreateServer({ key: opts.key, cert: opts.cert }, (tlsSocket) => {
|
|
145
|
+
const origin = createConnection({ host: opts.originHost, port: opts.originPort });
|
|
146
|
+
originSockets.add(origin);
|
|
147
|
+
tlsSocket.pipe(origin);
|
|
148
|
+
origin.pipe(tlsSocket);
|
|
149
|
+
const cleanup = (): void => {
|
|
150
|
+
origin.destroy();
|
|
151
|
+
tlsSocket.destroy();
|
|
152
|
+
};
|
|
153
|
+
tlsSocket.on('close', cleanup);
|
|
154
|
+
origin.on('close', () => {
|
|
155
|
+
originSockets.delete(origin);
|
|
156
|
+
tlsSocket.destroy();
|
|
157
|
+
});
|
|
158
|
+
tlsSocket.on('error', cleanup);
|
|
159
|
+
origin.on('error', cleanup);
|
|
160
|
+
});
|
|
161
|
+
server.once('error', reject);
|
|
162
|
+
server.listen(0, '127.0.0.1', () => {
|
|
163
|
+
server.removeListener('error', reject);
|
|
164
|
+
const addr = server.address();
|
|
165
|
+
// c8 ignore next 1
|
|
166
|
+
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
|
167
|
+
resolve({
|
|
168
|
+
server,
|
|
169
|
+
port,
|
|
170
|
+
close: () =>
|
|
171
|
+
new Promise<void>((res) => {
|
|
172
|
+
for (const s of originSockets) s.destroy();
|
|
173
|
+
originSockets.clear();
|
|
174
|
+
server.close(() => res());
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
describe('container-server — TLS e2e via in-process proxy (Spectrum analogue)', () => {
|
|
182
|
+
let origin: ContainerServer;
|
|
183
|
+
let proxy: { server: Server; port: number; close: () => Promise<void> };
|
|
184
|
+
let dir: string;
|
|
185
|
+
let cert: { key: string; cert: string };
|
|
186
|
+
|
|
187
|
+
beforeAll(async () => {
|
|
188
|
+
dir = await mkdtemp(join(tmpdir(), 'sirc-tls-'));
|
|
189
|
+
try {
|
|
190
|
+
cert = await generateSelfSignedCert(dir);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// Skip the entire suite if openssl isn't available.
|
|
193
|
+
console.warn(String((err as Error).message));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
origin = await startContainerServer({
|
|
197
|
+
port: 0,
|
|
198
|
+
host: '127.0.0.1',
|
|
199
|
+
serverName: 'irc.tls.test',
|
|
200
|
+
networkName: 'TlsTestNet',
|
|
201
|
+
});
|
|
202
|
+
proxy = await startTlsProxy({
|
|
203
|
+
key: cert.key,
|
|
204
|
+
cert: cert.cert,
|
|
205
|
+
originHost: '127.0.0.1',
|
|
206
|
+
originPort: origin.port,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
afterAll(async () => {
|
|
211
|
+
if (proxy !== undefined) await proxy.close();
|
|
212
|
+
if (origin !== undefined) await origin.close();
|
|
213
|
+
await rm(dir, { recursive: true, force: true });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('a TLS client completes register → join → privmsg → quit through the proxy', async () => {
|
|
217
|
+
if (cert === undefined) return; // openssl not available
|
|
218
|
+
|
|
219
|
+
const alice = new TlsIrcClient('127.0.0.1', proxy.port, cert);
|
|
220
|
+
await alice.secured();
|
|
221
|
+
const bob = new TlsIrcClient('127.0.0.1', proxy.port, cert);
|
|
222
|
+
await bob.secured();
|
|
223
|
+
|
|
224
|
+
await alice.send('NICK alice');
|
|
225
|
+
await alice.send('USER alice 0 * :Alice');
|
|
226
|
+
await alice.waitFor((l) => l.startsWith(':irc.tls.test 001 '));
|
|
227
|
+
|
|
228
|
+
await bob.send('NICK bob');
|
|
229
|
+
await bob.send('USER bob 0 * :Bob');
|
|
230
|
+
await bob.waitFor((l) => l.startsWith(':irc.tls.test 001 '));
|
|
231
|
+
|
|
232
|
+
await alice.send('JOIN #tls-e2e');
|
|
233
|
+
await alice.waitFor((l) => l.includes('JOIN #tls-e2e'));
|
|
234
|
+
await bob.send('JOIN #tls-e2e');
|
|
235
|
+
await bob.waitFor((l) => l.includes('JOIN #tls-e2e'));
|
|
236
|
+
|
|
237
|
+
await alice.send('PRIVMSG #tls-e2e :over tls');
|
|
238
|
+
await bob.waitFor((l) => l.includes('PRIVMSG #tls-e2e :over tls'));
|
|
239
|
+
|
|
240
|
+
await alice.close();
|
|
241
|
+
await bob.close();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('routes PING → PONG over TLS', async () => {
|
|
245
|
+
if (cert === undefined) return;
|
|
246
|
+
|
|
247
|
+
const client = new TlsIrcClient('127.0.0.1', proxy.port, cert);
|
|
248
|
+
await client.secured();
|
|
249
|
+
await client.send('NICK tlsPing');
|
|
250
|
+
await client.send('USER tlsPing 0 * :TlsPing');
|
|
251
|
+
await client.waitFor((l) => l.startsWith(':irc.tls.test 001 '));
|
|
252
|
+
|
|
253
|
+
await client.send('PING :tls-tok');
|
|
254
|
+
await client.waitFor((l) => l === 'PONG :tls-tok');
|
|
255
|
+
await client.close();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('the origin only sees plaintext (no TLS handshake bytes reach the actor)', async () => {
|
|
259
|
+
if (cert === undefined) return;
|
|
260
|
+
|
|
261
|
+
// If the origin received TLS handshake bytes, the TcpByteStreamTransport
|
|
262
|
+
// would try to parse them as IRC lines and produce garbage 421 replies.
|
|
263
|
+
// A clean registration proves only plaintext IRC lines arrived.
|
|
264
|
+
const client = new TlsIrcClient('127.0.0.1', proxy.port, cert);
|
|
265
|
+
await client.secured();
|
|
266
|
+
await client.send('NICK plaintext');
|
|
267
|
+
await client.send('USER plaintext 0 * :Plaintext');
|
|
268
|
+
const welcome = await client.waitFor((l) => l.startsWith(':irc.tls.test 001 plaintext'));
|
|
269
|
+
expect(welcome).toBeDefined();
|
|
270
|
+
// No 421 (ERR_UNKNOWNCOMMAND) should appear — that would indicate
|
|
271
|
+
// non-IRC bytes (TLS handshake) leaking through to the parser.
|
|
272
|
+
expect(client.received.some((l) => l.includes(' 421 '))).toBe(false);
|
|
273
|
+
await client.close();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"composite": true,
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"tsBuildInfoFile": "./.tsbuildinfo",
|
|
8
|
+
"types": ["node"]
|
|
9
|
+
},
|
|
10
|
+
"include": ["src/**/*.ts"],
|
|
11
|
+
"exclude": ["dist", "tests", "**/*.test.ts", "src/worker.ts", "src/container-do.ts"],
|
|
12
|
+
"references": [
|
|
13
|
+
{ "path": "../../packages/irc-core/tsconfig.build.json" },
|
|
14
|
+
{ "path": "../../packages/irc-server/tsconfig.build.json" },
|
|
15
|
+
{ "path": "../../packages/in-memory-runtime/tsconfig.build.json" }
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": true,
|
|
5
|
+
"types": ["node"],
|
|
6
|
+
"composite": false
|
|
7
|
+
},
|
|
8
|
+
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
|
9
|
+
"exclude": ["dist", "src/worker.ts", "src/container-do.ts"],
|
|
10
|
+
"references": [
|
|
11
|
+
{ "path": "../../packages/irc-core/tsconfig.build.json" },
|
|
12
|
+
{ "path": "../../packages/irc-server/tsconfig.build.json" },
|
|
13
|
+
{ "path": "../../packages/in-memory-runtime/tsconfig.build.json" }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: 'node',
|
|
6
|
+
testTimeout: 10_000,
|
|
7
|
+
include: ['tests/**/*.test.ts'],
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: 'v8',
|
|
10
|
+
all: false,
|
|
11
|
+
include: ['src/**/*.ts'],
|
|
12
|
+
// `main.ts` is CLI glue (argv parsing, signal handlers); exercised
|
|
13
|
+
// manually by `pnpm start`. `worker.ts` + `container-do.ts` are
|
|
14
|
+
// wrangler-only deploy artifacts (depend on @cloudflare/containers,
|
|
15
|
+
// compiled by wrangler at deploy time). The rest targets ≥90%.
|
|
16
|
+
exclude: ['src/main.ts', 'src/worker.ts', 'src/container-do.ts'],
|
|
17
|
+
reporter: ['text', 'html', 'json-summary'],
|
|
18
|
+
thresholds: {
|
|
19
|
+
lines: 90,
|
|
20
|
+
functions: 90,
|
|
21
|
+
branches: 80,
|
|
22
|
+
statements: 90,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#:vim:set ft=toml:
|
|
2
|
+
##
|
|
3
|
+
## Cloudflare Containers config for the ServerlessIRCd TCP origin.
|
|
4
|
+
##
|
|
5
|
+
## Deploys the IRC TCP container (apps/cf-tcp-container) to the Cloudflare
|
|
6
|
+
## Containers platform. The container listens on port 6667 (plaintext TCP);
|
|
7
|
+
## Cloudflare Spectrum (configured separately in terraform/spectrum.tf)
|
|
8
|
+
## terminates TLS on :6697 and forwards plaintext to this container.
|
|
9
|
+
##
|
|
10
|
+
## Prerequisites:
|
|
11
|
+
## - Cloudflare account with Containers beta access.
|
|
12
|
+
## - wrangler v4.112+ (`pnpm add -g wrangler`).
|
|
13
|
+
## - The container image is built from apps/cf-tcp-container/Dockerfile.
|
|
14
|
+
##
|
|
15
|
+
## Deploy:
|
|
16
|
+
## wrangler deploy --env staging
|
|
17
|
+
##
|
|
18
|
+
|
|
19
|
+
name = "sirc-tcp-origin"
|
|
20
|
+
main = "src/worker.ts"
|
|
21
|
+
compatibility_date = "2024-11-01"
|
|
22
|
+
compatibility_flags = ["nodejs_compat"]
|
|
23
|
+
|
|
24
|
+
[observability]
|
|
25
|
+
enabled = true
|
|
26
|
+
|
|
27
|
+
# --- Container definition ---
|
|
28
|
+
# The IRC TCP origin container. Listens on port 6667; the Worker below is a
|
|
29
|
+
# thin management/health-check wrapper (Spectrum routes TCP directly to the
|
|
30
|
+
# container origin, not through the Worker).
|
|
31
|
+
[[containers]]
|
|
32
|
+
class_name = "IrcTcpOrigin"
|
|
33
|
+
image = "./Dockerfile"
|
|
34
|
+
instance_type = "standard-1"
|
|
35
|
+
max_instances = 3
|
|
36
|
+
sleep_after = "2h"
|
|
37
|
+
|
|
38
|
+
# --- Durable Object binding for the container ---
|
|
39
|
+
[[durable_objects.bindings]]
|
|
40
|
+
name = "IRC_TCP_ORIGIN"
|
|
41
|
+
class_name = "IrcTcpOrigin"
|
|
42
|
+
|
|
43
|
+
# --- Migration ---
|
|
44
|
+
[[migrations]]
|
|
45
|
+
tag = "v1"
|
|
46
|
+
new_sqlite_classes = ["IrcTcpOrigin"]
|
|
47
|
+
|
|
48
|
+
# --- Environment ---
|
|
49
|
+
[vars]
|
|
50
|
+
SERVER_NAME = "irc.example.com"
|
|
51
|
+
NETWORK_NAME = "ExampleNet"
|
|
52
|
+
MOTD_LINES = "Welcome to the ServerlessIRCd TCP origin."
|
|
53
|
+
TCP_PORT = "6667"
|
|
54
|
+
PERSISTENCE_PATH = "/data/state.json"
|
|
55
|
+
|
|
56
|
+
# --- Staging ---
|
|
57
|
+
[env.staging]
|
|
58
|
+
name = "sirc-tcp-origin-staging"
|
|
59
|
+
|
|
60
|
+
[env.staging.vars]
|
|
61
|
+
SERVER_NAME = "irc-staging.example.com"
|
|
62
|
+
NETWORK_NAME = "StagingNet"
|
|
63
|
+
MOTD_LINES = "Welcome to the staging ServerlessIRCd TCP origin."
|
|
@@ -32,6 +32,12 @@ class_name = "ChannelDO"
|
|
|
32
32
|
name = "CHANNEL_REGISTRY_DO"
|
|
33
33
|
class_name = "ChannelRegistryDO"
|
|
34
34
|
|
|
35
|
+
# D1 backing for persistent SASL accounts
|
|
36
|
+
[[d1_databases]]
|
|
37
|
+
binding = "ACCOUNTS_DB"
|
|
38
|
+
database_name = "cf-worker-test-accounts"
|
|
39
|
+
database_id = "test-d1-worker-accounts"
|
|
40
|
+
|
|
35
41
|
[[migrations]]
|
|
36
42
|
tag = "v1"
|
|
37
43
|
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
|
|
@@ -50,7 +50,10 @@ invocation_logs = true
|
|
|
50
50
|
[vars]
|
|
51
51
|
SERVER_NAME = "irc.example.com"
|
|
52
52
|
NETWORK_NAME = "ServerlessIRCd"
|
|
53
|
-
MOTD_LINES = "
|
|
53
|
+
MOTD_LINES = """
|
|
54
|
+
Welcome to ServerlessIRCd.
|
|
55
|
+
This is a deployed Cloudflare Worker.
|
|
56
|
+
"""
|
|
54
57
|
|
|
55
58
|
## ---------------------------------------------------------------------------
|
|
56
59
|
## Staging environment.
|
|
@@ -68,7 +71,30 @@ head_sampling_rate = 1
|
|
|
68
71
|
[env.staging.vars]
|
|
69
72
|
SERVER_NAME = "irc-staging.example.com"
|
|
70
73
|
NETWORK_NAME = "ServerlessIRCd (staging)"
|
|
71
|
-
MOTD_LINES = "
|
|
74
|
+
MOTD_LINES = """
|
|
75
|
+
Welcome to ServerlessIRCd staging.
|
|
76
|
+
This is a deployed Cloudflare Worker.
|
|
77
|
+
This Worker was deployed by CI from the main branch.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
## ---------------------------------------------------------------------------
|
|
81
|
+
## D1 database — SASL account persistence.
|
|
82
|
+
## ---------------------------------------------------------------------------
|
|
83
|
+
## The `accounts` table stores scrypt-hashed SASL PLAIN credentials (never
|
|
84
|
+
## plaintext). Loaded at ConnectionDO construction; when empty or unreachable
|
|
85
|
+
## the resolver falls back to the `SASL_ACCOUNTS` env-var seed. Provision with:
|
|
86
|
+
## wrangler d1 create serverless-ircd-accounts
|
|
87
|
+
## wrangler d1 execute serverless-ircd-accounts --command \
|
|
88
|
+
## "CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)"
|
|
89
|
+
## Seed accounts via `tools/seed-cf-accounts.ts`.
|
|
90
|
+
[[d1_databases]]
|
|
91
|
+
binding = "ACCOUNTS_DB"
|
|
92
|
+
database_name = "serverless-ircd-accounts"
|
|
93
|
+
# database_id is filled in by `wrangler d1 create` and is env-specific.
|
|
94
|
+
|
|
95
|
+
[[env.staging.d1_databases]]
|
|
96
|
+
binding = "ACCOUNTS_DB"
|
|
97
|
+
database_name = "serverless-ircd-accounts-staging"
|
|
72
98
|
|
|
73
99
|
## ---------------------------------------------------------------------------
|
|
74
100
|
## Durable Object bindings.
|
|
@@ -21,6 +21,16 @@ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc
|
|
|
21
21
|
export interface CliConfigInput {
|
|
22
22
|
serverName: string;
|
|
23
23
|
networkName: string;
|
|
24
|
+
/**
|
|
25
|
+
* Server version surfaced in `002`/`004`/`351`/`371`. When omitted the
|
|
26
|
+
* schema default ({@link DEFAULT_SERVER_VERSION}) applies.
|
|
27
|
+
*/
|
|
28
|
+
serverVersion?: string;
|
|
29
|
+
/**
|
|
30
|
+
* "Created" text for `003 RPL_CREATED`. A string is used verbatim; a
|
|
31
|
+
* number is treated as epoch-ms and formatted as a UTC timestamp.
|
|
32
|
+
*/
|
|
33
|
+
createdAt?: string | number;
|
|
24
34
|
motdLines?: string[];
|
|
25
35
|
channelPrefixes?: string;
|
|
26
36
|
maxClients?: number;
|
|
@@ -35,9 +35,11 @@ import {
|
|
|
35
35
|
ConsoleLogger,
|
|
36
36
|
InMemoryAccountStore,
|
|
37
37
|
InMemoryMessageStore,
|
|
38
|
+
InMemoryNickHistoryStore,
|
|
38
39
|
LogLevel,
|
|
39
40
|
type Logger,
|
|
40
41
|
type MessageStore,
|
|
42
|
+
type NickHistoryStore,
|
|
41
43
|
type RawLine,
|
|
42
44
|
createConnection,
|
|
43
45
|
} from '@serverless-ircd/irc-core';
|
|
@@ -162,6 +164,12 @@ export interface LocalServer {
|
|
|
162
164
|
* one client is replayable by another. Exposed for test assertions.
|
|
163
165
|
*/
|
|
164
166
|
readonly messages: MessageStore;
|
|
167
|
+
/**
|
|
168
|
+
* The per-process nick-history store backing `WHOWAS`. Shared across
|
|
169
|
+
* every connection so a nick signed off by one client is queryable by
|
|
170
|
+
* another. Exposed for test assertions.
|
|
171
|
+
*/
|
|
172
|
+
readonly history: NickHistoryStore;
|
|
165
173
|
close(): Promise<void>;
|
|
166
174
|
/**
|
|
167
175
|
* Test-only seam: live server-side WebSockets. Exposed so tests can
|
|
@@ -311,6 +319,12 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
311
319
|
// is replayable when bob queries CHATHISTORY). The cap is config-driven.
|
|
312
320
|
const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
|
|
313
321
|
|
|
322
|
+
// Per-process nick-history store: one TTL-buffer shared by every
|
|
323
|
+
// connection so cross-connection WHOWAS works (a nick alice quits is
|
|
324
|
+
// queryable by bob). Driven by the real-system clock so TTL eviction
|
|
325
|
+
// matches wall time.
|
|
326
|
+
const history = new InMemoryNickHistoryStore({ now: () => Date.now() });
|
|
327
|
+
|
|
314
328
|
// SASL account store: seeded from config so `AUTHENTICATE PLAIN` works
|
|
315
329
|
// end-to-end. `undefined` (no accounts configured) leaves the actor's
|
|
316
330
|
// `ctx.accounts` unset, preserving the no-store behaviour.
|
|
@@ -336,7 +350,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
336
350
|
const admissionRecordId = decision.recordId;
|
|
337
351
|
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
338
352
|
|
|
339
|
-
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
|
|
353
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
|
|
340
354
|
sendText: (text) => {
|
|
341
355
|
if (ws.readyState === WebSocket.OPEN) ws.send(text);
|
|
342
356
|
},
|
|
@@ -413,7 +427,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
413
427
|
const admissionRecordId = decision.recordId;
|
|
414
428
|
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
415
429
|
|
|
416
|
-
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
|
|
430
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
|
|
417
431
|
sendText: (text) => {
|
|
418
432
|
if (socket.writable) socket.write(text);
|
|
419
433
|
},
|
|
@@ -478,6 +492,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
478
492
|
hostname,
|
|
479
493
|
runtime,
|
|
480
494
|
messages,
|
|
495
|
+
history,
|
|
481
496
|
testSockets: wss.clients,
|
|
482
497
|
testTcpSockets: tcpSockets,
|
|
483
498
|
close: () =>
|
|
@@ -506,6 +521,7 @@ function attachConnection(
|
|
|
506
521
|
cfg: ResolvedServerConfig,
|
|
507
522
|
messages: MessageStore,
|
|
508
523
|
accounts: AccountStore | undefined,
|
|
524
|
+
history: NickHistoryStore,
|
|
509
525
|
transport: {
|
|
510
526
|
sendText: (text: string) => void;
|
|
511
527
|
closeTransport: () => void;
|
|
@@ -549,6 +565,7 @@ function attachConnection(
|
|
|
549
565
|
motd: { lines: () => cfg.motdLines },
|
|
550
566
|
messages,
|
|
551
567
|
...(accounts !== undefined ? { accounts } : {}),
|
|
568
|
+
history,
|
|
552
569
|
logger,
|
|
553
570
|
});
|
|
554
571
|
|
|
@@ -641,4 +658,4 @@ function stringifyErr(err: unknown): unknown {
|
|
|
641
658
|
}
|
|
642
659
|
|
|
643
660
|
// Suppress "ChanName imported but unused" — kept for type re-export ergonomics.
|
|
644
|
-
export type { ChanName, ConnectionState };
|
|
661
|
+
export type { ChanName, ConnectionState, NickHistoryStore };
|
package/package.json
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "serverless-ircd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
7
7
|
"engines": {
|
|
8
|
-
"node": ">=
|
|
8
|
+
"node": ">=24"
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
|
-
"@biomejs/biome": "^1.9.
|
|
12
|
-
"@vitest/coverage-v8": "^4.1.
|
|
13
|
-
"fast-check": "^3.
|
|
14
|
-
"rimraf": "^6.
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
11
|
+
"@biomejs/biome": "^1.9.4",
|
|
12
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
13
|
+
"fast-check": "^3.23.2",
|
|
14
|
+
"rimraf": "^6.1.3",
|
|
15
|
+
"tsx": "^4.19.0",
|
|
16
|
+
"turbo": "^2.10.7",
|
|
17
|
+
"typescript": "^5.9.3",
|
|
18
|
+
"vite": "^7.3.6",
|
|
19
|
+
"vitest": "^4.1.10",
|
|
20
|
+
"@serverless-ircd/aws-adapter": "0.4.0"
|
|
19
21
|
},
|
|
20
22
|
"scripts": {
|
|
21
23
|
"build": "turbo run build",
|
|
@@ -33,6 +35,8 @@
|
|
|
33
35
|
"deploy:cf:staging": "pnpm --filter @serverless-ircd/cf-worker deploy:staging",
|
|
34
36
|
"deploy:cf:prod": "pnpm --filter @serverless-ircd/cf-worker deploy:prod",
|
|
35
37
|
"smoke:cf:staging": "pnpm --filter @serverless-ircd/cf-worker smoke:staging",
|
|
38
|
+
"deploy:cf-tcp:staging": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:staging",
|
|
39
|
+
"deploy:cf-tcp:prod": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:prod",
|
|
36
40
|
"deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
|
|
37
41
|
"deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
|
|
38
42
|
"smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/aws-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@serverless-ircd/irc-test-support": "workspace:*",
|
|
39
39
|
"@types/node": "^26.1.1",
|
|
40
40
|
"@vitest/coverage-v8": "^4.1.0",
|
|
41
|
-
"aws-cdk-lib": "^2.
|
|
41
|
+
"aws-cdk-lib": "^2.262.0",
|
|
42
42
|
"aws-sdk-client-mock": "^4.1.0",
|
|
43
43
|
"constructs": "^10.4.0",
|
|
44
44
|
"rimraf": "^6.0.0",
|
|
@@ -47,6 +47,6 @@
|
|
|
47
47
|
"vitest": "^4.1.0"
|
|
48
48
|
},
|
|
49
49
|
"engines": {
|
|
50
|
-
"node": ">=
|
|
50
|
+
"node": ">=24"
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -28,8 +28,8 @@ import {
|
|
|
28
28
|
type ParsedServerConfig,
|
|
29
29
|
} from '@serverless-ircd/irc-core';
|
|
30
30
|
import {
|
|
31
|
-
type HashedAccountCredential,
|
|
32
31
|
DynamoAccountStore,
|
|
32
|
+
type HashedAccountCredential,
|
|
33
33
|
hashAccountCredential,
|
|
34
34
|
loadDynamoAccountStore,
|
|
35
35
|
} from './dynamo-account-store.js';
|