mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-09 05:19:32 +08:00
fix: add explicit tailnet gateway bind
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -40,7 +40,7 @@ Notes:
|
||||
### Options
|
||||
|
||||
- `--port <port>`: WebSocket port (default comes from config/env; usually `18789`).
|
||||
- `--bind <loopback|lan|tailnet|auto>`: listener bind mode.
|
||||
- `--bind <loopback|lan|tailnet|auto|custom>`: listener bind mode.
|
||||
- `--auth <token|password>`: auth mode override.
|
||||
- `--token <token>`: token override (also sets `CLAWDBOT_GATEWAY_TOKEN` for the process).
|
||||
- `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
|
||||
|
||||
@@ -310,7 +310,7 @@ Options:
|
||||
- `--minimax-api-key <key>`
|
||||
- `--opencode-zen-api-key <key>`
|
||||
- `--gateway-port <port>`
|
||||
- `--gateway-bind <loopback|lan|tailnet|auto>`
|
||||
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
|
||||
- `--gateway-auth <off|token|password>`
|
||||
- `--gateway-token <token>`
|
||||
- `--gateway-password <password>`
|
||||
@@ -596,7 +596,7 @@ Run the WebSocket Gateway.
|
||||
|
||||
Options:
|
||||
- `--port <port>`
|
||||
- `--bind <loopback|tailnet|lan|auto>`
|
||||
- `--bind <loopback|tailnet|lan|auto|custom>`
|
||||
- `--token <token>`
|
||||
- `--auth <token|password>`
|
||||
- `--password <password>`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -46,6 +46,25 @@ force `gateway.auth.mode: "password"`.
|
||||
|
||||
Open: `https://<magicdns>/` (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://<tailscale-ip>:18789/`
|
||||
- WebSocket: `ws://<tailscale-ip>: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.
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>", "Port for the gateway WebSocket")
|
||||
.option(
|
||||
"--bind <mode>",
|
||||
'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 <token>",
|
||||
|
||||
@@ -76,7 +76,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
.option("--synthetic-api-key <key>", "Synthetic API key")
|
||||
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
|
||||
.option("--gateway-port <port>", "Gateway port")
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|auto|custom")
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
|
||||
.option("--gateway-auth <mode>", "Gateway auth: off|token|password")
|
||||
.option("--gateway-token <token>", "Gateway token (token auth)")
|
||||
.option("--gateway-password <password>", "Gateway password (password auth)")
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown> | 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");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user