Merge pull request #740 from jeffersonwarrior/main
feat: add Tailscale binary detection, custom gateway IP binding, and health probe auth fix
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
### Changes
|
||||
- Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor.
|
||||
- Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior)
|
||||
|
||||
## 2026.1.12-4
|
||||
|
||||
|
||||
@@ -417,6 +417,85 @@ type EmbeddedPiQueueHandle = {
|
||||
|
||||
const log = createSubsystemLogger("agent/embedded");
|
||||
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
||||
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
||||
"patternProperties",
|
||||
"additionalProperties",
|
||||
"$schema",
|
||||
"$id",
|
||||
"$ref",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"examples",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"multipleOf",
|
||||
"pattern",
|
||||
"format",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
]);
|
||||
|
||||
function findUnsupportedSchemaKeywords(
|
||||
schema: unknown,
|
||||
path: string,
|
||||
): string[] {
|
||||
if (!schema || typeof schema !== "object") return [];
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.flatMap((item, index) =>
|
||||
findUnsupportedSchemaKeywords(item, `${path}[${index}]`),
|
||||
);
|
||||
}
|
||||
const record = schema as Record<string, unknown>;
|
||||
const violations: string[] = [];
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS.has(key)) {
|
||||
violations.push(`${path}.${key}`);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
violations.push(
|
||||
...findUnsupportedSchemaKeywords(value, `${path}.${key}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function logToolSchemasForGoogle(params: {
|
||||
tools: AgentTool[];
|
||||
provider: string;
|
||||
}) {
|
||||
if (
|
||||
params.provider !== "google-antigravity" &&
|
||||
params.provider !== "google-gemini-cli"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`);
|
||||
log.info("google tool schema snapshot", {
|
||||
provider: params.provider,
|
||||
toolCount: params.tools.length,
|
||||
tools: toolNames,
|
||||
});
|
||||
for (const [index, tool] of params.tools.entries()) {
|
||||
const violations = findUnsupportedSchemaKeywords(
|
||||
tool.parameters,
|
||||
`${tool.name}.parameters`,
|
||||
);
|
||||
if (violations.length > 0) {
|
||||
log.warn("google tool schema has unsupported keywords", {
|
||||
index,
|
||||
tool: tool.name,
|
||||
violations: violations.slice(0, 12),
|
||||
violationCount: violations.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerUnhandledRejectionHandler((reason) => {
|
||||
const message = describeUnknownError(reason);
|
||||
@@ -1178,6 +1257,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
||||
// No currentChannelId/currentThreadTs for compaction - not in message context
|
||||
});
|
||||
logToolSchemasForGoogle({ tools, provider });
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeProvider = normalizeMessageProvider(
|
||||
params.messageProvider,
|
||||
@@ -1620,6 +1700,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
});
|
||||
logToolSchemasForGoogle({ tools, provider });
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeInfo = {
|
||||
host: machineName,
|
||||
|
||||
@@ -71,7 +71,8 @@ type ConfigSummary = {
|
||||
|
||||
type GatewayStatusSummary = {
|
||||
bindMode: BridgeBindMode;
|
||||
bindHost: string | null;
|
||||
bindHost: string;
|
||||
customBindHost?: string;
|
||||
port: number;
|
||||
portSource: "service args" | "env/config";
|
||||
probeUrl: string;
|
||||
@@ -190,8 +191,11 @@ function parsePortFromArgs(
|
||||
function pickProbeHostForBind(
|
||||
bindMode: string,
|
||||
tailnetIPv4: string | undefined,
|
||||
customBindHost?: string,
|
||||
) {
|
||||
if (bindMode === "tailnet") return tailnetIPv4 ?? "127.0.0.1";
|
||||
if (bindMode === "custom" && customBindHost?.trim()) {
|
||||
return customBindHost.trim();
|
||||
}
|
||||
if (bindMode === "auto") return tailnetIPv4 ?? "127.0.0.1";
|
||||
return "127.0.0.1";
|
||||
}
|
||||
@@ -429,11 +433,12 @@ async function gatherDaemonStatus(opts: {
|
||||
const bindMode = (daemonCfg.gateway?.bind ?? "loopback") as
|
||||
| "auto"
|
||||
| "lan"
|
||||
| "tailnet"
|
||||
| "loopback";
|
||||
const bindHost = resolveGatewayBindHost(bindMode);
|
||||
| "loopback"
|
||||
| "custom";
|
||||
const customBindHost = daemonCfg.gateway?.customBindHost;
|
||||
const bindHost = await resolveGatewayBindHost(bindMode, customBindHost);
|
||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||
const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4);
|
||||
const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost);
|
||||
const probeUrlOverride =
|
||||
typeof opts.rpc.url === "string" && opts.rpc.url.trim().length > 0
|
||||
? opts.rpc.url.trim()
|
||||
@@ -523,6 +528,7 @@ async function gatherDaemonStatus(opts: {
|
||||
gateway: {
|
||||
bindMode,
|
||||
bindHost,
|
||||
customBindHost,
|
||||
port: daemonPort,
|
||||
portSource,
|
||||
probeUrl,
|
||||
@@ -651,6 +657,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
|
||||
const links = resolveControlUiLinks({
|
||||
port: status.gateway.port,
|
||||
bind: status.gateway.bindMode,
|
||||
customBindHost: status.gateway.customBindHost,
|
||||
basePath: status.config?.daemon?.controlUi?.basePath,
|
||||
});
|
||||
defaultRuntime.log(`${label("Dashboard:")} ${infoText(links.httpUrl)}`);
|
||||
@@ -660,13 +667,6 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
|
||||
`${label("Probe note:")} ${infoText(status.gateway.probeNote)}`,
|
||||
);
|
||||
}
|
||||
if (status.gateway.bindMode === "tailnet" && !status.gateway.bindHost) {
|
||||
defaultRuntime.error(
|
||||
errorText(
|
||||
"Root cause: gateway bind=tailnet but no tailnet interface was found.",
|
||||
),
|
||||
);
|
||||
}
|
||||
spacer();
|
||||
}
|
||||
const runtimeLine = formatRuntimeStatus(service.runtime);
|
||||
|
||||
@@ -678,14 +678,14 @@ async function runGatewayCommand(
|
||||
const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback";
|
||||
const bind =
|
||||
bindRaw === "loopback" ||
|
||||
bindRaw === "tailnet" ||
|
||||
bindRaw === "lan" ||
|
||||
bindRaw === "auto"
|
||||
bindRaw === "auto" ||
|
||||
bindRaw === "custom"
|
||||
? bindRaw
|
||||
: null;
|
||||
if (!bind) {
|
||||
defaultRuntime.error(
|
||||
'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")',
|
||||
'Invalid --bind (use "loopback", "lan", "auto", or "custom")',
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
|
||||
@@ -292,7 +292,7 @@ export function buildProgram() {
|
||||
.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|tailnet|auto")
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|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)")
|
||||
@@ -369,8 +369,8 @@ export function buildProgram() {
|
||||
gatewayBind: opts.gatewayBind as
|
||||
| "loopback"
|
||||
| "lan"
|
||||
| "tailnet"
|
||||
| "auto"
|
||||
| "custom"
|
||||
| undefined,
|
||||
gatewayAuth: opts.gatewayAuth as
|
||||
| "off"
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import { findTailscaleBinary } from "../infra/tailscale.js";
|
||||
import { listChatProviders } from "../providers/registry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -220,16 +221,61 @@ async function promptGatewayConfig(
|
||||
|
||||
let bind = guardCancel(
|
||||
await select({
|
||||
message: "Gateway bind",
|
||||
message: "Gateway bind mode",
|
||||
options: [
|
||||
{ value: "loopback", label: "Loopback (127.0.0.1)" },
|
||||
{ value: "lan", label: "LAN" },
|
||||
{ value: "tailnet", label: "Tailnet" },
|
||||
{ value: "auto", label: "Auto" },
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto (Tailnet → LAN)",
|
||||
hint: "Prefer Tailnet IP, 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",
|
||||
hint: "Specify a specific IP address, with 0.0.0.0 fallback if unavailable",
|
||||
},
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
) as "loopback" | "lan" | "tailnet" | "auto";
|
||||
) as "auto" | "lan" | "loopback" | "custom";
|
||||
|
||||
let customBindHost: string | undefined;
|
||||
if (bind === "custom") {
|
||||
const input = guardCancel(
|
||||
await text({
|
||||
message: "Custom IP address",
|
||||
placeholder: "192.168.1.100",
|
||||
validate: (value) => {
|
||||
if (!value) return "IP address is required for custom bind mode";
|
||||
const trimmed = value.trim();
|
||||
const parts = trimmed.split(".");
|
||||
if (parts.length !== 4)
|
||||
return "Invalid IPv4 address (e.g., 192.168.1.100)";
|
||||
if (
|
||||
parts.every((part) => {
|
||||
const n = parseInt(part, 10);
|
||||
return (
|
||||
!Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n)
|
||||
);
|
||||
})
|
||||
)
|
||||
return undefined;
|
||||
return "Invalid IPv4 address (each octet must be 0-255)";
|
||||
},
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
customBindHost = typeof input === "string" ? input : undefined;
|
||||
}
|
||||
|
||||
let authMode = guardCancel(
|
||||
await select({
|
||||
@@ -268,6 +314,23 @@ async function promptGatewayConfig(
|
||||
runtime,
|
||||
) as "off" | "serve" | "funnel";
|
||||
|
||||
// Detect Tailscale binary before proceeding with serve/funnel setup
|
||||
if (tailscaleMode !== "off") {
|
||||
const tailscaleBin = await findTailscaleBinary();
|
||||
if (!tailscaleBin) {
|
||||
note(
|
||||
[
|
||||
"Tailscale binary not found in PATH or /Applications.",
|
||||
"Ensure Tailscale is installed from:",
|
||||
" https://tailscale.com/download/mac",
|
||||
"",
|
||||
"You can continue setup, but serve/funnel will fail at runtime.",
|
||||
].join("\n"),
|
||||
"Tailscale Warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tailscaleResetOnExit = false;
|
||||
if (tailscaleMode !== "off") {
|
||||
note(
|
||||
@@ -348,6 +411,7 @@ async function promptGatewayConfig(
|
||||
port,
|
||||
bind,
|
||||
auth: authConfig,
|
||||
...(customBindHost && { customBindHost }),
|
||||
tailscale: {
|
||||
...next.gateway?.tailscale,
|
||||
mode: tailscaleMode,
|
||||
@@ -943,16 +1007,32 @@ export async function runConfigureWizard(
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port: gatewayPort,
|
||||
customBindHost: nextConfig.gateway?.customBindHost,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
});
|
||||
const gatewayProbe = await probeGatewayReachable({
|
||||
// Try both new and old passwords since gateway may still have old config
|
||||
const newPassword =
|
||||
nextConfig.gateway?.auth?.password ??
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||
const oldPassword =
|
||||
baseConfig.gateway?.auth?.password ??
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||
const token =
|
||||
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
|
||||
let gatewayProbe = await probeGatewayReachable({
|
||||
url: links.wsUrl,
|
||||
token:
|
||||
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
password:
|
||||
nextConfig.gateway?.auth?.password ??
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD,
|
||||
token,
|
||||
password: newPassword,
|
||||
});
|
||||
// If new password failed and it's different from old password, try old too
|
||||
if (!gatewayProbe.ok && newPassword !== oldPassword && oldPassword) {
|
||||
gatewayProbe = await probeGatewayReachable({
|
||||
url: links.wsUrl,
|
||||
token,
|
||||
password: oldPassword,
|
||||
});
|
||||
}
|
||||
const gatewayStatusLine = gatewayProbe.ok
|
||||
? "Gateway: reachable"
|
||||
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
|
||||
|
||||
@@ -78,6 +78,7 @@ describe("dashboardCommand", () => {
|
||||
expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
customBindHost: undefined,
|
||||
basePath: undefined,
|
||||
});
|
||||
expect(mocks.copyToClipboard).toHaveBeenCalledWith(
|
||||
|
||||
@@ -25,10 +25,16 @@ export async function dashboardCommand(
|
||||
const port = resolveGatewayPort(cfg);
|
||||
const bind = cfg.gateway?.bind ?? "loopback";
|
||||
const basePath = cfg.gateway?.controlUi?.basePath;
|
||||
const customBindHost = cfg.gateway?.customBindHost;
|
||||
const token =
|
||||
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? "";
|
||||
|
||||
const links = resolveControlUiLinks({ port, bind, basePath });
|
||||
const links = resolveControlUiLinks({
|
||||
port,
|
||||
bind,
|
||||
customBindHost,
|
||||
basePath,
|
||||
});
|
||||
const authedUrl = token
|
||||
? `${links.httpUrl}?token=${encodeURIComponent(token)}`
|
||||
: links.httpUrl;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { openUrl, resolveBrowserOpenCommand } from "./onboard-helpers.js";
|
||||
import {
|
||||
openUrl,
|
||||
resolveBrowserOpenCommand,
|
||||
resolveControlUiLinks,
|
||||
} from "./onboard-helpers.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runCommandWithTimeout: vi.fn(async () => ({
|
||||
@@ -10,12 +14,17 @@ const mocks = vi.hoisted(() => ({
|
||||
signal: null,
|
||||
killed: false,
|
||||
})),
|
||||
pickPrimaryTailnetIPv4: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: mocks.runCommandWithTimeout,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4: mocks.pickPrimaryTailnetIPv4,
|
||||
}));
|
||||
|
||||
describe("openUrl", () => {
|
||||
it("quotes URLs on win32 so '&' is not treated as cmd separator", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
@@ -45,3 +54,25 @@ describe("resolveBrowserOpenCommand", () => {
|
||||
expect(resolved.quoteUrl).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveControlUiLinks", () => {
|
||||
it("uses customBindHost for custom bind", () => {
|
||||
const links = resolveControlUiLinks({
|
||||
port: 18789,
|
||||
bind: "custom",
|
||||
customBindHost: "192.168.1.100",
|
||||
});
|
||||
expect(links.httpUrl).toBe("http://192.168.1.100:18789/");
|
||||
expect(links.wsUrl).toBe("ws://192.168.1.100:18789");
|
||||
});
|
||||
|
||||
it("falls back to loopback for invalid customBindHost", () => {
|
||||
const links = resolveControlUiLinks({
|
||||
port: 18789,
|
||||
bind: "custom",
|
||||
customBindHost: "192.168.001.100",
|
||||
});
|
||||
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
|
||||
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -410,16 +410,21 @@ export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
|
||||
export function resolveControlUiLinks(params: {
|
||||
port: number;
|
||||
bind?: "auto" | "lan" | "tailnet" | "loopback";
|
||||
bind?: "auto" | "lan" | "loopback" | "custom";
|
||||
customBindHost?: string;
|
||||
basePath?: string;
|
||||
}): { httpUrl: string; wsUrl: string } {
|
||||
const port = params.port;
|
||||
const bind = params.bind ?? "loopback";
|
||||
const customBindHost = params.customBindHost?.trim();
|
||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||
const host =
|
||||
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
|
||||
? (tailnetIPv4 ?? "127.0.0.1")
|
||||
: "127.0.0.1";
|
||||
const host = (() => {
|
||||
if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) {
|
||||
return customBindHost;
|
||||
}
|
||||
if (bind === "auto" && tailnetIPv4) return tailnetIPv4 ?? "127.0.0.1";
|
||||
return "127.0.0.1";
|
||||
})();
|
||||
const basePath = normalizeControlUiBasePath(params.basePath);
|
||||
const uiPath = basePath ? `${basePath}/` : "/";
|
||||
const wsPath = basePath ? basePath : "";
|
||||
@@ -428,3 +433,12 @@ export function resolveControlUiLinks(params: {
|
||||
wsUrl: `ws://${host}:${port}${wsPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidIPv4(host: string): boolean {
|
||||
const parts = host.split(".");
|
||||
if (parts.length !== 4) return false;
|
||||
return parts.every((part) => {
|
||||
const n = Number.parseInt(part, 10);
|
||||
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export type AuthChoice =
|
||||
| "skip";
|
||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||
export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto";
|
||||
export type GatewayBind = "loopback" | "lan" | "auto" | "custom";
|
||||
export type TailscaleMode = "off" | "serve" | "funnel";
|
||||
export type NodeManagerChoice = "npm" | "pnpm" | "bun";
|
||||
export type ProviderChoice = ChatProviderId;
|
||||
|
||||
@@ -271,6 +271,7 @@ export async function statusAllCommand(
|
||||
? resolveControlUiLinks({
|
||||
port,
|
||||
bind: cfg.gateway?.bind,
|
||||
customBindHost: cfg.gateway?.customBindHost,
|
||||
basePath: cfg.gateway?.controlUi?.basePath,
|
||||
}).httpUrl
|
||||
: null;
|
||||
|
||||
@@ -818,6 +818,7 @@ export async function statusCommand(
|
||||
const links = resolveControlUiLinks({
|
||||
port: resolveGatewayPort(cfg),
|
||||
bind: cfg.gateway?.bind,
|
||||
customBindHost: cfg.gateway?.customBindHost,
|
||||
basePath: cfg.gateway?.controlUi?.basePath,
|
||||
});
|
||||
return links.httpUrl;
|
||||
|
||||
@@ -1247,6 +1247,23 @@ describe("legacy config detection", () => {
|
||||
expect((res.config?.gateway as { token?: string })?.token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("migrates gateway.bind and bridge.bind from 'tailnet' to 'auto'", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: { bind: "tailnet" as const },
|
||||
bridge: { bind: "tailnet" as const },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
"Migrated gateway.bind from 'tailnet' to 'auto'.",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Migrated bridge.bind from 'tailnet' to 'auto'.",
|
||||
);
|
||||
expect(res.config?.gateway?.bind).toBe("auto");
|
||||
expect(res.config?.bridge?.bind).toBe("auto");
|
||||
});
|
||||
|
||||
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
|
||||
@@ -891,6 +891,29 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||
delete raw.identity;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bind-tailnet->auto",
|
||||
describe: "Remap gateway/bridge 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");
|
||||
|
||||
const bridge = getRecord(raw.bridge);
|
||||
migrateBind(bridge, "bridge");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||
|
||||
@@ -1244,17 +1244,17 @@ export type ProviderCommandsConfig = {
|
||||
native?: NativeCommandsSetting;
|
||||
};
|
||||
|
||||
export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback";
|
||||
export type BridgeBindMode = "auto" | "lan" | "loopback" | "custom";
|
||||
|
||||
export type BridgeConfig = {
|
||||
enabled?: boolean;
|
||||
port?: number;
|
||||
/**
|
||||
* Bind address policy for the node bridge server.
|
||||
* - auto: prefer tailnet IP when present, else LAN (0.0.0.0)
|
||||
* - lan: 0.0.0.0 (reachable on local network + any forwarded interfaces)
|
||||
* - tailnet: bind to the Tailscale interface IP (100.64.0.0/10) plus loopback
|
||||
* - loopback: 127.0.0.1
|
||||
* - auto: Tailnet IPv4 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)
|
||||
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost on gateway)
|
||||
*/
|
||||
bind?: BridgeBindMode;
|
||||
};
|
||||
@@ -1369,9 +1369,15 @@ 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)
|
||||
* - lan: 0.0.0.0 (all interfaces, no fallback)
|
||||
* - loopback: 127.0.0.1 (local-only)
|
||||
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost)
|
||||
* Default: loopback (127.0.0.1).
|
||||
*/
|
||||
bind?: BridgeBindMode;
|
||||
/** Custom IP address for bind="custom" mode. Fallback: 0.0.0.0. */
|
||||
customBindHost?: string;
|
||||
controlUi?: GatewayControlUiConfig;
|
||||
auth?: GatewayAuthConfig;
|
||||
tailscale?: GatewayTailscaleConfig;
|
||||
|
||||
@@ -74,8 +74,8 @@ describe("callGateway url resolution", () => {
|
||||
closeReason = "";
|
||||
});
|
||||
|
||||
it("uses tailnet IP when local bind is tailnet", async () => {
|
||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } });
|
||||
it("uses tailnet IP when local bind is auto and tailnet is present", async () => {
|
||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } });
|
||||
resolveGatewayPort.mockReturnValue(18800);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
|
||||
|
||||
@@ -84,16 +84,6 @@ describe("callGateway url resolution", () => {
|
||||
expect(lastClientOptions?.url).toBe("ws://100.64.0.1:18800");
|
||||
});
|
||||
|
||||
it("uses tailnet IP when local bind is auto and tailnet is present", async () => {
|
||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } });
|
||||
resolveGatewayPort.mockReturnValue(18800);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.2");
|
||||
|
||||
await callGateway({ method: "health" });
|
||||
|
||||
expect(lastClientOptions?.url).toBe("ws://100.64.0.2:18800");
|
||||
});
|
||||
|
||||
it("falls back to loopback when local bind is auto without tailnet IP", async () => {
|
||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } });
|
||||
resolveGatewayPort.mockReturnValue(18800);
|
||||
|
||||
@@ -59,8 +59,7 @@ export function buildGatewayConnectionDetails(
|
||||
const localPort = resolveGatewayPort(config);
|
||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||
const bindMode = config.gateway?.bind ?? "loopback";
|
||||
const preferTailnet =
|
||||
bindMode === "tailnet" || (bindMode === "auto" && !!tailnetIPv4);
|
||||
const preferTailnet = bindMode === "auto" && !!tailnetIPv4;
|
||||
const localUrl =
|
||||
preferTailnet && tailnetIPv4
|
||||
? `ws://${tailnetIPv4}:${localPort}`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import net from "node:net";
|
||||
|
||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||
|
||||
export function isLoopbackAddress(ip: string | undefined): boolean {
|
||||
@@ -9,15 +11,86 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveGatewayBindHost(
|
||||
/**
|
||||
* Resolves gateway bind host with fallback strategy.
|
||||
*
|
||||
* 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
|
||||
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable
|
||||
*
|
||||
* @returns The bind address to use (never null)
|
||||
*/
|
||||
export async function resolveGatewayBindHost(
|
||||
bind: import("../config/config.js").BridgeBindMode | undefined,
|
||||
): string | null {
|
||||
customHost?: string,
|
||||
): Promise<string> {
|
||||
const mode = bind ?? "loopback";
|
||||
if (mode === "loopback") return "127.0.0.1";
|
||||
if (mode === "lan") return "0.0.0.0";
|
||||
if (mode === "tailnet") return pickPrimaryTailnetIPv4() ?? null;
|
||||
if (mode === "auto") return pickPrimaryTailnetIPv4() ?? "0.0.0.0";
|
||||
return "127.0.0.1";
|
||||
|
||||
if (mode === "loopback") {
|
||||
// 127.0.0.1 rarely fails, but handle gracefully
|
||||
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
|
||||
return "0.0.0.0"; // extreme fallback
|
||||
}
|
||||
|
||||
if (mode === "lan") {
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
if (mode === "custom") {
|
||||
const host = customHost?.trim();
|
||||
if (!host) return "0.0.0.0"; // invalid config → fall back to all
|
||||
|
||||
if (isValidIPv4(host) && (await canBindTo(host))) return host;
|
||||
// Custom IP failed → fall back to LAN
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
if (mode === "auto") {
|
||||
const tailnetIP = pickPrimaryTailnetIPv4();
|
||||
if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP;
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if we can bind to a specific host address.
|
||||
* Creates a temporary server, attempts to bind, then closes it.
|
||||
*
|
||||
* @param host - The host address to test
|
||||
* @returns True if we can successfully bind to this address
|
||||
*/
|
||||
async function canBindTo(host: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const testServer = net.createServer();
|
||||
testServer.once("error", () => {
|
||||
resolve(false);
|
||||
});
|
||||
testServer.once("listening", () => {
|
||||
testServer.close();
|
||||
resolve(true);
|
||||
});
|
||||
// Use port 0 to let OS pick an available port for testing
|
||||
testServer.listen(0, host);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid IPv4 address.
|
||||
*
|
||||
* @param host - The string to validate
|
||||
* @returns True if valid IPv4 format
|
||||
*/
|
||||
function isValidIPv4(host: string): boolean {
|
||||
const parts = host.split(".");
|
||||
if (parts.length !== 4) return false;
|
||||
return parts.every((part) => {
|
||||
const n = parseInt(part, 10);
|
||||
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
|
||||
});
|
||||
}
|
||||
|
||||
export function isLoopbackHost(host: string): boolean {
|
||||
|
||||
@@ -490,12 +490,9 @@ export async function startGatewayServer(
|
||||
}
|
||||
let pluginServices: PluginServicesHandle | null = null;
|
||||
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
||||
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
||||
if (!bindHost) {
|
||||
throw new Error(
|
||||
"gateway bind is tailnet, but no tailnet interface was found; refusing to start gateway",
|
||||
);
|
||||
}
|
||||
const customBindHost = cfgAtStart.gateway?.customBindHost;
|
||||
const bindHost =
|
||||
opts.host ?? (await resolveGatewayBindHost(bindMode, customBindHost));
|
||||
const controlUiEnabled =
|
||||
opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true;
|
||||
const openAiChatCompletionsEnabled =
|
||||
@@ -960,18 +957,20 @@ export async function startGatewayServer(
|
||||
}
|
||||
|
||||
const bind =
|
||||
cfgAtStart.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "tailnet" : "lan");
|
||||
cfgAtStart.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "auto" : "lan");
|
||||
if (bind === "loopback") return "127.0.0.1";
|
||||
if (bind === "lan") return "0.0.0.0";
|
||||
|
||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
||||
if (bind === "tailnet") {
|
||||
return tailnetIPv4 ?? tailnetIPv6 ?? null;
|
||||
}
|
||||
if (bind === "auto") {
|
||||
return tailnetIPv4 ?? tailnetIPv6 ?? "0.0.0.0";
|
||||
}
|
||||
if (bind === "custom") {
|
||||
// For bridge, customBindHost is not currently supported on GatewayConfig.
|
||||
// This will fall back to "0.0.0.0" until we add customBindHost to BridgeConfig.
|
||||
return "0.0.0.0";
|
||||
}
|
||||
return "0.0.0.0";
|
||||
})();
|
||||
|
||||
|
||||
@@ -22,18 +22,111 @@ function parsePossiblyNoisyJsonObject(stdout: string): Record<string, unknown> {
|
||||
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
||||
/**
|
||||
* Locate Tailscale binary using multiple strategies:
|
||||
* 1. PATH lookup (via which command)
|
||||
* 2. Known macOS app path
|
||||
* 3. find /Applications for Tailscale.app
|
||||
* 4. locate database (if available)
|
||||
*
|
||||
* @returns Path to Tailscale binary or null if not found
|
||||
*/
|
||||
export async function findTailscaleBinary(): Promise<string | null> {
|
||||
// Helper to check if a binary exists and is executable
|
||||
const checkBinary = async (path: string): Promise<boolean> => {
|
||||
if (!path || !existsSync(path)) return false;
|
||||
try {
|
||||
// Use Promise.race with runExec to implement timeout
|
||||
await Promise.race([
|
||||
runExec(path, ["--version"], { timeoutMs: 3000 }),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timeout")), 3000),
|
||||
),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Strategy 1: which command
|
||||
try {
|
||||
const { stdout } = await runExec("which", ["tailscale"]);
|
||||
const fromPath = stdout.trim();
|
||||
if (fromPath && (await checkBinary(fromPath))) {
|
||||
return fromPath;
|
||||
}
|
||||
} catch {
|
||||
// which failed, continue
|
||||
}
|
||||
|
||||
// Strategy 2: Known macOS app path
|
||||
const macAppPath = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
||||
if (await checkBinary(macAppPath)) {
|
||||
return macAppPath;
|
||||
}
|
||||
|
||||
// Strategy 3: find command in /Applications
|
||||
try {
|
||||
const { stdout } = await runExec(
|
||||
"find",
|
||||
[
|
||||
"/Applications",
|
||||
"-maxdepth",
|
||||
"3",
|
||||
"-name",
|
||||
"Tailscale",
|
||||
"-path",
|
||||
"*/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
],
|
||||
{ timeoutMs: 5000 },
|
||||
);
|
||||
const found = stdout.trim().split("\n")[0];
|
||||
if (found && (await checkBinary(found))) {
|
||||
return found;
|
||||
}
|
||||
} catch {
|
||||
// find failed, continue
|
||||
}
|
||||
|
||||
// Strategy 4: locate command
|
||||
try {
|
||||
const { stdout } = await runExec("locate", ["Tailscale.app"]);
|
||||
const candidates = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("/Tailscale.app/Contents/MacOS/Tailscale"),
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
if (await checkBinary(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// locate failed, continue
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getTailnetHostname(
|
||||
exec: typeof runExec = runExec,
|
||||
detectedBinary?: string,
|
||||
) {
|
||||
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
||||
const candidates = [
|
||||
"tailscale",
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
];
|
||||
const candidates = detectedBinary
|
||||
? [detectedBinary]
|
||||
: ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"];
|
||||
let lastError: unknown;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.startsWith("/") && !existsSync(candidate)) continue;
|
||||
try {
|
||||
const { stdout } = await exec(candidate, ["status", "--json"]);
|
||||
const { stdout } = await exec(candidate, ["status", "--json"], {
|
||||
timeoutMs: 5000,
|
||||
maxBuffer: 400_000,
|
||||
});
|
||||
const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
|
||||
const self =
|
||||
typeof parsed.Self === "object" && parsed.Self !== null
|
||||
@@ -44,7 +137,7 @@ export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
||||
? (self.DNSName as string)
|
||||
: undefined;
|
||||
const ips = Array.isArray(self?.TailscaleIPs)
|
||||
? (self.TailscaleIPs as string[])
|
||||
? ((parsed.Self as { TailscaleIPs?: string[] }).TailscaleIPs ?? [])
|
||||
: [];
|
||||
if (dns && dns.length > 0) return dns.replace(/\.$/, "");
|
||||
if (ips.length > 0) return ips[0];
|
||||
@@ -57,11 +150,24 @@ export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
||||
throw lastError ?? new Error("Could not determine Tailscale DNS or IP");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Tailscale binary command to use.
|
||||
* Returns a cached detected binary or the default "tailscale" command.
|
||||
*/
|
||||
let cachedTailscaleBinary: string | null = null;
|
||||
|
||||
export async function getTailscaleBinary(): Promise<string> {
|
||||
if (cachedTailscaleBinary) return cachedTailscaleBinary;
|
||||
cachedTailscaleBinary = await findTailscaleBinary();
|
||||
return cachedTailscaleBinary ?? "tailscale";
|
||||
}
|
||||
|
||||
export async function readTailscaleStatusJson(
|
||||
exec: typeof runExec = runExec,
|
||||
opts?: { timeoutMs?: number },
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { stdout } = await exec("tailscale", ["status", "--json"], {
|
||||
const tailscaleBin = await getTailscaleBinary();
|
||||
const { stdout } = await exec(tailscaleBin, ["status", "--json"], {
|
||||
timeoutMs: opts?.timeoutMs ?? 5000,
|
||||
maxBuffer: 400_000,
|
||||
});
|
||||
@@ -123,8 +229,9 @@ export async function ensureFunnel(
|
||||
) {
|
||||
// Ensure Funnel is enabled and publish the webhook port.
|
||||
try {
|
||||
const tailscaleBin = await getTailscaleBinary();
|
||||
const statusOut = (
|
||||
await exec("tailscale", ["funnel", "status", "--json"])
|
||||
await exec(tailscaleBin, ["funnel", "status", "--json"])
|
||||
).stdout.trim();
|
||||
const parsed = statusOut
|
||||
? (JSON.parse(statusOut) as Record<string, unknown>)
|
||||
@@ -155,7 +262,7 @@ export async function ensureFunnel(
|
||||
|
||||
logVerbose(`Enabling funnel on port ${port}…`);
|
||||
const { stdout } = await exec(
|
||||
"tailscale",
|
||||
tailscaleBin,
|
||||
["funnel", "--yes", "--bg", `${port}`],
|
||||
{
|
||||
maxBuffer: 200_000,
|
||||
@@ -216,14 +323,16 @@ export async function enableTailscaleServe(
|
||||
port: number,
|
||||
exec: typeof runExec = runExec,
|
||||
) {
|
||||
await exec("tailscale", ["serve", "--bg", "--yes", `${port}`], {
|
||||
const tailscaleBin = await getTailscaleBinary();
|
||||
await exec(tailscaleBin, ["serve", "--bg", "--yes", `${port}`], {
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function disableTailscaleServe(exec: typeof runExec = runExec) {
|
||||
await exec("tailscale", ["serve", "reset"], {
|
||||
const tailscaleBin = await getTailscaleBinary();
|
||||
await exec(tailscaleBin, ["serve", "reset"], {
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
@@ -233,14 +342,16 @@ export async function enableTailscaleFunnel(
|
||||
port: number,
|
||||
exec: typeof runExec = runExec,
|
||||
) {
|
||||
await exec("tailscale", ["funnel", "--bg", "--yes", `${port}`], {
|
||||
const tailscaleBin = await getTailscaleBinary();
|
||||
await exec(tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], {
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
|
||||
await exec("tailscale", ["funnel", "reset"], {
|
||||
const tailscaleBin = await getTailscaleBinary();
|
||||
await exec(tailscaleBin, ["funnel", "reset"], {
|
||||
maxBuffer: 200_000,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
|
||||
@@ -82,14 +82,14 @@ async function main() {
|
||||
"loopback";
|
||||
const bind =
|
||||
bindRaw === "loopback" ||
|
||||
bindRaw === "tailnet" ||
|
||||
bindRaw === "lan" ||
|
||||
bindRaw === "auto"
|
||||
bindRaw === "auto" ||
|
||||
bindRaw === "custom"
|
||||
? bindRaw
|
||||
: null;
|
||||
if (!bind) {
|
||||
defaultRuntime.error(
|
||||
'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")',
|
||||
'Invalid --bind (use "loopback", "lan", "auto", or "custom")',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import { findTailscaleBinary } from "../infra/tailscale.js";
|
||||
import { listProviderPlugins } from "../providers/plugins/index.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -173,14 +174,15 @@ export async function runOnboardingWizard(
|
||||
baseConfig.gateway?.auth?.mode !== undefined ||
|
||||
baseConfig.gateway?.auth?.token !== undefined ||
|
||||
baseConfig.gateway?.auth?.password !== undefined ||
|
||||
baseConfig.gateway?.customBindHost !== undefined ||
|
||||
baseConfig.gateway?.tailscale?.mode !== undefined;
|
||||
|
||||
const bindRaw = baseConfig.gateway?.bind;
|
||||
const bind =
|
||||
bindRaw === "loopback" ||
|
||||
bindRaw === "lan" ||
|
||||
bindRaw === "tailnet" ||
|
||||
bindRaw === "auto"
|
||||
bindRaw === "auto" ||
|
||||
bindRaw === "custom"
|
||||
? bindRaw
|
||||
: "loopback";
|
||||
|
||||
@@ -212,15 +214,16 @@ export async function runOnboardingWizard(
|
||||
tailscaleMode,
|
||||
token: baseConfig.gateway?.auth?.token,
|
||||
password: baseConfig.gateway?.auth?.password,
|
||||
customBindHost: baseConfig.gateway?.customBindHost,
|
||||
tailscaleResetOnExit: baseConfig.gateway?.tailscale?.resetOnExit ?? false,
|
||||
};
|
||||
})();
|
||||
|
||||
if (flow === "quickstart") {
|
||||
const formatBind = (value: "loopback" | "lan" | "tailnet" | "auto") => {
|
||||
const formatBind = (value: "loopback" | "lan" | "auto" | "custom") => {
|
||||
if (value === "loopback") return "Loopback (127.0.0.1)";
|
||||
if (value === "lan") return "LAN";
|
||||
if (value === "tailnet") return "Tailnet";
|
||||
if (value === "custom") return "Custom IP";
|
||||
return "Auto";
|
||||
};
|
||||
const formatAuth = (value: GatewayAuthChoice) => {
|
||||
@@ -238,6 +241,10 @@ export async function runOnboardingWizard(
|
||||
"Keeping your current gateway settings:",
|
||||
`Gateway port: ${quickstartGateway.port}`,
|
||||
`Gateway bind: ${formatBind(quickstartGateway.bind)}`,
|
||||
...(quickstartGateway.bind === "custom" &&
|
||||
quickstartGateway.customBindHost
|
||||
? [`Gateway custom IP: ${quickstartGateway.customBindHost}`]
|
||||
: []),
|
||||
`Gateway auth: ${formatAuth(quickstartGateway.authMode)}`,
|
||||
`Tailscale exposure: ${formatTailscale(
|
||||
quickstartGateway.tailscaleMode,
|
||||
@@ -396,11 +403,41 @@ export async function runOnboardingWizard(
|
||||
options: [
|
||||
{ value: "loopback", label: "Loopback (127.0.0.1)" },
|
||||
{ value: "lan", label: "LAN" },
|
||||
{ value: "tailnet", label: "Tailnet" },
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "custom", label: "Custom IP" },
|
||||
],
|
||||
})) as "loopback" | "lan" | "tailnet" | "auto")
|
||||
) as "loopback" | "lan" | "tailnet" | "auto";
|
||||
})) as "loopback" | "lan" | "auto" | "custom")
|
||||
) as "loopback" | "lan" | "auto" | "custom";
|
||||
|
||||
let customBindHost = quickstartGateway.customBindHost;
|
||||
if (bind === "custom") {
|
||||
const needsPrompt = flow !== "quickstart" || !customBindHost;
|
||||
if (needsPrompt) {
|
||||
const input = await prompter.text({
|
||||
message: "Custom IP address",
|
||||
placeholder: "192.168.1.100",
|
||||
initialValue: customBindHost ?? "",
|
||||
validate: (value) => {
|
||||
if (!value) return "IP address is required for custom bind mode";
|
||||
const trimmed = value.trim();
|
||||
const parts = trimmed.split(".");
|
||||
if (parts.length !== 4)
|
||||
return "Invalid IPv4 address (e.g., 192.168.1.100)";
|
||||
if (
|
||||
parts.every((part) => {
|
||||
const n = parseInt(part, 10);
|
||||
return (
|
||||
!Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n)
|
||||
);
|
||||
})
|
||||
)
|
||||
return undefined;
|
||||
return "Invalid IPv4 address (each octet must be 0-255)";
|
||||
},
|
||||
});
|
||||
customBindHost = typeof input === "string" ? input.trim() : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let authMode = (
|
||||
flow === "quickstart"
|
||||
@@ -445,6 +482,23 @@ export async function runOnboardingWizard(
|
||||
})) as "off" | "serve" | "funnel")
|
||||
) as "off" | "serve" | "funnel";
|
||||
|
||||
// Detect Tailscale binary before proceeding with serve/funnel setup
|
||||
if (tailscaleMode !== "off") {
|
||||
const tailscaleBin = await findTailscaleBinary();
|
||||
if (!tailscaleBin) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Tailscale binary not found in PATH or /Applications.",
|
||||
"Ensure Tailscale is installed from:",
|
||||
" https://tailscale.com/download/mac",
|
||||
"",
|
||||
"You can continue setup, but serve/funnel will fail at runtime.",
|
||||
].join("\n"),
|
||||
"Tailscale Warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tailscaleResetOnExit =
|
||||
flow === "quickstart" ? quickstartGateway.tailscaleResetOnExit : false;
|
||||
if (tailscaleMode !== "off" && flow !== "quickstart") {
|
||||
@@ -470,6 +524,7 @@ export async function runOnboardingWizard(
|
||||
"Note",
|
||||
);
|
||||
bind = "loopback";
|
||||
customBindHost = undefined;
|
||||
}
|
||||
|
||||
if (authMode === "off" && bind !== "loopback") {
|
||||
@@ -538,6 +593,7 @@ export async function runOnboardingWizard(
|
||||
...nextConfig.gateway,
|
||||
port,
|
||||
bind,
|
||||
...(bind === "custom" && customBindHost ? { customBindHost } : {}),
|
||||
tailscale: {
|
||||
...nextConfig.gateway?.tailscale,
|
||||
mode: tailscaleMode,
|
||||
@@ -747,6 +803,7 @@ export async function runOnboardingWizard(
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port,
|
||||
customBindHost,
|
||||
basePath: controlUiBasePath,
|
||||
});
|
||||
const tokenParam =
|
||||
|
||||
Reference in New Issue
Block a user