Files
ubuntu-post-install/services/asterisk.sh
T
Claude 225e277abb asterisk: default to FQDN mode, auto-detect VLANs, auto-sync Caddy certs
Defaults install_asterisk() to FQDN networking mode and prompts for VLAN/VPN
subnets (with host-network auto-detection to filter out noise like Docker
bridges) so phones on other networks get correct NAT/SDP handling from the
first boot.

The container now mounts Caddy's cert store read-only when Caddy is
installed, and the entrypoint syncs a matching Let's Encrypt cert for
DOMAIN_NAME automatically, re-checking every 12h to pick up renewals without
a restart. Falls back to self-signed only when no matching cert is found.

Also fixes a real bug hit in the field: a preserved/migrated pjsip.conf could
be missing the transport-udp/transport-tcp sections entirely, with no bind
error logged, silently blocking any device that registers without TLS. Adds
the same migration-injection already used for transport-tls.
2026-07-01 17:19:57 +00:00

534 lines
23 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# services/asterisk.sh — Easy Asterisk PBX + coturn TURN server (home intercom/VoIP).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash asterisk.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 homelab "Easy Asterisk PBX + coturn TURN server (home intercom/VoIP)" 5061
install_asterisk() {
require_docker || return 1
log_info "Installing Easy Asterisk PBX + coturn..."
local EA_DIR="$DOCKER_DIR/asterisk"
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 open UFW ports: 5060, 5061, 8080, 8088, 8089, 3478, 10000-20000, 49152-49252"
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 ──────────────────────────────────────────────────────────
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
# ── 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
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
# ── 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."
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)"
local TURN_SERVER_VAL=""
[[ -n "$DOMAIN_NAME" ]] && TURN_SERVER_VAL="${DOMAIN_NAME}:3478"
# ── docker-compose.yml ────────────────────────────────────────────────────
cat > docker-compose.yml << 'EOF'
name: asterisk
services:
asterisk:
build: .
container_name: easy-asterisk
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-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 ────────────────────────────────────────────────────
# Set to your FQDN for remote access. Leave empty for LAN-only.
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
TURN_SERVER=${TURN_SERVER_VAL}
# ── RTP port range ────────────────────────────────────────────
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.
HAS_VLANS=${HAS_VLANS_VAL}
VLAN_SUBNETS=${VLAN_SUBNETS_VAL}
# ── Web admin ─────────────────────────────────────────────────
WEB_ADMIN_PORT=8080
WEB_ADMIN_AUTH_DISABLED=false
ENV
chmod 600 .env
# ── UFW firewall rules ────────────────────────────────────────────────────
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
# ── 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
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 | <host-ip> (LAN) or your FQDN (FQDN) |
| SIP port | 5061 (TLS) / 5060 (UDP) |
| TURN server | <DOMAIN_NAME>: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 `<DOMAIN_NAME>: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://<host-ip>:8080
or via your configured reverse-proxy domain.
## 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 |
## Ports
| Port | Protocol | Purpose |
|---------------|----------|----------------------------------|
| 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) |
| 1000020000 | UDP | RTP media streams |
| 4915249252 | UDP | TURN relay media ports |
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 started" \
|| log_warning "Start failed — check: docker compose logs"
fi
# ── Summary ───────────────────────────────────────────────────────────────
echo ""
log_success "Easy Asterisk installed at $EA_DIR"
if [[ -n "$DOMAIN_NAME" ]]; then
echo " Mode: FQDN ($DOMAIN_NAME)"
echo " TURN server: ${DOMAIN_NAME}:3478"
else
echo " Mode: LAN-only"
echo " TURN server: (none — LAN/VPN only)"
fi
echo " SIP port: 5061 (TLS) / 5060 (UDP)"
echo " Web admin: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):8080"
echo " Manage: docker compose -f $EA_DIR/docker-compose.yml <up|down|logs>"
echo " Script: docker exec -it easy-asterisk easy-asterisk --help"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_asterisk