diff --git a/CHANGELOG.md b/CHANGELOG.md index acb3cb33a..2dd40fbd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,22 +5,19 @@ Docs: https://docs.clawd.bot ## 2026.1.21 ### Changes -- Caching: make tool-result pruning TTL-aware so cache reuse stays stable and token usage drops. - CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. - CLI: exec approvals mutations render tables instead of raw JSON. - Exec approvals: support wildcard agent allowlists (`*`) across all agents. - Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. -- Nodes: run always uses exec approvals + defaults, with raw shell mode and ask/security overrides. https://docs.clawd.bot/cli/nodes - CLI: flatten node service commands under `clawdbot node` and remove `service node` docs. - CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. - Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot. ### Fixes -- Embedded runner: drop obsolete pi-mono transcript workarounds now handled upstream. +- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380) - Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell. - Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. - macOS: exec approvals now respect wildcard agent allowlists (`*`). -- macOS: bundle and cache the model catalog instead of reading from a local pi-mono checkout. - macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-. - UI: remove the chat stop button and keep the composer aligned to the bottom edge. - Typing: start instant typing indicators at run start so DMs and mentions show immediately. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 1334bfe87..253130780 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/cli/index.md b/docs/cli/index.md index 710c87eca..1166d7508 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -310,7 +310,7 @@ Options: - `--minimax-api-key ` - `--opencode-zen-api-key ` - `--gateway-port ` -- `--gateway-bind ` +- `--gateway-bind ` - `--gateway-auth ` - `--gateway-token ` - `--gateway-password ` @@ -596,7 +596,7 @@ Run the WebSocket Gateway. Options: - `--port ` -- `--bind ` +- `--bind ` - `--token ` - `--auth ` - `--password ` diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 51d3c46fb..dbc157c8c 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2647,6 +2647,13 @@ Defaults: - bind: `loopback` - port: `18789` (single port for WS + HTTP) +Bind modes: +- `loopback`: `127.0.0.1` (local-only) +- `lan`: `0.0.0.0` (all interfaces) +- `tailnet`: Tailscale IPv4 address (100.64.0.0/10) +- `auto`: prefer loopback, fall back to LAN if loopback cannot bind +- `custom`: `gateway.customBindHost` (IPv4), fallback to LAN if unavailable + ```json5 { gateway: { @@ -2677,7 +2684,7 @@ Notes: - OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`. - OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`. - Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. -- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). +- Non-loopback binds (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). - The onboarding wizard generates a gateway token by default (even on loopback). - `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored. diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index 99ccb6326..800d9761e 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -112,7 +112,7 @@ Runbook: [macOS remote access](/platforms/mac/remote). Short version: **keep the Gateway loopback-only** unless you’re sure you need a bind. - **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure). -- **Non-loopback binds** (`lan`/`tailnet`/`auto`) must use auth tokens/passwords. +- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords. - `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth. - `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`. - **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`. diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 4afe2d380..cd9a80126 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -237,7 +237,7 @@ The Gateway multiplexes **WebSocket + HTTP** on a single port: Bind mode controls where the Gateway listens: - `gateway.bind: "loopback"` (default): only local clients can connect. -- Non-loopback binds (`"lan"`, `"tailnet"`, `"auto"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall. +- Non-loopback binds (`"lan"`, `"tailnet"`, `"custom"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall. Rules of thumb: - Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access). diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md index a7b9d6fbe..10477f90c 100644 --- a/docs/gateway/tailscale.md +++ b/docs/gateway/tailscale.md @@ -46,6 +46,25 @@ force `gateway.auth.mode: "password"`. Open: `https:///` (or your configured `gateway.controlUi.basePath`) +### Tailnet-only (bind to Tailnet IP) + +Use this when you want the Gateway to listen directly on the Tailnet IP (no Serve/Funnel). + +```json5 +{ + gateway: { + bind: "tailnet", + auth: { mode: "token", token: "your-token" } + } +} +``` + +Connect from another Tailnet device: +- Control UI: `http://:18789/` +- WebSocket: `ws://:18789` + +Note: loopback (`http://127.0.0.1:18789`) will **not** work in this mode. + ### Public internet (Funnel + shared password) ```json5 @@ -73,6 +92,8 @@ clawdbot gateway --tailscale funnel --auth password - `tailscale.mode: "funnel"` refuses to start unless auth mode is `password` to avoid public exposure. - Set `gateway.tailscale.resetOnExit` if you want Clawdbot to undo `tailscale serve` or `tailscale funnel` configuration on shutdown. +- `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel). +- `gateway.bind: "auto"` prefers loopback; use `tailnet` if you want Tailnet-only. - Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic uses the separate bridge port (default `18790`) and is **not** proxied by Serve. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 93f5d64bc..ad21290af 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -112,7 +112,7 @@ the Gateway likely refused to bind. - `gateway.mode` must be `local` for `clawdbot gateway` and the service. - If you set `gateway.mode=remote`, the **CLI defaults** to a remote URL. The service can still be running locally, but your CLI may be probing the wrong place. Use `clawdbot gateway status` to see the service’s resolved port + probe target (or pass `--url`). - `clawdbot gateway status` and `clawdbot doctor` surface the **last gateway error** from logs when the service looks running but the port is closed. -- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth: +- Non-loopback binds (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) require auth: `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). - `gateway.remote.token` is for remote CLI calls only; it does **not** enable local auth. - `gateway.token` is ignored; use `gateway.auth.token`. @@ -127,7 +127,7 @@ the Gateway likely refused to bind. - Fix: run `clawdbot doctor` to update it (or `clawdbot gateway install --force` for a full rewrite). **If `Last gateway error:` mentions “refusing to bind … without auth”** -- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`auto`) but left auth off. +- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but left auth off. - Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the service. **If `clawdbot gateway status` says `bind=tailnet` but no tailnet interface was found** diff --git a/docs/start/faq.md b/docs/start/faq.md index 92b6c645e..4b849b823 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -1415,7 +1415,7 @@ Fix: - Start Tailscale on that host (so it has a 100.x address), or - Switch to `gateway.bind: "loopback"` / `"lan"`. -Note: `tailnet` is legacy and is migrated to `auto` by Doctor. Prefer `gateway.bind: "auto"` when using Tailscale. +Note: `tailnet` is explicit. `auto` prefers loopback; use `gateway.bind: "tailnet"` when you want a tailnet-only bind. ### Can I run multiple Gateways on the same host? diff --git a/src/cli/daemon-cli/shared.ts b/src/cli/daemon-cli/shared.ts index 1e0d82dce..eba270610 100644 --- a/src/cli/daemon-cli/shared.ts +++ b/src/cli/daemon-cli/shared.ts @@ -46,7 +46,7 @@ export function pickProbeHostForBind( if (bindMode === "custom" && customBindHost?.trim()) { return customBindHost.trim(); } - if (bindMode === "auto") return tailnetIPv4 ?? "127.0.0.1"; + if (bindMode === "tailnet") return tailnetIPv4 ?? "127.0.0.1"; return "127.0.0.1"; } diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 1a2239cb9..c4b97aa53 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -172,7 +172,8 @@ export async function gatherDaemonStatus( | "auto" | "lan" | "loopback" - | "custom"; + | "custom" + | "tailnet"; const customBindHost = daemonCfg.gateway?.customBindHost; const bindHost = await resolveGatewayBindHost(bindMode, customBindHost); const tailnetIPv4 = pickPrimaryTailnetIPv4(); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index e75899fb4..1c2e8273c 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -174,11 +174,15 @@ async function runGatewayCommand(opts: GatewayRunOpts) { } const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback"; const bind = - bindRaw === "loopback" || bindRaw === "lan" || bindRaw === "auto" || bindRaw === "custom" + bindRaw === "loopback" || + bindRaw === "lan" || + bindRaw === "auto" || + bindRaw === "custom" || + bindRaw === "tailnet" ? bindRaw : null; if (!bind) { - defaultRuntime.error('Invalid --bind (use "loopback", "lan", "auto", or "custom")'); + defaultRuntime.error('Invalid --bind (use "loopback", "lan", "tailnet", "auto", or "custom")'); defaultRuntime.exit(1); return; } @@ -304,7 +308,7 @@ export function addGatewayRunCommand(cmd: Command): Command { .option("--port ", "Port for the gateway WebSocket") .option( "--bind ", - 'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).', + 'Bind mode ("loopback"|"lan"|"tailnet"|"auto"|"custom"). Defaults to config gateway.bind (or loopback).', ) .option( "--token ", diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 9b1ebed06..f2f75d3cc 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -76,7 +76,7 @@ export function registerOnboardCommand(program: Command) { .option("--synthetic-api-key ", "Synthetic API key") .option("--opencode-zen-api-key ", "OpenCode Zen API key") .option("--gateway-port ", "Gateway port") - .option("--gateway-bind ", "Gateway bind: loopback|lan|auto|custom") + .option("--gateway-bind ", "Gateway bind: loopback|tailnet|lan|auto|custom") .option("--gateway-auth ", "Gateway auth: off|token|password") .option("--gateway-token ", "Gateway token (token auth)") .option("--gateway-password ", "Gateway password (password auth)") diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index 120a7776a..ba44c3dcf 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -31,21 +31,26 @@ export async function promptGatewayConfig( await select({ message: "Gateway bind mode", options: [ + { + value: "loopback", + label: "Loopback (Local only)", + hint: "Bind to 127.0.0.1 - secure, local-only access", + }, + { + value: "tailnet", + label: "Tailnet (Tailscale IP)", + hint: "Bind to your Tailscale IP only (100.x.x.x)", + }, { value: "auto", - label: "Auto (Tailnet → LAN)", - hint: "Prefer Tailnet IP, fall back to all interfaces if unavailable", + label: "Auto (Loopback → LAN)", + hint: "Prefer loopback; fall back to all interfaces if unavailable", }, { value: "lan", label: "LAN (All interfaces)", hint: "Bind to 0.0.0.0 - accessible from anywhere on your network", }, - { - value: "loopback", - label: "Loopback (Local only)", - hint: "Bind to 127.0.0.1 - secure, local-only access", - }, { value: "custom", label: "Custom IP", @@ -54,7 +59,7 @@ export async function promptGatewayConfig( ], }), runtime, - ) as "auto" | "lan" | "loopback" | "custom"; + ) as "auto" | "lan" | "loopback" | "custom" | "tailnet"; let customBindHost: string | undefined; if (bind === "custom") { diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 20892d312..6a8dd21b8 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -71,4 +71,24 @@ describe("resolveControlUiLinks", () => { expect(links.httpUrl).toBe("http://127.0.0.1:18789/"); expect(links.wsUrl).toBe("ws://127.0.0.1:18789"); }); + + it("uses tailnet IP for tailnet bind", () => { + mocks.pickPrimaryTailnetIPv4.mockReturnValueOnce("100.64.0.9"); + const links = resolveControlUiLinks({ + port: 18789, + bind: "tailnet", + }); + expect(links.httpUrl).toBe("http://100.64.0.9:18789/"); + expect(links.wsUrl).toBe("ws://100.64.0.9:18789"); + }); + + it("keeps loopback for auto even when tailnet is present", () => { + mocks.pickPrimaryTailnetIPv4.mockReturnValueOnce("100.64.0.9"); + const links = resolveControlUiLinks({ + port: 18789, + bind: "auto", + }); + expect(links.httpUrl).toBe("http://127.0.0.1:18789/"); + expect(links.wsUrl).toBe("ws://127.0.0.1:18789"); + }); }); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index b609ee3b7..2e1ac091c 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -366,7 +366,7 @@ export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR; export function resolveControlUiLinks(params: { port: number; - bind?: "auto" | "lan" | "loopback" | "custom"; + bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet"; customBindHost?: string; basePath?: string; }): { httpUrl: string; wsUrl: string } { @@ -378,7 +378,7 @@ export function resolveControlUiLinks(params: { if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) { return customBindHost; } - if (bind === "auto" && tailnetIPv4) return tailnetIPv4 ?? "127.0.0.1"; + if (bind === "tailnet" && tailnetIPv4) return tailnetIPv4 ?? "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 8d4088e46..73c0d1582 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -91,7 +91,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", + bind: gatewayResult.bind as "auto" | "lan" | "loopback" | "custom" | "tailnet", port: gatewayResult.port, customBindHost: nextConfig.gateway?.customBindHost, basePath: undefined, diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 90bae1025..2a445053d 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -33,7 +33,7 @@ export type AuthChoice = | "skip"; export type GatewayAuthChoice = "off" | "token" | "password"; export type ResetScope = "config" | "config+creds+sessions" | "full"; -export type GatewayBind = "loopback" | "lan" | "auto" | "custom"; +export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet"; export type TailscaleMode = "off" | "serve" | "funnel"; export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type ChannelChoice = ChannelId; diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index 92f0f74cb..83b96e739 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -218,14 +218,14 @@ describe("legacy config detection", () => { expect(res.config?.gateway?.auth?.mode).toBe("token"); expect((res.config?.gateway as { token?: string })?.token).toBeUndefined(); }); - it("migrates gateway.bind from 'tailnet' to 'auto'", async () => { + it("keeps gateway.bind tailnet", async () => { vi.resetModules(); const { migrateLegacyConfig } = await import("./config.js"); const res = migrateLegacyConfig({ gateway: { bind: "tailnet" as const }, }); - expect(res.changes).toContain("Migrated gateway.bind from 'tailnet' to 'auto'."); - expect(res.config?.gateway?.bind).toBe("auto"); + expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'."); + expect(res.config?.gateway?.bind).toBe("tailnet"); }); it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => { vi.resetModules(); diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts index 111053fc0..fc34b1768 100644 --- a/src/config/legacy.migrations.part-3.ts +++ b/src/config/legacy.migrations.part-3.ts @@ -143,21 +143,4 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ delete raw.identity; }, }, - { - id: "bind-tailnet->auto", - describe: "Remap gateway bind 'tailnet' to 'auto'", - apply: (raw, changes) => { - const migrateBind = (obj: Record | null | undefined, key: string) => { - if (!obj) return; - const bind = obj.bind; - if (bind === "tailnet") { - obj.bind = "auto"; - changes.push(`Migrated ${key}.bind from 'tailnet' to 'auto'.`); - } - }; - - const gateway = getRecord(raw.gateway); - migrateBind(gateway, "gateway"); - }, - }, ]; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 700a611af..ebc65abd4 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -1,4 +1,4 @@ -export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom"; +export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom" | "tailnet"; export type GatewayTlsConfig = { /** Enable TLS for the gateway server. */ @@ -189,9 +189,10 @@ export type GatewayConfig = { mode?: "local" | "remote"; /** * Bind address policy for the Gateway WebSocket + Control UI HTTP server. - * - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces) + * - auto: Loopback (127.0.0.1) if available, else 0.0.0.0 (fallback to all interfaces) * - 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 * - 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 41fbdd57d..213f1d1fd 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -270,7 +270,13 @@ export const ClawdbotSchema = z port: z.number().int().positive().optional(), mode: z.union([z.literal("local"), z.literal("remote")]).optional(), bind: z - .union([z.literal("auto"), z.literal("lan"), z.literal("loopback"), z.literal("custom")]) + .union([ + z.literal("auto"), + z.literal("lan"), + z.literal("loopback"), + z.literal("custom"), + z.literal("tailnet"), + ]) .optional(), controlUi: z .object({ diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index bb0762671..a0a0d8583 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -72,14 +72,14 @@ describe("callGateway url resolution", () => { closeReason = ""; }); - it("uses tailnet IP when local bind is auto and tailnet is present", async () => { + it("keeps loopback when local bind is auto even if tailnet is present", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); resolveGatewayPort.mockReturnValue(18800); pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1"); await callGateway({ method: "health" }); - expect(lastClientOptions?.url).toBe("ws://100.64.0.1:18800"); + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); }); it("falls back to loopback when local bind is auto without tailnet IP", async () => { @@ -92,6 +92,16 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); }); + it("uses tailnet IP when local bind is tailnet and tailnet is present", async () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } }); + resolveGatewayPort.mockReturnValue(18800); + pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1"); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.url).toBe("ws://100.64.0.1:18800"); + }); + it("uses url override in remote mode even when remote url is missing", async () => { loadConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 3e630ecf5..90a502a52 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -63,7 +63,7 @@ export function buildGatewayConnectionDetails( const localPort = resolveGatewayPort(config); const tailnetIPv4 = pickPrimaryTailnetIPv4(); const bindMode = config.gateway?.bind ?? "loopback"; - const preferTailnet = bindMode === "auto" && !!tailnetIPv4; + const preferTailnet = bindMode === "tailnet" && !!tailnetIPv4; const scheme = tlsEnabled ? "wss" : "ws"; const localUrl = preferTailnet && tailnetIPv4 diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 890a5eee9..5e04e2c4c 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -33,7 +33,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) - * - auto: Tailnet IPv4 if available, else 0.0.0.0 + * - tailnet: Tailnet IPv4 if available, else loopback + * - auto: Loopback if available, else 0.0.0.0 * - custom: User-specified IP, fallback to 0.0.0.0 if unavailable * * @returns The bind address to use (never null) @@ -50,6 +51,13 @@ export async function resolveGatewayBindHost( return "0.0.0.0"; // extreme fallback } + if (mode === "tailnet") { + const tailnetIP = pickPrimaryTailnetIPv4(); + if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP; + if (await canBindTo("127.0.0.1")) return "127.0.0.1"; + return "0.0.0.0"; + } + if (mode === "lan") { return "0.0.0.0"; } @@ -64,8 +72,7 @@ export async function resolveGatewayBindHost( } if (mode === "auto") { - const tailnetIP = pickPrimaryTailnetIPv4(); - if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP; + if (await canBindTo("127.0.0.1")) return "127.0.0.1"; return "0.0.0.0"; } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 33a0d9152..d2242fa2a 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -97,7 +97,7 @@ export type GatewayServerOptions = { * - loopback: 127.0.0.1 * - lan: 0.0.0.0 * - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10) - * - auto: prefer tailnet, else LAN + * - auto: prefer loopback, else LAN */ bind?: import("../config/config.js").GatewayBindMode; /** diff --git a/src/macos/gateway-daemon.ts b/src/macos/gateway-daemon.ts index a8dfd773e..03379a6df 100644 --- a/src/macos/gateway-daemon.ts +++ b/src/macos/gateway-daemon.ts @@ -87,11 +87,15 @@ async function main() { cfg.gateway?.bind ?? "loopback"; const bind = - bindRaw === "loopback" || bindRaw === "lan" || bindRaw === "auto" || bindRaw === "custom" + bindRaw === "loopback" || + bindRaw === "lan" || + bindRaw === "auto" || + bindRaw === "custom" || + bindRaw === "tailnet" ? bindRaw : null; if (!bind) { - defaultRuntime.error('Invalid --bind (use "loopback", "lan", "auto", or "custom")'); + defaultRuntime.error('Invalid --bind (use "loopback", "lan", "tailnet", "auto", or "custom")'); process.exit(1); } diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts index 90a3de370..4974615eb 100644 --- a/src/wizard/onboarding.gateway-config.ts +++ b/src/wizard/onboarding.gateway-config.ts @@ -52,12 +52,13 @@ export async function configureGatewayForOnboarding( message: "Gateway bind", options: [ { value: "loopback", label: "Loopback (127.0.0.1)" }, - { value: "lan", label: "LAN" }, - { value: "auto", label: "Auto" }, + { value: "lan", label: "LAN (0.0.0.0)" }, + { value: "tailnet", label: "Tailnet (Tailscale IP)" }, + { value: "auto", label: "Auto (Loopback → LAN)" }, { value: "custom", label: "Custom IP" }, ], - })) as "loopback" | "lan" | "auto" | "custom") - ) as "loopback" | "lan" | "auto" | "custom"; + })) as "loopback" | "lan" | "auto" | "custom" | "tailnet") + ) as "loopback" | "lan" | "auto" | "custom" | "tailnet"; let customBindHost = quickstartGateway.customBindHost; if (bind === "custom") { diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 96bc0058f..0ec2a7fc0 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -177,7 +177,11 @@ export async function runOnboardingWizard( const bindRaw = baseConfig.gateway?.bind; const bind = - bindRaw === "loopback" || bindRaw === "lan" || bindRaw === "auto" || bindRaw === "custom" + bindRaw === "loopback" || + bindRaw === "lan" || + bindRaw === "auto" || + bindRaw === "custom" || + bindRaw === "tailnet" ? bindRaw : "loopback"; @@ -213,10 +217,11 @@ export async function runOnboardingWizard( })(); if (flow === "quickstart") { - const formatBind = (value: "loopback" | "lan" | "auto" | "custom") => { + const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => { 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)"; return "Auto"; }; const formatAuth = (value: GatewayAuthChoice) => { diff --git a/src/wizard/onboarding.types.ts b/src/wizard/onboarding.types.ts index f99ad16de..e49509d41 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"; + bind: "loopback" | "lan" | "auto" | "custom" | "tailnet"; 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"; + bind: "loopback" | "lan" | "auto" | "custom" | "tailnet"; customBindHost?: string; authMode: GatewayAuthChoice; gatewayToken?: string;