mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
chore: refactor setup scripts
This commit is contained in:
parent
53b8ba08fd
commit
964ee652d8
21 changed files with 247 additions and 72 deletions
|
|
@ -19,7 +19,23 @@ LANGFUSE_PUBLIC_KEY = os.getenv("LANGFUSE_PUBLIC_KEY")
|
||||||
LANGFUSE_SECRET_KEY = os.getenv("LANGFUSE_SECRET_KEY")
|
LANGFUSE_SECRET_KEY = os.getenv("LANGFUSE_SECRET_KEY")
|
||||||
|
|
||||||
# URLs for deployment
|
# 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")
|
UI_APP_URL = os.getenv("UI_APP_URL", "http://localhost:3010")
|
||||||
|
|
||||||
DATABASE_URL = os.environ["DATABASE_URL"]
|
DATABASE_URL = os.environ["DATABASE_URL"]
|
||||||
|
|
@ -44,7 +60,12 @@ ENABLE_AWS_S3 = os.getenv("ENABLE_AWS_S3", "false").lower() == "true"
|
||||||
|
|
||||||
# MinIO Configuration
|
# MinIO Configuration
|
||||||
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
|
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_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
|
||||||
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
|
||||||
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "voice-audio")
|
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_ROTATION_SIZE = os.getenv("LOG_ROTATION_SIZE", "100 MB")
|
||||||
LOG_RETENTION = os.getenv("LOG_RETENTION", "7 days")
|
LOG_RETENTION = os.getenv("LOG_RETENTION", "7 days")
|
||||||
LOG_COMPRESSION = os.getenv("LOG_COMPRESSION", "gz")
|
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:
|
def _get_version() -> str:
|
||||||
|
|
@ -149,7 +170,9 @@ DEFAULT_CIRCUIT_BREAKER_CONFIG = {
|
||||||
|
|
||||||
|
|
||||||
TURN_SECRET = os.getenv("TURN_SECRET")
|
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_PORT = int(os.getenv("TURN_PORT", "3478"))
|
||||||
TURN_TLS_PORT = int(os.getenv("TURN_TLS_PORT", "5349"))
|
TURN_TLS_PORT = int(os.getenv("TURN_TLS_PORT", "5349"))
|
||||||
TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400"))
|
TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400"))
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,10 @@ class HealthResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
version: str
|
version: str
|
||||||
backend_api_endpoint: 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
|
deployment_mode: str
|
||||||
auth_provider: str
|
auth_provider: str
|
||||||
turn_enabled: bool
|
turn_enabled: bool
|
||||||
|
|
@ -84,21 +88,34 @@ async def health() -> HealthResponse:
|
||||||
from api.constants import (
|
from api.constants import (
|
||||||
APP_VERSION,
|
APP_VERSION,
|
||||||
AUTH_PROVIDER,
|
AUTH_PROVIDER,
|
||||||
|
BACKEND_API_ENDPOINT,
|
||||||
DEPLOYMENT_MODE,
|
DEPLOYMENT_MODE,
|
||||||
FORCE_TURN_RELAY,
|
FORCE_TURN_RELAY,
|
||||||
STACK_AUTH_PROJECT_ID,
|
STACK_AUTH_PROJECT_ID,
|
||||||
STACK_PUBLISHABLE_CLIENT_KEY,
|
STACK_PUBLISHABLE_CLIENT_KEY,
|
||||||
TURN_SECRET,
|
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")
|
logger.debug("Health endpoint called")
|
||||||
backend_endpoint, _ = await get_backend_endpoints()
|
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"
|
is_stack = AUTH_PROVIDER == "stack"
|
||||||
return HealthResponse(
|
return HealthResponse(
|
||||||
status="ok",
|
status="ok",
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
backend_api_endpoint=backend_endpoint,
|
backend_api_endpoint=BACKEND_API_ENDPOINT,
|
||||||
|
tunnel_url=tunnel_url,
|
||||||
deployment_mode=DEPLOYMENT_MODE,
|
deployment_mode=DEPLOYMENT_MODE,
|
||||||
auth_provider=AUTH_PROVIDER,
|
auth_provider=AUTH_PROVIDER,
|
||||||
turn_enabled=bool(TURN_SECRET),
|
turn_enabled=bool(TURN_SECRET),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ Common utilities.
|
||||||
Shared functions used across the application.
|
Shared functions used across the application.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
@ -22,6 +23,43 @@ def get_scheme(url: str) -> str | None:
|
||||||
return url[:idx]
|
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:
|
def _validate_url(url: str) -> None:
|
||||||
"""
|
"""
|
||||||
Validate URL format and raise ValueError for invalid URLs.
|
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)
|
_validate_url(BACKEND_API_ENDPOINT)
|
||||||
|
|
||||||
if BACKEND_API_ENDPOINT:
|
if BACKEND_API_ENDPOINT:
|
||||||
# Handle localhost/127.0.0.1 special case - use tunnel URL if available
|
# Non-public address (localhost or a private/reserved IP) - the host isn't
|
||||||
if "localhost" in BACKEND_API_ENDPOINT or "127.0.0.1" in BACKEND_API_ENDPOINT:
|
# reachable from the internet, so prefer a running Cloudflare tunnel's URL.
|
||||||
|
if is_local_or_private_url(BACKEND_API_ENDPOINT):
|
||||||
logger.debug(
|
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:
|
try:
|
||||||
tunnel_urls = await TunnelURLProvider.get_tunnel_urls()
|
tunnel_urls = await TunnelURLProvider.get_tunnel_urls()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
|
|
@ -135,9 +149,18 @@ services:
|
||||||
ENVIRONMENT: "${ENVIRONMENT:-local}"
|
ENVIRONMENT: "${ENVIRONMENT:-local}"
|
||||||
LOG_LEVEL: "INFO"
|
LOG_LEVEL: "INFO"
|
||||||
|
|
||||||
# Replace this environment variable if you are using a custom
|
# Public origin for this deployment. The API derives BACKEND_API_ENDPOINT,
|
||||||
# domain to host the stack
|
# MINIO_PUBLIC_ENDPOINT and TURN_HOST from PUBLIC_BASE_URL / PUBLIC_HOST when
|
||||||
BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}"
|
# 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 configuration (using containerized postgres)
|
||||||
DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres"
|
DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres"
|
||||||
|
|
@ -169,10 +192,10 @@ services:
|
||||||
|
|
||||||
# MinIO
|
# MinIO
|
||||||
MINIO_ENDPOINT: "minio:9000"
|
MINIO_ENDPOINT: "minio:9000"
|
||||||
# Full URL (with scheme) browsers use to reach MinIO. For remote
|
# Full URL (with scheme) browsers use to reach MinIO. Defaults to
|
||||||
# deployments behind HTTPS, set MINIO_PUBLIC_ENDPOINT in .env to
|
# PUBLIC_BASE_URL for remote deployments (nginx proxies /voice-audio/) and to
|
||||||
# e.g. https://your-server.example.com (nginx proxies /voice-audio/).
|
# http://localhost:9000 for local; override only for a separate object store.
|
||||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
|
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"
|
||||||
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-minioadmin}"
|
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-minioadmin}"
|
||||||
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-minioadmin}"
|
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-minioadmin}"
|
||||||
MINIO_BUCKET: "voice-audio"
|
MINIO_BUCKET: "voice-audio"
|
||||||
|
|
@ -223,8 +246,6 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
cloudflared:
|
|
||||||
condition: service_started
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
|
|
@ -272,12 +293,35 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- 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:
|
cloudflared:
|
||||||
image: cloudflare/cloudflared:latest
|
image: cloudflare/cloudflared:latest
|
||||||
container_name: cloudflared-tunnel
|
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:
|
ports:
|
||||||
- "2000:2000" # Expose metrics endpoint
|
- "2000:2000" # metrics endpoint (quick-tunnel URL discovery)
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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
|
```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`.
|
The application is still available at `http://localhost:3010`.
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@ Set these when `AUTH_PROVIDER=stack` to delegate sign-in to [Stack Auth](https:/
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| 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 |
|
| `UI_APP_URL` | `http://localhost:3010` | URL of the frontend application |
|
||||||
| `MPS_API_URL` | `https://services.dograh.com` | Dograh Managed Platform Services URL |
|
| `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 |
|
| `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 |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `MINIO_ENDPOINT` | `localhost:9000` | MinIO server host and port |
|
| `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_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_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 |
|
| `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 |
|
| 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_PORT` | `3478` | TURN server port |
|
||||||
| `TURN_TLS_PORT` | `5349` | TURN server TLS port |
|
| `TURN_TLS_PORT` | `5349` | TURN server TLS port |
|
||||||
| `TURN_SECRET` | `null` | **Required for WebRTC.** Shared secret for TURN credential generation |
|
| `TURN_SECRET` | `null` | **Required for WebRTC.** Shared secret for TURN credential generation |
|
||||||
|
|
|
||||||
14
remote_up.sh
14
remote_up.sh
|
|
@ -65,10 +65,20 @@ else
|
||||||
COMPOSE_CMD=(sudo docker compose)
|
COMPOSE_CMD=(sudo docker compose)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# When SERVER_IP (sourced from .env above) is a private/reserved address the host
|
||||||
|
# has no public IP, so start the cloudflared service (tunnel profile) to make
|
||||||
|
# webhooks reachable. The backend resolves the tunnel's public URL at runtime using
|
||||||
|
# the same private-IP classification (api/utils/common.py:is_local_or_private_url),
|
||||||
|
# so the two stay in sync. A public-IP install runs nginx only.
|
||||||
|
PROFILE_ARGS=(--profile remote)
|
||||||
|
if dograh_is_local_ipv4 "${SERVER_IP:-}"; then
|
||||||
|
PROFILE_ARGS+=(--profile tunnel)
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$MODE" == "build" ]]; then
|
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
|
else
|
||||||
CMD=("${COMPOSE_CMD[@]}" --profile remote up -d --pull always --force-recreate)
|
CMD=("${COMPOSE_CMD[@]}" "${PROFILE_ARGS[@]}" up -d --pull always --force-recreate)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bash 3.2 on macOS treats "${empty_array[@]}" as unbound under `set -u`.
|
# Bash 3.2 on macOS treats "${empty_array[@]}" as unbound under `set -u`.
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,12 @@ This directory now has a shared deployment model for OSS Docker installs. If you
|
||||||
- `scripts/lib/setup_common.sh` is the shared deployment helper library. It is sourced by `setup_local.sh`, `setup_remote.sh`, `update_remote.sh`, `setup_custom_domain.sh`, `run_dograh_init.sh`, and repo-root `remote_up.sh`.
|
- `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.
|
- `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.
|
- `.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.
|
- `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`.
|
- `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`.
|
- `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.
|
- 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`.
|
||||||
|
|
|
||||||
|
|
@ -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" SERVER_IP "$server_ip"
|
||||||
dograh_set_env_key "$env_file" PUBLIC_HOST "$public_host"
|
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" 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"
|
# BACKEND_API_ENDPOINT / MINIO_PUBLIC_ENDPOINT / TURN_HOST are derived in-app
|
||||||
dograh_set_env_key "$env_file" TURN_HOST "$public_host"
|
# 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() {
|
dograh_validate_remote_runtime_env() {
|
||||||
|
|
@ -262,14 +264,12 @@ dograh_validate_remote_runtime_env() {
|
||||||
[[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET is missing"
|
[[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET is missing"
|
||||||
[[ -n "${PUBLIC_HOST:-}" ]] || dograh_fail "PUBLIC_HOST is missing"
|
[[ -n "${PUBLIC_HOST:-}" ]] || dograh_fail "PUBLIC_HOST is missing"
|
||||||
[[ -n "${PUBLIC_BASE_URL:-}" ]] || dograh_fail "PUBLIC_BASE_URL 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"
|
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://"
|
[[ "${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"
|
# BACKEND_API_ENDPOINT / MINIO_PUBLIC_ENDPOINT / TURN_HOST are derived in-app
|
||||||
[[ "${MINIO_PUBLIC_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT must match PUBLIC_BASE_URL"
|
# from PUBLIC_BASE_URL / PUBLIC_HOST (see api/constants.py), so they are not
|
||||||
[[ "${TURN_HOST}" == "${PUBLIC_HOST}" ]] || dograh_fail "TURN_HOST must match PUBLIC_HOST"
|
# required here. When an operator sets them explicitly (split deployment),
|
||||||
|
# their value is honored as-is — no equality check.
|
||||||
}
|
}
|
||||||
|
|
||||||
dograh_uses_init_compose_layout() {
|
dograh_uses_init_compose_layout() {
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,13 @@ dograh_set_env_key .env SERVER_IP "$SERVER_IP"
|
||||||
dograh_set_env_key .env PUBLIC_HOST "$DOMAIN_NAME"
|
dograh_set_env_key .env PUBLIC_HOST "$DOMAIN_NAME"
|
||||||
dograh_set_env_key .env PUBLIC_BASE_URL "https://$DOMAIN_NAME"
|
dograh_set_env_key .env PUBLIC_BASE_URL "https://$DOMAIN_NAME"
|
||||||
dograh_delete_env_key .env BACKEND_URL
|
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)"
|
dograh_prepare_remote_install "$(pwd)"
|
||||||
|
|
||||||
# Bring the stack up (recreating it) so dograh-init re-renders nginx with the
|
# Bring the stack up (recreating it) so dograh-init re-renders nginx with the
|
||||||
|
|
|
||||||
|
|
@ -307,13 +307,16 @@ Write-Host ''
|
||||||
if ($UseCoturn) {
|
if ($UseCoturn) {
|
||||||
Write-Warn 'To start Dograh with TURN, run:'
|
Write-Warn 'To start Dograh with TURN, run:'
|
||||||
Write-Host ''
|
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 {
|
} else {
|
||||||
Write-Warn 'To start Dograh, run:'
|
Write-Warn 'To start Dograh, run:'
|
||||||
Write-Host ''
|
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 ''
|
||||||
|
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-Warn 'Your application will be available at:'
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host ' http://localhost:3010' -ForegroundColor Blue
|
Write-Host ' http://localhost:3010' -ForegroundColor Blue
|
||||||
|
|
|
||||||
|
|
@ -211,13 +211,16 @@ echo ""
|
||||||
if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
|
if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
|
||||||
echo -e "${YELLOW}To start Dograh with TURN, run:${NC}"
|
echo -e "${YELLOW}To start Dograh with TURN, run:${NC}"
|
||||||
echo ""
|
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
|
else
|
||||||
echo -e "${YELLOW}To start Dograh, run:${NC}"
|
echo -e "${YELLOW}To start Dograh, run:${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${BLUE}docker compose up --pull always${NC}"
|
echo -e " ${BLUE}docker compose --profile tunnel up --pull always${NC}"
|
||||||
fi
|
fi
|
||||||
echo ""
|
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 -e "${YELLOW}Your application will be available at:${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${BLUE}http://localhost:3010${NC}"
|
echo -e " ${BLUE}http://localhost:3010${NC}"
|
||||||
|
|
|
||||||
|
|
@ -324,19 +324,14 @@ ENVIRONMENT=production
|
||||||
|
|
||||||
# Canonical public host/base URL for this install. SERVER_IP stays the raw IP
|
# 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
|
# (coturn external-ip and validation need it); PUBLIC_HOST is the sslip.io
|
||||||
# hostname when using a trusted cert, otherwise the IP.
|
# hostname when using a trusted cert, otherwise the IP. BACKEND_API_ENDPOINT,
|
||||||
|
# MINIO_PUBLIC_ENDPOINT and TURN_HOST are derived from these by the API
|
||||||
|
# (see api/constants.py) — set them here only to override for a split deployment.
|
||||||
SERVER_IP=$SERVER_IP
|
SERVER_IP=$SERVER_IP
|
||||||
PUBLIC_HOST=$PUBLIC_HOST_VALUE
|
PUBLIC_HOST=$PUBLIC_HOST_VALUE
|
||||||
PUBLIC_BASE_URL=https://$PUBLIC_HOST_VALUE
|
PUBLIC_BASE_URL=https://$PUBLIC_HOST_VALUE
|
||||||
|
|
||||||
# Backend API endpoint (public URL the backend uses to build webhook/embed links)
|
|
||||||
BACKEND_API_ENDPOINT=https://$PUBLIC_HOST_VALUE
|
|
||||||
|
|
||||||
# Public URL browsers use to fetch objects from MinIO (proxied by nginx)
|
|
||||||
MINIO_PUBLIC_ENDPOINT=https://$PUBLIC_HOST_VALUE
|
|
||||||
|
|
||||||
# TURN Server Configuration (time-limited credentials via TURN REST API)
|
# TURN Server Configuration (time-limited credentials via TURN REST API)
|
||||||
TURN_HOST=$PUBLIC_HOST_VALUE
|
|
||||||
TURN_SECRET=$TURN_SECRET
|
TURN_SECRET=$TURN_SECRET
|
||||||
# Relay-only ICE candidates for explicit TURN diagnostics
|
# Relay-only ICE candidates for explicit TURN diagnostics
|
||||||
FORCE_TURN_RELAY=$FORCE_TURN_RELAY
|
FORCE_TURN_RELAY=$FORCE_TURN_RELAY
|
||||||
|
|
|
||||||
|
|
@ -207,10 +207,9 @@ if ([string]::IsNullOrEmpty($existingMinioRootPassword)) {
|
||||||
|
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host "Docker registry: $Registry"
|
Write-Host "Docker registry: $Registry"
|
||||||
Write-Host "Telemetry enabled: $EnableTelemetry"
|
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host 'This will run:'
|
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 ''
|
Write-Host ''
|
||||||
|
|
||||||
$answer = Read-Host 'Start Dograh now? [Y/n]'
|
$answer = Read-Host 'Start Dograh now? [Y/n]'
|
||||||
|
|
@ -222,7 +221,7 @@ if ($answer -match '^[Nn]') {
|
||||||
$env:REGISTRY = $Registry
|
$env:REGISTRY = $Registry
|
||||||
$env:ENABLE_TELEMETRY = $EnableTelemetry
|
$env:ENABLE_TELEMETRY = $EnableTelemetry
|
||||||
Sync-PostgresPassword -Password (Get-DotEnvValue -Path $EnvFile -Key 'POSTGRES_PASSWORD')
|
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) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,10 +200,9 @@ fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Docker registry: $REGISTRY"
|
echo "Docker registry: $REGISTRY"
|
||||||
echo "Telemetry enabled: $ENABLE_TELEMETRY"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "This will run:"
|
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 ""
|
echo ""
|
||||||
|
|
||||||
if [[ ! -t 0 ]]; then
|
if [[ ! -t 0 ]]; then
|
||||||
|
|
@ -222,4 +221,4 @@ esac
|
||||||
postgres_password="$(dotenv_value POSTGRES_PASSWORD || true)"
|
postgres_password="$(dotenv_value POSTGRES_PASSWORD || true)"
|
||||||
sync_postgres_password "$postgres_password"
|
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
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export async function GET() {
|
||||||
let authProvider = "local";
|
let authProvider = "local";
|
||||||
let turnEnabled = false;
|
let turnEnabled = false;
|
||||||
let forceTurnRelay = false;
|
let forceTurnRelay = false;
|
||||||
|
let tunnelUrl: string | null = null;
|
||||||
let backendStatus: "reachable" | "unreachable" = "unreachable";
|
let backendStatus: "reachable" | "unreachable" = "unreachable";
|
||||||
let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`;
|
let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`;
|
||||||
|
|
||||||
|
|
@ -53,6 +54,7 @@ export async function GET() {
|
||||||
authProvider = data.auth_provider;
|
authProvider = data.auth_provider;
|
||||||
turnEnabled = Boolean(data.turn_enabled);
|
turnEnabled = Boolean(data.turn_enabled);
|
||||||
forceTurnRelay = Boolean(data.force_turn_relay);
|
forceTurnRelay = Boolean(data.force_turn_relay);
|
||||||
|
tunnelUrl = data.tunnel_url ?? null;
|
||||||
backendStatus = "reachable";
|
backendStatus = "reachable";
|
||||||
backendMessage = null;
|
backendMessage = null;
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +70,7 @@ export async function GET() {
|
||||||
authProvider,
|
authProvider,
|
||||||
turnEnabled,
|
turnEnabled,
|
||||||
forceTurnRelay,
|
forceTurnRelay,
|
||||||
|
tunnelUrl,
|
||||||
backend: {
|
backend: {
|
||||||
status: backendStatus,
|
status: backendStatus,
|
||||||
url: backendUrl,
|
url: backendUrl,
|
||||||
|
|
|
||||||
|
|
@ -55,24 +55,21 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { useAppConfig } from "@/context/AppConfigContext";
|
||||||
import { detailFromError } from "@/lib/apiError";
|
import { detailFromError } from "@/lib/apiError";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { resolveWebhookBaseUrl } from "@/lib/webhookUrl";
|
||||||
|
|
||||||
const INBOUND_WEBHOOK_PATH = "/api/v1/telephony/inbound/run";
|
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() {
|
export default function TelephonyConfigurationDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ configId: string }>();
|
const params = useParams<{ configId: string }>();
|
||||||
const configId = Number(params.configId);
|
const configId = Number(params.configId);
|
||||||
|
|
||||||
const { user, getAccessToken, loading: authLoading } = useAuth();
|
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 [config, setConfig] = useState<TelephonyConfigurationDetail | null>(null);
|
||||||
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
|
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -265,7 +262,7 @@ export default function TelephonyConfigurationDetailPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = getInboundWebhookUrl();
|
const url = inboundWebhookUrl;
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(url)
|
.writeText(url)
|
||||||
.then(() => toast.success("Inbound webhook URL copied"))
|
.then(() => toast.success("Inbound webhook URL copied"))
|
||||||
|
|
@ -275,7 +272,7 @@ export default function TelephonyConfigurationDetailPage() {
|
||||||
aria-label="Copy inbound webhook URL"
|
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"
|
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" />
|
<Copy className="h-3 w-3 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2830,6 +2830,10 @@ export type HealthResponse = {
|
||||||
* Backend Api Endpoint
|
* Backend Api Endpoint
|
||||||
*/
|
*/
|
||||||
backend_api_endpoint: string;
|
backend_api_endpoint: string;
|
||||||
|
/**
|
||||||
|
* Tunnel Url
|
||||||
|
*/
|
||||||
|
tunnel_url?: string | null;
|
||||||
/**
|
/**
|
||||||
* Deployment Mode
|
* Deployment Mode
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ import { FlowNodeData } from "@/components/flow/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
|
import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||||
|
import { useAppConfig } from "@/context/AppConfigContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { resolveWebhookBaseUrl } from "@/lib/webhookUrl";
|
||||||
|
|
||||||
import { NodeContent } from "./common/NodeContent";
|
import { NodeContent } from "./common/NodeContent";
|
||||||
import { NodeEditDialog } from "./common/NodeEditDialog";
|
import { NodeEditDialog } from "./common/NodeEditDialog";
|
||||||
|
|
@ -90,14 +92,12 @@ interface TriggerEndpoints {
|
||||||
|
|
||||||
function buildTriggerEndpoints(
|
function buildTriggerEndpoints(
|
||||||
triggerPath: string | undefined,
|
triggerPath: string | undefined,
|
||||||
|
baseUrl: string,
|
||||||
): TriggerEndpoints {
|
): TriggerEndpoints {
|
||||||
if (!triggerPath) return { production: "", test: "" };
|
if (!triggerPath) return { production: "", test: "" };
|
||||||
const backendUrl =
|
|
||||||
process.env.NEXT_PUBLIC_BACKEND_URL ||
|
|
||||||
(typeof window !== "undefined" ? window.location.origin : "");
|
|
||||||
return {
|
return {
|
||||||
production: `${backendUrl}/api/v1/public/agent/${triggerPath}`,
|
production: `${baseUrl}/api/v1/public/agent/${triggerPath}`,
|
||||||
test: `${backendUrl}/api/v1/public/agent/test/${triggerPath}`,
|
test: `${baseUrl}/api/v1/public/agent/test/${triggerPath}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,8 +182,12 @@ function CanvasPreview({
|
||||||
onStaleTools: (uuids: string[]) => void;
|
onStaleTools: (uuids: string[]) => void;
|
||||||
onStaleDocuments: (uuids: string[]) => void;
|
onStaleDocuments: (uuids: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { config: appConfig } = useAppConfig();
|
||||||
if (spec.name === "trigger") {
|
if (spec.name === "trigger") {
|
||||||
const endpoint = buildTriggerEndpoints(data.trigger_path).production;
|
const endpoint = buildTriggerEndpoints(
|
||||||
|
data.trigger_path,
|
||||||
|
resolveWebhookBaseUrl(appConfig?.tunnelUrl),
|
||||||
|
).production;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">API Endpoint:</p>
|
<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 { saveWorkflow, tools, documents, recordings } = useWorkflow();
|
||||||
const { bySpecName } = useNodeSpecs();
|
const { bySpecName } = useNodeSpecs();
|
||||||
|
const { config: appConfig } = useAppConfig();
|
||||||
const spec = bySpecName.get(type);
|
const spec = bySpecName.get(type);
|
||||||
|
const webhookBaseUrl = resolveWebhookBaseUrl(appConfig?.tunnelUrl);
|
||||||
|
|
||||||
// ── Form state ─────────────────────────────────────────────────────
|
// ── Form state ─────────────────────────────────────────────────────
|
||||||
// mcp_tool_filters is not a spec property, so seedValues won't carry it;
|
// 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 ──────────────────────────
|
// ── Trigger auto-UUID + canvas copy state ──────────────────────────
|
||||||
const [triggerCopied, setTriggerCopied] = useState(false);
|
const [triggerCopied, setTriggerCopied] = useState(false);
|
||||||
const handleCopyTrigger = useCallback(async () => {
|
const handleCopyTrigger = useCallback(async () => {
|
||||||
const endpoint = buildTriggerEndpoints(data.trigger_path).production;
|
const endpoint = buildTriggerEndpoints(data.trigger_path, webhookBaseUrl).production;
|
||||||
if (!endpoint) return;
|
if (!endpoint) return;
|
||||||
await navigator.clipboard.writeText(endpoint);
|
await navigator.clipboard.writeText(endpoint);
|
||||||
setTriggerCopied(true);
|
setTriggerCopied(true);
|
||||||
setTimeout(() => setTriggerCopied(false), 2000);
|
setTimeout(() => setTriggerCopied(false), 2000);
|
||||||
}, [data.trigger_path]);
|
}, [data.trigger_path, webhookBaseUrl]);
|
||||||
|
|
||||||
// For trigger nodes without a path yet, generate one and persist.
|
// For trigger nodes without a path yet, generate one and persist.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -684,7 +690,7 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
|
||||||
/>
|
/>
|
||||||
{type === "trigger" && (
|
{type === "trigger" && (
|
||||||
<TriggerWebhookUrls
|
<TriggerWebhookUrls
|
||||||
endpoints={buildTriggerEndpoints(data.trigger_path)}
|
endpoints={buildTriggerEndpoints(data.trigger_path, webhookBaseUrl)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ interface AppConfig {
|
||||||
authProvider: string;
|
authProvider: string;
|
||||||
turnEnabled: boolean;
|
turnEnabled: boolean;
|
||||||
forceTurnRelay: boolean;
|
forceTurnRelay: boolean;
|
||||||
|
// Public URL when the deployment is reached through a Cloudflare tunnel
|
||||||
|
// (host has no public IP); null for a directly-reachable deployment.
|
||||||
|
tunnelUrl: string | null;
|
||||||
backendStatus: BackendStatus;
|
backendStatus: BackendStatus;
|
||||||
backendUrl: string;
|
backendUrl: string;
|
||||||
backendMessage: string | null;
|
backendMessage: string | null;
|
||||||
|
|
@ -29,6 +32,7 @@ const defaultConfig: AppConfig = {
|
||||||
authProvider: 'local',
|
authProvider: 'local',
|
||||||
turnEnabled: false,
|
turnEnabled: false,
|
||||||
forceTurnRelay: false,
|
forceTurnRelay: false,
|
||||||
|
tunnelUrl: null,
|
||||||
backendStatus: 'unreachable',
|
backendStatus: 'unreachable',
|
||||||
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown',
|
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown',
|
||||||
backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL
|
backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL
|
||||||
|
|
@ -64,6 +68,7 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
|
||||||
authProvider: data.authProvider || 'local',
|
authProvider: data.authProvider || 'local',
|
||||||
turnEnabled: Boolean(data.turnEnabled),
|
turnEnabled: Boolean(data.turnEnabled),
|
||||||
forceTurnRelay: Boolean(data.forceTurnRelay),
|
forceTurnRelay: Boolean(data.forceTurnRelay),
|
||||||
|
tunnelUrl: typeof data.tunnelUrl === 'string' ? data.tunnelUrl : null,
|
||||||
backendStatus,
|
backendStatus,
|
||||||
backendUrl,
|
backendUrl,
|
||||||
backendMessage: typeof backend.message === 'string' && backend.message.length > 0
|
backendMessage: typeof backend.message === 'string' && backend.message.length > 0
|
||||||
|
|
|
||||||
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