import type { LookupAddress } from 'node:dns'; import type { LookupFunction } from 'node:net'; import { type EgressAddressPolicy } from '@lobu/connector-sdk/ip-reachability'; import { Agent, buildConnector } from 'undici'; /** * The one Node egress transport for untrusted destinations. * * Policy (which hosts a caller may name) is `@lobu/connector-sdk/egress-policy` * and the IP classifier plus address policy is * `@lobu/connector-sdk/ip-reachability`; both are pure. This module is the * Node layer above them: DNS resolution, socket pinning, and the credential * transport rule. The gateway dials through here for all of it — its worker * egress proxy, the MCP proxy, OAuth and connector-operation fetches — and so * does the connector isolate lane, for the guest's `fetch` (via * {@link fetchPublicUrl} on a per-executor {@link EgressDispatcher}) and for * the raw sockets the DB connectors open (via {@link resolveEgressAddresses}). * A DNS rebinding gap closed once is closed for every one of them. * * `fetchPublicUrl` closes the check-then-fetch (TOCTOU) gap a plain predicate * leaves open: its connector resolves every hostname once, rejects the whole * answer set if any address is refused, then hands one of those exact * validated addresses to the socket. Redirects stay on the same dispatcher, so * every hop gets the same treatment. * * `resolveEgressAddresses` is the same validation for callers that open their * own socket (the proxy's CONNECT tunnel, the isolate's `socketOpen`): they * dial an address it returned and never re-resolve the name. * * The address axis ({@link EgressAddressOptions}) is what differs between * callers: the gateway refuses every reserved address; a self-hosted database * run may reach private space under `allow-private`; an operator exemption * lowers ONE exact host to that floor. Cloud metadata stays refused under all * of them. * * `isInternalUrl` remains a plain predicate and still has that gap by * construction; prefer the transport whenever the point of the check is to then * make the request. */ /** * Raised when a target is, or resolves to, an address the policy refuses. * * A distinct class rather than a message prefix: `findPrivateAddressError` * digs this out of the `cause` / `AggregateError` chain that Node's fetch wraps * connector failures in, and matching on message text would silently stop * working the first time someone reworded the string. `hostname` is the name * the caller asked for; `address` is the refused DNS answer, or `null` when * the hostname itself was the refused literal or internal name. */ export declare class PrivateAddressError extends Error { readonly hostname: string; readonly address: string | null; constructor(hostname: string, address?: string); } /** Raised for a target that looks like an IP literal but does not parse as one. */ export declare class MalformedHostError extends Error { readonly hostname: string; constructor(hostname: string); } /** Raised when the resolver fails or returns no address for a name. */ export declare class DnsResolutionError extends Error { constructor(hostname: string, cause: unknown); } /** * Resolve the complete answer set before selecting an address. Selecting a * public answer while silently ignoring a private sibling would let resolver * ordering determine the result and leave a rebinding path open. */ export type ResolveAllAddresses = (hostname: string) => Promise>; /** The address axis of an egress decision; see the module header. */ export interface EgressAddressOptions { /** Which reserved space may be dialled. Default: `block-private`. */ addressPolicy?: EgressAddressPolicy; /** * Exact hostnames or IP literals lowered to the `allow-private` floor — an * operator naming its own database, or a run whose allowlist names * `localhost`. Never below the floor: metadata stays refused for them too. */ exemptHosts?: readonly string[]; /** The resolver; the system one by default. Tests stage answers here. */ lookup?: ResolveAllAddresses; } interface ResolvedEgressOptions { addressPolicy: EgressAddressPolicy; exemptHosts: ReadonlySet; lookup: ResolveAllAddresses; } declare function resolveEgressOptions(options: EgressAddressOptions): ResolvedEgressOptions; /** * Resolve `rawHostname` to the addresses a caller may dial under `options`. * * An IP literal is validated and returned in its canonical form; a name is * resolved once through `lookup` and the WHOLE answer set must pass the * policy. Throws {@link MalformedHostError}, {@link PrivateAddressError} or * {@link DnsResolutionError}; callers map those to their own protocol. */ export declare function resolveEgressAddresses(rawHostname: string, options?: EgressAddressOptions): Promise; /** * Parse an operator-supplied exemption list: comma-separated EXACT hosts * (`LOBU_DB_EGRESS_ALLOW_HOSTS`). Deployment config only — it rides the * gateway-authoritative config path and is never settable from tenant or * connection config, so a tenant cannot widen its own boundary. An entry * matches a host exactly (IPv6 without brackets); no wildcards or CIDRs, so * approving one tailnet host never approves `100.64.0.0/10`. Shapes that can * never match are rejected here rather than left silently inactive; `source` * names the setting in that error. */ export declare function parseExemptHosts(value: unknown, source: string): string[]; declare function createGuardedLookup(egress: ResolvedEgressOptions): LookupFunction; type RuntimeConnector = ReturnType; declare function createGuardedConnector(egress: ResolvedEgressOptions, connect?: RuntimeConnector): RuntimeConnector; /** * An undici dispatcher whose every new connection resolves through the guarded * lookup and re-checks literal targets at connect time; a reused socket is * already connected to a previously validated address. One per address * policy: the gateway shares the default (block-private, no exemptions) and * the isolate lane builds one per executor carrying that run's exact allowlist * entries. Nominal on purpose — `fetchPublicUrl` accepts only a dispatcher * built here, never an arbitrary undici Agent. */ export declare class EgressDispatcher extends Agent { private readonly egress; constructor(options?: EgressAddressOptions); /** Throw before a request leaves if `hostname` can never be dialled under this dispatcher's policy. */ assertReachable(hostname: string): void; } /** Narrow transport seams exposed only for dependency-free regression tests. */ export declare const __egressTransportTestOnly: { createGuardedConnector: typeof createGuardedConnector; createGuardedLookup: typeof createGuardedLookup; resolveEgressOptions: typeof resolveEgressOptions; }; /** * Fetch an untrusted URL without a DNS check-then-fetch race. Every new * connection resolves through the dispatcher's guarded lookup, and undici uses * the same dispatcher for redirect hops, including the literal-host check in * the connector above. The default dispatcher is the gateway's block-private * one; the isolate lane passes its own. */ export declare function fetchPublicUrl(input: string | URL, init?: RequestInit, dispatcher?: EgressDispatcher): Promise; /** * Parse a credential-bearing destination and require transport encryption. * This is intentionally narrower than {@link fetchPublicUrl}: unauthenticated * public HTTP remains supported, but authorization codes, client secrets, * refresh tokens, and bearer tokens must never be sent over plaintext HTTP. */ export declare function parseCredentialedHttpsUrl(input: string | URL): URL; /** Apply the HTTPS credential policy before entering the public URL transport. */ export declare function fetchCredentialedPublicUrl(input: string | URL, init?: RequestInit): Promise; /** * Resolve a URL's hostname and check whether it points to an internal/reserved * network. Returns true (blocked) when URL parsing fails. */ export declare function isInternalUrl(url: string): Promise; export {}; //# sourceMappingURL=transport.d.ts.map