serverless-ircd 0.8.0 → 0.10.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 +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- 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-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- 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/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- 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 +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -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 +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- 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/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/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 +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synth-time assertions for the `StaticSite` construct (the AWS web-client
|
|
3
|
+
* hosting substrate: a private S3 bucket fronted by a CloudFront
|
|
4
|
+
* distribution with Origin Access Control).
|
|
5
|
+
*
|
|
6
|
+
* The web client (Kiwi SPA + landing page) is the shared `apps/web/dist`
|
|
7
|
+
* build artifact. On AWS it is served exclusively over CloudFront; the S3
|
|
8
|
+
* origin bucket is private and grants the distribution via OAC (never
|
|
9
|
+
* public-read). The landing page at `/` is served via the distribution's
|
|
10
|
+
* `defaultRootObject` (`/index.html`); SPA client-side routing under
|
|
11
|
+
* `/webclient/*` is handled by a CloudFront custom error response mapping
|
|
12
|
+
* S3's 403/404 → `200 /webclient/index.html`.
|
|
13
|
+
*
|
|
14
|
+
* These tests synthesise the construct in isolation (via `Template` from
|
|
15
|
+
* `aws-cdk-lib/assertions`) and assert the exact CloudFormation shape: the
|
|
16
|
+
* `AWS::CloudFront::Distribution`, `AWS::S3::Bucket`, the OAC resource, the
|
|
17
|
+
* bucket-policy grant (OAC principal only — no `Public`/`*`), the
|
|
18
|
+
* 403/404 → `/webclient/index.html` error response, the `/index.html`
|
|
19
|
+
* default root object, and the `WebsiteURL` output. The integration into
|
|
20
|
+
* `IrcAwsStack` (opt-in prop) is covered in `stack.test.ts`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { runInNewContext } from 'node:vm';
|
|
24
|
+
import { App, Stack } from 'aws-cdk-lib';
|
|
25
|
+
import { Template } from 'aws-cdk-lib/assertions';
|
|
26
|
+
import { describe, expect, it } from 'vitest';
|
|
27
|
+
import { StaticSite, type StaticSiteProps } from '../src/static-site.js';
|
|
28
|
+
|
|
29
|
+
/** Synthesises a `StaticSite` in isolation and returns the assertion Template. */
|
|
30
|
+
function makeSiteTemplate(props: StaticSiteProps = {}): {
|
|
31
|
+
app: App;
|
|
32
|
+
stack: Stack;
|
|
33
|
+
template: Template;
|
|
34
|
+
} {
|
|
35
|
+
const app = new App();
|
|
36
|
+
const stack = new Stack(app, 'SiteStack');
|
|
37
|
+
new StaticSite(stack, 'Site', props);
|
|
38
|
+
const template = Template.fromStack(stack);
|
|
39
|
+
return { app, stack, template };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Shape of a synthesised CloudFront Distribution's DistributionConfig. */
|
|
43
|
+
type DistConfig = {
|
|
44
|
+
Origins?: Array<{
|
|
45
|
+
DomainName?: unknown;
|
|
46
|
+
S3OriginConfig?: unknown;
|
|
47
|
+
OriginAccessControlId?: unknown;
|
|
48
|
+
}>;
|
|
49
|
+
DefaultCacheBehavior?: {
|
|
50
|
+
ViewerProtocolPolicy?: string;
|
|
51
|
+
FunctionAssociations?: Array<{ EventType?: string; FunctionARN?: unknown }>;
|
|
52
|
+
};
|
|
53
|
+
Aliases?: string[];
|
|
54
|
+
CustomErrorResponses?: Array<{
|
|
55
|
+
ErrorCode?: number;
|
|
56
|
+
ResponseCode?: number;
|
|
57
|
+
ResponsePagePath?: string;
|
|
58
|
+
}>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Extracts the DistributionConfig from the single CloudFront distribution. */
|
|
62
|
+
function distConfig(template: Template): DistConfig {
|
|
63
|
+
const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
|
|
64
|
+
string,
|
|
65
|
+
{ Properties?: { DistributionConfig?: DistConfig } }
|
|
66
|
+
>;
|
|
67
|
+
const dist = Object.values(dists)[0];
|
|
68
|
+
return dist?.Properties?.DistributionConfig ?? {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Result of invoking the synthesised directory-index function: either the
|
|
73
|
+
* (possibly rewritten) request object (`uri` set), or a 301-redirect
|
|
74
|
+
* response (`statusCode` + `headers.location`). Kept as a single loose
|
|
75
|
+
* type with all-optional fields so the per-case tests can read either
|
|
76
|
+
* shape without per-call casts; the assertions themselves pin the shape.
|
|
77
|
+
*/
|
|
78
|
+
type IndexHandlerResult = {
|
|
79
|
+
uri?: string;
|
|
80
|
+
statusCode?: number;
|
|
81
|
+
statusDescription?: string;
|
|
82
|
+
headers?: { location?: { value?: string } };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Extracts the synthesized directory-index CloudFront Function's `handler`
|
|
87
|
+
* entry point as a callable, so its rewrite/redirect logic can be exercised
|
|
88
|
+
* behaviourally (not just "the source contains 'index.html'"). Uses
|
|
89
|
+
* `node:vm`'s `runInNewContext` (rather than `eval`/`new Function`, which
|
|
90
|
+
* lint forbids) to evaluate the inline `FunctionCode` and return the
|
|
91
|
+
* declared `handler`. Throws if no `AWS::CloudFront::Function` was
|
|
92
|
+
* synthesised — the calling tests use that as their RED signal.
|
|
93
|
+
*/
|
|
94
|
+
function extractIndexHandler(template: Template): (event: {
|
|
95
|
+
request: { uri: string; querystring?: Record<string, { value: string }> };
|
|
96
|
+
}) => IndexHandlerResult {
|
|
97
|
+
const fns = template.findResources('AWS::CloudFront::Function') as Record<
|
|
98
|
+
string,
|
|
99
|
+
{ Properties?: { FunctionCode?: string } }
|
|
100
|
+
>;
|
|
101
|
+
// CDK's `cloudfront.Function` puts `FunctionCode` directly under
|
|
102
|
+
// `Properties` (NOT nested under `FunctionConfig`, which only holds
|
|
103
|
+
// `Comment` + `Runtime`).
|
|
104
|
+
const code = Object.values(fns)[0]?.Properties?.FunctionCode;
|
|
105
|
+
if (code === undefined) {
|
|
106
|
+
throw new Error('no AWS::CloudFront::Function with FunctionCode was synthesised');
|
|
107
|
+
}
|
|
108
|
+
// The CloudFront function declares `function handler(event) {...}`. Append
|
|
109
|
+
// a trailing `handler` expression so runInNewContext returns it.
|
|
110
|
+
return runInNewContext(`${code}\nhandler`, {}) as (event: {
|
|
111
|
+
request: { uri: string; querystring?: Record<string, { value: string }> };
|
|
112
|
+
}) => IndexHandlerResult;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Returns all bucket-policy statements across the synthesised template. */
|
|
116
|
+
function bucketPolicyStatements(template: Template): Array<{
|
|
117
|
+
Principal?: unknown;
|
|
118
|
+
Action?: unknown;
|
|
119
|
+
Resource?: unknown;
|
|
120
|
+
Condition?: Record<string, Record<string, unknown>>;
|
|
121
|
+
}> {
|
|
122
|
+
const policies = template.findResources('AWS::S3::BucketPolicy') as Record<
|
|
123
|
+
string,
|
|
124
|
+
{ Properties?: { PolicyDocument?: { Statement?: Array<Record<string, unknown>> } } }
|
|
125
|
+
>;
|
|
126
|
+
const all: Array<Record<string, unknown>> = [];
|
|
127
|
+
for (const policy of Object.values(policies)) {
|
|
128
|
+
all.push(...(policy.Properties?.PolicyDocument?.Statement ?? []));
|
|
129
|
+
}
|
|
130
|
+
return all as Array<{
|
|
131
|
+
Principal?: unknown;
|
|
132
|
+
Action?: unknown;
|
|
133
|
+
Resource?: unknown;
|
|
134
|
+
Condition?: Record<string, Record<string, unknown>>;
|
|
135
|
+
}>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe('StaticSite — CloudFront distribution', () => {
|
|
139
|
+
it('creates exactly one AWS::CloudFront::Distribution', () => {
|
|
140
|
+
const { template } = makeSiteTemplate();
|
|
141
|
+
template.resourceCountIs('AWS::CloudFront::Distribution', 1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('creates a distribution backed by an S3 origin', () => {
|
|
145
|
+
const { template } = makeSiteTemplate();
|
|
146
|
+
const origins = distConfig(template).Origins ?? [];
|
|
147
|
+
expect(origins.length).toBeGreaterThanOrEqual(1);
|
|
148
|
+
expect(origins[0]?.S3OriginConfig).toBeDefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('redirects HTTP viewers to HTTPS', () => {
|
|
152
|
+
const { template } = makeSiteTemplate();
|
|
153
|
+
expect(distConfig(template).DefaultCacheBehavior?.ViewerProtocolPolicy).toBe(
|
|
154
|
+
'redirect-to-https',
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('StaticSite — private S3 origin bucket', () => {
|
|
160
|
+
it('creates exactly one AWS::S3::Bucket', () => {
|
|
161
|
+
const { template } = makeSiteTemplate();
|
|
162
|
+
template.resourceCountIs('AWS::S3::Bucket', 1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('never grants public read access (no Public/* principal in any bucket-policy statement)', () => {
|
|
166
|
+
const { template } = makeSiteTemplate();
|
|
167
|
+
const statements = bucketPolicyStatements(template);
|
|
168
|
+
expect(statements.length).toBeGreaterThanOrEqual(1);
|
|
169
|
+
for (const stmt of statements) {
|
|
170
|
+
const serialised = JSON.stringify(stmt.Principal);
|
|
171
|
+
expect(serialised, 'bucket policy must not grant Principal "*"').not.toBe('"*"');
|
|
172
|
+
expect(serialised, 'bucket policy must not grant Principal AWS "*"').not.toContain(
|
|
173
|
+
'"AWS":"*"',
|
|
174
|
+
);
|
|
175
|
+
expect(serialised, 'bucket policy must not be public-read').not.toContain('Public');
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('grants the CloudFront distribution read access via the OAC service principal', () => {
|
|
180
|
+
const { template } = makeSiteTemplate();
|
|
181
|
+
const statements = bucketPolicyStatements(template);
|
|
182
|
+
const oacGrant = statements.find((s) =>
|
|
183
|
+
JSON.stringify(s.Principal).includes('cloudfront.amazonaws.com'),
|
|
184
|
+
);
|
|
185
|
+
expect(oacGrant, 'expected a cloudfront OAC bucket-policy statement').toBeDefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('scopes the OAC grant to this distribution via a StringEquals AWS:SourceArn condition', () => {
|
|
189
|
+
// OAC uses a condition keyed on `AWS:SourceArn` (the distribution ARN)
|
|
190
|
+
// so only THIS distribution can read the bucket, not every CloudFront
|
|
191
|
+
// distribution in the account.
|
|
192
|
+
const { template } = makeSiteTemplate();
|
|
193
|
+
const statements = bucketPolicyStatements(template);
|
|
194
|
+
const oacGrant = statements.find((s) =>
|
|
195
|
+
JSON.stringify(s.Principal).includes('cloudfront.amazonaws.com'),
|
|
196
|
+
);
|
|
197
|
+
const cond = JSON.stringify(oacGrant?.Condition ?? {});
|
|
198
|
+
expect(cond).toContain('StringEquals');
|
|
199
|
+
expect(cond).toContain('AWS:SourceArn');
|
|
200
|
+
expect(cond).toContain('cloudfront');
|
|
201
|
+
expect(cond).toContain('distribution');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('StaticSite — Origin Access Control (OAC)', () => {
|
|
206
|
+
it('creates an AWS::CloudFront::OriginAccessControl resource', () => {
|
|
207
|
+
const { template } = makeSiteTemplate();
|
|
208
|
+
template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('configures the OAC for an S3 origin with SIGV4_ALWAYS signing', () => {
|
|
212
|
+
const { template } = makeSiteTemplate();
|
|
213
|
+
const oacs = template.findResources('AWS::CloudFront::OriginAccessControl') as Record<
|
|
214
|
+
string,
|
|
215
|
+
{
|
|
216
|
+
Properties?: {
|
|
217
|
+
OriginAccessControlConfig?: {
|
|
218
|
+
OriginAccessControlOriginType?: string;
|
|
219
|
+
SigningProtocol?: string;
|
|
220
|
+
SigningBehavior?: string;
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
>;
|
|
225
|
+
const oac = Object.values(oacs)[0]?.Properties?.OriginAccessControlConfig;
|
|
226
|
+
expect(oac?.OriginAccessControlOriginType).toBe('s3');
|
|
227
|
+
expect(oac?.SigningProtocol).toBe('sigv4');
|
|
228
|
+
expect(oac?.SigningBehavior).toBe('always');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('references the OAC id from the S3 origin', () => {
|
|
232
|
+
const { template } = makeSiteTemplate();
|
|
233
|
+
const origin = distConfig(template).Origins?.[0];
|
|
234
|
+
expect(origin?.OriginAccessControlId).toBeDefined();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('StaticSite — default root object + SPA error response', () => {
|
|
239
|
+
it('serves the landing page (/index.html) as the default root object', () => {
|
|
240
|
+
// `https://<dist>/` must serve the project landing page (the 13 KB
|
|
241
|
+
// static HTML at `dist/index.html` produced by step 5 of
|
|
242
|
+
// `apps/web/scripts/build.mjs`). Without this `defaultRootObject`,
|
|
243
|
+
// a request to `/` would 403 against the private bucket and trigger
|
|
244
|
+
// the SPA error response, which is the wrong page (the SPA shell,
|
|
245
|
+
// not the landing page).
|
|
246
|
+
const { template } = makeSiteTemplate();
|
|
247
|
+
const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
|
|
248
|
+
string,
|
|
249
|
+
{ Properties?: { DistributionConfig?: { DefaultRootObject?: string } } }
|
|
250
|
+
>;
|
|
251
|
+
const root = Object.values(dists)[0]?.Properties?.DistributionConfig?.DefaultRootObject;
|
|
252
|
+
expect(root).toBe('index.html');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('maps HTTP 403 → 200 with response page /webclient/index.html', () => {
|
|
256
|
+
const { template } = makeSiteTemplate();
|
|
257
|
+
const errors = distConfig(template).CustomErrorResponses ?? [];
|
|
258
|
+
const mapping = errors.find((e) => e.ErrorCode === 403);
|
|
259
|
+
expect(mapping, 'expected a 403 custom error response').toBeDefined();
|
|
260
|
+
expect(mapping?.ResponseCode).toBe(200);
|
|
261
|
+
expect(mapping?.ResponsePagePath).toBe('/webclient/index.html');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('maps HTTP 404 → 200 with response page /webclient/index.html', () => {
|
|
265
|
+
const { template } = makeSiteTemplate();
|
|
266
|
+
const errors = distConfig(template).CustomErrorResponses ?? [];
|
|
267
|
+
const mapping = errors.find((e) => e.ErrorCode === 404);
|
|
268
|
+
expect(mapping, 'expected a 404 custom error response').toBeDefined();
|
|
269
|
+
expect(mapping?.ResponseCode).toBe(200);
|
|
270
|
+
expect(mapping?.ResponsePagePath).toBe('/webclient/index.html');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('StaticSite — WebsiteURL output', () => {
|
|
275
|
+
/**
|
|
276
|
+
* The `WebsiteURL` CfnOutput lives inside the `StaticSite` construct, but
|
|
277
|
+
* its logical id is pinned to the stable key `WebsiteURL` via
|
|
278
|
+
* `overrideLogicalId` so operators see `Stack.WebsiteURL` rather than the
|
|
279
|
+
* CDK-hashed `StaticSiteWebsiteURL<8-hex>`. This matches the convention
|
|
280
|
+
* used by the parent-stack outputs (`WebsiteBucketName`,
|
|
281
|
+
* `WebsiteDistributionId`) and keeps the deploy pipeline's
|
|
282
|
+
* `describe-stacks --query` lookups predictable.
|
|
283
|
+
*/
|
|
284
|
+
it('emits a WebsiteURL output with a stable logical id (no CDK hash) naming the distribution domain', () => {
|
|
285
|
+
const { template } = makeSiteTemplate();
|
|
286
|
+
const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
|
|
287
|
+
expect(outputs.WebsiteURL, 'expected a WebsiteURL output with a stable key').toBeDefined();
|
|
288
|
+
const distIds = Object.keys(template.findResources('AWS::CloudFront::Distribution'));
|
|
289
|
+
expect(distIds).toHaveLength(1);
|
|
290
|
+
const getAtt = (outputs.WebsiteURL?.Value as { 'Fn::GetAtt'?: unknown[] })?.['Fn::GetAtt'];
|
|
291
|
+
expect(getAtt, 'expected WebsiteURL.Value to Fn::GetAtt the distribution DomainName').toEqual([
|
|
292
|
+
distIds[0],
|
|
293
|
+
'DomainName',
|
|
294
|
+
]);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('StaticSite — default domain (no custom-domain props)', () => {
|
|
299
|
+
it('does NOT configure an alternate domain name when no custom domain is provided', () => {
|
|
300
|
+
const { template } = makeSiteTemplate();
|
|
301
|
+
expect(distConfig(template).Aliases).toBeUndefined();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('does NOT create a Route53 record when no custom domain is provided', () => {
|
|
305
|
+
const { template } = makeSiteTemplate();
|
|
306
|
+
template.resourceCountIs('AWS::Route53::RecordSet', 0);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('StaticSite — custom domain (opt-in)', () => {
|
|
311
|
+
const customProps: StaticSiteProps = {
|
|
312
|
+
customDomain: 'app.example.com',
|
|
313
|
+
certificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/abc',
|
|
314
|
+
hostedZoneName: 'example.com.',
|
|
315
|
+
hostedZoneId: 'Z2KVMGOMG3KSP5',
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
it('configures the alternate domain name on the distribution', () => {
|
|
319
|
+
const { template } = makeSiteTemplate(customProps);
|
|
320
|
+
expect(distConfig(template).Aliases).toEqual(['app.example.com']);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('attaches the provided ACM certificate as the viewer certificate', () => {
|
|
324
|
+
const { template } = makeSiteTemplate(customProps);
|
|
325
|
+
const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
|
|
326
|
+
string,
|
|
327
|
+
{
|
|
328
|
+
Properties?: {
|
|
329
|
+
DistributionConfig?: { ViewerCertificate?: { AcmCertificateArn?: string } };
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
>;
|
|
333
|
+
const cert =
|
|
334
|
+
Object.values(dists)[0]?.Properties?.DistributionConfig?.ViewerCertificate?.AcmCertificateArn;
|
|
335
|
+
expect(cert).toBe('arn:aws:acm:us-east-1:123456789012:certificate/abc');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('creates a Route53 ARecord aliasing the distribution', () => {
|
|
339
|
+
const { template } = makeSiteTemplate(customProps);
|
|
340
|
+
template.hasResourceProperties('AWS::Route53::RecordSet', {
|
|
341
|
+
Name: 'app.example.com.',
|
|
342
|
+
Type: 'A',
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('configures the alias without a viewer certificate when certificateArn is omitted', () => {
|
|
347
|
+
// CloudFront requires a cert for an alias at deploy time, but the
|
|
348
|
+
// construct does not fail at synth when none is imported — the deploy
|
|
349
|
+
// surfaces the CFN error. This exercises the no-cert branch so the
|
|
350
|
+
// synth path is covered either way.
|
|
351
|
+
const { template } = makeSiteTemplate({
|
|
352
|
+
customDomain: 'app.example.com',
|
|
353
|
+
hostedZoneName: 'example.com.',
|
|
354
|
+
hostedZoneId: 'Z2KVMGOMG3KSP5',
|
|
355
|
+
});
|
|
356
|
+
expect(distConfig(template).Aliases).toEqual(['app.example.com']);
|
|
357
|
+
const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
|
|
358
|
+
string,
|
|
359
|
+
{ Properties?: { DistributionConfig?: { ViewerCertificate?: unknown } } }
|
|
360
|
+
>;
|
|
361
|
+
expect(
|
|
362
|
+
Object.values(dists)[0]?.Properties?.DistributionConfig?.ViewerCertificate,
|
|
363
|
+
).toBeUndefined();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('skips the Route53 record when the hosted zone id is omitted', () => {
|
|
367
|
+
// The ARecord needs both zone name AND id (fromHostedZoneAttributes);
|
|
368
|
+
// without the id the alias is still configured on the distribution but
|
|
369
|
+
// no Route53 record is materialised.
|
|
370
|
+
const { template } = makeSiteTemplate({
|
|
371
|
+
customDomain: 'app.example.com',
|
|
372
|
+
certificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/abc',
|
|
373
|
+
hostedZoneName: 'example.com.',
|
|
374
|
+
});
|
|
375
|
+
expect(distConfig(template).Aliases).toEqual(['app.example.com']);
|
|
376
|
+
template.resourceCountIs('AWS::Route53::RecordSet', 0);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('skips the Route53 record when hostedZoneName is omitted', () => {
|
|
380
|
+
const { template } = makeSiteTemplate({
|
|
381
|
+
customDomain: 'app.example.com',
|
|
382
|
+
certificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/abc',
|
|
383
|
+
});
|
|
384
|
+
template.resourceCountIs('AWS::Route53::RecordSet', 0);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('StaticSite — directory-index viewer-request function', () => {
|
|
389
|
+
// S3 REST origins (used with OAC) do NOT resolve directory indexes: a
|
|
390
|
+
// request for '/docs/' looks up an object literally named 'docs/' and
|
|
391
|
+
// 404s, which then falls through to the SPA error response and serves
|
|
392
|
+
// Kiwi instead of the rendered docs index (reproduced live on
|
|
393
|
+
// irc.aws.fowl.cloud). A CloudFront viewer-request function appends
|
|
394
|
+
// 'index.html' to any URI ending in '/', mirroring the CF Worker
|
|
395
|
+
// `[assets]` binding which resolves directory indexes automatically.
|
|
396
|
+
|
|
397
|
+
it('creates exactly one AWS::CloudFront::Function', () => {
|
|
398
|
+
const { template } = makeSiteTemplate();
|
|
399
|
+
template.resourceCountIs('AWS::CloudFront::Function', 1);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('associates the function as a viewer-request on the default behavior', () => {
|
|
403
|
+
const { template } = makeSiteTemplate();
|
|
404
|
+
const associations = distConfig(template).DefaultCacheBehavior?.FunctionAssociations ?? [];
|
|
405
|
+
const viewerRequest = associations.find((a) => a.EventType === 'viewer-request');
|
|
406
|
+
expect(viewerRequest, 'expected a viewer-request function association').toBeDefined();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('rewrites a trailing-slash directory URI by appending index.html', () => {
|
|
410
|
+
const { template } = makeSiteTemplate();
|
|
411
|
+
const handler = extractIndexHandler(template);
|
|
412
|
+
expect(handler({ request: { uri: '/docs/' } }).uri).toBe('/docs/index.html');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('301-redirects a directory URI without a trailing slash to the trailing-slash form', () => {
|
|
416
|
+
// A bare '/docs' must NOT be internally rewritten to '/docs/index.html':
|
|
417
|
+
// the browser URL would stay at '/docs' and the page's relative asset
|
|
418
|
+
// links (e.g. <link href="docs.css">) would resolve against the parent
|
|
419
|
+
// ('/docs.css') instead of the directory ('/docs/docs.css'). A 301 to
|
|
420
|
+
// '/docs/' makes the browser re-request the directory form, after which
|
|
421
|
+
// the trailing-slash rewrite serves '/docs/index.html' and relative
|
|
422
|
+
// links resolve correctly. Mirrors Apache/nginx DirectorySlash.
|
|
423
|
+
const { template } = makeSiteTemplate();
|
|
424
|
+
const handler = extractIndexHandler(template);
|
|
425
|
+
const result = handler({ request: { uri: '/docs' } });
|
|
426
|
+
expect(result.statusCode).toBe(301);
|
|
427
|
+
expect(result.headers?.location?.value).toBe('/docs/');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('preserves the query string in the no-trailing-slash redirect', () => {
|
|
431
|
+
// CloudFront Functions deliver request.querystring as an OBJECT
|
|
432
|
+
// ({ key: { value: "..." } }), not a raw string — an empty querystring
|
|
433
|
+
// is {} (truthy), so a naive string concat would append "?[object
|
|
434
|
+
// Object]". The function rebuilds the query string from the object.
|
|
435
|
+
const { template } = makeSiteTemplate();
|
|
436
|
+
const handler = extractIndexHandler(template);
|
|
437
|
+
const result = handler({
|
|
438
|
+
request: { uri: '/docs', querystring: { foo: { value: 'bar' } } },
|
|
439
|
+
});
|
|
440
|
+
expect(result.statusCode).toBe(301);
|
|
441
|
+
expect(result.headers?.location?.value).toBe('/docs/?foo=bar');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('omits the query string entirely when there are no params', () => {
|
|
445
|
+
// Regression guard for the live "?[object Object]" bug: an empty
|
|
446
|
+
// querystring object must NOT append a trailing "?".
|
|
447
|
+
const { template } = makeSiteTemplate();
|
|
448
|
+
const handler = extractIndexHandler(template);
|
|
449
|
+
const result = handler({ request: { uri: '/docs', querystring: {} } });
|
|
450
|
+
expect(result.statusCode).toBe(301);
|
|
451
|
+
expect(result.headers?.location?.value).toBe('/docs/');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('rewrites the bare root "/" to "/index.html"', () => {
|
|
455
|
+
const { template } = makeSiteTemplate();
|
|
456
|
+
const handler = extractIndexHandler(template);
|
|
457
|
+
expect(handler({ request: { uri: '/' } }).uri).toBe('/index.html');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('leaves a URI with a file extension unchanged', () => {
|
|
461
|
+
const { template } = makeSiteTemplate();
|
|
462
|
+
const handler = extractIndexHandler(template);
|
|
463
|
+
expect(handler({ request: { uri: '/docs/Services.html' } }).uri).toBe('/docs/Services.html');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('leaves a hashed asset path unchanged', () => {
|
|
467
|
+
// Every real static asset carries a file extension (.js/.css/.png/
|
|
468
|
+
// .json/.woff2/.ico), so the no-extension rewrite never touches them.
|
|
469
|
+
const { template } = makeSiteTemplate();
|
|
470
|
+
const handler = extractIndexHandler(template);
|
|
471
|
+
expect(handler({ request: { uri: '/webclient/static/js/app.d5acc7fe.js' } }).uri).toBe(
|
|
472
|
+
'/webclient/static/js/app.d5acc7fe.js',
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('301-redirects a no-extension SPA deep-link to the trailing-slash form', () => {
|
|
477
|
+
// A SPA client-side route like '/webclient/networks/example' has no
|
|
478
|
+
// extension, so the function redirects it to the trailing-slash form
|
|
479
|
+
// '/webclient/networks/example/'. That object does not exist → S3 404
|
|
480
|
+
// → the 403/404 SPA error response still serves '/webclient/index.html'
|
|
481
|
+
// (unchanged), and Kiwi's router resolves the deep link from the URL.
|
|
482
|
+
// The redirect therefore does not break SPA routing; it merely routes
|
|
483
|
+
// the (expected) miss through the existing fallback, with a canonical
|
|
484
|
+
// trailing slash on the browser URL.
|
|
485
|
+
const { template } = makeSiteTemplate();
|
|
486
|
+
const handler = extractIndexHandler(template);
|
|
487
|
+
const result = handler({ request: { uri: '/webclient/networks/example' } });
|
|
488
|
+
expect(result.statusCode).toBe(301);
|
|
489
|
+
expect(result.headers?.location?.value).toBe('/webclient/networks/example/');
|
|
490
|
+
});
|
|
491
|
+
});
|
|
@@ -57,7 +57,6 @@ describe('IrcAwsStack — test-mode bundle stub (no esbuild)', () => {
|
|
|
57
57
|
it('emits inline (ZipFile) Lambda code for the NLB streaming handler when tcpTlsDomainName is set', () => {
|
|
58
58
|
const app = new App();
|
|
59
59
|
const stack = new IrcAwsStack(app, 'TestStack', {
|
|
60
|
-
environmentName: 'staging',
|
|
61
60
|
tcpTlsDomainName: 'irc.example.com',
|
|
62
61
|
});
|
|
63
62
|
const template = Template.fromStack(stack);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-tcp-container",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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,8 +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
|
|
14
|
-
"deploy:prod": "wrangler deploy",
|
|
13
|
+
"deploy": "wrangler deploy",
|
|
15
14
|
"test": "vitest run",
|
|
16
15
|
"test:watch": "vitest",
|
|
17
16
|
"coverage": "vitest run --coverage",
|
|
@@ -18,20 +18,21 @@ import { randomUUID } from 'node:crypto';
|
|
|
18
18
|
import { type Server, type Socket, createServer } from 'node:net';
|
|
19
19
|
import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
|
|
20
20
|
import {
|
|
21
|
-
type AccountStore,
|
|
22
21
|
type AdmissionConfig,
|
|
23
22
|
type ChanName,
|
|
24
23
|
type Clock,
|
|
25
24
|
type ConnectionState,
|
|
26
25
|
ConsoleLogger,
|
|
27
|
-
InMemoryAccountStore,
|
|
28
26
|
InMemoryMessageStore,
|
|
29
27
|
InMemoryNickHistoryStore,
|
|
28
|
+
InMemoryServicesStore,
|
|
30
29
|
LogLevel,
|
|
31
30
|
type Logger,
|
|
32
31
|
type MessageStore,
|
|
33
32
|
type NickHistoryStore,
|
|
34
33
|
type RawLine,
|
|
34
|
+
type ServicesStore,
|
|
35
|
+
SystemClock,
|
|
35
36
|
createConnection,
|
|
36
37
|
} from '@serverless-ircd/irc-core';
|
|
37
38
|
import { ConnectionActor, TcpByteStreamTransport } from '@serverless-ircd/irc-server';
|
|
@@ -95,6 +96,9 @@ interface ConnectionBindings {
|
|
|
95
96
|
const DEFAULT_ID_FACTORY = {
|
|
96
97
|
batchId: () => randomUUID(),
|
|
97
98
|
nonce: () => randomUUID(),
|
|
99
|
+
// `sessionId` is required for `IdFactory` interface completeness; no
|
|
100
|
+
// current code path invokes it (only batchId/nonce/traceId are called).
|
|
101
|
+
// c8 ignore next 1
|
|
98
102
|
sessionId: () => randomUUID(),
|
|
99
103
|
traceId: () => randomUUID(),
|
|
100
104
|
};
|
|
@@ -131,10 +135,12 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
131
135
|
|
|
132
136
|
const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
|
|
133
137
|
const history = new InMemoryNickHistoryStore(clock);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
// Services store: the single credential home. Seeded from `saslAccounts`
|
|
139
|
+
// so SASL PLAIN / PASS-auth / NickServ IDENTIFY all verify.
|
|
140
|
+
const services = new InMemoryServicesStore({ clock: SystemClock });
|
|
141
|
+
for (const acct of opts.saslAccounts ?? []) {
|
|
142
|
+
services.registerNick(acct.username, acct.password, '');
|
|
143
|
+
}
|
|
138
144
|
|
|
139
145
|
// Restore persisted channel state before accepting connections.
|
|
140
146
|
if (opts.persistencePath !== undefined) {
|
|
@@ -154,12 +160,21 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
154
160
|
const tcpServer = createServer((socket) => {
|
|
155
161
|
sockets.add(socket);
|
|
156
162
|
|
|
163
|
+
// `remoteAddress` is always set on a TCP server's connection socket;
|
|
164
|
+
// the 'unknown' fallback is defensive and unreachable in practice.
|
|
165
|
+
/* c8 ignore start */
|
|
157
166
|
const ip = socket.remoteAddress ?? 'unknown';
|
|
167
|
+
/* c8 ignore stop */
|
|
158
168
|
const decision = runtime.admitConnection(ip, undefined);
|
|
159
169
|
if (!decision.ok) {
|
|
170
|
+
// A freshly-accepted TCP socket is always writable; the else branch
|
|
171
|
+
// (skip the ERROR write) is a defensive guard against an impossible
|
|
172
|
+
// race and cannot be exercised over a real socket.
|
|
173
|
+
/* c8 ignore start */
|
|
160
174
|
if (socket.writable) {
|
|
161
175
|
socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
|
|
162
176
|
}
|
|
177
|
+
/* c8 ignore stop */
|
|
163
178
|
socket.destroy();
|
|
164
179
|
sockets.delete(socket);
|
|
165
180
|
return;
|
|
@@ -167,7 +182,7 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
167
182
|
const admissionRecordId = decision.recordId;
|
|
168
183
|
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
169
184
|
|
|
170
|
-
const { state, actor } = attachConnection(runtime, opts, messages,
|
|
185
|
+
const { state, actor } = attachConnection(runtime, opts, messages, services, history, clock, {
|
|
171
186
|
sendText: (text) => {
|
|
172
187
|
if (socket.writable) socket.write(text);
|
|
173
188
|
},
|
|
@@ -213,8 +228,12 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
|
|
|
213
228
|
const onListening = (): void => {
|
|
214
229
|
tcpServer.removeListener('error', onError);
|
|
215
230
|
const addr = tcpServer.address();
|
|
216
|
-
//
|
|
231
|
+
// Inside the 'listening' handler `address()` is always an AddressInfo
|
|
232
|
+
// (TCP server, just bound); the null/string returns only occur for IPC
|
|
233
|
+
// sockets or before listening. The `opts.port` fallback is unreachable.
|
|
234
|
+
/* c8 ignore start */
|
|
217
235
|
const port = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
|
|
236
|
+
/* c8 ignore stop */
|
|
218
237
|
resolve({
|
|
219
238
|
port,
|
|
220
239
|
host,
|
|
@@ -243,7 +262,7 @@ function attachConnection(
|
|
|
243
262
|
runtime: InMemoryRuntime,
|
|
244
263
|
opts: StartContainerOptions,
|
|
245
264
|
messages: MessageStore,
|
|
246
|
-
|
|
265
|
+
services: ServicesStore,
|
|
247
266
|
history: NickHistoryStore,
|
|
248
267
|
clock: Clock,
|
|
249
268
|
transport: {
|
|
@@ -255,9 +274,13 @@ function attachConnection(
|
|
|
255
274
|
): ConnectionBindings {
|
|
256
275
|
const id = randomUUID();
|
|
257
276
|
const state = createConnection({ id, connectedSince: clock.now() });
|
|
277
|
+
// `transport.sourceHost` is always supplied (set to the peer IP) by the
|
|
278
|
+
// only call site above; the else branch is defensive and unreachable.
|
|
279
|
+
/* c8 ignore start */
|
|
258
280
|
if (transport.sourceHost !== undefined) {
|
|
259
281
|
state.host = transport.sourceHost;
|
|
260
282
|
}
|
|
283
|
+
/* c8 ignore stop */
|
|
261
284
|
|
|
262
285
|
const logger: Logger = new ConsoleLogger({ connectionId: id }, undefined, LogLevel.Info);
|
|
263
286
|
|
|
@@ -282,7 +305,7 @@ function attachConnection(
|
|
|
282
305
|
ids: DEFAULT_ID_FACTORY,
|
|
283
306
|
motd: { lines: () => opts.motdLines ?? [] },
|
|
284
307
|
messages,
|
|
285
|
-
...(
|
|
308
|
+
...(services !== undefined ? { services } : {}),
|
|
286
309
|
history,
|
|
287
310
|
logger,
|
|
288
311
|
transport: new TcpByteStreamTransport(),
|