diff --git a/docs/cli.md b/docs/cli.md index 8b3577f..b5fc824 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -121,8 +121,12 @@ without exposing them inside the VM (for HTTP/TLS-mediated flows). - `--tcp-map GUEST_HOST[:PORT]=UPSTREAM_HOST:PORT` - Add an explicit mapped TCP rule (repeatable) - `GUEST_HOST` (or `GUEST_HOST:PORT`) is matched using synthetic DNS host attribution + - `GUEST_HOST` may use a leading subdomain wildcard such as `*.example.com` - Traffic is forwarded as raw TCP to the explicit `UPSTREAM_HOST:PORT` + - Wildcards are only supported on the guest key side; upstream targets stay exact + - Exact mappings win over wildcard mappings - If both `GUEST_HOST` and `GUEST_HOST:PORT` are configured, the port-specific mapping wins + - If multiple wildcard mappings match, the longest matching suffix wins Examples: @@ -154,6 +158,9 @@ gondolin bash --tcp-map pg.internal=127.0.0.1:5432 Mapped TCP egress is an explicit exception path for non-HTTP protocols. - Rules are added with `--tcp-map GUEST_HOST[:PORT]=UPSTREAM_HOST:PORT` +- `GUEST_HOST` may use a leading subdomain wildcard (`*.example.com[:PORT]`) + - `*.example.com` matches subdomains such as `api.example.com` + - `*.example.com` does not match the apex `example.com` - `--tcp-map` requires synthetic DNS with per-host mapping - the CLI auto-selects `--dns synthetic` and `--dns-synthetic-host-mapping per-host` when needed - Mapped TCP is raw forwarding to the explicit upstream target diff --git a/docs/network.md b/docs/network.md index 45af835..6763dfd 100644 --- a/docs/network.md +++ b/docs/network.md @@ -205,13 +205,15 @@ How it works: - The guest resolves `HOST` in synthetic DNS mode - In `syntheticHostMapping: "per-host"`, Gondolin can map destination synthetic IPs back to hostnames -- If a `tcp.hosts` rule matches (`HOST` or `HOST:PORT`), the flow is marked as mapped TCP +- If a `tcp.hosts` rule matches (`HOST`, `HOST:PORT`, or a leading subdomain wildcard such as `*.example.com:443`), the flow is marked as mapped TCP - The host opens a TCP socket to the configured upstream target and forwards bytes Important constraints: - Mapped TCP requires `dns.mode: "synthetic"` and `dns.syntheticHostMapping: "per-host"` - Mapping values must be explicit `UPSTREAM_HOST:UPSTREAM_PORT` +- Wildcards are only supported in mapping keys, and `*.example.com` does not match the apex `example.com` +- Exact mappings win over wildcard mappings; overlapping wildcards use the longest matching suffix - Mapped TCP is a raw tunnel to the configured target - no HTTP parsing/hook pipeline - no HTTP secret placeholder substitution diff --git a/docs/sdk-network.md b/docs/sdk-network.md index 378db3e..7fefc08 100644 --- a/docs/sdk-network.md +++ b/docs/sdk-network.md @@ -108,6 +108,7 @@ const vm = await VM.create({ hosts: { "foo.internal": "127.0.0.1:9999", "foo.internal:42": "192.168.0.1:443", + "*.gateway.example:443": "127.0.0.1:9443", }, }, }); @@ -117,8 +118,13 @@ Semantics: - Mapping key `HOST` matches all guest destination ports for that host - Mapping key `HOST:PORT` matches that specific destination port +- Mapping keys may use a leading subdomain wildcard (`*.example.com[:PORT]`) + - the wildcard matches non-apex subdomains only; `*.example.com` does not match `example.com` - Mapping value is always `UPSTREAM_HOST:UPSTREAM_PORT` +- Mapping values do not support wildcards +- Exact mappings win over wildcard mappings - If both `HOST` and `HOST:PORT` exist, the port-specific mapping wins +- If multiple wildcard mappings match, the longest matching suffix wins Safety model: diff --git a/docs/security.md b/docs/security.md index 9b687df..629b2a2 100644 --- a/docs/security.md +++ b/docs/security.md @@ -353,6 +353,8 @@ These are rules to not compromise the security guarantees of the system: 5. **Treat `tcp.hosts` as a reduced-security exception path** - Keep mappings narrow (`HOST:PORT` when possible) + - Prefer exact hosts over wildcard subdomain keys + - If a wildcard key is necessary, use the narrowest suffix available; `*.example.com` does not match `example.com` - Prefer local/dev-only upstream targets - Use least-privilege, short-lived credentials on mapped services - Remember mapped TCP does not use HTTP hooks or header secret substitution diff --git a/host/bin/gondolin.ts b/host/bin/gondolin.ts index 3a8244f..9025849 100644 --- a/host/bin/gondolin.ts +++ b/host/bin/gondolin.ts @@ -294,6 +294,9 @@ function bashUsage() { console.log( " Format: GUEST_HOST[:PORT]=UPSTREAM_HOST:PORT", ); + console.log( + " GUEST_HOST may be a subdomain wildcard like *.example.com", + ); console.log( " --ssh-allow-host HOST[:PORT] Allow outbound SSH to host (repeatable; default port: 22)", ); @@ -438,6 +441,9 @@ function execUsage() { console.log( " Format: GUEST_HOST[:PORT]=UPSTREAM_HOST:PORT", ); + console.log( + " GUEST_HOST may be a subdomain wildcard like *.example.com", + ); console.log( " --ssh-allow-host HOST[:PORT] Allow outbound SSH to host (repeatable; default port: 22)", ); diff --git a/host/src/qemu/tcp.ts b/host/src/qemu/tcp.ts index 671d7e6..57cfab0 100644 --- a/host/src/qemu/tcp.ts +++ b/host/src/qemu/tcp.ts @@ -18,6 +18,11 @@ export type TcpMappedTarget = { connectPort: number; }; +type TcpWildcardMappedTarget = TcpMappedTarget & { + /** normalized suffix matched by a leading-label wildcard */ + wildcardSuffix: string; +}; + /** @internal */ export type QemuTcpInternals = { /** whether mapped tcp egress is enabled */ @@ -28,6 +33,10 @@ export type QemuTcpInternals = { byHostPort: Map; /** host-wide mapping lookup */ byHost: Map; + /** wildcard host:port mappings sorted by most-specific suffix first */ + wildcardHostPort: TcpWildcardMappedTarget[]; + /** wildcard host-wide mappings sorted by most-specific suffix first */ + wildcardHost: TcpWildcardMappedTarget[]; }; type ParsedHostPort = { @@ -117,8 +126,10 @@ function parseMappingKey(raw: string): ParsedHostPort { context: "tcp.hosts key", }); - if (parsed.host.includes("*")) { - throw new Error(`tcp.hosts key does not support wildcard '*': ${raw}`); + if (parsed.host.includes("*") && !isValidWildcardHost(parsed.host)) { + throw new Error( + `tcp.hosts key wildcard must be a leading subdomain pattern like '*.example.com': ${raw}`, + ); } return parsed; @@ -137,10 +148,43 @@ function parseMappingTarget(raw: string): ParsedHostPort { return parsed; } +function isValidWildcardHost(host: string): boolean { + if (!host.startsWith("*.")) return false; + + const suffix = host.slice(2); + if (!suffix || suffix.includes("*")) return false; + if (net.isIP(suffix)) return false; + + const labels = suffix.split("."); + return labels.length >= 2 && labels.every((label) => label.length > 0); +} + +function wildcardSuffix(host: string): string | null { + return isValidWildcardHost(host) ? host.slice(2) : null; +} + +function wildcardMatchesHost(hostname: string, suffix: string): boolean { + return ( + hostname.length > suffix.length + 1 && + hostname.endsWith(`.${suffix}`) + ); +} + +function sortWildcardTargets( + targets: TcpWildcardMappedTarget[], +): TcpWildcardMappedTarget[] { + return targets.sort( + (a, b) => b.wildcardSuffix.length - a.wildcardSuffix.length, + ); +} + /** @internal */ export function createQemuTcpInternals(options?: TcpOptions): QemuTcpInternals { const byHostPort = new Map(); const byHost = new Map(); + const wildcardHostPort: TcpWildcardMappedTarget[] = []; + const wildcardHost: TcpWildcardMappedTarget[] = []; + const wildcardKeys = new Set(); const rules: TcpMappedTarget[] = []; const hosts = options?.hosts ?? {}; @@ -156,6 +200,27 @@ export function createQemuTcpInternals(options?: TcpOptions): QemuTcpInternals { connectPort: target.port!, }; + const suffix = wildcardSuffix(match.host); + if (suffix) { + const key = `${match.host}${match.port === null ? "" : `:${match.port}`}`; + if (wildcardKeys.has(key)) { + throw new Error(`duplicate tcp.hosts mapping for ${key}`); + } + wildcardKeys.add(key); + + const wildcardRule: TcpWildcardMappedTarget = { + ...rule, + wildcardSuffix: suffix, + }; + if (match.port !== null) { + wildcardHostPort.push(wildcardRule); + } else { + wildcardHost.push(wildcardRule); + } + rules.push(rule); + continue; + } + if (match.port !== null) { const key = `${match.host}:${match.port}`; if (byHostPort.has(key)) { @@ -177,6 +242,8 @@ export function createQemuTcpInternals(options?: TcpOptions): QemuTcpInternals { rules, byHostPort, byHost, + wildcardHostPort: sortWildcardTargets(wildcardHostPort), + wildcardHost: sortWildcardTargets(wildcardHost), }; } @@ -214,5 +281,19 @@ export function resolveMappedTcpTarget( const exact = tcp.byHostPort.get(`${normalizedHost}:${dstPort}`); if (exact) return exact; - return tcp.byHost.get(normalizedHost) ?? null; + const hostOnly = tcp.byHost.get(normalizedHost); + if (hostOnly) return hostOnly; + + const wildcardExact = tcp.wildcardHostPort.find( + (target) => + target.port === dstPort && + wildcardMatchesHost(normalizedHost, target.wildcardSuffix), + ); + if (wildcardExact) return wildcardExact; + + return ( + tcp.wildcardHost.find((target) => + wildcardMatchesHost(normalizedHost, target.wildcardSuffix), + ) ?? null + ); } diff --git a/host/test/qemu-net.test.ts b/host/test/qemu-net.test.ts index 1f1078c..05234a5 100644 --- a/host/test/qemu-net.test.ts +++ b/host/test/qemu-net.test.ts @@ -3789,6 +3789,231 @@ test("qemu-net: tcp host mapping resolves host and host:port rules", () => { assert.equal(hostOnlySession.connectPort, 9999); }); +test("qemu-net: tcp host mapping resolves wildcard host rules", () => { + const backend = makeBackend({ + dns: { mode: "synthetic", syntheticHostMapping: "per-host" }, + tcp: { + hosts: { + "*.discord.gg:443": "127.0.0.1:9443", + }, + }, + }); + + const responses: any[] = []; + (backend as any).stack = { + handleUdpResponse: (msg: any) => responses.push(msg), + handleTcpConnected: () => {}, + }; + + (backend as any).handleUdpSend({ + key: "udp-tcp-map-wildcard", + srcIP: "192.168.127.3", + srcPort: 41126, + dstIP: "192.168.127.1", + dstPort: 53, + payload: buildQueryA("gateway-us-east1-b.discord.gg", 0x4013), + }); + + const response = responses[0].data as Buffer; + const parts = [...response.subarray(response.length - 4)]; + const gatewayIp = `${parts[0]}.${parts[1]}.${parts[2]}.${parts[3]}`; + + const mapped = (backend as any).handleTcpConnect({ + key: "tcp-map-wildcard", + srcIP: "192.168.127.3", + srcPort: 50022, + dstIP: gatewayIp, + dstPort: 443, + }); + const session = (backend as any).tcpSessions.get("tcp-map-wildcard"); + assert.equal(mapped.allowRawTcp, true); + assert.equal(session.connectIP, "127.0.0.1"); + assert.equal(session.connectPort, 9443); +}); + +test("qemu-net: tcp host wildcard does not match apex host", () => { + const backend = makeBackend({ + dns: { mode: "synthetic", syntheticHostMapping: "per-host" }, + tcp: { + hosts: { + "*.discord.gg:443": "127.0.0.1:9443", + }, + }, + }); + + const responses: any[] = []; + (backend as any).stack = { + handleUdpResponse: (msg: any) => responses.push(msg), + handleTcpConnected: () => {}, + }; + + (backend as any).handleUdpSend({ + key: "udp-tcp-map-wildcard-apex", + srcIP: "192.168.127.3", + srcPort: 41127, + dstIP: "192.168.127.1", + dstPort: 53, + payload: buildQueryA("discord.gg", 0x4014), + }); + + const response = responses[0].data as Buffer; + const parts = [...response.subarray(response.length - 4)]; + const apexIp = `${parts[0]}.${parts[1]}.${parts[2]}.${parts[3]}`; + + const mapped = (backend as any).handleTcpConnect({ + key: "tcp-map-wildcard-apex", + srcIP: "192.168.127.3", + srcPort: 50023, + dstIP: apexIp, + dstPort: 443, + }); + assert.equal(mapped.allowRawTcp, false); +}); + +test("qemu-net: tcp host mapping exact rules override wildcard rules", () => { + const backend = makeBackend({ + dns: { mode: "synthetic", syntheticHostMapping: "per-host" }, + tcp: { + hosts: { + "*.discord.gg:443": "127.0.0.1:9443", + "gateway.discord.gg": "127.0.0.1:8443", + }, + }, + }); + + const responses: any[] = []; + (backend as any).stack = { + handleUdpResponse: (msg: any) => responses.push(msg), + handleTcpConnected: () => {}, + }; + + (backend as any).handleUdpSend({ + key: "udp-tcp-map-exact-over-wildcard", + srcIP: "192.168.127.3", + srcPort: 41128, + dstIP: "192.168.127.1", + dstPort: 53, + payload: buildQueryA("gateway.discord.gg", 0x4015), + }); + + const response = responses[0].data as Buffer; + const parts = [...response.subarray(response.length - 4)]; + const gatewayIp = `${parts[0]}.${parts[1]}.${parts[2]}.${parts[3]}`; + + const mapped = (backend as any).handleTcpConnect({ + key: "tcp-map-exact-over-wildcard", + srcIP: "192.168.127.3", + srcPort: 50024, + dstIP: gatewayIp, + dstPort: 443, + }); + const session = (backend as any).tcpSessions.get( + "tcp-map-exact-over-wildcard", + ); + assert.equal(mapped.allowRawTcp, true); + assert.equal(session.connectIP, "127.0.0.1"); + assert.equal(session.connectPort, 8443); +}); + +test("qemu-net: tcp host mapping prefers most-specific wildcard suffix", () => { + const backend = makeBackend({ + dns: { mode: "synthetic", syntheticHostMapping: "per-host" }, + tcp: { + hosts: { + "*.discord.gg:443": "127.0.0.1:9443", + "*.b.discord.gg:443": "127.0.0.1:7443", + }, + }, + }); + + const responses: any[] = []; + (backend as any).stack = { + handleUdpResponse: (msg: any) => responses.push(msg), + handleTcpConnected: () => {}, + }; + + (backend as any).handleUdpSend({ + key: "udp-tcp-map-specific-wildcard", + srcIP: "192.168.127.3", + srcPort: 41129, + dstIP: "192.168.127.1", + dstPort: 53, + payload: buildQueryA("gateway.b.discord.gg", 0x4016), + }); + + const response = responses[0].data as Buffer; + const parts = [...response.subarray(response.length - 4)]; + const gatewayIp = `${parts[0]}.${parts[1]}.${parts[2]}.${parts[3]}`; + + const mapped = (backend as any).handleTcpConnect({ + key: "tcp-map-specific-wildcard", + srcIP: "192.168.127.3", + srcPort: 50025, + dstIP: gatewayIp, + dstPort: 443, + }); + const session = (backend as any).tcpSessions.get( + "tcp-map-specific-wildcard", + ); + assert.equal(mapped.allowRawTcp, true); + assert.equal(session.connectIP, "127.0.0.1"); + assert.equal(session.connectPort, 7443); +}); + +test("qemu-net: tcp host mapping rejects unsupported wildcard forms", () => { + assert.throws( + () => + makeBackend({ + dns: { mode: "synthetic", syntheticHostMapping: "per-host" }, + tcp: { + hosts: { + "*:443": "127.0.0.1:9443", + }, + }, + }), + /tcp\.hosts key wildcard must be a leading subdomain pattern/i, + ); + + assert.throws( + () => + makeBackend({ + dns: { mode: "synthetic", syntheticHostMapping: "per-host" }, + tcp: { + hosts: { + "*..discord.gg:443": "127.0.0.1:9443", + }, + }, + }), + /tcp\.hosts key wildcard must be a leading subdomain pattern/i, + ); + + assert.throws( + () => + makeBackend({ + dns: { mode: "synthetic", syntheticHostMapping: "per-host" }, + tcp: { + hosts: { + "api.*.discord.gg:443": "127.0.0.1:9443", + }, + }, + }), + /tcp\.hosts key wildcard must be a leading subdomain pattern/i, + ); + + assert.throws( + () => + makeBackend({ + dns: { mode: "synthetic", syntheticHostMapping: "per-host" }, + tcp: { + hosts: { + "*.discord.gg:443": "*.example.com:9443", + }, + }, + }), + /tcp\.hosts value does not support wildcard/i, + ); +}); + test("qemu-net: ssh egress requires synthetic dns mode", () => { assert.throws( () =>