feat(gateway): add wireguard bind mode for WireGuard mesh VPNs

This commit is contained in:
Kelvin Andrade 2026-01-29 01:05:13 +00:00
parent fdcac0ccf4
commit ebc11b6df1
17 changed files with 109 additions and 26 deletions

View File

@ -40,7 +40,7 @@ Notes:
### Options ### Options
- `--port <port>`: WebSocket port (default comes from config/env; usually `18789`). - `--port <port>`: WebSocket port (default comes from config/env; usually `18789`).
- `--bind <loopback|lan|tailnet|auto|custom>`: listener bind mode. - `--bind <loopback|lan|tailnet|wireguard|auto|custom>`: listener bind mode.
- `--auth <token|password>`: auth mode override. - `--auth <token|password>`: auth mode override.
- `--token <token>`: token override (also sets `CLAWDBOT_GATEWAY_TOKEN` for the process). - `--token <token>`: token override (also sets `CLAWDBOT_GATEWAY_TOKEN` for the process).
- `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process). - `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).

View File

@ -3142,10 +3142,11 @@ Defaults:
- bind: `lan` (binds to `0.0.0.0`) - bind: `lan` (binds to `0.0.0.0`)
Bind modes: Bind modes:
- `lan`: `0.0.0.0` (reachable on any interface, including LAN/WiFi and Tailscale) - `lan`: `0.0.0.0` (reachable on any interface, including LAN/Wi-Fi and overlay networks)
- `tailnet`: bind only to the machines Tailscale IP (recommended for Vienna ⇄ London) - `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) - `loopback`: `127.0.0.1` (local only)
- `auto`: prefer tailnet IP if present, else `lan` - `auto`: prefer loopback if available, else `lan`
TLS: TLS:
- `bridge.tls.enabled`: enable TLS for bridge connections (TLS-only when enabled). - `bridge.tls.enabled`: enable TLS for bridge connections (TLS-only when enabled).

View File

@ -179,11 +179,14 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
bindRaw === "lan" || bindRaw === "lan" ||
bindRaw === "auto" || bindRaw === "auto" ||
bindRaw === "custom" || bindRaw === "custom" ||
bindRaw === "tailnet" bindRaw === "tailnet" ||
bindRaw === "wireguard"
? bindRaw ? bindRaw
: null; : null;
if (!bind) { 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); defaultRuntime.exit(1);
return; return;
} }
@ -312,7 +315,7 @@ export function addGatewayRunCommand(cmd: Command): Command {
.option("--port <port>", "Port for the gateway WebSocket") .option("--port <port>", "Port for the gateway WebSocket")
.option( .option(
"--bind <mode>", "--bind <mode>",
'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( .option(
"--token <token>", "--token <token>",

View File

@ -41,6 +41,11 @@ export async function promptGatewayConfig(
label: "Tailnet (Tailscale IP)", label: "Tailnet (Tailscale IP)",
hint: "Bind to your Tailscale IP only (100.x.x.x)", 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", value: "auto",
label: "Auto (Loopback → LAN)", label: "Auto (Loopback → LAN)",
@ -59,7 +64,7 @@ export async function promptGatewayConfig(
], ],
}), }),
runtime, runtime,
) as "auto" | "lan" | "loopback" | "custom" | "tailnet"; ) as "auto" | "lan" | "loopback" | "custom" | "tailnet" | "wireguard";
let customBindHost: string | undefined; let customBindHost: string | undefined;
if (bind === "custom") { if (bind === "custom") {

View File

@ -20,7 +20,14 @@ export async function noteSecurityWarnings(cfg: MoltbotConfig) {
const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string; const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string;
const customBindHost = cfg.gateway?.customBindHost?.trim(); 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) const bindMode = bindModes.includes(gatewayBind as GatewayBindMode)
? (gatewayBind as GatewayBindMode) ? (gatewayBind as GatewayBindMode)
: undefined; : undefined;

View File

@ -12,6 +12,7 @@ import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js"; import { callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { isSafeExecutableValue } from "../infra/exec-safety.js"; import { isSafeExecutableValue } from "../infra/exec-safety.js";
import { pickPrimaryWireguardIPv4 } from "../infra/wireguard.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import { isWSL } from "../infra/wsl.js"; import { isWSL } from "../infra/wsl.js";
import { runCommandWithTimeout } from "../process/exec.js"; import { runCommandWithTimeout } from "../process/exec.js";
@ -393,7 +394,7 @@ export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
export function resolveControlUiLinks(params: { export function resolveControlUiLinks(params: {
port: number; port: number;
bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet"; bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet" | "wireguard";
customBindHost?: string; customBindHost?: string;
basePath?: string; basePath?: string;
}): { httpUrl: string; wsUrl: string } { }): { httpUrl: string; wsUrl: string } {
@ -401,11 +402,13 @@ export function resolveControlUiLinks(params: {
const bind = params.bind ?? "loopback"; const bind = params.bind ?? "loopback";
const customBindHost = params.customBindHost?.trim(); const customBindHost = params.customBindHost?.trim();
const tailnetIPv4 = pickPrimaryTailnetIPv4(); const tailnetIPv4 = pickPrimaryTailnetIPv4();
const wireguardIPv4 = pickPrimaryWireguardIPv4();
const host = (() => { const host = (() => {
if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) { if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) {
return customBindHost; return customBindHost;
} }
if (bind === "tailnet" && tailnetIPv4) return tailnetIPv4 ?? "127.0.0.1"; 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"; return "127.0.0.1";
})(); })();
const basePath = normalizeControlUiBasePath(params.basePath); const basePath = normalizeControlUiBasePath(params.basePath);

View File

@ -92,7 +92,7 @@ export async function runNonInteractiveOnboardingLocal(params: {
const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME; const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
if (!opts.skipHealth) { if (!opts.skipHealth) {
const links = resolveControlUiLinks({ 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, port: gatewayResult.port,
customBindHost: nextConfig.gateway?.customBindHost, customBindHost: nextConfig.gateway?.customBindHost,
basePath: undefined, basePath: undefined,

View File

@ -34,7 +34,7 @@ export type AuthChoice =
| "skip"; | "skip";
export type GatewayAuthChoice = "token" | "password"; export type GatewayAuthChoice = "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full"; 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 TailscaleMode = "off" | "serve" | "funnel";
export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type NodeManagerChoice = "npm" | "pnpm" | "bun";
export type ChannelChoice = ChannelId; export type ChannelChoice = ChannelId;

View File

@ -1,4 +1,4 @@
export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom" | "tailnet"; export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom" | "tailnet" | "wireguard";
export type GatewayTlsConfig = { export type GatewayTlsConfig = {
/** Enable TLS for the gateway server. */ /** Enable TLS for the gateway server. */
@ -219,6 +219,7 @@ export type GatewayConfig = {
* - lan: 0.0.0.0 (all interfaces, no fallback) * - lan: 0.0.0.0 (all interfaces, no fallback)
* - loopback: 127.0.0.1 (local-only) * - loopback: 127.0.0.1 (local-only)
* - tailnet: Tailnet IPv4 if available (100.64.0.0/10), else loopback * - 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) * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost)
* Default: loopback (127.0.0.1). * Default: loopback (127.0.0.1).
*/ */

View File

@ -311,6 +311,7 @@ export const MoltbotSchema = z
z.literal("loopback"), z.literal("loopback"),
z.literal("custom"), z.literal("custom"),
z.literal("tailnet"), z.literal("tailnet"),
z.literal("wireguard"),
]) ])
.optional(), .optional(),
controlUi: z controlUi: z

View File

@ -1,5 +1,6 @@
import net from "node:net"; import net from "node:net";
import { pickPrimaryWireguardIPv4 } from "../infra/wireguard.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
export function isLoopbackAddress(ip: string | undefined): boolean { 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; if (tailnetIPv4 && normalized === tailnetIPv4.toLowerCase()) return true;
const tailnetIPv6 = pickPrimaryTailnetIPv6(); const tailnetIPv6 = pickPrimaryTailnetIPv6();
if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase()) return true; if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase()) return true;
const wireguardIPv4 = pickPrimaryWireguardIPv4();
if (wireguardIPv4 && normalized === wireguardIPv4.toLowerCase()) return true;
return false; return false;
} }
@ -83,7 +86,8 @@ export function isLocalGatewayAddress(ip: string | undefined): boolean {
* Modes: * Modes:
* - loopback: 127.0.0.1 (rarely fails, but handled gracefully) * - loopback: 127.0.0.1 (rarely fails, but handled gracefully)
* - lan: always 0.0.0.0 (no fallback) * - 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 * - auto: Loopback if available, else 0.0.0.0
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable
* *
@ -94,17 +98,24 @@ export async function resolveGatewayBindHost(
customHost?: string, customHost?: string,
): Promise<string> { ): Promise<string> {
const mode = bind ?? "loopback"; const mode = bind ?? "loopback";
const localhost = "127.0.0.1";
if (mode === "loopback") { if (mode === "loopback") {
// 127.0.0.1 rarely fails, but handle gracefully // 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 return "0.0.0.0"; // extreme fallback
} }
if (mode === "tailnet") { if (mode === "tailnet") {
const tailnetIP = pickPrimaryTailnetIPv4(); const tailnetIP = pickPrimaryTailnetIPv4();
if (tailnetIP && (await canBindToHost(tailnetIP))) return tailnetIP; 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"; return "0.0.0.0";
} }
@ -122,7 +133,7 @@ export async function resolveGatewayBindHost(
} }
if (mode === "auto") { 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"; return "0.0.0.0";
} }

View File

@ -212,7 +212,7 @@ export const testState = {
allowFrom: undefined as string[] | undefined, allowFrom: undefined as string[] | undefined,
cronStorePath: undefined as string | undefined, cronStorePath: undefined as string | undefined,
cronEnabled: false as boolean | 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<string, unknown> | undefined, gatewayAuth: undefined as Record<string, unknown> | undefined,
gatewayControlUi: undefined as Record<string, unknown> | undefined, gatewayControlUi: undefined as Record<string, unknown> | undefined,
hooksConfig: undefined as HooksConfig | undefined, hooksConfig: undefined as HooksConfig | undefined,

44
src/infra/wireguard.ts Normal file
View File

@ -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];
}

View File

@ -94,11 +94,14 @@ async function main() {
bindRaw === "lan" || bindRaw === "lan" ||
bindRaw === "auto" || bindRaw === "auto" ||
bindRaw === "custom" || bindRaw === "custom" ||
bindRaw === "tailnet" bindRaw === "tailnet" ||
bindRaw === "wireguard"
? bindRaw ? bindRaw
: null; : null;
if (!bind) { 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); process.exit(1);
} }

View File

@ -54,11 +54,12 @@ export async function configureGatewayForOnboarding(
{ value: "loopback", label: "Loopback (127.0.0.1)" }, { value: "loopback", label: "Loopback (127.0.0.1)" },
{ value: "lan", label: "LAN (0.0.0.0)" }, { value: "lan", label: "LAN (0.0.0.0)" },
{ value: "tailnet", label: "Tailnet (Tailscale IP)" }, { value: "tailnet", label: "Tailnet (Tailscale IP)" },
{ value: "wireguard", label: "WireGuard (WireGuard IP)" },
{ value: "auto", label: "Auto (Loopback → LAN)" }, { value: "auto", label: "Auto (Loopback → LAN)" },
{ value: "custom", label: "Custom IP" }, { value: "custom", label: "Custom IP" },
], ],
})) as "loopback" | "lan" | "auto" | "custom" | "tailnet") })) as "loopback" | "lan" | "auto" | "custom" | "tailnet" | "wireguard")
) as "loopback" | "lan" | "auto" | "custom" | "tailnet"; ) as "loopback" | "lan" | "auto" | "custom" | "tailnet" | "wireguard";
let customBindHost = quickstartGateway.customBindHost; let customBindHost = quickstartGateway.customBindHost;
if (bind === "custom") { if (bind === "custom") {

View File

@ -236,11 +236,14 @@ export async function runOnboardingWizard(
})(); })();
if (flow === "quickstart") { 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 === "loopback") return "Loopback (127.0.0.1)";
if (value === "lan") return "LAN"; if (value === "lan") return "LAN";
if (value === "custom") return "Custom IP"; if (value === "custom") return "Custom IP";
if (value === "tailnet") return "Tailnet (Tailscale IP)"; if (value === "tailnet") return "Tailnet (Tailscale IP)";
if (value === "wireguard") return "WireGuard (WireGuard IP)";
return "Auto"; return "Auto";
}; };
const formatAuth = (value: GatewayAuthChoice) => { const formatAuth = (value: GatewayAuthChoice) => {

View File

@ -5,7 +5,7 @@ export type WizardFlow = "quickstart" | "advanced";
export type QuickstartGatewayDefaults = { export type QuickstartGatewayDefaults = {
hasExisting: boolean; hasExisting: boolean;
port: number; port: number;
bind: "loopback" | "lan" | "auto" | "custom" | "tailnet"; bind: "loopback" | "lan" | "auto" | "custom" | "tailnet" | "wireguard";
authMode: GatewayAuthChoice; authMode: GatewayAuthChoice;
tailscaleMode: "off" | "serve" | "funnel"; tailscaleMode: "off" | "serve" | "funnel";
token?: string; token?: string;
@ -16,7 +16,7 @@ export type QuickstartGatewayDefaults = {
export type GatewayWizardSettings = { export type GatewayWizardSettings = {
port: number; port: number;
bind: "loopback" | "lan" | "auto" | "custom" | "tailnet"; bind: "loopback" | "lan" | "auto" | "custom" | "tailnet" | "wireguard";
customBindHost?: string; customBindHost?: string;
authMode: GatewayAuthChoice; authMode: GatewayAuthChoice;
gatewayToken?: string; gatewayToken?: string;