feat(FN-2586): merge fusion/fn-2586
This commit is contained in:
5
.changeset/fn-2586-cloudflare-quick-tunnel.md
Normal file
5
.changeset/fn-2586-cloudflare-quick-tunnel.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add Cloudflare Quick Tunnel mode for Remote Access so Fusion can auto-provision an ephemeral `trycloudflare.com` URL via `cloudflared tunnel --url` without requiring a pre-created named tunnel or tunnel token.
|
||||
@@ -359,6 +359,9 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces
|
||||
- Lifecycle states: `"stopped" | "starting" | "running" | "stopping" | "failed"`
|
||||
- Error codes: `invalid_config`, `start_failed`, `stop_failed`, `switch_failed`, `readiness_timeout`, `process_exit`, etc.
|
||||
- `remote-access/provider-adapters.ts` provides provider-specific command composition + readiness parsing while enforcing config validation.
|
||||
- Cloudflare has two command variants:
|
||||
- Named tunnel mode: `cloudflared tunnel --no-autoupdate run <tunnelName>` (token from env)
|
||||
- Quick tunnel mode: `cloudflared tunnel --url http://localhost:<dashboardPort>` (ephemeral `trycloudflare.com` URL, no token)
|
||||
- Credential inputs are reference-based (`tokenEnvVar`, `credentialsPath`) and validated without logging raw secret values.
|
||||
- Redaction is applied to command previews and emitted log lines before publishing status/log events.
|
||||
- Deterministic stop semantics: graceful shutdown (`SIGTERM`) first, bounded wait, then force-kill fallback (`SIGKILL`).
|
||||
|
||||
@@ -44,11 +44,12 @@ Operational notes:
|
||||
- Fusion validates executable availability (`which tailscale` / `where tailscale`) before start.
|
||||
- If prerequisites are missing, start returns a prerequisite/config error (HTTP 409 from API start route).
|
||||
|
||||
## 1.3 Cloudflare Tunnel prerequisites
|
||||
## 1.3 Cloudflare named tunnel prerequisites
|
||||
|
||||
Cloudflare provider startup gates:
|
||||
Cloudflare **named tunnel** startup gates (`quickTunnel = false`):
|
||||
|
||||
- `remoteAccess.providers.cloudflare.enabled = true`
|
||||
- `remoteAccess.providers.cloudflare.quickTunnel = false` (default)
|
||||
- `remoteAccess.providers.cloudflare.tunnelName` is non-empty
|
||||
- `remoteAccess.providers.cloudflare.ingressUrl` is non-empty (must parse as `http://` or `https://` for login URL generation)
|
||||
- `remoteAccess.providers.cloudflare.tunnelToken` is non-empty
|
||||
@@ -59,6 +60,26 @@ Runtime command used by engine:
|
||||
- `cloudflared tunnel --no-autoupdate run <tunnelName>`
|
||||
- Token is passed via env (`TUNNEL_TOKEN`), not as a plain CLI argument.
|
||||
|
||||
## 1.4 Cloudflare Quick Tunnel prerequisites
|
||||
|
||||
Cloudflare **Quick Tunnel** startup gates (`quickTunnel = true`):
|
||||
|
||||
- `remoteAccess.providers.cloudflare.enabled = true`
|
||||
- `remoteAccess.providers.cloudflare.quickTunnel = true`
|
||||
- `cloudflared` executable is available on `PATH`
|
||||
|
||||
No Cloudflare account, tunnel token, named tunnel, or pre-created ingress URL is required.
|
||||
|
||||
Runtime command used by engine:
|
||||
|
||||
- `cloudflared tunnel --url http://localhost:<dashboardPort>`
|
||||
|
||||
Operational notes:
|
||||
|
||||
- `trycloudflare.com` URLs are ephemeral and typically change every tunnel restart.
|
||||
- Login URL generation for quick tunnel mode uses the **live runtime URL** reported by the running tunnel.
|
||||
- If the tunnel is not started yet, login URL generation cannot resolve a remote base URL.
|
||||
|
||||
---
|
||||
|
||||
## 2) Configuration and provider operations
|
||||
@@ -83,6 +104,7 @@ Minimal `remoteAccess` shape (redacted placeholders):
|
||||
},
|
||||
"cloudflare": {
|
||||
"enabled": true,
|
||||
"quickTunnel": false,
|
||||
"tunnelName": "<tunnel-name>",
|
||||
"tunnelToken": "<token>",
|
||||
"ingressUrl": "https://<host>"
|
||||
|
||||
@@ -192,9 +192,10 @@ Use **[Remote Access runbook](./remote-access.md)** for setup prerequisites (Tai
|
||||
| `remoteAccess.providers.tailscale.targetPort` | `number` | `0` | Local port exposed by Tailscale when configured. |
|
||||
| `remoteAccess.providers.tailscale.acceptRoutes` | `boolean` | `false` | Accept subnet routes when supported by local Tailscale config. |
|
||||
| `remoteAccess.providers.cloudflare.enabled` | `boolean` | `false` | Enables Cloudflare tunnel configuration. |
|
||||
| `remoteAccess.providers.cloudflare.tunnelName` | `string` | `""` | Named tunnel identifier for `cloudflared tunnel run`. |
|
||||
| `remoteAccess.providers.cloudflare.tunnelToken` | `string \| null` | `null` | Tunnel token value (treat as secret; do not log raw values). |
|
||||
| `remoteAccess.providers.cloudflare.ingressUrl` | `string` | `""` | Optional preferred public ingress URL for display. |
|
||||
| `remoteAccess.providers.cloudflare.quickTunnel` | `boolean` | `false` | Enables Cloudflare Quick Tunnel mode (`cloudflared tunnel --url`) with no account/token requirement; named tunnel fields are ignored while enabled. |
|
||||
| `remoteAccess.providers.cloudflare.tunnelName` | `string` | `""` | Named tunnel identifier for `cloudflared tunnel run` when `quickTunnel` is `false`. |
|
||||
| `remoteAccess.providers.cloudflare.tunnelToken` | `string \| null` | `null` | Tunnel token value (treat as secret; do not log raw values) for named tunnel mode. |
|
||||
| `remoteAccess.providers.cloudflare.ingressUrl` | `string` | `""` | Preferred public ingress URL for named tunnel mode; in quick tunnel mode the live `trycloudflare.com` URL comes from runtime status. |
|
||||
| `remoteAccess.tokenStrategy.persistent.enabled` | `boolean` | `true` | Enables persistent remote-auth token mode. |
|
||||
| `remoteAccess.tokenStrategy.persistent.token` | `string \| null` | `null` | Persistent remote-auth token. |
|
||||
| `remoteAccess.tokenStrategy.shortLived.enabled` | `boolean` | `false` | Enables short-lived token generation. |
|
||||
|
||||
@@ -2638,6 +2638,7 @@ describe("TaskStore", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "main-tunnel",
|
||||
tunnelToken: "cf-secret-token",
|
||||
ingressUrl: "https://project.example.com",
|
||||
|
||||
@@ -167,6 +167,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: false,
|
||||
quickTunnel: false,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
|
||||
@@ -1189,6 +1189,7 @@ export interface RemoteAccessProvidersConfig {
|
||||
};
|
||||
cloudflare: {
|
||||
enabled: boolean;
|
||||
quickTunnel: boolean;
|
||||
tunnelName: string;
|
||||
tunnelToken: string | null;
|
||||
ingressUrl: string;
|
||||
|
||||
@@ -410,6 +410,7 @@ export interface RemoteSettings {
|
||||
remoteTailscaleTargetPort: number;
|
||||
remoteTailscaleAcceptRoutes: boolean;
|
||||
remoteCloudflareEnabled: boolean;
|
||||
remoteCloudflareQuickTunnel: boolean;
|
||||
remoteCloudflareTunnelName: string;
|
||||
remoteCloudflareTunnelToken: string | null;
|
||||
remoteCloudflareIngressUrl: string;
|
||||
|
||||
@@ -1262,6 +1262,7 @@ export function SettingsModal({
|
||||
remoteTailscaleTargetPort: Number((form as Record<string, unknown>).remoteTailscaleTargetPort ?? 4040),
|
||||
remoteTailscaleAcceptRoutes: Boolean((form as Record<string, unknown>).remoteTailscaleAcceptRoutes),
|
||||
remoteCloudflareEnabled: Boolean((form as Record<string, unknown>).remoteCloudflareEnabled),
|
||||
remoteCloudflareQuickTunnel: Boolean((form as Record<string, unknown>).remoteCloudflareQuickTunnel),
|
||||
remoteCloudflareTunnelName: String((form as Record<string, unknown>).remoteCloudflareTunnelName ?? ""),
|
||||
remoteCloudflareTunnelToken: (((form as Record<string, unknown>).remoteCloudflareTunnelToken as string | null) || null),
|
||||
remoteCloudflareIngressUrl: String((form as Record<string, unknown>).remoteCloudflareIngressUrl ?? ""),
|
||||
@@ -3501,30 +3502,44 @@ export function SettingsModal({
|
||||
/>
|
||||
Enable Cloudflare provider config
|
||||
</label>
|
||||
<label htmlFor="remoteCloudflareTunnelName">Tunnel name</label>
|
||||
<input
|
||||
id="remoteCloudflareTunnelName"
|
||||
type="text"
|
||||
placeholder="Tunnel name"
|
||||
value={String(remoteForm.remoteCloudflareTunnelName ?? "")}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))}
|
||||
/>
|
||||
<label htmlFor="remoteCloudflareTunnelToken">Tunnel token</label>
|
||||
<input
|
||||
id="remoteCloudflareTunnelToken"
|
||||
type="password"
|
||||
placeholder="Tunnel token"
|
||||
value={String(remoteForm.remoteCloudflareTunnelToken ?? "")}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))}
|
||||
/>
|
||||
<label htmlFor="remoteCloudflareIngressUrl">Ingress URL</label>
|
||||
<input
|
||||
id="remoteCloudflareIngressUrl"
|
||||
type="text"
|
||||
placeholder="https://your-domain.example"
|
||||
value={String(remoteForm.remoteCloudflareIngressUrl ?? "")}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))}
|
||||
/>
|
||||
<label htmlFor="remoteCloudflareQuickTunnel" className="checkbox-label">
|
||||
<input
|
||||
id="remoteCloudflareQuickTunnel"
|
||||
type="checkbox"
|
||||
checked={Boolean(remoteForm.remoteCloudflareQuickTunnel)}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareQuickTunnel: e.target.checked } as SettingsFormState))}
|
||||
/>
|
||||
Quick Tunnel
|
||||
</label>
|
||||
<small>Automatically creates a random trycloudflare.com URL — no account or token needed.</small>
|
||||
{!remoteForm.remoteCloudflareQuickTunnel && (
|
||||
<>
|
||||
<label htmlFor="remoteCloudflareTunnelName">Tunnel name</label>
|
||||
<input
|
||||
id="remoteCloudflareTunnelName"
|
||||
type="text"
|
||||
placeholder="Tunnel name"
|
||||
value={String(remoteForm.remoteCloudflareTunnelName ?? "")}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))}
|
||||
/>
|
||||
<label htmlFor="remoteCloudflareTunnelToken">Tunnel token</label>
|
||||
<input
|
||||
id="remoteCloudflareTunnelToken"
|
||||
type="password"
|
||||
placeholder="Tunnel token"
|
||||
value={String(remoteForm.remoteCloudflareTunnelToken ?? "")}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))}
|
||||
/>
|
||||
<label htmlFor="remoteCloudflareIngressUrl">Ingress URL</label>
|
||||
<input
|
||||
id="remoteCloudflareIngressUrl"
|
||||
type="text"
|
||||
placeholder="https://your-domain.example"
|
||||
value={String(remoteForm.remoteCloudflareIngressUrl ?? "")}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
|
||||
@@ -206,6 +206,7 @@ describe("SettingsModal", () => {
|
||||
remoteTailscaleTargetPort: 4040,
|
||||
remoteTailscaleAcceptRoutes: false,
|
||||
remoteCloudflareEnabled: false,
|
||||
remoteCloudflareQuickTunnel: false,
|
||||
remoteCloudflareTunnelName: "",
|
||||
remoteCloudflareTunnelToken: null,
|
||||
remoteCloudflareIngressUrl: "",
|
||||
@@ -226,6 +227,7 @@ describe("SettingsModal", () => {
|
||||
remoteTailscaleTargetPort: 4040,
|
||||
remoteTailscaleAcceptRoutes: false,
|
||||
remoteCloudflareEnabled: false,
|
||||
remoteCloudflareQuickTunnel: false,
|
||||
remoteCloudflareTunnelName: "",
|
||||
remoteCloudflareTunnelToken: null,
|
||||
remoteCloudflareIngressUrl: "",
|
||||
@@ -1394,6 +1396,7 @@ describe("SettingsModal", () => {
|
||||
expect.objectContaining({
|
||||
remoteTailscaleEnabled: true,
|
||||
remoteCloudflareEnabled: true,
|
||||
remoteCloudflareQuickTunnel: false,
|
||||
remoteTailscaleHostname: "tail-new.ts.net",
|
||||
remoteTailscaleTargetPort: 4242,
|
||||
remoteCloudflareTunnelName: "cf-team",
|
||||
@@ -1405,6 +1408,33 @@ describe("SettingsModal", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles Cloudflare quick tunnel and hides manual cloudflare fields", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openRemoteSection();
|
||||
|
||||
expect(screen.getByLabelText("Tunnel name")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Tunnel token")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Ingress URL")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Quick Tunnel"));
|
||||
|
||||
expect(screen.queryByLabelText("Tunnel name")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Tunnel token")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Ingress URL")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save Remote Settings" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteCloudflareQuickTunnel: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates active provider selection and provider status affordance after activation", async () => {
|
||||
mockFetchRemoteStatus
|
||||
.mockResolvedValueOnce({ provider: null, state: "stopped", url: null, lastError: null })
|
||||
|
||||
@@ -296,6 +296,7 @@ describe("Auth middleware integration with createServer", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "demo-tunnel",
|
||||
tunnelToken: "cf-secret",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
|
||||
@@ -85,6 +85,7 @@ describe("remote access headless parity", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "demo",
|
||||
tunnelToken: "cf-secret-token",
|
||||
ingressUrl: "https://demo.example.com",
|
||||
|
||||
@@ -18,6 +18,7 @@ function buildRemoteAccessSettings(overrides: Record<string, unknown> = {}) {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "demo-tunnel",
|
||||
tunnelToken: "cf-secret-token",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
|
||||
@@ -24,6 +24,7 @@ function createRemoteSettings(overrides: Partial<RemoteAccessProjectSettings> =
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: false,
|
||||
quickTunnel: false,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
|
||||
@@ -18,6 +18,7 @@ function buildRemoteAccessSettings() {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "demo-tunnel",
|
||||
tunnelToken: "cf-secret-token",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
@@ -98,12 +99,14 @@ describe("remote access API route contracts", () => {
|
||||
settings: expect.objectContaining({
|
||||
remoteEnabled: true,
|
||||
remoteActiveProvider: "cloudflare",
|
||||
remoteCloudflareQuickTunnel: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const putRes = await REQUEST(app, "PUT", "/api/remote/settings", {
|
||||
remoteEnabled: true,
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteCloudflareQuickTunnel: true,
|
||||
remoteShortLivedEnabled: true,
|
||||
remoteShortLivedTtlMs: 180000,
|
||||
});
|
||||
@@ -113,10 +116,19 @@ describe("remote access API route contracts", () => {
|
||||
settings: expect.objectContaining({
|
||||
remoteEnabled: true,
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteCloudflareQuickTunnel: true,
|
||||
remoteShortLivedEnabled: true,
|
||||
remoteShortLivedTtlMs: 180000,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||
remoteAccess: expect.objectContaining({
|
||||
providers: expect.objectContaining({
|
||||
cloudflare: expect.objectContaining({ quickTunnel: true }),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("supports provider activation and tunnel lifecycle endpoints", async () => {
|
||||
@@ -159,6 +171,66 @@ describe("remote access API route contracts", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("uses live tunnel URL for cloudflare quick tunnel link generation", async () => {
|
||||
const quickTunnelSettings = {
|
||||
...buildRemoteAccessSettings(),
|
||||
providers: {
|
||||
...buildRemoteAccessSettings().providers,
|
||||
cloudflare: {
|
||||
...buildRemoteAccessSettings().providers.cloudflare,
|
||||
quickTunnel: true,
|
||||
ingressUrl: "",
|
||||
tunnelToken: null,
|
||||
tunnelName: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ remoteAccess: quickTunnelSettings }),
|
||||
});
|
||||
const engine = {
|
||||
getRemoteTunnelManager: () => ({
|
||||
getStatus: () => ({ url: "https://demo.trycloudflare.com" }),
|
||||
}),
|
||||
};
|
||||
const { app } = createApp({ store, engine });
|
||||
|
||||
const urlRes = await REQUEST(app, "GET", "/api/remote/url?tokenType=persistent");
|
||||
expect(urlRes.status).toBe(200);
|
||||
expect(urlRes.body.url).toContain("https://demo.trycloudflare.com/remote-login?rt=");
|
||||
});
|
||||
|
||||
it("returns 409 when quick tunnel URL is requested before cloudflared reports URL", async () => {
|
||||
const quickTunnelSettings = {
|
||||
...buildRemoteAccessSettings(),
|
||||
providers: {
|
||||
...buildRemoteAccessSettings().providers,
|
||||
cloudflare: {
|
||||
...buildRemoteAccessSettings().providers.cloudflare,
|
||||
quickTunnel: true,
|
||||
ingressUrl: "",
|
||||
tunnelToken: null,
|
||||
tunnelName: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ remoteAccess: quickTunnelSettings }),
|
||||
});
|
||||
const engine = {
|
||||
getRemoteTunnelManager: () => ({
|
||||
getStatus: () => ({ url: null }),
|
||||
}),
|
||||
};
|
||||
const { app } = createApp({ store, engine });
|
||||
|
||||
const urlRes = await REQUEST(app, "GET", "/api/remote/url?tokenType=persistent");
|
||||
expect(urlRes.status).toBe(409);
|
||||
expect(urlRes.body.error).toContain("quick tunnel has not started yet");
|
||||
});
|
||||
|
||||
it("supports persistent and short-lived token endpoints plus URL/QR contracts", async () => {
|
||||
const { app } = createApp();
|
||||
|
||||
|
||||
@@ -13207,6 +13207,7 @@ describe("PUT /settings", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: false,
|
||||
quickTunnel: false,
|
||||
tunnelName: "existing-tunnel",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
@@ -13680,6 +13681,7 @@ describe("GET /settings/scopes", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: false,
|
||||
quickTunnel: false,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
@@ -18031,6 +18033,7 @@ describe("remote access auth login-url endpoints", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "tunnel",
|
||||
tunnelToken: "cf-secret",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("createServer health and headless mode", () => {
|
||||
activeProvider: "cloudflare",
|
||||
providers: {
|
||||
tailscale: { enabled: false, hostname: "", targetPort: 4040, acceptRoutes: false },
|
||||
cloudflare: { enabled: true, tunnelName: "demo", tunnelToken: "cf-secret", ingressUrl: "https://remote.example.com" },
|
||||
cloudflare: { enabled: true, quickTunnel: false, tunnelName: "demo", tunnelToken: "cf-secret", ingressUrl: "https://remote.example.com" },
|
||||
},
|
||||
tokenStrategy: {
|
||||
persistent: { enabled: true, token: "frt_persistent_token" },
|
||||
@@ -2206,6 +2206,7 @@ describe("GET /remote-login", () => {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "tunnel",
|
||||
tunnelToken: "secret",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
|
||||
@@ -50,20 +50,33 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
const { router, options, store, runtimeLogger, getProjectContext, rethrowAsApiError } = ctx;
|
||||
const { githubToken, validateModelPresets, sanitizeOverlapIgnorePaths, discoverDashboardPiExtensions } = deps;
|
||||
|
||||
function resolveRemoteBaseUrl(remoteAccess: NonNullable<Awaited<ReturnType<typeof store.getSettings>>["remoteAccess"]>): URL {
|
||||
function resolveRemoteBaseUrl(
|
||||
remoteAccess: NonNullable<Awaited<ReturnType<typeof store.getSettings>>["remoteAccess"]>,
|
||||
tunnelUrl?: string | null,
|
||||
): URL {
|
||||
if (!remoteAccess.activeProvider) {
|
||||
throw new ApiError(409, "No active remote provider configured", { code: "REMOTE_PROVIDER_NOT_CONFIGURED" });
|
||||
}
|
||||
|
||||
if (remoteAccess.activeProvider === "cloudflare") {
|
||||
const ingressUrl = remoteAccess.providers.cloudflare.ingressUrl?.trim();
|
||||
if (!ingressUrl) {
|
||||
const cloudflare = remoteAccess.providers.cloudflare;
|
||||
const ingressUrl = cloudflare.ingressUrl?.trim();
|
||||
const candidateUrl = cloudflare.quickTunnel === true && !ingressUrl
|
||||
? (tunnelUrl?.trim() ?? "")
|
||||
: ingressUrl;
|
||||
|
||||
if (!candidateUrl) {
|
||||
if (cloudflare.quickTunnel === true) {
|
||||
throw new ApiError(409, "Cloudflare quick tunnel has not started yet", {
|
||||
code: "REMOTE_URL_NOT_READY",
|
||||
});
|
||||
}
|
||||
throw new ApiError(409, "Cloudflare ingress URL is not configured", { code: "REMOTE_URL_NOT_CONFIGURED" });
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(ingressUrl);
|
||||
parsed = new URL(candidateUrl);
|
||||
} catch {
|
||||
throw new ApiError(409, "Cloudflare ingress URL is invalid", { code: "REMOTE_URL_INVALID" });
|
||||
}
|
||||
@@ -115,9 +128,17 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
return token;
|
||||
}
|
||||
|
||||
function getCurrentTunnelUrl(engine: unknown): string | null {
|
||||
const manager = (engine as {
|
||||
getRemoteTunnelManager?: () => { getStatus?: () => { url?: string | null } } | undefined;
|
||||
} | undefined)?.getRemoteTunnelManager?.();
|
||||
return manager?.getStatus?.().url ?? null;
|
||||
}
|
||||
|
||||
async function buildRemoteLoginUrlForTokenType(
|
||||
scopedStore: typeof store,
|
||||
mode: "persistent" | "short-lived",
|
||||
tunnelUrl?: string | null,
|
||||
): Promise<{ loginUrl: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }> {
|
||||
const settings = await scopedStore.getSettings();
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
@@ -126,7 +147,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
throw new ApiError(409, "No remote provider is enabled", { code: "REMOTE_ACCESS_DISABLED" });
|
||||
}
|
||||
|
||||
const baseUrl = resolveRemoteBaseUrl(remoteAccess);
|
||||
const baseUrl = resolveRemoteBaseUrl(remoteAccess, tunnelUrl);
|
||||
|
||||
if (mode === "persistent") {
|
||||
if (!remoteAccess.tokenStrategy.persistent.enabled) {
|
||||
@@ -274,6 +295,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
remoteTailscaleTargetPort: Number(remoteAccess.providers.tailscale.targetPort ?? 4040),
|
||||
remoteTailscaleAcceptRoutes: Boolean(remoteAccess.providers.tailscale.acceptRoutes),
|
||||
remoteCloudflareEnabled: Boolean(remoteAccess.providers.cloudflare.enabled),
|
||||
remoteCloudflareQuickTunnel: Boolean(remoteAccess.providers.cloudflare.quickTunnel),
|
||||
remoteCloudflareTunnelName: remoteAccess.providers.cloudflare.tunnelName,
|
||||
remoteCloudflareTunnelToken: remoteAccess.providers.cloudflare.tunnelToken,
|
||||
remoteCloudflareIngressUrl: remoteAccess.providers.cloudflare.ingressUrl,
|
||||
@@ -330,6 +352,9 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
cloudflare: {
|
||||
...remoteAccess.providers.cloudflare,
|
||||
enabled: body.remoteCloudflareEnabled === undefined ? remoteAccess.providers.cloudflare.enabled : Boolean(body.remoteCloudflareEnabled),
|
||||
quickTunnel: body.remoteCloudflareQuickTunnel === undefined
|
||||
? Boolean(remoteAccess.providers.cloudflare.quickTunnel)
|
||||
: Boolean(body.remoteCloudflareQuickTunnel),
|
||||
tunnelName: body.remoteCloudflareTunnelName === undefined ? remoteAccess.providers.cloudflare.tunnelName : String(body.remoteCloudflareTunnelName ?? ""),
|
||||
tunnelToken: body.remoteCloudflareTunnelToken === undefined
|
||||
? remoteAccess.providers.cloudflare.tunnelToken
|
||||
@@ -553,8 +578,8 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
throw new ApiError(400, "mode must be 'persistent' or 'short-lived'", { code: "INVALID_REMOTE_AUTH_MODE" });
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, mode);
|
||||
const { store: scopedStore, engine } = await getProjectContext(req);
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, mode, getCurrentTunnelUrl(engine ?? options?.engine));
|
||||
res.json({
|
||||
loginUrl: payload.loginUrl,
|
||||
tokenType: payload.tokenType,
|
||||
@@ -568,9 +593,9 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
|
||||
router.get("/remote/url", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { store: scopedStore, engine } = await getProjectContext(req);
|
||||
const tokenType = req.query.tokenType === "short-lived" ? "short-lived" : "persistent";
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType);
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType, getCurrentTunnelUrl(engine ?? options?.engine));
|
||||
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
@@ -580,10 +605,10 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
|
||||
router.get("/remote/qr", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { store: scopedStore, engine } = await getProjectContext(req);
|
||||
const tokenType = req.query.tokenType === "short-lived" ? "short-lived" : "persistent";
|
||||
const format = req.query.format === "image/svg" ? "image/svg" : "text";
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType);
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType, getCurrentTunnelUrl(engine ?? options?.engine));
|
||||
if (format === "image/svg") {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80"><rect width="100%" height="100%" fill="white"/><text x="10" y="42" font-size="12" fill="black">${payload.loginUrl.replace(/&/g, "&").replace(/</g, "<")}</text></svg>`;
|
||||
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt, format, data: svg });
|
||||
|
||||
@@ -155,6 +155,7 @@ const baseRemoteAccess = {
|
||||
},
|
||||
cloudflare: {
|
||||
enabled: true,
|
||||
quickTunnel: false,
|
||||
tunnelName: "demo",
|
||||
tunnelToken: "cf-secret-token",
|
||||
ingressUrl: "https://remote.example.com",
|
||||
@@ -649,6 +650,121 @@ describe("ProjectEngine remote lifecycle restore policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectEngine remote lifecycle quick tunnel mode", () => {
|
||||
it("starts cloudflare quick tunnel without manual tunnel fields", async () => {
|
||||
const quickTunnelSettings = {
|
||||
...baseSettings,
|
||||
remoteAccess: {
|
||||
...baseRemoteAccess,
|
||||
providers: {
|
||||
...baseRemoteAccess.providers,
|
||||
cloudflare: {
|
||||
...baseRemoteAccess.providers.cloudflare,
|
||||
quickTunnel: true,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockStore = createMockStore(quickTunnelSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined);
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await engine.startRemoteTunnel();
|
||||
|
||||
expect(startSpy).toHaveBeenCalledWith(
|
||||
"cloudflare",
|
||||
expect.objectContaining({
|
||||
provider: "cloudflare",
|
||||
quickTunnel: true,
|
||||
executablePath: "cloudflared",
|
||||
args: ["tunnel", "--url", "http://localhost:4040"],
|
||||
}),
|
||||
);
|
||||
|
||||
await engine.stop();
|
||||
startSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("surfaces runtime prerequisite missing when cloudflared is unavailable in quick tunnel mode", async () => {
|
||||
mocks.execFile.mockImplementation((
|
||||
_file: string,
|
||||
_args: string[],
|
||||
_options: unknown,
|
||||
callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void,
|
||||
) => {
|
||||
const err = new Error("cloudflared not found");
|
||||
if (typeof _options === "function") {
|
||||
(_options as (error: Error, result: { stdout: string; stderr: string }) => void)(err, {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
return {} as never;
|
||||
}
|
||||
|
||||
callback?.(err, { stdout: "", stderr: "" });
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const quickTunnelSettings = {
|
||||
...baseSettings,
|
||||
remoteAccess: {
|
||||
...baseRemoteAccess,
|
||||
providers: {
|
||||
...baseRemoteAccess.providers,
|
||||
cloudflare: {
|
||||
...baseRemoteAccess.providers.cloudflare,
|
||||
quickTunnel: true,
|
||||
tunnelName: "",
|
||||
tunnelToken: null,
|
||||
ingressUrl: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockStore = createMockStore(quickTunnelSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await expect(engine.startRemoteTunnel()).rejects.toThrow(
|
||||
"runtime_prerequisite_missing:cloudflared is not available on PATH",
|
||||
);
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
it("keeps manual cloudflare validation unchanged when quick tunnel is disabled", async () => {
|
||||
const manualSettings = {
|
||||
...baseSettings,
|
||||
remoteAccess: {
|
||||
...baseRemoteAccess,
|
||||
providers: {
|
||||
...baseRemoteAccess.providers,
|
||||
cloudflare: {
|
||||
...baseRemoteAccess.providers.cloudflare,
|
||||
quickTunnel: false,
|
||||
tunnelToken: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockStore = createMockStore(manualSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await expect(engine.startRemoteTunnel()).rejects.toThrow(
|
||||
"provider_not_configured:Cloudflare tunnel token is required",
|
||||
);
|
||||
await engine.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectEngine shutdown merge handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -44,6 +44,16 @@ function cloudflareConfig(overrides: Partial<TunnelProviderConfig> = {}): Tunnel
|
||||
} as TunnelProviderConfig;
|
||||
}
|
||||
|
||||
function cloudflareQuickTunnelConfig(overrides: Partial<TunnelProviderConfig> = {}): TunnelProviderConfig {
|
||||
return {
|
||||
provider: "cloudflare",
|
||||
quickTunnel: true,
|
||||
executablePath: "cloudflared",
|
||||
args: ["tunnel", "--url", "http://localhost:4040"],
|
||||
...overrides,
|
||||
} as TunnelProviderConfig;
|
||||
}
|
||||
|
||||
describe("TunnelProcessManager", () => {
|
||||
let pid = 1000;
|
||||
let children = new Map<number, FakeChildProcess>();
|
||||
@@ -74,6 +84,19 @@ describe("TunnelProcessManager", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("accepts quick tunnel cloudflare config without token env requirements", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
const child = new FakeChildProcess(++pid);
|
||||
children.set(child.pid, child);
|
||||
return child as never;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(manager.start("cloudflare", cloudflareQuickTunnelConfig())).resolves.toBeUndefined();
|
||||
expect(manager.getStatus().state).toBe("starting");
|
||||
});
|
||||
|
||||
it("starts, emits readiness transitions, and redacts token-bearing logs", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
@@ -107,6 +130,25 @@ describe("TunnelProcessManager", () => {
|
||||
expect(allLogs).not.toContain("secret-token");
|
||||
});
|
||||
|
||||
it("detects trycloudflare readiness output for quick tunnel config", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
const child = new FakeChildProcess(++pid);
|
||||
children.set(child.pid, child);
|
||||
return child as never;
|
||||
},
|
||||
});
|
||||
|
||||
await manager.start("cloudflare", cloudflareQuickTunnelConfig());
|
||||
const child = [...children.values()][0];
|
||||
child.emitStdout("Tunnel ready https://demo.trycloudflare.com");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getStatus().state).toBe("running");
|
||||
});
|
||||
expect(manager.getStatus().url).toBe("https://demo.trycloudflare.com");
|
||||
});
|
||||
|
||||
it("transitions start→running and stop→stopped, with idempotent repeated stop", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
|
||||
@@ -686,6 +686,23 @@ export class ProjectEngine {
|
||||
if (!cloudflare.enabled) {
|
||||
return { provider, reason: "provider_not_enabled", message: "Cloudflare provider is disabled" };
|
||||
}
|
||||
if (cloudflare.quickTunnel === true) {
|
||||
const executable = await this.checkExecutableAvailable("cloudflared");
|
||||
if (!executable.available) {
|
||||
return { provider, reason: "runtime_prerequisite_missing", message: executable.message };
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
config: {
|
||||
provider: "cloudflare",
|
||||
quickTunnel: true,
|
||||
executablePath: "cloudflared",
|
||||
args: ["tunnel", "--url", "http://localhost:4040"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!cloudflare.tunnelName?.trim() || !cloudflare.ingressUrl?.trim()) {
|
||||
return { provider, reason: "provider_not_configured", message: "Cloudflare tunnel name and ingress URL must be configured" };
|
||||
}
|
||||
|
||||
@@ -157,6 +157,11 @@ const cloudflareAdapter: TunnelProviderAdapter = {
|
||||
provider: "cloudflare",
|
||||
validateConfig(config) {
|
||||
validateBaseConfig(config, "cloudflare");
|
||||
|
||||
if (config.provider === "cloudflare" && config.quickTunnel === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("credentialsPath" in config && config.credentialsPath !== undefined) {
|
||||
assertNonEmpty(config.credentialsPath, "credentialsPath");
|
||||
if (isAbsoluteOrPathLike(config.credentialsPath)) {
|
||||
|
||||
@@ -108,6 +108,11 @@ export interface TailscaleProviderConfig extends TunnelProviderConfigBase {
|
||||
|
||||
export interface CloudflareProviderConfig extends TunnelProviderConfigBase {
|
||||
provider: "cloudflare";
|
||||
/**
|
||||
* Enables account-less Cloudflare quick tunnels (`cloudflared tunnel --url ...`).
|
||||
* In this mode, no token env var or credentials file is required.
|
||||
*/
|
||||
quickTunnel?: boolean;
|
||||
/**
|
||||
* Optional environment variable name holding a Cloudflare token reference.
|
||||
* The manager validates that it exists when provided, but never logs its value.
|
||||
|
||||
Reference in New Issue
Block a user