serverless-ircd 0.7.0 → 0.9.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 +7 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +518 -29
- package/README.md +221 -161
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/container-server.ts +21 -1
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +95 -31
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/index.html +237 -16
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +46 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +43 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +578 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +20 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +130 -8
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +200 -0
- package/packages/irc-core/src/commands/chanserv.ts +1166 -0
- package/packages/irc-core/src/commands/hostserv.ts +522 -0
- package/packages/irc-core/src/commands/index.ts +13 -0
- package/packages/irc-core/src/commands/join.ts +171 -9
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +402 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +78 -12
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +60 -10
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +38 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
- package/packages/irc-core/tests/commands/join.test.ts +572 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +121 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +988 -15
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* These are the primary red→green tests for the infrastructure stack:
|
|
5
5
|
* they assert the exact CloudFormation shape that `cdk synth` must
|
|
6
|
-
* produce —
|
|
6
|
+
* produce — six DynamoDB tables with the PLAN §4 schema, a single
|
|
7
7
|
* Node 24 Lambda wired to all three WebSocket routes, the API Gateway
|
|
8
8
|
* v2 WebSocket API, the two stack outputs, and a least-privilege guard
|
|
9
9
|
* that no IAM policy grants `Resource: '*'` or `Action: '*'`.
|
|
@@ -44,10 +44,44 @@ function makeTemplate(environmentName?: string): { app: App; stack: Stack; templ
|
|
|
44
44
|
return { app, stack, template };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Returns the synthesised CloudFormation `Parameters` section as a record. */
|
|
48
|
+
function parameters(
|
|
49
|
+
template: Template,
|
|
50
|
+
): Record<string, { Type: string; Default?: string; Description?: string }> {
|
|
51
|
+
const tmpl = template.toJSON() as {
|
|
52
|
+
Parameters?: Record<string, { Type: string; Default?: string; Description?: string }>;
|
|
53
|
+
};
|
|
54
|
+
return tmpl.Parameters ?? {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Finds the `AWS::Events::Rule` whose target `Arn` references a Lambda
|
|
59
|
+
* with the given logical-id prefix (CDK appends a hash suffix, e.g.
|
|
60
|
+
* `IrcSweeper7D484D9E`). Match is via `Fn::GetAtt: [<id>, Arn]`.
|
|
61
|
+
*/
|
|
62
|
+
function findRuleTargetingLambda(
|
|
63
|
+
template: Template,
|
|
64
|
+
fnIdPrefix: string,
|
|
65
|
+
):
|
|
66
|
+
| { Properties?: { ScheduleExpression?: unknown; Targets?: Array<{ Arn?: unknown }> } }
|
|
67
|
+
| undefined {
|
|
68
|
+
const rules = template.findResources('AWS::Events::Rule') as Record<
|
|
69
|
+
string,
|
|
70
|
+
{ Properties?: { Targets?: Array<{ Arn?: unknown }> } }
|
|
71
|
+
>;
|
|
72
|
+
return Object.values(rules).find((rule) =>
|
|
73
|
+
(rule.Properties?.Targets ?? []).some((t) => {
|
|
74
|
+
const getAtt = (t.Arn as { 'Fn::GetAtt'?: string[] } | undefined)?.['Fn::GetAtt'];
|
|
75
|
+
const id = Array.isArray(getAtt) ? getAtt[0] : undefined;
|
|
76
|
+
return typeof id === 'string' && id.startsWith(fnIdPrefix);
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
47
81
|
describe('IrcAwsStack — DynamoDB tables', () => {
|
|
48
|
-
it('creates exactly
|
|
82
|
+
it('creates exactly six DynamoDB tables', () => {
|
|
49
83
|
const { template } = makeTemplate();
|
|
50
|
-
template.resourceCountIs('AWS::DynamoDB::Table',
|
|
84
|
+
template.resourceCountIs('AWS::DynamoDB::Table', 6);
|
|
51
85
|
});
|
|
52
86
|
|
|
53
87
|
it.each(TABLE_NAMES)('creates the %s table', (tableName) => {
|
|
@@ -125,6 +159,21 @@ describe('IrcAwsStack — DynamoDB tables', () => {
|
|
|
125
159
|
KeySchema: [{ AttributeName: 'account', KeyType: 'HASH' }],
|
|
126
160
|
});
|
|
127
161
|
});
|
|
162
|
+
|
|
163
|
+
it('keys the Services table by composite pk/sk (String/String)', () => {
|
|
164
|
+
const { template } = makeTemplate();
|
|
165
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
166
|
+
TableName: prefixedTableName(DEFAULT_ENV, 'Services'),
|
|
167
|
+
AttributeDefinitions: [
|
|
168
|
+
{ AttributeName: 'pk', AttributeType: 'S' },
|
|
169
|
+
{ AttributeName: 'sk', AttributeType: 'S' },
|
|
170
|
+
],
|
|
171
|
+
KeySchema: [
|
|
172
|
+
{ AttributeName: 'pk', KeyType: 'HASH' },
|
|
173
|
+
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
});
|
|
128
177
|
});
|
|
129
178
|
|
|
130
179
|
describe('IrcAwsStack — API Gateway v2 WebSocket API', () => {
|
|
@@ -171,7 +220,7 @@ describe('IrcAwsStack — IRCv3 WebSocket subprotocol echo', () => {
|
|
|
171
220
|
const { template } = makeTemplate();
|
|
172
221
|
template.hasResourceProperties('AWS::ApiGatewayV2::IntegrationResponse', {
|
|
173
222
|
ResponseParameters: {
|
|
174
|
-
'
|
|
223
|
+
'route.response.header.Sec-WebSocket-Protocol':
|
|
175
224
|
'integration.response.header.Sec-WebSocket-Protocol',
|
|
176
225
|
},
|
|
177
226
|
});
|
|
@@ -197,6 +246,163 @@ describe('IrcAwsStack — IRCv3 WebSocket subprotocol echo', () => {
|
|
|
197
246
|
});
|
|
198
247
|
});
|
|
199
248
|
|
|
249
|
+
describe('IrcAwsStack — API Gateway logging', () => {
|
|
250
|
+
/** Returns the synthesised CloudFormation `Parameters` section. */
|
|
251
|
+
function parameters(
|
|
252
|
+
template: Template,
|
|
253
|
+
): Record<
|
|
254
|
+
string,
|
|
255
|
+
{ Type: string; Default?: string; AllowedValues?: string[]; Description?: string }
|
|
256
|
+
> {
|
|
257
|
+
const tmpl = template.toJSON() as {
|
|
258
|
+
Parameters?: Record<
|
|
259
|
+
string,
|
|
260
|
+
{
|
|
261
|
+
Type: string;
|
|
262
|
+
Default?: string;
|
|
263
|
+
AllowedValues?: string[];
|
|
264
|
+
Description?: string;
|
|
265
|
+
}
|
|
266
|
+
>;
|
|
267
|
+
};
|
|
268
|
+
return tmpl.Parameters ?? {};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
it('exposes ApiGatewayLoggingLevel as a configurable String parameter (default INFO)', () => {
|
|
272
|
+
const { template } = makeTemplate();
|
|
273
|
+
const p = parameters(template).ApiGatewayLoggingLevel;
|
|
274
|
+
expect(p).toBeDefined();
|
|
275
|
+
expect(p?.Type).toBe('String');
|
|
276
|
+
expect(p?.Default).toBe('INFO');
|
|
277
|
+
expect(typeof p?.Description).toBe('string');
|
|
278
|
+
expect(p?.Description?.length).toBeGreaterThan(0);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('restricts ApiGatewayLoggingLevel to OFF/INFO/ERROR', () => {
|
|
282
|
+
const { template } = makeTemplate();
|
|
283
|
+
expect(parameters(template).ApiGatewayLoggingLevel?.AllowedValues).toEqual([
|
|
284
|
+
'OFF',
|
|
285
|
+
'INFO',
|
|
286
|
+
'ERROR',
|
|
287
|
+
]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('wires the stage execution logging level to the parameter (Ref) with data tracing on', () => {
|
|
291
|
+
const { template } = makeTemplate();
|
|
292
|
+
template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
|
|
293
|
+
DefaultRouteSettings: {
|
|
294
|
+
LoggingLevel: { Ref: 'ApiGatewayLoggingLevel' },
|
|
295
|
+
DataTraceEnabled: true,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('creates a CloudWatch LogGroup for API Gateway access logs', () => {
|
|
301
|
+
const { template } = makeTemplate();
|
|
302
|
+
template.resourceCountIs('AWS::Logs::LogGroup', 1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('configures the API Gateway account CloudWatch role so execution logs can be written', () => {
|
|
306
|
+
const { template } = makeTemplate();
|
|
307
|
+
template.hasResourceProperties('AWS::ApiGateway::Account', {});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('wires the stage access log destination to the LogGroup', () => {
|
|
311
|
+
const { template } = makeTemplate();
|
|
312
|
+
const stages = template.findResources('AWS::ApiGatewayV2::Stage') as Record<
|
|
313
|
+
string,
|
|
314
|
+
{ Properties?: { AccessLogSettings?: { DestinationArn?: unknown } } }
|
|
315
|
+
>;
|
|
316
|
+
const stage = Object.values(stages)[0];
|
|
317
|
+
expect(stage?.Properties?.AccessLogSettings?.DestinationArn).toBeDefined();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('makes the stage depend on the account CloudWatch role so logging can enable', () => {
|
|
321
|
+
// Without an explicit DependsOn, CloudFormation may apply the Stage's
|
|
322
|
+
// logging config before the AWS::ApiGateway::Account resource sets the
|
|
323
|
+
// account-wide CloudWatch role, and APIGW rejects the update:
|
|
324
|
+
// "CloudWatch Logs role ARN must be set in account settings to enable logging"
|
|
325
|
+
const { template } = makeTemplate();
|
|
326
|
+
const accountIds = Object.keys(template.findResources('AWS::ApiGateway::Account'));
|
|
327
|
+
expect(accountIds).toHaveLength(1);
|
|
328
|
+
const stages = template.findResources('AWS::ApiGatewayV2::Stage') as Record<
|
|
329
|
+
string,
|
|
330
|
+
{ DependsOn?: string[] }
|
|
331
|
+
>;
|
|
332
|
+
const stage = Object.values(stages)[0];
|
|
333
|
+
expect(stage?.DependsOn).toBeDefined();
|
|
334
|
+
expect(stage?.DependsOn?.some((id) => accountIds.includes(id))).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('makes the API Gateway Account depend on the logging role permissions policy', () => {
|
|
338
|
+
// APIGW validates the account CloudWatch role's CONTENT (trust AND the
|
|
339
|
+
// attached permissions) when it processes the AWS::ApiGateway::Account
|
|
340
|
+
// resource. CDK renders `addToPolicy` as a SEPARATE AWS::IAM::Policy
|
|
341
|
+
// resource, and the Account only references the role Arn, so without an
|
|
342
|
+
// explicit DependsOn CloudFormation can apply the Account after the Role
|
|
343
|
+
// is created but BEFORE the policy attaches. APIGW then assumes a
|
|
344
|
+
// permission-less role and rejects the update:
|
|
345
|
+
// "The role ARN does not have required permissions configured."
|
|
346
|
+
const { template } = makeTemplate();
|
|
347
|
+
const accounts = template.findResources('AWS::ApiGateway::Account') as Record<
|
|
348
|
+
string,
|
|
349
|
+
{ DependsOn?: string[] }
|
|
350
|
+
>;
|
|
351
|
+
expect(Object.keys(accounts)).toHaveLength(1);
|
|
352
|
+
const account = Object.values(accounts)[0];
|
|
353
|
+
expect(
|
|
354
|
+
account?.DependsOn,
|
|
355
|
+
'Account must depend on the logging role permissions policy',
|
|
356
|
+
).toBeDefined();
|
|
357
|
+
const policyIds = new Set(Object.keys(template.findResources('AWS::IAM::Policy')));
|
|
358
|
+
expect(
|
|
359
|
+
account?.DependsOn?.some((id) => policyIds.has(id)),
|
|
360
|
+
`Account.DependsOn ${JSON.stringify(account?.DependsOn)} must include the logging role policy`,
|
|
361
|
+
).toBe(true);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('grants the logging role CloudWatch Logs write access across ALL log groups', () => {
|
|
365
|
+
// APIGW writes execution logs to its OWN AWS/ApiGateway... log groups
|
|
366
|
+
// (separate from this stack's access-log group), so the role must be
|
|
367
|
+
// able to write broadly — a single-log-group ARN scope is rejected by
|
|
368
|
+
// APIGW account-role validation. Defined inline (the
|
|
369
|
+
// AmazonAPIGatewayPushToCloudWatchLogs managed policy is not available
|
|
370
|
+
// in every partition); CDK renders the statement as a separate
|
|
371
|
+
// AWS::IAM::Policy attached to the role.
|
|
372
|
+
const { template } = makeTemplate();
|
|
373
|
+
const roles = template.findResources('AWS::IAM::Role') as Record<string, unknown>;
|
|
374
|
+
const loggingRoleEntry = Object.entries(roles).find(([id]) =>
|
|
375
|
+
id.startsWith('ApiGatewayLoggingRole'),
|
|
376
|
+
);
|
|
377
|
+
expect(loggingRoleEntry, 'expected an ApiGatewayLoggingRole').toBeDefined();
|
|
378
|
+
const loggingRoleId = loggingRoleEntry?.[0];
|
|
379
|
+
const policies = template.findResources('AWS::IAM::Policy') as Record<
|
|
380
|
+
string,
|
|
381
|
+
{
|
|
382
|
+
Properties?: {
|
|
383
|
+
Roles?: Array<{ Ref?: string }>;
|
|
384
|
+
PolicyDocument?: {
|
|
385
|
+
Statement?: Array<{ Action?: string | string[]; Resource?: unknown }>;
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
>;
|
|
390
|
+
const attached = Object.values(policies).find((p) =>
|
|
391
|
+
(p.Properties?.Roles ?? []).some((r) => r.Ref === loggingRoleId),
|
|
392
|
+
);
|
|
393
|
+
expect(attached, 'expected an inline policy attached to the logging role').toBeDefined();
|
|
394
|
+
const stmts = attached?.Properties?.PolicyDocument?.Statement ?? [];
|
|
395
|
+
const flat = (a: string | string[] | undefined): string[] =>
|
|
396
|
+
Array.isArray(a) ? a : a !== undefined ? [a] : [];
|
|
397
|
+
const actions = JSON.stringify(stmts.flatMap((s) => flat(s.Action)));
|
|
398
|
+
const resources = JSON.stringify(stmts.flatMap((s) => flat(s.Resource as string[])));
|
|
399
|
+
expect(actions).toContain('logs:PutLogEvents');
|
|
400
|
+
expect(actions).toContain('logs:CreateLogGroup');
|
|
401
|
+
// Broad scope — NOT narrowed to a single log-group ARN.
|
|
402
|
+
expect(resources).toContain('*');
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
200
406
|
describe('IrcAwsStack — Lambda', () => {
|
|
201
407
|
it('creates exactly three Lambda functions (handler + sweeper + pingChecker)', () => {
|
|
202
408
|
const { template } = makeTemplate();
|
|
@@ -212,18 +418,13 @@ describe('IrcAwsStack — Lambda', () => {
|
|
|
212
418
|
});
|
|
213
419
|
|
|
214
420
|
describe('IrcAwsStack — gone-connection sweeper', () => {
|
|
215
|
-
it('creates an EventBridge rule
|
|
421
|
+
it('creates an EventBridge rule targeting the sweeper Lambda', () => {
|
|
216
422
|
const { template } = makeTemplate();
|
|
217
|
-
template.
|
|
218
|
-
ScheduleExpression: 'rate(5 minutes)',
|
|
219
|
-
});
|
|
423
|
+
expect(findRuleTargetingLambda(template, 'IrcSweeper')).toBeDefined();
|
|
220
424
|
});
|
|
221
425
|
|
|
222
|
-
it('
|
|
426
|
+
it('grants EventBridge permission to invoke the sweeper', () => {
|
|
223
427
|
const { template } = makeTemplate();
|
|
224
|
-
template.hasResourceProperties('AWS::Events::Rule', {
|
|
225
|
-
ScheduleExpression: 'rate(5 minutes)',
|
|
226
|
-
});
|
|
227
428
|
// EventBridge needs permission to invoke each scheduled target. APIGW
|
|
228
429
|
// also creates one permission per route ($connect/$disconnect/$default),
|
|
229
430
|
// so the total is 5: 3 APIGW + 2 EventBridge (sweeper + pingChecker).
|
|
@@ -237,16 +438,14 @@ describe('IrcAwsStack — gone-connection sweeper', () => {
|
|
|
237
438
|
// Both Lambda roles must scope their DynamoDB actions to the concrete
|
|
238
439
|
// table ARNs — the least-privilege suite below also asserts no '*'.
|
|
239
440
|
const tables = template.findResources('AWS::DynamoDB::Table');
|
|
240
|
-
expect(Object.keys(tables)).toHaveLength(
|
|
441
|
+
expect(Object.keys(tables)).toHaveLength(6);
|
|
241
442
|
});
|
|
242
443
|
});
|
|
243
444
|
|
|
244
445
|
describe('IrcAwsStack — idle / PING checker', () => {
|
|
245
|
-
it('creates an EventBridge rule
|
|
446
|
+
it('creates an EventBridge rule targeting the pingChecker Lambda', () => {
|
|
246
447
|
const { template } = makeTemplate();
|
|
247
|
-
template.
|
|
248
|
-
ScheduleExpression: 'rate(1 minute)',
|
|
249
|
-
});
|
|
448
|
+
expect(findRuleTargetingLambda(template, 'IrcPingChecker')).toBeDefined();
|
|
250
449
|
});
|
|
251
450
|
|
|
252
451
|
it('creates exactly two EventBridge rules (sweeper + pingChecker)', () => {
|
|
@@ -294,6 +493,55 @@ describe('IrcAwsStack — idle / PING checker', () => {
|
|
|
294
493
|
});
|
|
295
494
|
});
|
|
296
495
|
|
|
496
|
+
describe('IrcAwsStack — schedule CloudFormation parameters', () => {
|
|
497
|
+
it('exposes SweeperScheduleExpression and PingCheckerScheduleExpression as parameters', () => {
|
|
498
|
+
const { template } = makeTemplate();
|
|
499
|
+
const keys = Object.keys(parameters(template));
|
|
500
|
+
expect(keys).toContain('SweeperScheduleExpression');
|
|
501
|
+
expect(keys).toContain('PingCheckerScheduleExpression');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('declares SweeperScheduleExpression as a String parameter with a non-empty description', () => {
|
|
505
|
+
const { template } = makeTemplate();
|
|
506
|
+
const p = parameters(template).SweeperScheduleExpression;
|
|
507
|
+
expect(p).toBeDefined();
|
|
508
|
+
expect(p?.Type).toBe('String');
|
|
509
|
+
expect(typeof p?.Description).toBe('string');
|
|
510
|
+
expect(p?.Description?.length ?? 0).toBeGreaterThan(0);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('declares PingCheckerScheduleExpression as a String parameter with a non-empty description', () => {
|
|
514
|
+
const { template } = makeTemplate();
|
|
515
|
+
const p = parameters(template).PingCheckerScheduleExpression;
|
|
516
|
+
expect(p).toBeDefined();
|
|
517
|
+
expect(p?.Type).toBe('String');
|
|
518
|
+
expect(typeof p?.Description).toBe('string');
|
|
519
|
+
expect(p?.Description?.length ?? 0).toBeGreaterThan(0);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('defaults SweeperScheduleExpression to rate(5 minutes) when the prop is omitted', () => {
|
|
523
|
+
const { template } = makeTemplate();
|
|
524
|
+
expect(parameters(template).SweeperScheduleExpression?.Default).toBe('rate(5 minutes)');
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('defaults PingCheckerScheduleExpression to rate(1 minute) when the prop is omitted', () => {
|
|
528
|
+
const { template } = makeTemplate();
|
|
529
|
+
expect(parameters(template).PingCheckerScheduleExpression?.Default).toBe('rate(1 minute)');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('wires the sweeper rule ScheduleExpression via Ref to the parameter', () => {
|
|
533
|
+
const { template } = makeTemplate();
|
|
534
|
+
const rule = findRuleTargetingLambda(template, 'IrcSweeper');
|
|
535
|
+
expect(rule?.Properties?.ScheduleExpression).toEqual({ Ref: 'SweeperScheduleExpression' });
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('wires the pingChecker rule ScheduleExpression via Ref to the parameter', () => {
|
|
539
|
+
const { template } = makeTemplate();
|
|
540
|
+
const rule = findRuleTargetingLambda(template, 'IrcPingChecker');
|
|
541
|
+
expect(rule?.Properties?.ScheduleExpression).toEqual({ Ref: 'PingCheckerScheduleExpression' });
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
297
545
|
describe('IrcAwsStack — stack outputs', () => {
|
|
298
546
|
it('emits a ConnectUrl output', () => {
|
|
299
547
|
const { template } = makeTemplate();
|
|
@@ -333,10 +581,45 @@ describe('IrcAwsStack — least-privilege IAM', () => {
|
|
|
333
581
|
|
|
334
582
|
it('never grants Resource: "*" in any IAM policy', () => {
|
|
335
583
|
const { template } = makeTemplate();
|
|
584
|
+
// Documented exception: the API Gateway account CloudWatch role MUST
|
|
585
|
+
// use Resource: '*'. APIGW writes execution logs to its OWN
|
|
586
|
+
// AWS/ApiGateway... log groups (not this stack's access-log group), and
|
|
587
|
+
// rejects a role scoped to a single log-group ARN. Identify the role's
|
|
588
|
+
// policy by the apigateway service principal so the exception is narrow.
|
|
589
|
+
const apigwRoleIds = new Set(
|
|
590
|
+
Object.entries(template.findResources('AWS::IAM::Role'))
|
|
591
|
+
.filter(([, r]) => {
|
|
592
|
+
const stmts =
|
|
593
|
+
(
|
|
594
|
+
r as {
|
|
595
|
+
Properties?: {
|
|
596
|
+
AssumeRolePolicyDocument?: {
|
|
597
|
+
Statement?: Array<{ Principal?: { Service?: string } }>;
|
|
598
|
+
};
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
).Properties?.AssumeRolePolicyDocument?.Statement ?? [];
|
|
602
|
+
return stmts.some((s) => s.Principal?.Service === 'apigateway.amazonaws.com');
|
|
603
|
+
})
|
|
604
|
+
.map(([id]) => id),
|
|
605
|
+
);
|
|
336
606
|
const policies = template.findResources('AWS::IAM::Policy');
|
|
337
607
|
for (const [, policy] of Object.entries(policies)) {
|
|
338
|
-
const
|
|
339
|
-
|
|
608
|
+
const props = (
|
|
609
|
+
policy as {
|
|
610
|
+
Properties: {
|
|
611
|
+
Roles?: Array<{ Ref?: string }>;
|
|
612
|
+
PolicyDocument: { Statement: unknown[] };
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
).Properties;
|
|
616
|
+
const isApigwLoggingPolicy = (props.Roles ?? []).some(
|
|
617
|
+
(r) => r.Ref !== undefined && apigwRoleIds.has(r.Ref),
|
|
618
|
+
);
|
|
619
|
+
if (isApigwLoggingPolicy) {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const statements = props.PolicyDocument.Statement;
|
|
340
623
|
for (const stmt of statements) {
|
|
341
624
|
const resource = (stmt as { Resource?: unknown }).Resource;
|
|
342
625
|
if (Array.isArray(resource)) {
|
|
@@ -410,8 +693,8 @@ describe('IrcAwsStack — environmentName prop (env isolation)', () => {
|
|
|
410
693
|
{ Properties: { TableName: string } }
|
|
411
694
|
>,
|
|
412
695
|
).map((t) => t.Properties.TableName);
|
|
413
|
-
expect(stagingTables).toHaveLength(
|
|
414
|
-
expect(prodTables).toHaveLength(
|
|
696
|
+
expect(stagingTables).toHaveLength(6);
|
|
697
|
+
expect(prodTables).toHaveLength(6);
|
|
415
698
|
expect(prodNames.every((n) => !stagingNames.has(n))).toBe(true);
|
|
416
699
|
});
|
|
417
700
|
|
|
@@ -424,17 +707,17 @@ describe('IrcAwsStack — environmentName prop (env isolation)', () => {
|
|
|
424
707
|
});
|
|
425
708
|
});
|
|
426
709
|
|
|
427
|
-
describe('IrcAwsStack — server identity
|
|
710
|
+
describe('IrcAwsStack — server identity CloudFormation parameters', () => {
|
|
428
711
|
/** Finds a Lambda function resource by its logical-id prefix (e.g. `IrcHandler`). */
|
|
429
712
|
function findFunction(
|
|
430
713
|
template: Template,
|
|
431
714
|
idPrefix: string,
|
|
432
715
|
): {
|
|
433
|
-
Properties?: { Environment?: { Variables?: Record<string,
|
|
716
|
+
Properties?: { Environment?: { Variables?: Record<string, unknown> } };
|
|
434
717
|
} {
|
|
435
718
|
const fns = template.findResources('AWS::Lambda::Function') as Record<
|
|
436
719
|
string,
|
|
437
|
-
{ Properties?: { Environment?: { Variables?: Record<string,
|
|
720
|
+
{ Properties?: { Environment?: { Variables?: Record<string, unknown> } } }
|
|
438
721
|
>;
|
|
439
722
|
const found = Object.entries(fns).find(([id]) => id.startsWith(idPrefix));
|
|
440
723
|
if (!found) {
|
|
@@ -443,70 +726,131 @@ describe('IrcAwsStack — server identity props (configurable deploy identity)',
|
|
|
443
726
|
return found[1];
|
|
444
727
|
}
|
|
445
728
|
|
|
446
|
-
it('
|
|
729
|
+
it('exposes ServerName and NetworkName as CloudFormation parameters', () => {
|
|
730
|
+
const { template } = makeTemplate();
|
|
731
|
+
const keys = Object.keys(parameters(template));
|
|
732
|
+
expect(keys).toContain('ServerName');
|
|
733
|
+
expect(keys).toContain('NetworkName');
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('declares ServerName as a String parameter with a non-empty description', () => {
|
|
737
|
+
const { template } = makeTemplate();
|
|
738
|
+
const p = parameters(template).ServerName;
|
|
739
|
+
expect(p).toBeDefined();
|
|
740
|
+
expect(p?.Type).toBe('String');
|
|
741
|
+
expect(typeof p?.Description).toBe('string');
|
|
742
|
+
expect(p?.Description?.length).toBeGreaterThan(0);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it('declares NetworkName as a String parameter with a non-empty description', () => {
|
|
746
|
+
const { template } = makeTemplate();
|
|
747
|
+
const p = parameters(template).NetworkName;
|
|
748
|
+
expect(p).toBeDefined();
|
|
749
|
+
expect(p?.Type).toBe('String');
|
|
750
|
+
expect(typeof p?.Description).toBe('string');
|
|
751
|
+
expect(p?.Description?.length).toBeGreaterThan(0);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('defaults ServerName to the non-placeholder fallback when the prop is omitted', () => {
|
|
755
|
+
const { template } = makeTemplate();
|
|
756
|
+
expect(parameters(template).ServerName?.Default).toBe('irc.localhost');
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('defaults NetworkName to the non-placeholder fallback when the prop is omitted', () => {
|
|
760
|
+
const { template } = makeTemplate();
|
|
761
|
+
expect(parameters(template).NetworkName?.Default).toBe('ExampleNet');
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('flows an overridden serverName prop into the parameter Default', () => {
|
|
447
765
|
const app = new App();
|
|
448
766
|
const stack = new IrcAwsStack(app, 'TestStack', { serverName: 'irc.my.net' });
|
|
449
767
|
const template = Template.fromStack(stack);
|
|
450
|
-
expect(
|
|
451
|
-
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
452
|
-
).toBe('irc.my.net');
|
|
768
|
+
expect(parameters(template).ServerName?.Default).toBe('irc.my.net');
|
|
453
769
|
});
|
|
454
770
|
|
|
455
|
-
it('flows
|
|
771
|
+
it('flows an overridden networkName prop into the parameter Default', () => {
|
|
456
772
|
const app = new App();
|
|
457
|
-
const stack = new IrcAwsStack(app, 'TestStack', {
|
|
773
|
+
const stack = new IrcAwsStack(app, 'TestStack', { networkName: 'MyNet' });
|
|
458
774
|
const template = Template.fromStack(stack);
|
|
459
|
-
|
|
775
|
+
expect(parameters(template).NetworkName?.Default).toBe('MyNet');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('wires SERVER_NAME on the handler Lambda via Ref to the parameter', () => {
|
|
779
|
+
const { template } = makeTemplate();
|
|
780
|
+
expect(
|
|
781
|
+
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
782
|
+
).toEqual({ Ref: 'ServerName' });
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it('wires SERVER_NAME on the pingChecker Lambda via Ref to the parameter', () => {
|
|
786
|
+
const { template } = makeTemplate();
|
|
460
787
|
expect(
|
|
461
788
|
findFunction(template, 'IrcPingChecker').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
462
|
-
).
|
|
789
|
+
).toEqual({ Ref: 'ServerName' });
|
|
463
790
|
});
|
|
464
791
|
|
|
465
|
-
it('
|
|
466
|
-
const
|
|
467
|
-
const stack = new IrcAwsStack(app, 'TestStack', { networkName: 'MyNet' });
|
|
468
|
-
const template = Template.fromStack(stack);
|
|
792
|
+
it('wires NETWORK_NAME on the handler Lambda via Ref to the parameter', () => {
|
|
793
|
+
const { template } = makeTemplate();
|
|
469
794
|
expect(
|
|
470
795
|
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.NETWORK_NAME,
|
|
471
|
-
).
|
|
796
|
+
).toEqual({ Ref: 'NetworkName' });
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('wires NETWORK_NAME on the pingChecker Lambda via Ref to the parameter', () => {
|
|
800
|
+
const { template } = makeTemplate();
|
|
472
801
|
expect(
|
|
473
802
|
findFunction(template, 'IrcPingChecker').Properties?.Environment?.Variables?.NETWORK_NAME,
|
|
474
|
-
).
|
|
803
|
+
).toEqual({ Ref: 'NetworkName' });
|
|
475
804
|
});
|
|
476
805
|
|
|
477
|
-
it('
|
|
806
|
+
it('wires SERVER_NAME and NETWORK_NAME on the NLB handler via Ref when tcpTlsDomainName is set', () => {
|
|
478
807
|
const app = new App();
|
|
479
|
-
const stack = new IrcAwsStack(app, 'TestStack', {
|
|
808
|
+
const stack = new IrcAwsStack(app, 'TestStack', { tcpTlsDomainName: 'irc.example.com' });
|
|
480
809
|
const template = Template.fromStack(stack);
|
|
481
|
-
expect(
|
|
482
|
-
|
|
483
|
-
);
|
|
810
|
+
expect(
|
|
811
|
+
findFunction(template, 'IrcNlbHandler').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
812
|
+
).toEqual({ Ref: 'ServerName' });
|
|
813
|
+
expect(
|
|
814
|
+
findFunction(template, 'IrcNlbHandler').Properties?.Environment?.Variables?.NETWORK_NAME,
|
|
815
|
+
).toEqual({ Ref: 'NetworkName' });
|
|
484
816
|
});
|
|
485
817
|
|
|
486
|
-
it('
|
|
818
|
+
it('injects server identity into the sweeper Lambda (shared config loader requires it)', () => {
|
|
819
|
+
// sweeperHandler calls buildDepsFromEnv() -> loadServerConfigFromLambdaEnv(),
|
|
820
|
+
// which hard-requires SERVER_NAME / NETWORK_NAME (and reads MOTD). Omitting
|
|
821
|
+
// them makes every scheduled sweeper invocation throw "serverName: Required"
|
|
822
|
+
// — so the sweeper MUST receive the same server-identity env vars as the
|
|
823
|
+
// handler, wired through the same CloudFormation parameters.
|
|
487
824
|
const { template } = makeTemplate();
|
|
488
825
|
expect(
|
|
489
|
-
findFunction(template, '
|
|
490
|
-
).
|
|
826
|
+
findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
827
|
+
).toEqual({ Ref: 'ServerName' });
|
|
491
828
|
expect(
|
|
492
|
-
findFunction(template, '
|
|
493
|
-
).
|
|
829
|
+
findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.NETWORK_NAME,
|
|
830
|
+
).toEqual({ Ref: 'NetworkName' });
|
|
831
|
+
expect(
|
|
832
|
+
findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.MOTD,
|
|
833
|
+
).toBeDefined();
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('joins motdLines with newline so the config loader splits them into separate lines', () => {
|
|
837
|
+
const app = new App();
|
|
838
|
+
const stack = new IrcAwsStack(app, 'TestStack', { motdLines: ['line one', 'line two'] });
|
|
839
|
+
const template = Template.fromStack(stack);
|
|
840
|
+
expect(findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD).toBe(
|
|
841
|
+
'line one\nline two',
|
|
842
|
+
);
|
|
494
843
|
});
|
|
495
844
|
|
|
496
845
|
it('default MOTD is newline-joined so the loader yields multiple lines (no comma)', () => {
|
|
497
846
|
const { template } = makeTemplate();
|
|
498
847
|
const motd =
|
|
499
|
-
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD
|
|
848
|
+
(findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD as
|
|
849
|
+
| string
|
|
850
|
+
| undefined) ?? '';
|
|
500
851
|
expect(motd.split('\n').length).toBeGreaterThanOrEqual(2);
|
|
501
852
|
expect(motd).not.toContain(',');
|
|
502
853
|
});
|
|
503
|
-
|
|
504
|
-
it('does not inject server identity into the sweeper Lambda', () => {
|
|
505
|
-
const { template } = makeTemplate();
|
|
506
|
-
expect(
|
|
507
|
-
findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
508
|
-
).toBeUndefined();
|
|
509
|
-
});
|
|
510
854
|
});
|
|
511
855
|
|
|
512
856
|
describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-tcp-container",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -95,6 +95,9 @@ interface ConnectionBindings {
|
|
|
95
95
|
const DEFAULT_ID_FACTORY = {
|
|
96
96
|
batchId: () => randomUUID(),
|
|
97
97
|
nonce: () => randomUUID(),
|
|
98
|
+
// `sessionId` is required for `IdFactory` interface completeness; no
|
|
99
|
+
// current code path invokes it (only batchId/nonce/traceId are called).
|
|
100
|
+
// c8 ignore next 1
|
|
98
101
|
sessionId: () => randomUUID(),
|
|
99
102
|
traceId: () => randomUUID(),
|
|
100
103
|
};
|
|
@@ -154,12 +157,21 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
154
157
|
const tcpServer = createServer((socket) => {
|
|
155
158
|
sockets.add(socket);
|
|
156
159
|
|
|
160
|
+
// `remoteAddress` is always set on a TCP server's connection socket;
|
|
161
|
+
// the 'unknown' fallback is defensive and unreachable in practice.
|
|
162
|
+
/* c8 ignore start */
|
|
157
163
|
const ip = socket.remoteAddress ?? 'unknown';
|
|
164
|
+
/* c8 ignore stop */
|
|
158
165
|
const decision = runtime.admitConnection(ip, undefined);
|
|
159
166
|
if (!decision.ok) {
|
|
167
|
+
// A freshly-accepted TCP socket is always writable; the else branch
|
|
168
|
+
// (skip the ERROR write) is a defensive guard against an impossible
|
|
169
|
+
// race and cannot be exercised over a real socket.
|
|
170
|
+
/* c8 ignore start */
|
|
160
171
|
if (socket.writable) {
|
|
161
172
|
socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
|
|
162
173
|
}
|
|
174
|
+
/* c8 ignore stop */
|
|
163
175
|
socket.destroy();
|
|
164
176
|
sockets.delete(socket);
|
|
165
177
|
return;
|
|
@@ -213,8 +225,12 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
213
225
|
const onListening = (): void => {
|
|
214
226
|
tcpServer.removeListener('error', onError);
|
|
215
227
|
const addr = tcpServer.address();
|
|
216
|
-
//
|
|
228
|
+
// Inside the 'listening' handler `address()` is always an AddressInfo
|
|
229
|
+
// (TCP server, just bound); the null/string returns only occur for IPC
|
|
230
|
+
// sockets or before listening. The `opts.port` fallback is unreachable.
|
|
231
|
+
/* c8 ignore start */
|
|
217
232
|
const port = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
|
|
233
|
+
/* c8 ignore stop */
|
|
218
234
|
resolve({
|
|
219
235
|
port,
|
|
220
236
|
host,
|
|
@@ -255,9 +271,13 @@ function attachConnection(
|
|
|
255
271
|
): ConnectionBindings {
|
|
256
272
|
const id = randomUUID();
|
|
257
273
|
const state = createConnection({ id, connectedSince: clock.now() });
|
|
274
|
+
// `transport.sourceHost` is always supplied (set to the peer IP) by the
|
|
275
|
+
// only call site above; the else branch is defensive and unreachable.
|
|
276
|
+
/* c8 ignore start */
|
|
258
277
|
if (transport.sourceHost !== undefined) {
|
|
259
278
|
state.host = transport.sourceHost;
|
|
260
279
|
}
|
|
280
|
+
/* c8 ignore stop */
|
|
261
281
|
|
|
262
282
|
const logger: Logger = new ConsoleLogger({ connectionId: id }, undefined, LogLevel.Info);
|
|
263
283
|
|