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.
| 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
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.
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/subscribeon 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.
- 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), orboth(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.
- 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
- 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 thesms-outdialplan — 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 withSMS_PROVIDER=opensms, orautoto 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
- AvantFax user provisioning and HylaFAX+ integration
- Send faxes digitally from the portal; fax history with status tracking
- 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,standalonedisplay - In-call controls: mute, hold, hangup, DTMF, live call timer, incoming ring
- STUN/TURN support for NAT traversal
- Real-time call events (TCP :5038), automatic CDR collection →
call_history - Live device state per extension, AMI status indicator with auto-reconnect
- 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
- Full CRUD; auto-syncs conversation names; deep-links to messages
- 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
- 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.
| 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 |
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:3000Without Authentik configured, the portal falls back to the local password login. No account is seeded, so register one at
/signupfirst. OnceAUTHENTIK_ISSUER_URL/AUTHENTIK_CLIENT_ID/AUTHENTIK_CLIENT_SECRETare set, password login is disabled and every sign-in goes through Authentik.
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.
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:3000What 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:latestProvisions 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 -dTo 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 -dThe 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.
# 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.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
npm install
cp .env.example .env
npm run seed
npm run devscripts/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.
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-sslWildcard certificate workflow (TSIG, zero-click):
- Your DNS server must accept RFC 2136 dynamic updates signed with a TSIG key.
- Put the key in your env:
NPM_TSIG_NAMESERVER,NPM_TSIG_KEY_NAME,NPM_TSIG_KEY_SECRET(and optionalNPM_TSIG_ALGORITHM), withNPM_WILDCARD_CERT=1andNPM_DNS_PROVIDER=rfc2136. - 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).
- 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_MODELto a model verified against the authenticated gateway catalogue and a test completion. There is no guessed default and no directOLLAMA_URLfallback. - Store the gateway credential in Cerulean Vault at
cerulean/zeus, then useOMNIROUTE_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_KEYwithVAULT_ADDR, the product-scopedVAULT_TOKEN_FILE,VAULT_PREFIX=ceruleanandVAULT_PATH=zeusto 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.ymlselectszeus/portal:gateway-vault-20261001, the authenticated free routeauto/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 portalThe 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.
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) |
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".
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.
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 everyv*tag push: builds/publishes both images and attaches release artifacts (source bundle + deployment payload + checksums, viascripts/build-release-artifacts.sh) to the GitHub Release.
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)
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:
src/app/layout.tsxexportsexport const dynamic = "force-dynamic"— every route is server-rendered on demand instead of statically prerendered.patches/next+16.3.0.patch(applied bypatch-packagevia thepostinstallscript) — skips static generation of the synthetic/_global-errorand/_not-foundroutes 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.
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
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.
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 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.