mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-28 08:49:42 +02:00
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:
parent
3309face2c
commit
78427817a6
30 changed files with 838 additions and 392 deletions
|
|
@ -59,56 +59,53 @@ RUN npm ci --omit=dev && npm cache clean --force
|
|||
# Stage 3: Static ffmpeg binary (avoids apt ffmpeg pulling mesa/libllvm for
|
||||
# hardware acceleration we don't use server-side).
|
||||
#
|
||||
# Resilient download: johnvansickle.com is the primary source but it's a single
|
||||
# self-hosted host with no CDN and goes down intermittently. Use bounded-timeout
|
||||
# retries, then fall back to a pinned BtbN/FFmpeg-Builds autobuild. Every archive
|
||||
# is SHA256-verified before extraction. The two sources have different internal
|
||||
# layouts, so locate the binaries with `find` rather than a fixed strip path.
|
||||
# Source: BtbN/FFmpeg-Builds, served from GitHub's release-assets CDN (fast,
|
||||
# highly available, multi-arch). We pin a specific build for reproducibility,
|
||||
# but to a *month-end* autobuild tag — not a daily one. BtbN prunes daily
|
||||
# autobuilds after ~2 weeks (the previous pin was a daily tag and started
|
||||
# 404ing once GC'd), but keeps one month-end snapshot per month long-term
|
||||
# (~2 years back). A dated tag's assets are immutable, so the per-arch sha256
|
||||
# below never rots: builds stay reproducible AND integrity-verified.
|
||||
#
|
||||
# To upgrade ffmpeg: bump BTBN_TAG + BTBN_REV to a newer month-end autobuild
|
||||
# and refresh the two sha256s. No download needed — read tag, revision and
|
||||
# per-asset sha256 straight from the GitHub release-asset metadata:
|
||||
# gh api repos/BtbN/FFmpeg-Builds/releases/tags/<tag> \
|
||||
# --jq '.assets[] | select(.name|test("(linux64|linuxarm64)-gpl\\.tar\\.xz$")) | "\(.name) \(.digest)"'
|
||||
#
|
||||
# `--speed-limit/--speed-time` aborts a *stalled* transfer after 30s of <1KB/s
|
||||
# (the cause of "stuck" builds) without killing a slow-but-progressing
|
||||
# download; `--max-time` is a hard backstop; `--retry` rides out transient CDN
|
||||
# hiccups. The archive nests binaries under bin/, so locate them with `find`.
|
||||
FROM debian:trixie-slim AS ffmpeg-static
|
||||
ARG TARGETARCH
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& case "${TARGETARCH}" in \
|
||||
amd64) \
|
||||
primary_url="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ; \
|
||||
primary_sha256="abda8d77ce8309141f83ab8edf0596834087c52467f6badf376a6a2a4c87cf67" ; \
|
||||
fallback_url="https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-30-13-19/ffmpeg-N-124681-gb8c5376eb4-linux64-gpl.tar.xz" ; \
|
||||
fallback_sha256="6cfd689ee95ff128e89080af10c93f16e48760eb2acc124c5c8258dc922cc13b" ; \
|
||||
;; \
|
||||
arm64) \
|
||||
primary_url="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" ; \
|
||||
primary_sha256="f4149bb2b0784e30e99bdda85471c9b5930d3402014e934a5098b41d0f7201b1" ; \
|
||||
fallback_url="https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-30-13-19/ffmpeg-N-124681-gb8c5376eb4-linuxarm64-gpl.tar.xz" ; \
|
||||
fallback_sha256="b90a31f1d0b030f5d8a3d11cfec736e369bd5a1371b19bf65421a07f72b1d547" ; \
|
||||
;; \
|
||||
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& mkdir -p /tmp/ffmpeg \
|
||||
&& ok= \
|
||||
&& for source in \
|
||||
"primary ${primary_sha256} ${primary_url}" \
|
||||
"fallback ${fallback_sha256} ${fallback_url}" ; do \
|
||||
source_name="${source%% *}" ; \
|
||||
source_data="${source#* }" ; \
|
||||
sha256="${source_data%% *}" ; \
|
||||
url="${source_data#* }" ; \
|
||||
echo "Downloading ffmpeg (${source_name}) from ${url}" ; \
|
||||
if curl -fsSL --connect-timeout 20 --max-time 300 \
|
||||
--retry 3 --retry-delay 5 --retry-all-errors \
|
||||
-o /tmp/ffmpeg.tar.xz "${url}" \
|
||||
&& echo "${sha256} /tmp/ffmpeg.tar.xz" | sha256sum -c - ; then ok=1 ; break ; fi ; \
|
||||
rm -f /tmp/ffmpeg.tar.xz ; \
|
||||
echo "ffmpeg source failed, trying next: ${url}" >&2 ; \
|
||||
done \
|
||||
&& [ -n "${ok}" ] || { echo "all ffmpeg download sources failed" >&2 ; exit 1 ; } \
|
||||
&& tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg \
|
||||
&& ffmpeg_bin="$(find /tmp/ffmpeg -type f -name ffmpeg | head -n1)" \
|
||||
&& ffprobe_bin="$(find /tmp/ffmpeg -type f -name ffprobe | head -n1)" \
|
||||
&& [ -n "${ffmpeg_bin}" ] && [ -n "${ffprobe_bin}" ] \
|
||||
&& mv "${ffmpeg_bin}" "${ffprobe_bin}" /usr/local/bin/ \
|
||||
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \
|
||||
&& rm -rf /tmp/ffmpeg /tmp/ffmpeg.tar.xz
|
||||
ARG BTBN_TAG=autobuild-2026-05-31-13-22
|
||||
ARG BTBN_REV=N-124714-g49a77d37be
|
||||
RUN set -eu ; \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates xz-utils ; \
|
||||
rm -rf /var/lib/apt/lists/* ; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) btbn_arch=linux64 ; \
|
||||
sha256=ee052121296e6479325e09c6097d48e72a4af472d18c2b94388b5405dcde6cce ;; \
|
||||
arm64) btbn_arch=linuxarm64 ; \
|
||||
sha256=e97545305043794cdf7b698d713e29291464e0c35bb8e0f3ff1f62e4c56eedd6 ;; \
|
||||
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2 ; exit 1 ;; \
|
||||
esac ; \
|
||||
url="https://github.com/BtbN/FFmpeg-Builds/releases/download/${BTBN_TAG}/ffmpeg-${BTBN_REV}-${btbn_arch}-gpl.tar.xz" ; \
|
||||
mkdir -p /tmp/ffmpeg ; cd /tmp/ffmpeg ; \
|
||||
echo "Downloading ffmpeg (${BTBN_TAG}) from ${url}" ; \
|
||||
curl -fsSL --connect-timeout 20 --speed-limit 1024 --speed-time 30 \
|
||||
--max-time 600 --retry 3 --retry-delay 5 --retry-all-errors \
|
||||
-o ffmpeg.tar.xz "${url}" ; \
|
||||
echo "${sha256} ffmpeg.tar.xz" | sha256sum -c - ; \
|
||||
tar -xJf ffmpeg.tar.xz ; \
|
||||
ffmpeg_bin="$(find /tmp/ffmpeg -type f -name ffmpeg | head -n1)" ; \
|
||||
ffprobe_bin="$(find /tmp/ffmpeg -type f -name ffprobe | head -n1)" ; \
|
||||
[ -n "${ffmpeg_bin}" ] && [ -n "${ffprobe_bin}" ] ; \
|
||||
mv "${ffmpeg_bin}" "${ffprobe_bin}" /usr/local/bin/ ; \
|
||||
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe ; \
|
||||
rm -rf /tmp/ffmpeg
|
||||
|
||||
# Stage 4: Runtime - Minimal image with only runtime dependencies
|
||||
FROM python:3.13-slim AS runner
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -4,8 +4,21 @@ server {
|
|||
listen 80;
|
||||
server_name __DOGRAH_PUBLIC_HOST__;
|
||||
|
||||
# Redirect all HTTP to HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
# Serve Let's Encrypt HTTP-01 challenges out of the certs webroot that
|
||||
# certbot --webroot writes into (./certs is bind-mounted here read-only).
|
||||
# Only this path is exposed; local.crt/local.key are never served.
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/nginx/certs;
|
||||
default_type "text/plain";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Redirect everything else to HTTPS. This must live in a location block,
|
||||
# not a server-level `return`, or it would fire before location matching
|
||||
# and hijack the ACME challenge above.
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -5,6 +5,10 @@ description: "Deploy Dograh AI with custom domain names and SSL certificates"
|
|||
|
||||
Deploy Dograh AI with your own custom domain name for a professional production setup. By now, you should be able to create and test a voice agent by following the previous guide to setup the platform on a remote server using [Docker](docker#option-2%3A-remote-server-deployment)
|
||||
|
||||
<Tip>
|
||||
**You don't need to own a domain just to remove the browser warning.** If your server has a public IP, the [remote setup](docker#option-2%3A-remote-server-deployment) already issues a free, auto-renewing Let's Encrypt certificate via [sslip.io](https://sslip.io) (e.g. `https://203-0-113-10.sslip.io`) — no DNS configuration required. Follow this Custom Domain guide only when you want your **own** domain name (e.g. `voice.yourcompany.com`).
|
||||
</Tip>
|
||||
|
||||
## What is Custom Domain Deployment?
|
||||
|
||||
Custom domain deployment allows you to run Dograh AI with a personalized domain name (like `voice.yourcompany.com`) instead of using IP addresses. This setup includes:
|
||||
|
|
@ -109,65 +113,50 @@ sudo apt install certbot -y
|
|||
sudo yum install certbot -y
|
||||
```
|
||||
|
||||
### Stop Dograh Services
|
||||
### Point `.env` at your domain
|
||||
|
||||
Before generating certificates, stop the running Dograh services to free up port 80:
|
||||
Update `.env` so the canonical remote settings use your domain — `dograh-init` reads these to render nginx's `server_name`. Replace `voice.yourcompany.com` with your actual domain throughout:
|
||||
|
||||
```bash
|
||||
cd dograh
|
||||
sudo docker compose --profile remote down
|
||||
sed -i "s/^PUBLIC_HOST=.*/PUBLIC_HOST=voice.yourcompany.com/" .env
|
||||
sed -i "s|^PUBLIC_BASE_URL=.*|PUBLIC_BASE_URL=https://voice.yourcompany.com|" .env
|
||||
```
|
||||
|
||||
### Generate SSL Certificates
|
||||
### Start services so nginx can answer the ACME challenge
|
||||
|
||||
Run Certbot to obtain SSL certificates for your domain:
|
||||
Bring the stack up (or recreate it) through the validated wrapper. nginx serves the Let's Encrypt HTTP-01 challenge from `certs/.well-known/acme-challenge/` on port 80, so the stack must be **running** during issuance — there's no need to stop it:
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --standalone -d voice.yourcompany.com
|
||||
./remote_up.sh
|
||||
```
|
||||
|
||||
Replace `voice.yourcompany.com` with your actual domain name.
|
||||
### Generate the SSL certificate (webroot)
|
||||
|
||||
Issue the certificate using the webroot challenge served by the running nginx:
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --webroot -w "$(pwd)/certs" -d voice.yourcompany.com
|
||||
```
|
||||
|
||||
Certbot will:
|
||||
1. Verify that you control the domain
|
||||
2. Generate SSL certificates
|
||||
3. Store them in `/etc/letsencrypt/live/voice.yourcompany.com/`
|
||||
1. Write a challenge file under `certs/.well-known/acme-challenge/`
|
||||
2. Have Let's Encrypt fetch it over HTTP (port 80) to verify you control the domain
|
||||
3. Store the certificate in `/etc/letsencrypt/live/voice.yourcompany.com/`
|
||||
|
||||
<Note>
|
||||
You'll be prompted to enter an email address for renewal notifications and agree to the terms of service.
|
||||
You'll be prompted for an email address (for renewal notices) and to agree to the terms of service. Because nginx keeps serving traffic throughout, issuance and renewal happen with **no downtime** — unlike the older `--standalone` flow, which had to stop the stack to free port 80.
|
||||
</Note>
|
||||
|
||||
### Copy Certificates to Dograh Directory
|
||||
### Copy the certificate and load it
|
||||
|
||||
Copy the generated certificates to the dograh certs directory:
|
||||
Copy the issued certificate into the `certs/` directory nginx reads, then restart nginx to load it:
|
||||
|
||||
```bash
|
||||
cd dograh
|
||||
sudo cp /etc/letsencrypt/live/voice.yourcompany.com/fullchain.pem certs/local.crt
|
||||
sudo cp /etc/letsencrypt/live/voice.yourcompany.com/privkey.pem certs/local.key
|
||||
sudo chmod 644 certs/local.crt certs/local.key
|
||||
```
|
||||
|
||||
### Update Canonical Public URL Settings
|
||||
|
||||
Update `.env` so the canonical remote settings point at your domain:
|
||||
|
||||
```bash
|
||||
nano dograh/.env
|
||||
```
|
||||
|
||||
```bash
|
||||
PUBLIC_HOST=voice.yourcompany.com
|
||||
PUBLIC_BASE_URL=https://voice.yourcompany.com
|
||||
```
|
||||
|
||||
### Start Dograh Services
|
||||
|
||||
Start Dograh through the validated startup wrapper so `dograh-init` regenerates nginx and coturn runtime config before Docker starts:
|
||||
|
||||
```bash
|
||||
cd dograh
|
||||
./remote_up.sh
|
||||
sudo docker compose --profile remote restart nginx
|
||||
```
|
||||
|
||||
### Access Your Application
|
||||
|
|
@ -222,7 +211,7 @@ sudo certbot renew --dry-run
|
|||
|
||||
If Certbot fails to generate certificates:
|
||||
|
||||
1. **Port 80 blocked**: Ensure port 80 is open in your firewall and no service is using it
|
||||
1. **Port 80 blocked**: Ensure port 80 is open in your firewall and reachable from the internet. With the webroot flow nginx must be **running** and serving the challenge on port 80 (don't stop the stack)
|
||||
2. **DNS not propagated**: Wait for DNS changes to propagate and verify with `nslookup`
|
||||
3. **Rate limits**: Let's Encrypt has rate limits. If you've exceeded them, wait before retrying
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -123,13 +123,14 @@ Watch the video tutorial below for a step-by-step walkthrough of deploying Dogra
|
|||
allowFullScreen
|
||||
></iframe>
|
||||
|
||||
Deploy Dograh AI on a remote server to make it accessible from anywhere using your server's IP address. This setup includes HTTPS support via nginx reverse proxy with self-signed certificates. **We need to serve the application over HTTPS, since modern browsers only allow microphone permissions for websites being served over HTTPS**.
|
||||
Deploy Dograh AI on a remote server to make it accessible from anywhere. If your server has a **public IP**, setup obtains a free, trusted HTTPS certificate automatically via [sslip.io](https://sslip.io) and Let's Encrypt — no domain name or DNS configuration required, and no browser warning. On a private/reserved IP it falls back to a self-signed certificate. **We need to serve the application over HTTPS, since modern browsers only allow microphone permissions for websites being served over HTTPS**.
|
||||
|
||||
<Warning>We highly recommend you set up the platform on a fresh server, so that there are less chances of confliciting dependencies, and ports from other applications.</Warning>
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A server with Docker and Docker Compose installed. It should have minimum of 8 GB RAM and 4 vCPUs.
|
||||
- Root (`sudo`) access on the server — the setup script must be run as root and exits early otherwise.
|
||||
- Public IP address for your server. You can also access the server using a local IP address in your VPC as long as its reachable from your browser.
|
||||
- TCP Ports 80, 443, 3478, 5349 and UDP Ports 3478, 5349 and 49152:49200 reachable from Internet (Port 80 and 443 to access the UI and rest of the ports for WebRTC Signaling)
|
||||
|
||||
|
|
@ -140,9 +141,13 @@ Deploy Dograh AI on a remote server to make it accessible from anywhere using yo
|
|||
Run the automated setup script that will configure everything for you:
|
||||
|
||||
```bash
|
||||
curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && ./setup_remote.sh
|
||||
curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && sudo ./setup_remote.sh
|
||||
```
|
||||
|
||||
<Note>
|
||||
Run with `sudo` — the script must run as root (it provisions Docker, binds ports 80/443, and installs a Let's Encrypt certificate with a system renewal hook) and exits early if it isn't. On a public IP it obtains and auto-renews a trusted certificate; on a private IP it falls back to a self-signed one, since Let's Encrypt can't validate a private address. Optional environment-variable overrides: `CERT_MODE=self-signed` forces a self-signed cert, `ACME_DOMAIN_SUFFIX=nip.io` switches to the nip.io pool if sslip.io is rate-limited, and `LETSENCRYPT_EMAIL=you@example.com` sets the address for expiry notices.
|
||||
</Note>
|
||||
|
||||
The script will prompt you for:
|
||||
- Your server's public IP address
|
||||
- A password for the TURN server (optional, press Enter for default)
|
||||
|
|
@ -152,7 +157,8 @@ The script will prompt you for:
|
|||
It will automatically:
|
||||
- Get the source — `docker-compose.yaml` only (prebuilt mode), or clone the full repo (build mode)
|
||||
- Download the validated remote deployment helper bundle
|
||||
- Generate SSL certificates
|
||||
- Obtain a free trusted Let's Encrypt certificate via sslip.io and configure auto-renewal (public IP), or generate a self-signed certificate (private IP / no root)
|
||||
- For a public IP, also start the stack and print your ready-to-use `https://…sslip.io` URL
|
||||
- Create an environment file with TURN server configuration
|
||||
- Validate the runtime config that `dograh-init` will render from `.env`
|
||||
- Write a `docker-compose.override.yaml` with build directives (build mode only)
|
||||
|
|
@ -163,7 +169,7 @@ It will automatically:
|
|||
Please ensure that Docker Compose is installed on your machine before proceeding further. You can check whether its installed by running `docker compose version` command. If its not installed, please install it by following your server provider documentation.
|
||||
</Note>
|
||||
|
||||
After the setup script completes, start Dograh. The script prints the exact command to run at the end — it differs slightly between modes:
|
||||
After the setup script completes, start Dograh. For a **public-IP install the trusted-certificate flow already started the stack** and printed your `https://…sslip.io` URL — you can skip to [Access Your Application](#access-your-application). For a self-signed install, start it yourself (the script prints the exact command at the end — it differs slightly between modes):
|
||||
|
||||
<CodeGroup>
|
||||
```bash Prebuilt mode
|
||||
|
|
@ -180,13 +186,13 @@ First boot in build mode takes several minutes — Docker has to build both the
|
|||
|
||||
### Access Your Application
|
||||
|
||||
Your application will be available at
|
||||
```
|
||||
https://YOUR_SERVER_IP
|
||||
```
|
||||
Your application will be available at the URL the setup script printed at the end:
|
||||
|
||||
- **Public IP (trusted certificate):** `https://<your-ip-with-dashes>.sslip.io` — for example `https://203-0-113-10.sslip.io`. No browser warning.
|
||||
- **Self-signed (private IP):** `https://YOUR_SERVER_IP`
|
||||
|
||||
<Note>
|
||||
Since we are using a self-signed certificate, your browser will show a security warning. You can safely accept it to proceed.
|
||||
With a self-signed certificate your browser shows a security warning you can safely accept. With the sslip.io Let's Encrypt certificate there is no warning.
|
||||
</Note>
|
||||
|
||||
You should be able to create and test a voice agent now.
|
||||
|
|
@ -196,7 +202,7 @@ You should be able to create and test a voice agent now.
|
|||
- The remote deployment includes an nginx reverse proxy for HTTPS termination
|
||||
- File downloads (transcripts, recordings) are automatically routed through nginx
|
||||
- WebSocket connections for real-time features are properly proxied
|
||||
- The setup uses self-signed certificates - browsers will show a security warning that you can safely accept for testing
|
||||
- Public-IP installs get a trusted Let's Encrypt certificate (via sslip.io) that renews automatically; private-IP installs use a self-signed certificate (browser warning)
|
||||
- The TURN server (coturn) is configured for WebRTC NAT traversal
|
||||
- For production deployments with proper SSL and domain names, see the [Custom Domain](custom-domain) documentation
|
||||
|
||||
|
|
@ -213,7 +219,7 @@ The setup script creates the following files in the `dograh/` directory:
|
|||
| `scripts/lib/setup_common.sh` | Shared deployment helper library |
|
||||
| `deploy/templates/` | nginx and coturn runtime config templates |
|
||||
| `generate_certificate.sh` | Script to regenerate SSL certificates |
|
||||
| `certs/local.crt` | Self-signed SSL certificate |
|
||||
| `certs/local.crt` | SSL certificate (Let's Encrypt via sslip.io, or self-signed) |
|
||||
| `certs/local.key` | SSL private key |
|
||||
| `.env` | Single source of truth for deployment settings (TURN secret, JWT secret, FastAPI worker count, public host/base URL) |
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ Number of FastAPI workers (uvicorn processes nginx will load-balance):
|
|||
Press Enter for the default (`4`) or enter a different positive integer. Non-interactive callers (cloud-init, CI, Terraform) can set the value via environment variable instead:
|
||||
|
||||
```bash
|
||||
SERVER_IP=... TURN_SECRET=... FASTAPI_WORKERS=8 ./setup_remote.sh
|
||||
sudo SERVER_IP=... TURN_SECRET=... FASTAPI_WORKERS=8 ./setup_remote.sh
|
||||
```
|
||||
|
||||
The script stores the value in **`.env`** (`FASTAPI_WORKERS=N`). The supported startup path (`./remote_up.sh`) preflights the `dograh-init` render from that value before every remote start, so nginx and the API worker count stay aligned.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
19
remote_up.sh
19
remote_up.sh
|
|
@ -65,10 +65,25 @@ else
|
|||
COMPOSE_CMD=(sudo docker compose)
|
||||
fi
|
||||
|
||||
# Reconcile the Postgres role password with .env before starting the API.
|
||||
# POSTGRES_PASSWORD only applies on first volume init, so an existing volume can
|
||||
# hold a stale password the API would fail to authenticate against. Idempotent.
|
||||
dograh_sync_postgres_password "$SCRIPT_DIR" "${COMPOSE_CMD[@]}"
|
||||
|
||||
# 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`.
|
||||
|
|
|
|||
|
|
@ -29,12 +29,15 @@ 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`.
|
||||
- `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`. It hard-requires root (a guard near the top exits non-root with a "re-run with sudo" message) because it provisions Docker, binds :80/:443, and installs a Let's Encrypt cert + system renewal hook. Cloud-init/user-data callers (e.g. `infrastructure/`) already run as root, so they pass; interactive callers must use `sudo`. Docs that invoke it (`docs/deployment/docker.mdx`, `docs/deployment/scaling.mdx`) and the hint printed by `setup_custom_domain.sh` all use `sudo`.
|
||||
- `update_remote.sh` is the migration/upgrade path for prebuilt remote installs. It refreshes `docker-compose.yaml`, `remote_up.sh`, `scripts/run_dograh_init.sh`, `scripts/lib/setup_common.sh`, and `deploy/templates/*`, backs up touched files, removes legacy host `nginx.conf` / `turnserver.conf`, and revalidates the init-based path.
|
||||
- `setup_custom_domain.sh` is certificate/domain glue only. It must not own nginx config. It updates canonical public URL keys in `.env`, copies Let's Encrypt certs into `certs/`, installs renewal hook, and restarts through `./remote_up.sh`.
|
||||
- `setup_local.{sh,ps1}` has an interactive `Enable coturn? [y/N]` prompt unless `ENABLE_COTURN` is preset. If coturn is enabled, it downloads the minimal helper bundle needed for `local-turn` (`setup_common.sh`, `run_dograh_init.sh`, templates) and relies on `dograh-init` to render coturn config.
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
@ -401,6 +401,59 @@ dograh_preflight_remote_init_render() {
|
|||
rm -rf "$tmp_root"
|
||||
}
|
||||
|
||||
# Reconcile the running Postgres role password with POSTGRES_PASSWORD in .env.
|
||||
#
|
||||
# POSTGRES_PASSWORD only takes effect when the postgres data volume is first
|
||||
# initialized. If the volume was created before .env had a generated password
|
||||
# (e.g. an early start used the compose fallback `:-postgres`), or the password
|
||||
# was later rotated, the role keeps its old password while the API connects with
|
||||
# the .env value over TCP (pg_hba `scram-sha-256`) and dies with "password
|
||||
# authentication failed for user postgres". start_docker.sh handles this for the
|
||||
# OSS quickstart; the remote path (remote_up.sh) needs the same reconciliation.
|
||||
#
|
||||
# Bring postgres up on its own, then ALTER the role over the trusted local
|
||||
# socket (pg_hba trusts `local`, so this works even when the password is
|
||||
# currently mismatched). Idempotent: on a fresh volume it just re-sets the same
|
||||
# value. Survives the later `--force-recreate` because the password lives in the
|
||||
# data volume, not the container.
|
||||
dograh_sync_postgres_password() {
|
||||
local project_dir=$1
|
||||
shift
|
||||
local compose=("$@")
|
||||
local env_file="$project_dir/.env"
|
||||
local password=""
|
||||
local ready=""
|
||||
local i
|
||||
|
||||
[[ ${#compose[@]} -gt 0 ]] || compose=(docker compose)
|
||||
|
||||
if [[ -f "$env_file" ]]; then
|
||||
password="$(awk -F= '/^POSTGRES_PASSWORD=/{sub(/^POSTGRES_PASSWORD=/, ""); print; exit}' "$env_file")"
|
||||
fi
|
||||
|
||||
# No explicit password: the compose fallback (`:-postgres`) governs both the
|
||||
# DB init and the API's DATABASE_URL, so the two already agree — nothing to do.
|
||||
[[ -n "$password" ]] || return 0
|
||||
|
||||
dograh_info "Syncing Postgres password from .env..."
|
||||
( cd "$project_dir" && "${compose[@]}" up -d postgres ) >/dev/null
|
||||
|
||||
for ((i = 0; i < 30; i++)); do
|
||||
if ( cd "$project_dir" && "${compose[@]}" exec -T postgres pg_isready -U postgres ) >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[[ -n "$ready" ]] || dograh_fail "Postgres did not become ready while syncing POSTGRES_PASSWORD."
|
||||
|
||||
printf '%s\n' "ALTER USER postgres WITH PASSWORD :'pw';" \
|
||||
| ( cd "$project_dir" && "${compose[@]}" exec -T postgres \
|
||||
psql -U postgres -d postgres -v ON_ERROR_STOP=1 -v "pw=$password" ) >/dev/null \
|
||||
|| dograh_fail "Failed to sync Postgres password from .env."
|
||||
dograh_success "✓ Postgres password synced with .env"
|
||||
}
|
||||
|
||||
dograh_prepare_remote_install() {
|
||||
local project_dir=${1:-$(dograh_project_dir)}
|
||||
local env_file="$project_dir/.env"
|
||||
|
|
@ -410,6 +463,101 @@ dograh_prepare_remote_install() {
|
|||
dograh_preflight_remote_init_render "$project_dir"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS certificate helpers (self-signed bootstrap + Let's Encrypt via webroot)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Map an IPv4 address to a public sslip.io / nip.io hostname, e.g.
|
||||
# 203.0.113.10 -> 203-0-113-10.sslip.io. The hostname resolves back to the
|
||||
# embedded IP from any public resolver, so Let's Encrypt can validate it over
|
||||
# the HTTP-01 challenge without the operator owning a domain. Public IPs only:
|
||||
# Let's Encrypt refuses to validate private/reserved addresses.
|
||||
dograh_sslip_host_from_ip() {
|
||||
local ip=$1
|
||||
local suffix=${2:-sslip.io}
|
||||
|
||||
dograh_is_ipv4 "$ip" || dograh_fail "dograh_sslip_host_from_ip: '$ip' is not an IPv4 address"
|
||||
printf '%s.%s\n' "${ip//./-}" "$suffix"
|
||||
}
|
||||
|
||||
# Install certbot via the host package manager if it is not already present.
|
||||
# Returns non-zero (instead of exiting) when no supported package manager is
|
||||
# found or the install fails, so callers can fall back to a self-signed cert.
|
||||
dograh_install_certbot() {
|
||||
if command -v certbot >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
dograh_info "Installing Certbot..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq certbot
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y -q certbot
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y -q certbot
|
||||
else
|
||||
dograh_warn "Could not detect a package manager (apt/dnf/yum) to install certbot."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Obtain (or renew) a Let's Encrypt certificate for $host using the webroot
|
||||
# challenge served by the running nginx container out of <project>/certs, then
|
||||
# copy the issued cert to certs/local.{crt,key} (the files nginx reads). This
|
||||
# needs nginx already running and serving /.well-known/acme-challenge/ on :80.
|
||||
# Returns non-zero on failure so callers can keep the self-signed cert.
|
||||
dograh_issue_letsencrypt_webroot() {
|
||||
local project_dir=$1
|
||||
local host=$2
|
||||
local email=${3:-}
|
||||
local webroot="$project_dir/certs"
|
||||
local live_dir="/etc/letsencrypt/live/$host"
|
||||
local -a email_args
|
||||
|
||||
if [[ -n "$email" ]]; then
|
||||
email_args=(--email "$email")
|
||||
else
|
||||
email_args=(--register-unsafely-without-email)
|
||||
fi
|
||||
|
||||
mkdir -p "$webroot/.well-known/acme-challenge"
|
||||
|
||||
certbot certonly --webroot -w "$webroot" \
|
||||
--non-interactive --agree-tos --keep-until-expiring \
|
||||
"${email_args[@]}" \
|
||||
-d "$host" || return 1
|
||||
|
||||
[[ -f "$live_dir/fullchain.pem" && -f "$live_dir/privkey.pem" ]] || return 1
|
||||
|
||||
cp "$live_dir/fullchain.pem" "$webroot/local.crt"
|
||||
cp "$live_dir/privkey.pem" "$webroot/local.key"
|
||||
chmod 644 "$webroot/local.crt" "$webroot/local.key"
|
||||
}
|
||||
|
||||
# Install a certbot deploy hook so renewed certificates are copied into
|
||||
# <project>/certs and nginx is restarted to load them. Renewal itself is driven
|
||||
# by certbot's packaged systemd timer / cron; webroot renewals need no downtime
|
||||
# because the running nginx serves the challenge.
|
||||
dograh_install_cert_renewal_hook() {
|
||||
local project_dir=$1
|
||||
local host=$2
|
||||
local hook_dir="/etc/letsencrypt/renewal-hooks/deploy"
|
||||
local hook_path="$hook_dir/dograh-reload.sh"
|
||||
|
||||
mkdir -p "$hook_dir"
|
||||
|
||||
cat > "$hook_path" << HOOK_EOF
|
||||
#!/bin/bash
|
||||
cp /etc/letsencrypt/live/$host/fullchain.pem $project_dir/certs/local.crt
|
||||
cp /etc/letsencrypt/live/$host/privkey.pem $project_dir/certs/local.key
|
||||
chmod 644 $project_dir/certs/local.crt $project_dir/certs/local.key
|
||||
|
||||
cd $project_dir
|
||||
docker compose --profile remote restart nginx 2>/dev/null || true
|
||||
HOOK_EOF
|
||||
chmod +x "$hook_path"
|
||||
}
|
||||
|
||||
dograh_download_bundle_file_for_ref() {
|
||||
local destination=$1
|
||||
local remote_path=$2
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ if [[ ! -d "dograh" ]]; then
|
|||
echo -e "${RED}Error: 'dograh' directory not found.${NC}"
|
||||
echo -e "${YELLOW}Please run this script from the directory containing your Dograh installation.${NC}"
|
||||
echo -e "${YELLOW}If you haven't set up Dograh yet, run the remote setup first:${NC}"
|
||||
echo -e "${BLUE} curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && ./setup_remote.sh${NC}"
|
||||
echo -e "${BLUE} curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && sudo ./setup_remote.sh${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ echo -e " Domain: ${BLUE}$DOMAIN_NAME${NC}"
|
|||
echo -e " Email: ${BLUE}$EMAIL_ADDRESS${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}[1/7] Verifying DNS configuration...${NC}"
|
||||
echo -e "${BLUE}[1/6] Verifying DNS configuration...${NC}"
|
||||
SERVER_IP="$(curl -s ifconfig.me || curl -s icanhazip.com || echo "")"
|
||||
RESOLVED_IP="$(dig +short "$DOMAIN_NAME" | tail -1)"
|
||||
|
||||
|
|
@ -84,22 +84,14 @@ else
|
|||
echo -e "${GREEN}✓ DNS is correctly configured (${RESOLVED_IP})${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}[2/7] Installing Certbot...${NC}"
|
||||
if command -v apt-get &> /dev/null; then
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq certbot
|
||||
elif command -v yum &> /dev/null; then
|
||||
yum install -y -q certbot
|
||||
elif command -v dnf &> /dev/null; then
|
||||
dnf install -y -q certbot
|
||||
else
|
||||
dograh_fail "Could not detect package manager. Please install certbot manually."
|
||||
fi
|
||||
echo -e "${BLUE}[2/6] Installing Certbot...${NC}"
|
||||
dograh_install_certbot || dograh_fail "Could not install certbot. Please install it manually and re-run."
|
||||
echo -e "${GREEN}✓ Certbot installed${NC}"
|
||||
|
||||
echo -e "${BLUE}[3/7] Stopping Dograh services...${NC}"
|
||||
echo -e "${BLUE}[3/6] Pointing .env at $DOMAIN_NAME and starting services...${NC}"
|
||||
cd dograh
|
||||
DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)"
|
||||
DOGRAH_PATH="$(pwd)"
|
||||
|
||||
if [[ ! -f remote_up.sh || ! -f scripts/lib/setup_common.sh ]]; then
|
||||
dograh_download_remote_support_bundle "$(pwd)" "main"
|
||||
|
|
@ -107,113 +99,74 @@ fi
|
|||
|
||||
dograh_require_init_compose_layout "$(pwd)"
|
||||
|
||||
if docker compose --profile remote ps --quiet 2>/dev/null | grep -q .; then
|
||||
docker compose --profile remote down
|
||||
echo -e "${GREEN}✓ Dograh services stopped${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ No running services found${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}[4/7] Generating Let's Encrypt SSL certificate...${NC}"
|
||||
CERTBOT_OUTPUT=$(certbot certonly --standalone \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
--email "$EMAIL_ADDRESS" \
|
||||
-d "$DOMAIN_NAME" 2>&1) || {
|
||||
echo -e "${RED}✗ Certificate generation failed${NC}"
|
||||
echo ""
|
||||
|
||||
if echo "$CERTBOT_OUTPUT" | grep -qi "timeout\|firewall\|connection"; then
|
||||
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${YELLOW} Port 80 appears to be blocked by a firewall.${NC}"
|
||||
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e "Let's Encrypt needs to connect to port 80 to verify domain ownership."
|
||||
echo ""
|
||||
elif echo "$CERTBOT_OUTPUT" | grep -qi "too many\|rate.limit"; then
|
||||
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${YELLOW} Let's Encrypt rate limit reached.${NC}"
|
||||
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo "You've requested too many certificates recently."
|
||||
echo "Please wait before trying again (usually 1 hour)."
|
||||
echo ""
|
||||
elif echo "$CERTBOT_OUTPUT" | grep -qi "dns\|resolve\|NXDOMAIN"; then
|
||||
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${YELLOW} DNS resolution failed.${NC}"
|
||||
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo "The domain '$DOMAIN_NAME' does not resolve to this server."
|
||||
echo "Please verify your DNS A record is correctly configured."
|
||||
echo ""
|
||||
else
|
||||
echo -e "${YELLOW}Certbot output:${NC}"
|
||||
echo "$CERTBOT_OUTPUT"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e "After fixing the issue, re-run this script:"
|
||||
echo -e " ${BLUE}sudo ./setup_custom_domain.sh${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
}
|
||||
echo -e "${GREEN}✓ SSL certificate generated${NC}"
|
||||
|
||||
CERT_PATH="/etc/letsencrypt/live/$DOMAIN_NAME"
|
||||
echo ""
|
||||
echo -e "${BLUE}Certificate location:${NC}"
|
||||
echo -e " ${CERT_PATH}/"
|
||||
[[ -f "$CERT_PATH/fullchain.pem" ]] && echo -e " ${GREEN}✓${NC} fullchain.pem exists" || echo -e " ${RED}✗${NC} fullchain.pem NOT FOUND"
|
||||
[[ -f "$CERT_PATH/privkey.pem" ]] && echo -e " ${GREEN}✓${NC} privkey.pem exists" || echo -e " ${RED}✗${NC} privkey.pem NOT FOUND"
|
||||
echo ""
|
||||
|
||||
mkdir -p certs
|
||||
cp "$CERT_PATH/fullchain.pem" certs/local.crt
|
||||
cp "$CERT_PATH/privkey.pem" certs/local.key
|
||||
chmod 644 certs/local.crt certs/local.key
|
||||
echo -e "${GREEN}✓${NC} Certificates copied to certs/ directory"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}[5/7] Updating canonical remote settings and validating init-based config...${NC}"
|
||||
dograh_load_env_file .env
|
||||
|
||||
if [[ -z "${SERVER_IP:-}" ]]; then
|
||||
SERVER_IP="$(dograh_infer_server_ip "$(pwd)" || true)"
|
||||
fi
|
||||
|
||||
[[ -n "${SERVER_IP:-}" ]] || dograh_fail "Could not determine SERVER_IP from the existing install"
|
||||
|
||||
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)"
|
||||
echo -e "${GREEN}✓ .env synchronized and init-based config validated${NC}"
|
||||
|
||||
echo -e "${BLUE}[6/7] Setting up automatic certificate renewal...${NC}"
|
||||
DOGRAH_PATH="$(pwd)"
|
||||
# Bring the stack up (recreating it) so dograh-init re-renders nginx with the
|
||||
# domain server_name and the ACME challenge location, served with the existing
|
||||
# certificate. certbot --webroot then validates against the running nginx:
|
||||
# no downtime, and (unlike --standalone) renewal keeps working later while
|
||||
# nginx holds port 80.
|
||||
./remote_up.sh
|
||||
|
||||
cat > /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh << HOOK_EOF
|
||||
#!/bin/bash
|
||||
cp /etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem $DOGRAH_PATH/certs/local.crt
|
||||
cp /etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem $DOGRAH_PATH/certs/local.key
|
||||
chmod 644 $DOGRAH_PATH/certs/local.crt $DOGRAH_PATH/certs/local.key
|
||||
echo -e "${BLUE}Waiting for nginx to answer on port 80...${NC}"
|
||||
nginx_ready=0
|
||||
for ((i=1; i<=60; i++)); do
|
||||
if curl -s -o /dev/null --max-time 3 "http://127.0.0.1/"; then
|
||||
nginx_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
[[ "$nginx_ready" == "1" ]] || dograh_fail "nginx did not come up on port 80; cannot run the ACME challenge."
|
||||
echo -e "${GREEN}✓ Services running and serving the ACME challenge${NC}"
|
||||
|
||||
cd $DOGRAH_PATH
|
||||
docker compose --profile remote restart nginx 2>/dev/null || true
|
||||
HOOK_EOF
|
||||
chmod +x /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh
|
||||
echo -e "${BLUE}[4/6] Obtaining Let's Encrypt certificate for $DOMAIN_NAME...${NC}"
|
||||
if ! dograh_issue_letsencrypt_webroot "$(pwd)" "$DOMAIN_NAME" "$EMAIL_ADDRESS"; then
|
||||
echo -e "${RED}✗ Certificate issuance failed${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Common causes:${NC}"
|
||||
echo " - Port 80 not reachable from the internet (open it in your firewall)"
|
||||
echo " - DNS A record for $DOMAIN_NAME does not point to this server yet"
|
||||
echo " - Let's Encrypt rate limit reached (wait, then retry)"
|
||||
echo " - Upgrading an older install: run ./update_remote.sh first to refresh the"
|
||||
echo " nginx template so it serves the ACME challenge, then re-run this script"
|
||||
echo ""
|
||||
echo -e "The stack is still running with the previous certificate."
|
||||
echo -e "After fixing the issue, re-run: ${BLUE}sudo ./setup_custom_domain.sh${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓ Certificate issued and copied to certs/${NC}"
|
||||
|
||||
echo -e "${BLUE}[5/6] Loading the new certificate (restarting nginx)...${NC}"
|
||||
docker compose --profile remote restart nginx >/dev/null 2>&1 || true
|
||||
echo -e "${GREEN}✓ nginx restarted${NC}"
|
||||
|
||||
echo -e "${BLUE}[6/6] Configuring automatic certificate renewal...${NC}"
|
||||
dograh_install_cert_renewal_hook "$(pwd)" "$DOMAIN_NAME"
|
||||
if certbot renew --dry-run --quiet; then
|
||||
echo -e "${GREEN}✓ Auto-renewal configured and tested${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Auto-renewal test had issues, but certificates are installed${NC}"
|
||||
echo -e "${YELLOW}⚠ Auto-renewal dry-run had issues, but the certificate is installed${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}[7/7] Starting Dograh services through validated startup wrapper...${NC}"
|
||||
./remote_up.sh
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Custom Domain Setup Complete! ║${NC}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -35,9 +35,17 @@ echo "║ Automated HTTPS deployment with TURN server ║"
|
|||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
# Get the public IP address (skip prompt if SERVER_IP is already set)
|
||||
# This setup must run as root: it provisions Docker, binds privileged ports
|
||||
# 80/443, and (for public IPs) installs a Let's Encrypt certificate plus a
|
||||
# system renewal hook under /etc/letsencrypt — all of which require root. Stop
|
||||
# early with clear guidance rather than getting halfway and degrading the install.
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
dograh_fail "setup_remote.sh must be run as root.\nRe-run with sudo:\n sudo ./setup_remote.sh"
|
||||
fi
|
||||
|
||||
# Get the server IP address (skip prompt if SERVER_IP is already set)
|
||||
if [[ -z "${SERVER_IP:-}" ]]; then
|
||||
echo -e "${YELLOW}Enter your server's public IP address:${NC}"
|
||||
echo -e "${YELLOW}Enter your server's IP address:${NC}"
|
||||
read -p "> " SERVER_IP
|
||||
fi
|
||||
|
||||
|
|
@ -49,6 +57,61 @@ if ! dograh_is_ipv4 "$SERVER_IP"; then
|
|||
dograh_fail "Invalid IP address format"
|
||||
fi
|
||||
|
||||
# Certificate strategy. CERT_MODE selects how HTTPS is secured:
|
||||
# auto - public IP + root + docker -> sslip (trusted); otherwise self-signed
|
||||
# sslip - free trusted Let's Encrypt cert via <ip>.sslip.io (public IP only)
|
||||
# self-signed - generate a self-signed cert (browser shows a warning)
|
||||
# Reserved for future private-network paths (not implemented yet):
|
||||
# letsencrypt-dns, cloudflare-tunnel, external
|
||||
CERT_MODE="${CERT_MODE:-auto}"
|
||||
ACME_DOMAIN_SUFFIX="${ACME_DOMAIN_SUFFIX:-sslip.io}"
|
||||
LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL:-}"
|
||||
|
||||
if [[ "$CERT_MODE" == "auto" ]]; then
|
||||
if dograh_is_local_ipv4 "$SERVER_IP"; then
|
||||
CERT_MODE="self-signed"
|
||||
dograh_warn "$SERVER_IP is a private IP — using a self-signed certificate."
|
||||
dograh_warn "For a trusted cert, deploy on a public IP or a domain you own"
|
||||
dograh_warn "(https://docs.dograh.com/deployment/custom-domain)."
|
||||
elif ! command -v docker >/dev/null 2>&1; then
|
||||
CERT_MODE="self-signed"
|
||||
dograh_warn "Docker not found — skipping automatic Let's Encrypt setup and using a self-signed cert."
|
||||
else
|
||||
CERT_MODE="sslip"
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$CERT_MODE" in
|
||||
self-signed) ;;
|
||||
sslip)
|
||||
if dograh_is_local_ipv4 "$SERVER_IP"; then
|
||||
dograh_fail "CERT_MODE=sslip needs a public IP; $SERVER_IP is private/reserved."
|
||||
fi
|
||||
command -v docker >/dev/null 2>&1 || dograh_fail "CERT_MODE=sslip needs Docker to serve the ACME challenge."
|
||||
;;
|
||||
letsencrypt-dns|cloudflare-tunnel|external)
|
||||
dograh_fail "CERT_MODE=$CERT_MODE is reserved but not implemented yet. Use 'sslip' (public IP) or 'self-signed'."
|
||||
;;
|
||||
*)
|
||||
dograh_fail "Unknown CERT_MODE '$CERT_MODE' (expected: auto, sslip, self-signed)."
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$CERT_MODE" == "sslip" ]]; then
|
||||
PUBLIC_HOST_VALUE="$(dograh_sslip_host_from_ip "$SERVER_IP" "$ACME_DOMAIN_SUFFIX")"
|
||||
CERT_DESC="Let's Encrypt via $ACME_DOMAIN_SUFFIX (trusted)"
|
||||
else
|
||||
PUBLIC_HOST_VALUE="$SERVER_IP"
|
||||
CERT_DESC="self-signed (browser warning)"
|
||||
fi
|
||||
CERT_RESULT="$CERT_MODE"
|
||||
|
||||
if [[ "$CERT_MODE" == "sslip" && -z "$LETSENCRYPT_EMAIL" && -t 0 ]]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Email for Let's Encrypt expiry notices (optional, press Enter to skip):${NC}"
|
||||
read -p "> " LETSENCRYPT_EMAIL
|
||||
fi
|
||||
|
||||
FORCE_TURN_RELAY="${FORCE_TURN_RELAY:-false}"
|
||||
|
||||
# Get the TURN secret (skip prompt if TURN_SECRET is already set)
|
||||
|
|
@ -185,6 +248,8 @@ fi
|
|||
echo ""
|
||||
echo -e "${GREEN}Configuration:${NC}"
|
||||
echo -e " Server IP: ${BLUE}$SERVER_IP${NC}"
|
||||
echo -e " Public host: ${BLUE}$PUBLIC_HOST_VALUE${NC}"
|
||||
echo -e " Certificate: ${BLUE}$CERT_DESC${NC}"
|
||||
echo -e " TURN Secret: ${BLUE}********${NC}"
|
||||
echo -e " Deploy mode: ${BLUE}$DEPLOY_MODE${NC}"
|
||||
echo -e " Force TURN relay: ${BLUE}$FORCE_TURN_RELAY${NC}"
|
||||
|
|
@ -240,7 +305,7 @@ openssl req -x509 -nodes -newkey rsa:2048 \\
|
|||
-keyout certs/local.key \\
|
||||
-out certs/local.crt \\
|
||||
-days 365 \\
|
||||
-subj "/CN=$SERVER_IP"
|
||||
-subj "/CN=$PUBLIC_HOST_VALUE"
|
||||
CERT_EOF
|
||||
chmod +x generate_certificate.sh
|
||||
echo -e "${GREEN}✓ generate_certificate.sh created${NC}"
|
||||
|
|
@ -260,19 +325,16 @@ cat > .env << ENV_EOF
|
|||
# Remote deployments run with production signaling and HTTPS defaults
|
||||
ENVIRONMENT=production
|
||||
|
||||
# Canonical public host/base URL for this install.
|
||||
# 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. 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=$SERVER_IP
|
||||
PUBLIC_BASE_URL=https://$SERVER_IP
|
||||
|
||||
# Backend API endpoint (public URL the backend uses to build webhook/embed links)
|
||||
BACKEND_API_ENDPOINT=https://$SERVER_IP
|
||||
|
||||
# Public URL browsers use to fetch objects from MinIO (proxied by nginx)
|
||||
MINIO_PUBLIC_ENDPOINT=https://$SERVER_IP
|
||||
PUBLIC_HOST=$PUBLIC_HOST_VALUE
|
||||
PUBLIC_BASE_URL=https://$PUBLIC_HOST_VALUE
|
||||
|
||||
# TURN Server Configuration (time-limited credentials via TURN REST API)
|
||||
TURN_HOST=$SERVER_IP
|
||||
TURN_SECRET=$TURN_SECRET
|
||||
# Relay-only ICE candidates for explicit TURN diagnostics
|
||||
FORCE_TURN_RELAY=$FORCE_TURN_RELAY
|
||||
|
|
@ -332,6 +394,46 @@ OVERRIDE_EOF
|
|||
echo -e "${GREEN}✓ docker-compose.override.yaml created${NC}"
|
||||
fi
|
||||
|
||||
if [[ "$CERT_MODE" == "sslip" ]]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Starting Dograh and requesting a trusted certificate for ${PUBLIC_HOST_VALUE}...${NC}"
|
||||
|
||||
if [[ "$DEPLOY_MODE" == "build" ]]; then
|
||||
./remote_up.sh --build
|
||||
else
|
||||
./remote_up.sh
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Waiting for nginx to answer on port 80...${NC}"
|
||||
nginx_ready=0
|
||||
for ((i=1; i<=60; i++)); do
|
||||
if curl -s -o /dev/null --max-time 3 "http://127.0.0.1/"; then
|
||||
nginx_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ "$nginx_ready" != "1" ]]; then
|
||||
CERT_RESULT="self-signed"
|
||||
dograh_warn "nginx did not become reachable on port 80 — skipping Let's Encrypt for now."
|
||||
dograh_warn "The stack is running with the bootstrap self-signed certificate."
|
||||
elif dograh_install_certbot && dograh_issue_letsencrypt_webroot "$(pwd)" "$PUBLIC_HOST_VALUE" "$LETSENCRYPT_EMAIL"; then
|
||||
docker compose --profile remote restart nginx >/dev/null 2>&1 || true
|
||||
dograh_install_cert_renewal_hook "$(pwd)" "$PUBLIC_HOST_VALUE"
|
||||
CERT_RESULT="sslip"
|
||||
dograh_success "✓ Trusted Let's Encrypt certificate installed; auto-renewal configured"
|
||||
else
|
||||
CERT_RESULT="self-signed"
|
||||
echo ""
|
||||
dograh_warn "Let's Encrypt issuance failed — the stack is running with the self-signed certificate."
|
||||
dograh_warn "Common causes and fixes:"
|
||||
dograh_warn " - Port 80 not reachable from the internet: open it in your firewall/security group"
|
||||
dograh_warn " - Rate limited on ${ACME_DOMAIN_SUFFIX}: re-run with ACME_DOMAIN_SUFFIX=nip.io"
|
||||
dograh_warn " - Then retry: sudo certbot certonly --webroot -w \"$(pwd)/certs\" -d ${PUBLIC_HOST_VALUE}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Setup Complete! ║${NC}"
|
||||
|
|
@ -350,25 +452,42 @@ echo " - certs/local.crt"
|
|||
echo " - certs/local.key"
|
||||
echo " - .env"
|
||||
echo ""
|
||||
echo -e "${YELLOW}To start Dograh, run:${NC}"
|
||||
echo ""
|
||||
if [[ "$DEPLOY_MODE" != "build" || "${REPO_SOURCE:-}" != "existing" ]]; then
|
||||
echo -e " ${BLUE}cd $(pwd)${NC}"
|
||||
fi
|
||||
if [[ "$DEPLOY_MODE" == "build" ]]; then
|
||||
echo -e " ${BLUE}./remote_up.sh --build${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}A docker-compose.override.yaml has been created alongside${NC}"
|
||||
echo -e "${YELLOW}docker-compose.yaml. Compose auto-loads it, so no -f flag is${NC}"
|
||||
echo -e "${YELLOW}needed — it swaps the prebuilt images for local builds.${NC}"
|
||||
if [[ "$CERT_MODE" == "sslip" ]]; then
|
||||
if [[ "$CERT_RESULT" == "sslip" ]]; then
|
||||
echo -e "${GREEN}Dograh is running with a trusted certificate at:${NC}"
|
||||
echo ""
|
||||
echo -e " ${BLUE}https://$PUBLIC_HOST_VALUE${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}No browser warning — the certificate renews automatically before expiry.${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Dograh is running (with a temporary self-signed certificate) at:${NC}"
|
||||
echo ""
|
||||
echo -e " ${BLUE}https://$PUBLIC_HOST_VALUE${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Let's Encrypt issuance did not complete (see the message above). Your${NC}"
|
||||
echo -e "${YELLOW}browser will warn until a trusted certificate is issued.${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " ${BLUE}./remote_up.sh${NC}"
|
||||
echo -e "${YELLOW}To start Dograh, run:${NC}"
|
||||
echo ""
|
||||
if [[ "$DEPLOY_MODE" != "build" || "${REPO_SOURCE:-}" != "existing" ]]; then
|
||||
echo -e " ${BLUE}cd $(pwd)${NC}"
|
||||
fi
|
||||
if [[ "$DEPLOY_MODE" == "build" ]]; then
|
||||
echo -e " ${BLUE}./remote_up.sh --build${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}A docker-compose.override.yaml has been created alongside${NC}"
|
||||
echo -e "${YELLOW}docker-compose.yaml. Compose auto-loads it, so no -f flag is${NC}"
|
||||
echo -e "${YELLOW}needed — it swaps the prebuilt images for local builds.${NC}"
|
||||
else
|
||||
echo -e " ${BLUE}./remote_up.sh${NC}"
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${YELLOW}Your application will be available at:${NC}"
|
||||
echo ""
|
||||
echo -e " ${BLUE}https://$PUBLIC_HOST_VALUE${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note:${NC} Your browser will show a security warning for the self-signed"
|
||||
echo "certificate. You can safely accept it to proceed."
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${YELLOW}Your application will be available at:${NC}"
|
||||
echo ""
|
||||
echo -e " ${BLUE}https://$SERVER_IP${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note:${NC} Your browser will show a security warning for the self-signed"
|
||||
echo "certificate. You can safely accept it to proceed."
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# generated by datamodel-codegen:
|
||||
# filename: dograh-openapi-XXXXXX.json.FJTeRc3I3o
|
||||
# timestamp: 2026-06-25T16:50:38+00:00
|
||||
# filename: dograh-openapi-XXXXXX.json.MU08hRFPE8
|
||||
# timestamp: 2026-06-26T11:33:34+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -2830,6 +2830,10 @@ export type HealthResponse = {
|
|||
* Backend Api Endpoint
|
||||
*/
|
||||
backend_api_endpoint: string;
|
||||
/**
|
||||
* Tunnel Url
|
||||
*/
|
||||
tunnel_url?: string | null;
|
||||
/**
|
||||
* Deployment Mode
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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's own network.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 browser→API 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
16
ui/src/lib/webhookUrl.ts
Normal 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 : "")
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue