diff --git a/api/constants.py b/api/constants.py index a5b06396..4dae2780 100644 --- a/api/constants.py +++ b/api/constants.py @@ -19,7 +19,23 @@ LANGFUSE_PUBLIC_KEY = os.getenv("LANGFUSE_PUBLIC_KEY") LANGFUSE_SECRET_KEY = os.getenv("LANGFUSE_SECRET_KEY") # URLs for deployment -BACKEND_API_ENDPOINT = os.getenv("BACKEND_API_ENDPOINT", "http://localhost:8000") +# +# PUBLIC_BASE_URL is the single canonical origin a deployment is reached at +# (scheme + host, e.g. https://203-0-113-10.sslip.io). For a standard single-host +# install it is the only endpoint value an operator sets — the per-subsystem URLs +# below derive from it (and from PUBLIC_HOST for the TURN/ICE host). Each derived +# var can still be set explicitly to override it for a split deployment. +PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL") or None +PUBLIC_HOST = os.getenv("PUBLIC_HOST") or None + +# Public URL the backend builds webhook/callback/embed links from. Derives from +# PUBLIC_BASE_URL (public IP / domain), falling back to localhost for local dev. +# When this is a non-public address (localhost or a private/reserved IP) the host +# isn't reachable from the internet, so get_backend_endpoints() resolves a running +# Cloudflare tunnel's URL at runtime instead (see api/utils/common.py). +BACKEND_API_ENDPOINT = ( + os.getenv("BACKEND_API_ENDPOINT") or PUBLIC_BASE_URL or "http://localhost:8000" +) UI_APP_URL = os.getenv("UI_APP_URL", "http://localhost:3010") DATABASE_URL = os.environ["DATABASE_URL"] @@ -44,7 +60,12 @@ ENABLE_AWS_S3 = os.getenv("ENABLE_AWS_S3", "false").lower() == "true" # MinIO Configuration MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000") -MINIO_PUBLIC_ENDPOINT = os.getenv("MINIO_PUBLIC_ENDPOINT") +# Full URL (scheme + host) browsers use to reach object storage. Derives from +# PUBLIC_BASE_URL (remote nginx proxies /voice-audio/ to MinIO); set explicitly +# only to point object storage at a separate origin. +MINIO_PUBLIC_ENDPOINT = ( + os.getenv("MINIO_PUBLIC_ENDPOINT") or PUBLIC_BASE_URL or "http://localhost:9000" +) MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin") MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin") MINIO_BUCKET = os.getenv("MINIO_BUCKET", "voice-audio") @@ -84,7 +105,7 @@ LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper() LOG_ROTATION_SIZE = os.getenv("LOG_ROTATION_SIZE", "100 MB") LOG_RETENTION = os.getenv("LOG_RETENTION", "7 days") LOG_COMPRESSION = os.getenv("LOG_COMPRESSION", "gz") -ENABLE_TELEMETRY = os.getenv("ENABLE_TELEMETRY", "false").lower() == "true" +ENABLE_TELEMETRY = os.getenv("ENABLE_TELEMETRY", "true").lower() == "true" def _get_version() -> str: @@ -149,7 +170,9 @@ DEFAULT_CIRCUIT_BREAKER_CONFIG = { TURN_SECRET = os.getenv("TURN_SECRET") -TURN_HOST = os.getenv("TURN_HOST", "localhost") +# Host browsers dial for TURN/ICE. Derives from PUBLIC_HOST; set explicitly only +# when the TURN server runs on a separate host from the app. +TURN_HOST = os.getenv("TURN_HOST") or PUBLIC_HOST or "localhost" TURN_PORT = int(os.getenv("TURN_PORT", "3478")) TURN_TLS_PORT = int(os.getenv("TURN_TLS_PORT", "5349")) TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400")) diff --git a/api/routes/main.py b/api/routes/main.py index 067f2ab9..c7c589e0 100644 --- a/api/routes/main.py +++ b/api/routes/main.py @@ -68,6 +68,10 @@ class HealthResponse(BaseModel): status: str version: str backend_api_endpoint: str + # Public URL the deployment is reachable at when it sits behind a Cloudflare + # tunnel (the host has no public IP). null for a directly-reachable deployment. + # The UI shows this so operators know the URL telephony providers should call. + tunnel_url: str | None = None deployment_mode: str auth_provider: str turn_enabled: bool @@ -84,21 +88,34 @@ async def health() -> HealthResponse: from api.constants import ( APP_VERSION, AUTH_PROVIDER, + BACKEND_API_ENDPOINT, DEPLOYMENT_MODE, FORCE_TURN_RELAY, STACK_AUTH_PROJECT_ID, STACK_PUBLISHABLE_CLIENT_KEY, TURN_SECRET, ) - from api.utils.common import get_backend_endpoints + from api.utils.common import get_backend_endpoints, is_local_or_private_url logger.debug("Health endpoint called") backend_endpoint, _ = await get_backend_endpoints() + # tunnel_url is set only when a Cloudflare tunnel was actually resolved: the + # configured address isn't publicly reachable, but get_backend_endpoints found + # a public tunnel URL for it. This is the URL the UI shows for inbound webhooks. + # It stays null for a directly-reachable (public IP / domain) deployment, where + # backend_api_endpoint itself is the public URL. + tunnel_url = ( + backend_endpoint + if is_local_or_private_url(BACKEND_API_ENDPOINT) + and not is_local_or_private_url(backend_endpoint) + else None + ) is_stack = AUTH_PROVIDER == "stack" return HealthResponse( status="ok", version=APP_VERSION, - backend_api_endpoint=backend_endpoint, + backend_api_endpoint=BACKEND_API_ENDPOINT, + tunnel_url=tunnel_url, deployment_mode=DEPLOYMENT_MODE, auth_provider=AUTH_PROVIDER, turn_enabled=bool(TURN_SECRET), diff --git a/api/utils/common.py b/api/utils/common.py index 2770c5b2..fa03a094 100644 --- a/api/utils/common.py +++ b/api/utils/common.py @@ -3,6 +3,7 @@ Common utilities. Shared functions used across the application. """ +import ipaddress import re from loguru import logger @@ -22,6 +23,43 @@ def get_scheme(url: str) -> str | None: return url[:idx] +def is_local_or_private_url(url: str) -> bool: + """True when the URL's host is localhost or a private/reserved/loopback IP. + + Such an address is not reachable from the public internet, so external callers + (telephony webhooks/callbacks) can't reach it directly — the backend resolves a + Cloudflare tunnel URL at runtime instead. A public IP or a hostname/domain + returns False (assumed publicly reachable). + """ + host = url + if "://" in host: + host = host.split("://", 1)[1] + host = host.split("/", 1)[0] + # Strip a :port suffix (skip bare IPv6, which contains multiple colons). + if host.count(":") == 1: + host = host.rsplit(":", 1)[0] + + if host == "localhost" or host.endswith(".localhost"): + return True + try: + ip = ipaddress.ip_address(host) + except ValueError: + return False # hostname / domain -> assume publicly reachable + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_unspecified + ): + return True + # Carrier-grade NAT (RFC 6598) — behind NAT, not publicly reachable. Kept in + # sync with scripts/lib/setup_common.sh:dograh_is_local_ipv4. + return isinstance(ip, ipaddress.IPv4Address) and ip in ipaddress.ip_network( + "100.64.0.0/10" + ) + + def _validate_url(url: str) -> None: """ Validate URL format and raise ValueError for invalid URLs. @@ -119,10 +157,11 @@ async def get_backend_endpoints() -> tuple[str, str]: _validate_url(BACKEND_API_ENDPOINT) if BACKEND_API_ENDPOINT: - # Handle localhost/127.0.0.1 special case - use tunnel URL if available - if "localhost" in BACKEND_API_ENDPOINT or "127.0.0.1" in BACKEND_API_ENDPOINT: + # Non-public address (localhost or a private/reserved IP) - the host isn't + # reachable from the internet, so prefer a running Cloudflare tunnel's URL. + if is_local_or_private_url(BACKEND_API_ENDPOINT): logger.debug( - f"BACKEND_API_ENDPOINT is local ({BACKEND_API_ENDPOINT}), checking tunnel URL" + f"BACKEND_API_ENDPOINT is not publicly reachable ({BACKEND_API_ENDPOINT}), checking tunnel URL" ) try: tunnel_urls = await TunnelURLProvider.get_tunnel_urls() diff --git a/docker-compose.yaml b/docker-compose.yaml index 352c4356..6e07c396 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,3 +1,17 @@ +# Dograh deployment stack — driven by the helper scripts, not by a bare +# `docker compose up`. +# +# This stack needs a generated .env (secrets, public host/URL) and, for the +# remote/TURN profiles, runtime nginx/coturn config rendered by the dograh-init +# service. The setup scripts create those for you — start with them: +# +# Local: ./start_docker.sh (Windows: .\start_docker.ps1) +# Remote server: sudo ./setup_remote.sh then ./remote_up.sh +# +# Running `docker compose up` against a fresh checkout will fail or come up +# misconfigured (e.g. OSS_JWT_SECRET is required). Full guide: +# https://docs.dograh.com/deployment/docker + services: postgres: image: pgvector/pgvector:pg17 @@ -135,9 +149,18 @@ services: ENVIRONMENT: "${ENVIRONMENT:-local}" LOG_LEVEL: "INFO" - # Replace this environment variable if you are using a custom - # domain to host the stack - BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}" + # Public origin for this deployment. The API derives BACKEND_API_ENDPOINT, + # MINIO_PUBLIC_ENDPOINT and TURN_HOST from PUBLIC_BASE_URL / PUBLIC_HOST when + # they are not set explicitly (see api/constants.py), so a standard remote + # install only needs PUBLIC_BASE_URL + PUBLIC_HOST in .env. + PUBLIC_BASE_URL: "${PUBLIC_BASE_URL:-}" + PUBLIC_HOST: "${PUBLIC_HOST:-}" + + # Optional explicit override of the public URL the backend builds webhook / + # embed links from. Defaults to PUBLIC_BASE_URL. When the value is non-public + # (localhost or a private/reserved IP), the API resolves a running Cloudflare + # tunnel's URL at runtime instead (see api/utils/common.py). + BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-}" # Database configuration (using containerized postgres) DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres" @@ -169,10 +192,10 @@ services: # MinIO MINIO_ENDPOINT: "minio:9000" - # Full URL (with scheme) browsers use to reach MinIO. For remote - # deployments behind HTTPS, set MINIO_PUBLIC_ENDPOINT in .env to - # e.g. https://your-server.example.com (nginx proxies /voice-audio/). - MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" + # Full URL (with scheme) browsers use to reach MinIO. Defaults to + # PUBLIC_BASE_URL for remote deployments (nginx proxies /voice-audio/) and to + # http://localhost:9000 for local; override only for a separate object store. + MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}" MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-minioadmin}" MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-minioadmin}" MINIO_BUCKET: "voice-audio" @@ -223,8 +246,6 @@ services: condition: service_healthy minio: condition: service_healthy - cloudflared: - condition: service_started healthcheck: test: [ @@ -272,12 +293,35 @@ services: networks: - app-network + # Cloudflare tunnel for inbound webhook / WSS reachability when the host has no + # usable public IP (behind NAT or a firewall). Gated behind the "tunnel" profile + # so public-IP installs (served directly by nginx) never start it, and the api + # service no longer hard-depends on it. Two modes, chosen by the token: + # - CLOUDFLARE_TUNNEL_TOKEN set -> named tunnel with a stable hostname. Point + # its ingress at http://api:8000 in the Cloudflare dashboard and set + # BACKEND_API_ENDPOINT in .env to that hostname. + # - token unset -> quick tunnel with an ephemeral + # *.trycloudflare.com URL the API discovers from the metrics endpoint + # (api/utils/tunnel.py). Convenient for local dev. cloudflared: image: cloudflare/cloudflared:latest container_name: cloudflared-tunnel - command: tunnel --no-autoupdate --url http://api:8000 --metrics 0.0.0.0:2000 + profiles: ["tunnel"] + restart: unless-stopped + environment: + # cloudflared automatically picks up the token from TUNNEL_TOKEN when + # running `tunnel run`. Leave empty to fall back to a quick tunnel. + TUNNEL_TOKEN: "${CLOUDFLARE_TUNNEL_TOKEN:-}" + # The cloudflared image is distroless (no `sh`), so a `sh -c` conditional + # entrypoint can't run. The image's own entrypoint is already + # `cloudflared --no-autoupdate`; pick the mode via a single command instead: + # - token set -> set CLOUDFLARED_COMMAND="tunnel run" in .env for a named + # tunnel (cloudflared reads TUNNEL_TOKEN from the env above). + # - token unset -> the default below runs a quick tunnel with an ephemeral + # *.trycloudflare.com URL the API discovers from the metrics endpoint. + command: ${CLOUDFLARED_COMMAND:-tunnel --url http://api:8000 --metrics 0.0.0.0:2000} ports: - - "2000:2000" # Expose metrics endpoint + - "2000:2000" # metrics endpoint (quick-tunnel URL discovery) networks: - app-network diff --git a/docs/deployment/docker.mdx b/docs/deployment/docker.mdx index f9936fac..aec0447f 100644 --- a/docs/deployment/docker.mdx +++ b/docs/deployment/docker.mdx @@ -102,7 +102,7 @@ The script will prompt you for: It creates `docker-compose.yaml`, a `.env` file with JWT and TURN credentials, and the small helper bundle that `dograh-init` uses to render coturn config at startup. Start the stack with the `local-turn` profile so coturn comes up alongside the other services: ```bash -docker compose --profile local-turn up --pull always +docker compose --profile local-turn --profile tunnel up --pull always ``` The application is still available at `http://localhost:3010`. diff --git a/docs/developer/environment-variables.mdx b/docs/developer/environment-variables.mdx index 2bd6db04..fd8d6469 100644 --- a/docs/developer/environment-variables.mdx +++ b/docs/developer/environment-variables.mdx @@ -65,7 +65,9 @@ Set these when `AUTH_PROVIDER=stack` to delegate sign-in to [Stack Auth](https:/ | Variable | Default | Description | |---|---|---| -| `BACKEND_API_ENDPOINT` | `http://localhost:8000` | Internal URL of the backend API | +| `PUBLIC_BASE_URL` | `null` | Canonical public origin for the deployment (scheme + host, e.g. `https://203-0-113-10.sslip.io`). For a standard single-host install this is the only endpoint value you set — `BACKEND_API_ENDPOINT` and `MINIO_PUBLIC_ENDPOINT` derive from it | +| `PUBLIC_HOST` | `null` | Public host without scheme (e.g. `203-0-113-10.sslip.io`); `TURN_HOST` derives from it | +| `BACKEND_API_ENDPOINT` | `PUBLIC_BASE_URL`, else `http://localhost:8000` | Public URL the backend builds webhook / callback / embed links from. Set explicitly only to override the value derived from `PUBLIC_BASE_URL` | | `UI_APP_URL` | `http://localhost:3010` | URL of the frontend application | | `MPS_API_URL` | `https://services.dograh.com` | Dograh Managed Platform Services URL | | `DOGRAH_MPS_SECRET_KEY` | `null` | **Required for non-OSS deployments.** Secret key for authenticating with MPS | @@ -82,7 +84,7 @@ Dograh uses **MinIO by default**, which is bundled with the self-hosted deployme | Variable | Default | Description | |---|---|---| | `MINIO_ENDPOINT` | `localhost:9000` | MinIO server host and port | -| `MINIO_PUBLIC_ENDPOINT` | `null` | Publicly accessible MinIO URL (for download links) | +| `MINIO_PUBLIC_ENDPOINT` | `PUBLIC_BASE_URL`, else `http://localhost:9000` | Publicly accessible MinIO URL for download links. Derives from `PUBLIC_BASE_URL`; set explicitly only for a separate object-storage origin | | `MINIO_ACCESS_KEY` | N/A | **Required for OSS deployments.** MinIO access key. Must be set to a secure value in production | | `MINIO_SECRET_KEY` | N/A | **Required for OSS deployments.** MinIO secret key. Must be set to a secure value in production | | `MINIO_BUCKET` | `voice-audio` | Bucket name for audio files | @@ -128,7 +130,7 @@ Presigned URLs point at `S3_ENDPOINT_URL`, so that host must be reachable from t | Variable | Default | Description | |---|---|---| -| `TURN_HOST` | `localhost` | TURN server hostname for WebRTC NAT traversal | +| `TURN_HOST` | `PUBLIC_HOST`, else `localhost` | TURN server hostname for WebRTC NAT traversal. Derives from `PUBLIC_HOST`; set explicitly only when TURN runs on a separate host | | `TURN_PORT` | `3478` | TURN server port | | `TURN_TLS_PORT` | `5349` | TURN server TLS port | | `TURN_SECRET` | `null` | **Required for WebRTC.** Shared secret for TURN credential generation | diff --git a/remote_up.sh b/remote_up.sh index 29dc7845..4c67405a 100755 --- a/remote_up.sh +++ b/remote_up.sh @@ -65,10 +65,20 @@ else COMPOSE_CMD=(sudo docker compose) fi +# When SERVER_IP (sourced from .env above) is a private/reserved address the host +# has no public IP, so start the cloudflared service (tunnel profile) to make +# webhooks reachable. The backend resolves the tunnel's public URL at runtime using +# the same private-IP classification (api/utils/common.py:is_local_or_private_url), +# so the two stay in sync. A public-IP install runs nginx only. +PROFILE_ARGS=(--profile remote) +if dograh_is_local_ipv4 "${SERVER_IP:-}"; then + PROFILE_ARGS+=(--profile tunnel) +fi + if [[ "$MODE" == "build" ]]; then - CMD=("${COMPOSE_CMD[@]}" --profile remote up -d --build --force-recreate) + CMD=("${COMPOSE_CMD[@]}" "${PROFILE_ARGS[@]}" up -d --build --force-recreate) else - CMD=("${COMPOSE_CMD[@]}" --profile remote up -d --pull always --force-recreate) + CMD=("${COMPOSE_CMD[@]}" "${PROFILE_ARGS[@]}" up -d --pull always --force-recreate) fi # Bash 3.2 on macOS treats "${empty_array[@]}" as unbound under `set -u`. diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 0ffa1169..73ed8128 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -29,9 +29,12 @@ This directory now has a shared deployment model for OSS Docker installs. If you - `scripts/lib/setup_common.sh` is the shared deployment helper library. It is sourced by `setup_local.sh`, `setup_remote.sh`, `update_remote.sh`, `setup_custom_domain.sh`, `run_dograh_init.sh`, and repo-root `remote_up.sh`. - `setup_common.sh` must stay safe to source. It should not set shell options like `set -u` for callers. - `.env` is the single operator-owned source of truth for remote deployment settings. Remote/runtime config should derive from it, not the other way around. -- Canonical remote keys in `.env`: `ENVIRONMENT`, `SERVER_IP`, `PUBLIC_HOST`, `PUBLIC_BASE_URL`, `BACKEND_API_ENDPOINT`, `MINIO_PUBLIC_ENDPOINT`, `TURN_HOST`, `TURN_SECRET`, `FASTAPI_WORKERS`, `OSS_JWT_SECRET`. +- Canonical remote keys in `.env`: `ENVIRONMENT`, `SERVER_IP`, `PUBLIC_HOST`, `PUBLIC_BASE_URL`, `TURN_SECRET`, `FASTAPI_WORKERS`, `OSS_JWT_SECRET`. `PUBLIC_BASE_URL` (+ `PUBLIC_HOST`, and `SERVER_IP` for coturn's literal `external-ip`) is the single endpoint source of truth. +- `BACKEND_API_ENDPOINT`, `MINIO_PUBLIC_ENDPOINT`, `TURN_HOST` are **derived in-app** from `PUBLIC_BASE_URL` / `PUBLIC_HOST` (`api/constants.py`) and are no longer written to a remote `.env`. `dograh_sync_remote_env_file` neither writes nor deletes them — new installs omit them, and a value an operator sets by hand is left untouched as an explicit override for a split deployment (separate object store / TURN host). `dograh_validate_remote_runtime_env` therefore no longer requires them or asserts they equal `PUBLIC_BASE_URL`. - `remote_up.sh` is the supported remote startup entrypoint. It runs preflight via `dograh_prepare_remote_install`, runs `docker compose config -q`, then starts the stack. - `docker-compose.yaml` uses a one-shot `dograh-init` service for profiles `remote` and `local-turn`. +- `cloudflared` is gated behind a `tunnel` profile (no longer always-on; `api` no longer `depends_on` it). `remote_up.sh` adds `--profile tunnel` when `SERVER_IP` is a private/reserved address (`dograh_is_local_ipv4`) — i.e. the host has no public IP; public-IP installs run `--profile remote` only and start no tunnel. Local installs opt in with `--profile tunnel`. The backend mirrors this exactly: `api/utils/common.py:is_local_or_private_url` decides when `get_backend_endpoints()` resolves the tunnel URL at runtime, so deploy-side and runtime stay in sync (keep the two IP classifiers aligned, incl. CGNAT 100.64.0.0/10). +- `cloudflared` picks a mode by token: with `CLOUDFLARE_TUNNEL_TOKEN` it runs a named tunnel (stable hostname — set `BACKEND_API_ENDPOINT` to it and point its Cloudflare-dashboard ingress at `http://api:8000`); without a token it runs a quick tunnel (ephemeral `*.trycloudflare.com`, discovered via the `:2000` metrics endpoint by `api/utils/tunnel.py`). - `dograh-init` executes `scripts/run_dograh_init.sh`, which renders nginx/coturn runtime config into named volumes consumed by `nginx` and `coturn`. - Remote nginx/coturn config is runtime-generated. Host-managed `nginx.conf` / `turnserver.conf` are legacy only; update flow may back them up and delete them, but current installs should not depend on them. - `setup_remote.sh` writes `.env`, downloads the deployment helper bundle, generates self-signed certs, validates the init-based config, and tells operators to start via `./remote_up.sh` or `./remote_up.sh --build`. diff --git a/scripts/lib/setup_common.sh b/scripts/lib/setup_common.sh index fed3bb1f..a635209e 100644 --- a/scripts/lib/setup_common.sh +++ b/scripts/lib/setup_common.sh @@ -252,9 +252,11 @@ dograh_sync_remote_env_file() { dograh_set_env_key "$env_file" SERVER_IP "$server_ip" dograh_set_env_key "$env_file" PUBLIC_HOST "$public_host" dograh_set_env_key "$env_file" PUBLIC_BASE_URL "$public_base_url" - dograh_set_env_key "$env_file" BACKEND_API_ENDPOINT "$public_base_url" - dograh_set_env_key "$env_file" MINIO_PUBLIC_ENDPOINT "$public_base_url" - dograh_set_env_key "$env_file" TURN_HOST "$public_host" + + # BACKEND_API_ENDPOINT / MINIO_PUBLIC_ENDPOINT / TURN_HOST are derived in-app + # from PUBLIC_BASE_URL / PUBLIC_HOST (see api/constants.py), so sync neither + # writes nor removes them: new installs simply omit them, and any value an + # operator set by hand is left untouched as an explicit override. } dograh_validate_remote_runtime_env() { @@ -262,14 +264,12 @@ dograh_validate_remote_runtime_env() { [[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET is missing" [[ -n "${PUBLIC_HOST:-}" ]] || dograh_fail "PUBLIC_HOST is missing" [[ -n "${PUBLIC_BASE_URL:-}" ]] || dograh_fail "PUBLIC_BASE_URL is missing" - [[ -n "${BACKEND_API_ENDPOINT:-}" ]] || dograh_fail "BACKEND_API_ENDPOINT is missing" - [[ -n "${MINIO_PUBLIC_ENDPOINT:-}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT is missing" - [[ -n "${TURN_HOST:-}" ]] || dograh_fail "TURN_HOST is missing" dograh_is_ipv4 "${SERVER_IP:-}" || dograh_fail "SERVER_IP must be a valid IPv4 address" [[ "${PUBLIC_BASE_URL}" =~ ^https?:// ]] || dograh_fail "PUBLIC_BASE_URL must include http:// or https://" - [[ "${BACKEND_API_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "BACKEND_API_ENDPOINT must match PUBLIC_BASE_URL" - [[ "${MINIO_PUBLIC_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT must match PUBLIC_BASE_URL" - [[ "${TURN_HOST}" == "${PUBLIC_HOST}" ]] || dograh_fail "TURN_HOST must match PUBLIC_HOST" + # BACKEND_API_ENDPOINT / MINIO_PUBLIC_ENDPOINT / TURN_HOST are derived in-app + # from PUBLIC_BASE_URL / PUBLIC_HOST (see api/constants.py), so they are not + # required here. When an operator sets them explicitly (split deployment), + # their value is honored as-is — no equality check. } dograh_uses_init_compose_layout() { diff --git a/scripts/setup_custom_domain.sh b/scripts/setup_custom_domain.sh index a0892acd..8860281f 100755 --- a/scripts/setup_custom_domain.sh +++ b/scripts/setup_custom_domain.sh @@ -109,6 +109,13 @@ dograh_set_env_key .env SERVER_IP "$SERVER_IP" dograh_set_env_key .env PUBLIC_HOST "$DOMAIN_NAME" dograh_set_env_key .env PUBLIC_BASE_URL "https://$DOMAIN_NAME" dograh_delete_env_key .env BACKEND_URL +# Switching domains is an explicit repoint of the whole deployment. Drop any +# legacy per-subsystem endpoint keys an older install pinned to the previous host +# so they re-derive from the new PUBLIC_BASE_URL / PUBLIC_HOST (see api/constants.py). +# No-op on current installs, which don't write these keys. +dograh_delete_env_key .env BACKEND_API_ENDPOINT +dograh_delete_env_key .env MINIO_PUBLIC_ENDPOINT +dograh_delete_env_key .env TURN_HOST dograh_prepare_remote_install "$(pwd)" # Bring the stack up (recreating it) so dograh-init re-renders nginx with the diff --git a/scripts/setup_local.ps1 b/scripts/setup_local.ps1 index 4e54ec51..dedb0e2f 100644 --- a/scripts/setup_local.ps1 +++ b/scripts/setup_local.ps1 @@ -307,13 +307,16 @@ Write-Host '' if ($UseCoturn) { Write-Warn 'To start Dograh with TURN, run:' Write-Host '' - Write-Host ' docker compose --profile local-turn up --pull always' -ForegroundColor Blue + Write-Host ' docker compose --profile local-turn --profile tunnel up --pull always' -ForegroundColor Blue } else { Write-Warn 'To start Dograh, run:' Write-Host '' - Write-Host ' docker compose up --pull always' -ForegroundColor Blue + Write-Host ' docker compose --profile tunnel up --pull always' -ForegroundColor Blue } Write-Host '' +Write-Host 'This starts a Cloudflare quick tunnel so inbound telephony webhooks can' -ForegroundColor Yellow +Write-Host 'reach your local API over a temporary public URL.' -ForegroundColor Yellow +Write-Host '' Write-Warn 'Your application will be available at:' Write-Host '' Write-Host ' http://localhost:3010' -ForegroundColor Blue diff --git a/scripts/setup_local.sh b/scripts/setup_local.sh index 4c7fad5d..a1afc2ec 100755 --- a/scripts/setup_local.sh +++ b/scripts/setup_local.sh @@ -211,13 +211,16 @@ echo "" if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then echo -e "${YELLOW}To start Dograh with TURN, run:${NC}" echo "" - echo -e " ${BLUE}docker compose --profile local-turn up --pull always${NC}" + echo -e " ${BLUE}docker compose --profile local-turn --profile tunnel up --pull always${NC}" else echo -e "${YELLOW}To start Dograh, run:${NC}" echo "" - echo -e " ${BLUE}docker compose up --pull always${NC}" + echo -e " ${BLUE}docker compose --profile tunnel up --pull always${NC}" fi echo "" +echo -e "${YELLOW}This starts a Cloudflare quick tunnel so inbound telephony webhooks can${NC}" +echo -e "${YELLOW}reach your local API over a temporary public URL.${NC}" +echo "" echo -e "${YELLOW}Your application will be available at:${NC}" echo "" echo -e " ${BLUE}http://localhost:3010${NC}" diff --git a/scripts/setup_remote.sh b/scripts/setup_remote.sh index 6cb650f8..624b46a4 100755 --- a/scripts/setup_remote.sh +++ b/scripts/setup_remote.sh @@ -324,19 +324,14 @@ ENVIRONMENT=production # Canonical public host/base URL for this install. SERVER_IP stays the raw IP # (coturn external-ip and validation need it); PUBLIC_HOST is the sslip.io -# hostname when using a trusted cert, otherwise the IP. +# hostname when using a trusted cert, otherwise the IP. BACKEND_API_ENDPOINT, +# MINIO_PUBLIC_ENDPOINT and TURN_HOST are derived from these by the API +# (see api/constants.py) — set them here only to override for a split deployment. SERVER_IP=$SERVER_IP PUBLIC_HOST=$PUBLIC_HOST_VALUE PUBLIC_BASE_URL=https://$PUBLIC_HOST_VALUE -# Backend API endpoint (public URL the backend uses to build webhook/embed links) -BACKEND_API_ENDPOINT=https://$PUBLIC_HOST_VALUE - -# Public URL browsers use to fetch objects from MinIO (proxied by nginx) -MINIO_PUBLIC_ENDPOINT=https://$PUBLIC_HOST_VALUE - # TURN Server Configuration (time-limited credentials via TURN REST API) -TURN_HOST=$PUBLIC_HOST_VALUE TURN_SECRET=$TURN_SECRET # Relay-only ICE candidates for explicit TURN diagnostics FORCE_TURN_RELAY=$FORCE_TURN_RELAY diff --git a/scripts/start_docker.ps1 b/scripts/start_docker.ps1 index 22f78f6e..86b9aa3d 100644 --- a/scripts/start_docker.ps1 +++ b/scripts/start_docker.ps1 @@ -207,10 +207,9 @@ if ([string]::IsNullOrEmpty($existingMinioRootPassword)) { Write-Host '' Write-Host "Docker registry: $Registry" -Write-Host "Telemetry enabled: $EnableTelemetry" Write-Host '' Write-Host 'This will run:' -Write-Host " `$env:REGISTRY = '$Registry'; `$env:ENABLE_TELEMETRY = '$EnableTelemetry'; docker compose up --pull always" +Write-Host " `$env:REGISTRY = '$Registry'; `$env:ENABLE_TELEMETRY = '$EnableTelemetry'; docker compose --profile tunnel up --pull always" Write-Host '' $answer = Read-Host 'Start Dograh now? [Y/n]' @@ -222,7 +221,7 @@ if ($answer -match '^[Nn]') { $env:REGISTRY = $Registry $env:ENABLE_TELEMETRY = $EnableTelemetry Sync-PostgresPassword -Password (Get-DotEnvValue -Path $EnvFile -Key 'POSTGRES_PASSWORD') -docker compose up --pull always +docker compose --profile tunnel up --pull always if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/scripts/start_docker.sh b/scripts/start_docker.sh index 56812f0a..05d451d2 100755 --- a/scripts/start_docker.sh +++ b/scripts/start_docker.sh @@ -200,10 +200,9 @@ fi echo "" echo "Docker registry: $REGISTRY" -echo "Telemetry enabled: $ENABLE_TELEMETRY" echo "" echo "This will run:" -echo " REGISTRY=$REGISTRY ENABLE_TELEMETRY=$ENABLE_TELEMETRY docker compose up --pull always" +echo " REGISTRY=$REGISTRY ENABLE_TELEMETRY=$ENABLE_TELEMETRY docker compose --profile tunnel up --pull always" echo "" if [[ ! -t 0 ]]; then @@ -222,4 +221,4 @@ esac postgres_password="$(dotenv_value POSTGRES_PASSWORD || true)" sync_postgres_password "$postgres_password" -REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose up --pull always +REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose --profile tunnel up --pull always diff --git a/ui/src/app/api/config/version/route.ts b/ui/src/app/api/config/version/route.ts index 05900dbc..a031cf4a 100644 --- a/ui/src/app/api/config/version/route.ts +++ b/ui/src/app/api/config/version/route.ts @@ -35,6 +35,7 @@ export async function GET() { let authProvider = "local"; let turnEnabled = false; let forceTurnRelay = false; + let tunnelUrl: string | null = null; let backendStatus: "reachable" | "unreachable" = "unreachable"; let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`; @@ -53,6 +54,7 @@ export async function GET() { authProvider = data.auth_provider; turnEnabled = Boolean(data.turn_enabled); forceTurnRelay = Boolean(data.force_turn_relay); + tunnelUrl = data.tunnel_url ?? null; backendStatus = "reachable"; backendMessage = null; } @@ -68,6 +70,7 @@ export async function GET() { authProvider, turnEnabled, forceTurnRelay, + tunnelUrl, backend: { status: backendStatus, url: backendUrl, diff --git a/ui/src/app/telephony-configurations/[configId]/page.tsx b/ui/src/app/telephony-configurations/[configId]/page.tsx index 4465e5c1..c115373b 100644 --- a/ui/src/app/telephony-configurations/[configId]/page.tsx +++ b/ui/src/app/telephony-configurations/[configId]/page.tsx @@ -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(null); const [phoneNumbers, setPhoneNumbers] = useState([]); const [loading, setLoading] = useState(true); @@ -265,7 +262,7 @@ export default function TelephonyConfigurationDetailPage() { diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index ba615d99..1b615f9f 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -2830,6 +2830,10 @@ export type HealthResponse = { * Backend Api Endpoint */ backend_api_endpoint: string; + /** + * Tunnel Url + */ + tunnel_url?: string | null; /** * Deployment Mode */ diff --git a/ui/src/components/flow/nodes/GenericNode.tsx b/ui/src/components/flow/nodes/GenericNode.tsx index 5fb6a489..d39e0b0a 100644 --- a/ui/src/components/flow/nodes/GenericNode.tsx +++ b/ui/src/components/flow/nodes/GenericNode.tsx @@ -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 (

API Endpoint:

@@ -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" && ( )}
diff --git a/ui/src/context/AppConfigContext.tsx b/ui/src/context/AppConfigContext.tsx index b2a6d79e..7cf98205 100644 --- a/ui/src/context/AppConfigContext.tsx +++ b/ui/src/context/AppConfigContext.tsx @@ -11,6 +11,9 @@ 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; backendStatus: BackendStatus; backendUrl: string; backendMessage: string | null; @@ -29,6 +32,7 @@ const defaultConfig: AppConfig = { authProvider: 'local', turnEnabled: false, forceTurnRelay: false, + tunnelUrl: null, backendStatus: 'unreachable', backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown', backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL @@ -64,6 +68,7 @@ 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, backendStatus, backendUrl, backendMessage: typeof backend.message === 'string' && backend.message.length > 0 diff --git a/ui/src/lib/webhookUrl.ts b/ui/src/lib/webhookUrl.ts new file mode 100644 index 00000000..fa2697d9 --- /dev/null +++ b/ui/src/lib/webhookUrl.ts @@ -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 : "") + ); +}