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:
Fusion
2026-04-30 05:41:25 -07:00
committed by gsxdsm
parent 1b3e68740e
commit 48f2dd0de2
16 changed files with 506 additions and 8 deletions

View File

@@ -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();
});
});

View File

@@ -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();

View File

@@ -7,6 +7,7 @@ export { TunnelProcessManager, type TunnelProcessManagerOptions } from "./tunnel
export type {
CloudflareProviderConfig,
ExternalTunnelInfo,
ManagedTunnelProcess,
PreparedTunnelCommand,
TailscaleProviderConfig,

View File

@@ -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;

View File

@@ -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 =