feat: add gateway dev config options
This commit is contained in:
@@ -85,6 +85,7 @@
|
|||||||
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
|
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
|
||||||
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
|
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
|
||||||
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
|
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
|
||||||
|
- Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md). — thanks @steipete
|
||||||
|
|
||||||
## 2026.1.8
|
## 2026.1.8
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ Notes:
|
|||||||
- `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
|
- `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
|
||||||
- `--tailscale <off|serve|funnel>`: expose the Gateway via Tailscale.
|
- `--tailscale <off|serve|funnel>`: expose the Gateway via Tailscale.
|
||||||
- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
|
- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
|
||||||
|
- `--dev`: create a dev config + workspace if missing (skips BOOTSTRAP.md).
|
||||||
|
- `--reset`: recreate the dev config (requires `--dev`).
|
||||||
- `--force`: kill any existing listener on the selected port before starting.
|
- `--force`: kill any existing listener on the selected port before starting.
|
||||||
- `--verbose`: verbose logs.
|
- `--verbose`: verbose logs.
|
||||||
- `--claude-cli-logs`: only show claude-cli logs in the console (and enable its stdout/stderr).
|
- `--claude-cli-logs`: only show claude-cli logs in the console (and enable its stdout/stderr).
|
||||||
@@ -82,6 +84,25 @@ clawdbot gateway status
|
|||||||
clawdbot gateway status --json
|
clawdbot gateway status --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Remote over SSH (Mac app parity)
|
||||||
|
|
||||||
|
The macOS app “Remote over SSH” mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:<port>`.
|
||||||
|
|
||||||
|
CLI equivalent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot gateway status --ssh steipete@peters-mac-studio-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--ssh <target>`: `user@host` or `user@host:port` (port defaults to `22`).
|
||||||
|
- `--ssh-identity <path>`: identity file.
|
||||||
|
- `--ssh-auto`: pick the first discovered bridge host as SSH target (LAN/WAB only).
|
||||||
|
|
||||||
|
Config (optional, used as defaults):
|
||||||
|
- `gateway.remote.sshTarget`
|
||||||
|
- `gateway.remote.sshIdentity`
|
||||||
|
|
||||||
### `gateway call <method>`
|
### `gateway call <method>`
|
||||||
|
|
||||||
Low-level RPC helper.
|
Low-level RPC helper.
|
||||||
|
|||||||
@@ -409,6 +409,8 @@ Options:
|
|||||||
- `--tailscale <off|serve|funnel>`
|
- `--tailscale <off|serve|funnel>`
|
||||||
- `--tailscale-reset-on-exit`
|
- `--tailscale-reset-on-exit`
|
||||||
- `--allow-unconfigured`
|
- `--allow-unconfigured`
|
||||||
|
- `--dev`
|
||||||
|
- `--reset`
|
||||||
- `--force` (kill existing listener on port)
|
- `--force` (kill existing listener on port)
|
||||||
- `--verbose`
|
- `--verbose`
|
||||||
- `--ws-log <auto|full|compact>`
|
- `--ws-log <auto|full|compact>`
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
|
||||||
import { gatewayStatusCommand } from "../commands/gateway-status.js";
|
import { gatewayStatusCommand } from "../commands/gateway-status.js";
|
||||||
|
import { moveToTrash } from "../commands/onboard-helpers.js";
|
||||||
import {
|
import {
|
||||||
CONFIG_PATH_CLAWDBOT,
|
CONFIG_PATH_CLAWDBOT,
|
||||||
type GatewayAuthMode,
|
type GatewayAuthMode,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
resolveGatewayPort,
|
resolveGatewayPort,
|
||||||
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
GATEWAY_LAUNCH_AGENT_LABEL,
|
GATEWAY_LAUNCH_AGENT_LABEL,
|
||||||
@@ -34,6 +39,7 @@ import {
|
|||||||
} from "../logging.js";
|
} from "../logging.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
|
import { resolveUserPath } from "../utils.js";
|
||||||
import { forceFreePortAndWait } from "./ports.js";
|
import { forceFreePortAndWait } from "./ports.js";
|
||||||
import { withProgress } from "./progress.js";
|
import { withProgress } from "./progress.js";
|
||||||
|
|
||||||
@@ -62,6 +68,8 @@ type GatewayRunOpts = {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
rawStream?: boolean;
|
rawStream?: boolean;
|
||||||
rawStreamPath?: unknown;
|
rawStreamPath?: unknown;
|
||||||
|
dev?: boolean;
|
||||||
|
reset?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GatewayRunParams = {
|
type GatewayRunParams = {
|
||||||
@@ -69,6 +77,33 @@ type GatewayRunParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const gatewayLog = createSubsystemLogger("gateway");
|
const gatewayLog = createSubsystemLogger("gateway");
|
||||||
|
const DEV_IDENTITY_NAME = "Clawdbot Dev";
|
||||||
|
const DEV_IDENTITY_THEME = "helpful debug droid";
|
||||||
|
const DEV_IDENTITY_EMOJI = "🤖";
|
||||||
|
const DEV_AGENT_WORKSPACE_SUFFIX = "dev";
|
||||||
|
const DEV_AGENTS_TEMPLATE = `# AGENTS.md - Clawdbot Dev Workspace
|
||||||
|
|
||||||
|
Default dev workspace for clawdbot gateway --dev.
|
||||||
|
|
||||||
|
- Keep replies concise and direct.
|
||||||
|
- Prefer observable debugging steps and logs.
|
||||||
|
- Avoid destructive actions unless asked.
|
||||||
|
`;
|
||||||
|
const DEV_SOUL_TEMPLATE = `# SOUL.md - Dev Persona
|
||||||
|
|
||||||
|
Helpful robotic debugging assistant.
|
||||||
|
|
||||||
|
- Concise, structured answers.
|
||||||
|
- Ask for missing context before guessing.
|
||||||
|
- Prefer reproducible steps and logs.
|
||||||
|
`;
|
||||||
|
const DEV_IDENTITY_TEMPLATE = `# IDENTITY.md - Agent Identity
|
||||||
|
|
||||||
|
- Name: ${DEV_IDENTITY_NAME}
|
||||||
|
- Creature: debug droid
|
||||||
|
- Vibe: ${DEV_IDENTITY_THEME}
|
||||||
|
- Emoji: ${DEV_IDENTITY_EMOJI}
|
||||||
|
`;
|
||||||
|
|
||||||
type GatewayRunSignalAction = "stop" | "restart";
|
type GatewayRunSignalAction = "stop" | "restart";
|
||||||
|
|
||||||
@@ -93,6 +128,72 @@ const toOptionString = (value: unknown): string | undefined => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveDevWorkspaceDir = (
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string => {
|
||||||
|
const baseDir = resolveDefaultAgentWorkspaceDir(env, os.homedir);
|
||||||
|
return `${baseDir}-${DEV_AGENT_WORKSPACE_SUFFIX}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function writeFileIfMissing(filePath: string, content: string) {
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(filePath, content, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
flag: "wx",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const anyErr = err as { code?: string };
|
||||||
|
if (anyErr.code !== "EEXIST") throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDevWorkspace(dir: string) {
|
||||||
|
const resolvedDir = resolveUserPath(dir);
|
||||||
|
await fs.promises.mkdir(resolvedDir, { recursive: true });
|
||||||
|
await writeFileIfMissing(
|
||||||
|
path.join(resolvedDir, "AGENTS.md"),
|
||||||
|
DEV_AGENTS_TEMPLATE,
|
||||||
|
);
|
||||||
|
await writeFileIfMissing(
|
||||||
|
path.join(resolvedDir, "SOUL.md"),
|
||||||
|
DEV_SOUL_TEMPLATE,
|
||||||
|
);
|
||||||
|
await writeFileIfMissing(
|
||||||
|
path.join(resolvedDir, "IDENTITY.md"),
|
||||||
|
DEV_IDENTITY_TEMPLATE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
|
||||||
|
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
|
||||||
|
if (opts.reset && configExists) {
|
||||||
|
await moveToTrash(CONFIG_PATH_CLAWDBOT, defaultRuntime);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldWrite = opts.reset || !configExists;
|
||||||
|
if (!shouldWrite) return;
|
||||||
|
|
||||||
|
const workspace = resolveDevWorkspaceDir();
|
||||||
|
await writeConfigFile({
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
bind: "loopback",
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
workspace,
|
||||||
|
skipBootstrap: true,
|
||||||
|
},
|
||||||
|
identity: {
|
||||||
|
name: DEV_IDENTITY_NAME,
|
||||||
|
theme: DEV_IDENTITY_THEME,
|
||||||
|
emoji: DEV_IDENTITY_EMOJI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await ensureDevWorkspace(workspace);
|
||||||
|
defaultRuntime.log(`Dev config ready: ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
|
defaultRuntime.log(`Dev workspace ready: ${resolveUserPath(workspace)}`);
|
||||||
|
}
|
||||||
|
|
||||||
type GatewayDiscoverOpts = {
|
type GatewayDiscoverOpts = {
|
||||||
timeout?: string;
|
timeout?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
@@ -403,6 +504,11 @@ async function runGatewayCommand(
|
|||||||
opts: GatewayRunOpts,
|
opts: GatewayRunOpts,
|
||||||
params: GatewayRunParams = {},
|
params: GatewayRunParams = {},
|
||||||
) {
|
) {
|
||||||
|
if (opts.reset && !opts.dev) {
|
||||||
|
defaultRuntime.error("Use --reset with --dev.");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (params.legacyTokenEnv) {
|
if (params.legacyTokenEnv) {
|
||||||
const legacyToken = process.env.CLAWDIS_GATEWAY_TOKEN;
|
const legacyToken = process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||||
if (legacyToken && !process.env.CLAWDBOT_GATEWAY_TOKEN) {
|
if (legacyToken && !process.env.CLAWDBOT_GATEWAY_TOKEN) {
|
||||||
@@ -439,6 +545,10 @@ async function runGatewayCommand(
|
|||||||
process.env.CLAWDBOT_RAW_STREAM_PATH = rawStreamPath;
|
process.env.CLAWDBOT_RAW_STREAM_PATH = rawStreamPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.dev) {
|
||||||
|
await ensureDevGatewayConfig({ reset: Boolean(opts.reset) });
|
||||||
|
}
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const portOverride = parsePort(opts.port);
|
const portOverride = parsePort(opts.port);
|
||||||
if (opts.port !== undefined && portOverride === null) {
|
if (opts.port !== undefined && portOverride === null) {
|
||||||
@@ -692,6 +802,12 @@ function addGatewayRunCommand(
|
|||||||
"Allow gateway start without gateway.mode=local in config",
|
"Allow gateway start without gateway.mode=local in config",
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
.option(
|
||||||
|
"--dev",
|
||||||
|
"Create a dev config + workspace if missing (no BOOTSTRAP.md)",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.option("--reset", "Recreate dev config (requires --dev)", false)
|
||||||
.option(
|
.option(
|
||||||
"--force",
|
"--force",
|
||||||
"Kill any existing listener on the target port before starting",
|
"Kill any existing listener on the target port before starting",
|
||||||
@@ -825,6 +941,16 @@ export function registerGatewayCli(program: Command) {
|
|||||||
"--url <url>",
|
"--url <url>",
|
||||||
"Explicit Gateway WebSocket URL (still probes localhost)",
|
"Explicit Gateway WebSocket URL (still probes localhost)",
|
||||||
)
|
)
|
||||||
|
.option(
|
||||||
|
"--ssh <target>",
|
||||||
|
"SSH target for remote gateway tunnel (user@host or user@host:port)",
|
||||||
|
)
|
||||||
|
.option("--ssh-identity <path>", "SSH identity file path")
|
||||||
|
.option(
|
||||||
|
"--ssh-auto",
|
||||||
|
"Try to derive an SSH target from Bonjour discovery",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.option("--token <token>", "Gateway token (applies to all probes)")
|
.option("--token <token>", "Gateway token (applies to all probes)")
|
||||||
.option("--password <password>", "Gateway password (applies to all probes)")
|
.option("--password <password>", "Gateway password (applies to all probes)")
|
||||||
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
|
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ const loadConfig = vi.fn(() => ({
|
|||||||
const resolveGatewayPort = vi.fn(() => 18789);
|
const resolveGatewayPort = vi.fn(() => 18789);
|
||||||
const discoverGatewayBeacons = vi.fn(async () => []);
|
const discoverGatewayBeacons = vi.fn(async () => []);
|
||||||
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
|
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
|
||||||
|
const sshStop = vi.fn(async () => {});
|
||||||
|
const startSshPortForward = vi.fn(async () => ({
|
||||||
|
parsedTarget: { user: "me", host: "studio", port: 22 },
|
||||||
|
localPort: 18789,
|
||||||
|
remotePort: 18789,
|
||||||
|
pid: 123,
|
||||||
|
stderr: [],
|
||||||
|
stop: sshStop,
|
||||||
|
}));
|
||||||
const probeGateway = vi.fn(async ({ url }: { url: string }) => {
|
const probeGateway = vi.fn(async ({ url }: { url: string }) => {
|
||||||
if (url.includes("127.0.0.1")) {
|
if (url.includes("127.0.0.1")) {
|
||||||
return {
|
return {
|
||||||
@@ -71,6 +80,10 @@ vi.mock("../infra/tailnet.js", () => ({
|
|||||||
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
|
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../infra/ssh-tunnel.js", () => ({
|
||||||
|
startSshPortForward: (opts: unknown) => startSshPortForward(opts),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../gateway/probe.js", () => ({
|
vi.mock("../gateway/probe.js", () => ({
|
||||||
probeGateway: (opts: unknown) => probeGateway(opts),
|
probeGateway: (opts: unknown) => probeGateway(opts),
|
||||||
}));
|
}));
|
||||||
@@ -128,4 +141,36 @@ describe("gateway-status command", () => {
|
|||||||
expect(targets[0]?.health).toBeTruthy();
|
expect(targets[0]?.health).toBeTruthy();
|
||||||
expect(targets[0]?.summary).toBeTruthy();
|
expect(targets[0]?.summary).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports SSH tunnel targets", async () => {
|
||||||
|
const runtimeLogs: string[] = [];
|
||||||
|
const runtime = {
|
||||||
|
log: (msg: string) => runtimeLogs.push(msg),
|
||||||
|
error: (_msg: string) => {},
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`__exit__:${code}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
startSshPortForward.mockClear();
|
||||||
|
sshStop.mockClear();
|
||||||
|
probeGateway.mockClear();
|
||||||
|
|
||||||
|
const { gatewayStatusCommand } = await import("./gateway-status.js");
|
||||||
|
await gatewayStatusCommand(
|
||||||
|
{ timeout: "1000", json: true, ssh: "me@studio" },
|
||||||
|
runtime as unknown as import("../runtime.js").RuntimeEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(startSshPortForward).toHaveBeenCalledTimes(1);
|
||||||
|
expect(probeGateway).toHaveBeenCalled();
|
||||||
|
expect(sshStop).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const targets = parsed.targets as Array<Record<string, unknown>>;
|
||||||
|
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,17 +3,25 @@ import { loadConfig, resolveGatewayPort } from "../config/config.js";
|
|||||||
import type { ClawdbotConfig, ConfigFileSnapshot } from "../config/types.js";
|
import type { ClawdbotConfig, ConfigFileSnapshot } from "../config/types.js";
|
||||||
import { type GatewayProbeResult, probeGateway } from "../gateway/probe.js";
|
import { type GatewayProbeResult, probeGateway } from "../gateway/probe.js";
|
||||||
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
||||||
|
import { startSshPortForward } from "../infra/ssh-tunnel.js";
|
||||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
type TargetKind = "explicit" | "configRemote" | "localLoopback";
|
type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel";
|
||||||
|
|
||||||
type GatewayStatusTarget = {
|
type GatewayStatusTarget = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: TargetKind;
|
kind: TargetKind;
|
||||||
url: string;
|
url: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
tunnel?: {
|
||||||
|
kind: "ssh";
|
||||||
|
target: string;
|
||||||
|
localPort: number;
|
||||||
|
remotePort: number;
|
||||||
|
pid: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type GatewayConfigSummary = {
|
type GatewayConfigSummary = {
|
||||||
@@ -121,9 +129,17 @@ function resolveTargets(
|
|||||||
|
|
||||||
function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number {
|
function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number {
|
||||||
if (kind === "localLoopback") return Math.min(800, overallMs);
|
if (kind === "localLoopback") return Math.min(800, overallMs);
|
||||||
|
if (kind === "sshTunnel") return Math.min(2000, overallMs);
|
||||||
return Math.min(1500, overallMs);
|
return Math.min(1500, overallMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeSshTarget(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return trimmed.replace(/^ssh\s+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAuthForTarget(
|
function resolveAuthForTarget(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
target: GatewayStatusTarget,
|
target: GatewayStatusTarget,
|
||||||
@@ -292,6 +308,8 @@ function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) {
|
|||||||
const kindLabel =
|
const kindLabel =
|
||||||
target.kind === "localLoopback"
|
target.kind === "localLoopback"
|
||||||
? "Local loopback"
|
? "Local loopback"
|
||||||
|
: target.kind === "sshTunnel"
|
||||||
|
? "Remote over SSH"
|
||||||
: target.kind === "configRemote"
|
: target.kind === "configRemote"
|
||||||
? target.active
|
? target.active
|
||||||
? "Remote (configured)"
|
? "Remote (configured)"
|
||||||
@@ -319,6 +337,9 @@ export async function gatewayStatusCommand(
|
|||||||
password?: string;
|
password?: string;
|
||||||
timeout?: unknown;
|
timeout?: unknown;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
|
ssh?: string;
|
||||||
|
sshIdentity?: string;
|
||||||
|
sshAuto?: boolean;
|
||||||
},
|
},
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
@@ -327,7 +348,7 @@ export async function gatewayStatusCommand(
|
|||||||
const rich = isRich() && opts.json !== true;
|
const rich = isRich() && opts.json !== true;
|
||||||
const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3000);
|
const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3000);
|
||||||
|
|
||||||
const targets = resolveTargets(cfg, opts.url);
|
const baseTargets = resolveTargets(cfg, opts.url);
|
||||||
const network = buildNetworkHints(cfg);
|
const network = buildNetworkHints(cfg);
|
||||||
|
|
||||||
const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs);
|
const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs);
|
||||||
@@ -335,19 +356,16 @@ export async function gatewayStatusCommand(
|
|||||||
timeoutMs: discoveryTimeoutMs,
|
timeoutMs: discoveryTimeoutMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const probePromises = targets.map(async (target) => {
|
let sshTarget =
|
||||||
const auth = resolveAuthForTarget(cfg, target, {
|
sanitizeSshTarget(opts.ssh) ??
|
||||||
token: typeof opts.token === "string" ? opts.token : undefined,
|
sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
|
||||||
password: typeof opts.password === "string" ? opts.password : undefined,
|
const sshIdentity =
|
||||||
});
|
sanitizeSshTarget(opts.sshIdentity) ??
|
||||||
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind);
|
sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
|
||||||
const probe = await probeGateway({ url: target.url, auth, timeoutMs });
|
const remotePort = resolveGatewayPort(cfg);
|
||||||
const configSummary = probe.configSnapshot
|
|
||||||
? extractConfigSummary(probe.configSnapshot)
|
let sshTunnelError: string | null = null;
|
||||||
: null;
|
let sshTunnelStarted = false;
|
||||||
const self = pickGatewaySelfPresence(probe.presence);
|
|
||||||
return { target, probe, configSummary, self };
|
|
||||||
});
|
|
||||||
|
|
||||||
const { discovery, probed } = await withProgress(
|
const { discovery, probed } = await withProgress(
|
||||||
{
|
{
|
||||||
@@ -356,15 +374,111 @@ export async function gatewayStatusCommand(
|
|||||||
enabled: opts.json !== true,
|
enabled: opts.json !== true,
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const [discoveryRes, probesRes] = await Promise.allSettled([
|
const tryStartTunnel = async () => {
|
||||||
discoveryPromise,
|
if (!sshTarget) return null;
|
||||||
Promise.all(probePromises),
|
try {
|
||||||
]);
|
const tunnel = await startSshPortForward({
|
||||||
return {
|
target: sshTarget,
|
||||||
discovery:
|
identity: sshIdentity ?? undefined,
|
||||||
discoveryRes.status === "fulfilled" ? discoveryRes.value : [],
|
localPortPreferred: remotePort,
|
||||||
probed: probesRes.status === "fulfilled" ? probesRes.value : [],
|
remotePort,
|
||||||
|
timeoutMs: Math.min(1500, overallTimeoutMs),
|
||||||
|
});
|
||||||
|
sshTunnelStarted = true;
|
||||||
|
return tunnel;
|
||||||
|
} catch (err) {
|
||||||
|
sshTunnelError = err instanceof Error ? err.message : String(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const discoveryTask = discoveryPromise.catch(() => []);
|
||||||
|
const tunnelTask = sshTarget ? tryStartTunnel() : Promise.resolve(null);
|
||||||
|
|
||||||
|
const [discovery, tunnelFirst] = await Promise.all([
|
||||||
|
discoveryTask,
|
||||||
|
tunnelTask,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!sshTarget && opts.sshAuto) {
|
||||||
|
const user = process.env.USER?.trim() || "";
|
||||||
|
const candidates = discovery
|
||||||
|
.map((b) => {
|
||||||
|
const host = b.tailnetDns || b.lanHost || b.host;
|
||||||
|
if (!host?.trim()) return null;
|
||||||
|
const sshPort =
|
||||||
|
typeof b.sshPort === "number" && b.sshPort > 0 ? b.sshPort : 22;
|
||||||
|
const base = user ? `${user}@${host.trim()}` : host.trim();
|
||||||
|
return sshPort !== 22 ? `${base}:${sshPort}` : base;
|
||||||
|
})
|
||||||
|
.filter((x): x is string => Boolean(x));
|
||||||
|
if (candidates.length > 0) sshTarget = candidates[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tunnel =
|
||||||
|
tunnelFirst ||
|
||||||
|
(sshTarget && !sshTunnelStarted && !sshTunnelError
|
||||||
|
? await tryStartTunnel()
|
||||||
|
: null);
|
||||||
|
|
||||||
|
const tunnelTarget: GatewayStatusTarget | null = tunnel
|
||||||
|
? {
|
||||||
|
id: "sshTunnel",
|
||||||
|
kind: "sshTunnel",
|
||||||
|
url: `ws://127.0.0.1:${tunnel.localPort}`,
|
||||||
|
active: true,
|
||||||
|
tunnel: {
|
||||||
|
kind: "ssh",
|
||||||
|
target: sshTarget ?? "",
|
||||||
|
localPort: tunnel.localPort,
|
||||||
|
remotePort,
|
||||||
|
pid: tunnel.pid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const targets: GatewayStatusTarget[] = tunnelTarget
|
||||||
|
? [
|
||||||
|
tunnelTarget,
|
||||||
|
...baseTargets.filter((t) => t.url !== tunnelTarget.url),
|
||||||
|
]
|
||||||
|
: baseTargets;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const probed = await Promise.all(
|
||||||
|
targets.map(async (target) => {
|
||||||
|
const auth = resolveAuthForTarget(cfg, target, {
|
||||||
|
token: typeof opts.token === "string" ? opts.token : undefined,
|
||||||
|
password:
|
||||||
|
typeof opts.password === "string" ? opts.password : undefined,
|
||||||
|
});
|
||||||
|
const timeoutMs = resolveProbeBudgetMs(
|
||||||
|
overallTimeoutMs,
|
||||||
|
target.kind,
|
||||||
|
);
|
||||||
|
const probe = await probeGateway({
|
||||||
|
url: target.url,
|
||||||
|
auth,
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
const configSummary = probe.configSnapshot
|
||||||
|
? extractConfigSummary(probe.configSnapshot)
|
||||||
|
: null;
|
||||||
|
const self = pickGatewaySelfPresence(probe.presence);
|
||||||
|
return { target, probe, configSummary, self };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { discovery, probed };
|
||||||
|
} finally {
|
||||||
|
if (tunnel) {
|
||||||
|
try {
|
||||||
|
await tunnel.stop();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -373,6 +487,7 @@ export async function gatewayStatusCommand(
|
|||||||
const multipleGateways = reachable.length > 1;
|
const multipleGateways = reachable.length > 1;
|
||||||
const primary =
|
const primary =
|
||||||
reachable.find((p) => p.target.kind === "explicit") ??
|
reachable.find((p) => p.target.kind === "explicit") ??
|
||||||
|
reachable.find((p) => p.target.kind === "sshTunnel") ??
|
||||||
reachable.find((p) => p.target.kind === "configRemote") ??
|
reachable.find((p) => p.target.kind === "configRemote") ??
|
||||||
reachable.find((p) => p.target.kind === "localLoopback") ??
|
reachable.find((p) => p.target.kind === "localLoopback") ??
|
||||||
null;
|
null;
|
||||||
@@ -382,6 +497,14 @@ export async function gatewayStatusCommand(
|
|||||||
message: string;
|
message: string;
|
||||||
targetIds?: string[];
|
targetIds?: string[];
|
||||||
}> = [];
|
}> = [];
|
||||||
|
if (sshTarget && !sshTunnelStarted) {
|
||||||
|
warnings.push({
|
||||||
|
code: "ssh_tunnel_failed",
|
||||||
|
message: sshTunnelError
|
||||||
|
? `SSH tunnel failed: ${String(sshTunnelError)}`
|
||||||
|
: "SSH tunnel failed to start; falling back to direct probes.",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (multipleGateways) {
|
if (multipleGateways) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
code: "multiple_gateways",
|
code: "multiple_gateways",
|
||||||
@@ -427,6 +550,7 @@ export async function gatewayStatusCommand(
|
|||||||
kind: p.target.kind,
|
kind: p.target.kind,
|
||||||
url: p.target.url,
|
url: p.target.url,
|
||||||
active: p.target.active,
|
active: p.target.active,
|
||||||
|
tunnel: p.target.tunnel ?? null,
|
||||||
connect: {
|
connect: {
|
||||||
ok: p.probe.ok,
|
ok: p.probe.ok,
|
||||||
latencyMs: p.probe.connectLatencyMs,
|
latencyMs: p.probe.connectLatencyMs,
|
||||||
@@ -486,6 +610,11 @@ export async function gatewayStatusCommand(
|
|||||||
for (const p of probed) {
|
for (const p of probed) {
|
||||||
runtime.log(renderTargetHeader(p.target, rich));
|
runtime.log(renderTargetHeader(p.target, rich));
|
||||||
runtime.log(` ${renderProbeSummaryLine(p.probe, rich)}`);
|
runtime.log(` ${renderProbeSummaryLine(p.probe, rich)}`);
|
||||||
|
if (p.target.tunnel?.kind === "ssh") {
|
||||||
|
runtime.log(
|
||||||
|
` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, p.target.tunnel.target)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (p.probe.ok && p.self) {
|
if (p.probe.ok && p.self) {
|
||||||
const host = p.self.host ?? "unknown";
|
const host = p.self.host ?? "unknown";
|
||||||
const ip = p.self.ip ? ` (${p.self.ip})` : "";
|
const ip = p.self.ip ? ` (${p.self.ip})` : "";
|
||||||
|
|||||||
@@ -875,6 +875,13 @@ export type GatewayTailscaleConfig = {
|
|||||||
export type GatewayRemoteConfig = {
|
export type GatewayRemoteConfig = {
|
||||||
/** Remote Gateway WebSocket URL (ws:// or wss://). */
|
/** Remote Gateway WebSocket URL (ws:// or wss://). */
|
||||||
url?: string;
|
url?: string;
|
||||||
|
/**
|
||||||
|
* Remote gateway over SSH, forwarding the gateway port to localhost.
|
||||||
|
* Format: "user@host" or "user@host:port" (port defaults to 22).
|
||||||
|
*/
|
||||||
|
sshTarget?: string;
|
||||||
|
/** Optional SSH identity file path. */
|
||||||
|
sshIdentity?: string;
|
||||||
/** Token for remote auth (when the gateway requires token auth). */
|
/** Token for remote auth (when the gateway requires token auth). */
|
||||||
token?: string;
|
token?: string;
|
||||||
/** Password for remote auth (when the gateway requires password auth). */
|
/** Password for remote auth (when the gateway requires password auth). */
|
||||||
|
|||||||
202
src/infra/ssh-tunnel.ts
Normal file
202
src/infra/ssh-tunnel.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
import { ensurePortAvailable } from "./ports.js";
|
||||||
|
|
||||||
|
export type SshParsedTarget = {
|
||||||
|
user?: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SshTunnel = {
|
||||||
|
parsedTarget: SshParsedTarget;
|
||||||
|
localPort: number;
|
||||||
|
remotePort: number;
|
||||||
|
pid: number | null;
|
||||||
|
stderr: string[];
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
||||||
|
return Boolean(err && typeof err === "object" && "code" in err);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSshTarget(raw: string): SshParsedTarget | null {
|
||||||
|
const trimmed = raw.trim().replace(/^ssh\s+/, "");
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const [userPart, hostPart] = trimmed.includes("@")
|
||||||
|
? ((): [string | undefined, string] => {
|
||||||
|
const idx = trimmed.indexOf("@");
|
||||||
|
const user = trimmed.slice(0, idx).trim();
|
||||||
|
const host = trimmed.slice(idx + 1).trim();
|
||||||
|
return [user || undefined, host];
|
||||||
|
})()
|
||||||
|
: [undefined, trimmed];
|
||||||
|
|
||||||
|
const colonIdx = hostPart.lastIndexOf(":");
|
||||||
|
if (colonIdx > 0 && colonIdx < hostPart.length - 1) {
|
||||||
|
const host = hostPart.slice(0, colonIdx).trim();
|
||||||
|
const portRaw = hostPart.slice(colonIdx + 1).trim();
|
||||||
|
const port = Number.parseInt(portRaw, 10);
|
||||||
|
if (!host || !Number.isFinite(port) || port <= 0) return null;
|
||||||
|
return { user: userPart, host, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostPart) return null;
|
||||||
|
return { user: userPart, host: hostPart, port: 22 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickEphemeralPort(): Promise<number> {
|
||||||
|
return await new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const addr = server.address();
|
||||||
|
server.close(() => {
|
||||||
|
if (!addr || typeof addr === "string") {
|
||||||
|
reject(new Error("failed to allocate a local port"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(addr.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canConnectLocal(port: number): Promise<boolean> {
|
||||||
|
return await new Promise<boolean>((resolve) => {
|
||||||
|
const socket = net.connect({ host: "127.0.0.1", port });
|
||||||
|
const done = (ok: boolean) => {
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
resolve(ok);
|
||||||
|
};
|
||||||
|
socket.once("connect", () => done(true));
|
||||||
|
socket.once("error", () => done(false));
|
||||||
|
socket.setTimeout(250, () => done(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLocalListener(
|
||||||
|
port: number,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
if (await canConnectLocal(port)) return;
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
throw new Error(`ssh tunnel did not start listening on localhost:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSshPortForward(opts: {
|
||||||
|
target: string;
|
||||||
|
identity?: string;
|
||||||
|
localPortPreferred: number;
|
||||||
|
remotePort: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<SshTunnel> {
|
||||||
|
const parsed = parseSshTarget(opts.target);
|
||||||
|
if (!parsed) throw new Error(`invalid SSH target: ${opts.target}`);
|
||||||
|
|
||||||
|
let localPort = opts.localPortPreferred;
|
||||||
|
try {
|
||||||
|
await ensurePortAvailable(localPort);
|
||||||
|
} catch (err) {
|
||||||
|
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||||
|
localPort = await pickEphemeralPort();
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userHost = parsed.user ? `${parsed.user}@${parsed.host}` : parsed.host;
|
||||||
|
const args = [
|
||||||
|
"-N",
|
||||||
|
"-L",
|
||||||
|
`${localPort}:127.0.0.1:${opts.remotePort}`,
|
||||||
|
"-p",
|
||||||
|
String(parsed.port),
|
||||||
|
"-o",
|
||||||
|
"ExitOnForwardFailure=yes",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=accept-new",
|
||||||
|
"-o",
|
||||||
|
"UpdateHostKeys=yes",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=5",
|
||||||
|
"-o",
|
||||||
|
"ServerAliveInterval=15",
|
||||||
|
"-o",
|
||||||
|
"ServerAliveCountMax=3",
|
||||||
|
];
|
||||||
|
if (opts.identity?.trim()) {
|
||||||
|
args.push("-i", opts.identity.trim());
|
||||||
|
}
|
||||||
|
args.push(userHost);
|
||||||
|
|
||||||
|
const stderr: string[] = [];
|
||||||
|
const child = spawn("/usr/bin/ssh", args, {
|
||||||
|
stdio: ["ignore", "ignore", "pipe"],
|
||||||
|
});
|
||||||
|
child.stderr?.setEncoding("utf8");
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
const lines = String(chunk)
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
stderr.push(...lines);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
if (child.killed) return;
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
} finally {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
child.once("exit", () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
waitForLocalListener(localPort, Math.max(250, opts.timeoutMs)),
|
||||||
|
new Promise<void>((_, reject) => {
|
||||||
|
child.once("exit", (code, signal) => {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`ssh exited (${code ?? "null"}${signal ? `/${signal}` : ""})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
await stop();
|
||||||
|
const suffix = stderr.length > 0 ? `\n${stderr.join("\n")}` : "";
|
||||||
|
throw new Error(
|
||||||
|
`${err instanceof Error ? err.message : String(err)}${suffix}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parsedTarget: parsed,
|
||||||
|
localPort,
|
||||||
|
remotePort: opts.remotePort,
|
||||||
|
pid: typeof child.pid === "number" ? child.pid : null,
|
||||||
|
stderr,
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user