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
|
@@ -23,6 +23,23 @@ import type { StaticSiteProps } from '../src/static-site.js';
|
|
|
23
23
|
/** All five table names that must exist in the stack (PLAN §4). */
|
|
24
24
|
const TABLE_NAMES = ['ChannelMembers', 'ChannelMeta', 'Connections', 'Nicks', 'Services'] as const;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Raw synthesised CloudFormation node for a DynamoDB table. `DeletionPolicy`
|
|
28
|
+
* and `UpdateReplacePolicy` live at the resource root (siblings of
|
|
29
|
+
* `Properties`), so they are NOT visible via `Template.hasResourceProperties`
|
|
30
|
+
* — tests that assert the removal policy inspect the full node.
|
|
31
|
+
*/
|
|
32
|
+
type DynamoTableResource = {
|
|
33
|
+
Type: string;
|
|
34
|
+
DeletionPolicy?: string;
|
|
35
|
+
UpdateReplacePolicy?: string;
|
|
36
|
+
Properties?: {
|
|
37
|
+
TableName?: string;
|
|
38
|
+
PointInTimeRecoverySpecification?: { PointInTimeRecoveryEnabled?: boolean };
|
|
39
|
+
DeletionProtectionEnabled?: boolean;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
26
43
|
function makeTemplate(): { app: App; stack: Stack; template: Template } {
|
|
27
44
|
const app = new App();
|
|
28
45
|
const stack = new IrcAwsStack(app, 'TestStack');
|
|
@@ -99,7 +116,12 @@ describe('IrcAwsStack — DynamoDB tables', () => {
|
|
|
99
116
|
const { template } = makeTemplate();
|
|
100
117
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
101
118
|
TableName: 'Connections',
|
|
102
|
-
|
|
119
|
+
// connectionId (base key) plus the per-IP admission GSI keys.
|
|
120
|
+
AttributeDefinitions: [
|
|
121
|
+
{ AttributeName: 'connectionId', AttributeType: 'S' },
|
|
122
|
+
{ AttributeName: 'sourceIp', AttributeType: 'S' },
|
|
123
|
+
{ AttributeName: 'connectedSince', AttributeType: 'N' },
|
|
124
|
+
],
|
|
103
125
|
KeySchema: [{ AttributeName: 'connectionId', KeyType: 'HASH' }],
|
|
104
126
|
});
|
|
105
127
|
});
|
|
@@ -153,6 +175,173 @@ describe('IrcAwsStack — DynamoDB tables', () => {
|
|
|
153
175
|
});
|
|
154
176
|
});
|
|
155
177
|
|
|
178
|
+
describe('IrcAwsStack — DynamoDB table protection (RETAIN + PITR + deletion protection)', () => {
|
|
179
|
+
/**
|
|
180
|
+
* State-bearing tables hold user data (nicks, services credentials,
|
|
181
|
+
* channel state, membership) and MUST survive a mistaken `cdk destroy`,
|
|
182
|
+
* a stack-name collision, or a stray `aws dynamodb delete-table` from a
|
|
183
|
+
* compromised credential. `Connections` is the one exception: its rows
|
|
184
|
+
* are ephemeral per-connection records (TTL-reaped, sweeper-cleaned), so
|
|
185
|
+
* it stays DESTROY with no PITR and no deletion protection.
|
|
186
|
+
*/
|
|
187
|
+
const STATE_TABLES = ['ChannelMembers', 'ChannelMeta', 'Nicks', 'Services'] as const;
|
|
188
|
+
const EPHEMERAL_TABLE = 'Connections';
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Returns the synthesised `AWS::DynamoDB::Table` resources keyed by their
|
|
192
|
+
* physical `TableName`. Each value is the raw CloudFormation resource
|
|
193
|
+
* node, so `DeletionPolicy`, `UpdateReplacePolicy`, and the protection
|
|
194
|
+
* properties under `Properties` are all inspectable. `DeletionPolicy` lives
|
|
195
|
+
* at the resource root (sibling of `Properties`), so it is NOT visible to
|
|
196
|
+
* `hasResourceProperties` — the raw node is required.
|
|
197
|
+
*/
|
|
198
|
+
function dynamoTablesByName(template: Template): Record<
|
|
199
|
+
string,
|
|
200
|
+
{
|
|
201
|
+
Type: string;
|
|
202
|
+
DeletionPolicy?: string;
|
|
203
|
+
UpdateReplacePolicy?: string;
|
|
204
|
+
Properties?: {
|
|
205
|
+
TableName?: string;
|
|
206
|
+
PointInTimeRecoverySpecification?: { PointInTimeRecoveryEnabled?: boolean };
|
|
207
|
+
DeletionProtectionEnabled?: boolean;
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
> {
|
|
211
|
+
const tmpl = template.toJSON() as { Resources?: Record<string, unknown> };
|
|
212
|
+
const out: Record<string, DynamoTableResource> = {};
|
|
213
|
+
for (const raw of Object.values(tmpl.Resources ?? {})) {
|
|
214
|
+
const res = raw as { Type?: string; Properties?: { TableName?: string } };
|
|
215
|
+
if (res.Type !== 'AWS::DynamoDB::Table') {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const name = res.Properties?.TableName;
|
|
219
|
+
if (typeof name === 'string') {
|
|
220
|
+
out[name] = raw as DynamoTableResource;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
describe('default synth (no flags)', () => {
|
|
227
|
+
it.each(STATE_TABLES)('RETAINs the %s table (DeletionPolicy=Retain)', (name) => {
|
|
228
|
+
const { template } = makeTemplate();
|
|
229
|
+
expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Retain');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it.each(STATE_TABLES)('sets UpdateReplacePolicy=Retain on the %s table', (name) => {
|
|
233
|
+
const { template } = makeTemplate();
|
|
234
|
+
expect(dynamoTablesByName(template)[name]?.UpdateReplacePolicy).toBe('Retain');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('keeps the Connections table DESTROY (DeletionPolicy=Delete)', () => {
|
|
238
|
+
const { template } = makeTemplate();
|
|
239
|
+
expect(dynamoTablesByName(template)[EPHEMERAL_TABLE]?.DeletionPolicy).toBe('Delete');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it.each(STATE_TABLES)('enables point-in-time recovery on the %s table', (name) => {
|
|
243
|
+
const { template } = makeTemplate();
|
|
244
|
+
expect(
|
|
245
|
+
dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
|
|
246
|
+
?.PointInTimeRecoveryEnabled,
|
|
247
|
+
).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('does NOT enable point-in-time recovery on the Connections table', () => {
|
|
251
|
+
const { template } = makeTemplate();
|
|
252
|
+
expect(
|
|
253
|
+
dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.PointInTimeRecoverySpecification,
|
|
254
|
+
).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it.each(STATE_TABLES)('enables deletion protection on the %s table', (name) => {
|
|
258
|
+
const { template } = makeTemplate();
|
|
259
|
+
expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('does NOT enable deletion protection on the Connections table', () => {
|
|
263
|
+
const { template } = makeTemplate();
|
|
264
|
+
expect(
|
|
265
|
+
dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.DeletionProtectionEnabled,
|
|
266
|
+
).toBeUndefined();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('allowTableDeletion=true synth (teardown mode)', () => {
|
|
271
|
+
function makeTeardownTemplate(): Template {
|
|
272
|
+
const app = new App();
|
|
273
|
+
const stack = new IrcAwsStack(app, 'TestStack', { allowTableDeletion: true });
|
|
274
|
+
return Template.fromStack(stack);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
it.each(STATE_TABLES)('flips the %s table to DESTROY (DeletionPolicy=Delete)', (name) => {
|
|
278
|
+
const template = makeTeardownTemplate();
|
|
279
|
+
expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Delete');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it.each(STATE_TABLES)('disables deletion protection on the %s table', (name) => {
|
|
283
|
+
const template = makeTeardownTemplate();
|
|
284
|
+
expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('keeps the Connections table DESTROY', () => {
|
|
288
|
+
const template = makeTeardownTemplate();
|
|
289
|
+
expect(dynamoTablesByName(template)[EPHEMERAL_TABLE]?.DeletionPolicy).toBe('Delete');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('still keeps point-in-time recovery enabled on state tables in teardown mode', () => {
|
|
293
|
+
// allowTableDeletion and enablePitr are independent flags: a legitimate
|
|
294
|
+
// teardown still keeps PITR on (it is harmless and the data is still
|
|
295
|
+
// recoverable up until the table is actually deleted). Disabling PITR
|
|
296
|
+
// is a separate, deliberate opt-in via enablePitr=false.
|
|
297
|
+
const template = makeTeardownTemplate();
|
|
298
|
+
for (const name of STATE_TABLES) {
|
|
299
|
+
expect(
|
|
300
|
+
dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
|
|
301
|
+
?.PointInTimeRecoveryEnabled,
|
|
302
|
+
).toBe(true);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('enablePitr=false synth (PITR disabled)', () => {
|
|
308
|
+
function makeNoPitrTemplate(): Template {
|
|
309
|
+
const app = new App();
|
|
310
|
+
const stack = new IrcAwsStack(app, 'TestStack', { enablePitr: false });
|
|
311
|
+
return Template.fromStack(stack);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
it.each(STATE_TABLES)('disables point-in-time recovery on the %s table', (name) => {
|
|
315
|
+
const template = makeNoPitrTemplate();
|
|
316
|
+
expect(
|
|
317
|
+
dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
|
|
318
|
+
?.PointInTimeRecoveryEnabled,
|
|
319
|
+
).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('still does NOT enable point-in-time recovery on the Connections table', () => {
|
|
323
|
+
const template = makeNoPitrTemplate();
|
|
324
|
+
expect(
|
|
325
|
+
dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.PointInTimeRecoverySpecification,
|
|
326
|
+
).toBeUndefined();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('keeps state tables RETAIN with deletion protection when PITR is disabled', () => {
|
|
330
|
+
// enablePitr=false narrows ONLY the PITR knob; the RETAIN +
|
|
331
|
+
// deletion-protection guarantees are independent of PITR and must
|
|
332
|
+
// remain on so a disabled-PITR deploy is still protected from
|
|
333
|
+
// accidental stack deletion.
|
|
334
|
+
const template = makeNoPitrTemplate();
|
|
335
|
+
for (const name of STATE_TABLES) {
|
|
336
|
+
expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Retain');
|
|
337
|
+
expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(
|
|
338
|
+
true,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
156
345
|
describe('IrcAwsStack — API Gateway v2 WebSocket API', () => {
|
|
157
346
|
it('creates a WebSocket protocol API Gateway v2 API', () => {
|
|
158
347
|
const { template } = makeTemplate();
|
|
@@ -346,15 +535,12 @@ describe('IrcAwsStack — API Gateway logging', () => {
|
|
|
346
535
|
).toBe(true);
|
|
347
536
|
});
|
|
348
537
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
// in every partition); CDK renders the statement as a separate
|
|
356
|
-
// AWS::IAM::Policy attached to the role.
|
|
357
|
-
const { template } = makeTemplate();
|
|
538
|
+
/**
|
|
539
|
+
* Flattens the IAM actions granted by the inline policy CDK attaches to
|
|
540
|
+
* the `ApiGatewayLoggingRole` (`addToPolicy` renders as a separate
|
|
541
|
+
* `AWS::IAM::Policy` resource referencing the role).
|
|
542
|
+
*/
|
|
543
|
+
function loggingRoleActions(template: Template): string[] {
|
|
358
544
|
const roles = template.findResources('AWS::IAM::Role') as Record<string, unknown>;
|
|
359
545
|
const loggingRoleEntry = Object.entries(roles).find(([id]) =>
|
|
360
546
|
id.startsWith('ApiGatewayLoggingRole'),
|
|
@@ -379,12 +565,54 @@ describe('IrcAwsStack — API Gateway logging', () => {
|
|
|
379
565
|
const stmts = attached?.Properties?.PolicyDocument?.Statement ?? [];
|
|
380
566
|
const flat = (a: string | string[] | undefined): string[] =>
|
|
381
567
|
Array.isArray(a) ? a : a !== undefined ? [a] : [];
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
//
|
|
387
|
-
|
|
568
|
+
return stmts.flatMap((s) => flat(s.Action));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
it('grants the logging role exactly the three CloudWatch Logs WRITE actions across ALL log groups', () => {
|
|
572
|
+
// APIGW writes execution logs to its OWN AWS/ApiGateway... log groups
|
|
573
|
+
// (separate from this stack's access-log group), so the role must be
|
|
574
|
+
// able to write broadly — a single-log-group ARN scope is rejected by
|
|
575
|
+
// APIGW account-role validation. The grant set is EXACTLY the three
|
|
576
|
+
// write actions APIGW needs; the legacy inline policy additionally
|
|
577
|
+
// carried Describe* actions, which let a compromised role enumerate
|
|
578
|
+
// (and, via the managed policy AWS offers for this purpose, read)
|
|
579
|
+
// arbitrary log groups in the account. Exact-set assertion so any
|
|
580
|
+
// future widening fails this test.
|
|
581
|
+
const { template } = makeTemplate();
|
|
582
|
+
expect([...loggingRoleActions(template)].sort()).toEqual([
|
|
583
|
+
'logs:CreateLogGroup',
|
|
584
|
+
'logs:CreateLogStream',
|
|
585
|
+
'logs:PutLogEvents',
|
|
586
|
+
]);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('does not grant the logging role any log-enumeration or log-read actions', () => {
|
|
590
|
+
const { template } = makeTemplate();
|
|
591
|
+
const actions = loggingRoleActions(template);
|
|
592
|
+
expect(
|
|
593
|
+
actions.filter((a) => /^logs:(Describe|Get|Filter)/.test(a)),
|
|
594
|
+
'logging role must not enumerate or read log groups',
|
|
595
|
+
).toEqual([]);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('attaches no AWS-managed policies to the logging role (documented inline decision)', () => {
|
|
599
|
+
// Decision record: the AmazonAPIGatewayPushToCloudWatchLogs managed
|
|
600
|
+
// policy was considered and REJECTED. Its fixed grant set is broader
|
|
601
|
+
// than the tightened inline policy — it still grants DescribeLogGroups
|
|
602
|
+
// and DescribeLogStreams (the actions this stack deliberately drops)
|
|
603
|
+
// plus GetLogEvents and FilterLogEvents, all on Resource '*' — and AWS
|
|
604
|
+
// can widen its contents without this stack being re-reviewed. The
|
|
605
|
+
// inline policy keeps the grant set exact and auditable at synth time.
|
|
606
|
+
const { template } = makeTemplate();
|
|
607
|
+
const roles = template.findResources('AWS::IAM::Role') as Record<
|
|
608
|
+
string,
|
|
609
|
+
{ Properties?: { ManagedPolicyArns?: unknown } }
|
|
610
|
+
>;
|
|
611
|
+
const loggingRole = Object.entries(roles).find(([id]) =>
|
|
612
|
+
id.startsWith('ApiGatewayLoggingRole'),
|
|
613
|
+
);
|
|
614
|
+
expect(loggingRole, 'expected an ApiGatewayLoggingRole').toBeDefined();
|
|
615
|
+
expect(loggingRole?.[1]?.Properties?.ManagedPolicyArns).toBeUndefined();
|
|
388
616
|
});
|
|
389
617
|
});
|
|
390
618
|
|
|
@@ -1012,6 +1240,101 @@ describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
|
|
|
1012
1240
|
});
|
|
1013
1241
|
});
|
|
1014
1242
|
|
|
1243
|
+
/**
|
|
1244
|
+
* Collects every IAM policy statement attached to the NLB handler
|
|
1245
|
+
* Lambda's execution role — the role's inline `Policies` plus every
|
|
1246
|
+
* separate `AWS::IAM::Policy` resource referencing it (CDK renders
|
|
1247
|
+
* `grant*` calls as the role's `DefaultPolicy` resource).
|
|
1248
|
+
*/
|
|
1249
|
+
function nlbRoleStatements(template: Template): Array<Record<string, unknown>> {
|
|
1250
|
+
const fns = template.findResources('AWS::Lambda::Function') as Record<
|
|
1251
|
+
string,
|
|
1252
|
+
{ Properties?: { Role?: { 'Fn::GetAtt'?: string[] } | { Ref?: string } } }
|
|
1253
|
+
>;
|
|
1254
|
+
const nlbFn = Object.entries(fns).find(([id]) => id.startsWith('IrcNlbHandler'));
|
|
1255
|
+
if (nlbFn === undefined) {
|
|
1256
|
+
throw new Error('No Lambda with logical id starting with "IrcNlbHandler"');
|
|
1257
|
+
}
|
|
1258
|
+
const role = nlbFn[1]?.Properties?.Role as
|
|
1259
|
+
| { 'Fn::GetAtt'?: string[]; Ref?: string }
|
|
1260
|
+
| undefined;
|
|
1261
|
+
const getAtt = role?.['Fn::GetAtt'];
|
|
1262
|
+
const roleLogicalId = Array.isArray(getAtt) ? getAtt[0] : role?.Ref;
|
|
1263
|
+
if (typeof roleLogicalId !== 'string') {
|
|
1264
|
+
throw new Error('IrcNlbHandler Lambda has no resolvable execution role');
|
|
1265
|
+
}
|
|
1266
|
+
const statements: Array<Record<string, unknown>> = [];
|
|
1267
|
+
const roles = template.findResources('AWS::IAM::Role') as Record<
|
|
1268
|
+
string,
|
|
1269
|
+
{ Properties?: { Policies?: Array<{ PolicyDocument?: { Statement?: unknown[] } }> } }
|
|
1270
|
+
>;
|
|
1271
|
+
for (const inline of roles[roleLogicalId]?.Properties?.Policies ?? []) {
|
|
1272
|
+
statements.push(
|
|
1273
|
+
...((inline.PolicyDocument?.Statement ?? []) as Array<Record<string, unknown>>),
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
const policies = template.findResources('AWS::IAM::Policy') as Record<
|
|
1277
|
+
string,
|
|
1278
|
+
{
|
|
1279
|
+
Properties?: {
|
|
1280
|
+
Roles?: Array<{ Ref?: string }>;
|
|
1281
|
+
PolicyDocument?: { Statement?: unknown[] };
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
>;
|
|
1285
|
+
for (const policy of Object.values(policies)) {
|
|
1286
|
+
const attached = (policy.Properties?.Roles ?? []).some((r) => r.Ref === roleLogicalId);
|
|
1287
|
+
if (attached) {
|
|
1288
|
+
statements.push(
|
|
1289
|
+
...((policy.Properties?.PolicyDocument?.Statement ?? []) as Array<
|
|
1290
|
+
Record<string, unknown>
|
|
1291
|
+
>),
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return statements;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** True when a statement's Action includes `execute-api:ManageConnections`. */
|
|
1299
|
+
function managesConnections(stmt: Record<string, unknown>): boolean {
|
|
1300
|
+
const action = stmt.Action;
|
|
1301
|
+
return (
|
|
1302
|
+
action === 'execute-api:ManageConnections' ||
|
|
1303
|
+
(Array.isArray(action) && action.includes('execute-api:ManageConnections'))
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
it('grants the NLB handler Lambda execute-api:ManageConnections on the stage', () => {
|
|
1308
|
+
// The NLB stream handler passes MANAGEMENT_URL into AwsRuntime and
|
|
1309
|
+
// fans channel messages out to *other* connections via
|
|
1310
|
+
// ApiGatewayManagementApi.postToConnection — including wss clients
|
|
1311
|
+
// on the API Gateway transport. Without the stage grant on the NLB
|
|
1312
|
+
// handler's own execution role, every such cross-transport delivery
|
|
1313
|
+
// fails with 403.
|
|
1314
|
+
const { template } = makeTcpTemplate();
|
|
1315
|
+
const manages = nlbRoleStatements(template).filter(managesConnections);
|
|
1316
|
+
expect(
|
|
1317
|
+
manages.length,
|
|
1318
|
+
'NLB handler role must be granted execute-api:ManageConnections',
|
|
1319
|
+
).toBeGreaterThan(0);
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
it('scopes the NLB handler ManageConnections grant to this stack API stage connections', () => {
|
|
1323
|
+
// Least privilege: the grant must target THIS stack's WebSocket API
|
|
1324
|
+
// stage connections resource (…/apis/<api>/prod/*/@connections/*),
|
|
1325
|
+
// never a wildcard API or account-wide resource.
|
|
1326
|
+
const { template } = makeTcpTemplate();
|
|
1327
|
+
const apiIds = Object.keys(template.findResources('AWS::ApiGatewayV2::Api')).filter((id) =>
|
|
1328
|
+
id.startsWith('IrcWsApi'),
|
|
1329
|
+
);
|
|
1330
|
+
expect(apiIds).toHaveLength(1);
|
|
1331
|
+
const manages = nlbRoleStatements(template).filter(managesConnections);
|
|
1332
|
+
const resourceJson = JSON.stringify(manages.map((s) => s.Resource));
|
|
1333
|
+
expect(resourceJson).toContain('@connections');
|
|
1334
|
+
expect(resourceJson).toContain('/prod/');
|
|
1335
|
+
expect(resourceJson).toContain(apiIds[0] ?? '');
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1015
1338
|
it('still creates the wss WebSocket API (both transports coexist)', () => {
|
|
1016
1339
|
const { template } = makeTcpTemplate();
|
|
1017
1340
|
template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
|
|
@@ -1122,3 +1445,114 @@ describe('IrcAwsStack — web client hosting (StaticSite)', () => {
|
|
|
1122
1445
|
});
|
|
1123
1446
|
});
|
|
1124
1447
|
});
|
|
1448
|
+
|
|
1449
|
+
describe('IrcAwsStack — API Gateway stage throttling', () => {
|
|
1450
|
+
it('exposes ApiThrottlingRateLimit as a configurable Number parameter (default 100)', () => {
|
|
1451
|
+
const { template } = makeTemplate();
|
|
1452
|
+
const p = parameters(template).ApiThrottlingRateLimit;
|
|
1453
|
+
expect(p).toBeDefined();
|
|
1454
|
+
expect(p?.Type).toBe('Number');
|
|
1455
|
+
// CfnParameter renders a numeric default as a JSON number.
|
|
1456
|
+
expect(Number(p?.Default)).toBe(100);
|
|
1457
|
+
expect(typeof p?.Description).toBe('string');
|
|
1458
|
+
expect(p?.Description?.length).toBeGreaterThan(0);
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
it('exposes ApiThrottlingBurstLimit as a configurable Number parameter (default 200)', () => {
|
|
1462
|
+
const { template } = makeTemplate();
|
|
1463
|
+
const p = parameters(template).ApiThrottlingBurstLimit;
|
|
1464
|
+
expect(p).toBeDefined();
|
|
1465
|
+
expect(p?.Type).toBe('Number');
|
|
1466
|
+
expect(Number(p?.Default)).toBe(200);
|
|
1467
|
+
expect(typeof p?.Description).toBe('string');
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
it('wires the stage default-route throttling to the parameters (Ref)', () => {
|
|
1471
|
+
// Stage-level throttling is the global backstop behind the per-IP
|
|
1472
|
+
// admission gates: it bounds aggregate request rate into the API
|
|
1473
|
+
// (connect floods included) before a Lambda invocation is billed.
|
|
1474
|
+
const { template } = makeTemplate();
|
|
1475
|
+
template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
|
|
1476
|
+
DefaultRouteSettings: {
|
|
1477
|
+
ThrottlingRateLimit: { Ref: 'ApiThrottlingRateLimit' },
|
|
1478
|
+
ThrottlingBurstLimit: { Ref: 'ApiThrottlingBurstLimit' },
|
|
1479
|
+
},
|
|
1480
|
+
});
|
|
1481
|
+
});
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
describe('IrcAwsStack — Connections sourceIp GSI', () => {
|
|
1485
|
+
it('adds a sourceIp+connectedSince GSI to the Connections table', () => {
|
|
1486
|
+
// The per-IP admission gates ($connect simultaneous cap + rate
|
|
1487
|
+
// window, NLB new-flow rate) consult this index; without it the
|
|
1488
|
+
// handler would need a full-table Scan per connect.
|
|
1489
|
+
const { template } = makeTemplate();
|
|
1490
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
1491
|
+
TableName: 'Connections',
|
|
1492
|
+
GlobalSecondaryIndexes: [
|
|
1493
|
+
{
|
|
1494
|
+
IndexName: 'sourceIp-connectedSince',
|
|
1495
|
+
KeySchema: [
|
|
1496
|
+
{ AttributeName: 'sourceIp', KeyType: 'HASH' },
|
|
1497
|
+
{ AttributeName: 'connectedSince', KeyType: 'RANGE' },
|
|
1498
|
+
],
|
|
1499
|
+
},
|
|
1500
|
+
],
|
|
1501
|
+
});
|
|
1502
|
+
});
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
describe('IrcAwsStack — WAF rate-based rule on $connect', () => {
|
|
1506
|
+
/** Synths a stack with the optional edge rate limit configured. */
|
|
1507
|
+
function makeWafTemplate(limit: number): Template {
|
|
1508
|
+
const app = new App();
|
|
1509
|
+
const stack = new IrcAwsStack(app, 'TestStack', { wafConnectRateLimit: limit });
|
|
1510
|
+
return Template.fromStack(stack);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
it('emits no WebACL by default (the knob is opt-in)', () => {
|
|
1514
|
+
const { template } = makeTemplate();
|
|
1515
|
+
template.resourceCountIs('AWS::WAFv2::WebACL', 0);
|
|
1516
|
+
template.resourceCountIs('AWS::WAFv2::WebACLAssociation', 0);
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
it('emits a REGIONAL WebACL with a per-IP rate-based rule when the knob is set', () => {
|
|
1520
|
+
// WAF on a WebSocket API only inspects the initial HTTP upgrade —
|
|
1521
|
+
// the `$connect` route — so a plain rate-based statement is
|
|
1522
|
+
// effectively scoped to connection establishment.
|
|
1523
|
+
const template = makeWafTemplate(2000);
|
|
1524
|
+
template.resourceCountIs('AWS::WAFv2::WebACL', 1);
|
|
1525
|
+
template.hasResourceProperties('AWS::WAFv2::WebACL', {
|
|
1526
|
+
Scope: 'REGIONAL',
|
|
1527
|
+
DefaultAction: { Allow: {} },
|
|
1528
|
+
Rules: [
|
|
1529
|
+
{
|
|
1530
|
+
Name: 'ConnectRateLimit',
|
|
1531
|
+
Priority: 0,
|
|
1532
|
+
Action: { Block: {} },
|
|
1533
|
+
Statement: {
|
|
1534
|
+
RateBasedStatement: { Limit: 2000, AggregateKeyType: 'IP' },
|
|
1535
|
+
},
|
|
1536
|
+
},
|
|
1537
|
+
],
|
|
1538
|
+
});
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
it('associates the WebACL with the WebSocket stage', () => {
|
|
1542
|
+
const template = makeWafTemplate(2000);
|
|
1543
|
+
template.resourceCountIs('AWS::WAFv2::WebACLAssociation', 1);
|
|
1544
|
+
const assoc = template.findResources('AWS::WAFv2::WebACLAssociation') as Record<
|
|
1545
|
+
string,
|
|
1546
|
+
{ Properties?: { ResourceArn?: unknown; WebACLArn?: unknown } }
|
|
1547
|
+
>;
|
|
1548
|
+
const entry = Object.values(assoc)[0];
|
|
1549
|
+
expect(entry?.Properties?.WebACLArn).toBeDefined();
|
|
1550
|
+
// ResourceArn must be the API Gateway stage ARN (Fn::Join over the
|
|
1551
|
+
// partition/region refs + the WebSocket Api Ref + the stage name),
|
|
1552
|
+
// not a dangling literal.
|
|
1553
|
+
const arn = JSON.stringify(entry?.Properties?.ResourceArn);
|
|
1554
|
+
expect(arn).toContain('apigateway');
|
|
1555
|
+
expect(arn).toContain('/stages/prod');
|
|
1556
|
+
expect(arn).toContain('IrcWsApi');
|
|
1557
|
+
});
|
|
1558
|
+
});
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
# Cloudflare Container / Docker image for the ServerlessIRCd TCP origin.
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# TLS terminates INSIDE this container on :6697 (Spectrum runs in
|
|
4
|
+
# `tls_mode = "full"`, re-encrypting edge→origin) — no plaintext IRC
|
|
5
|
+
# ever crosses the origin network. The container runs the IRC core
|
|
6
|
+
# (ConnectionActor + InMemoryRuntime) over the TcpByteStreamTransport
|
|
7
|
+
# seam behind its own TLS listener.
|
|
6
8
|
#
|
|
7
9
|
# Build context: the repository root (monorepo). The container needs the
|
|
8
10
|
# irc-core, irc-server, and in-memory-runtime packages.
|
|
9
11
|
#
|
|
10
12
|
# docker build -f apps/cf-tcp-container/Dockerfile -t sirc-tcp-origin .
|
|
11
|
-
# docker run -p
|
|
13
|
+
# docker run -p 6697:6697 \
|
|
14
|
+
# -v $(pwd)/certs:/data/certs:ro \
|
|
15
|
+
# -e SERVER_NAME=irc.example.com sirc-tcp-origin
|
|
16
|
+
#
|
|
17
|
+
# Certificates: mount a PEM cert/key pair at /data/certs/ (the image
|
|
18
|
+
# defaults TLS_CERT_PATH=/data/certs/tls.pem, TLS_KEY_PATH=/data/certs/
|
|
19
|
+
# tls.key). Let's Encrypt, Bring-Your-Own, and Cloudflare Origin Certs
|
|
20
|
+
# are all supported — see docs/Cloudflare-TCP-Deployment.md. Cert files
|
|
21
|
+
# must be readable by UID 1000 (node).
|
|
12
22
|
#
|
|
13
23
|
# For Cloudflare Containers deployment, the same image is referenced from
|
|
14
24
|
# wrangler.toml.
|
|
@@ -57,13 +67,35 @@ COPY --from=builder /app/apps/cf-tcp-container/dist/ apps/cf-tcp-container/dist/
|
|
|
57
67
|
RUN corepack enable && corepack prepare pnpm@11.17.0 --activate && \
|
|
58
68
|
pnpm install --frozen-lockfile --prod
|
|
59
69
|
|
|
70
|
+
# Runtime-writable state: the persistence snapshot lives in /data, and the
|
|
71
|
+
# runtime process runs as the unprivileged `node` user — so the directory
|
|
72
|
+
# must exist and be node-owned before privileges drop (chown needs root).
|
|
73
|
+
RUN mkdir -p /data && chown node:node /data
|
|
74
|
+
|
|
60
75
|
# Persistence volume: Spectrum-origin channel state survives restarts.
|
|
61
76
|
VOLUME ["/data"]
|
|
62
77
|
|
|
63
|
-
|
|
78
|
+
# Cert files for TLS termination at the origin: mount the PEM pair at
|
|
79
|
+
# /data/certs/ (see ENV defaults below) — Let's Encrypt, BYO, or a
|
|
80
|
+
# Cloudflare Origin Cert all work. Any cert mount must be readable by
|
|
81
|
+
# UID 1000 (node) — mount them owned by 1000:1000.
|
|
82
|
+
|
|
83
|
+
# Drop root: the IRC stack (parser + actor + transport) handles untrusted
|
|
84
|
+
# input, so it must not run as UID 0. node:24-slim ships the non-root
|
|
85
|
+
# `node` user (UID 1000) out of the box.
|
|
86
|
+
USER node
|
|
87
|
+
|
|
88
|
+
EXPOSE 6697
|
|
64
89
|
|
|
90
|
+
# The image is a production origin by default: the config loader then
|
|
91
|
+
# refuses to boot without TLS material (no silent plaintext fallback).
|
|
92
|
+
ENV NODE_ENV=production
|
|
65
93
|
ENV TCP_PORT=6667
|
|
66
94
|
ENV TCP_HOST=0.0.0.0
|
|
67
95
|
ENV PERSISTENCE_PATH=/data/state.json
|
|
96
|
+
# Documented cert mount path (override per-deploy via TLS_CERT_PATH /
|
|
97
|
+
# TLS_KEY_PATH if the pair lives elsewhere).
|
|
98
|
+
ENV TLS_CERT_PATH=/data/certs/tls.pem
|
|
99
|
+
ENV TLS_KEY_PATH=/data/certs/tls.key
|
|
68
100
|
|
|
69
101
|
CMD ["node", "--enable-source-maps", "apps/cf-tcp-container/dist/main.js"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-tcp-container",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Cloudflare Container TCP origin for the irc+tls :6697 transport — Spectrum terminates TLS at the edge, this container runs the IRC core over plaintext TCP",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"build": "tsc -p tsconfig.build.json",
|
|
11
11
|
"typecheck": "tsc -p tsconfig.test.json --noEmit",
|
|
12
12
|
"start": "node --enable-source-maps ./dist/main.js",
|
|
13
|
-
"deploy": "
|
|
13
|
+
"deploy": "node ../../tools/ci-hardening/dist/cf-deploy-cli.js wrangler.toml",
|
|
14
14
|
"test": "vitest run",
|
|
15
15
|
"test:watch": "vitest",
|
|
16
16
|
"coverage": "vitest run --coverage",
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
"@serverless-ircd/irc-core": "workspace:*",
|
|
22
22
|
"@serverless-ircd/irc-server": "workspace:*"
|
|
23
23
|
},
|
|
24
|
+
"proxyProtocol": {
|
|
25
|
+
"version": 1,
|
|
26
|
+
"parser": "hand-rolled (src/proxy-protocol.ts)",
|
|
27
|
+
"rationale": "PROXY protocol v1 is a single fixed-format ASCII line, fully validated in ~40 lines; no npm proxy-protocol dependency is pulled onto the connection hot path (supply-chain surface > value)."
|
|
28
|
+
},
|
|
24
29
|
"devDependencies": {
|
|
25
30
|
"@cloudflare/containers": "^0.1.0",
|
|
26
31
|
"@types/node": "^26.1.1",
|