feat(scripts): free trusted HTTPS via sslip.io for public-IP remote i… (#460)

* feat(scripts): free trusted HTTPS via sslip.io for public-IP remote installs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: refactor setup scripts

* chore: generate sdk

* chore: fix messaging for setup_remote script

* fix: fix ffmpeg download url

* feat: centralise and simplify the url configuration

* fix: force script run as sudo

* fix: fix documentation

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-06-27 17:19:29 +05:30 committed by GitHub
parent 3309face2c
commit 78427817a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 838 additions and 392 deletions

View file

@ -35,6 +35,8 @@ export async function GET() {
let authProvider = "local";
let turnEnabled = false;
let forceTurnRelay = false;
let tunnelUrl: string | null = null;
let backendApiEndpoint: string | null = null;
let backendStatus: "reachable" | "unreachable" = "unreachable";
let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`;
@ -53,6 +55,12 @@ export async function GET() {
authProvider = data.auth_provider;
turnEnabled = Boolean(data.turn_enabled);
forceTurnRelay = Boolean(data.force_turn_relay);
tunnelUrl = data.tunnel_url ?? null;
backendApiEndpoint =
typeof data.backend_api_endpoint === "string" &&
data.backend_api_endpoint.length > 0
? trimTrailingSlash(data.backend_api_endpoint)
: null;
backendStatus = "reachable";
backendMessage = null;
}
@ -68,6 +76,8 @@ export async function GET() {
authProvider,
turnEnabled,
forceTurnRelay,
tunnelUrl,
backendApiEndpoint,
backend: {
status: backendStatus,
url: backendUrl,

View file

@ -55,24 +55,21 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAppConfig } from "@/context/AppConfigContext";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import { resolveWebhookBaseUrl } from "@/lib/webhookUrl";
const INBOUND_WEBHOOK_PATH = "/api/v1/telephony/inbound/run";
function getInboundWebhookUrl(): string {
const backendUrl =
process.env.NEXT_PUBLIC_BACKEND_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
return `${backendUrl}${INBOUND_WEBHOOK_PATH}`;
}
export default function TelephonyConfigurationDetailPage() {
const router = useRouter();
const params = useParams<{ configId: string }>();
const configId = Number(params.configId);
const { user, getAccessToken, loading: authLoading } = useAuth();
const { config: appConfig } = useAppConfig();
const inboundWebhookUrl = `${resolveWebhookBaseUrl(appConfig?.tunnelUrl)}${INBOUND_WEBHOOK_PATH}`;
const [config, setConfig] = useState<TelephonyConfigurationDetail | null>(null);
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
const [loading, setLoading] = useState(true);
@ -265,7 +262,7 @@ export default function TelephonyConfigurationDetailPage() {
<button
type="button"
onClick={() => {
const url = getInboundWebhookUrl();
const url = inboundWebhookUrl;
navigator.clipboard
.writeText(url)
.then(() => toast.success("Inbound webhook URL copied"))
@ -275,7 +272,7 @@ export default function TelephonyConfigurationDetailPage() {
aria-label="Copy inbound webhook URL"
className="inline-flex items-center gap-1 self-start rounded font-mono text-xs text-muted-foreground hover:text-foreground"
>
<span className="truncate">{getInboundWebhookUrl()}</span>
<span className="truncate">{inboundWebhookUrl}</span>
<Copy className="h-3 w-3 shrink-0" />
</button>
</div>

View file

@ -6,6 +6,7 @@ import { TurnCredentialsResponse } from "@/client/types.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import type { ConversationNodeTransitionItem, RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
import { useAppConfig } from "@/context/AppConfigContext";
import { resolveBrowserBackendUrl } from '@/lib/apiClient';
import logger from '@/lib/logger';
import { sdpFilterCodec } from "../utils";
@ -37,34 +38,6 @@ const HANDLED_SERVICE_ERROR_TYPES = new Set([
'quota_check_failed',
]);
const LOCALHOST_API_BASE_URL = 'http://localhost:8000';
const LOCALHOST_API_HEALTH_URL = `${LOCALHOST_API_BASE_URL}/api/v1/health`;
const LOCALHOST_API_PROBE_TIMEOUT_MS = 1500;
function isLocalhostUi() {
if (typeof window === 'undefined') return false;
return ['localhost', '127.0.0.1', '::1'].includes(window.location.hostname);
}
async function probeLocalhostApi() {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), LOCALHOST_API_PROBE_TIMEOUT_MS);
try {
const response = await fetch(LOCALHOST_API_HEALTH_URL, {
cache: 'no-store',
signal: controller.signal,
});
return response.ok;
} catch {
return false;
} finally {
window.clearTimeout(timeout);
}
}
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => {
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('idle');
const [connectionActive, setConnectionActive] = useState(false);
@ -137,41 +110,15 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const currentAllowInterruptRef = useRef<boolean | undefined>(undefined);
const interruptWarningShownRef = useRef(false);
const getWebSocketUrl = useCallback(async () => {
// An explicitly configured backend URL always wins. When set, honor it
// verbatim and skip the localhost autodetect below — the operator has
// told us exactly where the API lives. Read the env var directly (not
// client.getConfig().baseUrl) so we can distinguish "explicitly set"
// from the client's window.location.origin fallback.
const configuredBackendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
let baseUrl: string;
if (configuredBackendUrl) {
baseUrl = configuredBackendUrl;
} else if (isLocalhostUi()) {
// No backend URL configured and the UI is on localhost: the client
// would otherwise fall back to window.location.origin (the UI port,
// e.g. 3010), which is wrong for the API. Local Docker exposes the
// API on localhost:8000. WebSocket upgrades cannot pass through the
// Next.js route-handler HTTP proxy, so connect to the API directly
// when that port is reachable. A Next.js rewrite/proxy for the
// upgrade was considered, but we keep the WebRTC signaling path
// direct so signaling and the API's ICE/WebRTC handling terminate
// at the same local endpoint.
const localhostApiReachable = await probeLocalhostApi();
if (!localhostApiReachable) {
throw new Error('Dograh API is not reachable at http://localhost:8000. Ensure the api container is running and port 8000 is published.');
}
baseUrl = LOCALHOST_API_BASE_URL;
} else {
// Same-origin deployment: UI and API share an origin.
baseUrl = client.getConfig().baseUrl || 'http://127.0.0.1:8000';
}
// Convert HTTP to WS protocol
const getWebSocketUrl = useCallback(() => {
// Single source of truth for the browser→API base URL: the centrally
// resolved API client config (NEXT_PUBLIC_BACKEND_URL → the backend
// endpoint reported by /health → window.location.origin), seeded by
// createClientConfig and upgraded by AppConfigProvider. The backend now
// reports the endpoint it runs on, so the old localhost autodetect that
// forced :8000 (back when an unset endpoint fell through to the UI origin)
// is no longer needed.
const baseUrl = client.getConfig().baseUrl || resolveBrowserBackendUrl();
const wsUrl = baseUrl.replace(/^http/, 'ws');
return `${wsUrl}/api/v1/ws/signaling/${workflowId}/${workflowRunId}?token=${accessToken}`;
}, [workflowId, workflowRunId, accessToken]);
@ -352,7 +299,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
};
const connectWebSocket = useCallback(async () => {
const wsUrl = await getWebSocketUrl();
const wsUrl = getWebSocketUrl();
return new Promise<void>((resolve, reject) => {
logger.info(`Connecting to WebSocket: ${wsUrl}`);

View file

@ -2830,6 +2830,10 @@ export type HealthResponse = {
* Backend Api Endpoint
*/
backend_api_endpoint: string;
/**
* Tunnel Url
*/
tunnel_url?: string | null;
/**
* Deployment Mode
*/

View file

@ -6,22 +6,41 @@ import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { useAppConfig } from "@/context/AppConfigContext";
import { resolveBrowserBackendUrl } from "@/lib/apiClient";
const MCP_PATH = "/api/v1/mcp/";
export function MCPSection() {
const backendUrl =
process.env.NEXT_PUBLIC_BACKEND_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
const endpoint = `${backendUrl}/api/v1/mcp/`;
const { config } = useAppConfig();
// Backend URL: the address the deployment runs on (a private IP when the backend
// sits on one). Tunnel URL, when present: the publicly reachable Cloudflare tunnel
// URL externally-hosted assistants should use to reach an otherwise-private host.
const backendUrl = resolveBrowserBackendUrl(config?.backendApiEndpoint);
const tunnelUrl = config?.tunnelUrl ?? null;
const [endpointCopied, setEndpointCopied] = useState(false);
const endpoints = [
...(tunnelUrl
? [
{
key: "tunnel",
label: "Public URL (Cloudflare tunnel)",
url: `${tunnelUrl}${MCP_PATH}`,
},
]
: []),
{ key: "backend", label: "Backend URL", url: `${backendUrl}${MCP_PATH}` },
];
const handleCopy = async (
value: string,
setter: (v: boolean) => void,
) => {
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const handleCopy = async (value: string, key: string) => {
await navigator.clipboard.writeText(value);
setter(true);
setTimeout(() => setter(false), 2000);
setCopiedKey(key);
setTimeout(
() => setCopiedKey((current) => (current === key ? null : current)),
2000,
);
};
return (
@ -39,23 +58,40 @@ export function MCPSection() {
Get your API key
</Link>
</p>
<div className="flex items-center gap-2">
<code className="text-xs break-all bg-muted px-2 py-1 rounded flex-1">
{endpoint}
</code>
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={() => handleCopy(endpoint, setEndpointCopied)}
>
{endpointCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<div className="grid gap-3">
{endpoints.map(({ key, label, url }) => (
<div key={key} className="grid gap-1">
{endpoints.length > 1 && (
<span className="text-xs font-medium text-muted-foreground">
{label}
</span>
)}
<div className="flex items-center gap-2">
<code className="text-xs break-all bg-muted px-2 py-1 rounded flex-1">
{url}
</code>
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={() => handleCopy(url, key)}
>
{copiedKey === key ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
))}
</div>
{tunnelUrl && (
<p className="text-xs text-muted-foreground">
Use the public URL from externally-hosted assistants; the backend URL
works from the deployment&apos;s own network.
</p>
)}
</div>
<p className="text-xs text-muted-foreground">

View file

@ -13,7 +13,9 @@ import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
import { useAppConfig } from "@/context/AppConfigContext";
import { cn } from "@/lib/utils";
import { resolveWebhookBaseUrl } from "@/lib/webhookUrl";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
@ -90,14 +92,12 @@ interface TriggerEndpoints {
function buildTriggerEndpoints(
triggerPath: string | undefined,
baseUrl: string,
): TriggerEndpoints {
if (!triggerPath) return { production: "", test: "" };
const backendUrl =
process.env.NEXT_PUBLIC_BACKEND_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
return {
production: `${backendUrl}/api/v1/public/agent/${triggerPath}`,
test: `${backendUrl}/api/v1/public/agent/test/${triggerPath}`,
production: `${baseUrl}/api/v1/public/agent/${triggerPath}`,
test: `${baseUrl}/api/v1/public/agent/test/${triggerPath}`,
};
}
@ -182,8 +182,12 @@ function CanvasPreview({
onStaleTools: (uuids: string[]) => void;
onStaleDocuments: (uuids: string[]) => void;
}) {
const { config: appConfig } = useAppConfig();
if (spec.name === "trigger") {
const endpoint = buildTriggerEndpoints(data.trigger_path).production;
const endpoint = buildTriggerEndpoints(
data.trigger_path,
resolveWebhookBaseUrl(appConfig?.tunnelUrl),
).production;
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">API Endpoint:</p>
@ -474,7 +478,9 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
});
const { saveWorkflow, tools, documents, recordings } = useWorkflow();
const { bySpecName } = useNodeSpecs();
const { config: appConfig } = useAppConfig();
const spec = bySpecName.get(type);
const webhookBaseUrl = resolveWebhookBaseUrl(appConfig?.tunnelUrl);
// ── Form state ─────────────────────────────────────────────────────
// mcp_tool_filters is not a spec property, so seedValues won't carry it;
@ -500,12 +506,12 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
// ── Trigger auto-UUID + canvas copy state ──────────────────────────
const [triggerCopied, setTriggerCopied] = useState(false);
const handleCopyTrigger = useCallback(async () => {
const endpoint = buildTriggerEndpoints(data.trigger_path).production;
const endpoint = buildTriggerEndpoints(data.trigger_path, webhookBaseUrl).production;
if (!endpoint) return;
await navigator.clipboard.writeText(endpoint);
setTriggerCopied(true);
setTimeout(() => setTriggerCopied(false), 2000);
}, [data.trigger_path]);
}, [data.trigger_path, webhookBaseUrl]);
// For trigger nodes without a path yet, generate one and persist.
useEffect(() => {
@ -684,7 +690,7 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
/>
{type === "trigger" && (
<TriggerWebhookUrls
endpoints={buildTriggerEndpoints(data.trigger_path)}
endpoints={buildTriggerEndpoints(data.trigger_path, webhookBaseUrl)}
/>
)}
</div>

View file

@ -2,6 +2,9 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import { client } from '@/client/client.gen';
import { resolveBrowserBackendUrl } from '@/lib/apiClient';
type BackendStatus = 'reachable' | 'unreachable';
interface AppConfig {
@ -11,6 +14,14 @@ interface AppConfig {
authProvider: string;
turnEnabled: boolean;
forceTurnRelay: boolean;
// Public URL when the deployment is reached through a Cloudflare tunnel
// (host has no public IP); null for a directly-reachable deployment.
tunnelUrl: string | null;
// The URL the backend reports it is running on (via /health). This is the
// address the browser reaches the backend at — a private IP when the backend
// runs on one. null until /health is reached. Used to resolve the API client
// base URL; distinct from tunnelUrl, which is only for external consumers.
backendApiEndpoint: string | null;
backendStatus: BackendStatus;
backendUrl: string;
backendMessage: string | null;
@ -29,6 +40,8 @@ const defaultConfig: AppConfig = {
authProvider: 'local',
turnEnabled: false,
forceTurnRelay: false,
tunnelUrl: null,
backendApiEndpoint: null,
backendStatus: 'unreachable',
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown',
backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL
@ -56,6 +69,21 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
const backendUrl = typeof backend.url === 'string' && backend.url.length > 0
? backend.url
: defaultConfig.backendUrl;
const backendApiEndpoint = typeof data.backendApiEndpoint === 'string' && data.backendApiEndpoint.length > 0
? data.backendApiEndpoint
: null;
// createClientConfig seeds the API client base URL before /health is
// known. Now that the backend has reported the endpoint it runs on,
// re-apply the single browser→API preference order so all SDK calls
// (and anything reading client.getConfig().baseUrl) hit it directly —
// window.location.origin would be wrong when the API is served from a
// different host/port. resolveBrowserBackendUrl keeps NEXT_PUBLIC_BACKEND_URL
// ahead of the reported endpoint. Guard on a present endpoint so a
// transient /health failure never downgrades a good base URL to origin.
if (backendApiEndpoint) {
client.setConfig({ baseUrl: resolveBrowserBackendUrl(backendApiEndpoint) });
}
setConfig({
uiVersion: data.ui || 'dev',
@ -64,6 +92,8 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
authProvider: data.authProvider || 'local',
turnEnabled: Boolean(data.turnEnabled),
forceTurnRelay: Boolean(data.forceTurnRelay),
tunnelUrl: typeof data.tunnelUrl === 'string' ? data.tunnelUrl : null,
backendApiEndpoint,
backendStatus,
backendUrl,
backendMessage: typeof backend.message === 'string' && backend.message.length > 0

View file

@ -5,6 +5,31 @@ export function getServerBackendUrl() {
return process.env.BACKEND_URL || 'http://api:8000';
}
/**
* Resolve the base URL the browser should use to reach the backend API.
*
* Precedence:
* 1. NEXT_PUBLIC_BACKEND_URL explicit build-time operator config, always wins.
* 2. backendApiEndpoint the URL the backend reports it is running on via /health
* (surfaced through AppConfigContext). This is the address the browser actually
* reaches the backend at; for a backend on a private IP it is that private IP.
* Unknown at module init, so createClientConfig seeds without it and
* AppConfigProvider upgrades the client once /health resolves.
* 3. window.location.origin same-origin public deployment.
*
* This is the browserAPI order. It is intentionally NOT tunnel-aware: the
* Cloudflare tunnel URL is only for externally-hosted consumers (telephony
* webhooks, MCP, external API triggers) that cannot reach a private IP see
* resolveWebhookBaseUrl.
*/
export function resolveBrowserBackendUrl(backendApiEndpoint?: string | null): string {
return (
process.env.NEXT_PUBLIC_BACKEND_URL ||
backendApiEndpoint ||
(typeof window !== 'undefined' ? window.location.origin : '')
);
}
export const createClientConfig: CreateClientConfig = (config) => {
// Use different URLs for server-side vs client-side
const isServer = typeof window === 'undefined';
@ -13,7 +38,10 @@ export const createClientConfig: CreateClientConfig = (config) => {
if (isServer) {
baseUrl = getServerBackendUrl();
} else {
baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
// The backend-reported endpoint is not known yet at module init;
// AppConfigProvider upgrades the client base URL once /health reports it
// (when no explicit NEXT_PUBLIC_BACKEND_URL is configured).
baseUrl = resolveBrowserBackendUrl();
}
return {

16
ui/src/lib/webhookUrl.ts Normal file
View file

@ -0,0 +1,16 @@
/**
* Public base URL that external callers (telephony providers, webhook senders)
* should use to reach this deployment's API.
*
* Prefers the Cloudflare tunnel URL the backend reports via /health (set when the
* host has no public IP, so `window.location.origin` would be a private/LAN
* address an external caller can't reach), then a build-time configured backend
* URL, then the current origin (correct for a same-origin public deployment).
*/
export function resolveWebhookBaseUrl(tunnelUrl?: string | null): string {
return (
tunnelUrl ||
process.env.NEXT_PUBLIC_BACKEND_URL ||
(typeof window !== "undefined" ? window.location.origin : "")
);
}