Files
clawdbot/src/commands/configure.gateway.ts
Peter Steinberger c379191f80 chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-14 15:02:19 +00:00

225 lines
6.2 KiB
TypeScript

import type { ClawdbotConfig } from "../config/config.js";
import { resolveGatewayPort } from "../config/config.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
import { confirm, select, text } from "./configure.shared.js";
import { guardCancel, randomToken } from "./onboard-helpers.js";
type GatewayAuthChoice = "off" | "token" | "password";
export async function promptGatewayConfig(
cfg: ClawdbotConfig,
runtime: RuntimeEnv,
): Promise<{
config: ClawdbotConfig;
port: number;
token?: string;
}> {
const portRaw = guardCancel(
await text({
message: "Gateway port",
initialValue: String(resolveGatewayPort(cfg)),
validate: (value) => (Number.isFinite(Number(value)) ? undefined : "Invalid port"),
}),
runtime,
);
const port = Number.parseInt(String(portRaw), 10);
let bind = guardCancel(
await select({
message: "Gateway bind mode",
options: [
{
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 "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({
message: "Gateway auth",
options: [
{
value: "off",
label: "Off (loopback only)",
hint: "Not recommended unless you fully trust local processes",
},
{ value: "token", label: "Token", hint: "Recommended default" },
{ value: "password", label: "Password" },
],
initialValue: "token",
}),
runtime,
) as GatewayAuthChoice;
const tailscaleMode = guardCancel(
await select({
message: "Tailscale exposure",
options: [
{ value: "off", label: "Off", hint: "No Tailscale exposure" },
{
value: "serve",
label: "Serve",
hint: "Private HTTPS for your tailnet (devices on Tailscale)",
},
{
value: "funnel",
label: "Funnel",
hint: "Public HTTPS via Tailscale Funnel (internet)",
},
],
}),
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(
["Docs:", "https://docs.clawd.bot/gateway/tailscale", "https://docs.clawd.bot/web"].join(
"\n",
),
"Tailscale",
);
tailscaleResetOnExit = Boolean(
guardCancel(
await confirm({
message: "Reset Tailscale serve/funnel on exit?",
initialValue: false,
}),
runtime,
),
);
}
if (tailscaleMode !== "off" && bind !== "loopback") {
note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
bind = "loopback";
}
if (authMode === "off" && bind !== "loopback") {
note("Non-loopback bind requires auth. Switching to token auth.", "Note");
authMode = "token";
}
if (tailscaleMode === "funnel" && authMode !== "password") {
note("Tailscale funnel requires password auth.", "Note");
authMode = "password";
}
let gatewayToken: string | undefined;
let gatewayPassword: string | undefined;
let next = cfg;
if (authMode === "token") {
const tokenInput = guardCancel(
await text({
message: "Gateway token (blank to generate)",
initialValue: randomToken(),
}),
runtime,
);
gatewayToken = String(tokenInput).trim() || randomToken();
}
if (authMode === "password") {
const password = guardCancel(
await text({
message: "Gateway password",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
gatewayPassword = String(password).trim();
}
const authConfig = buildGatewayAuthConfig({
existing: next.gateway?.auth,
mode: authMode,
token: gatewayToken,
password: gatewayPassword,
});
next = {
...next,
gateway: {
...next.gateway,
mode: "local",
port,
bind,
auth: authConfig,
...(customBindHost && { customBindHost }),
tailscale: {
...next.gateway?.tailscale,
mode: tailscaleMode,
resetOnExit: tailscaleResetOnExit,
},
},
};
return { config: next, port, token: gatewayToken };
}