@quolu/lattice 0.52.2 → 0.52.4
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/package.json +1 -1
- package/src/artifact-contracts-v2.mjs +325 -325
- package/src/artifact-contracts.mjs +895 -895
- package/src/boundary-compiler.mjs +712 -712
- package/src/boundary-observation-compiler-v2.mjs +344 -344
- package/src/bounded-seam.mjs +230 -230
- package/src/bridge-address.mjs +107 -107
- package/src/bridge-cli.mjs +297 -297
- package/src/bridge-config.mjs +346 -346
- package/src/bridge-daemon.mjs +378 -378
- package/src/bridge-hub-protocol.mjs +198 -0
- package/src/bridge-launch-agent.mjs +323 -323
- package/src/bridge-registrar.mjs +102 -102
- package/src/bridge-server.mjs +376 -376
- package/src/cli-help.mjs +308 -308
- package/src/cli-stdio.mjs +40 -40
- package/src/control-compiler.mjs +532 -532
- package/src/dag-chain.mjs +261 -261
- package/src/factory-diagnostics.mjs +188 -188
- package/src/git-process.mjs +72 -0
- package/src/hash-chain.mjs +76 -76
- package/src/hooks-cli.mjs +1057 -1057
- package/src/isolation-runner.mjs +409 -409
- package/src/node-version-guard.mjs +44 -44
- package/src/project-cli.mjs +699 -697
- package/src/project-identity.mjs +130 -130
- package/src/rc1-black-box-oracle.mjs +906 -906
- package/src/rc1-comparison.mjs +154 -154
- package/src/rc1-evidence-bundle.mjs +456 -456
- package/src/rc1-v4-campaign.mjs +708 -708
- package/src/rc1-v4-transform.mjs +467 -467
- package/src/rc1-v5-artifact-set.mjs +855 -855
- package/src/rc1-v5-behavior-evidence.mjs +594 -594
- package/src/rc1-v5-campaign.mjs +797 -797
- package/src/rc1-v5-transform.mjs +421 -421
- package/src/rc1-v6-artifact-set.mjs +807 -807
- package/src/rc1-v6-behavior-evidence.mjs +273 -273
- package/src/rc1-v6-campaign.mjs +623 -623
- package/src/rc1-v6-causal-binding.mjs +473 -473
- package/src/rc1-v6-measurement.mjs +313 -313
- package/src/rc2-artifact-set.mjs +1584 -1584
- package/src/rc2-campaign.mjs +1506 -1506
- package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
- package/src/rc2-delivery-policy-oracle.mjs +134 -134
- package/src/rc2-delivery-policy-transform.mjs +1127 -1127
- package/src/rc2-rc1-transfer-front-end.mjs +511 -511
- package/src/rc3-actual-dogfood.mjs +652 -652
- package/src/rc3-dogfood-scaffold.mjs +315 -315
- package/src/rc3-scripted-campaign.mjs +1383 -1383
- package/src/rc4-stage1-dogfood.mjs +673 -673
- package/src/runtime-adapter-registry.mjs +524 -524
- package/src/runtime-cli.mjs +4588 -4588
- package/src/runtime-contracts.mjs +824 -824
- package/src/runtime-control-store.mjs +604 -604
- package/src/runtime-controller-protocol.mjs +587 -587
- package/src/runtime-decision-verifier.mjs +701 -701
- package/src/runtime-diff-observer.mjs +361 -361
- package/src/runtime-direct-os-observer.mjs +301 -301
- package/src/runtime-driver-state.mjs +166 -166
- package/src/runtime-engine.mjs +779 -779
- package/src/runtime-errors.mjs +356 -356
- package/src/runtime-event-store.mjs +189 -189
- package/src/runtime-front-end.mjs +925 -925
- package/src/runtime-gate-store.mjs +481 -481
- package/src/runtime-hold-recompile.mjs +916 -916
- package/src/runtime-io-sentinel.mjs +391 -391
- package/src/runtime-lifecycle-lock.mjs +294 -294
- package/src/runtime-managed-supervisor.mjs +1490 -1490
- package/src/runtime-multi-epoch-store.mjs +838 -838
- package/src/runtime-projection.mjs +269 -269
- package/src/runtime-pull-intake.mjs +1192 -1192
- package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
- package/src/runtime-scripted-executor.mjs +163 -163
- package/src/runtime-scripted-worktree.mjs +104 -104
- package/src/runtime-seam-resolve.mjs +428 -428
- package/src/runtime-seam-treatment.mjs +173 -173
- package/src/runtime-socket-owner.mjs +125 -125
- package/src/runtime-work-order-contracts.mjs +91 -91
- package/src/runtime-work-order-controller.mjs +1171 -1171
- package/src/runtime-worktree-executor.mjs +199 -199
- package/src/schedulability-compiler-v2.mjs +303 -303
- package/src/schedulability-verifier-v2.mjs +317 -317
- package/src/seam-apply.mjs +549 -549
- package/src/seam-commit-shared.mjs +22 -22
- package/src/seam-commit-transform.mjs +81 -81
- package/src/seam-commit.mjs +18 -18
- package/src/seam-cost.mjs +322 -322
- package/src/seam-derivation.mjs +188 -188
- package/src/seam-gate.mjs +146 -146
- package/src/seam-proposal-contracts.mjs +446 -446
- package/src/seam-proposal-queries.mjs +521 -521
- package/src/seam-proposal.mjs +2011 -2011
- package/src/seam-ref.mjs +33 -33
- package/src/seam-rewrite.mjs +286 -286
- package/src/seam-transform.mjs +554 -554
- package/src/seam-verification.mjs +260 -260
- package/src/sensor-adapter.mjs +432 -432
- package/src/sensor-cli.mjs +139 -139
- package/src/sensor-diff.mjs +661 -661
- package/src/sensor-node-runtime.mjs +53 -53
- package/src/sensor-runtime.mjs +52 -52
- package/src/timestamp-contract.mjs +8 -8
- package/src/todo-audit-pending.mjs +91 -91
- package/src/todo-chain.mjs +178 -178
- package/src/todo-cli.mjs +3143 -3141
- package/src/todo-contracts.mjs +728 -728
- package/src/todo-dashboard-registry.mjs +573 -573
- package/src/todo-dispatch-shape.mjs +190 -190
- package/src/todo-gantt-html-independence.mjs +239 -239
- package/src/todo-gantt-html-shared.mjs +226 -226
- package/src/todo-gantt-html-style.mjs +131 -131
- package/src/todo-gantt-html.mjs +248 -248
- package/src/todo-gantt-layout.mjs +974 -974
- package/src/todo-gantt-live.mjs +361 -361
- package/src/todo-gantt-nested.mjs +263 -263
- package/src/todo-gantt-presentation.mjs +217 -217
- package/src/todo-gantt-scope.mjs +123 -123
- package/src/todo-gantt-svg.mjs +353 -353
- package/src/todo-independence-contracts.mjs +595 -595
- package/src/todo-independence-guidance.mjs +322 -322
- package/src/todo-independence.mjs +640 -640
- package/src/todo-markdown-renderer.mjs +260 -260
- package/src/todo-migration.mjs +448 -448
- package/src/todo-narrative-anchor.mjs +130 -130
- package/src/todo-note-store.mjs +629 -629
- package/src/todo-parallel-candidates.mjs +114 -114
- package/src/todo-revision.mjs +995 -995
- package/src/todo-split.mjs +472 -472
- package/src/todo-status.mjs +690 -690
- package/src/todo-store-git-transaction.mjs +418 -418
- package/src/todo-store.mjs +4360 -4322
- package/src/treatment-compiler.mjs +728 -728
- package/src/treatment-runner.mjs +656 -656
- package/src/witness-scaffold.mjs +180 -180
package/src/bridge-server.mjs
CHANGED
|
@@ -1,376 +1,376 @@
|
|
|
1
|
-
import { createServer, request as httpRequest } from 'node:http';
|
|
2
|
-
import { request as httpsRequest } from 'node:https';
|
|
3
|
-
import { constants as fsConstants } from 'node:fs';
|
|
4
|
-
import { lstat, open } from 'node:fs/promises';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { parseTree } from 'jsonc-parser';
|
|
7
|
-
|
|
8
|
-
import { networkInterfaces } from 'node:os';
|
|
9
|
-
|
|
10
|
-
import { resolveBridgeListenAddress } from './bridge-address.mjs';
|
|
11
|
-
import { registerBridgeUpstream } from './bridge-registrar.mjs';
|
|
12
|
-
import {
|
|
13
|
-
BridgeConfigError, bridgeConfigPaths, normalizeBridgeAllowedHost, readBridgeConfig,
|
|
14
|
-
} from './bridge-config.mjs';
|
|
15
|
-
|
|
16
|
-
const HOP_BY_HOP = new Set([
|
|
17
|
-
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
18
|
-
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
19
|
-
]);
|
|
20
|
-
const UNTRUSTED_CLIENT_IP_HEADERS = new Set([
|
|
21
|
-
'cf-connecting-ip', 'client-ip', 'fastly-client-ip', 'true-client-ip',
|
|
22
|
-
'x-cluster-client-ip', 'x-proxyuser-ip',
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
|
-
function dashboardRuntimeDir(env) {
|
|
26
|
-
const configured = env.LATTICE_DASHBOARD_RUNTIME_DIR;
|
|
27
|
-
return typeof configured === 'string' && path.isAbsolute(configured)
|
|
28
|
-
? configured : path.join(bridgeConfigPaths(env).root, 'dashboard');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function duplicateJsonKey(node) {
|
|
32
|
-
if (node?.type === 'object') {
|
|
33
|
-
const keys = new Set();
|
|
34
|
-
for (const property of node.children ?? []) {
|
|
35
|
-
const [key, child] = property.children ?? [];
|
|
36
|
-
if (keys.has(key?.value) || duplicateJsonKey(child)) return true;
|
|
37
|
-
keys.add(key?.value);
|
|
38
|
-
}
|
|
39
|
-
} else if (node?.type === 'array') return (node.children ?? []).some(duplicateJsonKey);
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function readDashboardDescriptor(ref) {
|
|
44
|
-
let before;
|
|
45
|
-
let handle;
|
|
46
|
-
try {
|
|
47
|
-
before = await lstat(ref);
|
|
48
|
-
if (!before.isFile() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o600 || before.size > 65_536) {
|
|
49
|
-
throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor is unsafe');
|
|
50
|
-
}
|
|
51
|
-
handle = await open(ref, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
52
|
-
const opened = await handle.stat();
|
|
53
|
-
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
|
|
54
|
-
|| opened.size !== before.size || opened.size > 65_536) {
|
|
55
|
-
throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor changed during validation');
|
|
56
|
-
}
|
|
57
|
-
const text = await handle.readFile('utf8');
|
|
58
|
-
const after = await lstat(ref);
|
|
59
|
-
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) {
|
|
60
|
-
throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor changed during read');
|
|
61
|
-
}
|
|
62
|
-
const errors = [];
|
|
63
|
-
const tree = parseTree(text, errors, { allowTrailingComma: false, disallowComments: true });
|
|
64
|
-
if (errors.length > 0 || tree === undefined || duplicateJsonKey(tree)) {
|
|
65
|
-
throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor JSON is invalid');
|
|
66
|
-
}
|
|
67
|
-
return JSON.parse(text);
|
|
68
|
-
} catch (error) {
|
|
69
|
-
if (error instanceof BridgeConfigError) throw error;
|
|
70
|
-
throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE', 'dashboard descriptor is unavailable', undefined, error);
|
|
71
|
-
} finally { await handle?.close(); }
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export async function resolveBridgeUpstream(upstream, {
|
|
75
|
-
env = process.env, healthTimeoutMs = 2_000,
|
|
76
|
-
} = {}) {
|
|
77
|
-
if (upstream.mode === 'url') return new URL(upstream.url);
|
|
78
|
-
let descriptor;
|
|
79
|
-
try {
|
|
80
|
-
descriptor = await readDashboardDescriptor(path.join(dashboardRuntimeDir(env), 'daemon.json'));
|
|
81
|
-
} catch (error) {
|
|
82
|
-
throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE', 'dashboard descriptor is unavailable', undefined, error);
|
|
83
|
-
}
|
|
84
|
-
if (descriptor?.schema !== 'lattice.todo_dashboard_daemon.v1'
|
|
85
|
-
|| Object.keys(descriptor).sort().join(',') !== 'pid,port,schema,started_at'
|
|
86
|
-
|| !Number.isSafeInteger(descriptor.pid) || descriptor.pid <= 0
|
|
87
|
-
|| !Number.isSafeInteger(descriptor.port) || descriptor.port <= 0 || descriptor.port > 65_535
|
|
88
|
-
|| typeof descriptor.started_at !== 'string') {
|
|
89
|
-
throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor is invalid');
|
|
90
|
-
}
|
|
91
|
-
try {
|
|
92
|
-
const response = await fetch(`http://127.0.0.1:${descriptor.port}/__lattice/health`, {
|
|
93
|
-
signal: AbortSignal.timeout(healthTimeoutMs),
|
|
94
|
-
});
|
|
95
|
-
const health = response.status === 200 ? await response.json() : null;
|
|
96
|
-
if (health?.schema !== 'lattice.todo_dashboard_health.v1' || health.pid !== descriptor.pid
|
|
97
|
-
|| health.port !== descriptor.port) throw new Error('dashboard health mismatch');
|
|
98
|
-
} catch (error) {
|
|
99
|
-
throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE',
|
|
100
|
-
'dashboard descriptor could not be attested against loopback health', undefined, error);
|
|
101
|
-
}
|
|
102
|
-
return new URL(`http://127.0.0.1:${descriptor.port}/`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function responseError(response, status, code) {
|
|
106
|
-
if (response.headersSent) {
|
|
107
|
-
response.destroy();
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
111
|
-
response.end(`${JSON.stringify({ schema: 'lattice.bridge_http_error.v1', code })}\n`);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function connectionTokens(headers) {
|
|
115
|
-
const value = headers.connection;
|
|
116
|
-
return new Set((Array.isArray(value) ? value.join(',') : value ?? '')
|
|
117
|
-
.split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function forwardHeaders(headers) {
|
|
121
|
-
const nominated = connectionTokens(headers);
|
|
122
|
-
return Object.fromEntries(Object.entries(headers)
|
|
123
|
-
.filter(([name, value]) => value !== undefined && name !== 'host'
|
|
124
|
-
&& name.toLowerCase() !== 'forwarded' && name.toLowerCase() !== 'x-real-ip'
|
|
125
|
-
&& !name.toLowerCase().startsWith('x-forwarded-')
|
|
126
|
-
&& !UNTRUSTED_CLIENT_IP_HEADERS.has(name.toLowerCase())
|
|
127
|
-
&& !HOP_BY_HOP.has(name.toLowerCase()) && !nominated.has(name.toLowerCase())));
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function validatedRequestHost(value) {
|
|
131
|
-
if (typeof value !== 'string' || value.length === 0 || value.length > 512
|
|
132
|
-
|| /[\s\\/@,]/u.test(value)) {
|
|
133
|
-
throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
|
|
134
|
-
}
|
|
135
|
-
let parsed;
|
|
136
|
-
try { parsed = new URL(`http://${value}`); } catch {
|
|
137
|
-
throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
|
|
138
|
-
}
|
|
139
|
-
if (parsed.username !== '' || parsed.password !== '' || parsed.pathname !== '/'
|
|
140
|
-
|| parsed.search !== '' || parsed.hash !== '') {
|
|
141
|
-
throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
|
|
142
|
-
}
|
|
143
|
-
const rawHostname = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
|
|
144
|
-
? parsed.hostname.slice(1, -1) : parsed.hostname;
|
|
145
|
-
const hostname = normalizeBridgeAllowedHost(rawHostname);
|
|
146
|
-
const authorityHost = hostname.includes(':') ? `[${hostname}]` : hostname;
|
|
147
|
-
return { hostname, authority: parsed.port === '' ? authorityHost : `${authorityHost}:${parsed.port}` };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function forwardedHeaders(incoming, host) {
|
|
151
|
-
const remote = incoming.socket.remoteAddress ?? 'unknown';
|
|
152
|
-
const forwardedFor = remote.includes(':') ? `"[${remote}]"` : remote;
|
|
153
|
-
return {
|
|
154
|
-
forwarded: `for=${forwardedFor};host="${host.authority}";proto=http`,
|
|
155
|
-
'x-forwarded-for': remote,
|
|
156
|
-
'x-forwarded-host': host.authority,
|
|
157
|
-
'x-forwarded-proto': 'http',
|
|
158
|
-
'x-real-ip': remote,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function upstreamUrl(base, requestUrl) {
|
|
163
|
-
if (typeof requestUrl !== 'string' || requestUrl.includes('#') || !/^\/(?!\/)[^\s]*$/u.test(requestUrl)) {
|
|
164
|
-
throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge requires an origin-form request target');
|
|
165
|
-
}
|
|
166
|
-
const rawPath = requestUrl.split('?', 1)[0];
|
|
167
|
-
if (rawPath.includes('\\') || rawPath.includes('%')) {
|
|
168
|
-
throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID',
|
|
169
|
-
'bridge request target contains encoded path bytes or a backslash');
|
|
170
|
-
}
|
|
171
|
-
if (rawPath.split('/').some((segment) => segment === '.' || segment === '..')) {
|
|
172
|
-
throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge request target contains a dot segment');
|
|
173
|
-
}
|
|
174
|
-
const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`;
|
|
175
|
-
const target = new URL(requestUrl.slice(1), base);
|
|
176
|
-
if (target.origin !== base.origin || !target.pathname.startsWith(basePath)) {
|
|
177
|
-
throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge request target escapes upstream base');
|
|
178
|
-
}
|
|
179
|
-
return target;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function decodedRequestPath(requestUrl) {
|
|
183
|
-
if (typeof requestUrl !== 'string') return null;
|
|
184
|
-
try { return decodeURIComponent(requestUrl.split('?', 1)[0]); } catch { return null; }
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export async function startBridgeServer({
|
|
188
|
-
config, env = process.env,
|
|
189
|
-
instanceToken = null,
|
|
190
|
-
resolveUpstream = (upstream) => resolveBridgeUpstream(upstream, { env }),
|
|
191
|
-
interfaces = networkInterfaces(),
|
|
192
|
-
} = {}) {
|
|
193
|
-
if (config?.enabled !== true) throw new BridgeConfigError('BRIDGE_DISABLED', 'bridge is disabled');
|
|
194
|
-
if (!Array.isArray(config.allowed_hosts) || config.allowed_hosts.length === 0) {
|
|
195
|
-
throw new BridgeConfigError('BRIDGE_CONFIG_INVALID', 'bridge allowed hosts are required');
|
|
196
|
-
}
|
|
197
|
-
// A DHCP lease change moves the host inside its own subnet and strands the
|
|
198
|
-
// configured literal. Follow it rather than binding a dead address, but only
|
|
199
|
-
// within the same subnet (see bridge-address.mjs for why that bound matters).
|
|
200
|
-
const resolvedListen = resolveBridgeListenAddress({ configured: config.listen.address, interfaces });
|
|
201
|
-
if (resolvedListen.effective === null) {
|
|
202
|
-
throw new BridgeConfigError('BRIDGE_LISTEN_ADDRESS_ABSENT',
|
|
203
|
-
'configured bridge listen address is not present on this host',
|
|
204
|
-
{ ...config.listen, listen_state: resolvedListen.state });
|
|
205
|
-
}
|
|
206
|
-
const listenAddress = resolvedListen.effective;
|
|
207
|
-
let allowedHosts = new Set(config.allowed_hosts);
|
|
208
|
-
// The rebound address has to answer for itself, otherwise every request to it
|
|
209
|
-
// is rejected by the Host allow-list the operator never knew had gone stale.
|
|
210
|
-
if (listenAddress !== config.listen.address) {
|
|
211
|
-
allowedHosts.add(normalizeBridgeAllowedHost(listenAddress));
|
|
212
|
-
}
|
|
213
|
-
let currentConfig = config;
|
|
214
|
-
const handleRequest = async (incoming, response) => {
|
|
215
|
-
let requestHost;
|
|
216
|
-
try { requestHost = validatedRequestHost(incoming.headers.host); } catch (error) {
|
|
217
|
-
responseError(response, 400, error?.code ?? 'BRIDGE_HOST_INVALID');
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
if (!allowedHosts.has(requestHost.hostname)) {
|
|
221
|
-
responseError(response, 421, 'BRIDGE_HOST_NOT_ALLOWED');
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
if (incoming.url === '/__lattice/bridge-health') {
|
|
225
|
-
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
226
|
-
const attested = typeof instanceToken === 'string'
|
|
227
|
-
&& incoming.headers['x-lattice-bridge-instance-token'] === instanceToken;
|
|
228
|
-
response.end(`${JSON.stringify(attested
|
|
229
|
-
? { schema: 'lattice.bridge_health.v1', pid: process.pid,
|
|
230
|
-
address: currentConfig.listen.address, port: currentConfig.listen.port,
|
|
231
|
-
updated_at: currentConfig.updated_at ?? null }
|
|
232
|
-
: { schema: 'lattice.bridge_health.v1', status: 'available' })}\n`);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
if (decodedRequestPath(incoming.url) === '/__lattice/health') {
|
|
236
|
-
responseError(response, 404, 'BRIDGE_INTERNAL_PATH_DENIED');
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
let target;
|
|
240
|
-
try { target = upstreamUrl(await resolveUpstream(currentConfig.upstream), incoming.url); } catch (error) {
|
|
241
|
-
responseError(response, error?.code === 'BRIDGE_REQUEST_TARGET_INVALID' ? 400 : 503,
|
|
242
|
-
error?.code ?? 'BRIDGE_UPSTREAM_UNAVAILABLE');
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
let upstreamResponse = null;
|
|
246
|
-
const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, {
|
|
247
|
-
method: incoming.method,
|
|
248
|
-
headers: { ...forwardHeaders(incoming.headers), ...forwardedHeaders(incoming, requestHost), host: target.host },
|
|
249
|
-
}, (incomingResponse) => {
|
|
250
|
-
upstreamResponse = incomingResponse;
|
|
251
|
-
response.writeHead(incomingResponse.statusCode ?? 502, forwardHeaders(incomingResponse.headers));
|
|
252
|
-
incomingResponse.once('error', () => response.destroy());
|
|
253
|
-
incomingResponse.pipe(response);
|
|
254
|
-
});
|
|
255
|
-
request.once('error', (error) => responseError(response, 502,
|
|
256
|
-
error?.code === 'ECONNREFUSED' ? 'BRIDGE_UPSTREAM_REFUSED' : 'BRIDGE_PROXY_FAILED'));
|
|
257
|
-
incoming.once('aborted', () => request.destroy());
|
|
258
|
-
response.once('close', () => {
|
|
259
|
-
if (!response.writableFinished) {
|
|
260
|
-
request.destroy();
|
|
261
|
-
upstreamResponse?.destroy();
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
incoming.pipe(request);
|
|
265
|
-
};
|
|
266
|
-
const server = createServer((incoming, response) => {
|
|
267
|
-
handleRequest(incoming, response).catch((error) => responseError(response, 500,
|
|
268
|
-
error?.code ?? 'BRIDGE_REQUEST_FAILED'));
|
|
269
|
-
});
|
|
270
|
-
await new Promise((resolve, reject) => {
|
|
271
|
-
server.once('error', reject);
|
|
272
|
-
server.listen({ host: listenAddress, port: config.listen.port, exclusive: true }, resolve);
|
|
273
|
-
}).catch((error) => {
|
|
274
|
-
throw new BridgeConfigError(error?.code === 'EADDRINUSE' ? 'BRIDGE_PORT_UNAVAILABLE' : 'BRIDGE_BIND_FAILED',
|
|
275
|
-
'bridge listen failed', { ...config.listen, effective_address: listenAddress }, error);
|
|
276
|
-
});
|
|
277
|
-
const boundAddress = server.address();
|
|
278
|
-
const actualPort = typeof boundAddress === 'object' && boundAddress !== null
|
|
279
|
-
? boundAddress.port : config.listen.port;
|
|
280
|
-
let closed = false;
|
|
281
|
-
return Object.freeze({
|
|
282
|
-
address: listenAddress,
|
|
283
|
-
configured_address: config.listen.address,
|
|
284
|
-
rebound: listenAddress !== config.listen.address,
|
|
285
|
-
port: actualPort,
|
|
286
|
-
updateConfig(next) {
|
|
287
|
-
if (next?.enabled !== true || next.listen.address !== config.listen.address
|
|
288
|
-
|| next.listen.port !== config.listen.port) {
|
|
289
|
-
throw new BridgeConfigError('BRIDGE_RECONFIGURE_INVALID', 'live bridge binding cannot be mutated in place');
|
|
290
|
-
}
|
|
291
|
-
currentConfig = next;
|
|
292
|
-
allowedHosts = new Set(next.allowed_hosts);
|
|
293
|
-
if (listenAddress !== config.listen.address) {
|
|
294
|
-
allowedHosts.add(normalizeBridgeAllowedHost(listenAddress));
|
|
295
|
-
}
|
|
296
|
-
},
|
|
297
|
-
close: async () => {
|
|
298
|
-
if (closed) return;
|
|
299
|
-
closed = true;
|
|
300
|
-
const completion = new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
301
|
-
server.closeAllConnections?.();
|
|
302
|
-
await completion;
|
|
303
|
-
},
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
export function bridgeRuntimeController({
|
|
308
|
-
env = process.env, instanceToken = null,
|
|
309
|
-
register = registerBridgeUpstream,
|
|
310
|
-
report = (line) => process.stderr.write(`${line}\n`),
|
|
311
|
-
readInterfaces = () => networkInterfaces(),
|
|
312
|
-
} = {}) {
|
|
313
|
-
let active = null;
|
|
314
|
-
let fingerprint = null;
|
|
315
|
-
// A fresh binding is exactly when the reverse proxy's literal may have gone
|
|
316
|
-
// stale, so that is when the bridge tells it where it now is. A registration
|
|
317
|
-
// failure is reported, never swallowed, and never takes the local bridge down:
|
|
318
|
-
// the bridge is still serving, only the published route is behind.
|
|
319
|
-
const announce = async (binding) => {
|
|
320
|
-
try {
|
|
321
|
-
const outcome = await register({ port: binding.port, env });
|
|
322
|
-
if (outcome.state !== 'not_configured') report(JSON.stringify(outcome));
|
|
323
|
-
} catch (error) {
|
|
324
|
-
report(JSON.stringify({ schema: 'lattice.bridge_registrar_result.v1', state: 'failed',
|
|
325
|
-
port: binding.port, host: null, remote: null,
|
|
326
|
-
detail: error?.message ?? 'registration failed' }));
|
|
327
|
-
}
|
|
328
|
-
};
|
|
329
|
-
return Object.freeze({
|
|
330
|
-
async reconcile() {
|
|
331
|
-
const config = await readBridgeConfig({ env });
|
|
332
|
-
const next = config === null || !config.enabled ? null : JSON.stringify(config);
|
|
333
|
-
// The config is not the only thing that can change under a live binding.
|
|
334
|
-
// A DHCP lease change removes the bound address from the host without
|
|
335
|
-
// touching a byte of config, and the socket lingers on an address nothing
|
|
336
|
-
// can route to. Resolve the effective address on every pass, from the
|
|
337
|
-
// interfaces as they are now, so the binding follows the host rather than
|
|
338
|
-
// only the file. One snapshot serves both the comparison and the bind, so
|
|
339
|
-
// a lease that changes mid-reconcile cannot land them on different
|
|
340
|
-
// addresses.
|
|
341
|
-
const interfaces = next === null ? null : readInterfaces();
|
|
342
|
-
const effective = next === null ? null
|
|
343
|
-
: resolveBridgeListenAddress({ configured: config.listen.address, interfaces }).effective;
|
|
344
|
-
if (next === fingerprint && (active === null || active.address === effective)) return active;
|
|
345
|
-
if (next === null) {
|
|
346
|
-
await active?.close();
|
|
347
|
-
active = null;
|
|
348
|
-
fingerprint = null;
|
|
349
|
-
return null;
|
|
350
|
-
}
|
|
351
|
-
// The binding is reused while it still serves the same configuration AND
|
|
352
|
-
// still sits on the address that configuration resolves to today. The
|
|
353
|
-
// effective address is stable for an unchanged host, so this compares
|
|
354
|
-
// equal on every quiet pass and only forces a rebind when the ground has
|
|
355
|
-
// actually moved.
|
|
356
|
-
if (active !== null && active.configured_address === config.listen.address
|
|
357
|
-
&& active.port === config.listen.port && active.address === effective) {
|
|
358
|
-
active.updateConfig(config);
|
|
359
|
-
fingerprint = next;
|
|
360
|
-
return active;
|
|
361
|
-
}
|
|
362
|
-
const replacement = await startBridgeServer({ config, env, instanceToken, interfaces });
|
|
363
|
-
const previous = active;
|
|
364
|
-
active = replacement;
|
|
365
|
-
fingerprint = next;
|
|
366
|
-
await previous?.close();
|
|
367
|
-
await announce(active);
|
|
368
|
-
return active;
|
|
369
|
-
},
|
|
370
|
-
async close() {
|
|
371
|
-
await active?.close();
|
|
372
|
-
active = null;
|
|
373
|
-
fingerprint = null;
|
|
374
|
-
},
|
|
375
|
-
});
|
|
376
|
-
}
|
|
1
|
+
import { createServer, request as httpRequest } from 'node:http';
|
|
2
|
+
import { request as httpsRequest } from 'node:https';
|
|
3
|
+
import { constants as fsConstants } from 'node:fs';
|
|
4
|
+
import { lstat, open } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { parseTree } from 'jsonc-parser';
|
|
7
|
+
|
|
8
|
+
import { networkInterfaces } from 'node:os';
|
|
9
|
+
|
|
10
|
+
import { resolveBridgeListenAddress } from './bridge-address.mjs';
|
|
11
|
+
import { registerBridgeUpstream } from './bridge-registrar.mjs';
|
|
12
|
+
import {
|
|
13
|
+
BridgeConfigError, bridgeConfigPaths, normalizeBridgeAllowedHost, readBridgeConfig,
|
|
14
|
+
} from './bridge-config.mjs';
|
|
15
|
+
|
|
16
|
+
const HOP_BY_HOP = new Set([
|
|
17
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
18
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
19
|
+
]);
|
|
20
|
+
const UNTRUSTED_CLIENT_IP_HEADERS = new Set([
|
|
21
|
+
'cf-connecting-ip', 'client-ip', 'fastly-client-ip', 'true-client-ip',
|
|
22
|
+
'x-cluster-client-ip', 'x-proxyuser-ip',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function dashboardRuntimeDir(env) {
|
|
26
|
+
const configured = env.LATTICE_DASHBOARD_RUNTIME_DIR;
|
|
27
|
+
return typeof configured === 'string' && path.isAbsolute(configured)
|
|
28
|
+
? configured : path.join(bridgeConfigPaths(env).root, 'dashboard');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function duplicateJsonKey(node) {
|
|
32
|
+
if (node?.type === 'object') {
|
|
33
|
+
const keys = new Set();
|
|
34
|
+
for (const property of node.children ?? []) {
|
|
35
|
+
const [key, child] = property.children ?? [];
|
|
36
|
+
if (keys.has(key?.value) || duplicateJsonKey(child)) return true;
|
|
37
|
+
keys.add(key?.value);
|
|
38
|
+
}
|
|
39
|
+
} else if (node?.type === 'array') return (node.children ?? []).some(duplicateJsonKey);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readDashboardDescriptor(ref) {
|
|
44
|
+
let before;
|
|
45
|
+
let handle;
|
|
46
|
+
try {
|
|
47
|
+
before = await lstat(ref);
|
|
48
|
+
if (!before.isFile() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o600 || before.size > 65_536) {
|
|
49
|
+
throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor is unsafe');
|
|
50
|
+
}
|
|
51
|
+
handle = await open(ref, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
52
|
+
const opened = await handle.stat();
|
|
53
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
|
|
54
|
+
|| opened.size !== before.size || opened.size > 65_536) {
|
|
55
|
+
throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor changed during validation');
|
|
56
|
+
}
|
|
57
|
+
const text = await handle.readFile('utf8');
|
|
58
|
+
const after = await lstat(ref);
|
|
59
|
+
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) {
|
|
60
|
+
throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor changed during read');
|
|
61
|
+
}
|
|
62
|
+
const errors = [];
|
|
63
|
+
const tree = parseTree(text, errors, { allowTrailingComma: false, disallowComments: true });
|
|
64
|
+
if (errors.length > 0 || tree === undefined || duplicateJsonKey(tree)) {
|
|
65
|
+
throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor JSON is invalid');
|
|
66
|
+
}
|
|
67
|
+
return JSON.parse(text);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof BridgeConfigError) throw error;
|
|
70
|
+
throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE', 'dashboard descriptor is unavailable', undefined, error);
|
|
71
|
+
} finally { await handle?.close(); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function resolveBridgeUpstream(upstream, {
|
|
75
|
+
env = process.env, healthTimeoutMs = 2_000,
|
|
76
|
+
} = {}) {
|
|
77
|
+
if (upstream.mode === 'url') return new URL(upstream.url);
|
|
78
|
+
let descriptor;
|
|
79
|
+
try {
|
|
80
|
+
descriptor = await readDashboardDescriptor(path.join(dashboardRuntimeDir(env), 'daemon.json'));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE', 'dashboard descriptor is unavailable', undefined, error);
|
|
83
|
+
}
|
|
84
|
+
if (descriptor?.schema !== 'lattice.todo_dashboard_daemon.v1'
|
|
85
|
+
|| Object.keys(descriptor).sort().join(',') !== 'pid,port,schema,started_at'
|
|
86
|
+
|| !Number.isSafeInteger(descriptor.pid) || descriptor.pid <= 0
|
|
87
|
+
|| !Number.isSafeInteger(descriptor.port) || descriptor.port <= 0 || descriptor.port > 65_535
|
|
88
|
+
|| typeof descriptor.started_at !== 'string') {
|
|
89
|
+
throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor is invalid');
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const response = await fetch(`http://127.0.0.1:${descriptor.port}/__lattice/health`, {
|
|
93
|
+
signal: AbortSignal.timeout(healthTimeoutMs),
|
|
94
|
+
});
|
|
95
|
+
const health = response.status === 200 ? await response.json() : null;
|
|
96
|
+
if (health?.schema !== 'lattice.todo_dashboard_health.v1' || health.pid !== descriptor.pid
|
|
97
|
+
|| health.port !== descriptor.port) throw new Error('dashboard health mismatch');
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE',
|
|
100
|
+
'dashboard descriptor could not be attested against loopback health', undefined, error);
|
|
101
|
+
}
|
|
102
|
+
return new URL(`http://127.0.0.1:${descriptor.port}/`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function responseError(response, status, code) {
|
|
106
|
+
if (response.headersSent) {
|
|
107
|
+
response.destroy();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
111
|
+
response.end(`${JSON.stringify({ schema: 'lattice.bridge_http_error.v1', code })}\n`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function connectionTokens(headers) {
|
|
115
|
+
const value = headers.connection;
|
|
116
|
+
return new Set((Array.isArray(value) ? value.join(',') : value ?? '')
|
|
117
|
+
.split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function forwardHeaders(headers) {
|
|
121
|
+
const nominated = connectionTokens(headers);
|
|
122
|
+
return Object.fromEntries(Object.entries(headers)
|
|
123
|
+
.filter(([name, value]) => value !== undefined && name !== 'host'
|
|
124
|
+
&& name.toLowerCase() !== 'forwarded' && name.toLowerCase() !== 'x-real-ip'
|
|
125
|
+
&& !name.toLowerCase().startsWith('x-forwarded-')
|
|
126
|
+
&& !UNTRUSTED_CLIENT_IP_HEADERS.has(name.toLowerCase())
|
|
127
|
+
&& !HOP_BY_HOP.has(name.toLowerCase()) && !nominated.has(name.toLowerCase())));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function validatedRequestHost(value) {
|
|
131
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 512
|
|
132
|
+
|| /[\s\\/@,]/u.test(value)) {
|
|
133
|
+
throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
|
|
134
|
+
}
|
|
135
|
+
let parsed;
|
|
136
|
+
try { parsed = new URL(`http://${value}`); } catch {
|
|
137
|
+
throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
|
|
138
|
+
}
|
|
139
|
+
if (parsed.username !== '' || parsed.password !== '' || parsed.pathname !== '/'
|
|
140
|
+
|| parsed.search !== '' || parsed.hash !== '') {
|
|
141
|
+
throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
|
|
142
|
+
}
|
|
143
|
+
const rawHostname = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
|
|
144
|
+
? parsed.hostname.slice(1, -1) : parsed.hostname;
|
|
145
|
+
const hostname = normalizeBridgeAllowedHost(rawHostname);
|
|
146
|
+
const authorityHost = hostname.includes(':') ? `[${hostname}]` : hostname;
|
|
147
|
+
return { hostname, authority: parsed.port === '' ? authorityHost : `${authorityHost}:${parsed.port}` };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function forwardedHeaders(incoming, host) {
|
|
151
|
+
const remote = incoming.socket.remoteAddress ?? 'unknown';
|
|
152
|
+
const forwardedFor = remote.includes(':') ? `"[${remote}]"` : remote;
|
|
153
|
+
return {
|
|
154
|
+
forwarded: `for=${forwardedFor};host="${host.authority}";proto=http`,
|
|
155
|
+
'x-forwarded-for': remote,
|
|
156
|
+
'x-forwarded-host': host.authority,
|
|
157
|
+
'x-forwarded-proto': 'http',
|
|
158
|
+
'x-real-ip': remote,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function upstreamUrl(base, requestUrl) {
|
|
163
|
+
if (typeof requestUrl !== 'string' || requestUrl.includes('#') || !/^\/(?!\/)[^\s]*$/u.test(requestUrl)) {
|
|
164
|
+
throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge requires an origin-form request target');
|
|
165
|
+
}
|
|
166
|
+
const rawPath = requestUrl.split('?', 1)[0];
|
|
167
|
+
if (rawPath.includes('\\') || rawPath.includes('%')) {
|
|
168
|
+
throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID',
|
|
169
|
+
'bridge request target contains encoded path bytes or a backslash');
|
|
170
|
+
}
|
|
171
|
+
if (rawPath.split('/').some((segment) => segment === '.' || segment === '..')) {
|
|
172
|
+
throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge request target contains a dot segment');
|
|
173
|
+
}
|
|
174
|
+
const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`;
|
|
175
|
+
const target = new URL(requestUrl.slice(1), base);
|
|
176
|
+
if (target.origin !== base.origin || !target.pathname.startsWith(basePath)) {
|
|
177
|
+
throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge request target escapes upstream base');
|
|
178
|
+
}
|
|
179
|
+
return target;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function decodedRequestPath(requestUrl) {
|
|
183
|
+
if (typeof requestUrl !== 'string') return null;
|
|
184
|
+
try { return decodeURIComponent(requestUrl.split('?', 1)[0]); } catch { return null; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function startBridgeServer({
|
|
188
|
+
config, env = process.env,
|
|
189
|
+
instanceToken = null,
|
|
190
|
+
resolveUpstream = (upstream) => resolveBridgeUpstream(upstream, { env }),
|
|
191
|
+
interfaces = networkInterfaces(),
|
|
192
|
+
} = {}) {
|
|
193
|
+
if (config?.enabled !== true) throw new BridgeConfigError('BRIDGE_DISABLED', 'bridge is disabled');
|
|
194
|
+
if (!Array.isArray(config.allowed_hosts) || config.allowed_hosts.length === 0) {
|
|
195
|
+
throw new BridgeConfigError('BRIDGE_CONFIG_INVALID', 'bridge allowed hosts are required');
|
|
196
|
+
}
|
|
197
|
+
// A DHCP lease change moves the host inside its own subnet and strands the
|
|
198
|
+
// configured literal. Follow it rather than binding a dead address, but only
|
|
199
|
+
// within the same subnet (see bridge-address.mjs for why that bound matters).
|
|
200
|
+
const resolvedListen = resolveBridgeListenAddress({ configured: config.listen.address, interfaces });
|
|
201
|
+
if (resolvedListen.effective === null) {
|
|
202
|
+
throw new BridgeConfigError('BRIDGE_LISTEN_ADDRESS_ABSENT',
|
|
203
|
+
'configured bridge listen address is not present on this host',
|
|
204
|
+
{ ...config.listen, listen_state: resolvedListen.state });
|
|
205
|
+
}
|
|
206
|
+
const listenAddress = resolvedListen.effective;
|
|
207
|
+
let allowedHosts = new Set(config.allowed_hosts);
|
|
208
|
+
// The rebound address has to answer for itself, otherwise every request to it
|
|
209
|
+
// is rejected by the Host allow-list the operator never knew had gone stale.
|
|
210
|
+
if (listenAddress !== config.listen.address) {
|
|
211
|
+
allowedHosts.add(normalizeBridgeAllowedHost(listenAddress));
|
|
212
|
+
}
|
|
213
|
+
let currentConfig = config;
|
|
214
|
+
const handleRequest = async (incoming, response) => {
|
|
215
|
+
let requestHost;
|
|
216
|
+
try { requestHost = validatedRequestHost(incoming.headers.host); } catch (error) {
|
|
217
|
+
responseError(response, 400, error?.code ?? 'BRIDGE_HOST_INVALID');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (!allowedHosts.has(requestHost.hostname)) {
|
|
221
|
+
responseError(response, 421, 'BRIDGE_HOST_NOT_ALLOWED');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (incoming.url === '/__lattice/bridge-health') {
|
|
225
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
226
|
+
const attested = typeof instanceToken === 'string'
|
|
227
|
+
&& incoming.headers['x-lattice-bridge-instance-token'] === instanceToken;
|
|
228
|
+
response.end(`${JSON.stringify(attested
|
|
229
|
+
? { schema: 'lattice.bridge_health.v1', pid: process.pid,
|
|
230
|
+
address: currentConfig.listen.address, port: currentConfig.listen.port,
|
|
231
|
+
updated_at: currentConfig.updated_at ?? null }
|
|
232
|
+
: { schema: 'lattice.bridge_health.v1', status: 'available' })}\n`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (decodedRequestPath(incoming.url) === '/__lattice/health') {
|
|
236
|
+
responseError(response, 404, 'BRIDGE_INTERNAL_PATH_DENIED');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
let target;
|
|
240
|
+
try { target = upstreamUrl(await resolveUpstream(currentConfig.upstream), incoming.url); } catch (error) {
|
|
241
|
+
responseError(response, error?.code === 'BRIDGE_REQUEST_TARGET_INVALID' ? 400 : 503,
|
|
242
|
+
error?.code ?? 'BRIDGE_UPSTREAM_UNAVAILABLE');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
let upstreamResponse = null;
|
|
246
|
+
const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, {
|
|
247
|
+
method: incoming.method,
|
|
248
|
+
headers: { ...forwardHeaders(incoming.headers), ...forwardedHeaders(incoming, requestHost), host: target.host },
|
|
249
|
+
}, (incomingResponse) => {
|
|
250
|
+
upstreamResponse = incomingResponse;
|
|
251
|
+
response.writeHead(incomingResponse.statusCode ?? 502, forwardHeaders(incomingResponse.headers));
|
|
252
|
+
incomingResponse.once('error', () => response.destroy());
|
|
253
|
+
incomingResponse.pipe(response);
|
|
254
|
+
});
|
|
255
|
+
request.once('error', (error) => responseError(response, 502,
|
|
256
|
+
error?.code === 'ECONNREFUSED' ? 'BRIDGE_UPSTREAM_REFUSED' : 'BRIDGE_PROXY_FAILED'));
|
|
257
|
+
incoming.once('aborted', () => request.destroy());
|
|
258
|
+
response.once('close', () => {
|
|
259
|
+
if (!response.writableFinished) {
|
|
260
|
+
request.destroy();
|
|
261
|
+
upstreamResponse?.destroy();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
incoming.pipe(request);
|
|
265
|
+
};
|
|
266
|
+
const server = createServer((incoming, response) => {
|
|
267
|
+
handleRequest(incoming, response).catch((error) => responseError(response, 500,
|
|
268
|
+
error?.code ?? 'BRIDGE_REQUEST_FAILED'));
|
|
269
|
+
});
|
|
270
|
+
await new Promise((resolve, reject) => {
|
|
271
|
+
server.once('error', reject);
|
|
272
|
+
server.listen({ host: listenAddress, port: config.listen.port, exclusive: true }, resolve);
|
|
273
|
+
}).catch((error) => {
|
|
274
|
+
throw new BridgeConfigError(error?.code === 'EADDRINUSE' ? 'BRIDGE_PORT_UNAVAILABLE' : 'BRIDGE_BIND_FAILED',
|
|
275
|
+
'bridge listen failed', { ...config.listen, effective_address: listenAddress }, error);
|
|
276
|
+
});
|
|
277
|
+
const boundAddress = server.address();
|
|
278
|
+
const actualPort = typeof boundAddress === 'object' && boundAddress !== null
|
|
279
|
+
? boundAddress.port : config.listen.port;
|
|
280
|
+
let closed = false;
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
address: listenAddress,
|
|
283
|
+
configured_address: config.listen.address,
|
|
284
|
+
rebound: listenAddress !== config.listen.address,
|
|
285
|
+
port: actualPort,
|
|
286
|
+
updateConfig(next) {
|
|
287
|
+
if (next?.enabled !== true || next.listen.address !== config.listen.address
|
|
288
|
+
|| next.listen.port !== config.listen.port) {
|
|
289
|
+
throw new BridgeConfigError('BRIDGE_RECONFIGURE_INVALID', 'live bridge binding cannot be mutated in place');
|
|
290
|
+
}
|
|
291
|
+
currentConfig = next;
|
|
292
|
+
allowedHosts = new Set(next.allowed_hosts);
|
|
293
|
+
if (listenAddress !== config.listen.address) {
|
|
294
|
+
allowedHosts.add(normalizeBridgeAllowedHost(listenAddress));
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
close: async () => {
|
|
298
|
+
if (closed) return;
|
|
299
|
+
closed = true;
|
|
300
|
+
const completion = new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
301
|
+
server.closeAllConnections?.();
|
|
302
|
+
await completion;
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function bridgeRuntimeController({
|
|
308
|
+
env = process.env, instanceToken = null,
|
|
309
|
+
register = registerBridgeUpstream,
|
|
310
|
+
report = (line) => process.stderr.write(`${line}\n`),
|
|
311
|
+
readInterfaces = () => networkInterfaces(),
|
|
312
|
+
} = {}) {
|
|
313
|
+
let active = null;
|
|
314
|
+
let fingerprint = null;
|
|
315
|
+
// A fresh binding is exactly when the reverse proxy's literal may have gone
|
|
316
|
+
// stale, so that is when the bridge tells it where it now is. A registration
|
|
317
|
+
// failure is reported, never swallowed, and never takes the local bridge down:
|
|
318
|
+
// the bridge is still serving, only the published route is behind.
|
|
319
|
+
const announce = async (binding) => {
|
|
320
|
+
try {
|
|
321
|
+
const outcome = await register({ port: binding.port, env });
|
|
322
|
+
if (outcome.state !== 'not_configured') report(JSON.stringify(outcome));
|
|
323
|
+
} catch (error) {
|
|
324
|
+
report(JSON.stringify({ schema: 'lattice.bridge_registrar_result.v1', state: 'failed',
|
|
325
|
+
port: binding.port, host: null, remote: null,
|
|
326
|
+
detail: error?.message ?? 'registration failed' }));
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
return Object.freeze({
|
|
330
|
+
async reconcile() {
|
|
331
|
+
const config = await readBridgeConfig({ env });
|
|
332
|
+
const next = config === null || !config.enabled ? null : JSON.stringify(config);
|
|
333
|
+
// The config is not the only thing that can change under a live binding.
|
|
334
|
+
// A DHCP lease change removes the bound address from the host without
|
|
335
|
+
// touching a byte of config, and the socket lingers on an address nothing
|
|
336
|
+
// can route to. Resolve the effective address on every pass, from the
|
|
337
|
+
// interfaces as they are now, so the binding follows the host rather than
|
|
338
|
+
// only the file. One snapshot serves both the comparison and the bind, so
|
|
339
|
+
// a lease that changes mid-reconcile cannot land them on different
|
|
340
|
+
// addresses.
|
|
341
|
+
const interfaces = next === null ? null : readInterfaces();
|
|
342
|
+
const effective = next === null ? null
|
|
343
|
+
: resolveBridgeListenAddress({ configured: config.listen.address, interfaces }).effective;
|
|
344
|
+
if (next === fingerprint && (active === null || active.address === effective)) return active;
|
|
345
|
+
if (next === null) {
|
|
346
|
+
await active?.close();
|
|
347
|
+
active = null;
|
|
348
|
+
fingerprint = null;
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
// The binding is reused while it still serves the same configuration AND
|
|
352
|
+
// still sits on the address that configuration resolves to today. The
|
|
353
|
+
// effective address is stable for an unchanged host, so this compares
|
|
354
|
+
// equal on every quiet pass and only forces a rebind when the ground has
|
|
355
|
+
// actually moved.
|
|
356
|
+
if (active !== null && active.configured_address === config.listen.address
|
|
357
|
+
&& active.port === config.listen.port && active.address === effective) {
|
|
358
|
+
active.updateConfig(config);
|
|
359
|
+
fingerprint = next;
|
|
360
|
+
return active;
|
|
361
|
+
}
|
|
362
|
+
const replacement = await startBridgeServer({ config, env, instanceToken, interfaces });
|
|
363
|
+
const previous = active;
|
|
364
|
+
active = replacement;
|
|
365
|
+
fingerprint = next;
|
|
366
|
+
await previous?.close();
|
|
367
|
+
await announce(active);
|
|
368
|
+
return active;
|
|
369
|
+
},
|
|
370
|
+
async close() {
|
|
371
|
+
await active?.close();
|
|
372
|
+
active = null;
|
|
373
|
+
fingerprint = null;
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
}
|