From b96b85f675c3e2debe3c655ee1176c0da62b0660 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 29 Apr 2026 00:45:18 -0300 Subject: [PATCH] fix(models): apply blocked providers filter to non-chat catalog models (#1752) --- CHANGELOG.md | 1 + package-lock.json | 246 ++++++++++++++++- package.json | 3 +- .../dashboard/endpoint/EndpointPageClient.tsx | 253 +++++++++++++++++- src/app/api/tunnels/ngrok/route.ts | 74 +++++ src/app/api/v1/models/catalog.ts | 11 + src/i18n/messages/en.json | 19 +- src/i18n/messages/pt-BR.json | 19 +- src/i18n/messages/pt.json | 19 +- src/lib/ngrokTunnel.ts | 142 ++++++++++ 10 files changed, 780 insertions(+), 7 deletions(-) create mode 100644 src/app/api/tunnels/ngrok/route.ts create mode 100644 src/lib/ngrokTunnel.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8d7e928d..0311e61be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 🐛 Bug Fixes +- **fix(models):** apply blocked providers filter to non-chat catalog models (image, embedding, audio, etc.) (#1752) - **fix(antigravity):** stabilize streaming payload parsing and deduplicate usage/model metadata refreshes (#1748) --- diff --git a/package-lock.json b/package-lock.json index dee96e1e29..dfab87f3b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.7.4", + "version": "3.7.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.7.4", + "version": "3.7.5", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -16,6 +16,7 @@ "@lobehub/icons": "^5.5.4", "@modelcontextprotocol/sdk": "^1.27.1", "@monaco-editor/react": "^4.7.0", + "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.21", "axios": "^1.15.0", "bcryptjs": "^3.0.3", @@ -2322,6 +2323,247 @@ "node": ">= 10" } }, + "node_modules/@ngrok/ngrok": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.7.0.tgz", + "integrity": "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g==", + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@ngrok/ngrok-android-arm64": "1.7.0", + "@ngrok/ngrok-darwin-arm64": "1.7.0", + "@ngrok/ngrok-darwin-universal": "1.7.0", + "@ngrok/ngrok-darwin-x64": "1.7.0", + "@ngrok/ngrok-freebsd-x64": "1.7.0", + "@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0", + "@ngrok/ngrok-linux-arm64-gnu": "1.7.0", + "@ngrok/ngrok-linux-arm64-musl": "1.7.0", + "@ngrok/ngrok-linux-x64-gnu": "1.7.0", + "@ngrok/ngrok-linux-x64-musl": "1.7.0", + "@ngrok/ngrok-win32-arm64-msvc": "1.7.0", + "@ngrok/ngrok-win32-ia32-msvc": "1.7.0", + "@ngrok/ngrok-win32-x64-msvc": "1.7.0" + } + }, + "node_modules/@ngrok/ngrok-android-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.7.0.tgz", + "integrity": "sha512-8tco3ID6noSaNy+CMS7ewqPoIkIM6XO5COCzsUp3Wv3XEbMSyn65RN6cflX2JdqLfUCHcMyD0ahr9IEiHwqmbQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.7.0.tgz", + "integrity": "sha512-+dmJSOzSO+MNDVrPOca2yYDP1W3KfP4qOlAkarIeFRIfqonQwq3QCBmcR7HAlZocLsSqEwyG6KP4RRvAuT0WGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-universal": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.7.0.tgz", + "integrity": "sha512-fDEfewyE2pWGFBhOSwQZObeHUkc65U1l+3HIgSOe094TMHsqmyJD0KTCgW9KSn0VP4OvDZbAISi1T3nvqgZYhQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.7.0.tgz", + "integrity": "sha512-+fwMi5uHd9G8BS42MMa9ye6exI5lwTcjUO6Ut497Vu0qgLONdVRenRqnEePV+Q3KtQR7NjqkMnomVfkr9MBjtw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-freebsd-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.7.0.tgz", + "integrity": "sha512-2OGgbrjy3yLRrqAz5N6hlUKIWIXSpR5RjQa2chtZMsSbszQ6c9dI+uVQfOKAeo05tHMUgrYAZ7FocC+ig0dzdQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm-gnueabihf": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.7.0.tgz", + "integrity": "sha512-SN9YIfEQiR9xN90QVNvdgvAemqMLoFVSeTWZs779145hQMhvF9Qd9rnWi6J+2uNNK10OczdV1oc/nq1es7u/3g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.7.0.tgz", + "integrity": "sha512-KDMgzPKFU2kbpVSaA2RZBBia5IPdJEe063YlyVFnSMJmPYWCUnMwdybBsucXfV9u1Lw/ZjKTKotIlbTWGn3HGw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.7.0.tgz", + "integrity": "sha512-e66vUdVrBlQ0lT9ZdamB4U604zt5Gualt8/WVcUGzbu8s5LajWd6g/mzZCUjK4UepjvMpfgmCp1/+rX7Rk8d5A==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.7.0.tgz", + "integrity": "sha512-M6gF0DyOEFqXLfWxObfL3bxYZ4+PnKBHuyLVaqNfFN9Y5utY2mdPOn5422Ppbk4XoIK5/YkuhRqPJl/9FivKEw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.7.0.tgz", + "integrity": "sha512-4Ijm0dKeoyzZTMaYxR2EiNjtlK81ebflg/WYIO1XtleFrVy4UJEGnxtxEidYoT4BfCqi4uvXiK2Mx216xXKvog==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-arm64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-arm64-msvc/-/ngrok-win32-arm64-msvc-1.7.0.tgz", + "integrity": "sha512-u7qyWIJI2/YG1HTBnHwUR1+Z2tyGfAsUAItJK/+N1G0FeWJhIWQvSIFJHlaPy4oW1Dc8mSDBX9qvVsiQgLaRFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-ia32-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.7.0.tgz", + "integrity": "sha512-/UdYUsLNv/Q8j9YJsyIfq/jLCoD8WP+NidouucTUzSoDtmOsXBBT3itLrmPiZTEdEgKiFYLuC1Zon8XQQvbVLA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-x64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.7.0.tgz", + "integrity": "sha512-UFJg/duEWzZlLkEs61Gz6/5nYhGaKI62I8dvUGdBR3NCtIMagehnFaFxmnXZldyHmCM8U0aCIFNpWRaKcrQkoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index e17f1e9d4e..0a2a1d4300 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.7.4", + "version": "3.7.5", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { @@ -110,6 +110,7 @@ "@lobehub/icons": "^5.5.4", "@modelcontextprotocol/sdk": "^1.27.1", "@monaco-editor/react": "^4.7.0", + "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.21", "axios": "^1.15.0", "bcryptjs": "^3.0.3", diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index d07926b240..3c3395797b 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -63,6 +63,26 @@ type TailscaleTunnelStatus = { pid: number | null; }; +type NgrokTunnelPhase = + | "unsupported" + | "not_installed" + | "stopped" + | "needs_auth" + | "starting" + | "running" + | "error"; + +type NgrokTunnelStatus = { + supported: boolean; + installed: boolean; + running: boolean; + publicUrl: string | null; + apiUrl: string | null; + targetUrl: string; + phase: NgrokTunnelPhase; + lastError: string | null; +}; + type TunnelNotice = { type: "success" | "error" | "info"; message: string; @@ -105,6 +125,11 @@ export default function APIPageClient({ machineId }) { const [tailscalePassword, setTailscalePassword] = useState(""); const [showCloudflaredTunnel, setShowCloudflaredTunnel] = useState(true); const [showTailscaleFunnel, setShowTailscaleFunnel] = useState(true); + const [ngrokStatus, setNgrokStatus] = useState(null); + const [ngrokBusy, setNgrokBusy] = useState(false); + const [ngrokNotice, setNgrokNotice] = useState(null); + const [ngrokToken, setNgrokToken] = useState(""); + const [showNgrokTunnel, setShowNgrokTunnel] = useState(true); const { copied, copy } = useCopyToClipboard(); @@ -201,6 +226,35 @@ export default function APIPageClient({ machineId }) { [translateOrFallback] ); + const fetchNgrokStatus = useCallback( + async (silent = false) => { + try { + const res = await fetch("/api/tunnels/ngrok", { cache: "no-store" }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error( + data?.error || translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status") + ); + } + + setNgrokStatus(data); + return data as NgrokTunnelStatus; + } catch (error) { + if (!silent) { + setNgrokNotice({ + type: "error", + message: + error instanceof Error + ? error.message + : translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status"), + }); + } + return null; + } + }, + [translateOrFallback] + ); + useEffect(() => { Promise.allSettled([ loadCloudSettings(), @@ -209,10 +263,11 @@ export default function APIPageClient({ machineId }) { fetchSearchProviders(), fetchCloudflaredStatus(true), fetchTailscaleStatus(true), + fetchNgrokStatus(true), ]).finally(() => { setLoading(false); }); - }, [fetchCloudflaredStatus, fetchTailscaleStatus]); + }, [fetchCloudflaredStatus, fetchTailscaleStatus, fetchNgrokStatus]); const fetchModels = async () => { try { @@ -502,6 +557,52 @@ export default function APIPageClient({ machineId }) { } }; + const handleNgrokAction = async (action: "enable" | "disable") => { + setNgrokBusy(true); + setNgrokNotice(null); + + try { + const res = await fetch("/api/tunnels/ngrok", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, authToken: action === "enable" ? ngrokToken : undefined }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error( + data?.error || translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel") + ); + } + + if (data?.status) { + setNgrokStatus(data.status); + } + + setNgrokNotice({ + type: "success", + message: + action === "enable" + ? translateOrFallback("ngrokStarted", "ngrok tunnel started") + : translateOrFallback("ngrokStopped", "ngrok tunnel stopped"), + }); + if (action === "enable") { + setNgrokToken(""); + } + } catch (error) { + setNgrokNotice({ + type: "error", + message: + error instanceof Error + ? error.message + : translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel"), + }); + } finally { + setNgrokBusy(false); + await fetchNgrokStatus(true); + } + }; + const waitForTailscale = useCallback( async ( predicate: (status: TailscaleTunnelStatus) => boolean, @@ -891,6 +992,42 @@ export default function APIPageClient({ machineId }) { "Uses your Tailscale .ts.net address. Login and Funnel approval may be required on first use." ); + const ngrokPhase = ngrokStatus?.phase || "not_installed"; + const ngrokPhaseMeta: Record = { + running: { + label: translateOrFallback("ngrokRunning", "Running"), + className: "bg-green-500/10 border-green-500/30 text-green-400", + }, + starting: { + label: translateOrFallback("ngrokStarting", "Starting"), + className: "bg-blue-500/10 border-blue-500/30 text-blue-400", + }, + stopped: { + label: translateOrFallback("ngrokStoppedState", "Stopped"), + className: "bg-surface border-border/70 text-text-muted", + }, + needs_auth: { + label: translateOrFallback("ngrokNeedsAuth", "Needs Auth"), + className: "bg-amber-500/10 border-amber-500/30 text-amber-400", + }, + not_installed: { + label: translateOrFallback("ngrokNotInstalled", "Not installed"), + className: "bg-surface border-border/70 text-text-muted", + }, + unsupported: { + label: translateOrFallback("ngrokUnsupported", "Unsupported"), + className: "bg-amber-500/10 border-amber-500/30 text-amber-400", + }, + error: { + label: translateOrFallback("ngrokError", "Error"), + className: "bg-red-500/10 border-red-500/30 text-red-400", + }, + }; + const ngrokActionLabel = ngrokStatus?.running + ? translateOrFallback("ngrokDisable", "Stop Tunnel") + : translateOrFallback("ngrokEnable", "Enable Tunnel"); + const ngrokUrlNotice = translateOrFallback("ngrokUrlNotice", "Creates a public ngrok tunnel."); + return (
{/* Endpoint Card */} @@ -1229,6 +1366,120 @@ export default function APIPageClient({ machineId }) {
)} + + {showNgrokTunnel && ( +
+
+
+
+
+

+ {translateOrFallback("ngrokTitle", "ngrok Tunnel")} +

+ + {ngrokPhaseMeta[ngrokPhase].label} + +
+
+ + {ngrokStatus?.supported !== false && ( + + )} +
+ + {ngrokNotice && ( +
+ + {ngrokNotice.type === "success" + ? "check_circle" + : ngrokNotice.type === "info" + ? "info" + : "error"} + + {ngrokNotice.message} + +
+ )} + +

{ngrokUrlNotice}

+ {ngrokStatus?.phase === "needs_auth" && ( +
+ + setNgrokToken(event.target.value)} + placeholder={translateOrFallback( + "ngrokAuthTokenPlaceholder", + "Enter your ngrok authtoken" + )} + disabled={ngrokBusy} + className="font-mono text-sm" + /> +
+ )} +
+ + +
+ {ngrokStatus?.lastError && ( +

+ {translateOrFallback("ngrokLastError", "Last error: {error}", { + error: ngrokStatus.lastError, + })} +

+ )} +
+
+ )} diff --git a/src/app/api/tunnels/ngrok/route.ts b/src/app/api/tunnels/ngrok/route.ts new file mode 100644 index 0000000000..5fad841674 --- /dev/null +++ b/src/app/api/tunnels/ngrok/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { getNgrokTunnelStatus, startNgrokTunnel, stopNgrokTunnel } from "@/lib/ngrokTunnel"; + +export const dynamic = "force-dynamic"; + +const actionSchema = z.object({ + action: z.enum(["enable", "disable"]), + authToken: z.string().optional(), +}); + +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 getNgrokTunnelStatus(); + return NextResponse.json(status); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to load ngrok tunnel status", + }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return unauthorized(); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const validation = validateBody(actionSchema, rawBody); + if (isValidationFailure(validation)) { + return validation.response; + } + + const parsed = validation.data; + + try { + const status = + parsed.action === "enable" + ? await startNgrokTunnel(parsed.authToken) + : await stopNgrokTunnel(); + + return NextResponse.json({ + success: true, + action: parsed.action, + status, + }); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to update ngrok tunnel", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index e291961e60..962ca76e07 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -478,6 +478,17 @@ export async function getUnifiedModelsResponse( const isProviderActive = (provider: string) => { if (activeAliases.size === 0) return false; // No active connections = show nothing const alias = providerIdToAlias[provider] || provider; + const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || provider; + + // FIX #1752: Ensure blocked providers are not returned for non-chat models + if ( + blockedProviders.has(alias) || + blockedProviders.has(canonicalProviderId) || + blockedProviders.has(provider) + ) { + return false; + } + return activeAliases.has(alias) || activeAliases.has(provider); }; diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 0c36d058eb..ac2a5e5f7e 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1980,7 +1980,24 @@ "tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.", "tailscaleSudoPlaceholder": "Optional sudo password", "tailscaleInstalling": "Installing", - "tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)" + "tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)", + "ngrokTitle": "ngrok Tunnel", + "ngrokRunning": "Running", + "ngrokStarting": "Starting", + "ngrokStoppedState": "Stopped", + "ngrokNeedsAuth": "Needs Auth", + "ngrokNotInstalled": "Not installed", + "ngrokUnsupported": "Unsupported", + "ngrokError": "Error", + "ngrokEnable": "Enable Tunnel", + "ngrokDisable": "Stop Tunnel", + "ngrokUrlNotice": "Creates a public ngrok tunnel.", + "ngrokAuthTokenLabel": "Authtoken (Required if NGROK_AUTHTOKEN not set)", + "ngrokAuthTokenPlaceholder": "Enter your ngrok authtoken", + "ngrokLastError": "Last error: {error}", + "ngrokStarted": "ngrok tunnel started", + "ngrokStopped": "ngrok tunnel stopped", + "ngrokRequestFailed": "Failed to update ngrok tunnel" }, "endpoints": { "tabProxy": "Endpoint Proxy", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 7699421d57..02dccb8b74 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1902,7 +1902,24 @@ "tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.", "tailscaleSudoPlaceholder": "Optional sudo password", "tailscaleInstalling": "Installing", - "tailscaleSudoLabel": "Senha Sudo (obrigatĂłria em macOS/Linux)" + "tailscaleSudoLabel": "Senha Sudo (obrigatĂłria em macOS/Linux)", + "ngrokTitle": "TĂșnel ngrok", + "ngrokRunning": "Rodando", + "ngrokStarting": "Iniciando", + "ngrokStoppedState": "Parado", + "ngrokNeedsAuth": "Requer Autenticação", + "ngrokNotInstalled": "NĂŁo instalado", + "ngrokUnsupported": "NĂŁo suportado", + "ngrokError": "Erro", + "ngrokEnable": "Habilitar TĂșnel", + "ngrokDisable": "Parar TĂșnel", + "ngrokUrlNotice": "Cria um tĂșnel pĂșblico ngrok.", + "ngrokAuthTokenLabel": "Authtoken (NecessĂĄrio caso NGROK_AUTHTOKEN nĂŁo esteja definido)", + "ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok", + "ngrokLastError": "Último erro: {error}", + "ngrokStarted": "TĂșnel ngrok iniciado", + "ngrokStopped": "TĂșnel ngrok parado", + "ngrokRequestFailed": "Falha ao atualizar tĂșnel ngrok" }, "endpoints": { "tabProxy": "Proxy de terminal", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c7369919f5..106a115a3f 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1874,7 +1874,24 @@ "tailscaleInstallIntro": "Installs Tailscale on this machine and prepares OmniRoute to enable Funnel.", "tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.", "tailscaleSudoPlaceholder": "Optional sudo password", - "tailscaleInstalling": "Installing" + "tailscaleInstalling": "Installing", + "ngrokTitle": "TĂșnel ngrok", + "ngrokRunning": "Rodando", + "ngrokStarting": "Iniciando", + "ngrokStoppedState": "Parado", + "ngrokNeedsAuth": "Requer Autenticação", + "ngrokNotInstalled": "NĂŁo instalado", + "ngrokUnsupported": "NĂŁo suportado", + "ngrokError": "Erro", + "ngrokEnable": "Habilitar TĂșnel", + "ngrokDisable": "Parar TĂșnel", + "ngrokUrlNotice": "Cria um tĂșnel pĂșblico ngrok.", + "ngrokAuthTokenLabel": "Authtoken (NecessĂĄrio caso NGROK_AUTHTOKEN nĂŁo esteja definido)", + "ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok", + "ngrokLastError": "Último erro: {error}", + "ngrokStarted": "TĂșnel ngrok iniciado", + "ngrokStopped": "TĂșnel ngrok parado", + "ngrokRequestFailed": "Falha ao atualizar tĂșnel ngrok" }, "endpoints": { "tabProxy": "Endpoint Proxy", diff --git a/src/lib/ngrokTunnel.ts b/src/lib/ngrokTunnel.ts new file mode 100644 index 0000000000..369c3a8929 --- /dev/null +++ b/src/lib/ngrokTunnel.ts @@ -0,0 +1,142 @@ +import { getRuntimePorts } from "@/lib/runtime/ports"; + +export type TunnelPhase = + | "unsupported" + | "not_installed" + | "stopped" + | "needs_auth" + | "starting" + | "running" + | "error"; + +export type NgrokTunnelStatus = { + supported: boolean; + installed: boolean; + running: boolean; + publicUrl: string | null; + apiUrl: string | null; + targetUrl: string; + phase: TunnelPhase; + lastError: string | null; +}; + +// Next.js hot-reloading safe global storage for the listener +const globalForNgrok = globalThis as unknown as { + __ngrokListener: any; +}; + +let startPromise: Promise | null = null; + +function getLocalTargetUrl() { + const { apiPort } = getRuntimePorts(); + return `http://127.0.0.1:${apiPort}`; +} + +function getTunnelApiUrl(publicUrl: string | null) { + return publicUrl ? `${publicUrl.replace(/\/$/, "")}/v1` : null; +} + +export async function getNgrokTunnelStatus(): Promise { + const targetUrl = getLocalTargetUrl(); + const tokenAvailable = !!( + process.env.NGROK_AUTHTOKEN && process.env.NGROK_AUTHTOKEN.trim() !== "" + ); + const listener = globalForNgrok.__ngrokListener; + let currentUrl = null; + + if (listener) { + try { + currentUrl = typeof listener.url === "function" ? listener.url() : listener.url; + } catch { + // Ignored + } + } + + return { + supported: true, + installed: true, + running: currentUrl !== null, + publicUrl: currentUrl || null, + apiUrl: currentUrl ? getTunnelApiUrl(currentUrl) : null, + targetUrl, + phase: currentUrl === null ? (tokenAvailable ? "stopped" : "needs_auth") : "running", + lastError: null, + }; +} + +export async function startNgrokTunnel(inputAuthToken?: string): Promise { + const current = await getNgrokTunnelStatus(); + if (current.running) return current; + if (startPromise) return startPromise; + + startPromise = (async () => { + try { + const authToken = + inputAuthToken && inputAuthToken.trim() !== "" + ? inputAuthToken.trim() + : process.env.NGROK_AUTHTOKEN; + + if (!authToken) { + return { + ...(await getNgrokTunnelStatus()), + phase: "needs_auth", + lastError: "An ngrok authtoken is required.", + }; + } + + // Dynamically import ngrok so it doesn't break environments where native build fails if not used + const ngrok = await import("@ngrok/ngrok"); + + const targetUrl = getLocalTargetUrl(); + const listenerOptions: any = { addr: targetUrl }; + + if (!inputAuthToken && process.env.NGROK_AUTHTOKEN) { + listenerOptions.authtoken_from_env = true; + } else { + listenerOptions.authtoken = authToken; + } + + const listener = await ngrok.forward(listenerOptions); + globalForNgrok.__ngrokListener = listener; + + let url = null; + try { + url = typeof listener.url === "function" ? listener.url() : listener.url; + } catch { + // Ignored + } + + if (!url) { + await stopNgrokTunnel(); + throw new Error("ngrok did not return a public URL."); + } + + return await getNgrokTunnelStatus(); + } catch (error) { + return { + ...(await getNgrokTunnelStatus()), + phase: "error", + lastError: error instanceof Error ? error.message : String(error), + }; + } finally { + startPromise = null; + } + })(); + + return startPromise; +} + +export async function stopNgrokTunnel(): Promise { + const listener = globalForNgrok.__ngrokListener; + if (listener) { + try { + if (typeof listener.close === "function") { + await listener.close(); + } + } catch (e) { + // Ignore close errors + } + globalForNgrok.__ngrokListener = undefined; + } + return await getNgrokTunnelStatus(); +}