@agnt-sdk/simon-server 0.0.0-stage → 0.1.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.
Files changed (78) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +317 -2
  4. package/dist/account.d.ts +59 -0
  5. package/dist/account.js +244 -0
  6. package/dist/auth.d.ts +42 -0
  7. package/dist/auth.js +96 -0
  8. package/dist/config.d.ts +219 -0
  9. package/dist/config.js +300 -0
  10. package/dist/context.d.ts +57 -0
  11. package/dist/context.js +2 -0
  12. package/dist/continuity.d.ts +35 -0
  13. package/dist/continuity.js +74 -0
  14. package/dist/deliver.d.ts +22 -0
  15. package/dist/deliver.js +208 -0
  16. package/dist/errors.d.ts +11 -0
  17. package/dist/errors.js +11 -0
  18. package/dist/extensions.d.ts +216 -0
  19. package/dist/extensions.js +75 -0
  20. package/dist/fanout.d.ts +32 -0
  21. package/dist/fanout.js +79 -0
  22. package/dist/http.d.ts +69 -0
  23. package/dist/http.js +112 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.js +12 -0
  26. package/dist/main.d.ts +2 -0
  27. package/dist/main.js +41 -0
  28. package/dist/net/dns.d.ts +31 -0
  29. package/dist/net/dns.js +100 -0
  30. package/dist/net/fetch.d.ts +48 -0
  31. package/dist/net/fetch.js +173 -0
  32. package/dist/net/ip.d.ts +38 -0
  33. package/dist/net/ip.js +159 -0
  34. package/dist/net/psl.d.ts +22 -0
  35. package/dist/net/psl.js +91 -0
  36. package/dist/net/public-suffixes.d.ts +10 -0
  37. package/dist/net/public-suffixes.js +6885 -0
  38. package/dist/notice.d.ts +5 -0
  39. package/dist/notice.js +61 -0
  40. package/dist/outbox.d.ts +14 -0
  41. package/dist/outbox.js +356 -0
  42. package/dist/parts.d.ts +29 -0
  43. package/dist/parts.js +443 -0
  44. package/dist/public.d.ts +43 -0
  45. package/dist/public.js +134 -0
  46. package/dist/published.d.ts +3 -0
  47. package/dist/published.js +140 -0
  48. package/dist/rate.d.ts +28 -0
  49. package/dist/rate.js +77 -0
  50. package/dist/readers.d.ts +31 -0
  51. package/dist/readers.js +48 -0
  52. package/dist/register.d.ts +4 -0
  53. package/dist/register.js +401 -0
  54. package/dist/resolver.d.ts +140 -0
  55. package/dist/resolver.js +386 -0
  56. package/dist/send.d.ts +30 -0
  57. package/dist/send.js +216 -0
  58. package/dist/server.d.ts +52 -0
  59. package/dist/server.js +234 -0
  60. package/dist/signal.d.ts +5 -0
  61. package/dist/signal.js +137 -0
  62. package/dist/store/contract.d.ts +4 -0
  63. package/dist/store/contract.js +1177 -0
  64. package/dist/store/sqlite-migration.d.ts +82 -0
  65. package/dist/store/sqlite-migration.js +1019 -0
  66. package/dist/store/sqlite.d.ts +108 -0
  67. package/dist/store/sqlite.js +992 -0
  68. package/dist/store/store.d.ts +400 -0
  69. package/dist/store/store.js +23 -0
  70. package/dist/sync.d.ts +19 -0
  71. package/dist/sync.js +199 -0
  72. package/dist/testing.d.ts +38 -0
  73. package/dist/testing.js +87 -0
  74. package/dist/webhooks.d.ts +10 -0
  75. package/dist/webhooks.js +162 -0
  76. package/dist/worker.d.ts +15 -0
  77. package/dist/worker.js +33 -0
  78. package/package.json +57 -4
package/dist/http.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Serving HTTP: a request and reply independent of node:http (so tests can drive a server in-process), a
3
+ * small router, and the adapter to node:http. A body is read only up to its route's cap, so an oversized
4
+ * one is refused without being held. Every error leaves as an RFC 9457 problem (spec section 9), with the
5
+ * headers its WireError names: its content type, `WWW-Authenticate` on a 401, Retry-After.
6
+ */
7
+ import type { IncomingMessage, ServerResponse } from 'node:http';
8
+ import { WireError } from '@agnt-sdk/simon-protocol';
9
+ import { type Network } from './net/ip.ts';
10
+ export interface Request {
11
+ method: string;
12
+ /** The request target exactly as sent: the path and query a signature covers. */
13
+ target: string;
14
+ path: string;
15
+ query: URLSearchParams;
16
+ /** The route's `:name` segments, percent-decoded. */
17
+ params: Record<string, string>;
18
+ /** Lower-case names, as node:http gives them. */
19
+ headers: Record<string, string | string[] | undefined>;
20
+ body: Buffer;
21
+ /** The client's address, as the socket saw it. */
22
+ ip: string;
23
+ }
24
+ export interface Reply {
25
+ status: number;
26
+ /** A JSON body, sent in canonical form, as application/json unless `headers` name another type. */
27
+ json?: unknown;
28
+ /** A byte body, sent as application/octet-stream. */
29
+ bytes?: Buffer;
30
+ headers?: Record<string, string>;
31
+ }
32
+ export interface Route<Api = unknown> {
33
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
34
+ /** A path whose `:name` segments match any one segment, e.g. `/simon/agents/:address`. */
35
+ path: string;
36
+ /** The largest body accepted; the server's `requestBodyBytes` when absent. */
37
+ maxBody?: number;
38
+ handle(req: Request, api: Api): Reply | Promise<Reply>;
39
+ }
40
+ export declare const json: (body: unknown, status?: number) => Reply;
41
+ export declare const errorReply: (e: WireError) => Reply;
42
+ export declare class Router<Api> {
43
+ #private;
44
+ add(route: Route<Api>): void;
45
+ /** The route for a method and path, with its parameters; null when none matches. */
46
+ match(method: string, path: string): {
47
+ route: Route<Api>;
48
+ params: Record<string, string>;
49
+ } | null;
50
+ }
51
+ /** What the node:http adapter serves: the body cap for a request, and the handling of a whole one. */
52
+ export interface App {
53
+ bodyLimit(method: string, path: string): number;
54
+ handle(req: Request): Promise<Reply>;
55
+ }
56
+ /** Where a client's address comes from behind proxies: the header they set, and the networks they connect from. */
57
+ export interface ProxySettings {
58
+ clientIpHeader: string | null;
59
+ trustedProxies: readonly Network[];
60
+ }
61
+ /**
62
+ * The client's address. With no `clientIpHeader`, the connection's. With one, the header is a list each proxy
63
+ * appends the address it heard from to, so it is read from the right: while the address so far is a trusted
64
+ * proxy, the entry before it names who that proxy heard from. A client can write anything at the left of the
65
+ * list, but never past the last trusted hop, so no spoofed entry is reached; an entry that is not an address
66
+ * stops the walk.
67
+ */
68
+ export declare function clientIp(req: IncomingMessage, { clientIpHeader, trustedProxies }: ProxySettings): string;
69
+ export declare function nodeListener(app: App, proxies: ProxySettings): (req: IncomingMessage, res: ServerResponse) => void;
package/dist/http.js ADDED
@@ -0,0 +1,112 @@
1
+ import { isIP } from 'node:net';
2
+ import { bodyTooLarge, canonicalize, WireError } from '@agnt-sdk/simon-protocol';
3
+ import { inNetworks, parseNetwork } from "./net/ip.js";
4
+ export const json = (body, status = 200) => ({ status, json: body });
5
+ export const errorReply = (e) => ({ status: e.status, json: e.toProblem(), headers: e.headers });
6
+ export class Router {
7
+ #routes = [];
8
+ add(route) {
9
+ if (this.#routes.some(r => r.route.method === route.method && r.route.path === route.path)) {
10
+ throw new Error(`Two routes for ${route.method} ${route.path}.`);
11
+ }
12
+ this.#routes.push({ route, segments: route.path.split('/') });
13
+ }
14
+ /** The route for a method and path, with its parameters; null when none matches. */
15
+ match(method, path) {
16
+ const parts = path.split('/');
17
+ for (const { route, segments } of this.#routes) {
18
+ if (route.method !== method || segments.length !== parts.length)
19
+ continue;
20
+ const params = {};
21
+ const fits = segments.every((s, i) => {
22
+ if (!s.startsWith(':'))
23
+ return s === parts[i];
24
+ try {
25
+ params[s.slice(1)] = decodeURIComponent(parts[i]);
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ return parts[i] !== '';
31
+ });
32
+ if (fits)
33
+ return { route, params };
34
+ }
35
+ return null;
36
+ }
37
+ }
38
+ /** This machine, always a trusted proxy: a tunnel's connector runs here. */
39
+ const LOOPBACK = ['127.0.0.0/8', '::1/128'].map(cidr => parseNetwork(cidr));
40
+ /**
41
+ * The client's address. With no `clientIpHeader`, the connection's. With one, the header is a list each proxy
42
+ * appends the address it heard from to, so it is read from the right: while the address so far is a trusted
43
+ * proxy, the entry before it names who that proxy heard from. A client can write anything at the left of the
44
+ * list, but never past the last trusted hop, so no spoofed entry is reached; an entry that is not an address
45
+ * stops the walk.
46
+ */
47
+ export function clientIp(req, { clientIpHeader, trustedProxies }) {
48
+ let ip = req.socket.remoteAddress ?? '';
49
+ if (!clientIpHeader)
50
+ return ip;
51
+ const named = req.headers[clientIpHeader];
52
+ const hops = typeof named === 'string' ? named.split(',').map(s => s.trim()) : [];
53
+ while (hops.length && (inNetworks(ip, LOOPBACK) || inNetworks(ip, trustedProxies))) {
54
+ const hop = hops.pop();
55
+ if (!isIP(hop))
56
+ break;
57
+ ip = hop;
58
+ }
59
+ return ip;
60
+ }
61
+ export function nodeListener(app, proxies) {
62
+ return (req, res) => {
63
+ const target = req.url ?? '/';
64
+ const path = target.split('?')[0];
65
+ const limit = app.bodyLimit(req.method ?? 'GET', path);
66
+ readBody(req, limit)
67
+ .then(body => body === null
68
+ ? errorReply(bodyTooLarge(limit))
69
+ : app.handle({
70
+ method: req.method ?? 'GET',
71
+ target,
72
+ path,
73
+ query: new URLSearchParams(target.includes('?') ? target.slice(target.indexOf('?') + 1) : ''),
74
+ params: {},
75
+ headers: req.headers,
76
+ body,
77
+ ip: clientIp(req, proxies),
78
+ }))
79
+ .then(reply => write(res, reply))
80
+ .catch(() => res.destroy());
81
+ };
82
+ }
83
+ /** The body, or null as soon as it passes `limit` bytes. */
84
+ function readBody(req, limit) {
85
+ if (Number(req.headers['content-length'] ?? 0) > limit) {
86
+ req.resume();
87
+ return Promise.resolve(null);
88
+ }
89
+ return new Promise((resolve, reject) => {
90
+ const chunks = [];
91
+ let size = 0;
92
+ req.on('data', (c) => {
93
+ size += c.length;
94
+ if (size <= limit)
95
+ return void chunks.push(c);
96
+ req.removeAllListeners('data');
97
+ req.resume();
98
+ resolve(null);
99
+ });
100
+ req.on('end', () => resolve(Buffer.concat(chunks)));
101
+ req.on('error', reject);
102
+ });
103
+ }
104
+ function write(res, reply) {
105
+ const body = reply.bytes ?? (reply.json === undefined ? Buffer.alloc(0) : Buffer.from(canonicalize(reply.json)));
106
+ res.writeHead(reply.status, {
107
+ 'content-type': reply.bytes ? 'application/octet-stream' : 'application/json',
108
+ 'content-length': String(body.length),
109
+ ...reply.headers,
110
+ });
111
+ res.end(body);
112
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The basic SIMON server: the whole protocol and client API, with Node's standard library only. An
3
+ * application builds on it through `createServer`'s extensions and its `Store`, without editing the core.
4
+ */
5
+ export { createServer, type SimonServer, type ServerOptions } from './server.ts';
6
+ export { defineConfig, loadConfig, DEFAULT_RATES, type Config, type ConfigInput, type Rate, type RateName, } from './config.ts';
7
+ export type { MailboxNote, MailboxSender } from './context.ts';
8
+ export type { AddressDecision, AddressRequest, Arrival, Decision, Events, Extension, Listeners, Log, PrincipalAnswer, PrincipalRequest, ReaderDecision, ReaderRequest, ServerApi, Upload, } from './extensions.ts';
9
+ export { json, type Reply, type Request, type Route } from './http.ts';
10
+ export { systemDns, DnsFailure, type Dns } from './net/dns.ts';
11
+ export { guardedHttp, Unreachable, type Http, type HttpRequest, type HttpResponse } from './net/fetch.ts';
12
+ export { clientKey, isBlockedAddress } from './net/ip.ts';
13
+ export { SqliteStore } from './store/sqlite.ts';
14
+ export { MAX_STORED_KEYS, StoreCapacityError, resolveRetractionCapacity, type RetractionCapacity, } from './store/store.ts';
15
+ export type { Agent, AgentKey, AgentAdmission, Delivery, HeldSignal, PartFetch, PartSource, Page, QueuedArrival, Store, StoredMessage, SweepCounts, SyncEntry, SyncPage, Webhook, } from './store/store.ts';
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The basic SIMON server: the whole protocol and client API, with Node's standard library only. An
3
+ * application builds on it through `createServer`'s extensions and its `Store`, without editing the core.
4
+ */
5
+ export { createServer } from "./server.js";
6
+ export { defineConfig, loadConfig, DEFAULT_RATES, } from "./config.js";
7
+ export { json } from "./http.js";
8
+ export { systemDns, DnsFailure } from "./net/dns.js";
9
+ export { guardedHttp, Unreachable } from "./net/fetch.js";
10
+ export { clientKey, isBlockedAddress } from "./net/ip.js";
11
+ export { SqliteStore } from "./store/sqlite.js";
12
+ export { MAX_STORED_KEYS, StoreCapacityError, resolveRetractionCapacity, } from "./store/store.js";
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The server as a process: `node server/src/main.ts`, or `simon-server` installed from npm. Configuration
4
+ * comes from the environment (see config.ts or README.md); storage is the sqlite file SIMON_DB.
5
+ * `simon-server help` prints how to run it and exits.
6
+ */
7
+ import { loadConfig } from "./config.js";
8
+ import { createServer } from "./server.js";
9
+ import { SqliteStore } from "./store/sqlite.js";
10
+ const [command] = process.argv.slice(2);
11
+ if (command === 'help' || command === '--help' || command === '-h') {
12
+ process.stdout.write(`simon-server: the SIMON reference server, configured by the environment.
13
+
14
+ SIMON_ORIGIN=https://simon.example.com SIMON_SERVER_KEY=<alg>:<seed> simon-server
15
+
16
+ SIMON_ORIGIN this server's origin required
17
+ SIMON_SERVER_KEY the signing key, <alg>:<base64url 32-byte seed> or SIMON_SERVER_KEY_FILE
18
+ SIMON_DB the sqlite file (default simon.sqlite)
19
+ SIMON_PORT the port to listen on (default 8080)
20
+ SIMON_DEV=1 development: http and local hosts, a throwaway key
21
+
22
+ Every setting: https://github.com/agnt-ai/simon-protocol/tree/main/server#readme
23
+ `);
24
+ process.exit(0);
25
+ }
26
+ if (command !== undefined) {
27
+ console.error(`simon-server: unknown command ${command}; "simon-server help" says how to run it.`);
28
+ process.exit(2);
29
+ }
30
+ const config = loadConfig();
31
+ const store = new SqliteStore(config.database);
32
+ const server = createServer({ config, store, retiredAgentDocuments: config.retiredAgentDocuments });
33
+ const port = await server.listen();
34
+ console.error(JSON.stringify({ level: 'info', message: 'listening', origin: config.origin, port, dev: config.dev }));
35
+ for (const signal of ['SIGINT', 'SIGTERM']) {
36
+ process.once(signal, async () => {
37
+ await server.close();
38
+ await store.close();
39
+ process.exit(0);
40
+ });
41
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * DNS through c-ares (node:dns Resolver), never getaddrinfo: every query has a timeout, and an absence
3
+ * (NXDOMAIN, no records) is told apart from a failure that may pass (SERVFAIL, a timeout, a refusal), as
4
+ * resolution requires (spec section 3).
5
+ *
6
+ * With two or more DNS-over-HTTPS resolvers configured, every TXT answer is confirmed: c-ares and each
7
+ * resolver must return the same records, or the answer is a failure that may pass (spec section 11.1).
8
+ * A validating resolver answers SERVFAIL for a DNSSEC failure, which is therefore temporary too.
9
+ */
10
+ import { Resolver } from 'node:dns/promises';
11
+ export interface Dns {
12
+ /** TXT records at a name, each as its character strings; [] when there are none. Throws on a failure. */
13
+ txt(name: string): Promise<string[][]>;
14
+ /** The A and AAAA addresses of a host; [] when it does not exist. Throws on a failure. */
15
+ addresses(host: string): Promise<string[]>;
16
+ }
17
+ /** A DNS answer that may change: retried later, never read as "none". */
18
+ export declare class DnsFailure extends Error {
19
+ constructor(message: string);
20
+ }
21
+ /** The c-ares calls used, so tests can stand in for them. */
22
+ export type Lookups = Pick<Resolver, 'resolveTxt' | 'resolve4' | 'resolve6'>;
23
+ export declare function systemDns(opts?: {
24
+ timeoutMs?: number;
25
+ doh?: string[];
26
+ servers?: string[];
27
+ resolver?: Lookups;
28
+ fetch?: typeof fetch;
29
+ }): Dns;
30
+ /** A TXT record's character strings from presentation form (`"a" "b\"c"`); unquoted text is one string. */
31
+ export declare function txtStrings(data: string): string[];
@@ -0,0 +1,100 @@
1
+ /**
2
+ * DNS through c-ares (node:dns Resolver), never getaddrinfo: every query has a timeout, and an absence
3
+ * (NXDOMAIN, no records) is told apart from a failure that may pass (SERVFAIL, a timeout, a refusal), as
4
+ * resolution requires (spec section 3).
5
+ *
6
+ * With two or more DNS-over-HTTPS resolvers configured, every TXT answer is confirmed: c-ares and each
7
+ * resolver must return the same records, or the answer is a failure that may pass (spec section 11.1).
8
+ * A validating resolver answers SERVFAIL for a DNSSEC failure, which is therefore temporary too.
9
+ */
10
+ import { Resolver } from 'node:dns/promises';
11
+ /** A DNS answer that may change: retried later, never read as "none". */
12
+ export class DnsFailure extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = 'DnsFailure';
16
+ }
17
+ }
18
+ const ABSENT = new Set(['ENOTFOUND', 'ENODATA']);
19
+ export function systemDns(opts = {}) {
20
+ const timeoutMs = opts.timeoutMs ?? 3000;
21
+ const resolver = opts.resolver ?? cares(timeoutMs, opts.servers ?? []);
22
+ const get = opts.fetch ?? fetch;
23
+ async function query(name, lookup) {
24
+ try {
25
+ return await lookup();
26
+ }
27
+ catch (e) {
28
+ const code = e.code ?? '';
29
+ if (ABSENT.has(code))
30
+ return [];
31
+ throw new DnsFailure(`${name}: ${code || e.message}`);
32
+ }
33
+ }
34
+ return {
35
+ async txt(name) {
36
+ const [local, ...confirmations] = await Promise.all([
37
+ query(name, () => resolver.resolveTxt(name)),
38
+ ...(opts.doh ?? []).map(url => dohTxt(get, url, name, timeoutMs)),
39
+ ]);
40
+ const answer = recordSet(local);
41
+ if (confirmations.some(c => recordSet(c) !== answer)) {
42
+ throw new DnsFailure(`${name}: resolvers disagree`);
43
+ }
44
+ return local;
45
+ },
46
+ async addresses(host) {
47
+ const results = await Promise.allSettled([resolver.resolve4(host), resolver.resolve6(host)]);
48
+ const found = results.flatMap(r => (r.status === 'fulfilled' ? r.value : []));
49
+ const failed = results.some(r => r.status === 'rejected' && !ABSENT.has(r.reason.code ?? ''));
50
+ if (!found.length && failed)
51
+ throw new DnsFailure(`${host}: address lookup failed`);
52
+ return found;
53
+ },
54
+ };
55
+ }
56
+ /** Records compared as resolution reads them: each joined, sorted. */
57
+ const recordSet = (records) => JSON.stringify(records.map(r => r.join('')).sort());
58
+ /**
59
+ * TXT records from a DNS-over-HTTPS JSON endpoint (`?name=&type=TXT`, as Google and Cloudflare serve).
60
+ * Status 0 is an answer, 3 (NXDOMAIN) is none, and anything else (2 is SERVFAIL, a DNSSEC failure at a
61
+ * validating resolver among them) is a failure.
62
+ */
63
+ async function dohTxt(get, endpoint, name, timeoutMs) {
64
+ let body;
65
+ try {
66
+ const url = `${endpoint}?name=${encodeURIComponent(name)}&type=TXT&do=1`;
67
+ const res = await get(url, {
68
+ headers: { accept: 'application/dns-json' },
69
+ redirect: 'error',
70
+ signal: AbortSignal.timeout(timeoutMs),
71
+ });
72
+ if (res.status !== 200)
73
+ throw new Error(`HTTP ${res.status}`);
74
+ const text = await res.text();
75
+ if (text.length > 65_536)
76
+ throw new Error('answer too large');
77
+ body = JSON.parse(text);
78
+ }
79
+ catch (e) {
80
+ throw new DnsFailure(`${name} via ${endpoint}: ${e.message}`);
81
+ }
82
+ if (body.Status === 3)
83
+ return [];
84
+ if (body.Status !== 0)
85
+ throw new DnsFailure(`${name} via ${endpoint}: status ${body.Status}`);
86
+ return (body.Answer ?? []).filter(a => a.type === 16).map(a => txtStrings(a.data));
87
+ }
88
+ /** A TXT record's character strings from presentation form (`"a" "b\"c"`); unquoted text is one string. */
89
+ export function txtStrings(data) {
90
+ if (!data.startsWith('"'))
91
+ return [data];
92
+ return [...data.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(m => m[1].replace(/\\(\d{3}|.)/g, (_, c) => (c.length === 3 ? String.fromCharCode(Number(c)) : c)));
93
+ }
94
+ /** A c-ares resolver with a timeout, asking `servers` when given and the system's DNS servers otherwise. */
95
+ function cares(timeoutMs, servers) {
96
+ const resolver = new Resolver({ timeout: timeoutMs, tries: 2 });
97
+ if (servers.length)
98
+ resolver.setServers(servers);
99
+ return resolver;
100
+ }
@@ -0,0 +1,48 @@
1
+ import type { Dns } from './dns.ts';
2
+ import { type Network } from './ip.ts';
3
+ export interface HttpRequest {
4
+ method: 'GET' | 'POST';
5
+ url: string;
6
+ headers?: Record<string, string>;
7
+ body?: Uint8Array;
8
+ /** The largest answer body accepted; a larger one fails the request. */
9
+ maxBytes: number;
10
+ /** The deadline for the whole exchange. */
11
+ timeoutMs?: number;
12
+ /** Refuse to connect to an address this answers true for, as if it refused the connection. */
13
+ avoid?: (address: string) => boolean;
14
+ }
15
+ export interface HttpResponse {
16
+ status: number;
17
+ headers: Record<string, string>;
18
+ body: Buffer;
19
+ }
20
+ export interface Http {
21
+ request(r: HttpRequest): Promise<HttpResponse>;
22
+ }
23
+ /**
24
+ * Why a request got no answer:
25
+ * absent the host does not exist
26
+ * forbidden the URL or an address it resolves to is one this server must not call
27
+ * refused the connection was refused, or the address is one the caller avoids
28
+ * certificate the server's certificate is not valid for the host
29
+ * connect no connection within the connect timeout
30
+ * timeout connected, but no whole answer before the deadline
31
+ * too_large the answer is larger than `maxBytes`
32
+ * failed anything else that may pass: a DNS failure, a TLS failure, a reset
33
+ *
34
+ * `address` is the address a connection was tried to, when one was.
35
+ */
36
+ export declare class Unreachable extends Error {
37
+ readonly kind: 'absent' | 'forbidden' | 'refused' | 'certificate' | 'connect' | 'timeout' | 'too_large' | 'failed';
38
+ readonly address?: string;
39
+ constructor(kind: Unreachable['kind'], message: string, address?: string);
40
+ }
41
+ export declare function guardedHttp(opts: {
42
+ dns: Dns;
43
+ dev?: boolean;
44
+ /** Networks whose addresses may be called although they are not public (config.trustedNetworks). */
45
+ trustedNetworks?: readonly Network[];
46
+ connectTimeoutMs?: number;
47
+ timeoutMs?: number;
48
+ }): Http;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Outbound HTTP with an SSRF guard. Every request this server makes to a URL it did not choose (another
3
+ * server, a domain's well-known answer) goes through here:
4
+ *
5
+ * - https only, TLS 1.3 or later; http and this machine's own addresses only in development;
6
+ * - no address outside the public internet, except in the networks the operator trusts
7
+ * (SIMON_TRUSTED_NETWORKS, for a private network such as a Tailscale tailnet);
8
+ * - no credentials in the URL, no redirects followed;
9
+ * - the host resolved through `Dns` (c-ares), every address checked, and the connection pinned to the
10
+ * checked address, so a second lookup cannot swap in another one;
11
+ * - a short connect timeout, a deadline for the whole exchange, and a cap on the answer's bytes;
12
+ * - an address the caller knows is dead (`avoid`) is not connected to at all, so many names for one
13
+ * tarpit cost one timeout, not one each.
14
+ */
15
+ import http from 'node:http';
16
+ import https from 'node:https';
17
+ import net from 'node:net';
18
+ import { inNetworks, isBlockedAddress, isLoopback } from "./ip.js";
19
+ /**
20
+ * Why a request got no answer:
21
+ * absent the host does not exist
22
+ * forbidden the URL or an address it resolves to is one this server must not call
23
+ * refused the connection was refused, or the address is one the caller avoids
24
+ * certificate the server's certificate is not valid for the host
25
+ * connect no connection within the connect timeout
26
+ * timeout connected, but no whole answer before the deadline
27
+ * too_large the answer is larger than `maxBytes`
28
+ * failed anything else that may pass: a DNS failure, a TLS failure, a reset
29
+ *
30
+ * `address` is the address a connection was tried to, when one was.
31
+ */
32
+ export class Unreachable extends Error {
33
+ kind;
34
+ address;
35
+ constructor(kind, message, address) {
36
+ super(message);
37
+ this.name = 'Unreachable';
38
+ this.kind = kind;
39
+ this.address = address;
40
+ }
41
+ }
42
+ /** Node's codes for a certificate that does not authenticate the host (RFC 9525). */
43
+ const CERTIFICATE_ERRORS = new Set([
44
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
45
+ 'CERT_HAS_EXPIRED',
46
+ 'CERT_NOT_YET_VALID',
47
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
48
+ 'SELF_SIGNED_CERT_IN_CHAIN',
49
+ 'UNABLE_TO_GET_ISSUER_CERT',
50
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
51
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
52
+ 'CERT_REVOKED',
53
+ 'CERT_UNTRUSTED',
54
+ ]);
55
+ export function guardedHttp(opts) {
56
+ const connectTimeoutMs = opts.connectTimeoutMs ?? 3000;
57
+ const allowLocal = opts.dev ?? false;
58
+ const trusted = opts.trustedNetworks ?? [];
59
+ async function target(raw) {
60
+ let url;
61
+ try {
62
+ url = new URL(raw);
63
+ }
64
+ catch {
65
+ throw new Unreachable('forbidden', `not a URL: ${raw}`);
66
+ }
67
+ if (url.username || url.password)
68
+ throw new Unreachable('forbidden', 'credentials in a URL');
69
+ if (!(url.protocol === 'https:' || (allowLocal && url.protocol === 'http:'))) {
70
+ throw new Unreachable('forbidden', `${url.protocol} is not allowed`);
71
+ }
72
+ const host = url.hostname.replace(/^\[|\]$/g, '');
73
+ let addresses;
74
+ if (net.isIP(host))
75
+ addresses = [host];
76
+ else if (allowLocal && host === 'localhost')
77
+ addresses = ['127.0.0.1'];
78
+ else {
79
+ try {
80
+ addresses = await opts.dns.addresses(host);
81
+ }
82
+ catch (e) {
83
+ throw new Unreachable('failed', e.message);
84
+ }
85
+ }
86
+ if (!addresses.length)
87
+ throw new Unreachable('absent', `no such host: ${host}`);
88
+ const refused = addresses.find(a => isBlockedAddress(a) && !(allowLocal && isLoopback(a)) && !inNetworks(a, trusted));
89
+ if (refused)
90
+ throw new Unreachable('forbidden', `${host} resolves to ${refused}, not a public address`);
91
+ return { url, address: addresses[0] };
92
+ }
93
+ return {
94
+ async request(r) {
95
+ const deadline = r.timeoutMs ?? opts.timeoutMs ?? 10_000;
96
+ const abort = new AbortController();
97
+ const timer = setTimeout(() => abort.abort(new Unreachable('timeout', `no answer within ${deadline} ms`)), deadline);
98
+ try {
99
+ const t = await Promise.race([target(r.url), aborted(abort.signal)]);
100
+ if (r.avoid?.(t.address))
101
+ throw new Unreachable('refused', `${t.address} is avoided`, t.address);
102
+ return await send(r, t, abort.signal, connectTimeoutMs);
103
+ }
104
+ catch (e) {
105
+ if (abort.signal.aborted)
106
+ throw abort.signal.reason;
107
+ throw e instanceof Unreachable ? e : new Unreachable('failed', e.message);
108
+ }
109
+ finally {
110
+ clearTimeout(timer);
111
+ }
112
+ },
113
+ };
114
+ }
115
+ const aborted = (signal) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }));
116
+ function send(r, { url, address }, signal, connectTimeoutMs) {
117
+ const secure = url.protocol === 'https:';
118
+ return new Promise((resolve, reject) => {
119
+ const req = (secure ? https : http).request({
120
+ host: address,
121
+ port: url.port || (secure ? 443 : 80),
122
+ servername: net.isIP(url.hostname) ? undefined : url.hostname,
123
+ path: url.pathname + url.search,
124
+ method: r.method,
125
+ minVersion: 'TLSv1.3',
126
+ headers: { ...r.headers, host: url.host, ...(r.body ? { 'content-length': String(r.body.length) } : {}) },
127
+ agent: false,
128
+ signal,
129
+ });
130
+ req.on('socket', socket => {
131
+ const timer = setTimeout(() => {
132
+ req.destroy(new Unreachable('connect', `no connection to ${url.host} within ${connectTimeoutMs} ms`, address));
133
+ }, connectTimeoutMs);
134
+ socket.once(secure ? 'secureConnect' : 'connect', () => clearTimeout(timer));
135
+ socket.once('close', () => clearTimeout(timer));
136
+ });
137
+ req.on('response', res => {
138
+ const declared = Number(res.headers['content-length'] ?? 0);
139
+ if (declared > r.maxBytes) {
140
+ res.destroy();
141
+ return reject(new Unreachable('too_large', `answer larger than ${r.maxBytes} bytes`));
142
+ }
143
+ const chunks = [];
144
+ let size = 0;
145
+ res.on('data', (c) => {
146
+ size += c.length;
147
+ if (size <= r.maxBytes)
148
+ return void chunks.push(c);
149
+ res.destroy();
150
+ reject(new Unreachable('too_large', `answer larger than ${r.maxBytes} bytes`));
151
+ });
152
+ res.on('end', () => {
153
+ const headers = {};
154
+ for (const [k, v] of Object.entries(res.headers))
155
+ headers[k] = Array.isArray(v) ? v.join(', ') : String(v ?? '');
156
+ resolve({ status: res.statusCode ?? 0, headers, body: Buffer.concat(chunks) });
157
+ });
158
+ res.on('error', reject);
159
+ });
160
+ req.on('error', e => {
161
+ if (e instanceof Unreachable)
162
+ return reject(e);
163
+ const code = e.code ?? '';
164
+ const kind = ['ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH'].includes(code)
165
+ ? 'refused'
166
+ : CERTIFICATE_ERRORS.has(code)
167
+ ? 'certificate'
168
+ : 'failed';
169
+ reject(new Unreachable(kind, e.message, address));
170
+ });
171
+ req.end(r.body);
172
+ });
173
+ }
@@ -0,0 +1,38 @@
1
+ /** The eight 16-bit groups of an IPv6 address in any spelling (embedded IPv4 included), or null. */
2
+ export declare function v6Groups(ip: string): number[] | null;
3
+ /**
4
+ * Whether this server must not connect to an address. IPv6 forms that reach IPv4 are judged by the IPv4
5
+ * they reach (mapped, compatible, SIIT), and those that reach arbitrary IPv4 are refused outright (NAT64,
6
+ * 6to4, Teredo), as are unique-local, link-local, site-local, multicast and documentation ranges.
7
+ */
8
+ export declare function isBlockedAddress(ip: string): boolean;
9
+ /** A network an operator trusts: an address range in CIDR notation, parsed. */
10
+ export interface Network {
11
+ /** The network as written in canonical form: `100.64.0.0/10`, `fd7a:115c:a1e0:0:0:0:0:0/48`. */
12
+ cidr: string;
13
+ /** The address as 16-bit groups: two for IPv4, eight for IPv6. */
14
+ groups: number[];
15
+ prefix: number;
16
+ }
17
+ /** The narrowest prefix a trusted network may have: /8 and longer, for IPv4 and IPv6 alike. */
18
+ export declare const NARROWEST_TRUSTED_PREFIX = 8;
19
+ /**
20
+ * A network from CIDR notation (`address/prefix`), or a reason it is refused: not CIDR, broader than /8,
21
+ * with bits set after the prefix (`100.64.0.1/10` is a host, not a network), or IPv4 written as mapped
22
+ * IPv6, which `inNetworks` judges as IPv4 and so would never match.
23
+ */
24
+ export declare function parseNetwork(text: string): Network | string;
25
+ /** Whether an address is inside one of the networks. An IPv4-mapped IPv6 address is judged as its IPv4. */
26
+ export declare function inNetworks(ip: string, networks: readonly Network[]): boolean;
27
+ /** Whether an address is this machine: allowed only in development. */
28
+ export declare const isLoopback: (ip: string) => boolean;
29
+ /**
30
+ * The key a client is rate-limited under: an IPv4 address as is, an IPv6 address by its /64, since one
31
+ * subscriber holds a whole /64. An IPv4-mapped IPv6 address counts as its IPv4.
32
+ */
33
+ export declare function clientKey(ip: string): string;
34
+ /**
35
+ * The network a client is in, wider than `clientKey`: an IPv4 address by its /24, an IPv6 address by its /48.
36
+ * What one party needs many of to look like many, so a bound kept per network holds for carriers it makes up.
37
+ */
38
+ export declare function networkKey(ip: string): string;