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:
5
.changeset/fn-2976-external-tailscale-detection.md
Normal file
5
.changeset/fn-2976-external-tailscale-detection.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Detect pre-existing Tailscale funnel sessions in Remote Access settings, surface external tunnel status in `/api/remote/status`, and add a kill-external tunnel endpoint plus Settings UI actions to adopt or restart cleanly.
|
||||
@@ -406,8 +406,8 @@ Key server capabilities:
|
||||
- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including host memory rendered as both numeric values and a visual usage bar in the System Stats modal), task/agent aggregates, and manual vitest process termination
|
||||
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
|
||||
- Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md)
|
||||
- `/api/remote/tunnel/start` and `/api/remote/tunnel/stop` are the only lifecycle transition endpoints.
|
||||
- `/api/remote/status` includes tunnel status plus restore diagnostics (`restore.outcome` + `restore.reason`) with parity between dashboard and headless `fn serve` runtimes.
|
||||
- `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup.
|
||||
- `/api/remote/status` includes tunnel status, external funnel detection (`externalTunnel` when managed tunnel is stopped), plus restore diagnostics (`restore.outcome` + `restore.reason`) with parity between dashboard and headless `fn serve` runtimes.
|
||||
- Remote auth handoff endpoints:
|
||||
- `POST /api/remote-access/auth/login-url` (daemon-auth protected) issues a tokenized phone-login URL for either `persistent` or `short-lived` mode.
|
||||
- `GET /remote-login?rt=<token>` (public) validates remote token strategy and redirects to dashboard auth handoff (`/?token=<daemonToken>` when daemon auth is enabled, otherwise `/`).
|
||||
|
||||
@@ -147,12 +147,14 @@ Important behavior:
|
||||
|
||||
- Start: `POST /api/remote/tunnel/start`
|
||||
- Stop: `POST /api/remote/tunnel/stop`
|
||||
- Kill external funnel bindings: `POST /api/remote/tunnel/kill-external`
|
||||
- Status: `GET /api/remote/status`
|
||||
|
||||
Returned status fields include:
|
||||
|
||||
- `state`: `stopped | starting | running | stopping | failed`
|
||||
- `provider`, `url`, `lastError`, `lastErrorCode`
|
||||
- `externalTunnel` (nullable): detected externally-running tunnel metadata (`provider`, `url`) when Fusion-managed tunnel is stopped
|
||||
- `restore` diagnostics block (`outcome`, `reason`, `at`, `provider`, optional `message`)
|
||||
|
||||
---
|
||||
@@ -207,6 +209,8 @@ Supported actions:
|
||||
- Save Remote settings (provider config + token strategy)
|
||||
- Activate provider
|
||||
- Start/Stop tunnel
|
||||
- Detect externally-running Tailscale funnel sessions when opening Remote Access settings
|
||||
- Use Existing (adopt existing tunnel) or Start Fresh (kill external funnel bindings then start a managed tunnel)
|
||||
- Regenerate persistent token
|
||||
- Generate short-lived token
|
||||
- Show authenticated URL
|
||||
|
||||
@@ -272,6 +272,8 @@ Use **[Remote Access runbook](./remote-access.md)** for setup prerequisites (Tai
|
||||
|
||||
When `remoteAccess.activeProvider` is `cloudflare`, the Settings UI fetches `/api/remote/status` and surfaces `cloudflaredAvailable` to show installed/missing state plus a one-click `POST /api/remote/install-cloudflared` action.
|
||||
|
||||
When `remoteAccess.activeProvider` is `tailscale` and the Fusion-managed tunnel is stopped, `/api/remote/status` also returns `externalTunnel` when a pre-existing funnel is detected. The UI exposes two actions: **Use Existing** (start Fusion tunnel lifecycle against the existing funnel) and **Start Fresh** (`POST /api/remote/tunnel/kill-external` then start).
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
|---|---|---:|---|
|
||||
| `remoteAccess.enabled` | `boolean` | `false` | Master toggle for remote access orchestration. |
|
||||
|
||||
@@ -473,6 +473,10 @@ export interface RemoteStatus {
|
||||
lastError: string | null;
|
||||
lastErrorCode?: string | null;
|
||||
cloudflaredAvailable?: boolean | null;
|
||||
externalTunnel?: {
|
||||
provider: "tailscale" | "cloudflare";
|
||||
url: string | null;
|
||||
} | null;
|
||||
restore?: {
|
||||
outcome: "applied" | "skipped" | "failed";
|
||||
reason: string;
|
||||
@@ -525,6 +529,12 @@ export function stopRemoteTunnel(projectId?: string): Promise<{ state: "stopped"
|
||||
});
|
||||
}
|
||||
|
||||
export function killExternalTunnel(projectId?: string): Promise<{ ok: boolean }> {
|
||||
return api<{ ok: boolean }>(withProjectId("/remote/tunnel/kill-external", projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function regenerateRemotePersistentToken(projectId?: string): Promise<{ token: string; maskedToken: string }> {
|
||||
return api<{ token: string; maskedToken: string }>(withProjectId("/remote/token/persistent/regenerate", projectId), {
|
||||
method: "POST",
|
||||
|
||||
@@ -691,6 +691,36 @@
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.remote-external-tunnel-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: 0 var(--space-xl) var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--color-info) 10%, transparent);
|
||||
}
|
||||
|
||||
.remote-external-tunnel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.remote-external-tunnel-qr {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.remote-external-tunnel-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.remote-advanced-details {
|
||||
margin: var(--space-lg) var(--space-xl) 0;
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
@@ -871,6 +901,10 @@
|
||||
.remote-cli-detection {
|
||||
margin: 0 var(--space-lg) var(--space-md);
|
||||
}
|
||||
|
||||
.remote-external-tunnel-panel {
|
||||
margin: 0 var(--space-lg) var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Notifications Settings === */
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
resolveTitleSummarizerSettingsModel,
|
||||
} from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
|
||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
@@ -425,6 +425,7 @@ export function SettingsModal({
|
||||
|
||||
// Remote access state
|
||||
const [remoteStatus, setRemoteStatus] = useState<RemoteStatus | null>(null);
|
||||
const [externalTunnel, setExternalTunnel] = useState<{ provider: string; url: string | null } | null>(null);
|
||||
const [remoteBusyAction, setRemoteBusyAction] = useState<string | null>(null);
|
||||
const [cloudflaredInstalling, setCloudflaredInstalling] = useState(false);
|
||||
const [cloudflaredInstallError, setCloudflaredInstallError] = useState<string | null>(null);
|
||||
@@ -668,9 +669,17 @@ export function SettingsModal({
|
||||
|
||||
if (statusResult.status === "fulfilled") {
|
||||
setRemoteStatus(statusResult.value);
|
||||
setExternalTunnel(statusResult.value.externalTunnel ?? null);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = remoteStatus?.state;
|
||||
if (state === "running" || state === "starting") {
|
||||
setExternalTunnel(null);
|
||||
}
|
||||
}, [remoteStatus?.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "remote") {
|
||||
return;
|
||||
@@ -689,7 +698,12 @@ export function SettingsModal({
|
||||
const state = remoteStatus?.state;
|
||||
if (state !== "starting" && state !== "stopping") return;
|
||||
const interval = setInterval(() => {
|
||||
fetchRemoteStatus(projectId).then(setRemoteStatus).catch(() => {});
|
||||
fetchRemoteStatus(projectId)
|
||||
.then((status) => {
|
||||
setRemoteStatus(status);
|
||||
setExternalTunnel(status.externalTunnel ?? null);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSection, projectId, remoteStatus?.state]);
|
||||
@@ -724,6 +738,30 @@ export function SettingsModal({
|
||||
};
|
||||
}, [activeSection, projectId, remoteStatus?.state, remoteStatus?.url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "remote") return;
|
||||
if (remoteStatus?.state !== "stopped" || !externalTunnel?.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: "persistent" });
|
||||
if (cancelled) return;
|
||||
setTunnelShareLink({ url: externalTunnel.url, qrSvg: qr.data ?? null });
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setTunnelShareLink({ url: externalTunnel.url, qrSvg: null });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSection, externalTunnel?.url, projectId, remoteStatus?.state]);
|
||||
|
||||
// Lazy-load git remotes for the rebase-remote dropdown when the Worktrees
|
||||
// section becomes visible. Failure is non-fatal: the dropdown falls back
|
||||
// to just "Use git default".
|
||||
@@ -4039,6 +4077,25 @@ export function SettingsModal({
|
||||
{remoteStatus?.url && <code className="remote-status-url">{remoteStatus.url}</code>}
|
||||
{remoteStatus?.lastError && <span className="field-error">{remoteStatus.lastError}</span>}
|
||||
</div>
|
||||
{tunnelState === "stopped" && externalTunnel && (
|
||||
<div className="remote-external-tunnel-panel" role="status">
|
||||
<div className="remote-external-tunnel-header">
|
||||
<Globe aria-hidden="true" />
|
||||
<strong>External {externalTunnel.provider} tunnel detected</strong>
|
||||
</div>
|
||||
{externalTunnel.url && <code className="settings-url-output">{externalTunnel.url}</code>}
|
||||
{tunnelShareLink?.qrSvg && (
|
||||
<div className="remote-external-tunnel-qr">
|
||||
<small>Scan to open:</small>
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(tunnelShareLink.qrSvg)}`}
|
||||
alt="External tunnel QR code"
|
||||
className="settings-qr-preview-image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tunnelState === "running" && (remoteStatus?.url || tunnelShareLink) && (() => {
|
||||
let accessCode: string | null = null;
|
||||
let tailnetUrl: string | null = remoteStatus?.url ?? null;
|
||||
@@ -4178,6 +4235,57 @@ export function SettingsModal({
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{externalTunnel ? (
|
||||
<div className="remote-external-tunnel-actions">
|
||||
<button type="button" className="btn" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start fresh", async () => {
|
||||
const formState = form as Record<string, unknown>;
|
||||
const savePayload: Partial<RemoteSettings> = {
|
||||
remoteActiveProvider: activeProvider,
|
||||
remoteTailscaleEnabled: activeProvider === "tailscale",
|
||||
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
|
||||
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
|
||||
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
|
||||
remoteCloudflareEnabled: activeProvider === "cloudflare",
|
||||
remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true),
|
||||
remoteCloudflareTunnelName: String(formState.remoteCloudflareTunnelName ?? ""),
|
||||
remoteCloudflareTunnelToken: (formState.remoteCloudflareTunnelToken as string | null) || null,
|
||||
remoteCloudflareIngressUrl: String(formState.remoteCloudflareIngressUrl ?? ""),
|
||||
remoteShortLivedEnabled: Boolean(formState.remoteShortLivedEnabled),
|
||||
remoteShortLivedTtlMs: Number(formState.remoteShortLivedTtlMs ?? 900000),
|
||||
remoteRememberLastRunning: Boolean(formState.remoteRememberLastRunning),
|
||||
};
|
||||
await updateRemoteSettings(savePayload, projectId);
|
||||
await killExternalTunnel(projectId);
|
||||
await startRemoteTunnel(projectId);
|
||||
addToast("Remote tunnel restarted", "success");
|
||||
})}>
|
||||
{remoteBusyAction === "start fresh" ? "Restarting…" : "Start Fresh"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("use existing", async () => {
|
||||
const formState = form as Record<string, unknown>;
|
||||
const savePayload: Partial<RemoteSettings> = {
|
||||
remoteActiveProvider: activeProvider,
|
||||
remoteTailscaleEnabled: activeProvider === "tailscale",
|
||||
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
|
||||
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
|
||||
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
|
||||
remoteCloudflareEnabled: activeProvider === "cloudflare",
|
||||
remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true),
|
||||
remoteCloudflareTunnelName: String(formState.remoteCloudflareTunnelName ?? ""),
|
||||
remoteCloudflareTunnelToken: (formState.remoteCloudflareTunnelToken as string | null) || null,
|
||||
remoteCloudflareIngressUrl: String(formState.remoteCloudflareIngressUrl ?? ""),
|
||||
remoteShortLivedEnabled: Boolean(formState.remoteShortLivedEnabled),
|
||||
remoteShortLivedTtlMs: Number(formState.remoteShortLivedTtlMs ?? 900000),
|
||||
remoteRememberLastRunning: Boolean(formState.remoteRememberLastRunning),
|
||||
};
|
||||
await updateRemoteSettings(savePayload, projectId);
|
||||
await startRemoteTunnel(projectId);
|
||||
addToast("Remote tunnel started", "success");
|
||||
})}>
|
||||
{remoteBusyAction === "use existing" ? "Starting…" : "Use Existing"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start", async () => {
|
||||
const formState = form as Record<string, unknown>;
|
||||
const savePayload: Partial<RemoteSettings> = {
|
||||
@@ -4204,6 +4312,7 @@ export function SettingsModal({
|
||||
})}>
|
||||
{remoteBusyAction === "start" ? "Starting…" : "Start Tunnel"}
|
||||
</button>
|
||||
)}
|
||||
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? (
|
||||
<small className="field-error">cloudflared must be installed to start the tunnel</small>
|
||||
) : null}
|
||||
|
||||
@@ -42,6 +42,7 @@ const mockFetchRemoteStatus = vi.fn();
|
||||
const mockInstallCloudflared = vi.fn();
|
||||
const mockStartRemoteTunnel = vi.fn();
|
||||
const mockStopRemoteTunnel = vi.fn();
|
||||
const mockKillExternalTunnel = vi.fn();
|
||||
const mockRegenerateRemotePersistentToken = vi.fn();
|
||||
const mockGenerateShortLivedRemoteToken = vi.fn();
|
||||
const mockFetchRemoteQr = vi.fn();
|
||||
@@ -87,6 +88,7 @@ vi.mock("../../api", () => ({
|
||||
installCloudflared: (...args: unknown[]) => mockInstallCloudflared(...args),
|
||||
startRemoteTunnel: (...args: unknown[]) => mockStartRemoteTunnel(...args),
|
||||
stopRemoteTunnel: (...args: unknown[]) => mockStopRemoteTunnel(...args),
|
||||
killExternalTunnel: (...args: unknown[]) => mockKillExternalTunnel(...args),
|
||||
regenerateRemotePersistentToken: (...args: unknown[]) => mockRegenerateRemotePersistentToken(...args),
|
||||
generateShortLivedRemoteToken: (...args: unknown[]) => mockGenerateShortLivedRemoteToken(...args),
|
||||
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
|
||||
@@ -302,6 +304,7 @@ describe("SettingsModal", () => {
|
||||
mockInstallCloudflared.mockResolvedValue({ success: true, command: "brew install cloudflared" });
|
||||
mockStartRemoteTunnel.mockResolvedValue({ state: "starting", provider: "tailscale" });
|
||||
mockStopRemoteTunnel.mockResolvedValue({ state: "stopped", provider: null });
|
||||
mockKillExternalTunnel.mockResolvedValue({ ok: true });
|
||||
mockRegenerateRemotePersistentToken.mockResolvedValue({ token: "token", maskedToken: "****" });
|
||||
mockGenerateShortLivedRemoteToken.mockResolvedValue({ token: "short", expiresAt: new Date(Date.now() + 60000).toISOString(), ttlMs: 60000 });
|
||||
mockFetchRemoteQr.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null, format: "image/svg", data: "<svg></svg>" });
|
||||
@@ -1932,6 +1935,46 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows external tunnel panel with actions when external tunnel is detected", async () => {
|
||||
mockFetchRemoteStatus.mockResolvedValue({
|
||||
provider: "tailscale",
|
||||
state: "stopped",
|
||||
url: null,
|
||||
lastError: null,
|
||||
externalTunnel: { provider: "tailscale", url: "https://machine.ts.net/" },
|
||||
});
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openRemoteSection();
|
||||
|
||||
expect(await screen.findByText("External tailscale tunnel detected")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Start Fresh" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Use Existing" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Start Fresh kills external tunnel before starting", async () => {
|
||||
mockFetchRemoteStatus.mockResolvedValue({
|
||||
provider: "tailscale",
|
||||
state: "stopped",
|
||||
url: null,
|
||||
lastError: null,
|
||||
externalTunnel: { provider: "tailscale", url: "https://machine.ts.net/" },
|
||||
});
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openRemoteSection();
|
||||
await userEvent.click(screen.getByLabelText("Tailscale"));
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Start Fresh" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKillExternalTunnel).toHaveBeenCalledWith(undefined);
|
||||
expect(mockStartRemoteTunnel).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("regenerates persistent token and surfaces success feedback without exposing raw token text", async () => {
|
||||
const addToast = vi.fn();
|
||||
renderModal({ addToast });
|
||||
|
||||
@@ -251,6 +251,49 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns 200 for remote status when managed tunnel is stopped", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
remoteAccess: buildRemoteAccessSettings({ activeProvider: "tailscale" }),
|
||||
}),
|
||||
});
|
||||
const { app } = createApp({ store });
|
||||
|
||||
const status = await REQUEST(app, "GET", "/api/remote/status");
|
||||
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body).toEqual(expect.objectContaining({ state: "stopped" }));
|
||||
});
|
||||
|
||||
it("omits externalTunnel detection when managed tunnel is running", async () => {
|
||||
const engine = {
|
||||
getRemoteTunnelManager: vi.fn().mockReturnValue({
|
||||
getStatus: vi.fn().mockReturnValue({ state: "running", provider: "tailscale", url: "https://live.ts.net/", lastError: null }),
|
||||
}),
|
||||
getRemoteTunnelRestoreDiagnostics: vi.fn().mockReturnValue(null),
|
||||
detectExternalTunnel: vi.fn(),
|
||||
};
|
||||
const { app } = createApp({ engine });
|
||||
|
||||
const status = await REQUEST(app, "GET", "/api/remote/status");
|
||||
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body.externalTunnel).toBeNull();
|
||||
expect(engine.detectExternalTunnel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls engine killExternalTunnel via kill-external endpoint", async () => {
|
||||
const engine = {
|
||||
killExternalTunnel: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const { app } = createApp({ engine });
|
||||
|
||||
const result = await REQUEST(app, "POST", "/api/remote/tunnel/kill-external", {});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("installs cloudflared via endpoint and returns install command metadata", async () => {
|
||||
const { app } = createApp();
|
||||
|
||||
|
||||
@@ -380,8 +380,8 @@ describe("createServer health and headless mode", () => {
|
||||
|
||||
const dashStatusBody = dashStatus.body as Record<string, unknown>;
|
||||
const headlessStatusBody = headlessStatus.body as Record<string, unknown>;
|
||||
expect(Object.keys(dashStatusBody).sort()).toEqual(["cloudflaredAvailable", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
|
||||
expect(Object.keys(headlessStatusBody).sort()).toEqual(["cloudflaredAvailable", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
|
||||
expect(Object.keys(dashStatusBody).sort()).toEqual(["cloudflaredAvailable", "externalTunnel", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
|
||||
expect(Object.keys(headlessStatusBody).sort()).toEqual(["cloudflaredAvailable", "externalTunnel", "lastError", "lastErrorCode", "provider", "restore", "state", "url"]);
|
||||
expect(headlessRoot.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -494,18 +494,29 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
const restore = engine?.getRemoteTunnelRestoreDiagnostics();
|
||||
|
||||
const activeProvider = tunnelStatus?.provider ?? settings.remoteAccess?.activeProvider ?? null;
|
||||
const tunnelState = tunnelStatus?.state ?? "stopped";
|
||||
let cloudflaredAvailable: boolean | null = null;
|
||||
if (activeProvider === "cloudflare") {
|
||||
cloudflaredAvailable = await isCloudflaredAvailable();
|
||||
}
|
||||
|
||||
const externalTunnel = tunnelState === "stopped"
|
||||
? await engine?.detectExternalTunnel()
|
||||
: null;
|
||||
|
||||
res.json({
|
||||
provider: activeProvider,
|
||||
state: tunnelStatus?.state ?? "stopped",
|
||||
state: tunnelState,
|
||||
url: tunnelStatus?.url ?? null,
|
||||
lastError: tunnelStatus?.lastError?.message ?? null,
|
||||
lastErrorCode: tunnelStatus?.lastError?.code ?? null,
|
||||
cloudflaredAvailable,
|
||||
externalTunnel: externalTunnel
|
||||
? {
|
||||
provider: externalTunnel.provider,
|
||||
url: externalTunnel.url,
|
||||
}
|
||||
: null,
|
||||
restore: restore ?? {
|
||||
outcome: "skipped",
|
||||
reason: "not_attempted",
|
||||
@@ -634,6 +645,19 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/remote/tunnel/kill-external", async (req, res) => {
|
||||
try {
|
||||
const { engine } = await getProjectContext(req);
|
||||
if (engine) {
|
||||
await engine.killExternalTunnel();
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to kill external remote tunnel");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/remote/token/persistent/regenerate", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
|
||||
@@ -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