From d8072b61f4d8a4c809ee24fe0a839a377d5ab5a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 02:47:24 +0000 Subject: [PATCH 1/6] Add asterisk-do: Easy Asterisk PBX tuned for a DigitalOcean droplet Duplicates services/asterisk.sh (left untouched) into a DO-specific variant: auto-detects the droplet's public IP/ID via the DO metadata service, always assumes a public FQDN (no LAN/VLAN prompts), offers to provision a matching DigitalOcean Cloud Firewall via doctl (never touching one that's already attached), and documents droplet sizing, firewall rules, and Sipnetic client setup in the generated README. --- README.md | 3 +- services/asterisk-do.sh | 626 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 628 insertions(+), 1 deletion(-) create mode 100755 services/asterisk-do.sh diff --git a/README.md b/README.md index 1b197b6..dcedb7d 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`, `sunshine` | +| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `asterisk-do`, `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,6 +91,7 @@ homelab authelia homeassistant asterisk + asterisk-do sunshine utilities diff --git a/services/asterisk-do.sh b/services/asterisk-do.sh new file mode 100755 index 0000000..12d7332 --- /dev/null +++ b/services/asterisk-do.sh @@ -0,0 +1,626 @@ +#!/bin/bash +# services/asterisk-do.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-do.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}'" + } + + 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 + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + 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-do homelab "Easy Asterisk PBX + coturn, tuned for a public DigitalOcean droplet" 5061 + +install_asterisk-do() { + require_docker || return 1 + log_info "Installing Easy Asterisk PBX + coturn (DigitalOcean droplet edition)..." + + local EA_DIR="$DOCKER_DIR/asterisk-do" + + 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 detect droplet public IP via DO metadata service" + echo "[DRY-RUN] Would open UFW ports: 5060, 5061, 8080, 8088, 8089, 3478, 10000-20000, 49152-49252" + echo "[DRY-RUN] Would offer to create a DigitalOcean Cloud Firewall via doctl" + return 0 + 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" + ensure_docker_dir_ownership "$EA_DIR" + cd "$EA_DIR" || return 1 + + mkdir -p docker + + # ── Vendor files (shared with services/asterisk.sh — no duplication) ────── + 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 + 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 + 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 + + # ── 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:-}" + local DOMAIN_NAME="" + prompt_text "FQDN for this PBX [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" + + # ── docker-compose.yml ──────────────────────────────────────────────────── + 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 +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 + + # ── .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 ───────────────────────────────────────────────── +WEB_ADMIN_PORT=8080 +WEB_ADMIN_AUTH_DISABLED=false +ENV + chmod 600 .env + + # ── 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 + ufw allow 8080/tcp + 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 + 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" + "protocol:tcp,ports:8080,address:0.0.0.0/0,address:::/0" + "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-do" \ + --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-do' 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 + + # ── Caddy reverse proxy for web admin ───────────────────────────────────── + 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" "8080" "asterisk" "$EXTRA_BLOCK" + + # ── 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), 1 GB | 1 | 1 GB | Minimum — a few extensions, light use | +| **Basic (regular), 2 GB — recommended** | 1 | 2 GB | Comfortable headroom for Docker + a handful of concurrent calls | +| Basic (regular), 4 GB | 2 | 4 GB | Several simultaneous calls, conference bridges, transcoding | + +25–50 GB SSD (the smallest included disk) is plenty — this stack is not +storage-heavy. Any DO region close to where the phones actually are is fine; +SIP/RTP care about latency more than raw bandwidth. + +**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: + +\`\`\` +asterisk.yourdomain.com A +\`\`\` + +The installer reads the droplet's public IP itself (via the DigitalOcean +metadata service) and shows it to you during setup. + +## 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. +- Consider adding \`crowdsec\` (services/crowdsec.sh, also in this repo) for + intrusion-prevention against SIP brute-force/scanning, which is constant + background noise on any public SIP port. +- Consider DO's automated backups/snapshots for the droplet so a bad config + change or compromise is a quick rollback. + +### 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 | +| 8080 | TCP | Easy Asterisk web admin | +| 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 | + +## 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 + +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://:8080 +or via your configured reverse-proxy domain. + +## Data directories (all inside ~/docker/asterisk-do/, 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 + + # ── 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}:8080" + echo " Manage: docker compose -f $EA_DIR/docker-compose.yml " + echo " Script: docker exec -it easy-asterisk-do easy-asterisk --help" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_asterisk-do From 4f5f264a96afe9088f1ac829ea9c9991c3c19b74 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 02:57:09 +0000 Subject: [PATCH 2/6] asterisk-do: auto-add swap on low-RAM droplets, document the $4/mo tier DigitalOcean doesn't provision swap by default and the $4/mo (512MB) droplet has little headroom once Docker + Asterisk + coturn are running. The installer now detects RAM <=2GB with no existing swap and offers to add a persistent 2GB swapfile before doing anything else, so that tier is safe to use instead of risking an OOM kill under load. README updated with the corrected sizing table. --- services/asterisk-do.sh | 54 +++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/services/asterisk-do.sh b/services/asterisk-do.sh index 12d7332..a04c938 100755 --- a/services/asterisk-do.sh +++ b/services/asterisk-do.sh @@ -204,6 +204,7 @@ install_asterisk-do() { local EA_DIR="$DOCKER_DIR/asterisk-do" 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" @@ -212,6 +213,35 @@ install_asterisk-do() { return 0 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" @@ -464,15 +494,23 @@ instead. Asterisk + coturn is light for a handful of SIP extensions and personal use. -| Plan | vCPU | RAM | Good for | -|--------------------------------|------|------|----------------------------------------| -| Basic (regular), 1 GB | 1 | 1 GB | Minimum — a few extensions, light use | -| **Basic (regular), 2 GB — recommended** | 1 | 2 GB | Comfortable headroom for Docker + a handful of concurrent calls | -| Basic (regular), 4 GB | 2 | 4 GB | Several simultaneous calls, conference bridges, transcoding | +| 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 | -25–50 GB SSD (the smallest included disk) is plenty — this stack is not -storage-heavy. Any DO region close to where the phones actually are is fine; -SIP/RTP care about latency more than raw bandwidth. +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 From dfc298f6ab6106ec5d7b7ac2e405b8320dd142d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 03:20:36 +0000 Subject: [PATCH 3/6] Wire crowdsecurity/asterisk into crowdsec.sh for asterisk-do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendor's logger.conf only sent Asterisk's security-level log lines (auth failures, SIP registration scanning) to the console, i.e. Docker's stdout — not a file CrowdSec could tail. asterisk-do.sh now patches its copy of entrypoint.sh (vendor/ untouched) to also write those events to /var/log/asterisk/full, which is bind-mounted to ~/docker/asterisk-do/logs/full on the host. crowdsec.sh now detects that directory and, if present, installs the crowdsecurity/asterisk collection (asterisk_bf + asterisk_user_enum scenarios) with a matching log acquisition — mirroring the existing Caddy detection pattern. Order-independent: asterisk-do's install summary tells the user to rerun crowdsec if it's already installed, since detection only runs during crowdsec's own install step. --- services/asterisk-do.sh | 22 ++++++++++++++++++++++ services/crowdsec.sh | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/services/asterisk-do.sh b/services/asterisk-do.sh index a04c938..cdf0670 100755 --- a/services/asterisk-do.sh +++ b/services/asterisk-do.sh @@ -275,6 +275,20 @@ install_asterisk-do() { chmod 755 ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh \ ./docker/entrypoint.sh ./docker/coturn-entrypoint.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 — that's Docker's stdout, not a file + # CrowdSec/fail2ban can tail. Patch our copy of entrypoint.sh (not the + # shared vendor/ source) so it also writes those events to + # /var/log/asterisk/full, which is bind-mounted to $EA_DIR/logs/full — a + # host path services/crowdsec.sh can point its Asterisk acquisition at. + 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 + # ── 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. @@ -658,6 +672,14 @@ MD echo " Manage: docker compose -f $EA_DIR/docker-compose.yml " echo " Script: docker exec -it easy-asterisk-do easy-asterisk --help" echo "" + if command -v cscli &>/dev/null; then + log_info "CrowdSec is already installed — rerun it to add SIP brute-force protection for this install:" + log_info " sudo ./setup.sh crowdsec" + else + log_info "Install CrowdSec (services/crowdsec.sh) for SIP brute-force/enumeration protection —" + log_info " it auto-detects this install and wires up the crowdsecurity/asterisk collection." + fi + echo "" } # Run immediately when executed directly (deferred until after function definition) diff --git a/services/crowdsec.sh b/services/crowdsec.sh index 34d5f72..f51ee3b 100644 --- a/services/crowdsec.sh +++ b/services/crowdsec.sh @@ -102,6 +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-do is installed" echo "[DRY-RUN] Would optionally wire ntfy ban alerts into the default profile" echo "[DRY-RUN] Would enable + restart crowdsec and crowdsec-firewall-bouncer" echo "[DRY-RUN] Would write $DOCS_DIR/README.md (docs-only folder)" @@ -157,6 +158,34 @@ labels: echo " ✓ Caddy acquisition already exists" fi + # ── 5b. SIP brute-force/enumeration protection, if asterisk-do is installed + # (services/asterisk-do.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-do/logs" + if [ -d "$ASTERISK_LOG_DIR" ]; then + echo " Detected asterisk-do — installing SIP brute-force/enumeration protection..." + sudo cscli collections install crowdsecurity/asterisk 2>/dev/null || \ + echo " ⚠ crowdsecurity/asterisk collection may already be installed" + + local ASTERISK_ACQUIS="/etc/crowdsec/acquis.d/asterisk-do.yaml" + if [ ! -f "$ASTERISK_ACQUIS" ]; then + local ASTERISK_ACQUIS_CONTENT="filenames: + - $ASTERISK_LOG_DIR/full + - $ASTERISK_LOG_DIR/full.* +labels: + type: asterisk" + if echo "$ASTERISK_ACQUIS_CONTENT" | sudo tee "$ASTERISK_ACQUIS" > /dev/null; then + echo " ✓ Created Asterisk acquisition ($ASTERISK_ACQUIS)" + else + echo " ⚠ Failed to create Asterisk acquisition - create it manually" + fi + else + echo " ✓ Asterisk acquisition already exists" + fi + fi + # ── 6. Geo-blocking + reputation (the capability fail2ban/Authelia lack) ─ echo "" echo " Geo-blocking & IP reputation (optional):" @@ -233,7 +262,8 @@ install. The real configuration lives under `/etc/crowdsec`. ## What it does -- Detects malicious behaviour (SSH brute force, web scans, etc.) by parsing logs. +- Detects malicious behaviour (SSH brute force, web scans, SIP brute + force/enumeration if `asterisk-do` 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. @@ -255,6 +285,9 @@ 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-do` is installed): + `/etc/crowdsec/acquis.d/asterisk-do.yaml` + (`~/docker/asterisk-do/logs/full` — auth failures, registration scans) - Notifications: `/etc/crowdsec/notifications/` - ntfy ban alerts (if enabled): `/etc/crowdsec/notifications/ntfy.yaml`, wired into `/etc/crowdsec/profiles.yaml` From 5eddab9f9a1aa901c44419b7f60679e455c463da Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 03:48:34 +0000 Subject: [PATCH 4/6] asterisk-do: auto-install Caddy/CrowdSec, fix confusing domain prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained by default now: if Caddy or CrowdSec aren't already on the box, asterisk-do offers to install them itself (calling their install_ functions directly — setup.sh sources every services/*.sh up front, so they're already in-process during a wizard run). Standalone single-file runs get a manual pointer instead, since those functions don't exist outside the full repo checkout. Also fixes the confusing "Configure Caddy reverse proxy for Asterisk Web Admin" domain prompt: it used to ask for a second, independent domain, which silently breaks the TLS cert sync if it doesn't match the SIP FQDN exactly (Caddy only holds a cert for the domain it's actually serving). It now always reuses the SIP FQDN automatically — reconstructing configure_caddy_for_service's subdomain default so the common case (SIP domain is a subdomain of SITE_DOMAIN) needs zero extra input, with clear wording either way. FQDN prompt, README, and final summary updated to match. --- services/asterisk-do.sh | 120 +++++++++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 27 deletions(-) diff --git a/services/asterisk-do.sh b/services/asterisk-do.sh index cdf0670..c6061c5 100755 --- a/services/asterisk-do.sh +++ b/services/asterisk-do.sh @@ -205,11 +205,14 @@ install_asterisk-do() { if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would add a swapfile if RAM <= 2048MB and none exists" + echo "[DRY-RUN] Would offer to install Caddy if not already present (full repo only)" 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 open UFW ports: 5060, 5061, 8080, 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 (needed for cert sync)" + echo "[DRY-RUN] Would offer to install CrowdSec if not already present (full repo only)" return 0 fi @@ -242,6 +245,24 @@ install_asterisk-do() { fi fi + # ── Bring in Caddy automatically, if this is a full repo checkout ───────── + # Caddy is a separate service (services/caddy.sh); asterisk-do only + # *integrates* with it (reused certs, reverse-proxied admin) unless + # offered here. setup.sh sources every services/*.sh file up front, so + # install_caddy already exists in-process when running through the + # wizard — a standalone single-file run doesn't have it, so that case + # gets a manual pointer instead. + if [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -z "${CADDY_REMOTE_HOST:-}" ]]; then + if declare -F install_caddy &>/dev/null; then + local WANT_CADDY="" + prompt_yn "Caddy not detected — install it now for a trusted TLS cert + reverse proxy? (y/n):" "y" WANT_CADDY + [[ "$WANT_CADDY" =~ ^[Yy]$ ]] && install_caddy + else + log_warning "Caddy not detected, and this looks like a standalone copy of asterisk-do.sh." + log_warning "Grab the full repo to auto-install it, or run services/caddy.sh yourself." + 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" @@ -312,8 +333,13 @@ install_asterisk-do() { 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 [blank=self-signed cert, IP-only access]:" "" 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 ─────────────────────────────────────────────────────────────── @@ -482,18 +508,54 @@ ENV printf ' %s\n' "${DO_FW_RULES[@]}" fi - # ── Caddy reverse proxy for web admin ───────────────────────────────────── - 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 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. + if [[ -z "$DOMAIN_NAME" ]]; then + log_info "No FQDN set — web admin stays on http://${PUBLIC_IP:-localhost}:8080 (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}:8080, 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 fi + + # Reconstruct the subdomain-only fragment so configure_caddy_for_service's + # own ".${SITE_DOMAIN}" default lands exactly on $DOMAIN_NAME — + # pressing Enter at its domain prompt then just works. + local _CADDY_SUBDOMAIN="asterisk" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$DOMAIN_NAME" == *".${SITE_DOMAIN}" ]]; then + _CADDY_SUBDOMAIN="${DOMAIN_NAME%.${SITE_DOMAIN}}" + fi + + log_info "Reverse-proxying the web admin at https://${DOMAIN_NAME}/ — this is also what gets" + log_info "Asterisk a trusted TLS cert for SIP instead of a self-signed one." + log_info "When prompted for a domain next, use exactly: ${DOMAIN_NAME}" + configure_caddy_for_service "Asterisk Web Admin" "8080" "$_CADDY_SUBDOMAIN" "$EXTRA_BLOCK" + fi + + # ── CrowdSec: SIP brute-force/enumeration protection ────────────────────── + 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 declare -F install_crowdsec &>/dev/null; then + local WANT_CS="" + prompt_yn "CrowdSec not detected — install it now for SSH + SIP intrusion prevention? (y/n):" "y" WANT_CS + [[ "$WANT_CS" =~ ^[Yy]$ ]] && install_crowdsec + else + log_warning "CrowdSec not detected, and this looks like a standalone copy of asterisk-do.sh." + log_warning "Grab the full repo to auto-install it, or run services/crowdsec.sh yourself." fi - configure_caddy_for_service "Asterisk Web Admin" "8080" "asterisk" "$EXTRA_BLOCK" # ── README ──────────────────────────────────────────────────────────────── write_readme "$EA_DIR" << MD @@ -535,11 +597,13 @@ and supported longer (through 2031) if you'd rather track the newer LTS. Before running this installer, point an A record at the droplet's public IP: \`\`\` -asterisk.yourdomain.com A +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. +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 @@ -622,13 +686,15 @@ users: Linphone or Zoiper cover the same ground.) ## 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). +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 @@ -671,13 +737,13 @@ MD echo " Web admin: http://${PUBLIC_IP:-localhost}:8080" echo " Manage: docker compose -f $EA_DIR/docker-compose.yml " echo " Script: docker exec -it easy-asterisk-do easy-asterisk --help" - echo "" - if command -v cscli &>/dev/null; then - log_info "CrowdSec is already installed — rerun it to add SIP brute-force protection for this install:" - log_info " sudo ./setup.sh crowdsec" - else - log_info "Install CrowdSec (services/crowdsec.sh) for SIP brute-force/enumeration protection —" - log_info " it auto-detects this install and wires up the crowdsecurity/asterisk collection." + 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 "" } From af7fe3628331c8a414f3a302656dad3e3d3b4c63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 04:02:09 +0000 Subject: [PATCH 5/6] asterisk-do: wire in authelia/ntfy/watchtower/wg-easy, borg-backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the self-contained pattern from Caddy/CrowdSec to five more services, offered through one consolidated "Install:" prompt instead of five separate interruptions: - authelia: only offered if Caddy is present (it's useless without Caddy's forward-auth snippet); dispatched right where Caddy's state is already known. - wg-easy: installed alongside the other firewall rules so its port lands with them. Only 51820/udp (the VPN handshake) goes on the public firewall — the web UI (51821) is deliberately left closed, documented as reachable via SSH tunnel instead, since exposing a VPN's own admin panel publicly is a real foot-gun. - ntfy, watchtower: independent, dispatched after CrowdSec. Watchtower section is explicit that it only benefits coturn (a pulled image) — Asterisk is a local Dockerfile build with no registry tag to check. - backup (borg-backup): dispatched last. Documented clearly as a config/data backup to a local machine or SSH remote, not a full droplet image — the alternative to DO's paid Droplet Backups. Every sub-install this calls does its own `cd` into ~/docker/; each call site restores `cd "$EA_DIR"` afterward so the later bare `docker compose up -d --build` still targets the right directory. Verified in isolation (mocked cd side effects) since driving five real interactive sub-installs through piped stdin isn't practical. README updated with an "Optional extras" section covering all five. --- services/asterisk-do.sh | 119 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 5 deletions(-) diff --git a/services/asterisk-do.sh b/services/asterisk-do.sh index c6061c5..83c7ffa 100755 --- a/services/asterisk-do.sh +++ b/services/asterisk-do.sh @@ -206,10 +206,12 @@ install_asterisk-do() { if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would add a swapfile if RAM <= 2048MB and none exists" echo "[DRY-RUN] Would offer to install Caddy if not already present (full repo only)" + echo "[DRY-RUN] Would offer optional extras: authelia, ntfy, watchtower, wg-easy, backup" 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 open UFW ports: 5060, 5061, 8080, 8088, 8089, 3478, 10000-20000, 49152-49252" + echo "[DRY-RUN] Would open 51820/udp (not 51821) if wg-easy was selected" 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 (needed for cert sync)" echo "[DRY-RUN] Would offer to install CrowdSec if not already present (full repo only)" @@ -263,6 +265,36 @@ install_asterisk-do() { fi fi + # ── Optional extras ───────────────────────────────────────────────────── + # One prompt instead of five separate yes/no interruptions. Each is + # dispatched at the point later in this function where it actually makes + # sense (Authelia needs Caddy's state, which we just resolved above; + # wg-easy needs its own firewall rules alongside the others; backup makes + # most sense last). Only offered in full-repo mode, same reasoning as Caddy. + local EXTRAS="" + if declare -F install_authelia &>/dev/null; then + echo "" + echo " Optional extras (space-separated, blank = skip all):" + echo " authelia SSO/2FA in front of the web admin (needs Caddy, above)" + echo " ntfy self-hosted push notifications (e.g. CrowdSec ban alerts)" + echo " watchtower auto-updates pulled images — only helps coturn here;" + echo " Asterisk itself is built locally, so OS security patches" + echo " still need a manual 'docker compose up -d --build'" + echo " wg-easy WireGuard VPN — lets you lock the web admin to VPN-only later" + echo " backup Borg backup of ~/docker/* to a local machine or remote host —" + echo " config/data backup, NOT a full droplet image, instead of" + echo " DigitalOcean's paid Droplet Backups" + prompt_text "Install:" "" EXTRAS + fi + + if [[ "$EXTRAS" == *authelia* ]] && declare -F install_authelia &>/dev/null; then + if [[ -d "$DOCKER_DIR/caddy" ]]; then + install_authelia + else + log_warning "Skipping Authelia — it needs Caddy, which isn't installed." + 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" @@ -458,6 +490,22 @@ ENV ufw allow 3478/tcp ufw allow 10000:20000/udp ufw allow 49152:49252/udp + fi + + # ── wg-easy: install now so its firewall rule lands with the others ─────── + # Only the VPN port (51820/udp) goes on the public firewall — WireGuard's + # handshake is designed to be internet-facing. The web UI (51821) does + # NOT get opened publicly: it's a full account-management panel for the + # VPN, so it's reached via SSH tunnel instead (documented in the README). + if [[ "$EXTRAS" == *wg-easy* ]] && declare -F install_wg-easy &>/dev/null; then + install_wg-easy + cd "$EA_DIR" || return 1 # install_wg-easy cd's into ~/docker/wg-easy + if command -v ufw &>/dev/null; then + ufw allow 51820/udp + fi + fi + + if command -v ufw &>/dev/null; then log_success "UFW rules added." fi @@ -474,6 +522,7 @@ ENV "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" ) + [[ "$EXTRAS" == *wg-easy* ]] && DO_FW_RULES+=("protocol:udp,ports:51820,address:0.0.0.0/0,address:::/0") echo "" if [[ -n "$DROPLET_ID" ]] && command -v doctl &>/dev/null && doctl account get &>/dev/null; then @@ -557,6 +606,29 @@ ENV log_warning "Grab the full repo to auto-install it, or run services/crowdsec.sh yourself." fi + # ── ntfy / watchtower (independent extras, selected earlier) ────────────── + if [[ "$EXTRAS" == *ntfy* ]] && declare -F install_ntfy &>/dev/null; then + install_ntfy + cd "$EA_DIR" || return 1 # install_ntfy cd's into ~/docker/ntfy + fi + if [[ "$EXTRAS" == *watchtower* ]] && declare -F install_watchtower &>/dev/null; then + install_watchtower + cd "$EA_DIR" || return 1 # install_watchtower cd's into ~/docker/watchtower + fi + + # ── Backup: config/data to a local machine or remote host ───────────────── + # Not a full droplet image — it's ~/docker/* (this PBX's config, extensions, + # CDRs, and every other installed service here) via Borg, which supports + # local paths or user@host:/path SSH remotes. That's the alternative to + # DigitalOcean's paid Droplet Backups: point it at your own machine instead + # of paying DO to store snapshots. Disaster recovery then becomes "fresh + # droplet, rerun this installer, restore from the Borg repo" rather than + # restoring a multi-GB disk image. + if [[ "$EXTRAS" == *backup* ]] && declare -F install_borg-backup &>/dev/null; then + install_borg-backup + cd "$EA_DIR" || return 1 # install_borg-backup cd's into ~/docker/borg-backup + fi + # ── README ──────────────────────────────────────────────────────────────── write_readme "$EA_DIR" << MD # Easy Asterisk PBX + coturn — DigitalOcean droplet edition @@ -619,11 +691,11 @@ plan for the admin panel. 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. -- Consider adding \`crowdsec\` (services/crowdsec.sh, also in this repo) for - intrusion-prevention against SIP brute-force/scanning, which is constant - background noise on any public SIP port. -- Consider DO's automated backups/snapshots for the droplet so a bad config - change or compromise is a quick rollback. +- **CrowdSec** — SIP brute-force/enumeration protection (\`crowdsecurity/asterisk\` + collection). Offered automatically during install if not already present; + see \`services/crowdsec.sh\`. +- DO's paid Droplet Backups is one option for a rollback path — the + \`backup\` extra below is the alternative used here. ### Ports (open on both the Cloud Firewall and UFW) @@ -637,6 +709,43 @@ plan for the admin panel. | 3478 | UDP/TCP | TURN/STUN (coturn) | | 10000–20000 | UDP | RTP media streams | | 49152–49252 | UDP | TURN relay media ports | +| 51820 | UDP | WireGuard VPN, only if \`wg-easy\` was selected | + +## Optional extras + +Offered during install (space-separated at the "Install:" prompt); can also +be added later by running \`sudo ./setup.sh \` from the repo. + +- **authelia** — SSO/2FA in front of the web admin. Needs Caddy. Once + installed, re-running \`asterisk-do\` will offer to protect the web admin + with it. +- **ntfy** — self-hosted push notifications. Useful as a destination for + CrowdSec ban alerts (\`services/crowdsec.sh\` prompts for an ntfy URL — + point it at this instance instead of the public ntfy.sh if you'd rather + keep alerts off a third party). +- **watchtower** — auto-updates pulled Docker images daily. Only helps + \`coturn\` here — Asterisk's image is built locally from a Dockerfile, so + Watchtower has no registry tag to check against. Keep Asterisk patched + with an occasional \`docker compose up -d --build\`. +- **wg-easy** — WireGuard VPN. Only its VPN port (51820/udp) is opened on + the public firewall; the web UI (51821) is deliberately **not** exposed — + reach it via SSH tunnel: \`ssh -L 51821:localhost:51821 user@\`, + then browse \`http://localhost:51821\`. A natural next step once it's + installed: restrict the web admin (8080) to the VPN subnet only, on both + firewall layers, so reconfiguring the PBX requires being on the VPN — + done manually, not automatically, since a firewall mistake there can lock + you out. +- **backup** — Borg backup of \`~/docker/*\` (this PBX's config, extensions, + CDRs, and every other service on this droplet) to a **local path or a + remote host over SSH** (\`user@host:/path\`) — see \`services/borg-backup.sh\`. + This is the alternative to DigitalOcean's paid Droplet Backups: point it + at your own machine and pay nothing extra to DO. It is **not** a full + droplet disk image — disaster recovery is "fresh droplet, rerun this + installer, restore the Borg repo," which is faster and more portable than + restoring a multi-GB snapshot. Pointing it at a home machine that isn't + reachable by a stable public IP works best over a mesh VPN (this repo's + optional NetBird overlay, set up via \`services/base.sh\`) rather than + port-forwarding SSH on a home router. ## Manage From 36e56c343b43723aa0809eded3f3dffdf33232dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 05:04:02 +0000 Subject: [PATCH 6/6] asterisk-do: add netbird to the optional extras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'netbird' keyword to the existing extras prompt, dispatching services/base.sh's _base_setup_netbird helper — a plain function like any other once setup.sh sources every services/*.sh file, despite its underscore-prefixed, not-independently-registered naming. Its own prompt already defaults to enabling NetBird's built-in SSH server (--allow-server-ssh), which is what makes the 'backup' extra usable against a home machine without port-forwarding a router: install NetBird here and on that machine, join both to the same network, and Borg's SSH remote target becomes the home machine's mesh IP instead of a public address. Skips cleanly if NetBird's already installed. README's Optional extras section documents the pairing. --- services/asterisk-do.sh | 44 +++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/services/asterisk-do.sh b/services/asterisk-do.sh index 83c7ffa..006cdb0 100755 --- a/services/asterisk-do.sh +++ b/services/asterisk-do.sh @@ -206,7 +206,7 @@ install_asterisk-do() { if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would add a swapfile if RAM <= 2048MB and none exists" echo "[DRY-RUN] Would offer to install Caddy if not already present (full repo only)" - echo "[DRY-RUN] Would offer optional extras: authelia, ntfy, watchtower, wg-easy, backup" + echo "[DRY-RUN] Would offer optional extras: authelia, ntfy, watchtower, wg-easy, netbird, backup" 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" @@ -281,6 +281,9 @@ install_asterisk-do() { echo " Asterisk itself is built locally, so OS security patches" echo " still need a manual 'docker compose up -d --build'" echo " wg-easy WireGuard VPN — lets you lock the web admin to VPN-only later" + echo " netbird Mesh VPN + optional built-in SSH server, so this droplet and a" + echo " home machine can reach each other without port-forwarding —" + echo " pairs with 'backup' below" echo " backup Borg backup of ~/docker/* to a local machine or remote host —" echo " config/data backup, NOT a full droplet image, instead of" echo " DigitalOcean's paid Droplet Backups" @@ -616,6 +619,26 @@ ENV cd "$EA_DIR" || return 1 # install_watchtower cd's into ~/docker/watchtower fi + # ── NetBird: mesh VPN + optional built-in SSH server ─────────────────────── + # _base_setup_netbird lives in services/base.sh (not its own register_service + # entry — it's a helper base.sh calls on first run), but it's a plain + # function like any other once sourced, so it's callable here the same way. + # Doesn't cd anywhere, so no directory restore needed after it. Its own + # prompt already defaults "enable NetBird's built-in SSH server" to yes — + # that's the piece that lets 'backup' below reach a home machine without + # port-forwarding, IF the home machine also joins the same NetBird network + # (a separate, manual step on that machine — this only sets up this droplet). + if [[ "$EXTRAS" == *netbird* ]]; then + if command -v netbird &>/dev/null; then + log_info "NetBird is already installed on this droplet." + elif declare -F _base_setup_netbird &>/dev/null; then + _base_setup_netbird + else + log_warning "NetBird setup not found, and this looks like a standalone copy of asterisk-do.sh." + log_warning "Grab the full repo to auto-install it, or run services/base.sh yourself." + fi + fi + # ── Backup: config/data to a local machine or remote host ───────────────── # Not a full droplet image — it's ~/docker/* (this PBX's config, extensions, # CDRs, and every other installed service here) via Borg, which supports @@ -623,7 +646,8 @@ ENV # DigitalOcean's paid Droplet Backups: point it at your own machine instead # of paying DO to store snapshots. Disaster recovery then becomes "fresh # droplet, rerun this installer, restore from the Borg repo" rather than - # restoring a multi-GB disk image. + # restoring a multi-GB disk image. Pair with 'netbird' above so the SSH + # remote target is a private mesh IP instead of needing a port-forward. if [[ "$EXTRAS" == *backup* ]] && declare -F install_borg-backup &>/dev/null; then install_borg-backup cd "$EA_DIR" || return 1 # install_borg-backup cd's into ~/docker/borg-backup @@ -735,6 +759,15 @@ be added later by running \`sudo ./setup.sh \` from the repo. firewall layers, so reconfiguring the PBX requires being on the VPN — done manually, not automatically, since a firewall mistake there can lock you out. +- **netbird** — mesh VPN (via \`services/base.sh\`'s \`_base_setup_netbird\` + helper). Its own prompt defaults to enabling NetBird's **built-in SSH + server** (\`--allow-server-ssh\`) on this droplet. Install NetBird on a + home machine too (separately, outside this installer — same + \`curl -fsSL https://pkgs.netbird.io/install.sh | sh\`, then + \`netbird up --setup-key --allow-server-ssh\`) and join it to the + same network, and the two machines get a private mesh IP to reach each + other over — no router port-forwarding, no public SSH exposure on either + end. That mesh IP is what \`backup\` below should target. - **backup** — Borg backup of \`~/docker/*\` (this PBX's config, extensions, CDRs, and every other service on this droplet) to a **local path or a remote host over SSH** (\`user@host:/path\`) — see \`services/borg-backup.sh\`. @@ -742,10 +775,9 @@ be added later by running \`sudo ./setup.sh \` from the repo. at your own machine and pay nothing extra to DO. It is **not** a full droplet disk image — disaster recovery is "fresh droplet, rerun this installer, restore the Borg repo," which is faster and more portable than - restoring a multi-GB snapshot. Pointing it at a home machine that isn't - reachable by a stable public IP works best over a mesh VPN (this repo's - optional NetBird overlay, set up via \`services/base.sh\`) rather than - port-forwarding SSH on a home router. + restoring a multi-GB snapshot. If the destination is a home machine + without a stable public IP, install \`netbird\` (above) first and use its + mesh IP as the SSH host instead of port-forwarding a home router. ## Manage