mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Add Cloudflare Quick Tunnel controls to endpoint page
This commit is contained in:
@@ -876,6 +876,14 @@ docker compose --profile base up -d
|
||||
docker compose --profile cli up -d
|
||||
```
|
||||
|
||||
Dashboard support for Docker deployments now includes a one-click **Cloudflare Quick Tunnel** on `Dashboard → Endpoints`. The first enable downloads `cloudflared` only when needed, starts a temporary tunnel to your current `/v1` endpoint, and shows the generated `https://*.trycloudflare.com/v1` URL directly below your normal public URL.
|
||||
|
||||
Notes:
|
||||
|
||||
- Quick Tunnel URLs are temporary and change after every restart.
|
||||
- Managed install currently supports Linux, macOS, and Windows on `x64` / `arm64`.
|
||||
- Set `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` if you want OmniRoute to use an existing binary instead of downloading one.
|
||||
|
||||
**Using Docker Compose with Caddy (HTTPS Auto-TLS):**
|
||||
|
||||
OmniRoute can be securely exposed using Caddy's automatic SSL provisioning. Ensure your domain's DNS A record points to your server's IP.
|
||||
|
||||
@@ -216,23 +216,23 @@ Response example:
|
||||
|
||||
### Settings
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------------- | ------- | ---------------------- |
|
||||
| `/api/settings` | GET/PUT/PATCH | General settings |
|
||||
| `/api/settings/proxy` | GET/PUT | Network proxy config |
|
||||
| `/api/settings/proxy/test` | POST | Test proxy connection |
|
||||
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
|
||||
| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget |
|
||||
| `/api/settings/system-prompt` | GET/PUT | Global system prompt |
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------------- | ------------- | ---------------------- |
|
||||
| `/api/settings` | GET/PUT/PATCH | General settings |
|
||||
| `/api/settings/proxy` | GET/PUT | Network proxy config |
|
||||
| `/api/settings/proxy/test` | POST | Test proxy connection |
|
||||
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
|
||||
| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget |
|
||||
| `/api/settings/system-prompt` | GET/PUT | Global system prompt |
|
||||
|
||||
### Monitoring
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------ | ---------- | ----------------------- |
|
||||
| `/api/sessions` | GET | Active session tracking |
|
||||
| `/api/rate-limits` | GET | Per-account rate limits |
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `/api/sessions` | GET | Active session tracking |
|
||||
| `/api/rate-limits` | GET | Per-account rate limits |
|
||||
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
|
||||
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
|
||||
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
|
||||
|
||||
### Backup & Export/Import
|
||||
|
||||
@@ -253,6 +253,13 @@ Response example:
|
||||
| `/api/sync/initialize` | POST | Initialize sync |
|
||||
| `/api/cloud/*` | Various | Cloud management |
|
||||
|
||||
### Tunnels
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| -------------------------- | ------ | ----------------------------------------------------------------------- |
|
||||
| `/api/tunnels/cloudflared` | GET | Read Cloudflare Quick Tunnel install/runtime status for the dashboard |
|
||||
| `/api/tunnels/cloudflared` | POST | Enable or disable the Cloudflare Quick Tunnel (`action=enable/disable`) |
|
||||
|
||||
### CLI Tools
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
@@ -277,12 +284,12 @@ GET response includes `agents[]` (id, name, binary, version, installed, protocol
|
||||
|
||||
### Resilience & Rate Limits
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ----------------------- | ------- | ------------------------------- |
|
||||
| `/api/resilience` | GET/PATCH | Get/update resilience profiles |
|
||||
| `/api/resilience/reset` | POST | Reset circuit breakers |
|
||||
| `/api/rate-limits` | GET | Per-account rate limit status |
|
||||
| `/api/rate-limit` | GET | Global rate limit configuration |
|
||||
| Endpoint | Method | Description |
|
||||
| ----------------------- | --------- | ------------------------------- |
|
||||
| `/api/resilience` | GET/PATCH | Get/update resilience profiles |
|
||||
| `/api/resilience/reset` | POST | Reset circuit breakers |
|
||||
| `/api/rate-limits` | GET | Per-account rate limit status |
|
||||
| `/api/rate-limit` | GET | Global rate limit configuration |
|
||||
|
||||
### Evals
|
||||
|
||||
|
||||
@@ -507,24 +507,25 @@ post_install() {
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------- | ------------------------------------ | ------------------------------------------------------- |
|
||||
| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) |
|
||||
| `INITIAL_PASSWORD` | `123456` | First login password |
|
||||
| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) |
|
||||
| `PORT` | framework default | Service port (`20128` in examples) |
|
||||
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
|
||||
| `NODE_ENV` | runtime default | Set `production` for deploy |
|
||||
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL |
|
||||
| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL |
|
||||
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
|
||||
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` |
|
||||
| `ALLOW_API_KEY_REVEAL` | `false` | Allow Api Manager to copy full API keys on demand |
|
||||
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs |
|
||||
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) |
|
||||
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB |
|
||||
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries |
|
||||
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries |
|
||||
| Variable | Default | Description |
|
||||
| ------------------------- | ------------------------------------ | ---------------------------------------------------------------- |
|
||||
| `JWT_SECRET` | `omniroute-default-secret-change-me` | JWT signing secret (**change in production**) |
|
||||
| `INITIAL_PASSWORD` | `123456` | First login password |
|
||||
| `DATA_DIR` | `~/.omniroute` | Data directory (db, usage, logs) |
|
||||
| `PORT` | framework default | Service port (`20128` in examples) |
|
||||
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
|
||||
| `NODE_ENV` | runtime default | Set `production` for deploy |
|
||||
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL |
|
||||
| `CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL |
|
||||
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
|
||||
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` |
|
||||
| `ALLOW_API_KEY_REVEAL` | `false` | Allow Api Manager to copy full API keys on demand |
|
||||
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs |
|
||||
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) |
|
||||
| `CLOUDFLARED_BIN` | unset | Use an existing `cloudflared` binary instead of managed download |
|
||||
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB |
|
||||
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries |
|
||||
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries |
|
||||
|
||||
For the full environment variable reference, see the [README](../README.md).
|
||||
|
||||
@@ -639,6 +640,14 @@ Returns models grouped by provider with types (`chat`, `embedding`, `image`).
|
||||
- Automatic background sync with timeout + fail-fast
|
||||
- Prefer server-side `BASE_URL`/`CLOUD_URL` in production
|
||||
|
||||
### Cloudflare Quick Tunnel
|
||||
|
||||
- Available in **Dashboard → Endpoints** for Docker and other self-hosted deployments
|
||||
- Creates a temporary `https://*.trycloudflare.com` URL that forwards to your current OpenAI-compatible `/v1` endpoint
|
||||
- First enable installs `cloudflared` only when needed; later restarts reuse the same managed binary
|
||||
- Tunnel URLs are ephemeral and change every time you stop/start the tunnel
|
||||
- Set `CLOUDFLARED_BIN` if you prefer using a preinstalled `cloudflared` binary instead of the managed download
|
||||
|
||||
### LLM Gateway Intelligence (Phase 9)
|
||||
|
||||
- **Semantic Cache** — Auto-caches non-streaming, temperature=0 responses (bypass with `X-OmniRoute-No-Cache: true`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Link from "next/link";
|
||||
import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components";
|
||||
@@ -11,6 +11,36 @@ import { useTranslations } from "next-intl";
|
||||
const BUILD_TIME_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || null;
|
||||
const CLOUD_ACTION_TIMEOUT_MS = 15000;
|
||||
|
||||
type TranslationValues = Record<string, string | number | boolean | Date>;
|
||||
type CloudflaredTunnelPhase =
|
||||
| "unsupported"
|
||||
| "not_installed"
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "error";
|
||||
|
||||
type CloudflaredTunnelStatus = {
|
||||
supported: boolean;
|
||||
installed: boolean;
|
||||
managedInstall: boolean;
|
||||
installSource: string | null;
|
||||
binaryPath: string | null;
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
publicUrl: string | null;
|
||||
apiUrl: string | null;
|
||||
targetUrl: string;
|
||||
phase: CloudflaredTunnelPhase;
|
||||
lastError: string | null;
|
||||
logPath: string;
|
||||
};
|
||||
|
||||
type TunnelNotice = {
|
||||
type: "success" | "error" | "info";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export default function APIPageClient({ machineId }) {
|
||||
const [resolvedMachineId, setResolvedMachineId] = useState(machineId || "");
|
||||
const t = useTranslations("endpoint");
|
||||
@@ -36,9 +66,27 @@ export default function APIPageClient({ machineId }) {
|
||||
const [mcpStatus, setMcpStatus] = useState<any>(null);
|
||||
const [a2aStatus, setA2aStatus] = useState<any>(null);
|
||||
const [searchProviders, setSearchProviders] = useState<any[]>([]);
|
||||
const [cloudflaredStatus, setCloudflaredStatus] = useState<CloudflaredTunnelStatus | null>(null);
|
||||
const [cloudflaredBusy, setCloudflaredBusy] = useState(false);
|
||||
const [cloudflaredNotice, setCloudflaredNotice] = useState<TunnelNotice | null>(null);
|
||||
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const translateOrFallback = useCallback(
|
||||
(key: string, fallback: string, values?: TranslationValues) => {
|
||||
try {
|
||||
const message = values ? t(key as never, values as never) : t(key as never);
|
||||
if (!message || message === key || message === `endpoint.${key}`) {
|
||||
return fallback;
|
||||
}
|
||||
return message;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const fetchSearchProviders = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/search/providers");
|
||||
@@ -51,16 +99,53 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCloudflaredStatus = useCallback(
|
||||
async (silent = false) => {
|
||||
try {
|
||||
const res = await fetch("/api/tunnels/cloudflared", { cache: "no-store" });
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
data?.error ||
|
||||
translateOrFallback(
|
||||
"cloudflaredRequestFailed",
|
||||
"Failed to load Cloudflare tunnel status"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
setCloudflaredStatus(data);
|
||||
return data as CloudflaredTunnelStatus;
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
setCloudflaredNotice({
|
||||
type: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translateOrFallback(
|
||||
"cloudflaredRequestFailed",
|
||||
"Failed to load Cloudflare tunnel status"
|
||||
),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[translateOrFallback]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
loadCloudSettings(),
|
||||
fetchModels(),
|
||||
fetchProtocolStatus(),
|
||||
fetchSearchProviders(),
|
||||
fetchCloudflaredStatus(true),
|
||||
]).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
}, [fetchCloudflaredStatus]);
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
@@ -177,9 +262,19 @@ export default function APIPageClient({ machineId }) {
|
||||
}, [cloudStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchProtocolStatus, 30000);
|
||||
if (cloudflaredNotice) {
|
||||
const timer = setTimeout(() => setCloudflaredNotice(null), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [cloudflaredNotice]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void fetchProtocolStatus();
|
||||
void fetchCloudflaredStatus(true);
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [fetchCloudflaredStatus]);
|
||||
|
||||
const dispatchCloudChange = () => {
|
||||
globalThis.dispatchEvent(new Event("cloud-status-changed"));
|
||||
@@ -275,6 +370,50 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloudflaredAction = async (action: "enable" | "disable") => {
|
||||
setCloudflaredBusy(true);
|
||||
setCloudflaredNotice(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/tunnels/cloudflared", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
data?.error ||
|
||||
translateOrFallback("cloudflaredRequestFailed", "Failed to update Cloudflare tunnel")
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.status) {
|
||||
setCloudflaredStatus(data.status);
|
||||
}
|
||||
|
||||
setCloudflaredNotice({
|
||||
type: "success",
|
||||
message:
|
||||
action === "enable"
|
||||
? translateOrFallback("cloudflaredStarted", "Cloudflare tunnel started")
|
||||
: translateOrFallback("cloudflaredStopped", "Cloudflare tunnel stopped"),
|
||||
});
|
||||
} catch (error) {
|
||||
setCloudflaredNotice({
|
||||
type: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translateOrFallback("cloudflaredRequestFailed", "Failed to update Cloudflare tunnel"),
|
||||
});
|
||||
} finally {
|
||||
setCloudflaredBusy(false);
|
||||
await fetchCloudflaredStatus(true);
|
||||
}
|
||||
};
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState("/v1");
|
||||
const normalizedCloudBaseUrl = cloudBaseUrl
|
||||
? resolvedMachineId && !cloudBaseUrl.endsWith(`/${resolvedMachineId}`)
|
||||
@@ -305,6 +444,43 @@ export default function APIPageClient({ machineId }) {
|
||||
const a2aOnline = a2aStatus?.status === "ok";
|
||||
const mcpToolCount = Number(mcpStatus?.heartbeat?.toolCount || 0);
|
||||
const a2aActiveStreams = Number(a2aStatus?.tasks?.activeStreams || 0);
|
||||
const cloudflaredPhase = cloudflaredStatus?.phase || "not_installed";
|
||||
const cloudflaredPhaseMeta: Record<CloudflaredTunnelPhase, { label: string; className: string }> =
|
||||
{
|
||||
running: {
|
||||
label: translateOrFallback("cloudflaredRunning", "Running"),
|
||||
className: "bg-green-500/10 border-green-500/30 text-green-400",
|
||||
},
|
||||
starting: {
|
||||
label: translateOrFallback("cloudflaredStarting", "Starting"),
|
||||
className: "bg-blue-500/10 border-blue-500/30 text-blue-400",
|
||||
},
|
||||
stopped: {
|
||||
label: translateOrFallback("cloudflaredStoppedState", "Stopped"),
|
||||
className: "bg-surface border-border/70 text-text-muted",
|
||||
},
|
||||
not_installed: {
|
||||
label: translateOrFallback("cloudflaredNotInstalled", "Not installed"),
|
||||
className: "bg-surface border-border/70 text-text-muted",
|
||||
},
|
||||
unsupported: {
|
||||
label: translateOrFallback("cloudflaredUnsupported", "Unsupported"),
|
||||
className: "bg-amber-500/10 border-amber-500/30 text-amber-400",
|
||||
},
|
||||
error: {
|
||||
label: translateOrFallback("cloudflaredError", "Error"),
|
||||
className: "bg-red-500/10 border-red-500/30 text-red-400",
|
||||
},
|
||||
};
|
||||
const cloudflaredActionLabel = cloudflaredStatus?.running
|
||||
? translateOrFallback("cloudflaredDisable", "Stop Tunnel")
|
||||
: cloudflaredStatus?.installed
|
||||
? translateOrFallback("cloudflaredEnable", "Enable Tunnel")
|
||||
: translateOrFallback("cloudflaredInstallAndEnable", "Install & Enable");
|
||||
const cloudflaredUrlNotice = translateOrFallback(
|
||||
"cloudflaredUrlNotice",
|
||||
"Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -407,6 +583,99 @@ export default function APIPageClient({ machineId }) {
|
||||
{copied === "endpoint_url" ? tc("copied") : tc("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-surface/40 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{translateOrFallback("cloudflaredTitle", "Cloudflare Quick Tunnel")}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${cloudflaredPhaseMeta[cloudflaredPhase].className}`}
|
||||
>
|
||||
{cloudflaredPhaseMeta[cloudflaredPhase].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cloudflaredStatus?.supported !== false && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={cloudflaredStatus?.running ? "secondary" : "primary"}
|
||||
icon={cloudflaredStatus?.running ? "cloud_off" : "cloud_upload"}
|
||||
onClick={() =>
|
||||
handleCloudflaredAction(cloudflaredStatus?.running ? "disable" : "enable")
|
||||
}
|
||||
loading={cloudflaredBusy}
|
||||
className={
|
||||
cloudflaredStatus?.running
|
||||
? "border-border/70! text-text-muted! hover:text-text!"
|
||||
: "bg-linear-to-r from-primary to-cyan-500 hover:from-primary-hover hover:to-cyan-600"
|
||||
}
|
||||
>
|
||||
{cloudflaredActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cloudflaredNotice && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
cloudflaredNotice.type === "success"
|
||||
? "border-green-500/30 bg-green-500/10 text-green-400"
|
||||
: cloudflaredNotice.type === "info"
|
||||
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
|
||||
: "border-red-500/30 bg-red-500/10 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{cloudflaredNotice.type === "success"
|
||||
? "check_circle"
|
||||
: cloudflaredNotice.type === "info"
|
||||
? "info"
|
||||
: "error"}
|
||||
</span>
|
||||
<span className="flex-1">{cloudflaredNotice.message}</span>
|
||||
<button
|
||||
onClick={() => setCloudflaredNotice(null)}
|
||||
className="rounded p-0.5 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">{cloudflaredUrlNotice}</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={cloudflaredStatus?.apiUrl || ""}
|
||||
readOnly
|
||||
placeholder="https://*.trycloudflare.com/v1"
|
||||
className="flex-1 min-w-0 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "cloudflared_url" ? "check" : "content_copy"}
|
||||
onClick={() =>
|
||||
cloudflaredStatus?.apiUrl && copy(cloudflaredStatus.apiUrl, "cloudflared_url")
|
||||
}
|
||||
disabled={!cloudflaredStatus?.apiUrl}
|
||||
className="shrink-0 self-start sm:self-auto"
|
||||
>
|
||||
{copied === "cloudflared_url" ? tc("copied") : tc("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
{cloudflaredStatus?.lastError && (
|
||||
<p className="text-xs text-red-400">
|
||||
{translateOrFallback("cloudflaredLastError", "Last error: {error}", {
|
||||
error: cloudflaredStatus.lastError,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
|
||||
74
src/app/api/tunnels/cloudflared/route.ts
Normal file
74
src/app/api/tunnels/cloudflared/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import {
|
||||
getCloudflaredTunnelStatus,
|
||||
startCloudflaredTunnel,
|
||||
stopCloudflaredTunnel,
|
||||
} from "@/lib/cloudflaredTunnel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const actionSchema = z.object({
|
||||
action: z.enum(["enable", "disable"]),
|
||||
});
|
||||
|
||||
function unauthorized() {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getCloudflaredTunnelStatus();
|
||||
return NextResponse.json(status);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to load cloudflared tunnel status",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = actionSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const status =
|
||||
parsed.data.action === "enable"
|
||||
? await startCloudflaredTunnel()
|
||||
: await stopCloudflaredTunnel();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: parsed.data.action,
|
||||
status,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to update cloudflared tunnel",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1031,6 +1031,25 @@
|
||||
"cloudWorkerUnreachable": "Could not reach cloud worker. Make sure the cloud service is running (npm run dev in /cloud).",
|
||||
"connectionFailed": "Connection failed",
|
||||
"syncFailed": "Failed to sync cloud data",
|
||||
"cloudflaredTitle": "Cloudflare Quick Tunnel",
|
||||
"cloudflaredDescription": "Create a Cloudflare Quick Tunnel for this endpoint.",
|
||||
"cloudflaredUrlNotice": "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart.",
|
||||
"cloudflaredEnable": "Enable Tunnel",
|
||||
"cloudflaredInstallAndEnable": "Install & Enable",
|
||||
"cloudflaredDisable": "Stop Tunnel",
|
||||
"cloudflaredRunning": "Running",
|
||||
"cloudflaredStarting": "Starting",
|
||||
"cloudflaredStoppedState": "Stopped",
|
||||
"cloudflaredNotInstalled": "Not installed",
|
||||
"cloudflaredUnsupported": "Unsupported",
|
||||
"cloudflaredError": "Error",
|
||||
"cloudflaredStarted": "Cloudflare tunnel started",
|
||||
"cloudflaredStopped": "Cloudflare tunnel stopped",
|
||||
"cloudflaredRequestFailed": "Failed to update Cloudflare tunnel",
|
||||
"cloudflaredTemporaryNote": "Quick Tunnel URLs are temporary and will change after every restart.",
|
||||
"cloudflaredUnsupportedNote": "This platform is not supported for managed installs. Install cloudflared manually or point CLOUDFLARED_BIN to an existing binary.",
|
||||
"cloudflaredIdleNote": "Create a temporary Cloudflare Quick Tunnel for this endpoint.",
|
||||
"cloudflaredLastError": "Last error: {error}",
|
||||
"providerModelsTitle": "{provider} — Models",
|
||||
"noModelsForProvider": "No models available for this provider.",
|
||||
"chat": "Chat",
|
||||
|
||||
@@ -1031,6 +1031,25 @@
|
||||
"cloudWorkerUnreachable": "无法连接到云 Worker。请确认云服务已运行(在 `/cloud` 中执行 `npm run dev`)。",
|
||||
"connectionFailed": "连接失败",
|
||||
"syncFailed": "同步云端数据失败",
|
||||
"cloudflaredTitle": "Cloudflare Quick Tunnel",
|
||||
"cloudflaredDescription": "为当前端点创建 Cloudflare Quick Tunnel。",
|
||||
"cloudflaredUrlNotice": "创建一个临时的 Cloudflare Quick Tunnel。每次重启后 URL 都会变化。",
|
||||
"cloudflaredEnable": "启用 Tunnel",
|
||||
"cloudflaredInstallAndEnable": "安装并启用",
|
||||
"cloudflaredDisable": "停止 Tunnel",
|
||||
"cloudflaredRunning": "运行中",
|
||||
"cloudflaredStarting": "启动中",
|
||||
"cloudflaredStoppedState": "已停止",
|
||||
"cloudflaredNotInstalled": "未安装",
|
||||
"cloudflaredUnsupported": "不支持",
|
||||
"cloudflaredError": "错误",
|
||||
"cloudflaredStarted": "Cloudflare Tunnel 已启动",
|
||||
"cloudflaredStopped": "Cloudflare Tunnel 已停止",
|
||||
"cloudflaredRequestFailed": "更新 Cloudflare Tunnel 失败",
|
||||
"cloudflaredTemporaryNote": "Quick Tunnel URL 是临时地址,每次重启后都会变化。",
|
||||
"cloudflaredUnsupportedNote": "当前平台不支持托管安装。请自行安装 cloudflared,或通过 CLOUDFLARED_BIN 指向已有二进制文件。",
|
||||
"cloudflaredIdleNote": "为当前端点创建一个临时的 Cloudflare Quick Tunnel。",
|
||||
"cloudflaredLastError": "最近错误:{error}",
|
||||
"providerModelsTitle": "{provider} — 模型",
|
||||
"noModelsForProvider": "该提供商当前没有可用模型。",
|
||||
"chat": "聊天",
|
||||
|
||||
559
src/lib/cloudflaredTunnel.ts
Normal file
559
src/lib/cloudflaredTunnel.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
import { spawn, execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import { resolveDataDir } from "@/lib/dataPaths";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CLOUDFLARED_RELEASE_BASE =
|
||||
"https://github.com/cloudflare/cloudflared/releases/latest/download";
|
||||
const START_TIMEOUT_MS = 30000;
|
||||
const STOP_TIMEOUT_MS = 5000;
|
||||
|
||||
type CloudflaredInstallSource = "managed" | "path" | "env";
|
||||
type TunnelPhase = "unsupported" | "not_installed" | "stopped" | "starting" | "running" | "error";
|
||||
|
||||
type AssetSpec = {
|
||||
assetName: string;
|
||||
binaryName: string;
|
||||
archive: "none" | "tgz";
|
||||
downloadUrl: string;
|
||||
};
|
||||
|
||||
type BinaryResolution = {
|
||||
binaryPath: string | null;
|
||||
source: CloudflaredInstallSource | null;
|
||||
managed: boolean;
|
||||
};
|
||||
|
||||
type PersistedTunnelState = {
|
||||
binaryPath?: string | null;
|
||||
installSource?: CloudflaredInstallSource | null;
|
||||
pid?: number | null;
|
||||
publicUrl?: string | null;
|
||||
apiUrl?: string | null;
|
||||
targetUrl?: string | null;
|
||||
status?: TunnelPhase;
|
||||
lastError?: string | null;
|
||||
startedAt?: string | null;
|
||||
installedAt?: string | null;
|
||||
};
|
||||
|
||||
export type CloudflaredTunnelStatus = {
|
||||
supported: boolean;
|
||||
installed: boolean;
|
||||
managedInstall: boolean;
|
||||
installSource: CloudflaredInstallSource | null;
|
||||
binaryPath: string | null;
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
publicUrl: string | null;
|
||||
apiUrl: string | null;
|
||||
targetUrl: string;
|
||||
phase: TunnelPhase;
|
||||
lastError: string | null;
|
||||
logPath: string;
|
||||
};
|
||||
|
||||
let tunnelProcess: ReturnType<typeof spawn> | null = null;
|
||||
let tunnelPid: number | null = null;
|
||||
let installPromise: Promise<string> | null = null;
|
||||
let startPromise: Promise<CloudflaredTunnelStatus> | null = null;
|
||||
|
||||
function getTunnelDir() {
|
||||
return path.join(resolveDataDir(), "cloudflared");
|
||||
}
|
||||
|
||||
function getManagedBinaryPath(platform = process.platform) {
|
||||
return path.join(getTunnelDir(), "bin", platform === "win32" ? "cloudflared.exe" : "cloudflared");
|
||||
}
|
||||
|
||||
function getStateFilePath() {
|
||||
return path.join(getTunnelDir(), "quick-tunnel-state.json");
|
||||
}
|
||||
|
||||
function getPidFilePath() {
|
||||
return path.join(getTunnelDir(), ".quick-tunnel.pid");
|
||||
}
|
||||
|
||||
function getLogFilePath() {
|
||||
return path.join(getTunnelDir(), "quick-tunnel.log");
|
||||
}
|
||||
|
||||
function getLocalTargetUrl() {
|
||||
const { apiPort } = getRuntimePorts();
|
||||
return `http://127.0.0.1:${apiPort}`;
|
||||
}
|
||||
|
||||
function getTunnelApiUrl(publicUrl: string | null) {
|
||||
return publicUrl ? `${publicUrl.replace(/\/$/, "")}/v1` : null;
|
||||
}
|
||||
|
||||
async function ensureTunnelDir() {
|
||||
await fs.mkdir(path.join(getTunnelDir(), "bin"), { recursive: true });
|
||||
}
|
||||
|
||||
async function readStateFile(): Promise<PersistedTunnelState> {
|
||||
try {
|
||||
const content = await fs.readFile(getStateFilePath(), "utf8");
|
||||
return JSON.parse(content) as PersistedTunnelState;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeStateFile(state: PersistedTunnelState) {
|
||||
await ensureTunnelDir();
|
||||
await fs.writeFile(getStateFilePath(), JSON.stringify(state, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
async function updateStateFile(patch: PersistedTunnelState) {
|
||||
const current = await readStateFile();
|
||||
await writeStateFile({ ...current, ...patch });
|
||||
}
|
||||
|
||||
async function clearPidFile() {
|
||||
try {
|
||||
await fs.unlink(getPidFilePath());
|
||||
} catch {
|
||||
// Ignore missing/stale pid files.
|
||||
}
|
||||
}
|
||||
|
||||
async function writePidFile(pid: number) {
|
||||
await ensureTunnelDir();
|
||||
await fs.writeFile(getPidFilePath(), String(pid), "utf8");
|
||||
}
|
||||
|
||||
async function readPidFile() {
|
||||
try {
|
||||
const content = await fs.readFile(getPidFilePath(), "utf8");
|
||||
const pid = Number.parseInt(content.trim(), 10);
|
||||
return Number.isFinite(pid) ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number | null) {
|
||||
if (!pid || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function appendTunnelLog(source: "stdout" | "stderr", message: string) {
|
||||
await ensureTunnelDir();
|
||||
const timestamp = new Date().toISOString();
|
||||
await fs.appendFile(getLogFilePath(), `[${timestamp}] [${source}] ${message}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function extractTryCloudflareUrl(text: string) {
|
||||
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/i);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
export function getCloudflaredAssetSpec(
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
): AssetSpec | null {
|
||||
const matrix: Record<string, Record<string, Omit<AssetSpec, "downloadUrl">>> = {
|
||||
linux: {
|
||||
x64: {
|
||||
assetName: "cloudflared-linux-amd64",
|
||||
binaryName: "cloudflared",
|
||||
archive: "none",
|
||||
},
|
||||
arm64: {
|
||||
assetName: "cloudflared-linux-arm64",
|
||||
binaryName: "cloudflared",
|
||||
archive: "none",
|
||||
},
|
||||
},
|
||||
darwin: {
|
||||
x64: {
|
||||
assetName: "cloudflared-darwin-amd64.tgz",
|
||||
binaryName: "cloudflared",
|
||||
archive: "tgz",
|
||||
},
|
||||
arm64: {
|
||||
assetName: "cloudflared-darwin-arm64.tgz",
|
||||
binaryName: "cloudflared",
|
||||
archive: "tgz",
|
||||
},
|
||||
},
|
||||
win32: {
|
||||
x64: {
|
||||
assetName: "cloudflared-windows-amd64.exe",
|
||||
binaryName: "cloudflared.exe",
|
||||
archive: "none",
|
||||
},
|
||||
arm64: {
|
||||
assetName: "cloudflared-windows-arm64.exe",
|
||||
binaryName: "cloudflared.exe",
|
||||
archive: "none",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const spec = matrix[platform]?.[arch];
|
||||
if (!spec) return null;
|
||||
|
||||
return {
|
||||
...spec,
|
||||
downloadUrl: `${CLOUDFLARED_RELEASE_BASE}/${spec.assetName}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePathCommand(command: string) {
|
||||
const lookupCommand = process.platform === "win32" ? "where" : "which";
|
||||
const args = [command];
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(lookupCommand, args, { timeout: 3000 });
|
||||
const first = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
return first || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBinary(): Promise<BinaryResolution> {
|
||||
const envPath = String(process.env.CLOUDFLARED_BIN || "").trim();
|
||||
if (envPath && fsSync.existsSync(envPath)) {
|
||||
return { binaryPath: envPath, source: "env", managed: false };
|
||||
}
|
||||
|
||||
const managedPath = getManagedBinaryPath();
|
||||
if (fsSync.existsSync(managedPath)) {
|
||||
return { binaryPath: managedPath, source: "managed", managed: true };
|
||||
}
|
||||
|
||||
const pathBinary = await resolvePathCommand("cloudflared");
|
||||
if (pathBinary) {
|
||||
return { binaryPath: pathBinary, source: "path", managed: false };
|
||||
}
|
||||
|
||||
return { binaryPath: null, source: null, managed: false };
|
||||
}
|
||||
|
||||
async function extractArchive(archivePath: string, destinationDir: string) {
|
||||
await execFileAsync("tar", ["-xzf", archivePath, "-C", destinationDir], { timeout: 15000 });
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, destinationPath: string) {
|
||||
const response = await fetch(url, { redirect: "follow" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
await fs.writeFile(destinationPath, buffer);
|
||||
}
|
||||
|
||||
async function ensureExecutable(binaryPath: string) {
|
||||
if (process.platform !== "win32") {
|
||||
await fs.chmod(binaryPath, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
async function installManagedBinary() {
|
||||
if (installPromise) return installPromise;
|
||||
|
||||
installPromise = (async () => {
|
||||
const spec = getCloudflaredAssetSpec();
|
||||
if (!spec) {
|
||||
throw new Error(
|
||||
`Unsupported platform for managed cloudflared install: ${process.platform}/${process.arch}`
|
||||
);
|
||||
}
|
||||
|
||||
await ensureTunnelDir();
|
||||
const managedBinaryPath = getManagedBinaryPath();
|
||||
const tempDownloadPath = path.join(getTunnelDir(), `${spec.assetName}.download`);
|
||||
|
||||
await updateStateFile({
|
||||
status: "starting",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
try {
|
||||
await downloadToFile(spec.downloadUrl, tempDownloadPath);
|
||||
|
||||
if (spec.archive === "tgz") {
|
||||
await extractArchive(tempDownloadPath, path.dirname(managedBinaryPath));
|
||||
} else {
|
||||
await fs.rename(tempDownloadPath, managedBinaryPath);
|
||||
}
|
||||
|
||||
await ensureExecutable(managedBinaryPath);
|
||||
await updateStateFile({
|
||||
binaryPath: managedBinaryPath,
|
||||
installSource: "managed",
|
||||
installedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
return managedBinaryPath;
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(tempDownloadPath);
|
||||
} catch {
|
||||
// Ignore temp cleanup issues.
|
||||
}
|
||||
installPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return installPromise;
|
||||
}
|
||||
|
||||
async function ensureBinary() {
|
||||
const resolved = await resolveBinary();
|
||||
if (resolved.binaryPath) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const binaryPath = await installManagedBinary();
|
||||
return {
|
||||
binaryPath,
|
||||
source: "managed" as const,
|
||||
managed: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function finalizeProcessExit(code: number | null, signal: NodeJS.Signals | null) {
|
||||
const currentState = await readStateFile();
|
||||
const lastError =
|
||||
code === 0 || signal === "SIGTERM" || signal === "SIGINT"
|
||||
? null
|
||||
: `cloudflared exited unexpectedly (${code ?? "signal"}${signal ? `/${signal}` : ""})`;
|
||||
|
||||
tunnelProcess = null;
|
||||
tunnelPid = null;
|
||||
await clearPidFile();
|
||||
await writeStateFile({
|
||||
...currentState,
|
||||
pid: null,
|
||||
publicUrl: null,
|
||||
apiUrl: null,
|
||||
status: lastError ? "error" : "stopped",
|
||||
lastError,
|
||||
});
|
||||
}
|
||||
|
||||
async function killPid(pid: number) {
|
||||
process.kill(pid, "SIGTERM");
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < STOP_TIMEOUT_MS) {
|
||||
if (!isProcessAlive(pid)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
if (isProcessAlive(pid)) {
|
||||
process.kill(pid, "SIGKILL");
|
||||
}
|
||||
}
|
||||
|
||||
async function stopExistingTunnel() {
|
||||
if (tunnelProcess && tunnelPid && !tunnelProcess.killed) {
|
||||
const pid = tunnelPid;
|
||||
tunnelProcess.kill("SIGTERM");
|
||||
await killPid(pid);
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = await readPidFile();
|
||||
if (pid && isProcessAlive(pid)) {
|
||||
await killPid(pid);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCloudflaredTunnelStatus(): Promise<CloudflaredTunnelStatus> {
|
||||
const state = await readStateFile();
|
||||
const resolved = await resolveBinary();
|
||||
const pidFromState = tunnelPid || state.pid || (await readPidFile());
|
||||
const running = isProcessAlive(pidFromState);
|
||||
const publicUrl = running ? state.publicUrl || null : null;
|
||||
const phase =
|
||||
!getCloudflaredAssetSpec() && !resolved.binaryPath
|
||||
? "unsupported"
|
||||
: running
|
||||
? publicUrl
|
||||
? "running"
|
||||
: "starting"
|
||||
: resolved.binaryPath
|
||||
? state.lastError
|
||||
? "error"
|
||||
: "stopped"
|
||||
: "not_installed";
|
||||
|
||||
if (!running && state.pid) {
|
||||
await clearPidFile();
|
||||
}
|
||||
|
||||
return {
|
||||
supported: !!(getCloudflaredAssetSpec() || resolved.binaryPath),
|
||||
installed: !!resolved.binaryPath,
|
||||
managedInstall: resolved.managed,
|
||||
installSource: resolved.source,
|
||||
binaryPath: resolved.binaryPath,
|
||||
running,
|
||||
pid: running ? pidFromState : null,
|
||||
publicUrl,
|
||||
apiUrl: publicUrl ? getTunnelApiUrl(publicUrl) : null,
|
||||
targetUrl: state.targetUrl || getLocalTargetUrl(),
|
||||
phase,
|
||||
lastError: running ? null : state.lastError || null,
|
||||
logPath: getLogFilePath(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function startCloudflaredTunnel(): Promise<CloudflaredTunnelStatus> {
|
||||
const current = await getCloudflaredTunnelStatus();
|
||||
if (current.running) return current;
|
||||
if (startPromise) return startPromise;
|
||||
|
||||
startPromise = (async () => {
|
||||
const spec = getCloudflaredAssetSpec();
|
||||
if (!spec && !(await resolveBinary()).binaryPath) {
|
||||
throw new Error(
|
||||
`Unsupported platform for cloudflared tunnel: ${process.platform}/${process.arch}`
|
||||
);
|
||||
}
|
||||
|
||||
const binary = await ensureBinary();
|
||||
const targetUrl = getLocalTargetUrl();
|
||||
|
||||
await stopExistingTunnel();
|
||||
await ensureTunnelDir();
|
||||
await fs.writeFile(getLogFilePath(), "", "utf8");
|
||||
|
||||
await writeStateFile({
|
||||
binaryPath: binary.binaryPath,
|
||||
installSource: binary.source,
|
||||
pid: null,
|
||||
publicUrl: null,
|
||||
apiUrl: null,
|
||||
targetUrl,
|
||||
status: "starting",
|
||||
lastError: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const child = spawn(
|
||||
binary.binaryPath as string,
|
||||
["tunnel", "--url", targetUrl, "--no-autoupdate", "--protocol", "http2"],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
}
|
||||
);
|
||||
|
||||
tunnelProcess = child;
|
||||
tunnelPid = child.pid ?? null;
|
||||
|
||||
if (!child.pid) {
|
||||
throw new Error("cloudflared failed to start");
|
||||
}
|
||||
|
||||
await writePidFile(child.pid);
|
||||
await updateStateFile({ pid: child.pid, status: "starting" });
|
||||
|
||||
const ready = await new Promise<CloudflaredTunnelStatus>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const settle = (handler: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
handler();
|
||||
};
|
||||
|
||||
const handleOutput = async (source: "stdout" | "stderr", chunk: Buffer) => {
|
||||
const text = chunk.toString("utf8").trim();
|
||||
if (!text) return;
|
||||
|
||||
await appendTunnelLog(source, text);
|
||||
const url = extractTryCloudflareUrl(text);
|
||||
if (!url) return;
|
||||
|
||||
const apiUrl = getTunnelApiUrl(url);
|
||||
await updateStateFile({
|
||||
pid: child.pid,
|
||||
publicUrl: url,
|
||||
apiUrl,
|
||||
status: "running",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const status = await getCloudflaredTunnelStatus();
|
||||
settle(() => resolve(status));
|
||||
};
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
void handleOutput("stdout", chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
void handleOutput("stderr", chunk);
|
||||
});
|
||||
|
||||
child.once("exit", (code, signal) => {
|
||||
void finalizeProcessExit(code, signal);
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`cloudflared exited before tunnel URL was ready (${code ?? "signal"}${signal ? `/${signal}` : ""})`
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
timeout = setTimeout(async () => {
|
||||
await stopExistingTunnel();
|
||||
settle(() => reject(new Error("Timed out while waiting for Cloudflare tunnel URL")));
|
||||
}, START_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
return ready;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await startPromise;
|
||||
} catch (error) {
|
||||
await updateStateFile({
|
||||
status: "error",
|
||||
lastError: error instanceof Error ? error.message : "Failed to start cloudflared tunnel",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopCloudflaredTunnel() {
|
||||
await stopExistingTunnel();
|
||||
const current = await readStateFile();
|
||||
await writeStateFile({
|
||||
...current,
|
||||
pid: null,
|
||||
publicUrl: null,
|
||||
apiUrl: null,
|
||||
status: "stopped",
|
||||
lastError: null,
|
||||
});
|
||||
tunnelProcess = null;
|
||||
tunnelPid = null;
|
||||
await clearPidFile();
|
||||
return getCloudflaredTunnelStatus();
|
||||
}
|
||||
47
tests/unit/cloudflaredTunnel.test.mjs
Normal file
47
tests/unit/cloudflaredTunnel.test.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
extractTryCloudflareUrl,
|
||||
getCloudflaredAssetSpec,
|
||||
} from "../../src/lib/cloudflaredTunnel.ts";
|
||||
|
||||
test("extractTryCloudflareUrl parses trycloudflare URL from log output", () => {
|
||||
const url = extractTryCloudflareUrl(
|
||||
"INF +------------------------------------------------------------+\nINF | https://violet-sky-1234.trycloudflare.com |\nINF +------------------------------------------------------------+"
|
||||
);
|
||||
|
||||
assert.equal(url, "https://violet-sky-1234.trycloudflare.com");
|
||||
});
|
||||
|
||||
test("extractTryCloudflareUrl returns null when no tunnel URL is present", () => {
|
||||
assert.equal(extractTryCloudflareUrl("cloudflared starting without assigned URL"), null);
|
||||
});
|
||||
|
||||
test("getCloudflaredAssetSpec resolves linux amd64 binary", () => {
|
||||
const spec = getCloudflaredAssetSpec("linux", "x64");
|
||||
|
||||
assert.deepEqual(spec, {
|
||||
assetName: "cloudflared-linux-amd64",
|
||||
binaryName: "cloudflared",
|
||||
archive: "none",
|
||||
downloadUrl:
|
||||
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
|
||||
});
|
||||
});
|
||||
|
||||
test("getCloudflaredAssetSpec resolves darwin arm64 archive", () => {
|
||||
const spec = getCloudflaredAssetSpec("darwin", "arm64");
|
||||
|
||||
assert.deepEqual(spec, {
|
||||
assetName: "cloudflared-darwin-arm64.tgz",
|
||||
binaryName: "cloudflared",
|
||||
archive: "tgz",
|
||||
downloadUrl:
|
||||
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz",
|
||||
});
|
||||
});
|
||||
|
||||
test("getCloudflaredAssetSpec returns null for unsupported platforms", () => {
|
||||
assert.equal(getCloudflaredAssetSpec("freebsd", "x64"), null);
|
||||
});
|
||||
Reference in New Issue
Block a user