From 8b843ca1c1645b58da8fdcd008107f948defb503 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 01:06:38 +0000 Subject: [PATCH 01/10] Fold asterisk-digital-ocean into asterisk with droplet auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit services/asterisk-digital-ocean.sh was a near-verbatim copy of services/asterisk.sh — same vendor refresh, compose template, messaging dialplan, presence alerts, UFW rules and dashboard/trunk chaining, with the helper functions renamed _asterisk_do_*. Two copies meant every fix had to land twice, and several never did. There is now one `asterisk` service. It reads the DigitalOcean metadata service and asks either way (so a droplet with metadata blocked, or another provider's public VM, can still opt in), then gates the genuinely droplet-specific behaviour on that one answer: swapfile for low-RAM plans, public-FQDN-only setup with no LAN/VLAN prompts, a Caddy site block pinned to that FQDN, the remote-Authelia option, and the doctl Cloud Firewall. Two things that were droplet-only for no real reason now apply everywhere: the entrypoint patch that writes security-level events to logs/full, and the logrotate config for that file. Without them the Security Dashboard's Security Log tab and CrowdSec's Asterisk acquisition were silently empty on every home/LAN install; crowdsec.sh now detects either install directory. Existing droplets are left alone: an install at ~/docker/asterisk-digital-ocean keeps its directory and easy-asterisk-do container names, since its Caddyfile block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN trunk all name those exactly. New installs use ~/docker/asterisk / easy-asterisk. `sudo ./setup.sh asterisk-digital-ocean` still works via a new SERVICE_ALIAS map in setup.sh, without a second menu entry. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh --- CLAUDE.md | 59 +- README.md | 3 +- docs/anveo-direct-setup-guide.md | 12 +- docs/pstn-calling-voipms-plan.md | 17 +- services/asterisk-digital-ocean.sh | 1499 ---------------------------- services/asterisk.sh | 1051 +++++++++++++++---- services/authelia.sh | 2 +- services/caddy.sh | 2 +- services/crowdsec.sh | 40 +- services/pstn-trunk.sh | 47 +- setup.sh | 11 + 11 files changed, 971 insertions(+), 1772 deletions(-) delete mode 100755 services/asterisk-digital-ocean.sh diff --git a/CLAUDE.md b/CLAUDE.md index 451aa3c..97fcd9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ checklist per group, and calls `install_()` for each selected item. 1. Create `services/.sh` (kebab-case filename) 2. Call `register_service` at the top of the file 3. Define `install_()` — keep hyphens **literal** in the function name - (`install_asterisk-digital-ocean`, not `install_asterisk_digital_ocean`). + (`install_pstn-trunk`, not `install_pstn_trunk`). `setup.sh`'s dispatcher calls `install_${name}` with no hyphen→underscore conversion, so the function name must match the service name exactly. Confirmed live: a mismatched underscore here produces @@ -29,6 +29,33 @@ That's it. The menu picks it up on the next run. Also update the **Services table in `README.md`** — add the service name to the appropriate group row so the README stays current. +## Retiring a service name (merging two services) + +Deleting `services/.sh` removes it from the menu, but `sudo ./setup.sh +` then fails outright for anyone with that name in their notes, docs, or +shell history. Add the old name to `SERVICE_ALIAS` in `setup.sh` instead — +`run_service` resolves it to the surviving service, says so once, and runs +that. The alias never gets its own menu entry, which is the whole point. + +`services/asterisk-digital-ocean.sh` was merged into `services/asterisk.sh` +this way: one installer that detects a DigitalOcean droplet (metadata service, +with a y/n either way) and applies the droplet-only extras — swapfile, +public-FQDN-only flow, hand-built Caddy site block, remote Authelia, Cloud +Firewall — behind that one answer. Two lessons worth reusing: + +- **Don't rename a live install's directory or containers.** New installs + land in `~/docker/asterisk` with `easy-asterisk`; a pre-merge droplet keeps + `~/docker/asterisk-digital-ocean` and `easy-asterisk-do`, because its + Caddyfile block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN + trunk all name those exact paths. `_asterisk_resolve_layout()` picks + whichever exists, and every sibling service probes both. +- **Check whether a "flavor-specific" behavior was actually flavor-specific.** + The Asterisk security-logging patch and the `logs/full` logrotate config + were droplet-only purely because that's where they got written first — the + Security Dashboard's Security Log and CrowdSec's Asterisk acquisition were + silently empty on every home/LAN install as a result. Both now apply + everywhere. + ## Minimal Docker service template ```bash @@ -175,15 +202,16 @@ the auth server's own access-control rules say. Confirmed live: this was the actual cause of a "Caddy proxies fine but Authelia never prompts for login" bug, on a site block that otherwise looked completely correct. If a service builds its own site block instead of using this helper (e.g. -`services/asterisk-digital-ocean.sh` does, deliberately, see its own -comment for why), put its auth block first there too. +`services/asterisk.sh` does in droplet mode, deliberately — see +`_asterisk_configure_caddy_public`'s comment for why), put its auth block +first there too. **`forward_auth` to a remote Authelia over a scheme-qualified URL needs explicit `header_up` pins.** A bare `forward_auth authelia:9091` (Authelia on the same Docker network, one hop) is fine relying on Caddy's default `X-Forwarded-*` headers. But `forward_auth https://auth.example.com { ... }` (Authelia on a *different* machine, reached over its own public domain+TLS — -see `services/asterisk-digital-ocean.sh`'s remote-Authelia prompt) is a +see `services/asterisk.sh`'s droplet-mode remote-Authelia prompt) is a second Caddy hop: Caddy rewrites the outgoing request's `Host` header to `auth.example.com` so the remote Caddy can route/SNI-match it, and without an override `X-Forwarded-Host` picks up that rewritten value instead of the @@ -221,9 +249,10 @@ Use this to skip opening a host firewall port for a service Caddy already fronts *locally* (it reaches the service over `host.docker.internal`, not the network) — but still open it when `CADDY_SERVICE_MODE` is `"remote"`, since a remote Caddy machine needs to reach this host over the network -instead. See `services/asterisk.sh` and `services/asterisk-digital-ocean.sh` -for the reference pattern: call `configure_caddy_for_service` *before* -building firewall rules, not after, so the decision is known in time. +instead. See `services/asterisk.sh` for the reference pattern: it decides +the Caddy question *before* building firewall rules, not after, so the +answer is known in time (in droplet mode it hand-builds its own site block +and sets the same flag itself, for the reasons noted above). ### UFW enable @@ -252,8 +281,7 @@ blocks that too and silently breaks the service (confirmed live: closing the web admin port outright took Caddy down with it). Call `ufw_allow_from_caddy_net` right after the `delete` to re-open the port scoped to just `caddy_net`'s subnet — reachable from Caddy, not from the -internet. See `services/asterisk-digital-ocean.sh` and -`services/asterisk.sh` for the pattern. +internet. See `services/asterisk.sh` for the pattern. ### README generation @@ -298,7 +326,7 @@ on the same machine anyway. See `add_authelia_domain()` in `services/authelia.sh **Running a genuinely separate instance (e.g. one per machine).** `services/authelia.sh` runs standalone on any box (`sudo bash authelia.sh`, same pattern as `crowdsec.sh`) and -`asterisk-digital-ocean.sh` already auto-detects a local install (`if [ -d +`asterisk.sh` already auto-detects a local install (`if [ -d "$DOCKER_DIR/authelia" ]`), switching from the remote-Authelia `forward_auth` flow to the local `import authelia` snippet automatically — so a second, fully independent instance on another machine (e.g. a droplet, for resilience if the first machine goes down) works with @@ -429,9 +457,8 @@ rules, or reverse-proxy/SSO config that's already in place. If the vendor-copy or `docker-compose.yml`-generation logic is more than a few lines, factor it into a helper function so the fresh-install path and the update path share one copy instead of drifting apart — see -`_asterisk_do_refresh_vendor_files`/`_asterisk_do_write_compose` in -`services/asterisk-digital-ocean.sh` (and their `_asterisk_*` counterparts in -`services/asterisk.sh`) for the reference pattern. +`_asterisk_refresh_vendor_files`/`_asterisk_write_compose` in +`services/asterisk.sh` for the reference pattern. `cancel` must leave the install completely untouched — it's the default for a reason (a stray Enter on a service you're just checking on shouldn't @@ -443,9 +470,9 @@ would, prompts included. A service can call another service's `install_()` directly as a convenience step at the end of its own flow, instead of making the user remember to separately run `sudo ./setup.sh ` afterward. -`services/asterisk.sh`/`services/asterisk-digital-ocean.sh` do this for +`services/asterisk.sh` does this for `services/security-dashboard.sh` and `services/pstn-trunk.sh` — after -Asterisk itself is installed/updated, each asks once whether to also set up +Asterisk itself is installed/updated, it asks once whether to also set up the dashboard and/or a PSTN trunk (or, if either is already installed, silently re-invokes it so it gets refreshed as part of the same run — its own `prompt_reinstall_mode` gate decides update vs. skip, so this never @@ -501,7 +528,7 @@ it, so as long as your `install_()` calls `require_docker` before `docker compose up` (it always should), the network is guaranteed to exist regardless of whether Caddy itself has been installed yet. -**`network_mode: host` services (e.g. `asterisk`/`asterisk-digital-ocean`) don't join +**`network_mode: host` services (e.g. `asterisk`) don't join `caddy_net` at all** — Caddy reaching them (or anything else on the host network) needs `host.docker.internal:PORT` in the Caddyfile, not `localhost:PORT` or a container name. Caddy's own compose file diff --git a/README.md b/README.md index 6e9e59c..9b15325 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. | Group | Services | |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | -| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `asterisk-digital-ocean`, `pstn-trunk`, `security-dashboard`, `sunshine` | +| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `pstn-trunk`, `security-dashboard`, `sunshine` | | `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | @@ -91,7 +91,6 @@ homelab authelia homeassistant asterisk - asterisk-digital-ocean pstn-trunk security-dashboard sunshine diff --git a/docs/anveo-direct-setup-guide.md b/docs/anveo-direct-setup-guide.md index ad28055..0e2aca8 100644 --- a/docs/anveo-direct-setup-guide.md +++ b/docs/anveo-direct-setup-guide.md @@ -1,8 +1,8 @@ # Anveo Direct + Easy Asterisk — confirmed working setup guide This is the exact sequence that got a real Anveo Direct DID working end to -end (both outbound and inbound) with `asterisk-digital-ocean.sh` + -`pstn-trunk.sh`, confirmed live on a real droplet. +end (both outbound and inbound) with `asterisk.sh` + `pstn-trunk.sh`, +confirmed live on a real droplet. **Steps 1, 3 and 4 are one-time account setup** — the outbound Service Trunk (step 3) and the inbound SIP Trunk (step 4) each cover every DID on @@ -13,8 +13,8 @@ in the dashboard, and test. ## 0. Prerequisites -- `asterisk-digital-ocean.sh` (or `asterisk.sh` for a LAN box) already - installed and running, with at least one extension configured. +- `asterisk.sh` already installed and running (droplet or home/LAN — the + installer detects which), with at least one extension configured. - This box's public IP address (`curl -4 ifconfig.me`). ## 1. Anveo Direct account (one-time) @@ -194,7 +194,9 @@ In the Security Dashboard's PSTN Trunk tab: answers or 20 seconds pass. - Watch the live console while testing either direction: ``` - docker exec -it easy-asterisk-do asterisk -rvvv + docker exec -it easy-asterisk asterisk -rvvv + # on a droplet set up before the two Asterisk services were merged, the + # container is named easy-asterisk-do instead ``` ## Bugs hit and fixed along the way (informational — already fixed) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 63f1107..667da84 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -10,9 +10,8 @@ file** — this doc is the design/decision log; that one is the clean how-to. **Implemented** — see `services/pstn-trunk.sh` (run `sudo ./setup.sh -pstn-trunk` after `asterisk-digital-ocean` **or** `asterisk` (home/LAN) is -installed — both are supported, see the file for the static-IP caveat on the -LAN variant). Generic SIP trunk add-on that defaults to VoIP.ms but isn't +pstn-trunk` after `asterisk` is installed — droplet or home/LAN, both are +supported; see the file for the static-IP caveat on the LAN variant). Generic SIP trunk add-on that defaults to VoIP.ms but isn't hardcoded to it — any provider supporting IP authentication works. Covers: - IP-authenticated trunk, US/NANP-only outbound dialplan, no catch-all. @@ -65,7 +64,7 @@ file. - **Provider: VoIP.ms.** Chosen for its prepaid-balance model: turn off auto-recharge in the account's Finances settings and outbound calls simply fail once the balance hits $0 — that's the toll-fraud backstop if the - droplet's Asterisk (`asterisk-digital-ocean`) is ever compromised. + droplet's Asterisk is ever compromised. **Update — read VoIP.ms's actual ToS (not just the wiki) on this.** The wiki says plainly "only accounts with a balance over $0 are able to send @@ -173,7 +172,7 @@ estimated spend crosses a threshold, and every hour that call volume in the last hour looks like a burst. Denied/rejected calls alert immediately, separately from that hourly check. -## What it takes technically (asterisk-digital-ocean) +## What it takes technically (asterisk, droplet mode) - A PJSIP trunk: `endpoint` / `aor` / `identify` sections in the pjsip config. **Implemented with IP authentication** (no `auth` section, no SIP password stored anywhere) — see `services/pstn-trunk.sh`. Provider name, @@ -357,9 +356,9 @@ generator output. Fixed by quoting every value in that heredoc. model (internal/restricted/full) managed live via `pstn-permissions.conf` + the Security Dashboard web UI, no reinstall needed to change. ~~Generic Asterisk target~~ Done — - `services/pstn-trunk.sh` now supports either `asterisk-digital-ocean` or - the home/LAN `asterisk` install (the latter with a static-IP caveat for - the provider's IP authentication). Still unresolved: pick pay-per-minute + `services/pstn-trunk.sh` supports the `asterisk` install in either mode, + droplet or home/LAN (the latter with a static-IP caveat for the + provider's IP authentication). Still unresolved: pick pay-per-minute vs. unlimited DID plan on VoIP.ms's side based on real expected volume, and decide on E911 (see cost estimate). 5. ~~Concurrent-call cap~~ Done — both directions now (inbound was a real @@ -444,7 +443,7 @@ generator output. Fixed by quoting every value in that heredoc. `exten => ,1,...` per device, freshly regenerated by Easy Asterisk's own `rebuild_dialplan()` on every dialplan rebuild — exactly the collision this doc worried about. Solved by NOT sharing - `[intercom]`: `services/asterisk-digital-ocean.sh` now explicitly sets + `[intercom]`: `services/asterisk.sh` now explicitly sets `message_context=sip-messaging` on every endpoint (patched into both of Easy Asterisk's device-creation code paths — the CLI menu's bash heredoc and the web admin's Python `add_device()` — so new devices pick diff --git a/services/asterisk-digital-ocean.sh b/services/asterisk-digital-ocean.sh deleted file mode 100755 index ee6ce1b..0000000 --- a/services/asterisk-digital-ocean.sh +++ /dev/null @@ -1,1499 +0,0 @@ -#!/bin/bash -# services/asterisk-digital-ocean.sh — Easy Asterisk PBX + coturn, tuned for a -# public DigitalOcean droplet (public-IP FQDN by default, DO Cloud Firewall -# setup, no LAN/VLAN prompts). For a home/LAN box use services/asterisk.sh -# instead. -# Part of the modular post-install system (sourced by setup.sh). -# -# Can also be run standalone on a fresh droplet: -# sudo bash asterisk-digital-ocean.sh -# (Docker must already be installed when run standalone) - -# ── Standalone bootstrap ────────────────────────────────────────────────────── -# Detected when the script is executed directly rather than sourced by setup.sh. -# Sets up helpers and globals, then defers execution until after the function -# definition at the bottom of this file. -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } - - _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - _COMMON="$_SELF_DIR/../lib/common.sh" - - if [[ -f "$_COMMON" ]]; then - # Full repo present — use the real helpers (picks up ~/docker/.config too) - # shellcheck source=../lib/common.sh - source "$_COMMON" - else - # One-off copy — inline minimal stubs so the script works without the repo - log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } - log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } - log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } - log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } - - require_docker() { - command -v docker &>/dev/null || { - log_error "Docker not found. Install it first:" - log_error " curl -fsSL https://get.docker.com | sudo sh" - return 1 - } - docker compose version &>/dev/null || { - log_error "Docker Compose plugin missing:" - log_error " sudo apt-get install -y docker-compose-plugin" - return 1 - } - } - - ensure_docker_dir_ownership() { - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true - } - - # Match common.sh's eval-based pattern so local vars in install_* are set correctly - prompt_text() { - local _q="$1" _def="$2" _var="$3" _r - [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } - read -r -p " $_q " _r - eval "$_var='${_r:-$_def}'" - } - - prompt_yn() { - local _q="$1" _def="$2" _var="$3" _r - [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } - read -r -p " $_q " _r - eval "$_var='${_r:-$_def}'" - } - - prompt_reinstall_mode() { - local _var="$1" _r - if [[ "${UNATTENDED:-false}" == "true" ]]; then - eval "$_var='cancel'" - echo "Existing install detected — leaving it as-is [auto: cancel, unattended mode]" - return - fi - echo " Existing install detected. Choose:" - echo " r) Reinstall in place — refresh vendor files/config, keep existing settings" - echo " f) Full install — re-run every prompt from scratch" - echo " c) Cancel — leave everything as-is [default]" - read -r -p " Choice [r/f/c, Enter=cancel]: " _r - case "${_r,,}" in - r) eval "$_var='update'" ;; - f) eval "$_var='fresh'" ;; - *) eval "$_var='cancel'" ;; - esac - } - - configure_caddy_for_service() { - local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" - local _caddy_dir="$DOCKER_DIR/caddy" - local _caddyfile="$_caddy_dir/Caddyfile" - local _display_port="${_upstream##*:}" - - local _mode="none" - [[ -d "$_caddy_dir" ]] && _mode="local" - [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" - [[ "$_mode" == "none" ]] && { - log_info "Access $_name directly on port $_display_port." - return 0 - } - - echo "" - local _do_caddy="" - if [[ "$_mode" == "remote" ]]; then - log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." - log_info "A snippet file will be saved to ~/docker/caddy-snippets/." - fi - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:$_display_port" - return 0 - } - - local _default_domain="" - if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then - _default_domain="${_subdomain}.${SITE_DOMAIN}" - log_info "Default: $_default_domain" - fi - local _domain="" - read -r -p " Domain [${_default_domain:-required}]: " _domain - _domain="${_domain:-$_default_domain}" - [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - - local _block_upstream="$_upstream" - if [[ "$_mode" == "remote" ]]; then - _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" - fi - - local _site_block - _site_block="$(cat << CBLOCK - -# $_name -${_domain} { - reverse_proxy ${_block_upstream} - - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - X-Content-Type-Options "nosniff" - X-Frame-Options "SAMEORIGIN" - Referrer-Policy "strict-origin-when-cross-origin" - } - - log { - output file /var/log/caddy/${_domain}.log - format json - } -${_extra} -} -CBLOCK -)" - - if [[ "$_mode" == "local" ]]; then - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" - fi - - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - printf '%s\n' "$_site_block" >> "$_caddyfile" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - # The template Caddyfile ships with "admin off", so `caddy - # reload` (which needs that same admin API) never actually - # works here. Try it anyway, fall back to a restart. - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" - elif docker restart caddy &>/dev/null; then - log_success "Caddy restarted to apply changes (reload API is disabled by default)" - log_success "$_name should be accessible at: https://$_domain" - else - log_warning "Reload/restart failed — check: docker logs caddy" - log_info "Manual fix: docker restart caddy" - fi - else - local _snippet_dir="$DOCKER_DIR/caddy-snippets" - local _snippet_file="$_snippet_dir/${_subdomain}.caddy" - mkdir -p "$_snippet_dir" - printf '%s\n' "$_site_block" > "$_snippet_file" - chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true - log_success "Snippet saved: $_snippet_file" - log_info "Copy to Caddy machine:" - log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" - log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" - fi - } - - write_readme() { - local _dir="$1" - mkdir -p "$_dir" - [[ "${DRY_RUN:-false}" == "true" ]] && return 0 - cat > "$_dir/README.md" - } - - generate_password() { - local _len="${1:-32}" - tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" - echo - } - fi - - # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR - # ($HOME under sudo is /root, not the real user's home) - ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" - ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" - DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" - DRY_RUN="${DRY_RUN:-false}" - UNATTENDED="${UNATTENDED:-false}" - SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" - SITE_DOMAIN="${SITE_DOMAIN:-example.com}" - SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" - CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" - - register_service() { :; } # no-op — no wizard to register into - _RUN_STANDALONE=1 -fi -# ───────────────────────────────────────────────────────────────────────────── - -register_service asterisk-digital-ocean homelab "Easy Asterisk PBX + coturn, tuned for a public DigitalOcean droplet" 5061 - -# ── Shared: vendor file refresh ──────────────────────────────────────────── -# Called from both a fresh install and an "update in place" run, so a single -# copy of this logic stays current for both instead of drifting apart. Must -# be called with $PWD already at $EA_DIR. -_asterisk_do_refresh_vendor_files() { - mkdir -p docker scripts - - local _SELF_DIR_LOCAL - _SELF_DIR_LOCAL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - local VENDOR_DIR="$_SELF_DIR_LOCAL/../vendor/easy-asterisk" - - if [[ -d "$VENDOR_DIR" ]]; then - log_info "Copying vendor files from $VENDOR_DIR ..." - cp "$VENDOR_DIR/Dockerfile" ./Dockerfile - cp "$VENDOR_DIR/docker/entrypoint.sh" ./docker/entrypoint.sh - cp "$VENDOR_DIR/docker/coturn-entrypoint.sh" ./docker/coturn-entrypoint.sh - cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ./easy-asterisk.sh - cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ./easy-asterisk-v0.10.0.sh - cp "$VENDOR_DIR/scripts/vpn-diagnostics.sh" ./scripts/vpn-diagnostics.sh - cp "$VENDOR_DIR/scripts/dns-whitelist.sh" ./scripts/dns-whitelist.sh - else - log_info "Vendor directory not found — downloading from GitHub ..." - local GH_RAW="https://raw.githubusercontent.com/DeadDork/easy-asterisk/main" - curl -fsSL "$GH_RAW/Dockerfile" -o ./Dockerfile - curl -fsSL "$GH_RAW/docker/entrypoint.sh" -o ./docker/entrypoint.sh - curl -fsSL "$GH_RAW/docker/coturn-entrypoint.sh" -o ./docker/coturn-entrypoint.sh - curl -fsSL "$GH_RAW/easy-asterisk-v0.10.0.sh" -o ./easy-asterisk.sh - curl -fsSL "$GH_RAW/scripts/vpn-diagnostics.sh" -o ./scripts/vpn-diagnostics.sh - curl -fsSL "$GH_RAW/scripts/dns-whitelist.sh" -o ./scripts/dns-whitelist.sh - cp ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh - fi - - chmod 755 ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh \ - ./docker/entrypoint.sh ./docker/coturn-entrypoint.sh \ - ./scripts/vpn-diagnostics.sh ./scripts/dns-whitelist.sh - - # Persist security-level logging to a file — vendor's logger.conf only - # sends the "security" level (auth failures, SIP brute-force attempts) to - # the console (Docker stdout), not a file CrowdSec/fail2ban can tail. - if grep -q '^console => notice,warning,error,security$' ./docker/entrypoint.sh; then - sed -i '/^console => notice,warning,error,security$/a full => notice,warning,error,security' \ - ./docker/entrypoint.sh - else - log_warning "entrypoint.sh logger.conf template changed upstream — security events won't be logged to a file. Update the sed patch in this installer." - fi -} - -# ── Shared: log rotation for logs/full (unbounded otherwise) ────────────── -# Confirmed live: with no rotation, this file grew to 1.4GB in about 3 days -# on a busy box (SIP scanning noise is constant on the public internet) — -# a real disk-exhaustion risk on a small droplet, and separately made the -# Security Dashboard balloon to 600+MB RAM/GBs of swap reading it every 30s -# before that was fixed to only read a bounded tail (see -# services/security-dashboard.sh). copytruncate avoids needing to signal -# Asterisk to reopen its log file — it has a long-held file descriptor on -# this path and no reload mechanism this installer can reach from the host. -_asterisk_do_write_logrotate() { - local _ea_dir="$1" - cat > /etc/logrotate.d/asterisk-digital-ocean << LOGROTATE -$_ea_dir/logs/full { - size 100M - rotate 5 - compress - missingok - notifempty - copytruncate -} -LOGROTATE -} - -# ── Shared: extension presence (online/offline) ntfy alerts ──────────────── -# Polls PJSIP registration state and alerts only on a CHANGE from the last -# check (never on every poll) — same periodic-check shape as pstn-trunk.sh's -# usage-alert script, but purely informational, so a looser 2-minute -# interval is fine here (nothing enforces/blocks anything off the back of -# this one). UNVERIFIED: the `pjsip show contacts` column layout below is -# parsed defensively (grep for the Avail/Unavail keyword rather than a fixed -# column position) specifically because it hasn't been confirmed against a -# live install's actual output yet — run -# `docker exec easy-asterisk-do asterisk -rx "pjsip show contacts"` yourself -# after enabling this to confirm extensions/status actually show up as -# expected, same as any other not-yet-live-tested piece in this project. -_asterisk_do_write_presence_alert_script() { - local FILE="$1" CONTAINER_NAME="$2" NTFY_URL="$3" STATE_FILE="$4" - cat > "$FILE" << 'SCRIPT' -#!/bin/bash -# Auto-generated by services/asterisk-digital-ocean.sh — rerun the installer's -# presence-alert step to change settings instead of editing this directly. -CONTAINER_NAME="__PRESENCE_CONTAINER__" -NTFY_URL="__PRESENCE_NTFY_URL__" -STATE_FILE="__PRESENCE_STATE_FILE__" - -[[ -z "$NTFY_URL" ]] && exit 0 - -send_ntfy() { - curl -m 5 -s -d "$1" "$NTFY_URL" >/dev/null 2>&1 -} - -CURRENT="$(docker exec "$CONTAINER_NAME" asterisk -rx "pjsip show contacts" 2>/dev/null | grep '^ Contact:' | while read -r _ aor rest; do - ext="${aor%%/*}" - status="Unknown" - case "$rest" in - *Unavail*) status="Unavail" ;; - *Avail*) status="Avail" ;; - esac - echo "${ext}:${status}" -done)" - -[[ -z "$CURRENT" ]] && exit 0 - -touch "$STATE_FILE" -declare -A OLD_STATE -while IFS=: read -r ext status; do - [[ -n "$ext" ]] && OLD_STATE["$ext"]="$status" -done < "$STATE_FILE" - -: > "${STATE_FILE}.new" -while IFS=: read -r ext status; do - [[ -z "$ext" ]] && continue - echo "${ext}:${status}" >> "${STATE_FILE}.new" - old="${OLD_STATE[$ext]:-}" - if [[ -n "$old" && "$old" != "$status" && "$status" != "Unknown" ]]; then - if [[ "$status" == "Avail" ]]; then - send_ntfy "Extension $ext is back online." - elif [[ "$old" == "Avail" ]]; then - send_ntfy "Extension $ext went offline." - fi - fi -done <<< "$CURRENT" -mv "${STATE_FILE}.new" "$STATE_FILE" -SCRIPT - sed -i "s#__PRESENCE_CONTAINER__#${CONTAINER_NAME}#g; s#__PRESENCE_NTFY_URL__#${NTFY_URL}#g; s#__PRESENCE_STATE_FILE__#${STATE_FILE}#g" "$FILE" - chmod 755 "$FILE" -} - -_asterisk_do_install_presence_timer() { - local EA_DIR="$1" - mkdir -p "$EA_DIR/logs" - - if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then - cat > /etc/systemd/system/asterisk-presence-alert.service << SVCEOF -[Unit] -Description=Asterisk extension presence (online/offline) check - -[Service] -Type=oneshot -ExecStart=/bin/bash $EA_DIR/asterisk-presence-alert.sh -StandardOutput=append:$EA_DIR/logs/asterisk-presence-alert.log -StandardError=append:$EA_DIR/logs/asterisk-presence-alert.log -SVCEOF - - cat > /etc/systemd/system/asterisk-presence-alert.timer << SVCEOF -[Unit] -Description=Run the Asterisk presence check every 2 minutes - -[Timer] -OnBootSec=2min -OnUnitActiveSec=2min -AccuracySec=10s - -[Install] -WantedBy=timers.target -SVCEOF - - systemctl daemon-reload - systemctl enable --now asterisk-presence-alert.timer - log_success "Presence check installed (systemd timer, every 2 minutes)." - elif command -v cron >/dev/null 2>&1 || [[ -d /etc/cron.d ]]; then - cat > /etc/cron.d/asterisk-presence-alert << CRON -*/2 * * * * root /bin/bash $EA_DIR/asterisk-presence-alert.sh >> $EA_DIR/logs/asterisk-presence-alert.log 2>&1 -CRON - log_success "Presence check installed (cron.d fallback — systemd not detected)." - else - log_warning "Neither systemd nor cron available — run $EA_DIR/asterisk-presence-alert.sh manually/periodically." - fi -} - -# ── Shared: internal SIP MESSAGE routing/enforcement ──────────────────────── -# Confirmed live against a real install's pjsip.conf/extensions.conf -# (2026-07-23): every endpoint sets context=intercom and leaves -# message_context blank, so PJSIP messaging falls back to context=intercom — -# and [intercom] already owns an exact-match `exten => ,1,...` per -# device, freshly regenerated by the vendor's own rebuild_dialplan() on -# every dialplan rebuild. A competing priority-1 declaration for the same -# extension number in a #include'd file would race that (Asterisk doesn't -# merge two independent priority-1 declarations for the same context+exten — -# one silently wins) and risks breaking normal internal calling entirely. -# So this uses its own dedicated [sip-messaging] context instead, reached by -# explicitly setting message_context=sip-messaging on every endpoint, so -# there is never any overlap with [intercom]'s own per-device call routing. -# -# The vendor's device-creation code has exactly two independent code paths -# that write a fresh endpoint block (confirmed via grep — both contain the -# literal line "context=intercom" exactly once): the CLI menu's bash heredoc, -# and the web admin's Python add_device(). Patching the vendor's own -# generator source (same technique as _pstn_patch_vendor_files) makes every -# device added FROM NOW ON pick this up automatically, in either path. -# Devices that already existed before this was installed need one one-time -# migration pass over the live pjsip.conf (below) since they were written -# before the patch existed. -_asterisk_do_patch_messaging_vendor_files() { - local EA_DIR="$1" - local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh" - local EASY1="$EA_DIR/easy-asterisk.sh" - local EASY2 - EASY2="$(find "$EA_DIR" -maxdepth 1 -name 'easy-asterisk-v*.sh' | head -1)" - [[ -z "$EASY2" ]] && EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh" - local f - - for f in "$EASY1" "$EASY2"; do - [[ -f "$f" ]] || { log_error "$f not found — is the base Asterisk install fully set up?"; return 1; } - done - - # Device-creation templates: both occurrences of "context=intercom" in - # these two files (identical vendor source, copied twice) are the CLI - # and web-admin device-creation code paths — a single anchor on the bare - # line patches both in one pass. - for f in "$EASY1" "$EASY2"; do - if ! grep -q '^message_context=sip-messaging$' "$f"; then - if grep -q '^context=intercom$' "$f"; then - sed -i '/^context=intercom$/a message_context=sip-messaging' "$f" - else - log_warning "$(basename "$f"): 'context=intercom' anchor not found — vendor template changed upstream." - log_warning " Add 'message_context=sip-messaging' manually after every 'context=intercom' line in this file's device-creation code." - fi - fi - done - - # extensions.conf: same [intercom] anchor _pstn_patch_vendor_files uses, - # a SEPARATE #include so this coexists whether or not pstn-trunk is - # installed — messaging is independent of the PSTN trunk entirely. - for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do - [[ -f "$f" ]] || continue - if ! grep -q 'messaging-dialplan.conf' "$f"; then - if grep -q '^\[intercom\]$' "$f"; then - sed -i '/^\[intercom\]$/a #include messaging-dialplan.conf' "$f" - else - log_warning "$(basename "$f"): '[intercom]' anchor not found — vendor template changed upstream." - log_warning " Add '#include messaging-dialplan.conf' manually after [intercom] in this file's extensions.conf heredoc." - fi - fi - done - - log_success "Vendor generator functions patched for internal SIP messaging." -} - -# Confirmed live (2026-07-23, via a real pstn-trunk.sh failure that hit this -# same mechanism): the vendor-generator patch above only takes effect on a -# FUTURE regeneration, and Easy Asterisk's own entrypoint only regenerates -# extensions.conf if it doesn't already exist (docker/entrypoint.sh guards -# it behind `[[ ! -f ... ]]`) — a box that already has devices configured, -# which is the normal case here, never regenerates it on a plain restart. -# Patches the LIVE file directly instead, so it takes effect immediately -# regardless of whether Easy Asterisk ever regenerates it on its own. -_asterisk_do_ensure_live_messaging_include() { - local EA_DIR="$1" - local EXT_LIVE="$EA_DIR/config/asterisk/extensions.conf" - [[ -f "$EXT_LIVE" ]] || return 0 - if ! grep -q 'messaging-dialplan.conf' "$EXT_LIVE"; then - if grep -q '^\[intercom\]$' "$EXT_LIVE"; then - sed -i '/^\[intercom\]$/a #include messaging-dialplan.conf' "$EXT_LIVE" - log_success "Patched the messaging #include directly into the live extensions.conf." - else - log_warning "Couldn't find '[intercom]' in the live extensions.conf — add" - log_warning "'#include messaging-dialplan.conf' manually, then: docker exec easy-asterisk-do asterisk -rx \"dialplan reload\"" - fi - fi - docker exec easy-asterisk-do asterisk -rx "dialplan reload" &>/dev/null || true -} - -# One-time migration for devices that already existed before the patch above -# — new devices pick up message_context=sip-messaging automatically from now -# on, but anything already in pjsip.conf was written before that existed. -# Idempotent: buffers the file and only inserts where the very next line -# isn't already the exact value, so reruns (every "update") never duplicate it. -_asterisk_do_migrate_existing_devices_message_context() { - local PJSIP_FILE="$1" - [[ -f "$PJSIP_FILE" ]] || return 0 - grep -q '^context=intercom$' "$PJSIP_FILE" || return 0 - - local TMP_FILE - TMP_FILE="$(mktemp)" - awk ' - { lines[NR] = $0 } - END { - for (i = 1; i <= NR; i++) { - print lines[i] - if (lines[i] == "context=intercom" && lines[i+1] != "message_context=sip-messaging") { - print "message_context=sip-messaging" - } - } - } - ' "$PJSIP_FILE" > "$TMP_FILE" - - if ! diff -q "$PJSIP_FILE" "$TMP_FILE" >/dev/null 2>&1; then - cp "$PJSIP_FILE" "$PJSIP_FILE.backup.$(date +%Y%m%d-%H%M%S)" - mv "$TMP_FILE" "$PJSIP_FILE" - chown asterisk:asterisk "$PJSIP_FILE" 2>/dev/null || true - log_success "Existing devices migrated to message_context=sip-messaging (backup saved alongside pjsip.conf)." - else - rm -f "$TMP_FILE" - fi -} - -# The actual enforcement — gated on the SENDER's own "messaging" flag in -# pstn-permissions.conf (the exact file/flag the Security Dashboard's -# "Internal SIP messaging" checkbox writes, independent of whether the PSTN -# trunk is installed), read live via AST_CONFIG() on every message, same -# mechanism pstn-trunk.sh's own dialplan already relies on for permission -# tiers — no restart needed to take effect. Off by default: an extension -# with no entry, or messaging=no, is denied. UNVERIFIED: MESSAGE(from)'s -# exact format hasn't been confirmed on a live install — the CUT()-based -# extraction below is written to tolerate a display name (e.g. this -# project's "name0" <999> callerid format) but if it ever fails to parse, -# FROM_EXT ends up empty/wrong and the AST_CONFIG() lookup simply finds no -# match, which denies by default (same fail-closed behavior as an -# unlisted extension) rather than silently allowing anything through. -_asterisk_do_write_messaging_dialplan() { - local FILE="$1" - cat > "$FILE" << 'EOF' -; Internal SIP MESSAGE routing/enforcement — services/asterisk-digital-ocean.sh. -; Regenerated on every install/update; edit there, not here directly. -; -; Reached via each endpoint's message_context=sip-messaging (patched into -; Easy Asterisk's own device-creation code — see -; _asterisk_do_patch_messaging_vendor_files) instead of falling back to -; [intercom], which already owns an exact-match "exten => ,1,..." per -; device for CALLS, regenerated fresh on every dialplan rebuild — a -; competing priority-1 declaration for the same extension number here would -; race that and risk breaking normal internal calling. This context ONLY -; ever receives MESSAGE requests, never calls. -[sip-messaging] -exten => _X.,1,NoOp(SIP MESSAGE to ${EXTEN}) - same => n,Set(FROM_URI=${MESSAGE(from)}) - same => n,Set(FROM_PART=${CUT(FROM_URI,@,1)}) - same => n,Set(FROM_EXT=${CUT(FROM_PART,:,2)}) - same => n,Set(SENDER_OK=${AST_CONFIG(pstn-permissions.conf,${FROM_EXT},messaging)}) - same => n,GotoIf($["${SENDER_OK}" = "yes"]?deliver:deny) - same => n(deliver),MessageSend(pjsip:${EXTEN},${FROM_URI}) - same => n,Hangup() - same => n(deny),NoOp(Denied — extension ${FROM_EXT} is not messaging-enabled) - same => n,Hangup() -EOF -} - -_asterisk_do_remove_presence_timer() { - systemctl disable --now asterisk-presence-alert.timer 2>/dev/null || true - rm -f /etc/systemd/system/asterisk-presence-alert.timer /etc/systemd/system/asterisk-presence-alert.service - rm -f /etc/cron.d/asterisk-presence-alert - systemctl daemon-reload 2>/dev/null || true -} - -# Interactive step — called from both the fresh-install flow and "update in -# place" (always asked either way, same reasoning as pstn-trunk.sh's -# international-calling step: this is a live-editable extra, not a -# structural setting, so it doesn't belong exclusively to one path). -_asterisk_do_run_presence_step() { - local EA_DIR="$1" - local SETTINGS_FILE="$EA_DIR/.presence-alert.env" - local STATE_FILE="$EA_DIR/.presence-alert.state" - - echo "" - local _CUR_ENABLED="n" _CUR_NTFY="" - if [[ -f "$SETTINGS_FILE" ]]; then - # shellcheck disable=SC1090 - source "$SETTINGS_FILE" - _CUR_ENABLED="${PRESENCE_ENABLED:-n}" - _CUR_NTFY="${PRESENCE_NTFY_URL:-}" - fi - - if [[ "$_CUR_ENABLED" == "y" ]]; then - echo " Extension online/offline ntfy alerts are ON (topic: $_CUR_NTFY)." - local _CHANGE="" - prompt_yn " Change or disable this? (y/n):" "n" _CHANGE - [[ "$_CHANGE" =~ ^[Yy]$ ]] || return 0 - local _DISABLE="" - prompt_yn " Disable presence alerts entirely? (y/n):" "n" _DISABLE - if [[ "$_DISABLE" =~ ^[Yy]$ ]]; then - _asterisk_do_remove_presence_timer - rm -f "$EA_DIR/asterisk-presence-alert.sh" "$STATE_FILE" - cat > "$SETTINGS_FILE" << ENV -PRESENCE_ENABLED="n" -PRESENCE_NTFY_URL="" -ENV - log_success "Presence alerts disabled." - return 0 - fi - else - local _WANT="" - prompt_yn "Send an ntfy alert when an extension's SIP registration goes offline / comes back online? (y/n):" "n" _WANT - [[ "$_WANT" =~ ^[Yy]$ ]] || return 0 - fi - - local _ntfy_default="${_CUR_NTFY:-https://ntfy.sh/asterisk-presence}" - if [[ -z "$_CUR_NTFY" ]] && [[ -f "$DOCKER_DIR/ntfy/config/server.yml" ]]; then - local _local_base_url - _local_base_url="$(grep -oP '(?<=base-url: ")[^"]+' "$DOCKER_DIR/ntfy/config/server.yml" 2>/dev/null || true)" - if [[ -n "$_local_base_url" ]] && [[ "$_local_base_url" != "https://ntfy.example.com" ]]; then - _ntfy_default="${_local_base_url}/asterisk-presence" - log_info "Detected a configured local ntfy instance at $_local_base_url — using it as the default." - fi - fi - local PRESENCE_NTFY_URL="" - prompt_text " ntfy topic URL:" "$_ntfy_default" PRESENCE_NTFY_URL - if [[ -z "$PRESENCE_NTFY_URL" ]]; then - log_warning "No topic entered — presence alerts not enabled." - return 0 - fi - - _asterisk_do_write_presence_alert_script "$EA_DIR/asterisk-presence-alert.sh" "easy-asterisk-do" "$PRESENCE_NTFY_URL" "$STATE_FILE" - _asterisk_do_install_presence_timer "$EA_DIR" - - cat > "$SETTINGS_FILE" << ENV -PRESENCE_ENABLED="y" -PRESENCE_NTFY_URL="${PRESENCE_NTFY_URL}" -ENV - chown "$ACTUAL_USER:$ACTUAL_USER" "$SETTINGS_FILE" 2>/dev/null || true - log_success "Presence alerts enabled (checked every 2 minutes) — topic: $PRESENCE_NTFY_URL" - log_info "Fires only on a state CHANGE, never every check — the first check after enabling" - log_info "never alerts by itself, since there's no prior state to compare against yet." -} - -# See services/asterisk.sh's own copy for the full rationale — identical -# here, just calling into the same install_security-dashboard/ -# install_pstn-trunk entry points (still independently registered/ -# invocable; this is a convenience layer on top, not a replacement). -_asterisk_do_offer_dashboard_and_trunk() { - local EA_DIR="$1" - - if ! declare -F install_security-dashboard >/dev/null 2>&1 && ! declare -F install_pstn-trunk >/dev/null 2>&1; then - log_info "Run this from the full ubuntu-post-install repo (not a standalone copy) to also" - log_info "get prompts here for the Security Dashboard and a PSTN trunk — skipping both." - return 0 - fi - - if declare -F install_security-dashboard >/dev/null 2>&1; then - echo "" - if [[ -f "$DOCKER_DIR/security-dashboard/app.py" ]]; then - log_info "Security Dashboard already installed — refreshing it too..." - install_security-dashboard - else - local _WANT_DASH="" - prompt_yn "Set up the Security Dashboard (Security Log, Extensions, Asterisk Admin, PSTN Trunk, CrowdSec — one page)? (y/n):" "y" _WANT_DASH - [[ "$_WANT_DASH" =~ ^[Yy]$ ]] && install_security-dashboard - fi - fi - - if declare -F install_pstn-trunk >/dev/null 2>&1; then - echo "" - if [[ -f "$EA_DIR/config/asterisk/pstn-trunk-dialplan.conf" ]]; then - log_info "PSTN trunk already configured — refreshing it too..." - install_pstn-trunk - else - local _WANT_TRUNK="" - prompt_yn "Configure a real SIP/PSTN trunk (actual outside phone numbers, e.g. Anveo Direct/VoIP.ms)? (y/n):" "n" _WANT_TRUNK - [[ "$_WANT_TRUNK" =~ ^[Yy]$ ]] && install_pstn-trunk - fi - fi -} - -# ── Shared: docker-compose.yml ───────────────────────────────────────────── -# Same reasoning as above — one copy of the template used by both fresh -# installs and updates. Must be called with $PWD already at $EA_DIR. -_asterisk_do_write_compose() { - cat > docker-compose.yml << 'EOF' -name: asterisk-do - -services: - asterisk: - build: . - container_name: easy-asterisk-do - network_mode: host - depends_on: - coturn: - condition: service_started - volumes: - - ./config/asterisk:/etc/asterisk - - ./config/easy-asterisk:/etc/easy-asterisk - - ./logs:/var/log/asterisk - - ./spool:/var/spool/asterisk - - ./lib:/var/lib/asterisk - - ./easy-asterisk.sh:/usr/local/bin/easy-asterisk:ro - - ./exports:/root -CADDY_VOLUME_PLACEHOLDER - env_file: .env - restart: unless-stopped - healthcheck: - test: ["CMD", "asterisk", "-rx", "core show version"] - interval: 30s - timeout: 5s - retries: 3 - - coturn: - image: coturn/coturn:latest - container_name: easy-asterisk-do-coturn - network_mode: host - user: root - entrypoint: ["/coturn-entrypoint.sh"] - volumes: - - ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro - env_file: .env - command: - - -n - - --listening-port=${TURN_PORT:-3478} - - --listening-ip=0.0.0.0 - - --fingerprint - - --lt-cred-mech - - --user=${TURN_USERNAME:-easyasterisk}:${TURN_PASSWORD} - - --realm=${DOMAIN_NAME:-localhost} - - --min-port=49152 - - --max-port=49252 - - --no-tls - - --no-dtls - - --no-cli - - --no-multicast-peers - - --log-file=stdout - restart: unless-stopped - -EOF - - # Share Caddy's cert store (read-only) so the entrypoint can auto-sync a - # real Let's Encrypt cert for DOMAIN_NAME instead of falling back to - # self-signed. No-op if Caddy isn't installed on this box. - if [[ -d "$DOCKER_DIR/caddy/data" ]]; then - sed -i "s#CADDY_VOLUME_PLACEHOLDER# - ${DOCKER_DIR}/caddy/data:/caddy-data:ro#" docker-compose.yml - else - sed -i "/CADDY_VOLUME_PLACEHOLDER/d" docker-compose.yml - fi -} - -install_asterisk-digital-ocean() { - require_docker || return 1 - log_info "Installing Easy Asterisk PBX + coturn (DigitalOcean droplet edition)..." - - local EA_DIR="$DOCKER_DIR/asterisk-digital-ocean" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would add a swapfile if RAM <= 2048MB and none exists" - echo "[DRY-RUN] Would create $EA_DIR with Dockerfile, docker-compose.yml, .env" - echo "[DRY-RUN] Would copy/download vendor files from easy-asterisk" - echo "[DRY-RUN] Would detect droplet public IP via DO metadata service" - echo "[DRY-RUN] Would scan for a free web admin port starting at 8081 (avoids e.g. CrowdSec's 8080)" - echo "[DRY-RUN] Would open UFW ports: 5060, 5061, , 8088, 8089, 3478, 10000-20000, 49152-49252" - echo "[DRY-RUN] Would offer to create a DigitalOcean Cloud Firewall via doctl" - echo "[DRY-RUN] Would reverse-proxy the web admin on the SAME FQDN used for SIP if Caddy is already installed (needed for cert sync)" - echo "[DRY-RUN] Would offer local OR remote Authelia to protect the web admin, if either is already available" - echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if $EA_DIR already exists" - echo "[DRY-RUN] Would offer optional ntfy alerts on extension registration going offline/online" - echo "[DRY-RUN] (checked every 2 minutes via systemd timer, cron.d fallback; always asked," - echo "[DRY-RUN] update mode included)" - echo "[DRY-RUN] Would patch vendor device-creation code + extensions.conf generator to route" - echo "[DRY-RUN] internal SIP MESSAGE through a dedicated [sip-messaging] dialplan context," - echo "[DRY-RUN] gated live on each sender's 'messaging' flag in pstn-permissions.conf (the" - echo "[DRY-RUN] same file/flag the Security Dashboard's checkbox writes) — independent of" - echo "[DRY-RUN] whether the PSTN trunk is installed; migrates any already-existing devices too" - echo "[DRY-RUN] Would offer to also set up the Security Dashboard and a PSTN trunk in this" - echo "[DRY-RUN] same run (calling services/security-dashboard.sh / services/pstn-trunk.sh" - echo "[DRY-RUN] directly — both stay independently invocable via their own service name too)" - return 0 - fi - - # ── Existing install? Offer update-in-place instead of a full reinstall ─── - # A fresh install re-runs every prompt (domain, extras, DO firewall, - # Authelia). An update only refreshes vendor files + docker-compose.yml — - # picking up fixes like this one — and rebuilds, without touching .env, - # UFW, the Cloud Firewall, or the Caddy/Authelia config already in place. - if [[ -f "$EA_DIR/docker-compose.yml" && -f "$EA_DIR/.env" ]]; then - echo "" - log_info "Existing install found at $EA_DIR." - local REINSTALL_MODE="" - prompt_reinstall_mode REINSTALL_MODE - case "$REINSTALL_MODE" in - update) - mkdir -p "$EA_DIR/config/asterisk" "$EA_DIR/config/easy-asterisk" \ - "$EA_DIR/logs" "$EA_DIR/spool" "$EA_DIR/lib" "$EA_DIR/exports" - ensure_docker_dir_ownership "$EA_DIR" - cd "$EA_DIR" || return 1 - - _asterisk_do_refresh_vendor_files - _asterisk_do_write_compose - _asterisk_do_write_logrotate "$EA_DIR" - _asterisk_do_patch_messaging_vendor_files "$EA_DIR" - _asterisk_do_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" - _asterisk_do_ensure_live_messaging_include "$EA_DIR" - _asterisk_do_migrate_existing_devices_message_context "$EA_DIR/config/asterisk/pjsip.conf" - ensure_docker_dir_ownership "$EA_DIR/config/asterisk" - chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" - - log_info "Rebuilding and restarting containers..." - if docker compose up -d --build --force-recreate; then - log_success "Update complete — vendor files and docker-compose.yml refreshed." - else - log_warning "docker compose up failed — check: docker compose -f $EA_DIR/docker-compose.yml logs" - fi - - _asterisk_do_run_presence_step "$EA_DIR" - _asterisk_do_offer_dashboard_and_trunk "$EA_DIR" - - local _EXISTING_DOMAIN _EXISTING_PORT - _EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .env | cut -d= -f2-)" - _EXISTING_PORT="$(grep -E '^WEB_ADMIN_PORT=' .env | cut -d= -f2-)" - echo "" - log_success "Existing .env, UFW rules, Cloud Firewall, and Caddy/Authelia config were left untouched." - if [[ -n "$_EXISTING_DOMAIN" ]]; then - echo " Web admin: https://${_EXISTING_DOMAIN}/" - else - echo " Web admin: http://:${_EXISTING_PORT:-8081}" - fi - echo " Logs: docker compose -f $EA_DIR/docker-compose.yml logs -f" - echo "" - return 0 - ;; - cancel) - log_info "Leaving the existing install as-is — nothing changed." - return 0 - ;; - fresh) - log_info "Proceeding with a full fresh reinstall — every prompt below runs from scratch." - ;; - esac - fi - - # ── Swap file (insurance for low-RAM droplets, e.g. the $4/mo 512MB plan) ── - # DigitalOcean doesn't provision swap by default. Docker + Asterisk + coturn - # fit in 512MB-1GB at idle with little headroom; a swapfile absorbs spikes - # (apt/image pulls, log bursts, a few concurrent calls) instead of the - # kernel OOM-killing a container or the box going unresponsive over SSH. - local TOTAL_RAM_MB - TOTAL_RAM_MB="$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)" - if [[ "$TOTAL_RAM_MB" -gt 0 && "$TOTAL_RAM_MB" -le 2048 ]] && ! swapon --show | grep -q .; then - local FREE_DISK_MB SWAP_MB=2048 - FREE_DISK_MB="$(df -Pm / | awk 'NR==2 {print $4}')" - if [[ "$FREE_DISK_MB" -gt $((SWAP_MB + 2048)) ]]; then - local ADD_SWAP="" - prompt_yn "No swap detected on this ${TOTAL_RAM_MB}MB-RAM droplet — add a ${SWAP_MB}MB swapfile? (y/n):" "y" ADD_SWAP - if [[ "$ADD_SWAP" =~ ^[Yy]$ ]]; then - fallocate -l "${SWAP_MB}M" /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count="$SWAP_MB" status=none - chmod 600 /swapfile - mkswap /swapfile >/dev/null - swapon /swapfile - grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab - grep -q '^vm.swappiness' /etc/sysctl.conf 2>/dev/null || echo 'vm.swappiness=10' >> /etc/sysctl.conf - sysctl -w vm.swappiness=10 >/dev/null 2>&1 - log_success "Swapfile enabled (${SWAP_MB}MB, swappiness=10, persists across reboots)." - fi - else - log_warning "Not enough free disk for a safe swapfile (${FREE_DISK_MB}MB free) — skipping." - log_warning "Consider a bigger droplet, or free up disk before installing." - fi - fi - - mkdir -p "$EA_DIR" - mkdir -p "$EA_DIR/config/asterisk" "$EA_DIR/config/easy-asterisk" \ - "$EA_DIR/logs" "$EA_DIR/spool" "$EA_DIR/lib" "$EA_DIR/exports" - ensure_docker_dir_ownership "$EA_DIR" - cd "$EA_DIR" || return 1 - - _asterisk_do_refresh_vendor_files - _asterisk_do_write_logrotate "$EA_DIR" - _asterisk_do_patch_messaging_vendor_files "$EA_DIR" - _asterisk_do_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" - _asterisk_do_ensure_live_messaging_include "$EA_DIR" - ensure_docker_dir_ownership "$EA_DIR/config/asterisk" - chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" - - # ── DigitalOcean droplet detection ──────────────────────────────────────── - # A droplet's own public IP/ID are readable, unauthenticated, from the - # link-local metadata service — no API token needed for this part. - echo "" - log_info "Reading DigitalOcean droplet metadata..." - local DO_META="http://169.254.169.254/metadata/v1" - local DROPLET_ID PUBLIC_IP - DROPLET_ID="$(curl -fsS --max-time 2 "$DO_META/id" 2>/dev/null || true)" - PUBLIC_IP="$(curl -fsS --max-time 2 "$DO_META/interfaces/public/0/ipv4/address" 2>/dev/null || true)" - [[ -z "$PUBLIC_IP" ]] && PUBLIC_IP="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)" - [[ -z "$PUBLIC_IP" ]] && PUBLIC_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" - - if [[ -n "$DROPLET_ID" ]]; then - log_success "Detected DigitalOcean droplet id $DROPLET_ID, public IP ${PUBLIC_IP:-unknown}" - else - log_warning "DigitalOcean metadata service not reachable (not a droplet, or run in a container)." - log_warning "Continuing anyway — Cloud Firewall automation will be skipped." - fi - - # ── Domain (always public — this is a cloud box) ────────────────────────── - echo "" - echo " Point a DNS A record at this droplet before continuing:" - echo " .${SITE_DOMAIN:-example.com} A ${PUBLIC_IP:-}" - echo "" - echo " This one FQDN covers everything below — SIP registration, the web" - echo " admin, and (via Caddy) the TLS cert Asterisk needs for SIP. There's" - echo " no separate \"admin domain\" to pick later — whatever you enter here" - echo " is what your SIP client (e.g. Sipnetic) will register against." - local DOMAIN_NAME="" - prompt_text "FQDN for this PBX, e.g. sip.yourdomain.com [blank=self-signed cert, IP-only access]:" "" DOMAIN_NAME - [[ -z "$DOMAIN_NAME" ]] && log_warning "No FQDN entered — using a self-signed cert; phones must trust it manually." - - # ── Secrets ─────────────────────────────────────────────────────────────── - local TURN_PASSWORD - TURN_PASSWORD="$(generate_password 24)" - - # Unlike the LAN edition, a droplet is always reachable — TURN always has - # a usable address (the FQDN if set, otherwise the droplet's public IP). - local TURN_SERVER_VAL="${DOMAIN_NAME:-$PUBLIC_IP}:3478" - - _asterisk_do_write_compose - - # ── Pick a free port for the web admin ───────────────────────────────────── - # Hardcoding a single number gets fragile fast once several services share - # a host — CrowdSec's own LAPI already collides with 8080 by default (its - # own upstream default, confirmed against its real config.yaml). Scan - # instead: start at 8081 and take the first port nothing is listening on, - # capped so a pathological box can't spin this forever. - local WEB_ADMIN_PORT_VAL=8081 - local _port_scan_limit=$((WEB_ADMIN_PORT_VAL + 100)) - while ss -tlnH "sport = :${WEB_ADMIN_PORT_VAL}" 2>/dev/null | grep -q . \ - && [[ "$WEB_ADMIN_PORT_VAL" -lt "$_port_scan_limit" ]]; do - WEB_ADMIN_PORT_VAL=$((WEB_ADMIN_PORT_VAL + 1)) - done - if [[ "$WEB_ADMIN_PORT_VAL" -ge "$_port_scan_limit" ]]; then - log_warning "No free port found in 8081-${_port_scan_limit} — falling back to 8081 anyway." - WEB_ADMIN_PORT_VAL=8081 - elif [[ "$WEB_ADMIN_PORT_VAL" != 8081 ]]; then - log_info "Port 8081 was already taken — web admin will use ${WEB_ADMIN_PORT_VAL} instead." - fi - - # ── .env ────────────────────────────────────────────────────────────────── - cat > .env << ENV -# ── Domain ──────────────────────────────────────────────────── -# Public FQDN for this droplet. Leave empty to fall back to a self-signed -# cert reachable at the droplet's public IP (${PUBLIC_IP:-unknown}). -DOMAIN_NAME=${DOMAIN_NAME} - -# ── TURN/STUN ───────────────────────────────────────────────── -TURN_USERNAME=easyasterisk -TURN_PASSWORD=${TURN_PASSWORD} -TURN_PORT=3478 -TURN_SERVER=${TURN_SERVER_VAL} - -# ── RTP port range ──────────────────────────────────────────── -RTP_START=10000 -RTP_END=20000 - -# ── VLAN/VPN subnets ────────────────────────────────────────── -# A droplet has one public NIC, so this is usually irrelevant. Only set it -# if you're bridging phones back in over a VPN (e.g. WireGuard/Tailscale) -# on a subnet the droplet isn't directly attached to. -HAS_VLANS=n -VLAN_SUBNETS= - -# ── Web admin ───────────────────────────────────────────────── -# Picked automatically at install time (first free port starting at 8081) — -# see WEB_ADMIN_PORT_VAL in services/asterisk-digital-ocean.sh if this ever needs to -# change again; don't hand-edit without also updating Caddy's Caddyfile and -# both firewall layers to match. -WEB_ADMIN_PORT=${WEB_ADMIN_PORT_VAL} -WEB_ADMIN_AUTH_DISABLED=false -ENV - chmod 600 .env - - # ── Caddy: reverse-proxy the web admin on the SAME FQDN used for SIP ────── - # Caddy only holds a cert for domains it's actively serving. If the web - # admin were proxied on a different "admin" subdomain, Caddy would obtain - # a cert for THAT domain instead — the sync earlier would never find one - # matching $DOMAIN_NAME, and SIP TLS would silently stay self-signed. So - # there's no separate domain prompt: this always targets $DOMAIN_NAME. - # - # Decided before the firewall rules below so they can be scoped - # correctly: if Caddy ends up fronting the web admin locally, there's no - # reason to also expose it directly to the internet — Caddy already - # reaches it over the host's internal network (host.docker.internal), - # and leaving the bare IP:port open would let anyone bypass Caddy/ - # Authelia entirely. - local WEB_ADMIN_PUBLIC_ACCESS_NEEDED=true - if [[ -z "$DOMAIN_NAME" ]]; then - log_info "No FQDN set — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL} (nothing for Caddy to do)." - elif [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -z "${CADDY_REMOTE_HOST:-}" ]]; then - log_info "Caddy not installed — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL}, SIP TLS stays self-signed." - else - local EXTRA_BLOCK="" - if [ -d "$DOCKER_DIR/authelia" ]; then - local _use_auth="" - prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth - if [[ "$_use_auth" =~ ^[Yy]$ ]]; then - EXTRA_BLOCK=" import authelia" - # Disable built-in auth since Authelia handles it - sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env - fi - else - # No local Authelia — offer one running elsewhere (e.g. a homelab). - # There's no shared "(authelia)" Caddy snippet to import in that - # case (authelia.sh only writes one when installing locally), so - # this builds the same forward_auth block inline, targeting the - # remote instance directly instead of the local "authelia:9091" - # container reference. - local _use_remote_auth="" - prompt_yn "Protect the web admin with a remote Authelia instance (e.g. on a homelab)? (y/n):" "n" _use_remote_auth - if [[ "$_use_remote_auth" =~ ^[Yy]$ ]]; then - local _remote_authelia="" - prompt_text " Remote Authelia address — a bare host:port over a private network (e.g. a NetBird mesh IP:9091), or a full https:// URL if it's on its own public domain+TLS:" "" _remote_authelia - if [[ -n "$_remote_authelia" ]]; then - # header_up lines are required here (unlike the local - # "authelia:9091" snippet in services/authelia.sh) because - # this upstream is reached over a second Caddy hop when - # given as a scheme-qualified URL (https://auth.example.com). - # Caddy rewrites the outgoing request's Host header to that - # upstream host so the remote Caddy can route/SNI-match it — - # and without an explicit override, X-Forwarded-Host picks up - # that rewritten value instead of the original site's host. - # Confirmed live: Authelia was evaluating every request as - # if it were for auth.example.com itself (which has - # policy: bypass in access_control.rules), so every domain - # silently passed through with no 2FA prompt regardless of - # its own policy. Pinning these to the original request's - # values fixes it regardless of hop count. - # - # X-Forwarded-Host uses a literal domain, NOT the {host} - # placeholder. Confirmed live: {host} still evaluated to - # the upstream's own hostname (auth.example.com) rather - # than the original site's — Caddy appears to rewrite the - # outgoing request's Host to the upstream target before - # header_up placeholders are resolved for a scheme- - # qualified upstream, so {host} echoes back the already- - # rewritten value instead of the original client-facing - # host. Since this site block only ever serves one domain - # (DOMAIN_NAME), hardcoding it sidesteps the ambiguity - # entirely instead of depending on Caddy's internal - # header-mutation ordering. - EXTRA_BLOCK=" forward_auth ${_remote_authelia} { - uri /api/authz/forward-auth - copy_headers Remote-User Remote-Groups Remote-Name Remote-Email - header_up X-Forwarded-Method {method} - header_up X-Forwarded-Proto {scheme} - header_up X-Forwarded-Host ${DOMAIN_NAME} - header_up X-Forwarded-Uri {uri} - }" - sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env - log_info "Using remote Authelia at ${_remote_authelia}." - log_info "Verify it's reachable from this droplet before relying on it — e.g.:" - log_info " curl -I ${_remote_authelia}" - else - log_info "No address entered — skipping Authelia protection." - fi - fi - fi - - # Deliberately NOT using configure_caddy_for_service here. That helper - # asks for its own domain, defaulting to ".${SITE_DOMAIN}" — - # which only lands on $DOMAIN_NAME if SITE_DOMAIN happens to be set to - # match, and silently shows a useless blank/wrong default otherwise - # (real-world confirmed: SITE_DOMAIN is never set when this service is - # run by name, e.g. `sudo ./setup.sh asterisk-digital-ocean`, since that skips - # setup.sh's own site-defaults wizard entirely). There is exactly one - # correct domain for this site block — $DOMAIN_NAME — so it's written - # directly, with no domain prompt to get wrong. - echo "" - local WANT_CADDY_PROXY="" - prompt_yn "Reverse-proxy the web admin at https://${DOMAIN_NAME}/ via Caddy? (also gets Asterisk a trusted TLS cert for SIP instead of self-signed) (y/n):" "y" WANT_CADDY_PROXY - if [[ "$WANT_CADDY_PROXY" =~ ^[Yy]$ ]]; then - local _CADDY_MODE="local" - [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -n "${CADDY_REMOTE_HOST:-}" ]] && _CADDY_MODE="remote" - - # Asterisk runs with network_mode: host, so whatever proxies to it - # needs a way to reach the host, not "localhost" (which resolves - # to the proxying container's own netns). A local Caddy container - # reaches the host via host.docker.internal (wired up in - # services/caddy.sh's compose file); a remote Caddy machine needs - # this droplet's actual public IP instead. - local _PROXY_TARGET="host.docker.internal:${WEB_ADMIN_PORT_VAL}" - [[ "$_CADDY_MODE" == "remote" ]] && _PROXY_TARGET="${PUBLIC_IP}:${WEB_ADMIN_PORT_VAL}" - - local _SITE_BLOCK - _SITE_BLOCK="$(cat << CADDY_BLOCK - -# Asterisk Web Admin -${DOMAIN_NAME} { - # Auth (if any) must come before reverse_proxy — forward_auth is the - # same directive family as reverse_proxy internally, and Caddy doesn't - # reorder repeats of the same directive within a block; it runs them in - # the order they're written. With reverse_proxy first, it would handle - # and terminate every request immediately, so an auth check written - # after it would be dead code that never runs — full bypass regardless - # of what the auth server's own rules say. -${EXTRA_BLOCK} - reverse_proxy ${_PROXY_TARGET} - - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - X-Content-Type-Options "nosniff" - X-Frame-Options "SAMEORIGIN" - Referrer-Policy "strict-origin-when-cross-origin" - } - - log { - output file /var/log/caddy/${DOMAIN_NAME}.log - format json - } -} -CADDY_BLOCK -)" - - if [[ "$_CADDY_MODE" == "local" ]]; then - # Caddy reaches this over the host's internal network — no - # need to keep the port open to the public internet. - WEB_ADMIN_PUBLIC_ACCESS_NEEDED=false - local _CADDYFILE="$DOCKER_DIR/caddy/Caddyfile" - local _CADDY_BACKUP="$_CADDYFILE.backup.$(date +%Y%m%d-%H%M%S)" - if [[ -f "$_CADDYFILE" ]]; then - cp "$_CADDYFILE" "$_CADDY_BACKUP" - else - touch "$_CADDYFILE" - fi - if grep -q "^${DOMAIN_NAME}" "$_CADDYFILE" 2>/dev/null; then - log_warning "${DOMAIN_NAME} already in Caddyfile — leaving the existing entry alone." - else - printf '%s\n' "$_SITE_BLOCK" >> "$_CADDYFILE" - log_success "Added ${DOMAIN_NAME} to Caddyfile (backup: $(basename "$_CADDY_BACKUP"))" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - # The template Caddyfile ships with "admin off", so - # `caddy reload` (which needs that same admin API) never - # actually works here. Try it anyway, fall back to a - # restart — confirmed necessary on a real deployment. - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "Web admin accessible at: https://${DOMAIN_NAME}" - elif docker restart caddy &>/dev/null; then - log_success "Caddy restarted to apply changes (reload API is disabled by default)" - log_success "Web admin should be accessible at: https://${DOMAIN_NAME}" - else - log_warning "Reload/restart failed — check: docker logs caddy" - log_info "Manual fix: docker restart caddy" - fi - fi - else - local _SNIPPET_DIR="$DOCKER_DIR/caddy-snippets" - mkdir -p "$_SNIPPET_DIR" - printf '%s\n' "$_SITE_BLOCK" > "$_SNIPPET_DIR/asterisk-digital-ocean.caddy" - chown "$ACTUAL_USER:$ACTUAL_USER" "$_SNIPPET_DIR/asterisk-digital-ocean.caddy" 2>/dev/null || true - log_success "Snippet saved: $_SNIPPET_DIR/asterisk-digital-ocean.caddy" - log_info "Copy to your Caddy machine: scp $_SNIPPET_DIR/asterisk-digital-ocean.caddy caddy-host:~/caddy-snippets/" - log_info "Remote Caddy reaches this droplet over its public IP, so the web admin port stays open below." - fi - fi - fi - - # ── UFW firewall rules (host-level) ─────────────────────────────────────── - if command -v ufw &>/dev/null; then - log_info "Opening UFW ports for Asterisk + coturn..." - ufw allow 5060/udp - ufw allow 5060/tcp - ufw allow 5061/tcp - if [[ "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" == true ]]; then - ufw allow "${WEB_ADMIN_PORT_VAL}/tcp" - else - ufw delete allow "${WEB_ADMIN_PORT_VAL}/tcp" 2>/dev/null || true - ufw_allow_from_caddy_net "${WEB_ADMIN_PORT_VAL}" - fi - ufw allow 8088/tcp - ufw allow 8089/tcp - ufw allow 3478/udp - ufw allow 3478/tcp - ufw allow 10000:20000/udp - ufw allow 49152:49252/udp - ensure_ufw_enabled - log_success "UFW rules added." - fi - - # ── DigitalOcean Cloud Firewall (network edge, in front of the droplet) ─── - local DO_FW_RULES=( - "protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0" - "protocol:tcp,ports:5060,address:0.0.0.0/0,address:::/0" - "protocol:udp,ports:5060,address:0.0.0.0/0,address:::/0" - "protocol:tcp,ports:5061,address:0.0.0.0/0,address:::/0" - ) - if [[ "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" == true ]]; then - DO_FW_RULES+=("protocol:tcp,ports:${WEB_ADMIN_PORT_VAL},address:0.0.0.0/0,address:::/0") - fi - DO_FW_RULES+=( - "protocol:tcp,ports:8088-8089,address:0.0.0.0/0,address:::/0" - "protocol:tcp,ports:3478,address:0.0.0.0/0,address:::/0" - "protocol:udp,ports:3478,address:0.0.0.0/0,address:::/0" - "protocol:udp,ports:10000-20000,address:0.0.0.0/0,address:::/0" - "protocol:udp,ports:49152-49252,address:0.0.0.0/0,address:::/0" - ) - - echo "" - if [[ -n "$DROPLET_ID" ]] && command -v doctl &>/dev/null && doctl account get &>/dev/null; then - local EXISTING_FW - EXISTING_FW="$(doctl compute firewall list --format ID,DropletIDs --no-header 2>/dev/null \ - | grep -E "(^|[, ])${DROPLET_ID}([, ]|\$)" | awk '{print $1}' | head -1)" - - if [[ -n "$EXISTING_FW" ]]; then - log_warning "A Cloud Firewall (id $EXISTING_FW) is already attached to this droplet — not touching it." - log_warning "Add these inbound rules to it yourself (Networking → Firewalls in the DO console):" - printf ' %s\n' "${DO_FW_RULES[@]}" - else - local DO_FW="" - prompt_yn "Create a DigitalOcean Cloud Firewall for this droplet via doctl now? (y/n):" "y" DO_FW - if [[ "$DO_FW" =~ ^[Yy]$ ]]; then - if doctl compute firewall create \ - --name "asterisk-digital-ocean" \ - --droplet-ids "$DROPLET_ID" \ - --inbound-rules "$(IFS=' '; echo "${DO_FW_RULES[*]}")" \ - --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0 protocol:icmp,ports:0,address:0.0.0.0/0,address:::/0" \ - &>/dev/null; then - log_success "Cloud Firewall 'asterisk-digital-ocean' created and attached (SSH/22 included so you don't get locked out)." - log_info "Verify it in the DO console — adjust the SSH rule if you use a non-default SSH port." - else - log_warning "doctl firewall create failed — add the rules manually (see README)." - fi - fi - fi - else - log_info "doctl not installed/authenticated — configure a DigitalOcean Cloud Firewall manually:" - log_info "Control Panel → Networking → Firewalls → create, attach to this droplet, allow:" - printf ' %s\n' "${DO_FW_RULES[@]}" - fi - - # ── CrowdSec note ────────────────────────────────────────────────────────── - # Not installed here — select it separately from the whiptail menu, or - # `sudo ./setup.sh crowdsec`. Its own installer (services/crowdsec.sh) - # auto-detects an asterisk-digital-ocean install and wires up SIP - # brute-force protection on its own, in either install order. - if command -v cscli &>/dev/null; then - log_info "CrowdSec is already installed — rerun it to pick up SIP protection for this install:" - log_info " sudo ./setup.sh crowdsec" - else - log_info "CrowdSec not installed. Recommended for SSH + SIP intrusion prevention on a public" - log_info "droplet — install it separately (whiptail menu, or 'sudo ./setup.sh crowdsec')." - log_info "It auto-detects this asterisk-digital-ocean install and wires up SIP protection on its own." - fi - - # ── Extension presence (online/offline) ntfy alerts ──────────────────────── - _asterisk_do_run_presence_step "$EA_DIR" - - # ── README ──────────────────────────────────────────────────────────────── - write_readme "$EA_DIR" << MD -# Easy Asterisk PBX + coturn — DigitalOcean droplet edition - -Self-hosted SIP PBX using Easy Asterisk with a coturn TURN/STUN server for -NAT traversal, sized and secured for a public DigitalOcean droplet. For a -home/LAN box with VLAN support, use \`~/docker/asterisk\` (services/asterisk.sh) -instead. - -## Droplet sizing - -Asterisk + coturn is light for a handful of SIP extensions and personal use. - -| Plan | vCPU | RAM | Good for | -|--------------------------------|------|-------|----------------------------------------| -| Basic (regular), \$4/mo | 1 | 512 MB | Works — this installer adds a 2GB swapfile automatically to cover it. Fine for a couple of extensions and light personal use. | -| **Basic (regular), \$6/mo — recommended** | 1 | 1 GB | More headroom, still gets an automatic swapfile | -| Basic (regular), \$12/mo | 1 | 2 GB | Comfortable — no swap needed, a handful of concurrent calls | -| Basic (regular), \$24/mo | 2 | 4 GB | Several simultaneous calls, conference bridges, transcoding | - -10 GB SSD (the \$4/mo plan's disk) is enough — this stack isn't storage-heavy, -and the swapfile only takes 2GB of it. Any DO region close to where the -phones actually are is fine; SIP/RTP care about latency more than raw -bandwidth. - -**Swap:** DigitalOcean doesn't provision swap by default, and Docker + -Asterisk + coturn leave little headroom at 512MB–1GB RAM. This installer -detects RAM ≤2GB with no existing swap and offers to add a 2GB swapfile -automatically (persisted in \`/etc/fstab\`) — it's what makes the \$4/mo plan -viable instead of risking an OOM kill under load. - -**OS image:** Ubuntu 24.04 LTS (supported through April 2029) is the safe, -battle-tested choice for Docker + coturn. Ubuntu 26.04 LTS is also available -and supported longer (through 2031) if you'd rather track the newer LTS. - -## DNS - -Before running this installer, point an A record at the droplet's public IP: - -\`\`\` -sip.yourdomain.com A -\`\`\` - -The installer reads the droplet's public IP itself (via the DigitalOcean -metadata service) and shows it to you during setup. This one FQDN is used -for SIP, the web admin, and the TLS cert — there's no separate domain to -plan for the admin panel. - -## Security - -- **SSH:** key-based auth only, password login disabled — \`services/base.sh\` - in this repo offers to do this for you on first run. Don't skip it; this - box is public. -- **Two firewall layers, same rule set:** - - **DigitalOcean Cloud Firewall** — filters at the network edge, before - traffic reaches the droplet. This installer offers to create one - automatically via \`doctl\` (only if none is already attached to this - droplet — it never overwrites an existing one, to avoid clobbering a - custom SSH allow-list). If \`doctl\` isn't set up, add the rules below - manually in the DO console (Networking → Firewalls). - - **UFW** — host-level, configured automatically by this installer as a - second layer. Keep both in sync; don't let them contradict each other. -- **CrowdSec** — SIP brute-force/enumeration protection (\`crowdsecurity/asterisk\` - collection). Not installed by this script — install it separately (whiptail - menu, or \`sudo ./setup.sh crowdsec\`); its own installer auto-detects this - asterisk-digital-ocean install and wires up SIP protection regardless of install order. -- DO's paid Droplet Backups, or \`services/borg-backup.sh\` installed - separately, are both options for a rollback path. - -### Ports (open on both the Cloud Firewall and UFW) - -| Port | Protocol | Purpose | -|---------------|----------|-----------------------------------| -| 22 | TCP | SSH (keep this open or you're locked out) | -| 5060 | UDP/TCP | SIP signalling (unencrypted) | -| 5061 | TCP | SIP over TLS | -| ${WEB_ADMIN_PORT_VAL} | TCP | Easy Asterisk web admin (auto-picked — see \`.env\`). Only opened publicly if Caddy isn't fronting it locally — otherwise it's reachable only via \`https://${DOMAIN_NAME:-your-domain}/\`, not the bare IP:port. | -| 8088/8089 | TCP | Asterisk HTTP/WS (ARI/AMI) | -| 3478 | UDP/TCP | TURN/STUN (coturn) | -| 10000–20000 | UDP | RTP media streams | -| 49152–49252 | UDP | TURN relay media ports | - -## Internal SIP messaging (no PSTN trunk needed) - -Every extension can send/receive Asterisk's native SIP MESSAGE (no carrier -SMS, no PSTN, no cost) once its "messaging" flag is set to yes in -\`pstn-permissions.conf\` — via the Security Dashboard's "Internal SIP -messaging" card, or by hand. This works independent of \`pstn-trunk.sh\` -entirely. Under the hood: every device endpoint gets -\`message_context=sip-messaging\`, routing messages to a dedicated -\`config/asterisk/messaging-dialplan.conf\` context instead of \`[intercom]\` -(which already owns per-device call routing) — this install/update patches -both the device-creation code (so new extensions pick it up automatically) -and any devices that already existed. Confirmed against a live install's -\`pjsip.conf\`/\`extensions.conf\` on 2026-07-23 (message_context falls back to -context=intercom, one exact-match dialplan entry per device) — the MESSAGE -sender-extraction logic itself is still unconfirmed against real traffic; -if messages silently don't arrive, check -\`docker exec easy-asterisk-do asterisk -rx "core set verbose 3"\` while -sending one. - -## Extension presence (online/offline) alerts - -Optional ntfy alert when an extension's SIP registration changes state — -offered on both fresh install and "update in place". Checked every 2 -minutes (systemd timer, cron.d fallback); fires only on a change, never on -every check. - -## Other services (installed separately, not by this script) - -This installer only sets up Asterisk + coturn. Everything else — Caddy, -CrowdSec, Authelia, ntfy, watchtower, wg-easy, NetBird, Borg backup — is a -normal service in this repo: pick it from the whiptail menu, or run -\`sudo ./setup.sh \` directly. A few integrate automatically with this -install if already present, no extra config needed: - -- **Caddy** — if installed (locally, or you're on a remote-Caddy setup), this - installer reverse-proxies the web admin on \`DOMAIN_NAME\` and Asterisk syncs - the resulting Let's Encrypt cert for SIP-TLS too. Not installed → self-signed - cert, plain HTTP admin. -- **Authelia** — if installed locally (needs Caddy), or you point this - installer at a remote instance (e.g. a homelab, via NetBird mesh IP or a - public \`https://\` URL), the web admin gets SSO/2FA in front of it. -- **CrowdSec** — see Security above; wires up SIP protection automatically - once installed, regardless of whether it went in before or after this. - -## Manage - -\`\`\`bash -docker compose up -d --build # build image and start -docker compose up -d # start (after initial build) -docker compose down # stop -docker compose logs -f # follow logs -docker compose pull # update coturn image -docker compose up -d --build # rebuild asterisk image -\`\`\` - -## Management script - -\`\`\`bash -docker exec -it easy-asterisk-do easy-asterisk --help -\`\`\` - -Use it to create SIP extensions (Server Settings → Extensions) before -connecting a phone. - -## Connecting with Sipnetic (Android) - -[Sipnetic](https://www.sipnetic.com/) is a free Android SIP client with -TLS/SRTP and STUN/TURN/ICE support — a good fit for this setup. (iPhone -users: Linphone or Zoiper cover the same ground.) - -1. In the Easy Asterisk web admin, create an extension — note its - username/number and password. -2. In Sipnetic, add an account with: - -| Setting | Value | -|-------------------|------------------------------------------------| -| Username | extension number/username from easy-asterisk | -| Password | extension password from easy-asterisk | -| Domain | \`${DOMAIN_NAME:-$PUBLIC_IP}\` | -| Transport | TLS | -| Port | 5061 | -| SRTP | Enabled (optional, for encrypted media) | -| STUN/TURN server | \`${DOMAIN_NAME:-$PUBLIC_IP}:3478\` | -| TURN username | \`easyasterisk\` (see \`.env\` → \`TURN_USERNAME\`) | -| TURN password | see \`.env\` → \`TURN_PASSWORD\` | - -3. Save and let it register. If it registers but calls connect with no - audio, double-check the RTP/TURN port ranges are open on *both* firewall - layers above. - -## TLS certificate - -Caddy is what actually talks to Let's Encrypt — Asterisk never does ACME -itself. The installer always reverse-proxies the web admin on the exact -same FQDN used for SIP (never a separate "admin" domain), specifically -because that's what makes Caddy hold a cert matching \`DOMAIN_NAME\`. The -container then mounts Caddy's cert store read-only and the entrypoint syncs -that cert in automatically on every start — and re-checks every 12h so -renewals get picked up without a restart. No Caddy on the box, or no FQDN -set at all, falls back to a self-signed cert (phones must be configured to -accept it manually). - -## Web admin - -Access the Easy Asterisk web interface at http://:${WEB_ADMIN_PORT_VAL} -or via your configured reverse-proxy domain. - -## Data directories (all inside ~/docker/asterisk-digital-ocean/, included in backup) - -| Directory | Contents | -|-----------------------|----------------------------------| -| config/asterisk/ | /etc/asterisk — dialplan, SIP | -| config/easy-asterisk/ | /etc/easy-asterisk — web config | -| logs/ | /var/log/asterisk | -| spool/ | /var/spool/asterisk | -| lib/ | /var/lib/asterisk | -MD - - # ── Start ───────────────────────────────────────────────────────────────── - echo "" - local START_NOW="" - prompt_yn "Build and start Asterisk now? (y/n):" "y" START_NOW - if [ "$START_NOW" = "y" ] || [ "$START_NOW" = "Y" ]; then - docker compose up -d --build \ - && log_success "Easy Asterisk (DO edition) started" \ - || log_warning "Start failed — check: docker compose logs" - fi - - _asterisk_do_offer_dashboard_and_trunk "$EA_DIR" - - # ── Summary ─────────────────────────────────────────────────────────────── - echo "" - log_success "Easy Asterisk (DigitalOcean edition) installed at $EA_DIR" - if [[ -n "$DOMAIN_NAME" ]]; then - echo " Mode: FQDN ($DOMAIN_NAME)" - echo " TURN server: ${DOMAIN_NAME}:3478" - else - echo " Mode: IP-only (self-signed cert)" - echo " TURN server: ${PUBLIC_IP:-unknown}:3478" - fi - echo " Public IP: ${PUBLIC_IP:-unknown}" - echo " SIP port: 5061 (TLS) / 5060 (UDP)" - echo " Web admin: http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL}" - echo " Manage: docker compose -f $EA_DIR/docker-compose.yml " - echo " Script: docker exec -it easy-asterisk-do easy-asterisk --help" - if [[ -n "$DOMAIN_NAME" ]] && [[ -d "$DOCKER_DIR/caddy" ]]; then - echo "" - log_info "If Caddy was just installed in this same run, it may still be obtaining the" - log_info "Let's Encrypt cert for ${DOMAIN_NAME} — Asterisk only checks for it at startup" - log_info "and then every 12h. If SIP TLS still shows self-signed after a couple of" - log_info "minutes, pick it up immediately with:" - log_info " docker compose -f $EA_DIR/docker-compose.yml restart asterisk" - fi - echo "" -} - -# Run immediately when executed directly (deferred until after function definition) -[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_asterisk-digital-ocean diff --git a/services/asterisk.sh b/services/asterisk.sh index ad5f4fb..019ea2b 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -2,6 +2,20 @@ # services/asterisk.sh — Easy Asterisk PBX + coturn TURN server (home intercom/VoIP). # Part of the modular post-install system (sourced by setup.sh). # +# One installer for both deployment shapes. It detects a DigitalOcean droplet +# (via the link-local metadata service, with a y/n fallback if that's blocked) +# and, in droplet mode, swaps in the public-cloud specifics: a swapfile for +# low-RAM plans, a public-FQDN-only flow with no LAN/VLAN prompts, a Caddy +# site block pinned to that one FQDN, an optional remote Authelia, and a +# DigitalOcean Cloud Firewall via doctl. Everything else — vendor files, +# compose, messaging dialplan, presence alerts, UFW, log rotation — is +# identical either way. +# +# This used to be two services (services/asterisk-digital-ocean.sh held a +# near-duplicate copy of the whole file). An existing droplet install at +# ~/docker/asterisk-digital-ocean is detected and kept in place, container +# names included, so the merge doesn't strand it. +# # Can also be run standalone on any machine: # sudo bash asterisk.sh # (Docker must already be installed when run standalone) @@ -218,12 +232,83 @@ CBLOCK fi # ───────────────────────────────────────────────────────────────────────────── -register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (home intercom/VoIP)" 5061 +register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (intercom/VoIP; auto-tunes for a DigitalOcean droplet)" 5061 + +# ── Install layout: directory + container names ──────────────────────────── +# Sets ASTERISK_DIR / ASTERISK_CONTAINER / ASTERISK_COTURN / ASTERISK_PROJECT. +# +# New installs always land in ~/docker/asterisk with the plain container +# names, droplet or not — the DigitalOcean specifics are behaviour, not a +# separate install. But boxes provisioned by the old, separate +# services/asterisk-digital-ocean.sh have a live install at +# ~/docker/asterisk-digital-ocean running containers named easy-asterisk-do / +# easy-asterisk-do-coturn, with a Caddyfile block, UFW rules, a Cloud +# Firewall, CrowdSec acquisition and a PSTN trunk all pointing at those exact +# paths and names. Renaming any of that from under a running deployment would +# break every one of those references at once, so an existing legacy install +# is detected and kept exactly as it is; only new installs get the unified +# naming. Every sibling service in this repo (pstn-trunk, security-dashboard, +# crowdsec) already probes for both directories, so both layouts stay fully +# supported without further special-casing. +_asterisk_resolve_layout() { + if [[ -f "$DOCKER_DIR/asterisk-digital-ocean/docker-compose.yml" ]]; then + ASTERISK_DIR="$DOCKER_DIR/asterisk-digital-ocean" + ASTERISK_CONTAINER="easy-asterisk-do" + ASTERISK_COTURN="easy-asterisk-do-coturn" + ASTERISK_PROJECT="asterisk-do" + else + ASTERISK_DIR="$DOCKER_DIR/asterisk" + ASTERISK_CONTAINER="easy-asterisk" + ASTERISK_COTURN="easy-asterisk-coturn" + ASTERISK_PROJECT="asterisk" + fi +} + +# ── DigitalOcean droplet detection ───────────────────────────────────────── +# Sets IS_DO (true/false), DROPLET_ID and PUBLIC_IP. +# +# A droplet's own id/public IP are readable, unauthenticated, from the +# link-local metadata service — no API token needed for this part. The +# metadata service isn't always reachable (a container, a firewalled +# 169.254.0.0/16, a non-DO cloud that still wants the same public-IP +# treatment), so a miss falls back to asking rather than silently deciding +# for the user. Droplet mode is what gates the swapfile, the public-FQDN-only +# flow, and the Cloud Firewall step further down. +_asterisk_detect_digitalocean() { + local _meta="http://169.254.169.254/metadata/v1" + DROPLET_ID="$(curl -fsS --max-time 2 "$_meta/id" 2>/dev/null || true)" + PUBLIC_IP="$(curl -fsS --max-time 2 "$_meta/interfaces/public/0/ipv4/address" 2>/dev/null || true)" + + echo "" + local _answer="" + if [[ -n "$DROPLET_ID" ]]; then + [[ -z "$PUBLIC_IP" ]] && PUBLIC_IP="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)" + log_success "DigitalOcean droplet detected (id $DROPLET_ID, public IP ${PUBLIC_IP:-unknown})." + log_info "Droplet mode adds: swapfile for low-RAM plans, public-FQDN-only setup (no" + log_info "LAN/VLAN prompts), a Cloud Firewall via doctl, and a remote-Authelia option." + prompt_yn "Set this up as a public droplet? (n = treat it as a home/LAN box) (y/n):" "y" _answer + else + log_info "No DigitalOcean metadata service reachable — assuming a home/LAN box." + log_info "Answer y here anyway if this is a public cloud VM (droplet with metadata" + log_info "blocked, or another provider) that should get the public-IP treatment." + prompt_yn "Set this up as a public cloud box? (y/n):" "n" _answer + fi + + if [[ "$_answer" =~ ^[Yy]$ ]]; then + IS_DO=true + [[ -z "$PUBLIC_IP" ]] && PUBLIC_IP="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)" + [[ -z "$PUBLIC_IP" ]] && PUBLIC_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + [[ -z "$DROPLET_ID" ]] && log_warning "No droplet id — the Cloud Firewall step will print manual rules instead of using doctl." + else + IS_DO=false + DROPLET_ID="" + fi +} # ── Shared: vendor file refresh ──────────────────────────────────────────── # Called from both a fresh install and an "update in place" run, so a single # copy of this logic stays current for both instead of drifting apart. Must -# be called with $PWD already at $EA_DIR. +# be called with $PWD already at $ASTERISK_DIR. _asterisk_refresh_vendor_files() { mkdir -p docker scripts @@ -255,6 +340,50 @@ _asterisk_refresh_vendor_files() { chmod 755 ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh \ ./docker/entrypoint.sh ./docker/coturn-entrypoint.sh \ ./scripts/vpn-diagnostics.sh ./scripts/dns-whitelist.sh + + # Persist security-level logging to a file — vendor's logger.conf only + # sends the "security" level (auth failures, SIP brute-force attempts) to + # the console (Docker stdout), not a file CrowdSec/fail2ban can tail. + # Applies on every box, not just droplets: the Security Dashboard's + # Security Log tab and services/crowdsec.sh's Asterisk acquisition both + # read logs/full, and neither has anything to read without this patch. + if grep -q '^console => notice,warning,error,security$' ./docker/entrypoint.sh; then + sed -i '/^console => notice,warning,error,security$/a full => notice,warning,error,security' \ + ./docker/entrypoint.sh + else + log_warning "entrypoint.sh logger.conf template changed upstream — security events won't be logged to a file. Update the sed patch in this installer." + fi +} + +# ── Shared: log rotation for logs/full (unbounded otherwise) ────────────── +# Confirmed live: with no rotation, this file grew to 1.4GB in about 3 days +# on a busy box (SIP scanning noise is constant on the public internet) — +# a real disk-exhaustion risk on a small droplet, and separately made the +# Security Dashboard balloon to 600+MB RAM/GBs of swap reading it every 30s +# before that was fixed to only read a bounded tail (see +# services/security-dashboard.sh). copytruncate avoids needing to signal +# Asterisk to reopen its log file — it has a long-held file descriptor on +# this path and no reload mechanism this installer can reach from the host. +# +# Not droplet-only: a LAN box reachable from the internet (port-forwarded +# SIP) collects the same scanning noise, and the file is unbounded either +# way now that the security-level logging patch above applies everywhere. +_asterisk_write_logrotate() { + local _ea_dir="$1" + cat > /etc/logrotate.d/asterisk << LOGROTATE +$_ea_dir/logs/full { + size 100M + rotate 5 + compress + missingok + notifempty + copytruncate +} +LOGROTATE + # Supersedes the config the old separate droplet installer wrote. Left in + # place it would rotate the very same path a second time (both files can + # name the same log), so it goes when this one lands. + rm -f /etc/logrotate.d/asterisk-digital-ocean } # ── Shared: extension presence (online/offline) ntfy alerts ──────────────── @@ -266,7 +395,7 @@ _asterisk_refresh_vendor_files() { # parsed defensively (grep for the Avail/Unavail keyword rather than a fixed # column position) specifically because it hasn't been confirmed against a # live install's actual output yet — run -# `docker exec easy-asterisk asterisk -rx "pjsip show contacts"` yourself +# `docker exec asterisk -rx "pjsip show contacts"` yourself # after enabling this to confirm extensions/status actually show up as # expected, same as any other not-yet-live-tested piece in this project. _asterisk_write_presence_alert_script() { @@ -442,7 +571,7 @@ _asterisk_patch_messaging_vendor_files() { # Patches the LIVE file directly instead, so it takes effect immediately # regardless of whether Easy Asterisk ever regenerates it on its own. _asterisk_ensure_live_messaging_include() { - local EA_DIR="$1" + local EA_DIR="$1" CONTAINER_NAME="$2" local EXT_LIVE="$EA_DIR/config/asterisk/extensions.conf" [[ -f "$EXT_LIVE" ]] || return 0 if ! grep -q 'messaging-dialplan.conf' "$EXT_LIVE"; then @@ -451,10 +580,10 @@ _asterisk_ensure_live_messaging_include() { log_success "Patched the messaging #include directly into the live extensions.conf." else log_warning "Couldn't find '[intercom]' in the live extensions.conf — add" - log_warning "'#include messaging-dialplan.conf' manually, then: docker exec easy-asterisk asterisk -rx \"dialplan reload\"" + log_warning "'#include messaging-dialplan.conf' manually, then: docker exec ${CONTAINER_NAME} asterisk -rx \"dialplan reload\"" fi fi - docker exec easy-asterisk asterisk -rx "dialplan reload" &>/dev/null || true + docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true } # One-time migration for devices that already existed before the patch above @@ -493,8 +622,8 @@ _asterisk_migrate_existing_devices_message_context() { # The actual enforcement — gated on the SENDER's own "messaging" flag in # pstn-permissions.conf (the exact file/flag the Security Dashboard's -# "Internal SIP messaging" checkbox writes, independent of whether the PSTN -# trunk is installed), read live via AST_CONFIG() on every message, same +# Messaging column writes, independent of whether the PSTN trunk is +# installed), read live via AST_CONFIG() on every message, same # mechanism pstn-trunk.sh's own dialplan already relies on for permission # tiers — no restart needed to take effect. Off by default: an extension # with no entry, or messaging=no, is denied. UNVERIFIED: MESSAGE(from)'s @@ -544,7 +673,7 @@ _asterisk_remove_presence_timer() { # international-calling step: this is a live-editable extra, not a # structural setting, so it doesn't belong exclusively to one path). _asterisk_run_presence_step() { - local EA_DIR="$1" + local EA_DIR="$1" CONTAINER_NAME="$2" local SETTINGS_FILE="$EA_DIR/.presence-alert.env" local STATE_FILE="$EA_DIR/.presence-alert.state" @@ -596,7 +725,7 @@ ENV return 0 fi - _asterisk_write_presence_alert_script "$EA_DIR/asterisk-presence-alert.sh" "easy-asterisk" "$PRESENCE_NTFY_URL" "$STATE_FILE" + _asterisk_write_presence_alert_script "$EA_DIR/asterisk-presence-alert.sh" "$CONTAINER_NAME" "$PRESENCE_NTFY_URL" "$STATE_FILE" _asterisk_install_presence_timer "$EA_DIR" cat > "$SETTINGS_FILE" << ENV @@ -638,7 +767,7 @@ _asterisk_offer_dashboard_and_trunk() { install_security-dashboard else local _WANT_DASH="" - prompt_yn "Set up the Security Dashboard (Security Log, Extensions, Asterisk Admin, PSTN Trunk, CrowdSec — one page)? (y/n):" "y" _WANT_DASH + prompt_yn "Set up the Security Dashboard (Security Log, Extensions, CrowdSec — one page)? (y/n):" "y" _WANT_DASH [[ "$_WANT_DASH" =~ ^[Yy]$ ]] && install_security-dashboard fi fi @@ -658,17 +787,23 @@ _asterisk_offer_dashboard_and_trunk() { # ── Shared: docker-compose.yml ───────────────────────────────────────────── # Same reasoning as above — one copy of the template used by both fresh -# installs and updates. Must be called with $PWD already at $EA_DIR. +# installs and updates. Must be called with $PWD already at the install dir. # HAS_VLANS_VAL/VLAN_SUBNETS_VAL aren't referenced here — they live only in # .env, which the entrypoint reads at container start. +# +# The heredoc stays quoted so ${TURN_PORT} and friends reach docker compose +# literally (it interpolates them from .env, this script must not). Project +# and container names are therefore substituted afterwards, same placeholder +# trick the Caddy volume line already uses below. _asterisk_write_compose() { + local PROJECT="$1" CONTAINER="$2" COTURN_CONTAINER="$3" cat > docker-compose.yml << 'EOF' -name: asterisk +name: PROJECT_NAME_PLACEHOLDER services: asterisk: build: . - container_name: easy-asterisk + container_name: ASTERISK_CONTAINER_PLACEHOLDER network_mode: host depends_on: coturn: @@ -692,7 +827,7 @@ CADDY_VOLUME_PLACEHOLDER coturn: image: coturn/coturn:latest - container_name: easy-asterisk-coturn + container_name: COTURN_CONTAINER_PLACEHOLDER network_mode: host user: root entrypoint: ["/coturn-entrypoint.sh"] @@ -718,6 +853,10 @@ CADDY_VOLUME_PLACEHOLDER EOF + sed -i "s#PROJECT_NAME_PLACEHOLDER#${PROJECT}#; \ + s#ASTERISK_CONTAINER_PLACEHOLDER#${CONTAINER}#; \ + s#COTURN_CONTAINER_PLACEHOLDER#${COTURN_CONTAINER}#" docker-compose.yml + # Share Caddy's cert store (read-only) so the entrypoint can auto-sync a # real Let's Encrypt cert for DOMAIN_NAME instead of falling back to # self-signed. No-op if Caddy isn't installed on this box. @@ -728,15 +867,541 @@ EOF fi } +# ── Shared: swapfile for low-RAM public cloud boxes ──────────────────────── +# DigitalOcean doesn't provision swap by default. Docker + Asterisk + coturn +# fit in 512MB-1GB at idle with little headroom; a swapfile absorbs spikes +# (apt/image pulls, log bursts, a few concurrent calls) instead of the +# kernel OOM-killing a container or the box going unresponsive over SSH. +_asterisk_offer_swapfile() { + local TOTAL_RAM_MB + TOTAL_RAM_MB="$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)" + [[ "$TOTAL_RAM_MB" -gt 0 && "$TOTAL_RAM_MB" -le 2048 ]] || return 0 + swapon --show | grep -q . && return 0 + + local FREE_DISK_MB SWAP_MB=2048 + FREE_DISK_MB="$(df -Pm / | awk 'NR==2 {print $4}')" + if [[ "$FREE_DISK_MB" -le $((SWAP_MB + 2048)) ]]; then + log_warning "Not enough free disk for a safe swapfile (${FREE_DISK_MB}MB free) — skipping." + log_warning "Consider a bigger box, or free up disk before installing." + return 0 + fi + + local ADD_SWAP="" + prompt_yn "No swap detected on this ${TOTAL_RAM_MB}MB-RAM box — add a ${SWAP_MB}MB swapfile? (y/n):" "y" ADD_SWAP + [[ "$ADD_SWAP" =~ ^[Yy]$ ]] || return 0 + + fallocate -l "${SWAP_MB}M" /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count="$SWAP_MB" status=none + chmod 600 /swapfile + mkswap /swapfile >/dev/null + swapon /swapfile + grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab + grep -q '^vm.swappiness' /etc/sysctl.conf 2>/dev/null || echo 'vm.swappiness=10' >> /etc/sysctl.conf + sysctl -w vm.swappiness=10 >/dev/null 2>&1 + log_success "Swapfile enabled (${SWAP_MB}MB, swappiness=10, persists across reboots)." +} + +# ── Droplet-mode Caddy: web admin on the SAME FQDN used for SIP ──────────── +# Deliberately NOT using configure_caddy_for_service in this mode. Caddy only +# holds a cert for domains it's actively serving, and Asterisk never does ACME +# itself — it mounts Caddy's cert store and copies the cert matching +# DOMAIN_NAME. Proxy the admin on a separate "admin" subdomain and Caddy +# obtains a cert for THAT name instead, the sync finds nothing matching +# DOMAIN_NAME, and SIP TLS silently stays self-signed. The helper would also +# prompt for its own domain, defaulting to ".${SITE_DOMAIN}" — +# which is blank or wrong whenever SITE_DOMAIN isn't set, i.e. every time +# this service is run by name (`sudo ./setup.sh asterisk` skips setup.sh's +# site-defaults wizard). There is exactly one correct domain here, so the +# site block is written directly with no domain prompt to get wrong. +# +# Sets WEB_ADMIN_PUBLIC_ACCESS_NEEDED (out-param) so the firewall steps below +# know whether the bare IP:port still has to be reachable. +_asterisk_configure_caddy_public() { + local DOMAIN_NAME="$1" WEB_ADMIN_PORT_VAL="$2" PUBLIC_IP="$3" + + WEB_ADMIN_PUBLIC_ACCESS_NEEDED=true + + if [[ -z "$DOMAIN_NAME" ]]; then + log_info "No FQDN set — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL} (nothing for Caddy to do)." + return 0 + fi + if [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -z "${CADDY_REMOTE_HOST:-}" ]]; then + log_info "Caddy not installed — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL}, SIP TLS stays self-signed." + return 0 + fi + + local EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth + if [[ "$_use_auth" =~ ^[Yy]$ ]]; then + EXTRA_BLOCK=" import authelia" + # Disable built-in auth since Authelia handles it + sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env + fi + else + # No local Authelia — offer one running elsewhere (e.g. a homelab). + # There's no shared "(authelia)" Caddy snippet to import in that + # case (authelia.sh only writes one when installing locally), so + # this builds the same forward_auth block inline, targeting the + # remote instance directly instead of the local "authelia:9091" + # container reference. + local _use_remote_auth="" + prompt_yn "Protect the web admin with a remote Authelia instance (e.g. on a homelab)? (y/n):" "n" _use_remote_auth + if [[ "$_use_remote_auth" =~ ^[Yy]$ ]]; then + local _remote_authelia="" + prompt_text " Remote Authelia address — a bare host:port over a private network (e.g. a NetBird mesh IP:9091), or a full https:// URL if it's on its own public domain+TLS:" "" _remote_authelia + if [[ -n "$_remote_authelia" ]]; then + # header_up lines are required here (unlike the local + # "authelia:9091" snippet in services/authelia.sh) because + # this upstream is reached over a second Caddy hop when + # given as a scheme-qualified URL (https://auth.example.com). + # Caddy rewrites the outgoing request's Host header to that + # upstream host so the remote Caddy can route/SNI-match it — + # and without an explicit override, X-Forwarded-Host picks up + # that rewritten value instead of the original site's host. + # Confirmed live: Authelia was evaluating every request as + # if it were for auth.example.com itself (which has + # policy: bypass in access_control.rules), so every domain + # silently passed through with no 2FA prompt regardless of + # its own policy. Pinning these to the original request's + # values fixes it regardless of hop count. + # + # X-Forwarded-Host uses a literal domain, NOT the {host} + # placeholder. Confirmed live: {host} still evaluated to + # the upstream's own hostname (auth.example.com) rather + # than the original site's — Caddy appears to rewrite the + # outgoing request's Host to the upstream target before + # header_up placeholders are resolved for a scheme- + # qualified upstream, so {host} echoes back the already- + # rewritten value instead of the original client-facing + # host. Since this site block only ever serves one domain + # (DOMAIN_NAME), hardcoding it sidesteps the ambiguity + # entirely instead of depending on Caddy's internal + # header-mutation ordering. + EXTRA_BLOCK=" forward_auth ${_remote_authelia} { + uri /api/authz/forward-auth + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + header_up X-Forwarded-Method {method} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host ${DOMAIN_NAME} + header_up X-Forwarded-Uri {uri} + }" + sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env + log_info "Using remote Authelia at ${_remote_authelia}." + log_info "Verify it's reachable from this box before relying on it — e.g.:" + log_info " curl -I ${_remote_authelia}" + else + log_info "No address entered — skipping Authelia protection." + fi + fi + fi + + echo "" + local WANT_CADDY_PROXY="" + prompt_yn "Reverse-proxy the web admin at https://${DOMAIN_NAME}/ via Caddy? (also gets Asterisk a trusted TLS cert for SIP instead of self-signed) (y/n):" "y" WANT_CADDY_PROXY + [[ "$WANT_CADDY_PROXY" =~ ^[Yy]$ ]] || return 0 + + local _CADDY_MODE="local" + [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -n "${CADDY_REMOTE_HOST:-}" ]] && _CADDY_MODE="remote" + + # Asterisk runs with network_mode: host, so whatever proxies to it + # needs a way to reach the host, not "localhost" (which resolves + # to the proxying container's own netns). A local Caddy container + # reaches the host via host.docker.internal (wired up in + # services/caddy.sh's compose file); a remote Caddy machine needs + # this box's actual public IP instead. + local _PROXY_TARGET="host.docker.internal:${WEB_ADMIN_PORT_VAL}" + [[ "$_CADDY_MODE" == "remote" ]] && _PROXY_TARGET="${PUBLIC_IP}:${WEB_ADMIN_PORT_VAL}" + + local _SITE_BLOCK + _SITE_BLOCK="$(cat << CADDY_BLOCK + +# Asterisk Web Admin +${DOMAIN_NAME} { + # Auth (if any) must come before reverse_proxy — forward_auth is the + # same directive family as reverse_proxy internally, and Caddy doesn't + # reorder repeats of the same directive within a block; it runs them in + # the order they're written. With reverse_proxy first, it would handle + # and terminate every request immediately, so an auth check written + # after it would be dead code that never runs — full bypass regardless + # of what the auth server's own rules say. +${EXTRA_BLOCK} + reverse_proxy ${_PROXY_TARGET} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${DOMAIN_NAME}.log + format json + } +} +CADDY_BLOCK +)" + + if [[ "$_CADDY_MODE" == "local" ]]; then + # Caddy reaches this over the host's internal network — no + # need to keep the port open to the public internet. + WEB_ADMIN_PUBLIC_ACCESS_NEEDED=false + local _CADDYFILE="$DOCKER_DIR/caddy/Caddyfile" + local _CADDY_BACKUP="$_CADDYFILE.backup.$(date +%Y%m%d-%H%M%S)" + if [[ -f "$_CADDYFILE" ]]; then + cp "$_CADDYFILE" "$_CADDY_BACKUP" + else + touch "$_CADDYFILE" + fi + if grep -q "^${DOMAIN_NAME}" "$_CADDYFILE" 2>/dev/null; then + log_warning "${DOMAIN_NAME} already in Caddyfile — leaving the existing entry alone." + else + printf '%s\n' "$_SITE_BLOCK" >> "$_CADDYFILE" + log_success "Added ${DOMAIN_NAME} to Caddyfile (backup: $(basename "$_CADDY_BACKUP"))" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + # The template Caddyfile ships with "admin off", so + # `caddy reload` (which needs that same admin API) never + # actually works here. Try it anyway, fall back to a + # restart — confirmed necessary on a real deployment. + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "Web admin accessible at: https://${DOMAIN_NAME}" + elif docker restart caddy &>/dev/null; then + log_success "Caddy restarted to apply changes (reload API is disabled by default)" + log_success "Web admin should be accessible at: https://${DOMAIN_NAME}" + else + log_warning "Reload/restart failed — check: docker logs caddy" + log_info "Manual fix: docker restart caddy" + fi + fi + else + local _SNIPPET_DIR="$DOCKER_DIR/caddy-snippets" + mkdir -p "$_SNIPPET_DIR" + printf '%s\n' "$_SITE_BLOCK" > "$_SNIPPET_DIR/asterisk.caddy" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_SNIPPET_DIR/asterisk.caddy" 2>/dev/null || true + log_success "Snippet saved: $_SNIPPET_DIR/asterisk.caddy" + log_info "Copy to your Caddy machine: scp $_SNIPPET_DIR/asterisk.caddy caddy-host:~/caddy-snippets/" + log_info "Remote Caddy reaches this box over its public IP, so the web admin port stays open below." + fi +} + +# ── DigitalOcean Cloud Firewall (network edge, in front of the droplet) ──── +_asterisk_configure_do_cloud_firewall() { + local DROPLET_ID="$1" WEB_ADMIN_PORT_VAL="$2" WEB_ADMIN_PUBLIC="$3" + + local DO_FW_RULES=( + "protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0" + "protocol:tcp,ports:5060,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:5060,address:0.0.0.0/0,address:::/0" + "protocol:tcp,ports:5061,address:0.0.0.0/0,address:::/0" + ) + if [[ "$WEB_ADMIN_PUBLIC" == true ]]; then + DO_FW_RULES+=("protocol:tcp,ports:${WEB_ADMIN_PORT_VAL},address:0.0.0.0/0,address:::/0") + fi + DO_FW_RULES+=( + "protocol:tcp,ports:8088-8089,address:0.0.0.0/0,address:::/0" + "protocol:tcp,ports:3478,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:3478,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:10000-20000,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:49152-49252,address:0.0.0.0/0,address:::/0" + ) + + echo "" + if [[ -n "$DROPLET_ID" ]] && command -v doctl &>/dev/null && doctl account get &>/dev/null; then + local EXISTING_FW + EXISTING_FW="$(doctl compute firewall list --format ID,DropletIDs --no-header 2>/dev/null \ + | grep -E "(^|[, ])${DROPLET_ID}([, ]|\$)" | awk '{print $1}' | head -1)" + + if [[ -n "$EXISTING_FW" ]]; then + log_warning "A Cloud Firewall (id $EXISTING_FW) is already attached to this droplet — not touching it." + log_warning "Add these inbound rules to it yourself (Networking → Firewalls in the DO console):" + printf ' %s\n' "${DO_FW_RULES[@]}" + else + local DO_FW="" + prompt_yn "Create a DigitalOcean Cloud Firewall for this droplet via doctl now? (y/n):" "y" DO_FW + if [[ "$DO_FW" =~ ^[Yy]$ ]]; then + if doctl compute firewall create \ + --name "asterisk" \ + --droplet-ids "$DROPLET_ID" \ + --inbound-rules "$(IFS=' '; echo "${DO_FW_RULES[*]}")" \ + --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0 protocol:icmp,ports:0,address:0.0.0.0/0,address:::/0" \ + &>/dev/null; then + log_success "Cloud Firewall 'asterisk' created and attached (SSH/22 included so you don't get locked out)." + log_info "Verify it in the DO console — adjust the SSH rule if you use a non-default SSH port." + else + log_warning "doctl firewall create failed — add the rules manually (see README)." + fi + fi + fi + else + log_info "doctl not installed/authenticated — configure a DigitalOcean Cloud Firewall manually:" + log_info "Control Panel → Networking → Firewalls → create, attach to this droplet, allow:" + printf ' %s\n' "${DO_FW_RULES[@]}" + fi +} + +# ── Shared: README ───────────────────────────────────────────────────────── +# One document with a droplet-only section appended in public-cloud mode, so +# the two deployment shapes can't document themselves differently by accident. +_asterisk_write_readme() { + local EA_DIR="$1" CONTAINER="$2" IS_DO="$3" DOMAIN_NAME="$4" PUBLIC_IP="$5" WEB_ADMIN_PORT_VAL="$6" + local _host="${DOMAIN_NAME:-${PUBLIC_IP:-}}" + + { + cat << MD +# Easy Asterisk PBX + coturn + +Self-hosted SIP PBX using Easy Asterisk with a coturn TURN/STUN server for +NAT traversal. Suitable for home intercom, VoIP handsets, and softphones. + +One installer covers both a home/LAN box and a public cloud VM — it detects a +DigitalOcean droplet at install time and adjusts. This install is in +**$( [[ "$IS_DO" == true ]] && echo "public cloud / droplet" || echo "home / LAN" )** mode; re-run +\`sudo ./setup.sh asterisk\` and pick a full reinstall to change that. + +## Manage + +\`\`\`bash +docker compose up -d --build # build image and start +docker compose up -d # start (after initial build) +docker compose down # stop +docker compose logs -f # follow logs +docker compose pull # update coturn image +docker compose up -d --build # rebuild asterisk image +\`\`\` + +## Management script + +\`\`\`bash +docker exec -it ${CONTAINER} easy-asterisk --help +\`\`\` + +Use it to create SIP extensions (Server Settings → Extensions) before +connecting a phone. The Security Dashboard's Extensions tab +(\`services/security-dashboard.sh\`) does the same thing from a browser. + +## SIP client setup + +| Setting | Value | +|-----------------|--------------------------------------| +| SIP server | \`${_host}\` | +| SIP port | 5061 (TLS) / 5060 (UDP) | +| TURN server | \`${_host}:3478\` | +| TURN username | easyasterisk | +| TURN password | see \`.env\` → \`TURN_PASSWORD\` | + +Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave, and +[Sipnetic](https://www.sipnetic.com/) on Android (free, TLS/SRTP + +STUN/TURN/ICE). For a phone to work the same way regardless of network (LAN, +VLAN, remote, no VPN), register it against \`${_host}:5061\` over TLS. Plain +UDP/TCP on 5060 still works for LAN-only devices, but only the FQDN+TLS path +is location-independent. + +If it registers but calls connect with no audio, the RTP/TURN port ranges +below are almost always the cause — check them on every firewall layer. + +## TLS certificate + +Caddy is what actually talks to Let's Encrypt — Asterisk never does ACME +itself. If Caddy is installed and holds a cert for \`DOMAIN_NAME\` (i.e. +there's a Caddyfile site block for that exact hostname), the container mounts +Caddy's cert store read-only and the entrypoint syncs it in automatically on +every start — and re-checks every 12h so renewals get picked up without a +restart. No Caddyfile block for the domain, or no Caddy at all, falls back to +a self-signed cert (phones must be configured to accept it). + +## Web admin + +Access the Easy Asterisk web interface at +\`http://${PUBLIC_IP:-}:${WEB_ADMIN_PORT_VAL}\` or via your configured +reverse-proxy domain. (8081 is the default; if that port was already taken by +something else on this box, the installer picked the next free one instead — +\`WEB_ADMIN_PORT\` in \`.env\` is the actual value.) + +## Internal SIP messaging (no PSTN trunk needed) + +Every extension can send/receive Asterisk's native SIP MESSAGE (no carrier +SMS, no PSTN, no cost) once its "messaging" flag is set to yes in +\`pstn-permissions.conf\` — via the Security Dashboard's Extensions tab, or by +hand. This works independent of \`pstn-trunk.sh\` entirely. Under the hood: +every device endpoint gets \`message_context=sip-messaging\`, routing messages +to a dedicated \`config/asterisk/messaging-dialplan.conf\` context instead of +\`[intercom]\` (which already owns per-device call routing) — this +install/update patches both the device-creation code (so new extensions pick +it up automatically) and any devices that already existed. Confirmed against +a live install's \`pjsip.conf\`/\`extensions.conf\` on 2026-07-23 — the MESSAGE +sender-extraction logic itself is still unconfirmed against real traffic; if +messages silently don't arrive, check +\`docker exec ${CONTAINER} asterisk -rx "core set verbose 3"\` while sending one. + +## Extension presence (online/offline) alerts + +Optional ntfy alert when an extension's SIP registration changes state — +offered on both fresh install and "update in place". Checked every 2 +minutes (systemd timer, cron.d fallback); fires only on a change, never on +every check. + +## Logs + +Asterisk's security-level events (auth failures, SIP brute-force attempts) +are written to \`logs/full\` as well as the container's stdout — that file is +what the Security Dashboard's Security Log tab and CrowdSec's Asterisk +acquisition both read. It's rotated at 100MB (5 generations, compressed) via +\`/etc/logrotate.d/asterisk\`; unrotated it reached 1.4GB in three days on a +publicly reachable box. + +## VLANs / other subnets + +\`.env\` → \`HAS_VLANS\`/\`VLAN_SUBNETS\` lists extra networks (space-separated +CIDRs) this server isn't itself attached to but that phones live on. These +become \`local_net=\` entries in \`pjsip.conf\` so NAT/SDP handling is correct +for those devices (missing entries here is the most common cause of calls +connecting with no audio). To change this after install: + +\`\`\`bash +docker exec -it ${CONTAINER} easy-asterisk +# Server Settings → Configure VLAN/VPN Subnets +\`\`\` + +## Ports + +| Port | Protocol | Purpose | +|---------------|----------|----------------------------------| +| 5060 | UDP/TCP | SIP signalling (unencrypted) | +| 5061 | TCP | SIP over TLS | +| ${WEB_ADMIN_PORT_VAL} | TCP | Easy Asterisk web admin (auto-picked — see \`.env\`) | +| 8088/8089 | TCP | Asterisk HTTP/WS (ARI/AMI) | +| 3478 | UDP/TCP | TURN/STUN (coturn) | +| 10000–20000 | UDP | RTP media streams | +| 49152–49252 | UDP | TURN relay media ports | + +## Data directories (all inside ${EA_DIR}/, included in backup) + +| Directory | Contents | +|----------------------|---------------------------------| +| config/asterisk/ | /etc/asterisk — dialplan, SIP | +| config/easy-asterisk/| /etc/easy-asterisk — web config | +| logs/ | /var/log/asterisk | +| spool/ | /var/spool/asterisk | +| lib/ | /var/lib/asterisk | +MD + + # Droplet-only appendix. Guarded with an `if`, not an early return — + # this block runs in the pipeline's subshell, where a bare `return` + # would only leave the subshell and quietly skip nothing useful. + [[ "$IS_DO" == true ]] && cat << MD + +## DigitalOcean droplet notes + +This install is in public-cloud mode: the installer read the droplet's public +IP from the metadata service, set up a swapfile, offered a Cloud Firewall, and +reverse-proxied the web admin on the same FQDN used for SIP. + +### Droplet sizing + +Asterisk + coturn is light for a handful of SIP extensions and personal use. + +| Plan | vCPU | RAM | Good for | +|--------------------------------|------|-------|----------------------------------------| +| Basic (regular), \$4/mo | 1 | 512 MB | Works — this installer adds a 2GB swapfile automatically to cover it. Fine for a couple of extensions and light personal use. | +| **Basic (regular), \$6/mo — recommended** | 1 | 1 GB | More headroom, still gets an automatic swapfile | +| Basic (regular), \$12/mo | 1 | 2 GB | Comfortable — no swap needed, a handful of concurrent calls | +| Basic (regular), \$24/mo | 2 | 4 GB | Several simultaneous calls, conference bridges, transcoding | + +10 GB SSD (the \$4/mo plan's disk) is enough — this stack isn't storage-heavy, +and the swapfile only takes 2GB of it. Any DO region close to where the +phones actually are is fine; SIP/RTP care about latency more than raw +bandwidth. + +**Swap:** DigitalOcean doesn't provision swap by default, and Docker + +Asterisk + coturn leave little headroom at 512MB–1GB RAM. The installer +detects RAM ≤2GB with no existing swap and offers to add a 2GB swapfile +(persisted in \`/etc/fstab\`) — it's what makes the \$4/mo plan viable instead +of risking an OOM kill under load. + +**OS image:** Ubuntu 24.04 LTS (supported through April 2029) is the safe, +battle-tested choice for Docker + coturn. Ubuntu 26.04 LTS is also available +and supported longer (through 2031) if you'd rather track the newer LTS. + +### DNS + +Point an A record at the droplet's public IP before running the installer: + +\`\`\` +sip.yourdomain.com A ${PUBLIC_IP:-} +\`\`\` + +That one FQDN is used for SIP, the web admin, and the TLS cert — there's no +separate domain to plan for the admin panel. + +### Security + +- **SSH:** key-based auth only, password login disabled — \`services/base.sh\` + in this repo offers to do this for you on first run. Don't skip it; this + box is public. +- **Two firewall layers, same rule set:** + - **DigitalOcean Cloud Firewall** — filters at the network edge, before + traffic reaches the droplet. The installer offers to create one + automatically via \`doctl\` (only if none is already attached to this + droplet — it never overwrites an existing one, to avoid clobbering a + custom SSH allow-list). If \`doctl\` isn't set up, add the same rules as + the Ports table above manually in the DO console (Networking → + Firewalls), plus TCP 22 for SSH. + - **UFW** — host-level, configured automatically by the installer as a + second layer. Keep both in sync; don't let them contradict each other. +- The web admin port is only opened publicly when Caddy isn't fronting it + locally — otherwise it's reachable at \`https://${DOMAIN_NAME:-your-domain}/\` + only, not the bare IP:port. +- **CrowdSec** — SIP brute-force/enumeration protection + (\`crowdsecurity/asterisk\` collection). Not installed by this script — + install it separately (whiptail menu, or \`sudo ./setup.sh crowdsec\`); its + own installer auto-detects this install and wires up SIP protection + regardless of install order. +- DO's paid Droplet Backups, or \`services/borg-backup.sh\` installed + separately, are both options for a rollback path. + +### Other services (installed separately, not by this script) + +This installer only sets up Asterisk + coturn. Everything else — Caddy, +CrowdSec, Authelia, ntfy, watchtower, wg-easy, NetBird, Borg backup — is a +normal service in this repo: pick it from the whiptail menu, or run +\`sudo ./setup.sh \` directly. A few integrate automatically with this +install if already present, no extra config needed: + +- **Caddy** — if installed (locally, or you're on a remote-Caddy setup), the + installer reverse-proxies the web admin on \`DOMAIN_NAME\` and Asterisk syncs + the resulting Let's Encrypt cert for SIP-TLS too. Not installed → + self-signed cert, plain HTTP admin. +- **Authelia** — if installed locally (needs Caddy), or you point the + installer at a remote instance (e.g. a homelab, via NetBird mesh IP or a + public \`https://\` URL), the web admin gets SSO/2FA in front of it. +- **CrowdSec** — see Security above; wires up SIP protection automatically + once installed, regardless of whether it went in before or after this. +MD + } | write_readme "$EA_DIR" +} + install_asterisk() { require_docker || return 1 log_info "Installing Easy Asterisk PBX + coturn..." - local EA_DIR="$DOCKER_DIR/asterisk" + local ASTERISK_DIR ASTERISK_CONTAINER ASTERISK_COTURN ASTERISK_PROJECT + _asterisk_resolve_layout + local EA_DIR="$ASTERISK_DIR" + local CONTAINER="$ASTERISK_CONTAINER" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $EA_DIR with Dockerfile, docker-compose.yml, .env" - echo "[DRY-RUN] Would copy/download vendor files from easy-asterisk" + echo "[DRY-RUN] Would copy/download vendor files from easy-asterisk, patching Asterisk to" + echo "[DRY-RUN] log security events to logs/full (what CrowdSec + the Security Dashboard read)" + echo "[DRY-RUN] Would rotate logs/full at 100MB via /etc/logrotate.d/asterisk" + echo "[DRY-RUN] Would detect a DigitalOcean droplet via its metadata service (asking either way)" + echo "[DRY-RUN] and, in droplet mode, additionally:" + echo "[DRY-RUN] - add a swapfile if RAM <= 2048MB and none exists" + echo "[DRY-RUN] - skip the LAN/VLAN prompts and set up one public FQDN for SIP + web admin" + echo "[DRY-RUN] - reverse-proxy the web admin on that SAME FQDN (needed for SIP cert sync)" + echo "[DRY-RUN] - offer local OR remote Authelia to protect the web admin" + echo "[DRY-RUN] - offer to create a DigitalOcean Cloud Firewall via doctl" echo "[DRY-RUN] Would scan for a free web admin port starting at 8081 (avoids e.g. CrowdSec's 8080)" echo "[DRY-RUN] Would open UFW ports: 5060, 5061, , 8088, 8089, 3478, 10000-20000, 49152-49252" echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if $EA_DIR already exists" @@ -753,11 +1418,15 @@ install_asterisk() { return 0 fi + [[ "$EA_DIR" == *asterisk-digital-ocean ]] && \ + log_info "Using the existing droplet install at $EA_DIR (containers ${CONTAINER}/${ASTERISK_COTURN}) — left where it is so Caddy, UFW, CrowdSec and the PSTN trunk keep pointing at it." + # ── Existing install? Offer update-in-place instead of a full reinstall ─── - # A fresh install re-runs every prompt (networking mode, domain, VLANs, - # Authelia). An update only refreshes vendor files + docker-compose.yml — - # picking up fixes like this one — and rebuilds, without touching .env, - # UFW, or the Caddy/Authelia config already in place. + # A fresh install re-runs every prompt (droplet detection, networking mode, + # domain, VLANs, firewalls, Authelia). An update only refreshes vendor + # files + docker-compose.yml — picking up fixes like this one — and + # rebuilds, without touching .env, UFW, any Cloud Firewall, or the + # Caddy/Authelia config already in place. if [[ -f "$EA_DIR/docker-compose.yml" && -f "$EA_DIR/.env" ]]; then echo "" log_info "Existing install found at $EA_DIR." @@ -771,10 +1440,11 @@ install_asterisk() { cd "$EA_DIR" || return 1 _asterisk_refresh_vendor_files - _asterisk_write_compose + _asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" + _asterisk_write_logrotate "$EA_DIR" _asterisk_patch_messaging_vendor_files "$EA_DIR" _asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" - _asterisk_ensure_live_messaging_include "$EA_DIR" + _asterisk_ensure_live_messaging_include "$EA_DIR" "$CONTAINER" _asterisk_migrate_existing_devices_message_context "$EA_DIR/config/asterisk/pjsip.conf" ensure_docker_dir_ownership "$EA_DIR/config/asterisk" chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" @@ -786,14 +1456,14 @@ install_asterisk() { log_warning "docker compose up failed — check: docker compose -f $EA_DIR/docker-compose.yml logs" fi - _asterisk_run_presence_step "$EA_DIR" + _asterisk_run_presence_step "$EA_DIR" "$CONTAINER" _asterisk_offer_dashboard_and_trunk "$EA_DIR" local _EXISTING_DOMAIN _EXISTING_PORT _EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .env | cut -d= -f2-)" _EXISTING_PORT="$(grep -E '^WEB_ADMIN_PORT=' .env | cut -d= -f2-)" echo "" - log_success "Existing .env, UFW rules, and Caddy/Authelia config were left untouched." + log_success "Existing .env, firewall rules, and Caddy/Authelia config were left untouched." if [[ -n "$_EXISTING_DOMAIN" ]]; then echo " Web admin: https://${_EXISTING_DOMAIN}/" else @@ -813,6 +1483,13 @@ install_asterisk() { esac fi + # ── Public cloud (DigitalOcean droplet) or home/LAN box? ────────────────── + # Everything droplet-specific below hangs off this one answer. + local IS_DO=false DROPLET_ID="" PUBLIC_IP="" + _asterisk_detect_digitalocean + + [[ "$IS_DO" == true ]] && _asterisk_offer_swapfile + mkdir -p "$EA_DIR" mkdir -p "$EA_DIR/config/asterisk" "$EA_DIR/config/easy-asterisk" \ "$EA_DIR/logs" "$EA_DIR/spool" "$EA_DIR/lib" "$EA_DIR/exports" @@ -820,58 +1497,83 @@ install_asterisk() { cd "$EA_DIR" || return 1 _asterisk_refresh_vendor_files + _asterisk_write_logrotate "$EA_DIR" _asterisk_patch_messaging_vendor_files "$EA_DIR" _asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" + _asterisk_ensure_live_messaging_include "$EA_DIR" "$CONTAINER" ensure_docker_dir_ownership "$EA_DIR/config/asterisk" chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" - # ── Networking mode ─────────────────────────────────────────────────────── - echo "" - echo " Networking mode:" - echo " 1) FQDN (recommended) — TLS + TURN relay, every phone connects the" - echo " same way regardless of LAN/VLAN/remote" - echo " 2) LAN-only — no domain, self-signed cert, local network/VPN only" - local HA_NETMODE="" - prompt_text "Choose [1]:" "1" HA_NETMODE + # ── Domain / networking mode ────────────────────────────────────────────── + # A public cloud box is always reachable from anywhere, so there's no + # LAN-only option worth offering and no VLAN to bridge — one FQDN covers + # SIP registration, the web admin, and the TLS cert. A home box gets the + # full choice, plus the VLAN/subnet questions that only matter there. + local DOMAIN_NAME="" HAS_VLANS_VAL="n" VLAN_SUBNETS_VAL="" - local DOMAIN_NAME="" - if [[ "$HA_NETMODE" != "2" ]]; then - prompt_text "FQDN (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank=fall back to LAN-only]:" "" DOMAIN_NAME - [[ -z "$DOMAIN_NAME" ]] && log_warning "No FQDN entered — proceeding in LAN-only mode." - fi + if [[ "$IS_DO" == true ]]; then + echo "" + echo " Point a DNS A record at this box before continuing:" + echo " .${SITE_DOMAIN:-example.com} A ${PUBLIC_IP:-}" + echo "" + echo " This one FQDN covers everything below — SIP registration, the web" + echo " admin, and (via Caddy) the TLS cert Asterisk needs for SIP. There's" + echo " no separate \"admin domain\" to pick later — whatever you enter here" + echo " is what your SIP client (e.g. Sipnetic) will register against." + prompt_text "FQDN for this PBX, e.g. sip.yourdomain.com [blank=self-signed cert, IP-only access]:" "" DOMAIN_NAME + [[ -z "$DOMAIN_NAME" ]] && log_warning "No FQDN entered — using a self-signed cert; phones must trust it manually." + else + echo "" + echo " Networking mode:" + echo " 1) FQDN (recommended) — TLS + TURN relay, every phone connects the" + echo " same way regardless of LAN/VLAN/remote" + echo " 2) LAN-only — no domain, self-signed cert, local network/VPN only" + local HA_NETMODE="" + prompt_text "Choose [1]:" "1" HA_NETMODE - # ── Local networks / VLANs ──────────────────────────────────────────────── - # Feeds HAS_VLANS/VLAN_SUBNETS into .env, which the entrypoint reads to add - # extra local_net= entries in pjsip.conf so phones on those subnets get - # correct NAT/SDP handling (this is what fixes the "no sound" symptom for - # devices on a VLAN the server isn't itself attached to). - echo "" - echo " Detecting networks this host can see..." - local DETECTED_NETS="" - DETECTED_NETS="$(ip -o -f inet addr show scope global 2>/dev/null \ - | awk '{print $2, $4}' \ - | grep -Ev '^(docker|br-|veth|tun|tap|wg)' \ - | awk '{ split($2,a,"/"); split(a[1],o,"."); print o[1]"."o[2]"."o[3]".0/"a[2] }' \ - | sort -u)" - if [[ -n "$DETECTED_NETS" ]]; then - echo " This host is directly attached to:" - echo "$DETECTED_NETS" | sed 's/^/ /' + if [[ "$HA_NETMODE" != "2" ]]; then + prompt_text "FQDN (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank=fall back to LAN-only]:" "" DOMAIN_NAME + [[ -z "$DOMAIN_NAME" ]] && log_warning "No FQDN entered — proceeding in LAN-only mode." + fi + + # ── Local networks / VLANs ──────────────────────────────────────────── + # Feeds HAS_VLANS/VLAN_SUBNETS into .env, which the entrypoint reads to + # add extra local_net= entries in pjsip.conf so phones on those subnets + # get correct NAT/SDP handling (this is what fixes the "no sound" + # symptom for devices on a VLAN the server isn't itself attached to). + echo "" + echo " Detecting networks this host can see..." + local DETECTED_NETS="" + DETECTED_NETS="$(ip -o -f inet addr show scope global 2>/dev/null \ + | awk '{print $2, $4}' \ + | grep -Ev '^(docker|br-|veth|tun|tap|wg)' \ + | awk '{ split($2,a,"/"); split(a[1],o,"."); print o[1]"."o[2]"."o[3]".0/"a[2] }' \ + | sort -u)" + if [[ -n "$DETECTED_NETS" ]]; then + echo " This host is directly attached to:" + echo "$DETECTED_NETS" | sed 's/^/ /' + fi + echo " Phones on OTHER VLANs (this server usually can't see those directly)" + echo " still need to be listed here so their media is treated as local/trusted." + prompt_text "VLAN/VPN subnets, space-separated CIDRs [blank=none]:" "" VLAN_SUBNETS_VAL + [[ -n "$VLAN_SUBNETS_VAL" ]] && HAS_VLANS_VAL="y" fi - echo " Phones on OTHER VLANs (this server usually can't see those directly)" - echo " still need to be listed here so their media is treated as local/trusted." - local VLAN_SUBNETS_VAL="" - prompt_text "VLAN/VPN subnets, space-separated CIDRs [blank=none]:" "" VLAN_SUBNETS_VAL - local HAS_VLANS_VAL="n" - [[ -n "$VLAN_SUBNETS_VAL" ]] && HAS_VLANS_VAL="y" # ── Secrets ─────────────────────────────────────────────────────────────── local TURN_PASSWORD TURN_PASSWORD="$(generate_password 24)" + # A public box always has a usable TURN address (the FQDN if set, else its + # public IP). A LAN box with no FQDN has none — coturn is only reachable + # over the local network, so clients use the server's LAN address directly. local TURN_SERVER_VAL="" - [[ -n "$DOMAIN_NAME" ]] && TURN_SERVER_VAL="${DOMAIN_NAME}:3478" + if [[ "$IS_DO" == true ]]; then + TURN_SERVER_VAL="${DOMAIN_NAME:-$PUBLIC_IP}:3478" + elif [[ -n "$DOMAIN_NAME" ]]; then + TURN_SERVER_VAL="${DOMAIN_NAME}:3478" + fi - _asterisk_write_compose + _asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" # ── Pick a free port for the web admin ───────────────────────────────────── # Hardcoding a single number gets fragile fast once several services share @@ -893,16 +1595,27 @@ install_asterisk() { fi # ── .env ────────────────────────────────────────────────────────────────── + local _domain_comment="Set to your FQDN for remote access. Leave empty for LAN-only." + local _vlan_comment="Extra local_net= entries for phones on networks this server isn't +# itself attached to. Space-separated CIDRs." + if [[ "$IS_DO" == true ]]; then + _domain_comment="Public FQDN for this box. Leave empty to fall back to a self-signed +# cert reachable at the public IP (${PUBLIC_IP:-unknown})." + _vlan_comment="A public cloud box has one public NIC, so this is usually irrelevant. +# Only set it if you're bridging phones back in over a VPN (e.g. +# WireGuard/Tailscale) on a subnet this box isn't directly attached to." + fi + cat > .env << ENV # ── Domain ──────────────────────────────────────────────────── -# Set to your FQDN for remote access. Leave empty for LAN-only. +# ${_domain_comment} DOMAIN_NAME=${DOMAIN_NAME} # ── TURN/STUN ───────────────────────────────────────────────── TURN_USERNAME=easyasterisk TURN_PASSWORD=${TURN_PASSWORD} TURN_PORT=3478 -# For LAN-only: TURN_SERVER is empty. For FQDN: set to domain:3478 +# Empty when there's no publicly resolvable address (LAN-only, no FQDN). TURN_SERVER=${TURN_SERVER_VAL} # ── RTP port range ──────────────────────────────────────────── @@ -910,8 +1623,7 @@ RTP_START=10000 RTP_END=20000 # ── VLAN/VPN subnets ────────────────────────────────────────── -# Extra local_net= entries for phones on networks this server isn't -# itself attached to. Space-separated CIDRs. +# ${_vlan_comment} HAS_VLANS=${HAS_VLANS_VAL} VLAN_SUBNETS=${VLAN_SUBNETS_VAL} @@ -919,40 +1631,51 @@ VLAN_SUBNETS=${VLAN_SUBNETS_VAL} # Picked automatically at install time (first free port starting at 8081) — # see WEB_ADMIN_PORT_VAL in services/asterisk.sh if this ever needs to # change again; don't hand-edit without also updating Caddy's Caddyfile and -# any firewall rules to match. +# every firewall layer to match. WEB_ADMIN_PORT=${WEB_ADMIN_PORT_VAL} WEB_ADMIN_AUTH_DISABLED=false ENV chmod 600 .env - # ── Caddy reverse proxy for web admin ───────────────────────────────────── - # Decided before the firewall rules below so they can be scoped - # correctly: if a local Caddy ends up fronting the web admin, there's no - # reason to also expose it on the LAN — Caddy already reaches it over - # the host's internal network (host.docker.internal). - local EXTRA_BLOCK="" - if [ -d "$DOCKER_DIR/authelia" ]; then - local _use_auth="" - prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth - if [[ "$_use_auth" =~ ^[Yy]$ ]]; then - EXTRA_BLOCK=" import authelia" - # Disable built-in auth since Authelia handles it - sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env + # ── Caddy reverse proxy for the web admin ───────────────────────────────── + # Decided before the firewall rules below so they can be scoped correctly: + # if a local Caddy ends up fronting the web admin, there's no reason to + # also expose it directly — Caddy already reaches it over the host's + # internal network (host.docker.internal), and leaving the bare IP:port + # open would let anyone bypass Caddy/Authelia entirely. + local WEB_ADMIN_PUBLIC_ACCESS_NEEDED=true + if [[ "$IS_DO" == true ]]; then + _asterisk_configure_caddy_public "$DOMAIN_NAME" "$WEB_ADMIN_PORT_VAL" "$PUBLIC_IP" + else + local EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth + if [[ "$_use_auth" =~ ^[Yy]$ ]]; then + EXTRA_BLOCK=" import authelia" + # Disable built-in auth since Authelia handles it + sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env + fi + fi + configure_caddy_for_service "Asterisk Web Admin" "${WEB_ADMIN_PORT_VAL}" "asterisk" "$EXTRA_BLOCK" + if [[ "$CADDY_SERVICE_CONFIGURED" == true && "$CADDY_SERVICE_MODE" == "local" ]]; then + WEB_ADMIN_PUBLIC_ACCESS_NEEDED=false fi fi - configure_caddy_for_service "Asterisk Web Admin" "${WEB_ADMIN_PORT_VAL}" "asterisk" "$EXTRA_BLOCK" - # ── UFW firewall rules ──────────────────────────────────────────────────── + # ── UFW firewall rules (host-level) ─────────────────────────────────────── if command -v ufw &>/dev/null; then log_info "Opening UFW ports for Asterisk + coturn..." ufw allow 5060/udp ufw allow 5060/tcp ufw allow 5061/tcp - if [[ "$CADDY_SERVICE_CONFIGURED" == true && "$CADDY_SERVICE_MODE" == "local" ]]; then + if [[ "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" == true ]]; then + ufw allow "${WEB_ADMIN_PORT_VAL}/tcp" + else + # Scoped, not deleted outright: a bare `ufw delete allow` also + # blocks Caddy's own request arriving over the caddy_net bridge. ufw delete allow "${WEB_ADMIN_PORT_VAL}/tcp" 2>/dev/null || true ufw_allow_from_caddy_net "${WEB_ADMIN_PORT_VAL}" - else - ufw allow "${WEB_ADMIN_PORT_VAL}/tcp" fi ufw allow 8088/tcp ufw allow 8089/tcp @@ -964,123 +1687,33 @@ ENV log_success "UFW rules added." fi + # ── DigitalOcean Cloud Firewall (network edge) ──────────────────────────── + [[ "$IS_DO" == true ]] && \ + _asterisk_configure_do_cloud_firewall "$DROPLET_ID" "$WEB_ADMIN_PORT_VAL" "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" + + # ── CrowdSec note ────────────────────────────────────────────────────────── + # Not installed here — select it separately from the whiptail menu, or + # `sudo ./setup.sh crowdsec`. Its own installer (services/crowdsec.sh) + # auto-detects an Asterisk install and wires up SIP brute-force + # protection on its own, in either install order. + if command -v cscli &>/dev/null; then + log_info "CrowdSec is already installed — rerun it to pick up SIP protection for this install:" + log_info " sudo ./setup.sh crowdsec" + elif [[ "$IS_DO" == true ]]; then + log_info "CrowdSec not installed. Recommended for SSH + SIP intrusion prevention on a" + log_info "public box — install it separately (whiptail menu, or 'sudo ./setup.sh crowdsec')." + log_info "It auto-detects this install and wires up SIP protection on its own." + else + log_info "CrowdSec not installed. Worth adding if SIP is reachable from the internet" + log_info "(port-forwarded 5060/5061) — whiptail menu, or 'sudo ./setup.sh crowdsec'." + log_info "It auto-detects this install and wires up SIP protection on its own." + fi + # ── Extension presence (online/offline) ntfy alerts ──────────────────────── - _asterisk_run_presence_step "$EA_DIR" + _asterisk_run_presence_step "$EA_DIR" "$CONTAINER" # ── README ──────────────────────────────────────────────────────────────── - write_readme "$EA_DIR" << 'MD' -# Easy Asterisk PBX + coturn - -Self-hosted SIP PBX using Easy Asterisk with a coturn TURN/STUN server for -NAT traversal. Suitable for home intercom, VoIP handsets, and softphones. - -## Manage - -```bash -docker compose up -d --build # build image and start -docker compose up -d # start (after initial build) -docker compose down # stop -docker compose logs -f # follow logs -docker compose pull # update coturn image -docker compose up -d --build # rebuild asterisk image -``` - -## Management script - -```bash -docker exec -it easy-asterisk easy-asterisk --help -``` - -## SIP client setup - -| Setting | Value | -|-----------------|--------------------------------------| -| SIP server | (LAN) or your FQDN (FQDN) | -| SIP port | 5061 (TLS) / 5060 (UDP) | -| TURN server | :3478 (FQDN mode only) | -| TURN username | easyasterisk | -| TURN password | see .env → TURN_PASSWORD | - -Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave. - -For a phone to work the same way regardless of network (LAN, VLAN, remote, -no VPN), register it against `:5061` over TLS — that's what -FQDN mode is for. Plain UDP/TCP on 5060 still works for LAN-only devices, -but only the FQDN+TLS path is location-independent. - -## VLANs / other subnets - -`.env` → `HAS_VLANS`/`VLAN_SUBNETS` lists extra networks (space-separated -CIDRs) this server isn't itself attached to but that phones live on. These -become `local_net=` entries in `pjsip.conf` so NAT/SDP handling is correct -for those devices (missing entries here is the most common cause of calls -connecting with no audio). To change this after install: - -```bash -docker exec -it easy-asterisk easy-asterisk -# Server Settings → Configure VLAN/VPN Subnets -``` - -## TLS certificate - -If Caddy is installed and already holds a Let's Encrypt cert for -`DOMAIN_NAME` (i.e. there's a Caddyfile site block for that exact hostname), -the container mounts Caddy's cert store read-only and the entrypoint syncs -it in automatically on every start — and re-checks every 12h so renewals -get picked up without a restart. No Caddyfile block for the domain, or no -Caddy at all, falls back to a self-signed cert (phones must be configured -to accept it). - -## Web admin - -Access the Easy Asterisk web interface at http://:8081 -or via your configured reverse-proxy domain. (8081 is the default; if that -port was already taken by something else on this box, the installer picked -the next free one instead — check WEB_ADMIN_PORT in .env for the actual -value.) - -## Data directories (all inside ~/docker/asterisk/, included in backup) - -| Directory | Contents | -|----------------------|---------------------------------| -| config/asterisk/ | /etc/asterisk — dialplan, SIP | -| config/easy-asterisk/| /etc/easy-asterisk — web config | -| logs/ | /var/log/asterisk | -| spool/ | /var/spool/asterisk | -| lib/ | /var/lib/asterisk | - -## Internal SIP messaging (no PSTN trunk needed) - -Every extension can send/receive Asterisk's native SIP MESSAGE (no carrier -SMS, no PSTN, no cost) once its "messaging" flag is set to yes in -\`pstn-permissions.conf\` — via the Security Dashboard's "Internal SIP -messaging" card, or by hand. This works independent of \`pstn-trunk.sh\` -entirely. Under the hood: every device endpoint gets -\`message_context=sip-messaging\`, routing messages to a dedicated -\`config/asterisk/messaging-dialplan.conf\` context instead of \`[intercom]\` -(which already owns per-device call routing) — this install/update patches -both the device-creation code (so new extensions pick it up automatically) -and any devices that already existed. - -## Extension presence (online/offline) alerts - -Optional ntfy alert when an extension's SIP registration changes state — -offered on both fresh install and "update in place". Checked every 2 -minutes (systemd timer, cron.d fallback); fires only on a change, never on -every check. - -## Ports - -| Port | Protocol | Purpose | -|---------------|----------|----------------------------------| -| 5060 | UDP/TCP | SIP signalling (unencrypted) | -| 5061 | TCP | SIP over TLS | -| 8081 | TCP | Easy Asterisk web admin (default — see .env) | -| 8088/8089 | TCP | Asterisk HTTP/WS (ARI/AMI) | -| 3478 | UDP/TCP | TURN/STUN (coturn) | -| 10000–20000 | UDP | RTP media streams | -| 49152–49252 | UDP | TURN relay media ports | -MD + _asterisk_write_readme "$EA_DIR" "$CONTAINER" "$IS_DO" "$DOMAIN_NAME" "$PUBLIC_IP" "$WEB_ADMIN_PORT_VAL" # ── Start ───────────────────────────────────────────────────────────────── echo "" @@ -1095,19 +1728,33 @@ MD _asterisk_offer_dashboard_and_trunk "$EA_DIR" # ── Summary ─────────────────────────────────────────────────────────────── + local _LOCAL_IP + _LOCAL_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost)" echo "" log_success "Easy Asterisk installed at $EA_DIR" if [[ -n "$DOMAIN_NAME" ]]; then - echo " Mode: FQDN ($DOMAIN_NAME)" + echo " Mode: FQDN ($DOMAIN_NAME)$( [[ "$IS_DO" == true ]] && echo ", public cloud" )" echo " TURN server: ${DOMAIN_NAME}:3478" + elif [[ "$IS_DO" == true ]]; then + echo " Mode: IP-only, public cloud (self-signed cert)" + echo " TURN server: ${PUBLIC_IP:-unknown}:3478" else echo " Mode: LAN-only" echo " TURN server: (none — LAN/VPN only)" fi + [[ "$IS_DO" == true ]] && echo " Public IP: ${PUBLIC_IP:-unknown}" echo " SIP port: 5061 (TLS) / 5060 (UDP)" - echo " Web admin: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):${WEB_ADMIN_PORT_VAL}" + echo " Web admin: http://${PUBLIC_IP:-$_LOCAL_IP}:${WEB_ADMIN_PORT_VAL}" echo " Manage: docker compose -f $EA_DIR/docker-compose.yml " - echo " Script: docker exec -it easy-asterisk easy-asterisk --help" + echo " Script: docker exec -it ${CONTAINER} easy-asterisk --help" + if [[ -n "$DOMAIN_NAME" ]] && [[ -d "$DOCKER_DIR/caddy" ]]; then + echo "" + log_info "If Caddy was just installed in this same run, it may still be obtaining the" + log_info "Let's Encrypt cert for ${DOMAIN_NAME} — Asterisk only checks for it at startup" + log_info "and then every 12h. If SIP TLS still shows self-signed after a couple of" + log_info "minutes, pick it up immediately with:" + log_info " docker compose -f $EA_DIR/docker-compose.yml restart asterisk" + fi echo "" } diff --git a/services/authelia.sh b/services/authelia.sh index 613085f..169685d 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -435,7 +435,7 @@ auth.${AUTHELIA_DOMAIN} { # own incoming request (always auth.${AUTHELIA_DOMAIN} itself) and # overwrites the value a forward_auth caller (e.g. a remote site's # "forward_auth https://auth.${AUTHELIA_DOMAIN}" block, see - # services/asterisk-digital-ocean.sh) set for its own domain. Confirmed + # services/asterisk.sh's droplet-mode Caddy block) set for its own domain. Confirmed # live: every forward-auth check evaluated as if it were for # auth.${AUTHELIA_DOMAIN} itself (which has policy: bypass in # access_control.rules so its own login portal isn't gated behind diff --git a/services/caddy.sh b/services/caddy.sh index aeb83b7..126cb65 100644 --- a/services/caddy.sh +++ b/services/caddy.sh @@ -268,7 +268,7 @@ services: labels: - "io.podman.annotations.label/crowdsec.enable=true" # Lets Caddyfile blocks reach services that use network_mode: host - # (e.g. asterisk/asterisk-digital-ocean) via "host.docker.internal:PORT" — Caddy + # (e.g. asterisk) via "host.docker.internal:PORT" — Caddy # itself is on the caddy_net bridge network below, so plain "localhost" # in a site block resolves to Caddy's own container, not the host. extra_hosts: diff --git a/services/crowdsec.sh b/services/crowdsec.sh index eeed3b3..3891613 100644 --- a/services/crowdsec.sh +++ b/services/crowdsec.sh @@ -102,7 +102,7 @@ install_crowdsec() { echo "[DRY-RUN] Would ensure /var/log/caddy exists for log acquisition" echo "[DRY-RUN] Would install collections: sshd, linux, caddy, base-http-scenarios" echo "[DRY-RUN] Would write Caddy acquisition /etc/crowdsec/acquis.d/caddy.yaml" - echo "[DRY-RUN] Would install crowdsecurity/asterisk + write an acquisition if asterisk-digital-ocean is installed" + echo "[DRY-RUN] Would install crowdsecurity/asterisk + write an acquisition if asterisk is installed" echo "[DRY-RUN] Would optionally wire ntfy ban alerts into the default profile" echo "[DRY-RUN] Would optionally register with a remote/central LAPI and disable the local one" echo "[DRY-RUN] Would enable + restart crowdsec and crowdsec-firewall-bouncer" @@ -194,17 +194,26 @@ labels: echo " ✓ Caddy acquisition already exists" fi - # ── 5b. SIP brute-force/enumeration protection, if asterisk-digital-ocean - # is installed (services/asterisk-digital-ocean.sh patches Asterisk to log - # security events — auth failures, registration scanning — to - # $EA_DIR/logs/full. The plain LAN asterisk.sh doesn't emit that file yet, - # so it's intentionally not detected here.) - local ASTERISK_LOG_DIR="$DOCKER_DIR/asterisk-digital-ocean/logs" - if [ -d "$ASTERISK_LOG_DIR" ]; then - echo " Detected asterisk-digital-ocean — installing SIP brute-force/enumeration protection..." + # ── 5b. SIP brute-force/enumeration protection, if Asterisk is installed. + # services/asterisk.sh patches Asterisk to log security events — auth + # failures, registration scanning — to $EA_DIR/logs/full, which is what + # the acquisition below tails. Both directories are probed: a box set up + # before the droplet edition was merged back into `asterisk` still runs + # out of ~/docker/asterisk-digital-ocean. The logging patch used to be + # droplet-only; it now applies to every install, so a home/LAN box gets + # SIP protection here too. + local ASTERISK_LOG_DIR="" + local _ea_candidate + for _ea_candidate in "$DOCKER_DIR/asterisk-digital-ocean" "$DOCKER_DIR/asterisk"; do + [ -d "$_ea_candidate/logs" ] && { ASTERISK_LOG_DIR="$_ea_candidate/logs"; break; } + done + if [ -n "$ASTERISK_LOG_DIR" ]; then + echo " Detected Asterisk at ${ASTERISK_LOG_DIR%/logs} — installing SIP brute-force/enumeration protection..." sudo cscli collections install crowdsecurity/asterisk 2>/dev/null || \ echo " ⚠ crowdsecurity/asterisk collection may already be installed" + # Filename kept as-is so a droplet that already has this acquisition + # isn't given a second one pointing at the same log. local ASTERISK_ACQUIS="/etc/crowdsec/acquis.d/asterisk-digital-ocean.yaml" if [ ! -f "$ASTERISK_ACQUIS" ]; then local ASTERISK_ACQUIS_CONTENT="filenames: @@ -543,7 +552,7 @@ install. The real configuration lives under `/etc/crowdsec`. ## What it does - Detects malicious behaviour (SSH brute force, web scans, SIP brute - force/enumeration if `asterisk-digital-ocean` is installed) by parsing logs. + force/enumeration if `asterisk` is installed) by parsing logs. - Bans offending IPs via the **firewall bouncer** (iptables/nftables). - Pulls **community IP reputation** blocklists so known-bad IPs are blocked before they ever touch your services. @@ -573,9 +582,12 @@ sudo cscli collections list # installed detection collections - Log acquisition (what to watch): `/etc/crowdsec/acquis.d/` - Caddy access logs: `/etc/crowdsec/acquis.d/caddy.yaml` (`/var/log/caddy/*.log` — Caddy writes JSON access logs there) - - Asterisk SIP auth events (if `asterisk-digital-ocean` is installed): - `/etc/crowdsec/acquis.d/asterisk-digital-ocean.yaml` - (`~/docker/asterisk-digital-ocean/logs/full` — auth failures, registration scans) + - Asterisk SIP auth events (if `asterisk` is installed): + `/etc/crowdsec/acquis.d/asterisk-digital-ocean.yaml` (filename kept from + when the droplet edition was its own service, so existing droplets aren't + given a duplicate acquisition) + (`~/docker/asterisk/logs/full`, or `~/docker/asterisk-digital-ocean/logs/full` + on a pre-merge droplet — auth failures, registration scans) - Notifications: `/etc/crowdsec/notifications/` - ntfy ban alerts (if enabled): `/etc/crowdsec/notifications/ntfy.yaml`, wired into `/etc/crowdsec/profiles.yaml` @@ -609,7 +621,7 @@ sudo cscli collections list # installed detection collections list directly in that file, then `sudo systemctl restart crowdsec`. This can block Let's Encrypt's out-of-region ACME validation checks; if a cert renewal fails mysteriously, check here first. -- ASN-exempt Asterisk brute-force scenarios (if enabled, asterisk-digital-ocean +- ASN-exempt Asterisk brute-force scenarios (if enabled, Asterisk installs only): `/etc/crowdsec/scenarios/local-asterisk_bf.yaml` and `local-asterisk_user_enum.yaml` — local forks of the stock hub scenarios with specific carrier ASNs excluded from their filter (the hub originals get diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 2d36529..7e8d565 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -1,6 +1,6 @@ #!/bin/bash -# services/pstn-trunk.sh — SIP PSTN trunk add-on for asterisk-digital-ocean -# (or the home/LAN asterisk install): US-only outbound (NANP dialplan +# services/pstn-trunk.sh — SIP PSTN trunk add-on for services/asterisk.sh: +# US-only outbound (NANP dialplan # restriction), independent outbound/inbound concurrent-call caps, a 3-tier # permission model per extension (internal-only / restricted to pre-approved # numbers / full US calling), a configurable inbound ring-group, @@ -15,19 +15,19 @@ # works the same way. VoIP.ms and Anveo Direct are both confirmed working; # see docs/pstn-calling-voipms-plan.md for the design/cost background. # -# Requires an existing services/asterisk-digital-ocean.sh OR services/asterisk.sh -# install — this adds a PSTN trunk on top of one of them and does not stand -# alone. Permission tiers AND concurrency caps are managed live (no restart +# Requires an existing services/asterisk.sh install (either directory layout — +# ~/docker/asterisk, or ~/docker/asterisk-digital-ocean on a box set up before +# the droplet edition was merged back in) — this adds a PSTN trunk on top and +# does not stand alone. Permission tiers AND concurrency caps are managed live (no restart # needed) via pstn-permissions.conf / pstn-limits.conf — editable by hand, or -# from services/security-dashboard.sh's "PSTN Trunk" tab if that's installed. +# from services/security-dashboard.sh's Extensions tab if that's installed. # # Part of the modular post-install system (sourced by setup.sh). -register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean/asterisk — US-only, per-extension permission tiers, spend/volume alerts (any IP-authenticated provider — VoIP.ms and Anveo Direct both confirmed)" +register_service pstn-trunk homelab "SIP PSTN trunk for asterisk — US-only, per-extension permission tiers, spend/volume alerts (any IP-authenticated provider — VoIP.ms and Anveo Direct both confirmed)" # ── Surviving Easy Asterisk's regeneration ────────────────────────────────── -# Easy Asterisk (the vendor project asterisk-digital-ocean.sh/asterisk.sh -# build on) fully OVERWRITES both pjsip.conf and extensions.conf from its own +# Easy Asterisk (the vendor project services/asterisk.sh builds on) fully OVERWRITES both pjsip.conf and extensions.conf from its own # internal state: # - extensions.conf: rebuilt by rebuild_dialplan() on every container start, # and whenever a device/room is added or removed via the web admin. @@ -41,9 +41,9 @@ register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean/a # the #include itself survive regeneration too, _pstn_patch_vendor_files # (below) patches it into the vendor's *generator functions* — the same # technique this repo already uses for the logger.conf security-logging fix -# in _asterisk_do_refresh_vendor_files (see services/asterisk-digital-ocean.sh). +# in _asterisk_refresh_vendor_files (see services/asterisk.sh). # -# Caveat: if the base asterisk-digital-ocean/asterisk install is later +# Caveat: if the base asterisk install is later # refreshed ("update in place", which re-copies fresh vendor files) # independently of this service, the patch is wiped along with it and needs # reapplying — run this service again (fresh or update mode both reapply it) @@ -459,7 +459,7 @@ EOF # zero of the outbound NANP patterns either, and `dialplan show # from-pstn-trunk` reported the context didn't exist at all, with no # warning or error anywhere (config log, full log, or the reload command's -# own output) pointing at why. Meanwhile services/asterisk-digital-ocean.sh's +# own output) pointing at why. Meanwhile services/asterisk.sh's # messaging-dialplan.conf — #include'd via the exact same mechanism, right # after [intercom] in the same extensions.conf — loaded fine every time. # The one structural difference: messaging-dialplan.conf's first real line @@ -1608,7 +1608,7 @@ install_pstn-trunk() { [[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would require an existing asterisk-digital-ocean OR asterisk (LAN) install" + echo "[DRY-RUN] Would require an existing asterisk install (droplet or home/LAN)" echo "[DRY-RUN] Would prompt for: known-provider quick-pick (Anveo Direct runs a full 5-step" echo "[DRY-RUN] interactive portal walkthrough — account/funding, DID ordering, both trunk" echo "[DRY-RUN] objects, confirmed rate — pausing for Enter between each; VoIP.ms pre-fills known" @@ -1640,10 +1640,12 @@ install_pstn-trunk() { fi if [[ -z "$EA_DIR" ]]; then - log_error "Neither asterisk-digital-ocean nor asterisk (LAN) is installed — install one first:" - log_error " sudo ./setup.sh asterisk-digital-ocean (recommended — public droplet, static IP)" - log_error " sudo ./setup.sh asterisk (home/LAN — see the static-IP caveat below)" - log_error "This service adds a PSTN trunk on top of one of them; it doesn't stand alone." + log_error "Asterisk is not installed — install it first:" + log_error " sudo ./setup.sh asterisk" + log_error "A public droplet (which that installer detects and tunes for) is the" + log_error "recommended host, since IP authentication wants a static IP — see the" + log_error "caveat below for what that means on a home/LAN box." + log_error "This service adds a PSTN trunk on top of it; it doesn't stand alone." return 1 fi @@ -1652,7 +1654,7 @@ install_pstn-trunk() { log_warning "Using the home/LAN asterisk install. IP authentication needs a STABLE public IP —" log_warning "if this box is behind a dynamic home IP, your provider's IP allow-list goes stale" log_warning "whenever your ISP rotates it, breaking calls until you update it there yourself." - log_warning "A static IP from your ISP avoids that; asterisk-digital-ocean sidesteps it entirely." + log_warning "A static IP from your ISP avoids that; a cloud droplet sidesteps it entirely." fi log_info "Configuring a SIP PSTN trunk for $ASTERISK_KIND (any IP-authenticated provider —" @@ -2213,12 +2215,11 @@ Asterisk's native SIP \`MESSAGE\` support (extension-to-extension texting — no carrier SMS, no PSTN, no cost) is gated by a \`messaging=yes\` flag per extension in \`pstn-permissions.conf\`, independent of the PSTN calling tiers above — off by default, same "opt in" posture. Live-editable any -time via the Security Dashboard's "PSTN Trunk" tab, in its own -always-available "Internal SIP messaging" card — no dependency on this -trunk (or any PSTN trunk at all) being installed. +time via the Security Dashboard's Extensions tab, in the Messaging column of +its always-available extensions table — no dependency on this trunk (or any +PSTN trunk at all) being installed. -Actually enforced, not just a flag — \`services/asterisk-digital-ocean.sh\` -(and \`services/asterisk.sh\` for the LAN edition) routes messages through a +Actually enforced, not just a flag — \`services/asterisk.sh\` routes messages through a dedicated \`[sip-messaging]\` dialplan context (separate from \`[intercom]\`'s own per-device call routing, so there's no collision risk) and checks this same flag via \`AST_CONFIG()\` before delivering. One caveat still flagged diff --git a/setup.sh b/setup.sh index eeeec5a..449cd95 100755 --- a/setup.sh +++ b/setup.sh @@ -27,6 +27,10 @@ export TERM="${TERM:-xterm-256color}" CATEGORY_ORDER=(base homelab utilities media cameras gaming extras backup) # Service ordering hint within a category (lower = earlier). Default 50. declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 ) +# Retired service names that now resolve to another service. Keeps a name +# that used to work on the command line (and in docs/muscle memory) working +# after a merge, without giving it a second menu entry of its own. +declare -A SERVICE_ALIAS=( [asterisk-digital-ocean]=asterisk ) # ── Parse flags / collect service names ────────────────────────────────────── DRY_RUN=false; UNATTENDED=false; DO_LIST=false @@ -89,6 +93,9 @@ is_installed() { sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;; sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;; sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;; + # Either directory counts: boxes set up before the droplet edition was + # merged back into `asterisk` still run out of ~/docker/asterisk-digital-ocean. + asterisk) [ -e "$DOCKER_DIR/asterisk" ] || [ -e "$DOCKER_DIR/asterisk-digital-ocean" ] ;; pstn-trunk) [ -f "$DOCKER_DIR/asterisk-digital-ocean/config/asterisk/pstn-trunk-pjsip.conf" ] || [ -f "$DOCKER_DIR/asterisk/config/asterisk/pstn-trunk-pjsip.conf" ] ;; ssh-config) false ;; # repeatable management tool, never shows [installed] *) [ -e "$DOCKER_DIR/$1" ] ;; @@ -97,6 +104,10 @@ is_installed() { run_service() { local name="$1" + if [ -n "${SERVICE_ALIAS[$name]:-}" ]; then + log_info "'$name' is now part of '${SERVICE_ALIAS[$name]}' — running that instead." + name="${SERVICE_ALIAS[$name]}" + fi if [ -z "${SERVICE_GROUP[$name]:-}" ]; then log_error "Unknown service: $name (try --list)"; return 1; fi declare -F "install_${name}" >/dev/null || { log_error "Service '$name' has no install_${name}"; return 1; } log_info "=== ${name} (${SERVICE_DESC[$name]}) ===" From bde7e9d12d0a8cdc5de8cef9baf8c25ed570d92b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 01:14:15 +0000 Subject: [PATCH 02/10] Merge dashboard Asterisk Admin, Extensions and PSTN Trunk into one tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tabs listed the same extensions three different ways: Asterisk Admin as devices with category/status/transport, Extensions as a row of messaging checkboxes, PSTN Trunk as permission tiers with a duplicate Messaging column that wrote the same flag. Changing one extension meant knowing which of the three owned the setting you wanted. There is now one Extensions tab with one extensions table, merged from pjsip.conf (via /api/pstn-permissions, which always works) and /api/ea-devices where the Easy Asterisk container is reachable, keyed by extension so a row known to only one source still shows. Capabilities add columns rather than nav buttons: Category/Status/Transport are .ea-only, Tier/Approved-numbers are .pstn-only, and both classes start on so nothing flashes before /api/ea-status and /api/pstn-status answer. Categories, Rooms, Groups, Concurrent-call caps and Personal numbers are cards under the same tab, gated the same way. Per-row Save picks its write path: tier + approved numbers + messaging via /api/pstn-permissions with a trunk installed, messaging alone via /api/pstn-messaging without one — which is what that endpoint has always been for. No backend changes; the standalone messaging-chips card and the duplicate Messaging column are both gone. Verified in Chromium against a fixture Asterisk config: full layout renders nine columns and six cards, the bare layout collapses to Ext/Name/Messaging with two cards, and both save paths write pstn-permissions.conf correctly (messaging-only leaves tier and allowed_numbers untouched). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh --- docs/pstn-calling-voipms-plan.md | 4 +- services/pstn-trunk.sh | 6 +- services/security-dashboard.sh | 720 +++++++++++++++---------------- 3 files changed, 360 insertions(+), 370 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 667da84..3d68c6a 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -39,7 +39,7 @@ hardcoded to it — any provider supporting IP authentication works. Covers: - A configurable **inbound ring-group** (one extension or several), each member's live tier/approved-numbers checked per inbound call via an unrolled per-member dialplan block (no AGI needed). -- **`services/security-dashboard.sh` integration** — a "PSTN Trunk" tab +- **`services/security-dashboard.sh` integration** — its Extensions tab shows both concurrency caps and every extension (parsed from `pjsip.conf`) with its live tier and approved numbers, all editable with no restart. This is what makes the tier model and caps actually @@ -212,7 +212,7 @@ separately from that hourly check. admin-controlled approved-list in the pattern position and the live call data in the tested-string position — worth keeping that direction if this is ever refactored. -- **Web UI — implemented.** `services/security-dashboard.sh`'s "PSTN Trunk" +- **Web UI — implemented.** `services/security-dashboard.sh`'s Extensions tab lists every extension (parsed from `pjsip.conf`, the same marker format Easy Asterisk's own `rebuild_dialplan()` uses) with a tier dropdown and approved-numbers field, saving straight to `pstn-permissions.conf`. diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 7e8d565..eeab1bc 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -2091,7 +2091,7 @@ access specifically: Stored in \`config/asterisk/pstn-permissions.conf\`, read **live** by the dialplan via Asterisk's \`AST_CONFIG()\` on every call — editing this file -(by hand, or via the Security Dashboard's "PSTN Trunk" tab, if that service +(by hand, or via the Security Dashboard's Extensions tab, if that service is installed) takes effect on the next call, no restart needed. Re-running this installer in "update" mode never touches this file — only a "fresh" reinstall (with confirmation) or the web UI change it, the same protection @@ -2243,11 +2243,11 @@ Stored in \`config/asterisk/pstn-personal-dids.conf\` (DID -> owner, read live by the dialplan for inbound routing) and a \`personal_did=\` field per extension in \`pstn-permissions.conf\` (the outbound Caller-ID override) — both kept in sync automatically by the CLI installer and the Security -Dashboard's "PSTN Trunk" tab, live, no restart needed. +Dashboard's Extensions tab, live, no restart needed. ## Managing this from a web UI -If \`services/security-dashboard.sh\` is installed, its "PSTN Trunk" tab +If \`services/security-dashboard.sh\` is installed, its Extensions tab shows the per-extension permission tiers, the outbound/inbound concurrency caps, and personal-number assignments, all editable live — no restart, no reinstall. Install/update it any time with \`sudo ./setup.sh diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 0438726..a524027 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -64,16 +64,17 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then fi # ───────────────────────────────────────────────────────────────────────────── -register_service security-dashboard homelab "Security dashboard: Asterisk failed-connections + CrowdSec bans (Authelia-protected)" 8092 +register_service security-dashboard homelab "Security dashboard: Asterisk failed-connections + extension/trunk management + CrowdSec bans (Authelia-protected)" 8092 install_security-dashboard() { local APP_DIR="/opt/security-dashboard" local DASHBOARD_PORT=8092 local SVC_USER="secdash" - # Either Asterisk flavor works — prefer asterisk-digital-ocean if both - # happen to be installed, matching services/pstn-trunk.sh's own - # preference order for consistency. + # Either install layout works — prefer ~/docker/asterisk-digital-ocean + # (a droplet from before that service was merged into `asterisk`) if both + # happen to exist, matching services/pstn-trunk.sh's own preference order + # for consistency. local ASTERISK_EA_DIR="" if [ -d "$DOCKER_DIR/asterisk-digital-ocean" ]; then ASTERISK_EA_DIR="$DOCKER_DIR/asterisk-digital-ocean" @@ -86,9 +87,10 @@ install_security-dashboard() { # pjsip.conf — see vendor/easy-asterisk/easy-asterisk-v0.10.0.sh's own # CATEGORIES_FILE/ROOMS_FILE constants (/etc/easy-asterisk/*, not # /etc/asterisk/*). ASTERISK_EA_CONTAINER names the actual container to - # `docker exec` into for the native Asterisk Admin tab's writes/CLI - # calls (ea_* functions) — "easy-asterisk-do" for the droplet flavor, - # "easy-asterisk" for LAN, matching each service's own container_name. + # `docker exec` into for the Extensions tab's device writes/CLI calls + # (ea_* functions) — "easy-asterisk", or "easy-asterisk-do" for a droplet + # set up before the two Asterisk services merged, matching whichever + # container_name services/asterisk.sh actually used there. local ASTERISK_EA_CONFIG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/config/easy-asterisk}" local ASTERISK_EA_CONTAINER="" if [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]]; then @@ -100,17 +102,17 @@ install_security-dashboard() { echo "" echo "┌─────────────────────────────────────────────────────────────────┐" echo "│ SECURITY DASHBOARD │" - echo "│ Asterisk failed-connection log + CrowdSec decisions + PSTN │" - echo "│ trunk permissions, one page. Runs natively on the host (not │" - echo "│ Docker) so it can call cscli and read Asterisk's files │" - echo "│ directly. Authelia-protected. │" + echo "│ Asterisk failed-connection log + one Extensions tab (devices, │" + echo "│ categories, rooms, groups, PSTN tiers, DIDs) + CrowdSec bans, │" + echo "│ one page. Runs natively on the host (not Docker) so it can call │" + echo "│ cscli and read Asterisk's files directly. Authelia-protected. │" echo "└─────────────────────────────────────────────────────────────────┘" echo "" if [ -z "$ASTERISK_EA_DIR" ]; then - log_warning "No asterisk-digital-ocean or asterisk install detected." - log_warning "The Security Log, Extensions, Asterisk Admin, and PSTN Trunk tabs will just be" - log_warning "empty/hidden — CrowdSec's tab still works fine." + log_warning "No Asterisk install detected." + log_warning "The Security Log and Extensions tabs will just be empty — CrowdSec's tab" + log_warning "still works fine." fi if [ "$DRY_RUN" = true ]; then @@ -118,7 +120,7 @@ install_security-dashboard() { echo "[DRY-RUN] Would write $APP_DIR/app.py" echo "[DRY-RUN] Would write /etc/sudoers.d/security-dashboard (scoped cscli/systemctl/set-asn-exempt.sh only)" echo "[DRY-RUN] Would write a systemd unit and start it on 0.0.0.0:$DASHBOARD_PORT (firewalled via UFW, not interface binding)" - echo "[DRY-RUN] Would grant read/write access to the detected Asterisk config dir (for the PSTN Trunk tab)" + echo "[DRY-RUN] Would grant read/write access to the detected Asterisk config dir (for the Extensions tab)" echo "[DRY-RUN] Would configure Caddy + Authelia for a domain you'll be prompted for" return 0 fi @@ -202,59 +204,66 @@ protected page. Runs natively on the host (systemd service \`security-dashboard\ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. ## Tabs -The nav only ever shows tabs for things actually present on this box — no -tab for a service you haven't installed. **Security Log** and **Extensions** -are always there (they only need Asterisk itself, detected once at install -time). **Asterisk Admin**, **PSTN Trunk**, and **CrowdSec** each check their -own live install state on every page load and hide their own nav button -entirely if not found, so this one page/URL scales from a bare LAN Asterisk -box (just those first two tabs) up to a full droplet with a trunk and -CrowdSec, without ever showing a tab for something that isn't set up. + +Three tabs: **Security Log**, **Extensions**, **CrowdSec**. The first two are +always there (they only need Asterisk itself, detected once at install time); +CrowdSec checks its own live install state on every page load and hides its +nav button if \`cscli\` isn't found. + +Extensions used to be three separate tabs — *Asterisk Admin*, *Extensions* +and *PSTN Trunk* — which between them listed the same extensions three times: +once as devices with a category/status, once as a row of messaging +checkboxes, and once as permission tiers. They're now one tab with one +extensions table, and each capability adds columns and cards to it instead of +a nav button of its own. That means the page still scales from a bare LAN +Asterisk box up to a full droplet with a trunk, without ever showing a +control for something that isn't set up — you just don't have to remember +which tab a given extension's settings live on. - **Security Log** — parses \`$ASTERISK_LOG_DIR/full\` for SIP auth failures (wrong password, unknown extension, etc.) with timestamp/account/remote IP, sortable per column (click a header to sort, click again to reverse). -- **Asterisk Admin** — a native reimplementation of Easy Asterisk's own - vendored web admin (\`vendor/easy-asterisk/easy-asterisk-v0.10.0.sh\`'s - device/category/room management), not a link or an iframe to that separate - process — one page, one login. Reads \`pjsip.conf\`/\`categories.conf\`/ - \`rooms.conf\` directly (same formats the vendor's own - \`easy-asterisk --rebuild-dialplan\` CLI still generates the dialplan from); - writes go through \`docker exec ... tee\` (root, sudo-gated) instead of a - direct host-side file write, since Easy Asterisk's container writes these - as its own internal user and a host-side write would just be fighting that - ownership again on the next restart. Its nav button only appears once the - live \`/api/ea-status\` check confirms an Asterisk container is actually - reachable. - - **Devices** — add/rename/delete a SIP extension, reassign its category; - live registered/unregistered status per device. +- **Extensions** — one row per extension, merged from \`pjsip.conf\` (which + always works) and, when the Easy Asterisk container is reachable, its own + device list. Columns: Ext, Name, then Category/Status/Transport if that + container is present, then Tier/Approved-numbers if a PSTN trunk dialplan + is installed, then Messaging (always — internal SIP texting has no PSTN + dependency at all: no cost, no carrier, no DID) and a per-row Save. Save + writes tier + approved numbers + messaging together when there's a trunk, + and messaging alone when there isn't. + - **Extensions** — add/rename/delete a SIP extension, reassign its category; + live registered/unregistered status per device. This is a native + reimplementation of Easy Asterisk's own vendored web admin + (\`vendor/easy-asterisk/easy-asterisk-v0.10.0.sh\`'s device/category/room + management), not a link or an iframe to that separate process — one page, + one login. Reads \`pjsip.conf\`/\`categories.conf\`/\`rooms.conf\` directly + (same formats the vendor's own \`easy-asterisk --rebuild-dialplan\` CLI + still generates the dialplan from); writes go through + \`docker exec ... tee\` (root, sudo-gated) instead of a direct host-side + file write, since Easy Asterisk's container writes these as its own + internal user and a host-side write would just be fighting that ownership + again on the next restart. Every write reloads PJSIP and/or rebuilds the + dialplan automatically, the same way the vendored admin's own actions do. - **Categories** — device profiles (an auto-answer default + description). - **Rooms** — ring groups/paging groups; add/remove members per room. - - Every write reloads PJSIP and/or rebuilds the dialplan automatically, the - same way the vendored admin's own actions do. -- **Extensions** — always available, independent of any PSTN trunk. A - **Groups** card lets you name a set of extensions and bulk-enable/disable - messaging for all of them at once — a management convenience only, not a - runtime concept: applying an action just writes the same per-extension - \`pstn-permissions.conf\` key each member's own checkbox would, and - membership changes never retroactively affect anything already applied. - An **Internal SIP messaging** card (a checkbox chip per known extension, - independent of PSTN calling entirely — no cost, no carrier, no DID, no - dependency on a PSTN trunk being installed) sits below it. -- **PSTN Trunk** — its nav button only appears once - \`services/pstn-trunk.sh\`'s dialplan is actually installed - (\`pstn-trunk-dialplan.conf\` present), so it never shows a - real-looking-but-unenforced editor. When present: the outbound/inbound - concurrent-call caps, and every known extension's permission tier - (internal / restricted / full) and, for restricted, its approved numbers — - all editable live, no Asterisk restart, no reinstall, sortable per column. - Also manages personal-number assignments (DID -> owner extension or - group), additive to the shared trunk DID. Writes directly to - \`pstn-limits.conf\` / \`pstn-permissions.conf\` / \`pstn-personal-dids.conf\`, - which the dialplan reads fresh on every call. The spend-cap kill-switch - and international-calling allow-list are deliberately **not** managed - here — CLI-only, via \`sudo ./setup.sh pstn-trunk\` — since both are more - security-sensitive than what this tab already exposes. + - **Groups** — name a set of extensions and bulk-enable/disable messaging + for all of them at once. A management convenience only, not a runtime + concept: applying an action just writes the same per-extension + \`pstn-permissions.conf\` key each member's own checkbox would, and + membership changes never retroactively affect anything already applied. + (A group owning a personal DID *is* evaluated live against current + membership, though — see below.) + - **Concurrent-call caps** and **Personal numbers** appear only once + \`services/pstn-trunk.sh\`'s dialplan is actually installed + (\`pstn-trunk-dialplan.conf\` present), so the page never shows a + real-looking-but-unenforced editor. Caps are the outbound/inbound + concurrent-call limits; personal numbers map a DID to an owner extension + or group, additive to the shared trunk DID. Writes go directly to + \`pstn-limits.conf\` / \`pstn-permissions.conf\` / \`pstn-personal-dids.conf\`, + which the dialplan reads fresh on every call. The spend-cap kill-switch + and international-calling allow-list are deliberately **not** managed + here — CLI-only, via \`sudo ./setup.sh pstn-trunk\` — since both are more + security-sensitive than what this tab already exposes. - **CrowdSec** — its nav button only appears once \`cscli\` is detected on this host. Current bans (\`cscli decisions list\`), a delete/unban button per entry, carrier/ASN + country columns (sortable per column), and @@ -289,8 +298,8 @@ sudo journalctl -u security-dashboard -f \`systemctl restart crowdsec\`, and \`set-asn-exempt.sh\` (root:root, mode 700, installed alongside \`app.py\` — the one thing that edits CrowdSec's Asterisk-scenario YAMLs, since \`secdash\` has no write access to those - root-owned files directly and shouldn't). Asterisk Admin (only added if an - Asterisk install is detected): \`docker exec -i tee\` against + root-owned files directly and shouldn't). Extension/device management (only + added if an Asterisk install is detected): \`docker exec -i tee\` against exactly \`pjsip.conf\`/\`categories.conf\`/\`rooms.conf\`, plus \`asterisk -rx "module reload res_pjsip.so"\`, \`asterisk -rx "pjsip show endpoints"\`, and @@ -386,7 +395,7 @@ _secdash_grant_asterisk_access() { # fresh temp file then renames it into place) — only on the config dir, # not the log dir (no reason for secdash to ever create files there). # _ea_config_dir (categories.conf/rooms.conf) deliberately stays - # read-only — the native Asterisk Admin tab writes those through + # read-only — the Extensions tab writes those through # `docker exec ... tee` instead (see the ea_* functions), not a direct # host-side write, so there's no reason to grant it write access at all. if [ -n "$_config_dir" ] && [ -d "$_config_dir" ]; then @@ -454,13 +463,13 @@ SDSVC _secdash_write_sudoers() { local _svc_user="$1" _ea_container="${2:-}" local _ea_lines="" - # Native Asterisk Admin tab (ea_* functions) — every write goes through + # Extensions tab device management (ea_* functions) — every write goes through # `docker exec -i tee ` instead of a direct # host-side file write (see _secdash_grant_asterisk_access's comment on # why), plus the two Asterisk CLI calls needed after a change and the # live registration-status check. All seven are exact commands, no # wildcards, scoped to the one container actually installed on this box. - # The last line (docker restart) backs the PSTN Trunk tab's "Commit + # The last line (docker restart) backs the Extensions tab's "Commit # Changes" button — see restart_asterisk_container()'s comment for why # that exists (AST_CONFIG() live-reads not always picking up dashboard # edits without a full container restart). @@ -1241,8 +1250,8 @@ def write_permission(ext, tier, numbers_raw, messaging_enabled=False): def write_messaging(ext, enabled): """Sets/clears just the messaging flag for one extension, leaving any tier/allowed_numbers/personal_did untouched. This is the write path for - the standalone "Internal SIP messaging" card, which works whether or - not a PSTN trunk has ever been installed — messaging has no dependency + the Extensions tab's Messaging column when there's no PSTN trunk to + save alongside — messaging works whether or not one has been installed — messaging has no dependency on one (no cost, no carrier, no DID), unlike the calling-permissions table this dashboard otherwise gates behind pstn_installed(). Creates pstn-permissions.conf from scratch if it doesn't exist yet.""" @@ -1373,9 +1382,9 @@ def pstn_installed(): """True only once services/pstn-trunk.sh has actually wired the dialplan in (pstn-trunk-dialplan.conf existing), not just because base Asterisk is present — pjsip.conf/extensions.conf exist either way, so extension names - alone can't tell us this. Without this check the tab would show a real - extension list and a default-but-unenforced 10/10 cap even when there is - no PSTN trunk at all.""" + alone can't tell us this. Without this check the Extensions tab would + show tier columns and a default-but-unenforced 10/10 cap even when there + is no PSTN trunk at all.""" if not ASTERISK_CONFIG_DIR: return False return os.path.isfile(os.path.join(ASTERISK_CONFIG_DIR, "pstn-trunk-dialplan.conf")) @@ -1583,7 +1592,7 @@ def remove_personal_did(did): return True, "Removed %s" % did -# ── Easy Asterisk Admin (native — devices, categories, rooms/ring-groups) ── +# ── Easy Asterisk device management (devices, categories, rooms/ring-groups) ── # Full reimplementation of vendor/easy-asterisk/easy-asterisk-v0.10.0.sh's # vendored web admin (its own separate process, normally reached via its own # port/domain) as native code here instead — one tab, one process, no @@ -1651,7 +1660,7 @@ def ea_rebuild_dialplan(): def restart_asterisk_container(): """Restarts the Easy Asterisk container - the "Commit Changes" button on - the PSTN Trunk tab. Confirmed live: dashboard writes to + the Extensions tab. Confirmed live: dashboard writes to pstn-permissions.conf/pstn-groups.conf/pstn-personal-dids.conf land on disk immediately (readable via a plain `cat` right after saving), but AST_CONFIG() in the dialplan sometimes kept returning a stale value @@ -1662,7 +1671,7 @@ def restart_asterisk_container(): throughout), but the restart reliably clears it, so this button exists instead of requiring every admin to rediscover "just restart it" the hard way. Uses the same ASTERISK_EA_CONTAINER/run_sudo mechanism as the - Easy Asterisk Admin tab's own docker exec calls - no new sudoers scope + Extensions tab's own docker exec calls - no new sudoers scope needed beyond the one line added for this.""" if not ASTERISK_EA_CONTAINER: return False, "No Asterisk container detected on this box" @@ -2258,16 +2267,23 @@ INDEX_HTML = """ .muted { color: #9aa4b2; font-size: 0.85rem; } a { color: #4f8cff; } #msg { margin-top: 0.5rem; font-size: 0.85rem; } + /* Capability gating for the Extensions tab. Everything that needs the Easy + Asterisk container (device/category/room writes) is .ea-only; everything + that needs a PSTN trunk dialplan is .pstn-only. Both classes start ON the + body so nothing flashes before /api/ea-status and /api/pstn-status answer, + and they're removed once those confirm. Marking cells rather than juggling + column indices keeps the one extensions table honest as columns come and + go. */ + body.no-ea .ea-only { display: none !important; } + body.no-pstn .pstn-only { display: none !important; } - +

Security Dashboard

@@ -2309,35 +2325,7 @@ INDEX_HTML = """ -