Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/network.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/sdk-network.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
});
Expand All @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions host/bin/gondolin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
);
Expand Down Expand Up @@ -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)",
);
Expand Down
87 changes: 84 additions & 3 deletions host/src/qemu/tcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -28,6 +33,10 @@ export type QemuTcpInternals = {
byHostPort: Map<string, TcpMappedTarget>;
/** host-wide mapping lookup */
byHost: Map<string, TcpMappedTarget>;
/** 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 = {
Expand Down Expand Up @@ -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;
Expand All @@ -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<string, TcpMappedTarget>();
const byHost = new Map<string, TcpMappedTarget>();
const wildcardHostPort: TcpWildcardMappedTarget[] = [];
const wildcardHost: TcpWildcardMappedTarget[] = [];
const wildcardKeys = new Set<string>();
const rules: TcpMappedTarget[] = [];

const hosts = options?.hosts ?? {};
Expand All @@ -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)) {
Expand All @@ -177,6 +242,8 @@ export function createQemuTcpInternals(options?: TcpOptions): QemuTcpInternals {
rules,
byHostPort,
byHost,
wildcardHostPort: sortWildcardTargets(wildcardHostPort),
wildcardHost: sortWildcardTargets(wildcardHost),
};
}

Expand Down Expand Up @@ -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
);
}
Loading
Loading