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]}) ==="