feat(api): complete phase 2.4 control plane

This commit is contained in:
chick
2026-07-17 00:37:11 +08:00
parent c2702b5fc6
commit 7b91dbbad1
40 changed files with 4526 additions and 74 deletions
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { PinnedHttpRequester, type PinnedDispatchOptions } from './pinned-http-requester.js';
describe('PinnedHttpRequester', () => {
it('pins Node lookup to the validated address and sets bounded request options', async () => {
let captured: PinnedDispatchOptions | undefined;
const requester = new PinnedHttpRequester({
dispatch: async (options) => {
captured = options;
return { status: 200, headers: {}, body: '' };
},
});
await requester.request(
{ url: 'http://device.lan:8080/api/health', method: 'GET', headers: {} },
[{ address: '192.168.1.20', family: 4 }],
);
if (!captured) throw new Error('dispatch was not called');
const dispatchOptions = captured;
expect(dispatchOptions.followRedirects).toBe(false);
expect(dispatchOptions.timeoutMs).toBe(5000);
expect(dispatchOptions.maxBodyBytes).toBe(65536);
await expect(
new Promise((resolve, reject) =>
dispatchOptions.lookup('device.lan', { all: false }, (error, address, family) => {
if (error) reject(error);
else if (Array.isArray(address))
reject(new Error('unexpected all-address lookup result'));
else resolve({ a: address, f: family });
}),
),
).resolves.toEqual({ a: '192.168.1.20', f: 4 });
});
});
@@ -0,0 +1,110 @@
import * as http from 'node:http';
import * as https from 'node:https';
import type { LookupFunction } from 'node:net';
import type {
PinnedRequest,
ResolvedAddress,
TransportResponse,
} from './safe-instance-transport.js';
import { asUpstreamError, UpstreamError } from './upstream-error.js';
export interface PinnedDispatchOptions {
readonly url: URL;
readonly method: 'GET' | 'POST';
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
readonly lookup: LookupFunction;
readonly followRedirects: false;
readonly timeoutMs: number;
readonly maxBodyBytes: number;
}
export interface PinnedHttpRequesterOptions {
readonly dispatch?: (options: PinnedDispatchOptions) => Promise<TransportResponse>;
readonly timeoutMs?: number;
readonly maxBodyBytes?: number;
}
const DEFAULT_TIMEOUT_MS = 5_000;
const DEFAULT_MAX_BODY_BYTES = 65_536;
const lookupFor = (addresses: readonly ResolvedAddress[]): LookupFunction => {
const first = addresses[0];
if (!first) throw new Error('No validated address');
return (_hostname, _options, callback) => callback(null, first.address, first.family);
};
export class PinnedHttpRequester {
private readonly dispatch: (options: PinnedDispatchOptions) => Promise<TransportResponse>;
private readonly timeoutMs: number;
private readonly maxBodyBytes: number;
constructor(options: PinnedHttpRequesterOptions = {}) {
this.dispatch = options.dispatch ?? dispatchNative;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
}
async request(
request: PinnedRequest,
addresses: readonly ResolvedAddress[],
): Promise<TransportResponse> {
try {
return await this.dispatch({
url: new URL(request.url),
method: request.method,
headers: request.headers,
...(request.body === undefined ? {} : { body: request.body }),
lookup: lookupFor(addresses),
followRedirects: false,
timeoutMs: this.timeoutMs,
maxBodyBytes: this.maxBodyBytes,
});
} catch (error) {
throw asUpstreamError(error);
}
}
}
const dispatchNative = (options: PinnedDispatchOptions): Promise<TransportResponse> =>
new Promise((resolve, reject) => {
const client = options.url.protocol === 'https:' ? https : http;
const request = client.request({
protocol: options.url.protocol,
hostname: options.url.hostname,
port: options.url.port || undefined,
path: `${options.url.pathname}${options.url.search}`,
method: options.method,
headers: options.headers,
lookup: options.lookup,
agent: false,
timeout: options.timeoutMs,
maxHeaderSize: 16_384,
...(options.url.protocol === 'https:' ? { servername: options.url.hostname } : {}),
});
let settled = false;
const fail = (error: Error): void => {
if (settled) return;
settled = true;
request.destroy(error);
reject(error);
};
request.once('timeout', () => fail(new UpstreamError('UPSTREAM_TIMEOUT')));
request.once('error', fail);
request.once('response', (response) => {
const chunks: Buffer[] = [];
let bytes = 0;
response.on('data', (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > options.maxBodyBytes) fail(new UpstreamError('UPSTREAM_RESPONSE_TOO_LARGE'));
else chunks.push(chunk);
});
response.once('error', fail);
response.once('end', () => {
if (settled) return;
settled = true;
const headers: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(response.headers))
headers[key] = Array.isArray(value) ? value.join(', ') : value;
resolve({
status: response.statusCode ?? 0,
headers,
body: Buffer.concat(chunks).toString('utf8'),
});
});
});
request.end(options.body);
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { createProductionUpstream } from './production-upstream.js';
describe('createProductionUpstream', () => {
it('passes every resolved address from its resolver to the pinned requester', async () => {
let received: unknown;
const upstream = createProductionUpstream({
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
request: async (request, addresses) => {
received = { request, addresses };
return { status: 200, headers: {}, body: '' };
},
});
await upstream.get('http://router.lan:8080/api/health');
expect(received).toMatchObject({
request: { method: 'GET', url: 'http://router.lan:8080/api/health' },
addresses: [{ address: '192.168.1.20', family: 4 }],
});
});
});
@@ -0,0 +1,33 @@
import { lookup as nodeLookup } from 'node:dns/promises';
import {
createSafeControlPlaneUpstream,
type SafeControlPlaneUpstream,
} from './safe-control-plane-upstream.js';
import { PinnedHttpRequester } from './pinned-http-requester.js';
import {
SafeInstanceTransport,
type PinnedRequest,
type ResolvedAddress,
type TransportResponse,
} from './safe-instance-transport.js';
export interface ProductionUpstreamOptions {
readonly resolve?: (hostname: string) => Promise<readonly ResolvedAddress[]>;
readonly request?: (
request: PinnedRequest,
addresses: readonly ResolvedAddress[],
) => Promise<TransportResponse>;
}
export function createProductionUpstream(
options: ProductionUpstreamOptions = {},
): SafeControlPlaneUpstream {
const requester = options.request ? { request: options.request } : new PinnedHttpRequester();
const transport = new SafeInstanceTransport({
resolve:
options.resolve ??
((hostname) =>
nodeLookup(hostname, { all: true, verbatim: true }) as Promise<ResolvedAddress[]>),
request: (request, addresses) => requester.request(request, addresses),
});
return createSafeControlPlaneUpstream(transport);
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { createSafeControlPlaneUpstream } from './safe-control-plane-upstream.js';
describe('createSafeControlPlaneUpstream', () => {
it('routes both health GET and login POST through the same safe transport', async () => {
const calls: unknown[] = [];
const upstream = createSafeControlPlaneUpstream({
get: async (url) => {
calls.push({ method: 'GET', url });
return { status: 401, headers: {}, body: '' };
},
post: async (url, headers, body) => {
calls.push({ method: 'POST', url, headers, body });
return { status: 200, headers: {}, body: '' };
},
});
await upstream.get('http://192.168.1.20/api/health');
await upstream.request({
url: 'https://192.168.1.20/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
secret: '[REDACTED]',
body: '[REDACTED]',
});
expect(calls).toEqual([
{ method: 'GET', url: 'http://192.168.1.20/api/health' },
{
method: 'POST',
url: 'https://192.168.1.20/api/auth/login',
headers: { 'content-type': 'application/json' },
body: '{"password":"[REDACTED]"}',
},
]);
});
});
@@ -0,0 +1,16 @@
import type { ConnectionTransport } from '../../application/connections/connection-probe.js';
import type { UpstreamSessionClientOptions } from '../../application/connections/upstream-session-client.js';
import { SafeUpstreamGateway, type SafeUpstreamTransport } from './safe-upstream-gateway.js';
export interface SafeControlPlaneUpstream extends ConnectionTransport {
request: UpstreamSessionClientOptions['request'];
}
export function createSafeControlPlaneUpstream(
transport: SafeUpstreamTransport,
): SafeControlPlaneUpstream {
const gateway = new SafeUpstreamGateway({ transport });
return {
get: (url) => transport.get(url),
request: (request) => gateway.request(request),
};
}
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import { SafeInstanceTransport, TransportError } from './safe-instance-transport.js';
const allowed = new SafeInstanceTransport({
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
request: async (request) => ({
status: 200,
headers: {},
body: `ok:${request.url}`,
}),
});
describe('SafeInstanceTransport', () => {
it('resolves a hostname immediately before requesting its health endpoint', async () => {
await expect(allowed.get('http://router.lan:3000/api/health')).resolves.toMatchObject({
status: 200,
body: 'ok:http://router.lan:3000/api/health',
});
});
it('supports HTTPS origins using the same validated address pinning path', async () => {
await expect(allowed.get('https://router.lan:3443/api/health')).resolves.toMatchObject({
status: 200,
body: 'ok:https://router.lan:3443/api/health',
});
});
it('pins IPv6 ULA literals without attempting DNS resolution', async () => {
let resolved = false;
let addresses: readonly { address: string; family: 4 | 6 }[] | undefined;
const transport = new SafeInstanceTransport({
resolve: async () => {
resolved = true;
return [];
},
request: async (_request, received) => {
addresses = received;
return { status: 200, headers: {}, body: '' };
},
});
await transport.get('http://[fd00::1]:8080/api/health');
expect(resolved).toBe(false);
expect(addresses).toEqual([{ address: 'fd00::1', family: 6 }]);
});
it.each([
{ name: 'loopback', address: '127.0.0.1' },
{ name: 'link-local', address: '169.254.169.254' },
{ name: 'unspecified', address: '0.0.0.0' },
{ name: 'multicast', address: '224.0.0.1' },
{ name: 'public', address: '8.8.8.8' },
])('rejects a DNS $name result before network I/O', async ({ address }) => {
let requested = false;
const transport = new SafeInstanceTransport({
resolve: async () => [{ address, family: 4 }],
request: async () => {
requested = true;
return { status: 200, headers: {}, body: '' };
},
});
await expect(transport.get('http://device.lan/api/health')).rejects.toMatchObject({
code: 'UNSAFE_RESOLUTION',
} satisfies Partial<TransportError>);
expect(requested).toBe(false);
});
it('passes only the validated resolution set to POST requesters for pinning', async () => {
let received:
| { url: string; method: string; headers: Readonly<Record<string, string>>; body?: string }
| undefined;
let addresses: readonly { address: string; family: 4 | 6 }[] | undefined;
const transport = new SafeInstanceTransport({
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
request: async (request, resolved) => {
received = request;
addresses = resolved;
return { status: 200, headers: {}, body: '' };
},
});
await transport.post(
'http://device.lan/api/auth/login',
{ 'content-type': 'application/json' },
'{"password":"[REDACTED]"}',
);
expect(received).toEqual({
url: 'http://device.lan/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"password":"[REDACTED]"}',
});
expect(addresses).toEqual([{ address: '192.168.1.20', family: 4 }]);
});
it('passes only the validated resolution set to the requester for pinning', async () => {
let received: readonly { address: string; family: 4 | 6 }[] | undefined;
const transport = new SafeInstanceTransport({
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
request: async (request, addresses) => {
expect(request.method).toBe('GET');
received = addresses;
return { status: 200, headers: {}, body: '' };
},
});
await transport.get('http://device.lan/api/health');
expect(received).toEqual([{ address: '192.168.1.20', family: 4 }]);
});
it('rejects mixed safe and unsafe DNS answers to prevent rebinding races', async () => {
const transport = new SafeInstanceTransport({
resolve: async () => [
{ address: '192.168.1.20', family: 4 },
{ address: '127.0.0.1', family: 4 },
],
request: async () => ({ status: 200, headers: {}, body: '' }),
});
await expect(transport.get('http://device.lan/api/health')).rejects.toMatchObject({
code: 'UNSAFE_RESOLUTION',
} satisfies Partial<TransportError>);
});
it('does not follow redirects across trust boundaries', async () => {
let calls = 0;
const transport = new SafeInstanceTransport({
resolve: async () => [{ address: '192.168.1.20', family: 4 }],
request: async () => {
calls += 1;
return { status: 302, headers: { location: 'http://127.0.0.1/private' }, body: '' };
},
});
await expect(transport.get('http://device.lan/api/health')).rejects.toMatchObject({
code: 'REDIRECT_REJECTED',
} satisfies Partial<TransportError>);
expect(calls).toBe(1);
});
});
@@ -0,0 +1,105 @@
import { isIP } from 'node:net';
import { asUpstreamError, UpstreamError } from './upstream-error.js';
export interface ResolvedAddress {
readonly address: string;
readonly family: 4 | 6;
}
export interface TransportResponse {
readonly status: number;
readonly headers: Readonly<Record<string, string | undefined>>;
readonly body: string;
}
export interface PinnedRequest {
readonly url: string;
readonly method: 'GET' | 'POST';
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
}
export interface SafeTransportOptions {
readonly resolve: (hostname: string) => Promise<readonly ResolvedAddress[]>;
readonly request: (
request: PinnedRequest,
addresses: readonly ResolvedAddress[],
) => Promise<TransportResponse>;
}
export class TransportError extends UpstreamError {
constructor(override readonly code: 'UNSAFE_ORIGIN' | 'UNSAFE_RESOLUTION' | 'REDIRECT_REJECTED') {
super(code);
this.name = 'TransportError';
}
}
const isPrivateV4 = (address: string): boolean => {
const parts = address.split('.').map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
return false;
const [a, b, c] = parts;
if (a === undefined || b === undefined || c === undefined) return false;
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 192 && b === 0 && c === 0)
);
};
const isAllowedAddress = ({ address, family }: ResolvedAddress): boolean => {
if (family === 4) return isPrivateV4(address);
const normalized = address.toLowerCase();
return normalized.startsWith('fc') || normalized.startsWith('fd');
};
const origin = (raw: string): URL => {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new TransportError('UNSAFE_ORIGIN');
}
if (
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
)
throw new TransportError('UNSAFE_ORIGIN');
if (!parsed.pathname.startsWith('/')) throw new TransportError('UNSAFE_ORIGIN');
return parsed;
};
export class SafeInstanceTransport {
constructor(private readonly options: SafeTransportOptions) {}
async get(raw: string): Promise<TransportResponse> {
return this.send({ url: raw, method: 'GET', headers: {} });
}
async post(
raw: string,
headers: Readonly<Record<string, string>>,
body: string,
): Promise<TransportResponse> {
return this.send({ url: raw, method: 'POST', headers, body });
}
private async send(request: PinnedRequest): Promise<TransportResponse> {
const parsed = origin(request.url);
const dialHost = parsed.hostname.replace(/^\[|\]$/g, '');
const literalFamily = isIP(dialHost);
let addresses: readonly ResolvedAddress[];
try {
addresses = literalFamily
? [{ address: dialHost, family: literalFamily as 4 | 6 }]
: await this.options.resolve(dialHost);
} catch (error) {
throw asUpstreamError(error);
}
if (addresses.length === 0 || addresses.some((entry) => !isAllowedAddress(entry)))
throw new TransportError('UNSAFE_RESOLUTION');
let response: TransportResponse;
try {
response = await this.options.request({ ...request, url: parsed.toString() }, addresses);
} catch (error) {
throw asUpstreamError(error);
}
if (response.status >= 300 && response.status < 400)
throw new TransportError('REDIRECT_REJECTED');
return response;
}
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { SafeUpstreamGateway } from './safe-upstream-gateway.js';
describe('SafeUpstreamGateway', () => {
it('sends login password only as JSON through the pinned POST transport', async () => {
const calls: unknown[] = [];
const gateway = new SafeUpstreamGateway({
transport: {
get: async () => ({ status: 200, headers: {}, body: '' }),
post: async (url, headers, body) => {
calls.push({ url, headers, body });
return { status: 200, headers: { 'set-cookie': 'simadmin_session=opaque' }, body: '' };
},
},
});
await gateway.request({
url: 'https://192.168.1.20:8080/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
secret: '[REDACTED]',
body: '[REDACTED]',
});
expect(calls).toEqual([
{
url: 'https://192.168.1.20:8080/api/auth/login',
headers: { 'content-type': 'application/json' },
body: '{"password":"[REDACTED]"}',
},
]);
});
it('rejects HTTP login and logout so credentials and cookies are never sent in cleartext', async () => {
const gateway = new SafeUpstreamGateway({
transport: {
get: async () => ({ status: 200, headers: {}, body: '' }),
post: async () => ({ status: 200, headers: {}, body: '' }),
},
});
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
secret: '[REDACTED]',
body: '[REDACTED]',
}),
).rejects.toThrow('UPSTREAM_INSECURE_AUTH');
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/auth/logout',
method: 'POST',
headers: { cookie: 'simadmin_session=opaque' },
}),
).rejects.toThrow('UPSTREAM_INSECURE_AUTH');
});
it('does not allow a supplied redacted body marker to become a network request body', async () => {
const gateway = new SafeUpstreamGateway({
transport: {
get: async () => ({ status: 200, headers: {}, body: '' }),
post: async () => ({ status: 200, headers: {}, body: '' }),
},
});
await expect(
gateway.request({
url: 'https://192.168.1.20:8080/api/auth/login',
method: 'POST',
headers: {},
body: '[REDACTED]',
}),
).rejects.toThrow('UPSTREAM_REQUEST_INVALID');
});
});
@@ -0,0 +1,38 @@
import type {
UpstreamRequest,
UpstreamResponse,
} from '../../application/connections/upstream-session-client.js';
import type { TransportResponse } from './safe-instance-transport.js';
import { UpstreamError } from './upstream-error.js';
export interface SafeUpstreamTransport {
get(url: string): Promise<TransportResponse>;
post(
url: string,
headers: Readonly<Record<string, string>>,
body: string,
): Promise<TransportResponse>;
}
export class SafeUpstreamGateway {
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
const url = new URL(request.url);
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (request.url.endsWith('/api/auth/login')) {
if (typeof request.secret !== 'string' || request.body !== '[REDACTED]')
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(
request.url,
request.headers,
JSON.stringify({ password: request.secret }),
);
}
if (request.url.endsWith('/api/auth/logout')) {
if (request.secret !== undefined || request.body !== undefined)
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
return this.options.transport.post(request.url, request.headers, '');
}
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
}
}
@@ -0,0 +1,24 @@
export type UpstreamErrorCode =
| 'UPSTREAM_UNAVAILABLE'
| 'UPSTREAM_TIMEOUT'
| 'UPSTREAM_RESPONSE_TOO_LARGE'
| 'UPSTREAM_INSECURE_AUTH'
| 'UPSTREAM_REQUEST_INVALID'
| 'UNSAFE_ORIGIN'
| 'UNSAFE_RESOLUTION'
| 'REDIRECT_REJECTED';
export class UpstreamError extends Error {
constructor(
readonly code: UpstreamErrorCode,
options?: { readonly cause?: unknown },
) {
super(code, options);
this.name = 'UpstreamError';
}
}
export const asUpstreamError = (error: unknown): UpstreamError =>
error instanceof UpstreamError
? error
: new UpstreamError('UPSTREAM_UNAVAILABLE', { cause: error });