feat(FN-2976): detect and display external Tailscale funnel tunnels in remo
Merges external Tailscale funnel detection (FN-2976) — adds types, detection logic, status inclusion, kill flow, and a dedicated UI panel for funnel processes started outside Fusion — alongside custom AI providers API routes and a new settings UI section (FN-2965). Fusion-Task-Id: FN-2976
This commit is contained in:
@@ -1,6 +1,21 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockExecFile, mockExec } = vi.hoisted(() => ({
|
||||
mockExecFile: vi.fn(),
|
||||
mockExec: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
execFile: mockExecFile,
|
||||
exec: mockExec,
|
||||
};
|
||||
});
|
||||
|
||||
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
|
||||
import type { TunnelProviderConfig } from "../remote-access/types.js";
|
||||
|
||||
@@ -62,6 +77,23 @@ describe("TunnelProcessManager", () => {
|
||||
beforeEach(() => {
|
||||
pid = 1000;
|
||||
children = new Map();
|
||||
mockExecFile.mockReset();
|
||||
mockExec.mockReset();
|
||||
mockExecFile.mockImplementation((_command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
|
||||
: maybeCallback;
|
||||
callback?.(null, "", "");
|
||||
return {} as never;
|
||||
});
|
||||
mockExec.mockImplementation((_command: string, optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
|
||||
: maybeCallback;
|
||||
callback?.(null, "", "");
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
processKillSpy = vi.spyOn(process, "kill") as unknown as ReturnType<typeof vi.spyOn>;
|
||||
processKillSpy.mockImplementation((...args: unknown[]) => {
|
||||
const targetPid = Number(args[0]);
|
||||
@@ -308,4 +340,96 @@ describe("TunnelProcessManager", () => {
|
||||
const logText = logs.join("\n");
|
||||
expect(logText).not.toContain("secret-token");
|
||||
});
|
||||
|
||||
it("returns null when managed tunnel is already running during external detection", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
const child = new FakeChildProcess(++pid);
|
||||
children.set(child.pid, child);
|
||||
return child as never;
|
||||
},
|
||||
});
|
||||
|
||||
await manager.start("tailscale", {
|
||||
provider: "tailscale",
|
||||
executablePath: "tailscale",
|
||||
args: ["funnel", "4040"],
|
||||
});
|
||||
[...children.values()][0].emitStdout("Available on the internet: https://node.ts.net/");
|
||||
await vi.waitFor(() => expect(manager.getStatus().state).toBe("running"));
|
||||
|
||||
await expect(manager.detectExternalFunnel()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns ExternalTunnelInfo when tailscale status has DNSName", async () => {
|
||||
mockExecFile.mockImplementation((_command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
|
||||
: maybeCallback;
|
||||
callback?.(null, "{\"Self\":{\"DNSName\":\"machine.tailnet.ts.net.\"}}", "");
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const manager = new TunnelProcessManager();
|
||||
const detected = await manager.detectExternalFunnel();
|
||||
if (detected !== null) {
|
||||
expect(detected).toEqual({
|
||||
provider: "tailscale",
|
||||
url: "https://machine.tailnet.ts.net/",
|
||||
pid: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when tailscale binary is unavailable", async () => {
|
||||
mockExecFile.mockImplementation((_command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null) => void
|
||||
: maybeCallback;
|
||||
callback?.(new Error("ENOENT"));
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const manager = new TunnelProcessManager();
|
||||
await expect(manager.detectExternalFunnel()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when tailscale status JSON is malformed", async () => {
|
||||
mockExecFile.mockImplementation((_command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string) => void
|
||||
: maybeCallback;
|
||||
callback?.(null, "not-json");
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const manager = new TunnelProcessManager();
|
||||
await expect(manager.detectExternalFunnel()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("killExternalFunnel uses tailscale reset command when available", async () => {
|
||||
const manager = new TunnelProcessManager();
|
||||
await expect(manager.killExternalFunnel()).resolves.toBeUndefined();
|
||||
expect(mockExecFile).toHaveBeenCalledWith("tailscale", ["serve", "reset"], { timeout: 5_000 }, expect.any(Function));
|
||||
});
|
||||
|
||||
it("killExternalFunnel falls back gracefully when tailscale is unavailable", async () => {
|
||||
mockExecFile.mockImplementation((_command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null) => void
|
||||
: maybeCallback;
|
||||
callback?.(new Error("ENOENT"));
|
||||
return {} as never;
|
||||
});
|
||||
mockExec.mockImplementation((_command: string, optionsOrCallback: unknown, maybeCallback?: (error: Error | null) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null) => void
|
||||
: maybeCallback;
|
||||
callback?.(new Error("no pgrep"));
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const manager = new TunnelProcessManager();
|
||||
await expect(manager.killExternalFunnel()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ResearchOrchestrator } from "./research-orchestrator.js";
|
||||
import { ResearchStepRunner } from "./research-step-runner.js";
|
||||
import { TunnelProcessManager } from "./remote-access/tunnel-process-manager.js";
|
||||
import type {
|
||||
ExternalTunnelInfo,
|
||||
TunnelProvider,
|
||||
TunnelProviderConfig,
|
||||
TunnelRestoreDiagnostics,
|
||||
@@ -642,6 +643,36 @@ export class ProjectEngine {
|
||||
return manager.getStatus();
|
||||
}
|
||||
|
||||
async detectExternalTunnel(): Promise<ExternalTunnelInfo | null> {
|
||||
const manager = this.remoteTunnelManager;
|
||||
if (!manager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings = await this.runtime.getTaskStore().getSettings();
|
||||
const provider = settings.remoteAccess?.activeProvider ?? null;
|
||||
if (provider !== "tailscale") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return manager.detectExternalFunnel();
|
||||
}
|
||||
|
||||
async killExternalTunnel(): Promise<void> {
|
||||
const manager = this.remoteTunnelManager;
|
||||
if (!manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await this.runtime.getTaskStore().getSettings();
|
||||
const provider = settings.remoteAccess?.activeProvider ?? null;
|
||||
if (provider !== "tailscale") {
|
||||
return;
|
||||
}
|
||||
|
||||
await manager.killExternalFunnel();
|
||||
}
|
||||
|
||||
/** Get the RoutineRunner (if initialized). */
|
||||
getRoutineRunner(): RoutineRunner | undefined {
|
||||
return this.runtime.getRoutineRunner();
|
||||
|
||||
@@ -7,6 +7,7 @@ export { TunnelProcessManager, type TunnelProcessManagerOptions } from "./tunnel
|
||||
|
||||
export type {
|
||||
CloudflareProviderConfig,
|
||||
ExternalTunnelInfo,
|
||||
ManagedTunnelProcess,
|
||||
PreparedTunnelCommand,
|
||||
TailscaleProviderConfig,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { exec, execFile, spawn, type ChildProcess } from "node:child_process";
|
||||
import type { Readable } from "node:stream";
|
||||
import { promisify } from "node:util";
|
||||
import { remoteTunnelLog } from "../logger.js";
|
||||
import {
|
||||
getTunnelProviderAdapter,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from "./provider-adapters.js";
|
||||
import type {
|
||||
ManagedTunnelProcess,
|
||||
ExternalTunnelInfo,
|
||||
TunnelErrorCode,
|
||||
TunnelLogEntry,
|
||||
TunnelLogLevel,
|
||||
@@ -28,6 +30,8 @@ export interface TunnelProcessManagerOptions {
|
||||
|
||||
const DEFAULT_MAX_LOG_ENTRIES = 400;
|
||||
const DEFAULT_STOP_TIMEOUT_MS = 5_000;
|
||||
const execFileAsync = promisify(execFile);
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
class LineBuffer {
|
||||
private pending = "";
|
||||
@@ -160,6 +164,64 @@ export class TunnelProcessManager extends EventEmitter implements TunnelManager
|
||||
});
|
||||
}
|
||||
|
||||
async detectExternalFunnel(): Promise<ExternalTunnelInfo | null> {
|
||||
if (this.processHandle || this.status.state === "starting" || this.status.state === "running") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync("tailscale", ["status", "--json"], { timeout: 3_000 });
|
||||
const data = JSON.parse(String(stdout)) as { Self?: { DNSName?: string } };
|
||||
const dnsName = data.Self?.DNSName?.replace(/\.$/, "");
|
||||
if (!dnsName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "tailscale",
|
||||
url: `https://${dnsName}/`,
|
||||
pid: null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async killExternalFunnel(): Promise<void> {
|
||||
const resetCommands: Array<{ command: string; args: string[] }> = [
|
||||
{ command: "tailscale", args: ["serve", "reset"] },
|
||||
{ command: "tailscale", args: ["funnel", "reset"] },
|
||||
{ command: "tailscale", args: ["funnel", "off"] },
|
||||
];
|
||||
|
||||
for (const resetCommand of resetCommands) {
|
||||
try {
|
||||
await execFileAsync(resetCommand.command, resetCommand.args, { timeout: 5_000 });
|
||||
return;
|
||||
} catch {
|
||||
// continue to next strategy
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync("pgrep -f \"tailscale funnel\"", { timeout: 5_000 });
|
||||
const pids = stdout
|
||||
.split(/\s+/)
|
||||
.map((value) => Number(value.trim()))
|
||||
.filter((value) => Number.isInteger(value) && value > 0);
|
||||
|
||||
await Promise.all(pids.map(async (pid) => {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
// ignore if process already stopped
|
||||
}
|
||||
}));
|
||||
} catch {
|
||||
// tailscale may not be installed or no matching process may exist
|
||||
}
|
||||
}
|
||||
|
||||
async switchProvider(target: TunnelProvider, config: TunnelProviderConfig): Promise<void> {
|
||||
return this.runExclusive(async () => {
|
||||
const previousProvider = this.status.provider;
|
||||
|
||||
@@ -37,6 +37,12 @@ export interface TunnelStatusSnapshot {
|
||||
lastError: TunnelError | null;
|
||||
}
|
||||
|
||||
export interface ExternalTunnelInfo {
|
||||
provider: TunnelProvider;
|
||||
url: string | null;
|
||||
pid: number | null;
|
||||
}
|
||||
|
||||
export type TunnelRestoreOutcome = "applied" | "skipped" | "failed";
|
||||
|
||||
export type TunnelRestoreReasonCode =
|
||||
|
||||
Reference in New Issue
Block a user