From ebc11b6df11fd415358ced2a7b1bf41c86662fff Mon Sep 17 00:00:00 2001 From: Kelvin Andrade Date: Thu, 29 Jan 2026 01:05:13 +0000 Subject: [PATCH] feat(gateway): add wireguard bind mode for WireGuard mesh VPNs --- docs/cli/gateway.md | 2 +- docs/gateway/configuration.md | 7 +-- src/cli/gateway-cli/run.ts | 9 ++-- src/commands/configure.gateway.ts | 7 ++- src/commands/doctor-security.ts | 9 +++- src/commands/onboard-helpers.ts | 5 ++- src/commands/onboard-non-interactive/local.ts | 2 +- src/commands/onboard-types.ts | 2 +- src/config/types.gateway.ts | 3 +- src/config/zod-schema.ts | 1 + src/gateway/net.ts | 21 ++++++--- src/gateway/test-helpers.mocks.ts | 2 +- src/infra/wireguard.ts | 44 +++++++++++++++++++ src/macos/gateway-daemon.ts | 7 ++- src/wizard/onboarding.gateway-config.ts | 5 ++- src/wizard/onboarding.ts | 5 ++- src/wizard/onboarding.types.ts | 4 +- 17 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 src/infra/wireguard.ts diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index beeed7fcc..fc9fd0a9b 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -40,7 +40,7 @@ Notes: ### Options - `--port `: WebSocket port (default comes from config/env; usually `18789`). -- `--bind `: listener bind mode. +- `--bind `: listener bind mode. - `--auth `: auth mode override. - `--token `: token override (also sets `CLAWDBOT_GATEWAY_TOKEN` for the process). - `--password `: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 1d270974d..4eb48ec1c 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -3142,10 +3142,11 @@ Defaults: - bind: `lan` (binds to `0.0.0.0`) Bind modes: -- `lan`: `0.0.0.0` (reachable on any interface, including LAN/Wi‑Fi and Tailscale) -- `tailnet`: bind only to the machine’s Tailscale IP (recommended for Vienna ⇄ London) +- `lan`: `0.0.0.0` (reachable on any interface, including LAN/Wi-Fi and overlay networks) +- `tailnet`: bind only to the machine's Tailscale IP (100.64.0.0/10 range) +- `wireguard`: bind only to the machine's WireGuard mesh IP (wg0/wt0 interface - Netbird, Headscale, etc.) - `loopback`: `127.0.0.1` (local only) -- `auto`: prefer tailnet IP if present, else `lan` +- `auto`: prefer loopback if available, else `lan` TLS: - `bridge.tls.enabled`: enable TLS for bridge connections (TLS-only when enabled). diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index cb26aa98d..7e3d463ec 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -179,11 +179,14 @@ async function runGatewayCommand(opts: GatewayRunOpts) { bindRaw === "lan" || bindRaw === "auto" || bindRaw === "custom" || - bindRaw === "tailnet" + bindRaw === "tailnet" || + bindRaw === "wireguard" ? bindRaw : null; if (!bind) { - defaultRuntime.error('Invalid --bind (use "loopback", "lan", "tailnet", "auto", or "custom")'); + defaultRuntime.error( + 'Invalid --bind (use "loopback", "lan", "tailnet", "wireguard", "auto", or "custom")', + ); defaultRuntime.exit(1); return; } @@ -312,7 +315,7 @@ export function addGatewayRunCommand(cmd: Command): Command { .option("--port ", "Port for the gateway WebSocket") .option( "--bind ", - 'Bind mode ("loopback"|"lan"|"tailnet"|"auto"|"custom"). Defaults to config gateway.bind (or loopback).', + 'Bind mode ("loopback"|"lan"|"tailnet"|"wireguard"|"auto"|"custom"). Defaults to config gateway.bind (or loopback).', ) .option( "--token ", diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index 7ddfce3b6..027a57ace 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -41,6 +41,11 @@ export async function promptGatewayConfig( label: "Tailnet (Tailscale IP)", hint: "Bind to your Tailscale IP only (100.x.x.x)", }, + { + value: "wireguard", + label: "WireGuard (WireGuard IP)", + hint: "Bind to your WireGuard mesh IP (wg0/wt0 interface)", + }, { value: "auto", label: "Auto (Loopback → LAN)", @@ -59,7 +64,7 @@ export async function promptGatewayConfig( ], }), runtime, - ) as "auto" | "lan" | "loopback" | "custom" | "tailnet"; + ) as "auto" | "lan" | "loopback" | "custom" | "tailnet" | "wireguard"; let customBindHost: string | undefined; if (bind === "custom") { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 856b18bfb..a6a97c5f4 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -20,7 +20,14 @@ export async function noteSecurityWarnings(cfg: MoltbotConfig) { const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string; const customBindHost = cfg.gateway?.customBindHost?.trim(); - const bindModes: GatewayBindMode[] = ["auto", "lan", "loopback", "custom", "tailnet"]; + const bindModes: GatewayBindMode[] = [ + "auto", + "lan", + "loopback", + "custom", + "tailnet", + "wireguard", + ]; const bindMode = bindModes.includes(gatewayBind as GatewayBindMode) ? (gatewayBind as GatewayBindMode) : undefined; diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 376555a39..e42aac497 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -12,6 +12,7 @@ import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { isSafeExecutableValue } from "../infra/exec-safety.js"; +import { pickPrimaryWireguardIPv4 } from "../infra/wireguard.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { isWSL } from "../infra/wsl.js"; import { runCommandWithTimeout } from "../process/exec.js"; @@ -393,7 +394,7 @@ export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR; export function resolveControlUiLinks(params: { port: number; - bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet"; + bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet" | "wireguard"; customBindHost?: string; basePath?: string; }): { httpUrl: string; wsUrl: string } { @@ -401,11 +402,13 @@ export function resolveControlUiLinks(params: { const bind = params.bind ?? "loopback"; const customBindHost = params.customBindHost?.trim(); const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const wireguardIPv4 = pickPrimaryWireguardIPv4(); const host = (() => { if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) { return customBindHost; } if (bind === "tailnet" && tailnetIPv4) return tailnetIPv4 ?? "127.0.0.1"; + if (bind === "wireguard" && wireguardIPv4) return wireguardIPv4 ?? "127.0.0.1"; return "127.0.0.1"; })(); const basePath = normalizeControlUiBasePath(params.basePath); diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index d642cc1da..ba226dbc0 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -92,7 +92,7 @@ export async function runNonInteractiveOnboardingLocal(params: { const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME; if (!opts.skipHealth) { const links = resolveControlUiLinks({ - bind: gatewayResult.bind as "auto" | "lan" | "loopback" | "custom" | "tailnet", + bind: gatewayResult.bind as "auto" | "lan" | "loopback" | "custom" | "tailnet" | "wireguard", port: gatewayResult.port, customBindHost: nextConfig.gateway?.customBindHost, basePath: undefined, diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index aa1d9afe0..f079f9c5f 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -34,7 +34,7 @@ export type AuthChoice = | "skip"; export type GatewayAuthChoice = "token" | "password"; export type ResetScope = "config" | "config+creds+sessions" | "full"; -export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet"; +export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet" | "wireguard"; export type TailscaleMode = "off" | "serve" | "funnel"; export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type ChannelChoice = ChannelId; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index a0d562f7b..198f81dea 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -1,4 +1,4 @@ -export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom" | "tailnet"; +export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom" | "tailnet" | "wireguard"; export type GatewayTlsConfig = { /** Enable TLS for the gateway server. */ @@ -219,6 +219,7 @@ export type GatewayConfig = { * - lan: 0.0.0.0 (all interfaces, no fallback) * - loopback: 127.0.0.1 (local-only) * - tailnet: Tailnet IPv4 if available (100.64.0.0/10), else loopback + * - wireguard: WireGuard IPv4 if available (wg0/wt0 interface), else loopback * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost) * Default: loopback (127.0.0.1). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ce4115517..c9fb8ed43 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -311,6 +311,7 @@ export const MoltbotSchema = z z.literal("loopback"), z.literal("custom"), z.literal("tailnet"), + z.literal("wireguard"), ]) .optional(), controlUi: z diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 6702e0e8b..bfa0a5892 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -1,5 +1,6 @@ import net from "node:net"; +import { pickPrimaryWireguardIPv4 } from "../infra/wireguard.js"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; export function isLoopbackAddress(ip: string | undefined): boolean { @@ -74,6 +75,8 @@ export function isLocalGatewayAddress(ip: string | undefined): boolean { if (tailnetIPv4 && normalized === tailnetIPv4.toLowerCase()) return true; const tailnetIPv6 = pickPrimaryTailnetIPv6(); if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase()) return true; + const wireguardIPv4 = pickPrimaryWireguardIPv4(); + if (wireguardIPv4 && normalized === wireguardIPv4.toLowerCase()) return true; return false; } @@ -83,7 +86,8 @@ export function isLocalGatewayAddress(ip: string | undefined): boolean { * Modes: * - loopback: 127.0.0.1 (rarely fails, but handled gracefully) * - lan: always 0.0.0.0 (no fallback) - * - tailnet: Tailnet IPv4 if available, else loopback + * - tailnet: Tailnet IPv4 if available (100.64.0.0/10), else loopback + * - wireguard: WireGuard IPv4 if available (wg0/wt0 interface), else loopback * - auto: Loopback if available, else 0.0.0.0 * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable * @@ -94,17 +98,24 @@ export async function resolveGatewayBindHost( customHost?: string, ): Promise { const mode = bind ?? "loopback"; - + const localhost = "127.0.0.1"; if (mode === "loopback") { // 127.0.0.1 rarely fails, but handle gracefully - if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; + if (await canBindToHost(localhost)) return localhost; return "0.0.0.0"; // extreme fallback } if (mode === "tailnet") { const tailnetIP = pickPrimaryTailnetIPv4(); if (tailnetIP && (await canBindToHost(tailnetIP))) return tailnetIP; - if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; + if (await canBindToHost(localhost)) return localhost; + return "0.0.0.0"; + } + + if (mode === "wireguard") { + const wgIP = pickPrimaryWireguardIPv4(); + if (wgIP && (await canBindToHost(wgIP))) return wgIP; + if (await canBindToHost(localhost)) return localhost; return "0.0.0.0"; } @@ -122,7 +133,7 @@ export async function resolveGatewayBindHost( } if (mode === "auto") { - if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; + if (await canBindToHost(localhost)) return localhost; return "0.0.0.0"; } diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 298e52618..3566f3e1b 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -212,7 +212,7 @@ export const testState = { allowFrom: undefined as string[] | undefined, cronStorePath: undefined as string | undefined, cronEnabled: false as boolean | undefined, - gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined, + gatewayBind: undefined as "auto" | "lan" | "tailnet" | "wireguard" | "loopback" | undefined, gatewayAuth: undefined as Record | undefined, gatewayControlUi: undefined as Record | undefined, hooksConfig: undefined as HooksConfig | undefined, diff --git a/src/infra/wireguard.ts b/src/infra/wireguard.ts new file mode 100644 index 000000000..dc1941b8f --- /dev/null +++ b/src/infra/wireguard.ts @@ -0,0 +1,44 @@ +import os from "node:os"; + +export type WireguardAddresses = { + ipv4: string[]; + ipv6: string[]; +}; + +/** + * Detect WireGuard mesh VPN interfaces (Netbird wt*, standard wg*, etc.). + * These typically use the 100.64.0.0/10 CGNAT range or private RFC1918 ranges. + */ +function isWireguardInterface(name: string): boolean { + // wt0, wt1 (Netbird); wg0, wg1 + return /^(wt|wg)\d+$/.test(name); +} + +export function listWireguardAddresses(): WireguardAddresses { + const ipv4: string[] = []; + const ipv6: string[] = []; + + const ifaces = os.networkInterfaces(); + for (const [ifaceName, entries] of Object.entries(ifaces)) { + if (!entries) continue; + if (!isWireguardInterface(ifaceName)) continue; + + for (const e of entries) { + if (!e || e.internal) continue; + const address = e.address?.trim(); + if (!address) continue; + if (e.family === "IPv4" || (e.family as unknown) === 4) { + ipv4.push(address); + } + if (e.family === "IPv6" || (e.family as unknown) === 6) { + ipv6.push(address); + } + } + } + + return { ipv4: [...new Set(ipv4)], ipv6: [...new Set(ipv6)] }; +} + +export function pickPrimaryWireguardIPv4(): string | undefined { + return listWireguardAddresses().ipv4[0]; +} diff --git a/src/macos/gateway-daemon.ts b/src/macos/gateway-daemon.ts index 686f705c2..b16293e68 100644 --- a/src/macos/gateway-daemon.ts +++ b/src/macos/gateway-daemon.ts @@ -94,11 +94,14 @@ async function main() { bindRaw === "lan" || bindRaw === "auto" || bindRaw === "custom" || - bindRaw === "tailnet" + bindRaw === "tailnet" || + bindRaw === "wireguard" ? bindRaw : null; if (!bind) { - defaultRuntime.error('Invalid --bind (use "loopback", "lan", "tailnet", "auto", or "custom")'); + defaultRuntime.error( + 'Invalid --bind (use "loopback", "lan", "tailnet", "wireguard", "auto", or "custom")', + ); process.exit(1); } diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts index 1a097f42e..50ca69848 100644 --- a/src/wizard/onboarding.gateway-config.ts +++ b/src/wizard/onboarding.gateway-config.ts @@ -54,11 +54,12 @@ export async function configureGatewayForOnboarding( { value: "loopback", label: "Loopback (127.0.0.1)" }, { value: "lan", label: "LAN (0.0.0.0)" }, { value: "tailnet", label: "Tailnet (Tailscale IP)" }, + { value: "wireguard", label: "WireGuard (WireGuard IP)" }, { value: "auto", label: "Auto (Loopback → LAN)" }, { value: "custom", label: "Custom IP" }, ], - })) as "loopback" | "lan" | "auto" | "custom" | "tailnet") - ) as "loopback" | "lan" | "auto" | "custom" | "tailnet"; + })) as "loopback" | "lan" | "auto" | "custom" | "tailnet" | "wireguard") + ) as "loopback" | "lan" | "auto" | "custom" | "tailnet" | "wireguard"; let customBindHost = quickstartGateway.customBindHost; if (bind === "custom") { diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 75543ca19..5272f7894 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -236,11 +236,14 @@ export async function runOnboardingWizard( })(); if (flow === "quickstart") { - const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => { + const formatBind = ( + value: "loopback" | "lan" | "auto" | "custom" | "tailnet" | "wireguard", + ) => { if (value === "loopback") return "Loopback (127.0.0.1)"; if (value === "lan") return "LAN"; if (value === "custom") return "Custom IP"; if (value === "tailnet") return "Tailnet (Tailscale IP)"; + if (value === "wireguard") return "WireGuard (WireGuard IP)"; return "Auto"; }; const formatAuth = (value: GatewayAuthChoice) => { diff --git a/src/wizard/onboarding.types.ts b/src/wizard/onboarding.types.ts index e49509d41..1e43d51e5 100644 --- a/src/wizard/onboarding.types.ts +++ b/src/wizard/onboarding.types.ts @@ -5,7 +5,7 @@ export type WizardFlow = "quickstart" | "advanced"; export type QuickstartGatewayDefaults = { hasExisting: boolean; port: number; - bind: "loopback" | "lan" | "auto" | "custom" | "tailnet"; + bind: "loopback" | "lan" | "auto" | "custom" | "tailnet" | "wireguard"; authMode: GatewayAuthChoice; tailscaleMode: "off" | "serve" | "funnel"; token?: string; @@ -16,7 +16,7 @@ export type QuickstartGatewayDefaults = { export type GatewayWizardSettings = { port: number; - bind: "loopback" | "lan" | "auto" | "custom" | "tailnet"; + bind: "loopback" | "lan" | "auto" | "custom" | "tailnet" | "wireguard"; customBindHost?: string; authMode: GatewayAuthChoice; gatewayToken?: string;