Skip to content
innotelincPublic

About

Self-hosted cloud-native VoIP platform: number provisioning, WebRTC softphone, SMS, digital fax, AI voicemail summaries, and billing

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Latest commit

 

History

415 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CI Conformity License: AGPL-3.0-or-later

⚡ Zeus — VOIP Platform

Cloud-native telecommunications — VoIP services, SIP routing, self-service portals, instant number provisioning, messaging, fax, and billing.

Zeus delivers business communications on your own infrastructure: phone numbers ordered and provisioned through VoIP.ms in seconds, a web + PWA softphone over WebRTC, SMS and unified messaging, AvantFax digital faxing, AI voicemail summaries, Magnate (RevenueOps) subscription billing, and reseller white-label — fronted by a Next.js 16 portal with Cerulean Authentik SSO and Nginx Proxy Manager HTTPS.

Docker publish Release Latest release Theme: Unity


Why Zeus

Problem Zeus answer
Cloud voice APIs leak audio + cost per minute Local speech + local LLM over Asterisk/FreePBX; no audio leaves the box
Identity per-service password stores Cerulean Authentik-first SSO; disable a user and their telephony access dies
Telephony vendor lock-in Runs on your own FreePBX + VoIP.ms + local LLM; no cloud voice dependency
Billing doesn't flow to access control Magnate (RevenueOps) subscriptions gate paid seats; cancel and access dies
Telephony recovery after host loss is manual Zeus captures the full PBX + portal + fax + voicemail payload per release

About Zeus — the cloud-native VOIP platform for VoIP services, SIP routing, customer self-service portals, instant phone-number provisioning, messaging, fax, billing, and business communications — built on FreePBX/Asterisk, VoIP.ms, and AvantFax, with a Next.js 16 customer portal, Cerulean Authentik SSO, and Nginx Proxy Manager. Landing page: innotelinc.github.io/zeus


Canonical subdomains

Every service has a fixed hostname under your base domain (default zeus.innotel.us). An NPM sync script (scripts/npm-proxy-hosts.py) keeps these proxy hosts provisioned through the NPM REST API — no UI clicks:

Subdomain Service Upstream
zeus.innotel.us Zeus Customer Portal (apex) :3000
subscribe.zeus.innotel.us Zeus subscription page (plans & sign-up) :3000
app.zeus.innotel.us Zeus Customer Portal / PWA :3000
api.zeus.innotel.us Zeus Portal API :3000
portal.zeus.innotel.us Zeus Customer Portal (alias) :3000
auth.cerulean.innotel.us Cerulean Authentik (SSO / user management) (shared)
pbx.zeus.innotel.us FreePBX :80
admin.zeus.innotel.us Nginx Proxy Manager admin UI :81
ws.zeus.innotel.us WebRTC WSS signaling (softphone) :8089 (WSS)

Wildcard SSL: with NPM_WILDCARD_CERT=1 and the TSIG key in your env (default provider rfc2136 — dynamic DNS updates signed with a TSIG key, matching the capstone convention), the sync script provisions the NPM DNS credential and issues one Let's Encrypt certificate covering *.zeus.innotel.us + the apex via DNS-01, auto-attached to every proxy host — zero manual NPM clicks. Without the TSIG key the sync falls back to per-host HTTP-01 certs.


Subscriptions

Zeus sells two things, and they are billed in two places on purpose:

Plan Price Where it is bought
Phone — one plan for everyone $19.99/mo this portal's /signup (it provisions the number, extension and softphone)
AI voice agents (Capstone add-on) $49/mo, $490/yr Magnate's checkout, at app.magnate.innotel.us/signup?plan=agents

There is one phone plan — no Consumer/Business split. business still exists in the plans table for the subscribers already on it; it is no longer offered to anyone new and no longer appears on any page.

Two entry points, one page. The subscription page is served from this same deployment twice:

  • subscribe.zeus.innotel.us/ — the page a buyer lands on (its own hostname, so "where do I buy this?" has a stable answer that is not the portal's own origin),
  • zeus.innotel.us/subscribe (and /subscribe on any other host) — the same page.

Because it is one deployment there is no second build to keep in sync. The page reads its plans from src/lib/catalog.ts, and the agents card hands off to Magnate because Magnate owns billing — Zeus never holds Stripe keys and never processes a payment. The ecosystem-wide plan list lives at subscribe.innotel.us; MAGNATE_SUBSCRIBE_URL points at it.

Zeus is not an identity provider, a secrets store or a billing platform. Identity is Cerulean (Authentik), secrets are Cerulean Vault, storage is Onyx and revenue is Magnate.


Features

🔐 Cerulean Authentik SSO & User Management

  • Cerulean Authentik is the identity provider: all sign-in, signup, and password management happens in Cerulean Authentik (auth.zeus.innotel.us). The portal uses the OIDC authorization-code flow with PKCE.
  • Three authentication modes via AUTH_MODE: authentik (SSO only), freepbx (local password auth), or both (hybrid — see Authentication modes).
  • New users are auto-provisioned in the portal DB on first sign-in and linked by Authentik's stable subject ID.
  • Admins are flagged from AUTHENTIK_ADMIN_EMAILS; portal passwords are never stored for SSO accounts.
  • Single sign-out: signing out of the portal also ends the Authentik session for SSO accounts.

📱 Phone Management

  • DID search and ordering via the VoIP.ms REST API (instant number search and provisioning)
  • FreePBX PJSIP extension provisioning (OAuth2 + GraphQL)
  • Plan-based number limits

💬 SMS & Unified Communications

  • Full web messaging UI: conversation list, chat bubbles, real-time compose
  • Contact name auto-population from the contacts directory
  • SMS send over the FreePBX PJSIP trunk (AMI MessageSend, same path as the sms-out dialplan — no VoIP.ms REST per-message fee) + inbound webhook (/api/webhooks/voipms)
  • Optional OpenSMS transport: a local SMS gateway (POST /sms/) — the Android app, or this repo's own Linux gateway (smsgateway/, a USB modem or AT-capable phone: scripts/install-smsgateway.sh --apt) — selectable with SMS_PROVIDER=opensms, or auto to fall back to the trunk when the gateway is offline, and pickable per message in the Messages composer (Provider / Local) — outbound only, see docs/ops-sms-trunk.md and smsgateway/README.md
  • Voicemail-to-email, fax-to-email notifications

📠 Fax

  • AvantFax user provisioning and HylaFAX+ integration
  • Send faxes digitally from the portal; fax history with status tracking

🎙️ Progressive Web App Softphone

  • SIP.js dial pad in the dashboard, connectable via WSS through ws.zeus.innotel.us
  • Installable PWA (manifest.webmanifest + service worker): works offline for cached assets, standalone display
  • In-call controls: mute, hold, hangup, DTMF, live call timer, incoming ring
  • STUN/TURN support for NAT traversal

📡 Asterisk AMI Integration

  • Real-time call events (TCP :5038), automatic CDR collection → call_history
  • Live device state per extension, AMI status indicator with auto-reconnect

📞 Voicemail & Call History

  • Voicemail inbox with caller ID, duration, and transcriptions
  • AI summaries: one click generates a concise summary of the transcript via a local LLM (Ollama)
  • Live CDR table populated by AMI events

👥 Contacts

  • Full CRUD; auto-syncs conversation names; deep-links to messages

💰 Billing

  • Magnate (RevenueOps) is the billing platform: checkout, plans, and invoices run through the shared Magnate storefront (MAGNATE_PUBLIC_URL)
  • The bundled STRIPE_* self-billing path is a deprecated legacy mode — only active when Stripe keys are set, empty by default

🏷️ Reseller & White-Label

  • White-label branding via NEXT_PUBLIC_BRAND_NAME (UI + emails)
  • Resellers (Admin → Resellers): each reseller gets a name, brand, and domain. Users who sign in through a reseller domain see that reseller's brand automatically.

Which deployment do I need?

You want to… Use
Run just the portal (existing FreePBX/Asterisk) Docker (portal only) or npm
Run the entire stack (Asterisk + FreePBX + Portal) Docker (full stack) or setup.sh + setup-portal.sh
Add Cerulean Authentik SSO to either docker-compose.platform.yml
Wire up HTTPS subdomains scripts/npm-proxy-hosts.py
Develop / contribute npm dev

Quick Start

npm install
cp .env.example .env   # fill in your credentials
npm run seed           # seeds the fax service lines (no demo data)
npm run dev            # runs on http://localhost:3000

Without Authentik configured, the portal falls back to the local password login. No account is seeded, so register one at /signup first. Once AUTHENTIK_ISSUER_URL/AUTHENTIK_CLIENT_ID/AUTHENTIK_CLIENT_SECRET are set, password login is disabled and every sign-in goes through Authentik.

What a fresh database contains

npm run seed seeds the estate's own infrastructure and nothing else: the four fax service lines (3291–3294, the HylaFAX modems, whose Asterisk peers are iaxmodem1–iaxmodem4), owned by the tenant account that owns them in production. It writes the account with password_hash = '!oidc', the marker for "managed by Authentik", so the first SSO login binds to it by email.

There is deliberately no demo dataset — no demo user, number, contact, SMS, fax, voicemail or call history — so register an account or configure Authentik to sign in.


Deployment Options

Option 1: Docker — Portal Only

git clone https://innotelinc.github.io/zeus.git
cd zeus
cp .env.docker.example .env   # edit with your server addresses
docker compose up -d          # portal at http://localhost:3000

What you still need running externally: FreePBX 17 (API module), Asterisk AMI (:5038), PJSIP WebSocket (:8089 WSS), AvantFax (optional), and (for SSO) the Authentik platform compose.

Files: Dockerfile, docker-compose.yml, .env.docker.example

docker pull ghcr.io/innotelinc/zeus:latest

Option 2: Docker — Full Stack

Provisions everything: Asterisk 22.11 + FreePBX 17 + AvantFax + Zeus Portal, with the pre-built full-stack image pulled from GHCR.

cp .env.docker.example .env   # edit with your credentials
# one-time on a fresh host — the voice-plane volumes are shared with the
# Capstone add-on, so compose declares them external and never creates them
for v in pbx-asterisk-config pbx-asterisk-sounds pbx-asterisk-spool \
         pbx-asterisk-logs pbx-freepbx-www pbx-mariadb-data; do
  docker volume inspect "$v" >/dev/null 2>&1 || docker volume create "$v"
done
docker compose -f docker-compose.full.yml up -d

To build the full-stack image locally instead (45-90 min, one-time), fetch the pinned build inputs first, then build:

bash scripts/fetch-vendor.sh        # FreePBX framework + api/ucp, hash-checked
bash scripts/fetch-vendor.sh --check  # verify vendor/ without the network
docker compose -f docker-compose.full.yml -f docker-compose.full.build.yml up -d

The six pbx-* volumes are the shared voice plane: the identical names the Capstone agent add-on declares, so whichever stack runs the PBX mounts the same extensions, routes, CDRs, voicemail and Asterisk config — and switching which one owns the PBX is a container swap, not a data migration. On a box that already ran the PBX, HOSTNAME in .env must stay the value that box was provisioned with (it becomes the PBX's self-signed cert CN); on a fresh box the pbx.zeus.innotel.us default is right.

vendor/ (~40 MB, gitignored) is the only thing Dockerfile.full installs FreePBX from — the image build itself needs no network, so a mirror outage (mirror.freepbx.org 503s, and serves an empty-bodied 404 for the module index) cannot take the build down. Bump a version by editing the pins in scripts/fetch-vendor.sh and refetching with --force.

Files: Dockerfile.full, docker-compose.full.yml, docker-entrypoint-full.sh

Service Image Ports
MariaDB inside full-stack image 3306 (internal)
Asterisk + FreePBX ghcr.io/innotelinc/zeus:latest-fullstack 8083 (GUI, loopback only), 5060/udp, 8088, 8089, 5038, 10000, 10101-10120/udp (RTP — FREEPBX_RTP_PORT_START/END)
coturn (TURN/STUN) coturn/coturn:latest 3478/tcp+udp, 49152-49251/udp (relay — TURN_RELAY_PORT_START/END)
AvantFax inside full-stack image (/fax) over pbx-net as http://zeus-freepbx/fax; pbx.<domain> reaches it through the pbx-sso gateway
Zeus Portal built from Dockerfile 3000

The RTP block is parameterized (.env: FREEPBX_RTP_PORT_START / FREEPBX_RTP_PORT_END, default 10101-10120) and is the same plane the Capstone agent add-on rides — compose publishes it, docker-entrypoint-full.sh caps rtp_custom.conf with it, and the same range is written into FreePBX's kvstore_Sipsettings so an Apply Config cannot revert Asterisk to its default 10000-20000 (unpublished → one-way audio). See pbx/README.md → RTP media plane.

TURN is part of that same plane and belongs to Zeus too: the coturn service is the one relay both products use (the Capstone add-on's own coturn carries the standalone profile and stays down while Zeus is primary), and docker-entrypoint-full.sh keeps Asterisk's STUN address and the browsers' WebRTC relay rows in kvstore_Sipsettings in step with it. The relay range must be forwarded on the router as well as 3478 — 3478 only carries the TURN control channel, so without the range relayed media reaches a closed port and calls connect with no audio. TURN_EXTERNAL_IP is best left empty: the coturn image detects the WAN address on every start, which a pinned value cannot do when the link is residential and the address moves. See pbx/README.md → TURN / WebRTC media.

Cerulean Authentik SSO (any deployment)

# 1. Authentik (server + worker + postgres + redis)
cp .env.docker.example .env   # set the AUTHENTIK_* secrets
docker compose -f docker-compose.platform.yml up -d
# Authentik web UI: http://<host>:9000  (proxy it as auth.zeus.innotel.us)

# 2. Create an OIDC "Provider" + "Application" ("zeus") in Authentik with:
#    Redirect URI: https://app.zeus.innotel.us/api/auth/authentik/callback
#    (or http://localhost:3000/api/auth/authentik/callback for LAN-only dev)

# 3. Set the portal env vars (AUTHENTIK_ISSUER_URL, _CLIENT_ID, _CLIENT_SECRET,
#    AUTHENTIK_ADMIN_EMAILS) in your .env and restart the portal.

Authentication modes (AUTH_MODE)

The portal supports three sign-in configurations:

AUTH_MODE Sign-in paths Use when
authentik Authentik SSO only — password login and self-service signup are disabled Cerulean Authentik is the single identity provider (default when the AUTHENTIK_* OIDC vars are set)
freepbx Local password login + signup only — Authentik is not offered even if configured Standalone/air-gapped installs without SSO
both Authentik button and the password form; locally-created accounts keep password auth, Authentik-provisioned accounts sign in via SSO Hybrid deployments migrating to SSO, or mixed operator/tenant audiences

Leave AUTH_MODE empty for automatic behaviour: authentik when the OIDC env vars are set, freepbx otherwise. In both mode, password change/settings apply only to locally-created accounts — Authentik-provisioned accounts are redirected to Authentik, and portal logout ends the Authentik session only for those accounts.

Files: docker-compose.platform.yml

Option 3: npm (dev)

npm install
cp .env.example .env
npm run seed
npm run dev

Option 4: Bare Metal — setup.sh + setup-portal.sh

scripts/setup.sh provisions the FreePBX/fax server on Ubuntu 24.04 from source; scripts/setup-portal.sh deploys the Zeus Customer Portal against it.

cp scripts/pbx.env.example scripts/pbx.env
nano scripts/pbx.env   # fill in DB_PASS, VoIP.ms creds, portal secrets

sudo bash scripts/setup.sh           # FreePBX / Asterisk / fax stack (~45-90 min)
sudo bash scripts/setup-portal.sh    # Zeus Customer Portal (reads the same pbx.env)

setup.sh installs Asterisk 22.11.0 LTS, FreePBX 17, PJSIP WSS (:8089), AMI (:5038), AvantFax 3.4.1 + HylaFAX + IAXModem + Tesseract OCR, VoIP.ms SIP/IAX trunks, SMS-over-PJSIP, FreePBX OAuth2 API, VOSK STT + AI CDR (Ollama), Webmin, Code-Server, Fail2Ban, AsterBan — and, when NPM credentials are present, runs the NPM proxy-host sync automatically at the end. When an MS Teams SBC is configured in pbx.env (CERULEAN_SBC_FQDN/MS_TEAMS_SBC_IP + CERULEAN_API_URL or the TSIG key), it also runs the MS Teams Direct Routing setup (pbx/cerulean-msteams.sh --full) automatically.


NPM Proxy Hosts Automation

scripts/npm-proxy-hosts.py keeps Nginx Proxy Manager in sync with the platform through its REST API — idempotent, safe to re-run, --check verifies without writing:

# Create/update all canonical subdomains + wildcard cert + prune stale hosts
python3 scripts/npm-proxy-hosts.py

# The same via env: NPM_ADMIN_EMAIL/NPM_ADMIN_PASSWORD, NPM_BASE_DOMAIN,
# NPM_UPSTREAM_HOST (Docker host IP), NPM_LETSENCRYPT_EMAIL,
# NPM_WILDCARD_CERT=1, NPM_DNS_PROVIDER=rfc2136,
# NPM_DNS_PROVIDER_CREDENTIALS=<NPM credential id with the TSIG key>

python3 scripts/npm-proxy-hosts.py --check   # exit 1 if anything is out of sync
python3 scripts/npm-proxy-hosts.py --no-prune
python3 scripts/npm-proxy-hosts.py --no-ssl

Wildcard certificate workflow (TSIG, zero-click):

  1. Your DNS server must accept RFC 2136 dynamic updates signed with a TSIG key.
  2. Put the key in your env: NPM_TSIG_NAMESERVER, NPM_TSIG_KEY_NAME, NPM_TSIG_KEY_SECRET (and optional NPM_TSIG_ALGORITHM), with NPM_WILDCARD_CERT=1 and NPM_DNS_PROVIDER=rfc2136.
  3. Run the script — it builds the rfc2136 credentials, provisions the wildcard cert via DNS-01, and attaches it to every host.

setup.sh runs this automatically when NPM_ADMIN_EMAIL/NPM_ADMIN_PASSWORD are in pbx.env and NPM is reachable. For non-TSIG providers, NPM_DNS_PROVIDER_CREDENTIALS accepts raw credentials content (or a path to a credentials file).


AI Voicemail Summaries & Call Routing

  • Vosk transcribes; the shared gateway summarises. The portal uses OmniRoute's OpenAI-compatible chat API at http://192.168.1.71:20128/v1. No local Ollama service or extra voice-host RAM is needed. The bare-metal AI CDR installer below remains a separate legacy path.
  • Set VOICEMAIL_SUMMARY_MODEL to a model verified against the authenticated gateway catalogue and a test completion. There is no guessed default and no direct OLLAMA_URL fallback.
  • Store the gateway credential in Cerulean Vault at cerulean/zeus, then use OMNIROUTE_API_KEY=vault://cerulean/zeus#OMNIROUTE_API_KEY. The portal entrypoint resolves it with the existing product-scoped token before startup. URLs and model IDs are non-secret configuration; provider credentials belong to the gateway, not Zeus.
  • Use scripts/vault-migrate.py --from-env-file .env --keys OMNIROUTE_API_KEY with VAULT_ADDR, the product-scoped VAULT_TOKEN_FILE, VAULT_PREFIX=cerulean and VAULT_PATH=zeus to migrate an existing key. Verify the secret reads back before replacing its plaintext assignment with the reference. Do not copy platform root tokens into Zeus or log credential values.
  • Deploy a newly built portal image before recreating the portal: the old published image does not contain this chat API or the expanded Vault resolver.
  • Deployment verified on .30 (2026-10-01): compose.gateway-vault.yml selects zeus/portal:gateway-vault-20261001, the authenticated free route auto/best-free, and the Vault reference. Preserve this override on subsequent deployments:
docker compose -f docker-compose.full.yml -f compose.gateway-vault.yml up -d --no-deps portal

The dedicated zeus-voicemail key has no dollar-based usage cap. Free routes still have provider time limits/cooldowns. A synthetic authenticated voicemail request returned 200 and its summary persisted; the synthetic records were removed. The PBX was not restarted. Rollback image zeus/portal:pre-gateway-vault-20261001 is retained on .30; a rollback must select that image and restore the previous portal environment, not change the PBX.

  • The bare-metal installer keeps the full AI stack: VOSK speech-to-text (voicemail transcription) + AI CDR call summaries (Ollama) + the ARI/WebSocket client for AI call handling. AI-driven call routing hooks (business-hours routing, AI receptionist) are dialplan-level and configured in setup.sh.

Environment Variables

Templates: .env.example (npm/dev), .env.docker.example (Docker). Key groups:

Variable Description
AUTH_MODE Sign-in paths: authentik (SSO only) · freepbx (local password auth) · both (Authentik button + password form). Empty = auto: authentik when the OIDC vars are set, else freepbx
AUTHENTIK_ISSUER_URL / _CLIENT_ID / _CLIENT_SECRET Authentik OIDC — enables SSO (disables password login in authentik mode)
AUTHENTIK_BOOTSTRAP_EMAIL / _PASSWORD Authentik superuser (platform compose)
AUTHENTIK_ADMIN_EMAILS Comma-separated emails granted the admin role
NPM_ADMIN_EMAIL / NPM_ADMIN_PASSWORD / NPM_API_TOKEN NPM API auth for the host sync
NPM_BASE_DOMAIN / NPM_UPSTREAM_HOST Proxy host base domain + Docker host IP
NPM_LETSENCRYPT_EMAIL Let's Encrypt email for proxy-host certs
NPM_WILDCARD_CERT / NPM_DNS_PROVIDER / NPM_TSIG_NAMESERVER / NPM_TSIG_KEY_NAME / NPM_TSIG_KEY_SECRET / NPM_TSIG_ALGORITHM Wildcard cert via DNS-01 (TSIG/rfc2136) — auto-provisioned
OMNIROUTE_API_KEY Gateway credential; use vault://cerulean/zeus#OMNIROUTE_API_KEY
VOICEMAIL_SUMMARY_URL / VOICEMAIL_SUMMARY_MODEL Shared gateway API base (including /v1) and explicitly verified summary model; no direct Ollama fallback
NEXT_PUBLIC_BRAND_NAME White-label brand override
SESSION_SECRET Session signing key (auto-generated if unset)
VOIPMS_API_USERNAME / _PASSWORD / VOIPMS_WEBHOOK_SECRET VoIP.ms API + SMS webhook
SMS_PROVIDER Outbound SMS transport: freepbx (default) · opensms (local gateway — Android app or smsgateway/) · auto (OpenSMS, then trunk)
OPENSMS_URL / OPENSMS_API_KEY / OPENSMS_TIMEOUT_MS SMS gateway root and key (Vault-resolvable) + request budget; scripts/install-smsgateway.sh fills these in
FREEPBX_URL / FREEPBX_CLIENT_ID / FREEPBX_CLIENT_SECRET FreePBX OAuth2
ASTERISK_AMI_HOST / _PORT / _USERNAME / _SECRET Real-time AMI monitoring
NEXT_PUBLIC_FREEPBX_WSS_URL WebRTC softphone WSS endpoint
AVANTFAX_URL / NEXT_PUBLIC_AVANTFAX_URL Fax UI
MAGNATE_PUBLIC_URL Magnate (RevenueOps) storefront base URL — routes dashboard billing & cancellations to Magnate (default when set)
STRIPE_* Legacy self-billing mode — deprecated; leave empty when Magnate owns billing
SMTP_* Voicemail/fax/invoice email
NEXT_PUBLIC_URL Portal origin (default https://app.zeus.innotel.us)

The VoIP.ms trunk sits at Rejected

If pjsip show registrations says Rejected and the log only has Registration timed out., look at the router before the PBX: a static UDP 5060 port-forward collides with the PBX's own outbound SIP, since both want source port 5060. The router remaps the REGISTER, the provider's reply lands on a mapping that no longer matches, and it is dropped before it reaches this host.

Standard, expected exchange: REGISTER → 401 → REGISTER(auth) → 200 OK. Capture against the provider's address, not port 5060 — a mis-ported reply is invisible to a port filter:

tcpdump -n -nn -i eth0 'host newyork1.voip.ms' &
docker exec zeus-freepbx asterisk -rx 'pjsip send unregister voipms-reg'
docker exec zeus-freepbx asterisk -rx 'pjsip send register voipms-reg'

Nothing inbound at all means the router is eating it. Remove the UDP 5060 forward and let inbound calls ride the registration's own NAT mapping (qualify_frequency=60 keeps it warm); registration then completes and the AOR reports Avail with a ~40 ms RTT. Full write-up and a one-liner that proves the WAN path is healthy: Capstone repo → docs/operations.md → "The trunk sits at Rejected".

DEST STATUS: EMPTY — Blacklist destination

fwconsole reload (Apply Config) ends with a problem-destination report, and on a fresh PBX it names one:

DEST STATUS: EMPTY
   Blacklist: Destination for BlackListed Calls

This is bookkeeping, not call handling. Blacklisted callers are already hung up: the generated [app-blacklist-check] context reads the destination at call time and falls back to the hangup when the value is missing.

Set(BLDEST=${DB(blacklist/dest)})
ExecIf($["${BLDEST}"=""]?Set(BLDEST=app-blackhole,hangup,1))

FreePBX's Destinations report reads the raw AstDB value instead, so an unseeded blacklist/dest still reads as EMPTY. The Blacklist module seeds it only inside install(), which never runs again on an already-installed module — and AstDB is not on a volume here (only /var/lib/asterisk/sounds is), so astdb.sqlite3 lives in the container's writable layer and every recreate starts it empty. The entrypoint re-asserts the destination on boot, so this heals itself; to do it by hand, or to send blacklisted calls somewhere other than the hangup:

docker exec zeus-freepbx asterisk -rx 'database put blacklist dest app-blackhole,hangup,1'
docker exec zeus-freepbx fwconsole reload          # clears the report
docker exec zeus-freepbx asterisk -rx 'database show blacklist'

Set BLACKLIST_DESTINATION in the container environment to change what the entrypoint converges (app-blackhole,hangup,1 by default); any FreePBX destination works, e.g. from-did-direct,600,1 to ring an extension instead.


CI / CD

GitHub Actions builds and publishes Docker images and generates release artifacts:

Trigger Image / artifact Tag
Push to master ghcr.io/innotelinc/zeus latest, sha-xxxxx, master (portal)
Push to master ghcr.io/innotelinc/zeus latest-fullstack, master-fullstack, sha-xxxxx-fullstack
Tag v* (release pipeline) portal + fullstack images 1.0.0, 1.0.0-fullstack (+ latest/latest-fullstack)
Tag v* (release pipeline) release artifacts: source bundle + deployment payload + SHA256SUMS attached to the GitHub Release
  • docker-publish.yml — portal image on every master push; PR smoke-tests (portal boots, serves / and /api/health); full-stack compose validation; weekly full-stack build check.
  • release.yml — on every v* tag push: builds/publishes both images and attaches release artifacts (source bundle + deployment payload + checksums, via scripts/build-release-artifacts.sh) to the GitHub Release.

Project Structure

scripts/
  setup.sh                # FreePBX/fax stack installer (Ubuntu 24.04)
  setup-portal.sh         # Zeus Customer Portal installer
  npm-proxy-hosts.py      # NPM proxy-host sync + wildcard cert automation
  build-release-artifacts.sh  # source bundle + deployment payload for releases
  smoke-test.sh           # live-stack smoke test (portal, PBX, fax, numbers)
  zeus-pbx-sync.sh        # PBX fragment drift re-apply (timer-driven)
  schema.sql              # SQLite schema
  seed.mjs                # Demo account seed
  migrations/             # Schema migrations
  pbx.env.example         # Bare-metal secrets template
pbx/
  bootstrap-zeus-pbx.sh   # render + apply Asterisk fragments (--check drift)
  asterisk/               # AMI, ARI, HTTP/WSS, dialplan fragments
systemd/
  zeus-portal.service     # portal unit (installed by setup-portal.sh)
  zeus-pbx-sync.service/.timer  # PBX fragment reconciliation every 15 min
patches/
  next+16.3.0.patch       # patch-package: skips static generation of synthetic
                          # error routes (see "Build notes" below)
docker-compose.yml        # portal only
docker-compose.full.yml   # full stack (Asterisk + FreePBX + portal)
docker-compose.platform.yml  # Authentik (SSO) platform services
src/
  app/
    page.tsx              # Landing page
    layout.tsx            # Root layout (force-dynamic, PWA metadata)
    (auth)/login          # SSO sign-in (Authentik) / dev password fallback
    (auth)/signup         # Managed by Authentik / dev plan-selector fallback
    dashboard/            # Phone, Messages, Contacts, Fax, Voicemail, History,
                          # Billing, Settings, Health, Admin (users, plans, resellers)
    api/
      auth/authentik/     # OIDC login + callback
      voicemail/summary/   # AI voicemail summaries (Ollama)
      admin/resellers/     # White-label reseller CRUD
      ...                  # phone, messages, fax, contacts, billing, webhooks
  lib/
    oidc.ts               # Authentik OIDC client (PKCE, discovery, userinfo)
    resellers.ts          # White-label reseller lookup by host
    ami.ts / freepbx.ts / voipms.ts / avantfax.ts / sms.ts / atlas-api.ts
    auth.ts               # HMAC session cookie auth (issued post-OIDC)
    db.ts                 # SQLite connection (better-sqlite3)

Build notes (Next.js 16 workaround)

next build fails in some environments (including the Docker/CI builders used by this project) with a framework-level crash while statically generating error routes:

Error occurred prerendering page "/_global-error" …
TypeError: Cannot read properties of null (reading 'useContext')

This is an upstream Next.js bug (vercel/next.js issues #86178, #87719, #95741 — unfixed across all 16.x as of Aug 2026). The workaround has two parts:

  1. src/app/layout.tsx exports export const dynamic = "force-dynamic" — every route is server-rendered on demand instead of statically prerendered.
  2. patches/next+16.3.0.patch (applied by patch-package via the postinstall script) — skips static generation of the synthetic /_global-error and /_not-found routes and the default pages-router /404 /500.

To verify the workaround is active after npm ci: grep -c pbx-patch node_modules/next/dist/esm/build/index.js should print 2.


Proprietary — Zeus VOIP Platform. All rights reserved.

🏛️ Platform stack

Zeus is the ecosystem's VoiceOps platform — VoIP, SIP, SMS, PBX, and number provisioning in the Innotel Platform Stack — the canonical single-responsibility architecture where Authentik owns identity, Cerulean Vault owns secrets, Cerulean owns trust, ONYX owns storage, Magnate owns revenue, NPM Edge owns the edge, and every other platform is a

License

Zeus is licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See LICENSE for the full text.

business function that consumes them. See docs/stack.md for this platform's owns/consumes boundaries and its Cerulean Vault secret setup.


Addressing rule — LAN IPs only

Docker addresses do not work for this project. Every service target on this box is this host's LAN IP (192.168.x.x), set once as LAN_IP in .env; the AMI permit, the PJSIP local network and the portal's own service targets all derive from it.

Setting Value What breaks if it is a docker address
LAN_IP this host's LAN IP every target below drifts
PJSIP_STUN_TURN_ADDR 192.168.x.x:3478 ast_sockaddr_resolve fails and STUN is disabled with no error
PJSIP_LOCAL_NETS the LAN subnet, never a docker range Asterisk classifies the LAN as off-net and mangles Contact/SDP
DOGRAH_WS_URI ws://192.168.x.x:8000/... media WebSocket dies silently — calls connect with no audio, nothing in the log
NPM_UPSTREAM_HOST / NPM_HOST_IP the docker host / the NPM host NPM forwards into a network it is not on
FREEPBX_WSS_URL / TURN_SERVER public edge names browsers cannot resolve a container name at all
AMI permit = loopback + the LAN subnet a bridge range (172.16.0.0/12) is only ever needed to allow a Docker address

Why: host.docker.internal does not resolve inside these containers (there is no extra_hosts entry, so every lookup fails), a bridge or service name is not a contract anything outside that network can use, and a docker subnet recorded by a different stack is wrong here — this box declared 172.18.0.0/16 while its own network is 172.31.0.0/16.

The portal runs on the host network, deliberately

The portal joins the host namespace (network_mode: host, PORT=3001) rather than the pbx-net bridge. On the bridge, every call it makes to FreePBX, AMI and AvantFax arrives sourced from the container's own 172.31.x.x address, so the AMI permit and the FreePBX access log both end up keyed on a Docker address. On the host network it reaches all three on this host's LAN IP, Asterisk sees 192.168.1.30, and the permit is the LAN subnet only. Nothing references the portal by container name (the PBX never calls back into it), so there is no Docker DNS name to lose — and NPM still forwards to <LAN_IP>:3001.

The two remaining docker references are deliberate and are not service addresses: scripts/install-fail2ban.sh exempts the bridge ranges so another stack's bridge client cannot be banned for a failed AMI probe, and stale copies inside /etc/asterisk/backup/ are FreePBX's own backups.

About

Self-hosted cloud-native VoIP platform: number provisioning, WebRTC softphone, SMS, digital fax, AI voicemail summaries, and billing

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages