This commit is contained in:
Kelvin 2026-01-30 13:37:01 -03:00 committed by GitHub
commit d223cb69f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 109 additions and 26 deletions

View File

@ -40,7 +40,7 @@ Notes:
### Options
- `--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.
- `--token <token>`: token override (also sets `OPENCLAW_GATEWAY_TOKEN` for the process).
- `--password <password>`: password override (also sets `OPENCLAW_GATEWAY_PASSWORD` for the process).

View File

@ -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/WiFi and Tailscale)
- `tailnet`: bind only to the machines 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).

View File

@ -181,11 +181,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;
}
@ -314,7 +317,7 @@ export function addGatewayRunCommand(cmd: Command): Command {
.option("--port <port>", "Port for the gateway WebSocket")
.option(
"--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(
"--token <token>",

View File

@ -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") {

View File

@ -20,7 +20,14 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
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;

View File

@ -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);

View File

@ -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,

View File

@ -35,7 +35,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;

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 = {
/** Enable TLS for the gateway server. */
@ -221,6 +221,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).
*/

View File

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

View File

@ -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<string> {
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";
}

View File

@ -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<string, unknown> | undefined,
gatewayControlUi: undefined as Record<string, unknown> | 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

@ -96,11 +96,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);
}

View File

@ -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") {

View File

@ -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) => {

View File

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