diff --git a/README.md b/README.md index c1013c1..5b7615d 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ Update them any time with `sudo ./setup.sh configure`. | 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) | -| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant` | -| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `rustdesk`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | +| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` | +| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` | diff --git a/lib/common.sh b/lib/common.sh index 45d1f40..c2d1fe8 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -297,10 +297,16 @@ configure_caddy_for_service() { echo "" echo "Enter the full domain for $SERVICE_NAME:" - echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com" + local _default_domain="" + if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then + _default_domain="$DEFAULT_SUBDOMAIN.$SITE_DOMAIN" + echo " Default: $_default_domain" + else + echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com" + fi echo "" local SERVICE_DOMAIN="" - prompt_text "Domain:" "" SERVICE_DOMAIN + prompt_text "Domain [${_default_domain:-required}]:" "$_default_domain" SERVICE_DOMAIN if [ -z "$SERVICE_DOMAIN" ]; then echo " ⚠ No domain provided, skipping Caddy configuration."; return 0 fi diff --git a/services/asterisk.sh b/services/asterisk.sh new file mode 100644 index 0000000..9db5797 --- /dev/null +++ b/services/asterisk.sh @@ -0,0 +1,507 @@ +#!/bin/bash +# services/asterisk.sh — Easy Asterisk PBX with self-hosted coturn TURN server. +# Part of the modular post-install system (sourced by setup.sh). +# +# Based on https://github.com/outis1one/easy-asterisk +# Source files vendored in vendor/easy-asterisk/ +# Personal/home-lab use only. Not for commercial or emergency services. +# +# Can also be run standalone on any machine: +# sudo bash asterisk.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + 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 + } + + 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" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + 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 + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_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 + + 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 + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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}" + + register_service() { :; } + _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..." + + local EA_DIR="$DOCKER_DIR/asterisk" + + # Locate vendored source files (works when sourced by setup.sh or run standalone) + local _script_dir + _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" \ + || _script_dir="$(dirname "$(realpath "$0" 2>/dev/null || echo "$0")")" + local VENDOR_DIR="$_script_dir/../vendor/easy-asterisk" + VENDOR_DIR="$(cd "$VENDOR_DIR" 2>/dev/null && pwd)" || VENDOR_DIR="" + + if [[ -z "$VENDOR_DIR" || ! -f "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ]]; then + log_warning "Vendored easy-asterisk files not found at $VENDOR_DIR" + log_warning "Expected: vendor/easy-asterisk/ alongside services/ directory" + log_error "Cannot install — run from the ubuntu-post-install repo root." + return 1 + fi + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $EA_DIR" + echo "[DRY-RUN] Would copy vendored easy-asterisk files (Dockerfile, scripts, entrypoints)" + echo "[DRY-RUN] Would write docker-compose.yml, .env" + echo "[DRY-RUN] Would open UFW ports for SIP/RTP/TURN" + return 0 + fi + + mkdir -p "$EA_DIR/docker" "$EA_DIR/scripts" + ensure_docker_dir_ownership "$EA_DIR" + cd "$EA_DIR" || return 1 + + # ── Copy vendored source files ──────────────────────────────────────────── + log_info "Copying Easy Asterisk source files from vendor/..." + + cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" "$EA_DIR/easy-asterisk.sh" + cp "$VENDOR_DIR/Dockerfile" "$EA_DIR/Dockerfile" + cp "$VENDOR_DIR/docker/entrypoint.sh" "$EA_DIR/docker/entrypoint.sh" + cp "$VENDOR_DIR/docker/coturn-entrypoint.sh" "$EA_DIR/docker/coturn-entrypoint.sh" + cp "$VENDOR_DIR/scripts/vpn-diagnostics.sh" "$EA_DIR/scripts/vpn-diagnostics.sh" + cp "$VENDOR_DIR/scripts/dns-whitelist.sh" "$EA_DIR/scripts/dns-whitelist.sh" + + chmod 750 "$EA_DIR/easy-asterisk.sh" + chmod 755 "$EA_DIR/docker/entrypoint.sh" "$EA_DIR/docker/coturn-entrypoint.sh" + chmod 755 "$EA_DIR/scripts/vpn-diagnostics.sh" "$EA_DIR/scripts/dns-whitelist.sh" + + log_success "Source files copied" + + # The Dockerfile COPYs easy-asterisk-v0.10.0.sh (the versioned name). + # We keep easy-asterisk.sh as the canonical name and make a real copy + # with the versioned filename so Docker COPY works reliably (no symlinks). + cp "$EA_DIR/easy-asterisk.sh" "$EA_DIR/easy-asterisk-v0.10.0.sh" + + # ── FQDN setup ──────────────────────────────────────────────────────────── + echo "" + echo " Easy Asterisk can run in two modes:" + echo "" + echo " LAN/VPN — UDP transport, no TLS, no TURN." + echo " Simple setup for devices on your local network or WireGuard/Tailscale." + echo "" + echo " FQDN — TLS + SRTP + coturn TURN relay." + echo " Works from anywhere: LAN, cellular, hotel WiFi, Proton VPN." + echo " Requires a domain name pointing to this server's public IP." + echo "" + + local DOMAIN_NAME="" + prompt_text "FQDN for this server (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank for LAN-only]:" "" DOMAIN_NAME + + local LAN_ONLY=false + if [[ -z "$DOMAIN_NAME" ]]; then + LAN_ONLY=true + log_info "LAN/VPN-only mode — TLS and TURN disabled." + else + log_info "FQDN mode: $DOMAIN_NAME" + echo "" + echo " Required router port forwards:" + printf " %-22s %s\n" "5061/tcp" "SIP TLS signaling" + printf " %-22s %s\n" "3478/udp+tcp" "STUN/TURN (NAT traversal)" + printf " %-22s %s\n" "10000-20000/udp" "RTP media (Asterisk)" + printf " %-22s %s\n" "49152-49252/udp" "TURN relay range (coturn)" + echo "" + fi + + # ── Generate TURN password ──────────────────────────────────────────────── + local TURN_PASSWORD + TURN_PASSWORD="$(openssl rand -base64 18 2>/dev/null | tr -dc 'a-zA-Z0-9' | head -c 24 \ + || tr -dc 'A-Za-z0-9' docker-compose.yml << 'COMPOSE_EOF' +# Easy Asterisk — managed by ubuntu-post-install +# Manage: docker exec -it easy-asterisk easy-asterisk +# Source: https://github.com/outis1one/easy-asterisk + +services: + + asterisk: + build: + context: . + dockerfile: Dockerfile + container_name: easy-asterisk + # Host networking: required for RTP (10000-20000/udp) and proper NAT detection. + # SIP clients connect directly to the host IP; Caddy is only used for the web admin. + network_mode: host + depends_on: + coturn: + condition: service_started + volumes: + - asterisk-config:/etc/asterisk + - easy-asterisk-config:/etc/easy-asterisk + - asterisk-logs:/var/log/asterisk + - asterisk-spool:/var/spool/asterisk + - asterisk-lib:/var/lib/asterisk + # Bind-mount the management script so updates don't require a rebuild + - ./easy-asterisk.sh:/usr/local/bin/easy-asterisk:ro + environment: + - DOMAIN_NAME=${DOMAIN_NAME} + - ENABLE_TLS=${ENABLE_TLS:-y} + - PUBLIC_IP=${PUBLIC_IP:-} + - LOCAL_CIDR=${LOCAL_CIDR:-} + - HAS_VLANS=${HAS_VLANS:-n} + - VLAN_SUBNETS=${VLAN_SUBNETS:-} + - TURN_ENABLED=${TURN_ENABLED:-y} + - TURN_SERVER=${TURN_SERVER} + - TURN_USERNAME=${TURN_USERNAME:-easyasterisk} + - TURN_PASSWORD=${TURN_PASSWORD} + - RTP_START=${RTP_START:-10000} + - RTP_END=${RTP_END:-20000} + - WEB_ADMIN_PORT=${WEB_ADMIN_PORT:-8080} + - WEB_ADMIN_AUTH_DISABLED=${WEB_ADMIN_AUTH_DISABLED:-false} + 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 + environment: + - PUBLIC_IP=${PUBLIC_IP:-} + 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=${TURN_RELAY_MIN:-49152} + - --max-port=${TURN_RELAY_MAX:-49252} + - --no-tls + - --no-dtls + - --no-cli + - --no-multicast-peers + - --log-file=stdout + restart: unless-stopped + +volumes: + asterisk-config: + easy-asterisk-config: + asterisk-logs: + asterisk-spool: + asterisk-lib: +COMPOSE_EOF + + # ── .env ───────────────────────────────────────────────────────────────── + cat > .env << ENV +# Easy Asterisk — environment configuration +# Edit and restart: docker compose down && docker compose up -d + +# FQDN pointing to this server's public IP (required for remote/TLS mode) +DOMAIN_NAME=$DOMAIN_NAME + +# Public IP — leave empty to auto-detect +PUBLIC_IP= + +# TLS — always 'y' for remote access, 'n' for LAN-only +ENABLE_TLS=$( [[ "$LAN_ONLY" == "true" ]] && echo "n" || echo "y" ) + +# Local network CIDR — auto-detected if empty +LOCAL_CIDR= + +# Additional subnets for site-to-site VPNs (WireGuard/Tailscale mesh, NOT client-side) +HAS_VLANS=n +VLAN_SUBNETS= + +# TURN/STUN credentials — must match in both Asterisk and coturn +# Regenerate: openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c 24 +TURN_USERNAME=easyasterisk +TURN_PASSWORD=$TURN_PASSWORD + +# TURN server address — auto-set based on FQDN or LAN mode above +# LAN-only: leave empty (coturn not used). FQDN mode: domain:port +TURN_SERVER=$( [[ "$LAN_ONLY" == "true" ]] && echo "" || echo "${DOMAIN_NAME}:3478" ) + +# TURN port (change to 3479 if 3478 conflicts with UniFi controller or Mattermost) +TURN_PORT=3478 + +# TURN relay port range — forward this range on your router +TURN_RELAY_MIN=49152 +TURN_RELAY_MAX=49252 + +# RTP media port range — forward this range on your router +RTP_START=10000 +RTP_END=20000 + +# Web admin interface +WEB_ADMIN_PORT=8080 +WEB_ADMIN_AUTH_DISABLED=false +ENV + + chmod 600 .env + chown "$ACTUAL_USER:$ACTUAL_USER" .env + + # ── UFW firewall rules ──────────────────────────────────────────────────── + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + log_info "Opening UFW ports for Asterisk..." + ufw allow 5060/udp comment "Asterisk SIP UDP" >/dev/null + ufw allow 5060/tcp comment "Asterisk SIP TCP" >/dev/null + ufw allow 5061/tcp comment "Asterisk SIP TLS" >/dev/null + ufw allow 8080/tcp comment "Asterisk web admin" >/dev/null + ufw allow 8088/tcp comment "Asterisk HTTP provision" >/dev/null + ufw allow 8089/tcp comment "Asterisk HTTPS provision" >/dev/null + ufw allow 3478/udp comment "coturn STUN/TURN UDP" >/dev/null + ufw allow 3478/tcp comment "coturn STUN/TURN TCP" >/dev/null + ufw allow 10000:20000/udp comment "Asterisk RTP media" >/dev/null + ufw allow 49152:49252/udp comment "coturn TURN relay" >/dev/null + log_success "UFW rules added" + else + log_info "UFW not active — open these ports manually if needed:" + log_info " 5060/udp+tcp, 5061/tcp" + log_info " 8080/tcp (web admin), 8088/tcp, 8089/tcp (provisioning)" + log_info " 3478/udp+tcp (STUN/TURN)" + log_info " 10000-20000/udp (RTP), 49152-49252/udp (TURN relay)" + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR" + + # ── Caddy for web admin (with optional Authelia SSO) ────────────────────── + # The web admin has no built-in auth; let Authelia gate it if available. + local EA_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 + EA_EXTRA_BLOCK=" import authelia" + # Tell Asterisk's web admin to skip its own auth — Authelia handles it + sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" "$EA_DIR/.env" + log_info "WEB_ADMIN_AUTH_DISABLED=true set (Authelia will handle authentication)" + fi + fi + configure_caddy_for_service "Asterisk Web Admin" "localhost:8080" "asterisk" "$EA_EXTRA_BLOCK" + + # ── README ──────────────────────────────────────────────────────────────── + write_readme "$EA_DIR" << MD +# Easy Asterisk PBX + +Home intercom / VoIP system built on Asterisk with self-hosted coturn TURN server. +Personal/home-lab use only. Source: https://github.com/outis1one/easy-asterisk + +## Access +- Web admin: http://localhost:8080/clients +- FQDN: $( [[ -n "$DOMAIN_NAME" ]] && echo "$DOMAIN_NAME" || echo "(LAN-only — no domain)" ) + +## Management +\`\`\`bash +# Interactive management menu (add devices, provisioning, diagnostics) +docker exec -it easy-asterisk easy-asterisk + +# VPN diagnostics +docker exec -it easy-asterisk vpn-diagnostics + +# DNS whitelist check +docker exec -it easy-asterisk dns-whitelist +\`\`\` + +## Adding devices +Run the management menu → Device Management → Add device. +Each device gets a SIP extension, password, and setup instructions +for Linphone (remote provisioning) or Baresip (manual). + +## Connection modes +- **LAN/VPN**: UDP, no encryption — local network or WireGuard/Tailscale +- **FQDN**: TLS + SRTP + coturn TURN relay — works from anywhere + +## Caddy and phone calls +Asterisk uses **host networking** — SIP signaling and RTP media connect +directly to the server, completely bypassing Caddy. Do NOT put SIP ports +behind a reverse proxy (Contact header rewriting will break registration). + +Caddy only handles the **web admin** (port 8080) for HTTPS browser access. + +The **provisioning server** (ports 8088/8089) is Asterisk's built-in HTTP +server for Linphone XML config delivery. Access it directly by IP/domain, +not through Caddy — SIP clients fetch it at startup before registering. + +## Router port forwards (FQDN mode) +| Port | Protocol | Service | +|------|----------|---------| +| 5061 | TCP | SIP TLS signaling | +| 3478 | UDP+TCP | STUN/TURN | +| 10000-20000 | UDP | RTP media | +| 49152-49252 | UDP | TURN relay | +| 8088 | TCP | Provisioning (Linphone XML) — optional | + +## TURN credentials (for SIP clients behind strict NAT) +- Server: \${DOMAIN_NAME}:3478 +- Username: easyasterisk +- Password: (see .env → TURN_PASSWORD) + +## Manage +\`\`\`bash +cd $EA_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull # update coturn image +docker compose build --pull && docker compose up -d # rebuild Asterisk image +\`\`\` +MD + + # ── Build and start ─────────────────────────────────────────────────────── + echo "" + local START_EA="" + prompt_yn "Build and start Easy Asterisk now? (y/n):" "y" START_EA + if [[ "$START_EA" =~ ^[Yy]$ ]]; then + log_info "Building Asterisk image (first build takes a few minutes)..." + if docker compose build --pull 2>&1 | tail -5; then + if docker compose up -d; then + log_success "Easy Asterisk started" + echo "" + echo " Web admin: http://localhost:8080/clients" + echo " Management: docker exec -it easy-asterisk easy-asterisk" + echo "" + log_info "Next: add your first device via the management menu." + else + log_warning "Start failed — check: docker compose logs" + fi + else + log_warning "Build failed — check output above" + fi + fi + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_asterisk diff --git a/services/homeassistant.sh b/services/homeassistant.sh index 7087d15..56317c3 100644 --- a/services/homeassistant.sh +++ b/services/homeassistant.sh @@ -242,6 +242,25 @@ HA_CONFIG configure_caddy_for_service "Home Assistant" "homeassistant:8123" "home" fi + write_readme "$HOMEASSISTANT_DIR" << MD +# Home Assistant + +Home automation hub. Built-in auth — no Authelia needed. + +## Access +- URL: http://localhost:8123 +- First run: create your admin account through the onboarding wizard + +## Manage +\`\`\`bash +cd $HOMEASSISTANT_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + local START_HA="" prompt_yn "Start Home Assistant now? (y/n):" "y" START_HA if [ "$START_HA" = "y" ] || [ "$START_HA" = "Y" ]; then diff --git a/services/js99er.sh b/services/js99er.sh index 5377027..3ee645b 100644 --- a/services/js99er.sh +++ b/services/js99er.sh @@ -420,7 +420,13 @@ COMPOSE log_success "js99er configured at $JS99ER_DIR" # ── 5. Reverse proxy (no-ops if Caddy isn't installed locally) ─────────── - configure_caddy_for_service "js99er" "js99er:80" "js99er" + local JS99ER_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect js99er with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && JS99ER_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "js99er" "js99er:80" "js99er" "$JS99ER_EXTRA_BLOCK" # ── 6. Build & start ───────────────────────────────────────────────────── local START_JS99ER="" @@ -442,7 +448,24 @@ COMPOSE fi fi - # ── 7. Access summary ──────────────────────────────────────────────────── + write_readme "$JS99ER_DIR" << MD +# js99er — TI-99/4A Emulator + +Browser-based TI-99/4A emulator. No built-in auth — protect with Authelia if exposing externally. + +## Access +- URL: http://localhost:${JS99ER_PORT} +- Online (no install): https://js99er.net + +## Manage +\`\`\`bash +cd $JS99ER_DIR +docker compose up -d --build # start (or rebuild) +docker compose down # stop +docker compose logs -f # logs +\`\`\` +MD + echo "" echo " Access at: http://localhost:${JS99ER_PORT}" echo " If you set a domain above, it is also reachable via that domain (HTTPS)." diff --git a/services/magicmirror.sh b/services/magicmirror.sh index ab78ba2..6e1bd14 100644 --- a/services/magicmirror.sh +++ b/services/magicmirror.sh @@ -283,7 +283,15 @@ MM_COMPOSE log_success "MagicMirror instance $i configured at $MM_DIR (port $MM_PORT)" # Offer Caddy only for first instance - [ "$i" -eq 1 ] && configure_caddy_for_service "MagicMirror" "magicmirror-${MM_PORT}:8080" "mirror" + if [ "$i" -eq 1 ]; then + local MM_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect MagicMirror with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && MM_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "MagicMirror" "magicmirror-${MM_PORT}:8080" "mirror" "$MM_EXTRA_BLOCK" + fi local START_MM="" prompt_yn "Start instance $i now? (y/n):" "y" START_MM diff --git a/services/mattermost.sh b/services/mattermost.sh new file mode 100644 index 0000000..5690bd1 --- /dev/null +++ b/services/mattermost.sh @@ -0,0 +1,442 @@ +#!/bin/bash +# services/mattermost.sh — Team messaging with voice/video calls (Mattermost + coturn). +# Part of the modular post-install system (sourced by setup.sh). +# +# Mattermost Team Edition with PostgreSQL and a dedicated coturn TURN server +# (port 3479 — distinct from Easy Asterisk's coturn on 3478). +# +# Can also be run standalone on any machine: +# sudo bash mattermost.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + 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 + } + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' /dev/null || true + } + + 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" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + 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 + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_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 + + 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 + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service mattermost utilities "Team messaging with voice/video calls (Mattermost + coturn)" 8065 + +install_mattermost() { + require_docker || return 1 + log_info "Installing Mattermost Team Edition..." + + local DIR="$DOCKER_DIR/mattermost" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR with subdirectories: data logs config plugins db" + echo "[DRY-RUN] Would generate DB password, MM secret key, and TURN secret" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would open UFW ports: 3479/udp+tcp, 49153-49352/udp" + echo "[DRY-RUN] Would configure Caddy reverse proxy for Mattermost" + return 0 + fi + + # ── Create directory structure ──────────────────────────────────────────── + mkdir -p "$DIR"/{data,logs,config,plugins,db} + # Mattermost runs as UID 2000 inside the container + chown -R 2000:2000 "$DIR/data" "$DIR/logs" "$DIR/config" "$DIR/plugins" + ensure_docker_dir_ownership "$DIR/db" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + # ── Generate secrets ────────────────────────────────────────────────────── + local DB_PASS MM_SECRET TURN_SECRET + DB_PASS="$(generate_password 32)" + MM_SECRET="$(generate_password 48)" + TURN_SECRET="$(openssl rand -hex 32 2>/dev/null || generate_password 32)" + + # ── Site URL ────────────────────────────────────────────────────────────── + local SITE_URL="http://localhost:8065" + if [[ -n "$SITE_DOMAIN" && "$SITE_DOMAIN" != "example.com" ]]; then + SITE_URL="https://chat.${SITE_DOMAIN}" + fi + local CONFIGURED_SITEURL="" + prompt_text "Mattermost site URL [${SITE_URL}]:" "$SITE_URL" CONFIGURED_SITEURL + [[ -n "$CONFIGURED_SITEURL" ]] && SITE_URL="$CONFIGURED_SITEURL" + + # ── docker-compose.yml ──────────────────────────────────────────────────── + cat > docker-compose.yml << COMPOSE +# Mattermost Team Edition — generated by ubuntu-post-install +# Manage: docker compose up -d / down / logs -f +# Admin setup: \${MATTERMOST_SITE_URL}/signup_user_complete + +name: mattermost + +services: + + db: + image: postgres:15-alpine + container_name: mattermost-db + restart: unless-stopped + security_opt: + - no-new-privileges:true + pids_limit: 100 + volumes: + - ./db:/var/lib/postgresql/data + environment: + - POSTGRES_USER=mattermost + - POSTGRES_PASSWORD=\${DB_PASS} + - POSTGRES_DB=mattermost + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mattermost"] + interval: 10s + timeout: 5s + retries: 5 + + mattermost: + image: mattermost/mattermost-team-edition:latest + container_name: mattermost + restart: unless-stopped + security_opt: + - no-new-privileges:true + pids_limit: 200 + depends_on: + db: + condition: service_healthy + ports: + - "8065:8065" + - "8443:8443/udp" # Calls plugin RTC server (WebRTC direct path) + volumes: + - ./data:/mattermost/data + - ./logs:/mattermost/logs + - ./config:/mattermost/config + - ./plugins:/mattermost/plugins + environment: + - MM_SQLSETTINGS_DRIVERNAME=postgres + - MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:\${DB_PASS}@db:5432/mattermost?sslmode=disable + - MM_SERVICESETTINGS_SITEURL=\${MATTERMOST_SITE_URL} + - MM_PLUGINSETTINGS_ENABLEUPLOADS=true + - MM_SERVICESETTINGS_ENABLELOCALMODE=true + - TZ=\${TZ} + networks: + - default + - caddy_net + + coturn: + image: coturn/coturn:latest + container_name: mattermost-coturn + restart: unless-stopped + network_mode: host + command: + - -n + - --listening-port=3479 + - --tls-listening-port=5350 + - --listening-ip=0.0.0.0 + - --fingerprint + - --use-auth-secret + - --static-auth-secret=\${TURN_SECRET} + - --realm=\${TURN_REALM} + - --min-port=49153 + - --max-port=49352 + - --no-tls + - --no-dtls + - --no-cli + - --no-multicast-peers + - --log-file=stdout + +networks: + default: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} +COMPOSE + + # ── .env ────────────────────────────────────────────────────────────────── + cat > .env << ENV +# Mattermost — environment configuration +# Edit and restart: docker compose down && docker compose up -d + +# PostgreSQL password (do not change after first start without migrating data) +DB_PASS=$DB_PASS + +# Mattermost secret key (used for signing session tokens) +MM_SECRET=$MM_SECRET + +# Site URL — must match the public URL clients use to access Mattermost +MATTERMOST_SITE_URL=$SITE_URL + +# Timezone +TZ=$SITE_TZ + +# TURN server shared secret for Mattermost Calls plugin +# Generate a new one: openssl rand -hex 32 +TURN_SECRET=$TURN_SECRET + +# TURN realm (typically your domain) +TURN_REALM=${SITE_DOMAIN:-localhost} + +# Caddy network name +CADDY_NET=$SITE_CADDY_NET +ENV + + chmod 600 .env + chown "$ACTUAL_USER:$ACTUAL_USER" .env + + # ── UFW firewall rules ───────────────────────────────────────────────────── + echo "" + log_info "Firewall — Mattermost coturn uses port 3479 (avoiding conflict with Easy Asterisk on 3478)." + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + log_info "Opening UFW ports for Mattermost..." + ufw allow 8443/udp comment "Mattermost Calls RTC server" >/dev/null + ufw allow 3479/udp comment "Mattermost coturn STUN/TURN" >/dev/null + ufw allow 3479/tcp comment "Mattermost coturn STUN/TURN" >/dev/null + ufw allow 49153:49352/udp comment "Mattermost coturn relay" >/dev/null + log_success "UFW rules added" + else + log_info "UFW not active — add these rules manually if needed:" + echo " ufw allow 8443/udp # Mattermost Calls RTC" + echo " ufw allow 3479/udp && ufw allow 3479/tcp # coturn STUN/TURN" + echo " ufw allow 49153:49352/udp # coturn relay" + fi + + # ── Router port-forward instructions ────────────────────────────────────── + echo "" + echo " ┌─────────────────────────────────────────────────────────────────┐" + echo " │ Router port-forwards needed for Mattermost Calls (external) │" + echo " ├──────────────────┬──────────┬──────────────────────────────────┤" + echo " │ Port(s) │ Protocol │ Service │" + echo " ├──────────────────┼──────────┼──────────────────────────────────┤" + echo " │ 8443 │ UDP │ Calls plugin RTC (direct WebRTC) │" + echo " │ 3479 │ UDP+TCP │ coturn STUN/TURN │" + echo " │ 49153–49352 │ UDP │ coturn relay range │" + echo " └──────────────────┴──────────┴──────────────────────────────────┘" + echo "" + echo " ⚠ WebRTC (Calls) requires HTTPS. Calls will not work if Mattermost" + echo " is accessed over plain HTTP. Configure Caddy with a domain below." + echo "" + + ensure_docker_dir_ownership "$DIR" + + # ── Caddy reverse proxy ─────────────────────────────────────────────────── + # SITEURL is already set from SITE_DOMAIN above. configure_caddy_for_service + # will pre-fill the domain prompt with chat.$SITE_DOMAIN. + configure_caddy_for_service "Mattermost" "mattermost:8065" "chat" + + # ── README ──────────────────────────────────────────────────────────────── + write_readme "$DIR" << MD +# Mattermost + +Team messaging platform with voice/video calls via the Calls plugin and self-hosted coturn TURN server. + +## Access +- Direct: http://localhost:8065 +- Via Caddy: see your configured domain (e.g. https://chat.${SITE_DOMAIN:-example.com}) + +## Initial admin setup +Visit: \`${SITE_URL}/signup_user_complete\` + +The first user to sign up becomes the System Admin. + +## Calls plugin (voice/video) +The Mattermost Calls plugin provides voice/video channels. +**WebRTC requires HTTPS** — calls will not work over plain HTTP. + +### Enable the plugin +1. Go to **System Console → Plugins → Plugin Management** +2. Enable the **Calls** plugin (pre-installed in Team Edition) + +### Configure ICE / TURN server +1. Go to **System Console → Plugins → Calls** +2. Set **RTC Server Address**: your server's public IP or domain +3. Set **TURN server URL**: \`turn::3479\` +4. Set **TURN credentials type**: Static credentials (auth secret) +5. Set **TURN static auth secret**: (see \`TURN_SECRET\` in \`$DIR/.env\`) +6. Save and test a call in a channel + +Direct WebRTC (port 8443/UDP) is tried first; coturn relay is the fallback +for clients behind strict NAT (cellular, hotel WiFi, Proton VPN, etc.). + +## Router port-forwards (for external calls) +| Port(s) | Protocol | Service | +|--------------|-----------|---------------------------------| +| 8443 | UDP | Calls plugin RTC (direct path) | +| 3479 | UDP+TCP | coturn STUN/TURN | +| 49153–49352 | UDP | coturn relay range | + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # all logs +docker compose logs -f mattermost # app logs only +docker compose logs -f coturn # TURN server logs +docker compose pull && docker compose up -d # update images +\`\`\` + +## Backup +Important paths to back up: +- \`$DIR/data/\` — uploaded files and attachments +- \`$DIR/config/\` — server configuration +- \`$DIR/plugins/\` — installed plugins +- \`$DIR/db/\` — PostgreSQL data directory +- \`$DIR/.env\` — secrets and configuration + +## Configuration +Main config file: \`$DIR/config/config.json\` (created on first start). +Environment variables in \`.env\` override config.json values. +After editing .env: \`docker compose down && docker compose up -d\` +MD + + # ── Start ────────────────────────────────────────────────────────────────── + echo "" + local START="" + prompt_yn "Start Mattermost now? (y/n):" "y" START + if [[ "$START" =~ ^[Yy]$ ]]; then + log_info "Pulling images and starting Mattermost (first start may take a minute)..." + if docker compose pull 2>&1 | tail -3 && docker compose up -d; then + log_success "Mattermost started" + echo "" + echo " App: http://localhost:8065" + echo " Admin setup: ${SITE_URL}/signup_user_complete" + echo "" + log_info "Enable the Calls plugin and configure TURN at:" + log_info " System Console → Plugins → Calls" + log_info " TURN URL: turn::3479" + log_info " TURN secret: (see $DIR/.env → TURN_SECRET)" + else + log_warning "Start failed — check: docker compose logs" + fi + fi + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_mattermost diff --git a/services/minecraft.sh b/services/minecraft.sh index a855b56..0c1d033 100644 --- a/services/minecraft.sh +++ b/services/minecraft.sh @@ -2489,6 +2489,25 @@ NETEOF echo " Run: sudo ./setup.sh backup" echo "" + write_readme "$MC_DIR" << MD +# Minecraft — ${MC_NAME} + +## Manage +\`\`\`bash +cd $MC_DIR +docker compose up -d --build # start (builds image on first run) +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d --build # update +\`\`\` + +## Backups +World data lives in \`./data\` — covered by Kopia/Borg if installed. +\`\`\`bash +sudo ./setup.sh backup +\`\`\` +MD + # ── Optional: start server and run pre-gen now ────────────────────────────── local START_MC="" prompt_yn "Start the Minecraft server now? (first build takes a few minutes) (y/n) [y]:" "y" START_MC diff --git a/services/nextcloud.sh b/services/nextcloud.sh new file mode 100644 index 0000000..adc6645 --- /dev/null +++ b/services/nextcloud.sh @@ -0,0 +1,362 @@ +#!/bin/bash +# services/nextcloud.sh — Self-hosted cloud storage with SMB/local file access (Nextcloud). +# Part of the modular post-install system (sourced by setup.sh). +# +# Uses a custom Dockerfile (nextcloud:apache + smbclient) so SMB external storage +# works without AIO. All data uses bind mounts under ~/docker/nextcloud/ so that +# Kopia/Borg backup scripts cover everything automatically. +# +# Can also be run standalone on any machine: +# sudo bash nextcloud.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 + 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 + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + # 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" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port 8080." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:8080" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + 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 + + # Remove existing block for this domain if present + 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 + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_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 + + 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 + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service nextcloud utilities "Self-hosted cloud storage with SMB/local file access (Nextcloud)" 8080 + +install_nextcloud() { + require_docker || return 1 + log_info "Installing Nextcloud..." + local DIR="$DOCKER_DIR/nextcloud" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR with:" + echo "[DRY-RUN] Dockerfile (nextcloud:apache + smbclient)" + echo "[DRY-RUN] docker-compose.yml (nextcloud + mariadb:10.11)" + echo "[DRY-RUN] .env with generated DB and admin passwords" + echo "[DRY-RUN] Bind-mount directories: html/ db/ config/ custom_apps/" + echo "[DRY-RUN] Would expose Nextcloud on port 8080" + echo "[DRY-RUN] Would enable files_external app via occ after deploy" + return 0 + fi + + mkdir -p "$DIR/html" "$DIR/db" "$DIR/config" "$DIR/custom_apps" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local DB_PASS NC_ADMIN_PASS TZ_VAL + DB_PASS=$(generate_password 32) + NC_ADMIN_PASS=$(generate_password 24) + TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + + # ── Dockerfile — adds SMB support to the official apache image ──────────── + cat > Dockerfile << 'DOCKERFILE' +FROM nextcloud:apache + +RUN apt-get update \ + && apt-get install -y --no-install-recommends procps smbclient \ + && rm -rf /var/lib/apt/lists/* +DOCKERFILE + + # ── docker-compose.yml — single-quoted EOF prevents variable expansion ──── + cat > docker-compose.yml << 'EOF' +name: nextcloud + +services: + nextcloud: + build: . + container_name: nextcloud + hostname: nextcloud + restart: unless-stopped + env_file: .env + depends_on: + - db + volumes: + - ./html:/var/www/html + - ./config:/var/www/html/config + - ./custom_apps:/var/www/html/custom_apps + ports: + - "8080:80" + networks: + - caddy_net + + db: + image: mariadb:10.11 + container_name: nextcloud-db + hostname: nextcloud-db + restart: unless-stopped + env_file: .env + volumes: + - ./db:/var/lib/mysql + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +EOF + + # ── .env — actual variable values (NOT inside the compose heredoc) ──────── + cat > .env << NC_ENV +# ── Timezone & network ──────────────────────────────────────────────────────── +TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET + +# ── MariaDB ─────────────────────────────────────────────────────────────────── +MYSQL_ROOT_PASSWORD=$DB_PASS +MYSQL_DATABASE=nextcloud +MYSQL_USER=nextcloud +MYSQL_PASSWORD=$DB_PASS +MARIADB_AUTO_UPGRADE=1 + +# ── Nextcloud bootstrap ─────────────────────────────────────────────────────── +# These are used only on the very first startup to create the admin account +# and wire up the database. They are ignored on subsequent startups. +NEXTCLOUD_ADMIN_USER=admin +NEXTCLOUD_ADMIN_PASSWORD=$NC_ADMIN_PASS +NEXTCLOUD_DB_TYPE=mysql +MYSQL_HOST=db +NC_ENV + + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + log_success "Nextcloud configured at $DIR" + + configure_caddy_for_service "Nextcloud" "nextcloud:80" "cloud" + + write_readme "$DIR" << MD +# Nextcloud + +Self-hosted cloud storage — files, contacts, calendar, notes, and more. +SMB/local external storage is enabled via a custom Docker image (nextcloud:apache + smbclient). + +## Access +- URL: http://localhost:8080 +- Admin user: \`admin\` +- Admin password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`.env\` + +## Directory layout (all bind-mounted — covered by Kopia/Borg backups) +\`\`\` +$DIR/ + html/ # Nextcloud web root (PHP app + uploaded files) + config/ # config.php and other Nextcloud config files + custom_apps/ # manually installed apps not shipped with Nextcloud + db/ # MariaDB data directory + Dockerfile # custom image definition (adds smbclient) + docker-compose.yml + .env # secrets — chmod 600 +\`\`\` + +## External Storage (SMB / local paths) +The \`files_external\` app is enabled automatically during setup. +Add mounts in the Nextcloud web UI: +**Admin → Administration → External Storage** + +Supported backends: Local, SMB/CIFS, FTP, S3, WebDAV, and more. + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose build --pull && docker compose up -d # rebuild image + update +docker exec --user www-data nextcloud php occ list # occ CLI +\`\`\` + +## Backup note +All data lives under \`$DIR/\` as bind mounts. +Include this directory in your Kopia/Borg backup policy. +Run \`docker compose down\` before a cold backup of \`db/\` for consistency, +or use \`mysqldump\` for a hot backup: +\`\`\`bash +docker exec nextcloud-db mysqldump -u nextcloud -p\$MYSQL_PASSWORD nextcloud > nextcloud_db.sql +\`\`\` +MD + + local START_NC="" + prompt_yn "Start Nextcloud now? (y/n):" "y" START_NC + if [ "$START_NC" = "y" ] || [ "$START_NC" = "Y" ]; then + docker compose up -d \ + && log_success "Nextcloud started — first boot may take 1-2 minutes" \ + || { log_warning "Start failed — check: docker compose logs"; return 1; } + + # Wait for Nextcloud to finish first-boot initialisation before running occ + log_info "Waiting for Nextcloud to finish initialising (up to 90 s)..." + local _waited=0 + until docker exec --user www-data nextcloud php occ status --output=json 2>/dev/null \ + | grep -q '"installed":true'; do + sleep 5 + _waited=$(( _waited + 5 )) + if (( _waited >= 90 )); then + log_warning "Nextcloud did not finish initialising within 90 s." + log_warning "Run the occ command manually once the container is ready:" + log_warning " docker exec --user www-data nextcloud php occ app:enable files_external" + break + fi + done + + if (( _waited < 90 )); then + if docker exec --user www-data nextcloud php occ app:enable files_external; then + log_success "External Storage app enabled" + else + log_warning "Could not enable files_external — run manually:" + log_warning " docker exec --user www-data nextcloud php occ app:enable files_external" + fi + fi + fi + + echo "" + echo " URL: http://localhost:8080" + echo " Admin user: admin" + echo " Admin password: $NC_ADMIN_PASS" + echo " (Credentials also saved to $DIR/.env)" + echo "" + echo " To add SMB or local external storage:" + echo " Nextcloud → Admin → Administration → External Storage" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_nextcloud diff --git a/services/onlyoffice.sh b/services/onlyoffice.sh new file mode 100644 index 0000000..12206b6 --- /dev/null +++ b/services/onlyoffice.sh @@ -0,0 +1,358 @@ +#!/bin/bash +# services/onlyoffice.sh — Self-hosted OnlyOffice Document Server. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash onlyoffice.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + 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 + } + + 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" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + 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 + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_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 + + 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 + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + + generate_password() { + local len="${1:-32}" + tr -dc 'A-Za-z0-9' /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}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service onlyoffice utilities "Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser)" 8082 + +# ── Ensure yq v4 is installed ───────────────────────────────────────────────── +_ensure_yq() { + if command -v yq &>/dev/null; then + local major + major=$(yq --version 2>&1 | grep -oP '(?<=v)\d+' | head -1 || echo 0) + [[ "$major" -ge 4 ]] && return 0 + log_info "yq found but version < 4 — reinstalling..." + else + log_info "yq not found — installing..." + fi + local arch + arch=$(uname -m) + local yq_bin="yq_linux_amd64" + [[ "$arch" == "aarch64" || "$arch" == "arm64" ]] && yq_bin="yq_linux_arm64" + if wget -qO /usr/local/bin/yq \ + "https://github.com/mikefarah/yq/releases/latest/download/${yq_bin}" \ + && chmod +x /usr/local/bin/yq; then + log_success "yq installed ($(yq --version 2>&1 | head -1))" + else + log_warning "Could not install yq — FileBrowser config.yaml will need manual update" + return 1 + fi +} + +# ── Wire OnlyOffice into Nextcloud ──────────────────────────────────────────── +_wire_nextcloud() { + local jwt_secret="$1" + local nc_dir="$DOCKER_DIR/nextcloud" + + [[ -d "$nc_dir" ]] || return 0 + + log_info "Nextcloud detected — wiring OnlyOffice integration..." + + if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^nextcloud$"; then + log_warning "Nextcloud container not running — skipping occ wiring." + log_info " Start Nextcloud and re-run: sudo bash $0" + return 0 + fi + + docker exec --user www-data nextcloud php occ app:enable onlyoffice \ + && log_success "OnlyOffice app enabled in Nextcloud" \ + || log_warning "app:enable failed — may already be enabled" + docker exec --user www-data nextcloud php occ \ + config:app:set onlyoffice DocumentServerUrl \ + --value "http://onlyoffice:80/" \ + && log_success "DocumentServerUrl → http://onlyoffice:80/" \ + || log_warning "Could not set DocumentServerUrl" + docker exec --user www-data nextcloud php occ \ + config:app:set onlyoffice jwt_secret \ + --value "$jwt_secret" \ + && log_success "jwt_secret set" \ + || log_warning "Could not set jwt_secret" + docker exec --user www-data nextcloud php occ \ + config:app:set onlyoffice jwt_header \ + --value "AuthorizationJwt" \ + && log_success "jwt_header set" \ + || log_warning "Could not set jwt_header" +} + +# ── Wire OnlyOffice into FileBrowser Quantum ────────────────────────────────── +_wire_filebrowser() { + local fb_config="$DOCKER_DIR/filebrowser/data/config.yaml" + + [[ -f "$fb_config" ]] || return 0 + + log_info "FileBrowser Quantum detected — updating config.yaml..." + + if ! _ensure_yq; then + log_info "Set officeServer manually in $fb_config:" + log_info " officeServer: \"http://onlyoffice:80/\"" + return 0 + fi + + yq e -i '.officeServer = "http://onlyoffice:80/"' "$fb_config" \ + && log_success "FileBrowser config.yaml: officeServer → http://onlyoffice:80/" \ + || log_warning "yq failed — set officeServer manually in $fb_config" + + if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^filebrowser$"; then + docker restart filebrowser >/dev/null 2>&1 \ + && log_info "FileBrowser restarted to pick up config change" \ + || log_warning "Could not restart FileBrowser container" + fi +} + +install_onlyoffice() { + require_docker || return 1 + log_info "Installing OnlyOffice Document Server..." + + local DIR="$DOCKER_DIR/onlyoffice" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR with docker-compose.yml and .env" + echo "[DRY-RUN] Would deploy onlyoffice/documentserver:latest on port 8082" + echo "[DRY-RUN] Would install yq if missing" + echo "[DRY-RUN] Would wire OnlyOffice into Nextcloud (if running)" + echo "[DRY-RUN] Would wire OnlyOffice into FileBrowser Quantum (if present)" + return 0 + fi + + # Always install yq — needed for FBQ config patching + _ensure_yq || true + + mkdir -p "$DIR" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + # Generate JWT secret (or read existing one so re-runs don't rotate it) + local JWT_SECRET="" + if [[ -f "$DIR/.env" ]]; then + JWT_SECRET=$(grep "^JWT_SECRET=" "$DIR/.env" 2>/dev/null | cut -d= -f2-) + fi + [[ -z "$JWT_SECRET" ]] && JWT_SECRET="$(generate_password 32)" + + cat > docker-compose.yml << 'OO_COMPOSE' +name: onlyoffice + +services: + onlyoffice: + image: onlyoffice/documentserver:latest + container_name: onlyoffice + hostname: onlyoffice + restart: unless-stopped + env_file: .env + ports: + - "8082:80" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +OO_COMPOSE + + cat > .env << OO_ENV +# OnlyOffice Document Server — environment +CADDY_NET=$SITE_CADDY_NET + +# JWT authentication — keep JWT_SECRET private +# If you rotate it, update Nextcloud (occ config:app:set onlyoffice jwt_secret) +# and any other integration that uses this server +JWT_ENABLED=true +JWT_SECRET=$JWT_SECRET +JWT_HEADER=AuthorizationJwt +OO_ENV + + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + + configure_caddy_for_service "OnlyOffice" "onlyoffice:80" "office" + + local START="" + prompt_yn "Start OnlyOffice now? (y/n):" "y" START + if [[ "$START" =~ ^[Yy]$ ]]; then + docker compose up -d \ + && log_success "OnlyOffice started" \ + || log_warning "Start failed — check: docker compose logs" + fi + + # Wire into integrations every run (idempotent) + echo "" + _wire_nextcloud "$JWT_SECRET" + _wire_filebrowser + + write_readme "$DIR" << MD +# OnlyOffice Document Server + +Self-hosted collaborative editing for DOCX, XLSX, PPTX, and ODT files. +Integrates with Nextcloud and FileBrowser Quantum. +Port: 8082 (internal 80) + +## JWT Secret +Stored in \`.env\` (chmod 600). If you rotate it: +1. Update \`JWT_SECRET\` in \`.env\` +2. Re-run the installer to re-wire all integrations: \`sudo bash services/onlyoffice.sh\` + +## Verify integrations +\`\`\`bash +# Nextcloud +docker exec --user www-data nextcloud php occ config:app:get onlyoffice DocumentServerUrl +docker exec --user www-data nextcloud php occ config:app:get onlyoffice jwt_secret + +# FileBrowser Quantum +grep officeServer ~/docker/filebrowser/data/config.yaml +\`\`\` + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + + log_success "OnlyOffice installed at $DIR" + echo "" + echo " Port: http://localhost:8082" + echo " JWT Secret: $JWT_SECRET" + echo " (Secret also saved to $DIR/.env)" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_onlyoffice diff --git a/services/wolf-pair.sh b/services/wolf-pair.sh index e1acf02..f549472 100644 --- a/services/wolf-pair.sh +++ b/services/wolf-pair.sh @@ -399,7 +399,13 @@ COMPOSE fi # ── 5. Caddy (optional) ─────────────────────────────────────────────────── - configure_caddy_for_service "wolf-pair" "$WOLFPAIR_PORT" "wolf-pair" + local WOLFPAIR_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect wolf-pair with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && WOLFPAIR_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "wolf-pair" "$WOLFPAIR_PORT" "wolf-pair" "$WOLFPAIR_EXTRA_BLOCK" # ── 6. README ───────────────────────────────────────────────────────────── write_readme "$WOLFPAIR_DIR" << 'MD' diff --git a/services/wolf.sh b/services/wolf.sh index 0573129..aee0d4d 100644 --- a/services/wolf.sh +++ b/services/wolf.sh @@ -927,6 +927,61 @@ PYEOF echo " Set up automatic backups with the backup module:" echo " sudo ./setup.sh backup" echo "" + # Wolf's web UI (pair/manage) has no built-in auth — protect with Authelia if available + local WOLF_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect Wolf web UI with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && WOLF_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "Wolf" "wolf:47990" "wolf" "$WOLF_EXTRA_BLOCK" + + write_readme "$WOLF_DIR" << MD +# Wolf — Cloud Gaming (Games-on-Whales) + +Stream games to any Moonlight client over your LAN or Tailscale VPN. + +## Pair a new client +1. Open Moonlight on the client device +2. Add host: this server's IP +3. Run the pin command on the server: +\`\`\`bash +cd $WOLF_DIR && ./manage.sh pin +\`\`\` + +## Manage +\`\`\`bash +cd $WOLF_DIR +./manage.sh start # start Wolf +./manage.sh stop # stop +./manage.sh restart # restart +./manage.sh logs # live logs +./manage.sh status # container status +./manage.sh update # pull latest image and restart +./manage.sh add-apps # add game launchers +\`\`\` + +## Ports (open on firewall / router) +| Port(s) | Protocol | Use | +|---------|----------|-----| +| 47984–47990 | TCP | Moonlight control | +| 48010 | TCP | RTSP | +| 47998–48000 | UDP | RTP video/audio/control | + +## Backup +\`\`\`bash +sudo ./setup.sh backup # covers /etc/wolf saves and ES-DE settings +\`\`\` +MD + + local START_WOLF="" + prompt_yn "Start Wolf now? (y/n):" "y" START_WOLF + if [[ "$START_WOLF" =~ ^[Yy]$ ]]; then + docker compose up -d \ + && log_success "Wolf started — pair Moonlight to this server's IP" \ + || log_warning "Start failed — check: docker compose logs" + fi + log_success "Done. Pair Moonlight and play." } diff --git a/vendor/easy-asterisk/.env.example b/vendor/easy-asterisk/.env.example new file mode 100644 index 0000000..332af67 --- /dev/null +++ b/vendor/easy-asterisk/.env.example @@ -0,0 +1,99 @@ +# ================================================================ +# Easy Asterisk - Environment Configuration +# +# Setup: +# 1. cp .env.example .env +# 2. Set DOMAIN_NAME (the only required setting) +# 3. docker compose up -d +# 4. docker exec -it easy-asterisk easy-asterisk +# +# Port forwarding required on your router: +# 5061/tcp → SIP TLS signaling +# 3478/udp+tcp → STUN/TURN (NAT traversal + media relay) +# (change with TURN_PORT if 3478 is taken) +# 10000-20000/udp → RTP media (or your custom range below) +# +# How it works: +# - All SIP clients connect to DOMAIN_NAME:5061 (TLS) +# - coturn handles NAT traversal (STUN) and media relay (TURN) +# - Works from any network: LAN, cellular, Proton VPN, hotel WiFi +# - Set TURN_PASSWORD below (generate one: openssl rand -base64 18) +# ================================================================ + +# ── Domain Name (REQUIRED) ──────────────────────────────────── +# The FQDN that points to this server's public IP. +# This is what SIP clients use to connect. +# Example: asterisk.yourdomain.com +DOMAIN_NAME= + +# ── Public IP ───────────────────────────────────────────────── +# Your server's public IP address. +# Leave empty to auto-detect (uses ifconfig.me). +# Set manually if auto-detection fails (e.g., behind double NAT). +PUBLIC_IP= + +# ── TLS ─────────────────────────────────────────────────────── +# Always "y" for remote access. Self-signed certs are auto-generated. +# For trusted certs (no client warnings), mount your Let's Encrypt +# certs into /etc/asterisk/certs/ via docker compose volumes. +ENABLE_TLS=y + +# ── Local Network ───────────────────────────────────────────── +# Your LAN CIDR. Auto-detected if empty. +# Example: 192.168.1.0/24 +LOCAL_CIDR= + +# ── Additional Subnets (optional) ───────────────────────────── +# Only needed for site-to-site VPNs or VLANs where the server +# has a direct route to client IPs (e.g., WireGuard, Tailscale). +# +# NOT needed for client-side VPNs (Proton, NordVPN, etc.) +# - Those clients appear with random public IPs +# - TURN handles media relay for them automatically +# +# Examples: +# WireGuard: VLAN_SUBNETS=10.8.0.0/24 +# Tailscale: VLAN_SUBNETS=100.64.0.0/10 +# Multiple: VLAN_SUBNETS=10.8.0.0/24 10.10.0.0/24 +HAS_VLANS=n +VLAN_SUBNETS= + +# ── TURN/STUN Settings ────────────────────────────────────── +# Used by coturn for TURN relay authentication. +# If empty, defaults to "changeme" — set a real password for security. +# Generate one with: openssl rand -base64 18 +# +# These credentials are for coturn only. SIP clients that need TURN +# relay (behind strict NAT) must configure the same credentials in +# their SIP app settings. +TURN_USERNAME=easyasterisk +TURN_PASSWORD= + +# ── TURN/STUN Port ────────────────────────────────────────── +# Default: 3478 (standard STUN/TURN port) +# Change if 3478 is already in use (e.g., UniFi controller uses 3478/udp). +# Common alternative: 3479 +TURN_PORT=3478 + +# ── TURN Relay Port Range ───────────────────────────────────── +# Ports coturn uses for media relay. Forward this range on your router. +# Default is 100 ports (enough for ~50 simultaneous relayed calls). +# Most calls use direct paths; TURN relay is the fallback. +TURN_RELAY_MIN=49152 +TURN_RELAY_MAX=49252 + +# ── RTP Port Range ──────────────────────────────────────────── +# Asterisk's own RTP media ports. Forward this range on your router. +# Default: 10000-20000 (10,000 ports) +# For constrained environments: 10000-10200 +RTP_START=10000 +RTP_END=20000 + +# ── Web Admin ───────────────────────────────────────────────── +# HTTP management interface. Access via browser at: +# http://your-server:8080/clients +# +# For HTTPS: put this behind Caddy or nginx reverse proxy, +# then set WEB_ADMIN_AUTH_DISABLED=true (let the proxy handle auth). +WEB_ADMIN_PORT=8080 +WEB_ADMIN_AUTH_DISABLED=false diff --git a/vendor/easy-asterisk/Dockerfile b/vendor/easy-asterisk/Dockerfile new file mode 100644 index 0000000..96a54d5 --- /dev/null +++ b/vendor/easy-asterisk/Dockerfile @@ -0,0 +1,103 @@ +# ================================================================ +# Easy Asterisk - Docker Container +# Asterisk PBX with web admin and optional STUN support +# +# Usage: +# docker compose up -d # Asterisk only +# docker compose --profile stun up -d # Asterisk + self-hosted STUN +# docker exec -it easy-asterisk easy-asterisk # Interactive management +# docker exec -it easy-asterisk vpn-diagnostics # VPN diagnostics +# docker exec -it easy-asterisk dns-whitelist # DNS whitelist check +# ================================================================ + +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV LANG=C.UTF-8 + +# Install Asterisk and all dependencies (matches install_asterisk_packages) +RUN echo "exit 101" > /usr/sbin/policy-rc.d && chmod +x /usr/sbin/policy-rc.d && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + asterisk \ + asterisk-core-sounds-en-gsm \ + asterisk-modules \ + ca-certificates \ + openssl \ + curl \ + wget \ + tcpdump \ + sngrep \ + python3 \ + iproute2 \ + net-tools \ + dnsutils \ + iputils-ping \ + procps \ + lsof \ + && rm -rf /var/lib/apt/lists/* \ + && rm -f /usr/sbin/policy-rc.d \ + && ldconfig \ + && update-ca-certificates 2>/dev/null || true + +# NOTE: Opus transcoding (codec_opus.so) is NOT available on Ubuntu 24.04 due to +# a packaging bug (Launchpad #2044135). The Digium precompiled binary is ABI-incompatible. +# Opus pass-through (phone-to-phone) still works via res_format_attr_opus.so from +# asterisk-modules. Only Opus<->ulaw transcoding is missing, which is rarely needed +# since modern SIP phones all support the same codecs natively. + +# Create required directories +RUN mkdir -p \ + /etc/easy-asterisk \ + /etc/asterisk/certs \ + /var/lib/asterisk/static-http \ + /var/log/asterisk \ + /var/spool/asterisk \ + /var/run/asterisk \ + && chown -R asterisk:asterisk \ + /etc/asterisk \ + /var/lib/asterisk \ + /var/log/asterisk \ + /var/spool/asterisk \ + /var/run/asterisk + +# Docker detection marker (used by is_docker() in the script) +RUN touch /.dockerenv + +# Copy the main management script +COPY easy-asterisk-v0.10.0.sh /usr/local/bin/easy-asterisk +RUN chmod +x /usr/local/bin/easy-asterisk + +# Copy diagnostic and utility scripts +COPY scripts/vpn-diagnostics.sh /usr/local/bin/vpn-diagnostics +COPY scripts/dns-whitelist.sh /usr/local/bin/dns-whitelist +RUN chmod +x /usr/local/bin/vpn-diagnostics /usr/local/bin/dns-whitelist + +# Copy entrypoint +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# SIP signaling +EXPOSE 5060/udp +EXPOSE 5060/tcp +EXPOSE 5061/tcp + +# Web admin + provisioning +EXPOSE 8080/tcp +EXPOSE 8088/tcp +EXPOSE 8089/tcp + +# STUN (if running coturn in same container; default 3478, configurable via TURN_PORT) +EXPOSE 3478/udp + +# RTP media range (use --network host in production for full range) +# Docker port-mapping 10000 ports is impractical; host networking recommended +EXPOSE 10000-10100/udp + +# Persistent data +VOLUME ["/etc/asterisk", "/etc/easy-asterisk", "/var/log/asterisk"] + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD asterisk -rx "core show version" >/dev/null 2>&1 || exit 1 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/vendor/easy-asterisk/docker/coturn-entrypoint.sh b/vendor/easy-asterisk/docker/coturn-entrypoint.sh new file mode 100644 index 0000000..e9aee8e --- /dev/null +++ b/vendor/easy-asterisk/docker/coturn-entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# ================================================================ +# Robust coturn entrypoint +# +# The coturn/coturn Docker image's native entrypoint uses: +# exec $(eval "echo $@") +# which is fragile — if DETECT_EXTERNAL_IP's DNS lookup returns empty, +# the eval produces an empty token → "ERROR: CONFIG: Unknown argument:" +# +# This wrapper reuses the image's detect-external-ip script but avoids +# the eval word-splitting issue. If detection fails, we simply omit +# --external-ip rather than passing a blank argument. +# ================================================================ + +# Use explicit PUBLIC_IP if provided, otherwise auto-detect +if [ -z "$PUBLIC_IP" ]; then + PUBLIC_IP=$(detect-external-ip 2>/dev/null || true) +fi + +# Only add --external-ip if we actually have an IP +EXTERNAL_IP_ARG="" +if [ -n "$PUBLIC_IP" ]; then + EXTERNAL_IP_ARG="--external-ip=$PUBLIC_IP" +fi + +exec turnserver "$@" $EXTERNAL_IP_ARG diff --git a/vendor/easy-asterisk/docker/entrypoint.sh b/vendor/easy-asterisk/docker/entrypoint.sh new file mode 100644 index 0000000..da1cfa8 --- /dev/null +++ b/vendor/easy-asterisk/docker/entrypoint.sh @@ -0,0 +1,449 @@ +#!/bin/bash +# ================================================================ +# Easy Asterisk Docker Entrypoint +# +# Fully automated: +# - Detects public IP +# - Generates TURN credentials if not provided +# - Configures Asterisk with FQDN, TLS, ICE, STUN, TURN +# - Starts web admin + Asterisk +# ================================================================ + +set -e + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[entrypoint]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[entrypoint]${NC} $1"; } +log_error() { echo -e "${RED}[entrypoint]${NC} $1"; } + +CONFIG_DIR="/etc/easy-asterisk" +CONFIG_FILE="${CONFIG_DIR}/config" +WEB_ADMIN_SCRIPT="/usr/local/bin/easy-asterisk-webadmin" + +# ── Helper: generate random password ───────────────────────── +gen_password() { + openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c 24 +} + +# ── 1. Ensure asterisk user exists ─────────────────────────── +if ! id asterisk >/dev/null 2>&1; then + useradd -r -s /bin/false -d /var/lib/asterisk asterisk 2>/dev/null || true +fi + +# ── 2. Detect public IP ────────────────────────────────────── +PUBLIC_IP="${PUBLIC_IP:-}" +if [[ -z "$PUBLIC_IP" ]]; then + log_info "Auto-detecting public IP..." + PUBLIC_IP=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || true) + if [[ -z "$PUBLIC_IP" ]]; then + PUBLIC_IP=$(curl -s -4 --connect-timeout 5 icanhazip.com 2>/dev/null || true) + fi + if [[ -z "$PUBLIC_IP" ]]; then + PUBLIC_IP=$(curl -s -4 --connect-timeout 5 api.ipify.org 2>/dev/null || true) + fi +fi + +if [[ -n "$PUBLIC_IP" ]]; then + log_info "Public IP: ${PUBLIC_IP}" +else + log_warn "Could not detect public IP. Set PUBLIC_IP in .env" +fi + +# ── 3. TURN credentials ───────────────────────────────────────── +# The password MUST match what coturn was started with. In Docker, both +# read from the same env-var / .env file, so we use the value as-is. +# Auto-generating a different password here would create a mismatch +# (coturn is already running with ITS copy of the env-var). +TURN_USERNAME="${TURN_USERNAME:-easyasterisk}" +TURN_PASSWORD="${TURN_PASSWORD:-changeme}" +if [[ "${TURN_PASSWORD}" == "changeme" ]]; then + log_warn "TURN password is the default 'changeme' — set TURN_PASSWORD in .env for better security" +fi + +# ── 4. Detect local network ────────────────────────────────── +local_ip=$(hostname -I 2>/dev/null | awk '{print $1}') +raw_cidr=$(ip -o -f inet addr show 2>/dev/null | awk '/scope global/ {print $4}' | head -1) +default_cidr="$raw_cidr" +if [[ "$raw_cidr" =~ \.([0-9]+)/([0-9]+)$ ]]; then + default_cidr="${raw_cidr%.*}.0/${BASH_REMATCH[2]}" +fi + +# ── 5. Generate self-signed certs ────────────────────────────── +# Regenerate if missing OR if existing cert lacks SANs (modern TLS clients require them) +regen_cert=false +if [[ ! -f /etc/asterisk/certs/server.crt ]]; then + regen_cert=true +elif ! openssl x509 -in /etc/asterisk/certs/server.crt -noout -ext subjectAltName 2>/dev/null | grep -q "DNS:"; then + log_info "Existing TLS cert lacks SANs — regenerating for mobile phone compatibility" + regen_cert=true +fi + +if $regen_cert; then + log_info "Generating self-signed TLS certificate..." + mkdir -p /etc/asterisk/certs + cn="${DOMAIN_NAME:-asterisk-local}" + # Include Subject Alternative Names — required by modern TLS clients (iOS/Android SIP apps) + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout /etc/asterisk/certs/server.key \ + -out /etc/asterisk/certs/server.crt \ + -subj "/CN=${cn}" \ + -addext "subjectAltName=DNS:${cn}${PUBLIC_IP:+,IP:${PUBLIC_IP}}" \ + 2>/dev/null + chown asterisk:asterisk /etc/asterisk/certs/server.* + chmod 644 /etc/asterisk/certs/server.crt + chmod 600 /etc/asterisk/certs/server.key +fi + +# ── 6. Write config file ───────────────────────────────────── +mkdir -p "$CONFIG_DIR" + +# Determine TURN/STUN server address +turn_server="${TURN_SERVER:-${DOMAIN_NAME:-$local_ip}:${TURN_PORT:-3478}}" + +cat > "$CONFIG_FILE" << EOF +# Easy Asterisk Configuration (Docker) - $(date) +KIOSK_USER="" +KIOSK_UID="" +KIOSK_EXTENSION="" +KIOSK_NAME="" +SIP_PASSWORD="" +ASTERISK_HOST="${DOMAIN_NAME:-$local_ip}" +DOMAIN_NAME="${DOMAIN_NAME:-}" +ENABLE_TLS="${ENABLE_TLS:-y}" +HAS_VLANS="${HAS_VLANS:-n}" +VLAN_SUBNETS="${VLAN_SUBNETS:-}" +CERT_PATH="" +KEY_PATH="" +INSTALLED_SERVER="y" +INSTALLED_CLIENT="n" +CURRENT_PUBLIC_IP="${PUBLIC_IP}" +PTT_DEVICE="" +PTT_KEYCODE="" +LOCAL_CIDR="${LOCAL_CIDR:-$default_cidr}" +WEB_ADMIN_PORT="${WEB_ADMIN_PORT:-8080}" +WEB_ADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" +VPN_ICE_ENABLED="y" +CUSTOM_STUN_SERVER="${turn_server}" +TURN_ENABLED="y" +TURN_SERVER="${turn_server}" +TURN_USERNAME="${TURN_USERNAME}" +TURN_PASSWORD="${TURN_PASSWORD}" +EOF +chmod 644 "$CONFIG_FILE" + +# ── 7. Initialize categories & rooms if missing ────────────── +CATEGORIES_FILE="${CONFIG_DIR}/categories.conf" +if [[ ! -f "$CATEGORIES_FILE" ]]; then + log_info "Creating default device categories..." + cat > "$CATEGORIES_FILE" << 'EOF' +kiosks|Kiosks|yes|Fixed wall-mount tablets & intercoms +mobile|Mobile|no|Phones & tablets (ring normally) +custom|Custom|no|Custom configuration +EOF +fi + +ROOMS_FILE="${CONFIG_DIR}/rooms.conf" +if [[ ! -f "$ROOMS_FILE" ]]; then + cat > "$ROOMS_FILE" << 'EOF' +# ext|name|members|timeout|type +EOF +fi + +# ── 8. Generate Asterisk configs ───────────────────────────── + +# Build local_net entries +all_local_nets="local_net=${LOCAL_CIDR:-$default_cidr}" +if [[ "${HAS_VLANS:-n}" == "y" && -n "${VLAN_SUBNETS:-}" ]]; then + for subnet in $VLAN_SUBNETS; do + all_local_nets="${all_local_nets} +local_net=${subnet}" + done +fi + +# NAT settings - always include external addresses for FQDN mode +nat_settings="" +if [[ -n "$PUBLIC_IP" ]]; then + nat_settings="external_media_address=${PUBLIC_IP} +external_signaling_address=${PUBLIC_IP} +${all_local_nets}" +else + nat_settings="${all_local_nets}" +fi + +# ── pjsip.conf (only if empty/missing - preserves existing devices) ── +if [[ ! -f /etc/asterisk/pjsip.conf ]] || [[ ! -s /etc/asterisk/pjsip.conf ]]; then + log_info "Generating PJSIP configuration..." + cat > /etc/asterisk/pjsip.conf << EOF +; Easy Asterisk (Docker) - FQDN: ${DOMAIN_NAME:-none} +[global] +type=global +user_agent=EasyAsterisk + +[transport-udp] +type=transport +protocol=udp +bind=0.0.0.0:5060 +; Server IP: ${local_ip} | Public IP: ${PUBLIC_IP:-unknown} +${nat_settings} + +[transport-tcp] +type=transport +protocol=tcp +bind=0.0.0.0:5060 +; Server IP: ${local_ip} | Public IP: ${PUBLIC_IP:-unknown} +${nat_settings} + +[transport-tls] +type=transport +protocol=tls +bind=0.0.0.0:5061 +; Server IP: ${local_ip} | Public IP: ${PUBLIC_IP:-unknown} +cert_file=/etc/asterisk/certs/server.crt +priv_key_file=/etc/asterisk/certs/server.key +; ca_list_file not set — only needed for verify_client=yes (client cert auth) +method=tlsv1_2 +${nat_settings} + +EOF + chown asterisk:asterisk /etc/asterisk/pjsip.conf +else + # Update NAT settings in existing pjsip.conf transports if public IP changed + if [[ -n "$PUBLIC_IP" ]]; then + current_ext=$(grep "^external_media_address=" /etc/asterisk/pjsip.conf 2>/dev/null | head -1 | cut -d= -f2) + if [[ "$current_ext" != "$PUBLIC_IP" && -n "$current_ext" ]]; then + log_info "Updating public IP in pjsip.conf: ${current_ext} -> ${PUBLIC_IP}" + sed -i "s|external_media_address=.*|external_media_address=${PUBLIC_IP}|g" /etc/asterisk/pjsip.conf + sed -i "s|external_signaling_address=.*|external_signaling_address=${PUBLIC_IP}|g" /etc/asterisk/pjsip.conf + fi + fi +fi + +# ── Sanitize pjsip.conf: remove endpoint-only options from aor sections ── +if [[ -f /etc/asterisk/pjsip.conf ]]; then + # Options that are only valid in [endpoint] sections, not in [aor] sections + endpoint_only_opts="direct_media|rtp_symmetric|force_rport|rewrite_contact|rtp_keepalive|rtp_timeout|rtp_timeout_hold|ice_support|context|disallow|allow|auth|aors|callerid|media_encryption|transport" + current_type="" + needs_fix=false + while IFS= read -r line; do + if [[ "$line" =~ ^type=(.*) ]]; then + current_type="${BASH_REMATCH[1]}" + fi + if [[ "$current_type" == "aor" ]] && echo "$line" | grep -qE "^(${endpoint_only_opts})="; then + needs_fix=true + break + fi + done < /etc/asterisk/pjsip.conf + + if $needs_fix; then + log_info "Sanitizing pjsip.conf (removing misplaced options from aor sections)..." + awk -v opts="$endpoint_only_opts" ' + BEGIN { split(opts, arr, "|"); for (i in arr) bad[arr[i]]=1 } + /^type=/ { current_type = substr($0, 6) } + { + if (current_type == "aor") { + split($0, kv, "=") + if (kv[1] in bad) next + } + print + } + ' /etc/asterisk/pjsip.conf > /tmp/pjsip_sanitized.conf + mv /tmp/pjsip_sanitized.conf /etc/asterisk/pjsip.conf + chown asterisk:asterisk /etc/asterisk/pjsip.conf + fi +fi + +# ── Ensure transport-tls exists in pjsip.conf (upgrade / migration path) ── +# If pjsip.conf was preserved from a pre-TLS config or a non-Docker install it +# will have no [transport-tls] section. Asterisk starts without TLS silently, +# and mobile devices cannot register. Inject the section when it is absent. +if [[ -f /etc/asterisk/pjsip.conf ]] && ! grep -q "^\[transport-tls\]" /etc/asterisk/pjsip.conf; then + log_info "transport-tls missing from pjsip.conf — adding TLS transport (required for mobile registration)..." + cat >> /etc/asterisk/pjsip.conf << EOF + +[transport-tls] +type=transport +protocol=tls +bind=0.0.0.0:5061 +cert_file=/etc/asterisk/certs/server.crt +priv_key_file=/etc/asterisk/certs/server.key +; ca_list_file not set — only needed for verify_client=yes (client cert auth) +method=tlsv1_2 +${nat_settings} + +EOF + chown asterisk:asterisk /etc/asterisk/pjsip.conf +fi + +# ── rtp.conf (always regenerated) ── +# ICE is enabled so Asterisk participates in ICE negotiation with clients. +# stunaddr/turnaddr are NOT set here because: +# - Asterisk already knows its public IP via external_media_address in pjsip.conf +# - Its RTP ports are port-forwarded, so host candidates are sufficient +# - Setting stunaddr/turnaddr causes STUN/TURN gather timeouts (~27s per call) +# when the STUN/TURN server is unreachable or misconfigured +# coturn is for SIP CLIENTS behind strict NAT — they configure TURN in their +# own app settings, independently of Asterisk's rtp.conf. +log_info "Configuring RTP with ICE support..." +cat > /etc/asterisk/rtp.conf << EOF +[general] +rtpstart=${RTP_START:-10000} +rtpend=${RTP_END:-20000} +strictrtp=yes +icesupport=yes +EOF +chown asterisk:asterisk /etc/asterisk/rtp.conf + +# ── extensions.conf (only if missing) ── +if [[ ! -f /etc/asterisk/extensions.conf ]] || [[ ! -s /etc/asterisk/extensions.conf ]]; then + log_info "Generating dialplan..." + cat > /etc/asterisk/extensions.conf << 'EOF' +[general] +static=yes +writeprotect=no +[default] +exten => _X.,1,Hangup() +[intercom] +EOF + chown asterisk:asterisk /etc/asterisk/extensions.conf +fi + +# ── Other core configs (only if missing) ── +if [[ ! -f /etc/asterisk/asterisk.conf ]]; then + cat > /etc/asterisk/asterisk.conf << 'EOF' +[directories] +[options] +runuser = asterisk +rungroup = asterisk +EOF +fi + +# ── logger.conf (always regenerated - ensures security logging is on) ── +cat > /etc/asterisk/logger.conf << 'EOF' +[general] +[logfiles] +; security level captures TLS handshake failures and auth issues +console => notice,warning,error,security +EOF + +# ── modules.conf (always regenerated - ensures chan_sip stays disabled) ── +cat > /etc/asterisk/modules.conf << 'EOF' +[modules] +autoload=yes +noload => chan_sip.so +noload => chan_iax2.so +; Opus transcoding unavailable on Ubuntu 24.04 (bug #2044135) +; Opus pass-through still works via res_format_attr_opus.so +noload => codec_opus.so +noload => format_ogg_opus.so +load => res_pjsip.so +load => res_pjsip_session.so +load => res_pjsip_logger.so +load => chan_pjsip.so +load => codec_ulaw.so +load => codec_alaw.so +load => codec_g722.so +load => res_rtp_asterisk.so +load => app_dial.so +load => app_page.so +load => pbx_config.so +EOF + +# ── Remove incompatible Digium codec_opus if present on volume ── +# The Digium binary is ABI-incompatible with Ubuntu 24.04's Asterisk and crashes it +MODULES_DIR=$(find /usr/lib -type d -name modules -path "*/asterisk/*" 2>/dev/null | head -1) +if [[ -n "$MODULES_DIR" ]]; then + for bad_module in codec_opus.so format_ogg_opus.so; do + if [[ -f "$MODULES_DIR/$bad_module" ]] && ! dpkg -S "$MODULES_DIR/$bad_module" >/dev/null 2>&1; then + log_warn "Removing incompatible $bad_module (not from Ubuntu package)" + rm -f "$MODULES_DIR/$bad_module" + fi + done +fi + +# ── 9. Fix permissions ─────────────────────────────────────── +chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk 2>/dev/null || true + +# ── 10. Start Web Admin in background ───────────────────────── +# The web admin script is generated by the 'easy-asterisk' management tool. +# On first run: docker exec -it easy-asterisk easy-asterisk → Web Admin menu → Start +if [[ -f "$WEB_ADMIN_SCRIPT" ]]; then + log_info "Starting Web Admin on port ${WEB_ADMIN_PORT:-8080}..." + WEBADMIN_PORT="${WEB_ADMIN_PORT:-8080}" \ + WEBADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" \ + python3 "$WEB_ADMIN_SCRIPT" & +fi + +# ── 11. Signal handling for clean shutdown ──────────────────── +cleanup() { + log_info "Shutting down..." + pkill -f "easy-asterisk-webadmin" 2>/dev/null || true + asterisk -rx "core stop now" 2>/dev/null || true + exit 0 +} +trap cleanup SIGTERM SIGINT + +# ── 12. Start Asterisk ─────────────────────────────────────── +log_info "Starting Asterisk PBX..." +echo "" + +# Start Asterisk in the background, then print management info once ready +asterisk -f -U asterisk -G asterisk & +ASTERISK_PID=$! + +# Wait for Asterisk to be ready (up to 60 seconds) +for i in $(seq 1 60); do + if asterisk -rx "core show version" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +# Verify PJSIP transports are listening +tls_ok=false +udp_ok=false +if asterisk -rx "pjsip show transports" 2>/dev/null | grep -q "transport-tls"; then + tls_ok=true +fi +if asterisk -rx "pjsip show transports" 2>/dev/null | grep -q "transport-udp"; then + udp_ok=true +fi + +# Check if port 5061 is actually bound +tls_listen="" +if command -v ss &>/dev/null; then + tls_listen=$(ss -tlnp 2>/dev/null | grep ":5061 " || true) +elif command -v netstat &>/dev/null; then + tls_listen=$(netstat -tlnp 2>/dev/null | grep ":5061 " || true) +fi + +echo "" +echo -e "${CYAN}══════════════════════════════════════════════════════════════${NC}" +echo -e "${CYAN} Easy Asterisk (Docker)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════════════════════${NC}" +echo -e " FQDN: ${GREEN}${DOMAIN_NAME:-not set}${NC}" +echo -e " Public IP: ${GREEN}${PUBLIC_IP:-unknown}${NC}" +echo -e " TURN/STUN: ${GREEN}${turn_server}${NC}" +if $tls_ok && [[ -n "$tls_listen" ]]; then + echo -e " TLS: ${GREEN}Enabled (port 5061)${NC}" +elif $tls_ok; then + echo -e " TLS: ${YELLOW}Transport loaded but port 5061 not bound — check certs${NC}" +else + echo -e " TLS: ${RED}NOT LOADED — check Asterisk logs${NC}" +fi +echo -e " ICE: ${GREEN}Enabled${NC}" +echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}" +echo -e " SIP clients connect to: ${GREEN}${DOMAIN_NAME:-$local_ip}:5061${NC} (TLS)" +echo -e " Web Admin: ${GREEN}http://${local_ip}:${WEB_ADMIN_PORT:-8080}/clients${NC}" +echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}" +echo -e " Management: ${YELLOW}docker exec -it easy-asterisk easy-asterisk${NC}" +echo -e " Diagnostics: docker exec -it easy-asterisk vpn-diagnostics" +echo -e "${CYAN}══════════════════════════════════════════════════════════════${NC}" +echo "" + +# Wait for Asterisk process (keeps container running) +wait $ASTERISK_PID diff --git a/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh b/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh new file mode 100644 index 0000000..bc63904 --- /dev/null +++ b/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh @@ -0,0 +1,6929 @@ +#!/bin/bash +# ================================================================ +# Easy Asterisk - Interactive Installer v0.10.0 +# +# Copyright (C) 2025 Easy Asterisk Contributors +# Licensed under GNU General Public License v3.0 +# See LICENSE file or https://www.gnu.org/licenses/gpl-3.0.html +# +# UPDATES in v0.10.0: +# - FIXED: Extension deletion now properly removes all sections (endpoint, auth, aor) +# - FIXED: Extension renaming now preserves AA tags correctly +# - FIXED: LAN/VPN devices now explicitly use UDP transport (prevents TLS fallback) +# - FIXED: LAN devices now have media_encryption=no to prevent SRTP issues +# - FIXED: VPN subnets now included as local_net in LAN mode (fixes VPN mobile offline) +# - FIXED: One-way audio on WiFi-to-mobile-data handoff (rtp_keepalive + timers) +# - ADDED: Web Admin interface for browser-based client management +# - View device status (online/offline) in real-time +# - Add/delete devices via web interface +# - View rooms and categories +# - HTTP Basic authentication with SHA256 password hashing +# - Access at http://server:8080/clients +# - ADDED: VPN subnet auto-detection (Tailscale, WireGuard, OpenVPN) +# - ADDED: VPN STUN/ICE configuration for third-party VPNs +# - Self-hosted coturn STUN (no external DNS dependencies) +# - Custom STUN server support +# - Per-device ICE for LAN/VPN mode endpoints +# - ADDED: Docker container support (Dockerfile + docker-compose) +# - ADDED: VPN diagnostics tool (vpn-diagnostics) +# - ADDED: DNS whitelist checker for filtered networks (dns-whitelist) +# - IMPROVED: Device deletion uses awk for reliable multi-section removal +# - IMPROVED: Device renaming uses awk to handle all edge cases +# +# PREVIOUS UPDATES (v0.9.9): +# - REMOVED: All COTURN/TURN relay server code (focus on direct connections) +# - ADDED: VLAN subnet configuration to prevent 30-second call drops +# - ADDED: Provisioning Manager (http.conf setup, symlinks, linphone.xml editor) +# - ADDED: Manual Update System for Asterisk with backup/rollback +# - ADDED: Room Directory (visual display of Ring Groups vs Page Groups) +# - ADDED: Split-horizon DNS documentation for VLAN environments +# - IMPROVED: Server IP address documented in transport configurations +# - IMPROVED: Multiple local_net entries for proper VLAN support +# ================================================================ + +set +e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# Defaults +DEFAULT_SIP_PORT="5060" +DEFAULT_SIPS_PORT="5061" +CONFIG_DIR="/etc/easy-asterisk" +CONFIG_FILE="${CONFIG_DIR}/config" +PTT_CONFIG_FILE="${CONFIG_DIR}/ptt-device" +CATEGORIES_FILE="${CONFIG_DIR}/categories.conf" +ROOMS_FILE="${CONFIG_DIR}/rooms.conf" +PROVISIONING_DIR="/var/lib/asterisk/static-http" +SCRIPT_VERSION="0.10.0" + +# ================================================================ +# 1. CORE HELPER FUNCTIONS +# ================================================================ + +print_header() { + echo -e "\n${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN} $1${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}\n" +} + +print_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +print_error() { echo -e "${RED}[ERROR]${NC} $1"; } +print_success() { echo -e "${GREEN}[OK]${NC} $1"; } + +check_root() { + if [[ $EUID -ne 0 ]]; then + print_error "This script must be run as root (use sudo)" + exit 1 + fi +} + +# ── Docker / Container Detection ───────────────────────────── +# Returns 0 (true) if running inside a Docker/container environment + +is_docker() { + [[ -f /.dockerenv ]] || grep -qsE "docker|containerd|lxc" /proc/1/cgroup 2>/dev/null +} + +# Check if Asterisk process is running (works in both Docker and bare metal) +asterisk_running() { + if is_docker; then + pgrep -x asterisk >/dev/null 2>&1 + else + systemctl is-active asterisk >/dev/null 2>&1 + fi +} + +# Start/restart Asterisk (Docker-aware) +restart_asterisk_safe() { + print_info "Restarting Asterisk..." + if is_docker; then + # In Docker: use Asterisk CLI to restart, or restart the process + if pgrep -x asterisk >/dev/null 2>&1; then + asterisk -rx "core restart now" 2>/dev/null || true + sleep 3 + fi + # If not running, start it in the background + if ! pgrep -x asterisk >/dev/null 2>&1; then + rm -f /var/run/asterisk/asterisk.pid 2>/dev/null || true + asterisk -U asterisk -G asterisk & + sleep 3 + fi + if pgrep -x asterisk >/dev/null 2>&1; then + print_success "Asterisk running" + else + print_error "Asterisk failed to start" + fi + else + systemctl stop asterisk 2>/dev/null || true + sleep 2 + pkill -9 -x asterisk 2>/dev/null || true + rm -f /var/run/asterisk/asterisk.pid 2>/dev/null || true + rm -f /var/lib/asterisk/.asterisk_history 2>/dev/null || true + systemctl start asterisk + sleep 3 + if systemctl is-active asterisk >/dev/null; then + print_success "Asterisk running" + else + print_error "Asterisk failed to start" + journalctl -u asterisk -n 15 --no-pager + fi + fi +} + +# Web admin process management (Docker-aware) +webadmin_running() { + pgrep -f "easy-asterisk-webadmin" >/dev/null 2>&1 +} + +start_webadmin() { + load_config + if webadmin_running; then + print_warn "Web admin already running" + return + fi + create_web_admin_script + if [[ ! -f "$WEB_ADMIN_HTPASSWD" ]] && [[ "${WEB_ADMIN_AUTH_DISABLED:-}" != "true" ]]; then + setup_web_admin_auth + fi + WEBADMIN_PORT="${WEB_ADMIN_PORT:-8080}" \ + WEBADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" \ + nohup python3 "$WEB_ADMIN_SCRIPT" >/dev/null 2>&1 & + sleep 2 + if webadmin_running; then + print_success "Web Admin started on port ${WEB_ADMIN_PORT}" + else + print_error "Web Admin failed to start" + fi +} + +stop_webadmin() { + if webadmin_running; then + pkill -f "easy-asterisk-webadmin" 2>/dev/null || true + sleep 1 + # Force kill if still running + if webadmin_running; then + pkill -9 -f "easy-asterisk-webadmin" 2>/dev/null || true + sleep 1 + fi + fi + # Also kill anything on the port + local port_pids=$(lsof -ti ":${WEB_ADMIN_PORT}" 2>/dev/null) + if [[ -n "$port_pids" ]]; then + echo "$port_pids" | xargs kill -9 2>/dev/null || true + sleep 1 + fi + if ! webadmin_running; then + print_success "Web Admin stopped" + else + print_error "Web Admin could not be stopped" + fi +} + +restart_webadmin() { + stop_webadmin 2>/dev/null + start_webadmin +} + +generate_password() { + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16 +} + +select_user() { + # Scan /home for real users (exclude system accounts) + local -a users=() + local -a user_ids=() + local count=0 + + echo "Scanning for users..." + echo "" + + # Get users from /home with valid shells + while IFS=: read -r username _ uid _ _ homedir shell; do + # Only include users with UID >= 1000 and valid shell + if [[ $uid -ge 1000 && -d "$homedir" && "$shell" != "/usr/sbin/nologin" && "$shell" != "/bin/false" ]]; then + ((count++)) + users+=("$username") + user_ids+=("$uid") + echo " ${count}) ${username} (UID: ${uid}, Home: ${homedir})" + fi + done < /etc/passwd + + # Add option to manually enter username + ((count++)) + echo " ${count}) Enter username manually" + echo "" + + # Suggest default based on SUDO_USER or first user found + local default_choice="" + local default_user="${SUDO_USER:-}" + if [[ -z "$default_user" ]]; then + default_user="${users[0]:-}" + default_choice="1" + else + # Find index of SUDO_USER + for i in "${!users[@]}"; do + if [[ "${users[$i]}" == "$default_user" ]]; then + default_choice=$((i + 1)) + break + fi + done + fi + + if [[ -n "$default_choice" ]]; then + read -p "Select user [${default_choice}]: " choice + choice="${choice:-$default_choice}" + else + read -p "Select user: " choice + fi + + # Validate choice + if [[ "$choice" =~ ^[0-9]+$ && "$choice" -le "${#users[@]}" && "$choice" -gt 0 ]]; then + local idx=$((choice - 1)) + KIOSK_USER="${users[$idx]}" + KIOSK_UID="${user_ids[$idx]}" + echo "" + print_success "Selected user: $KIOSK_USER (UID: $KIOSK_UID)" + return 0 + elif [[ "$choice" == "$count" ]]; then + # Manual entry + echo "" + read -p "Enter username: " KIOSK_USER + if id "$KIOSK_USER" >/dev/null 2>&1; then + KIOSK_UID=$(id -u "$KIOSK_USER") + print_success "Selected user: $KIOSK_USER (UID: $KIOSK_UID)" + return 0 + else + print_error "User '$KIOSK_USER' not found" + return 1 + fi + else + print_error "Invalid selection" + return 1 + fi +} + +load_config() { + if [[ -f "$CONFIG_FILE" ]]; then + source "$CONFIG_FILE" 2>/dev/null || true + fi + INSTALLED_SERVER="${INSTALLED_SERVER:-n}" + INSTALLED_CLIENT="${INSTALLED_CLIENT:-n}" + KIOSK_USER="${KIOSK_USER:-}" + KIOSK_UID="${KIOSK_UID:-}" + HAS_VLANS="${HAS_VLANS:-n}" + VLAN_SUBNETS="${VLAN_SUBNETS:-}" + WEB_ADMIN_PORT="${WEB_ADMIN_PORT:-8080}" + WEB_ADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" + VPN_ICE_ENABLED="${VPN_ICE_ENABLED:-n}" + CUSTOM_STUN_SERVER="${CUSTOM_STUN_SERVER:-}" + TURN_ENABLED="${TURN_ENABLED:-n}" + TURN_SERVER="${TURN_SERVER:-}" + TURN_USERNAME="${TURN_USERNAME:-}" + TURN_PASSWORD="${TURN_PASSWORD:-}" + return 0 +} + +backup_config() { + local file=$1 + if [[ -f "$file" ]]; then + cp "$file" "${file}.backup-$(date +%s)" + ls -tp "${file}.backup-"* 2>/dev/null | tail -n +6 | xargs -I {} rm -- {} 2>/dev/null + fi +} + +save_config() { + mkdir -p "$CONFIG_DIR" + chmod 755 "$CONFIG_DIR" + + cat > "$CONFIG_FILE" << EOF +# Easy Asterisk Configuration - $(date) +KIOSK_USER="$KIOSK_USER" +KIOSK_UID="$KIOSK_UID" +KIOSK_EXTENSION="$KIOSK_EXTENSION" +KIOSK_NAME="$KIOSK_NAME" +SIP_PASSWORD="$SIP_PASSWORD" +ASTERISK_HOST="$ASTERISK_HOST" +DOMAIN_NAME="$DOMAIN_NAME" +ENABLE_TLS="$ENABLE_TLS" +HAS_VLANS="$HAS_VLANS" +VLAN_SUBNETS="$VLAN_SUBNETS" +CERT_PATH="$CERT_PATH" +KEY_PATH="$KEY_PATH" +INSTALLED_SERVER="$INSTALLED_SERVER" +INSTALLED_CLIENT="$INSTALLED_CLIENT" +CURRENT_PUBLIC_IP="$CURRENT_PUBLIC_IP" +PTT_DEVICE="$PTT_DEVICE" +PTT_KEYCODE="$PTT_KEYCODE" +LOCAL_CIDR="$LOCAL_CIDR" +WEB_ADMIN_PORT="$WEB_ADMIN_PORT" +WEB_ADMIN_AUTH_DISABLED="$WEB_ADMIN_AUTH_DISABLED" +VPN_ICE_ENABLED="$VPN_ICE_ENABLED" +CUSTOM_STUN_SERVER="$CUSTOM_STUN_SERVER" +TURN_ENABLED="$TURN_ENABLED" +TURN_SERVER="$TURN_SERVER" +TURN_USERNAME="$TURN_USERNAME" +TURN_PASSWORD="$TURN_PASSWORD" +EOF + chmod 644 "$CONFIG_FILE" + + # Save PTT config separately + if [[ -n "$PTT_DEVICE" ]]; then + cat > "$PTT_CONFIG_FILE" << EOF +PTT_DEVICE="$PTT_DEVICE" +PTT_KEYCODE="$PTT_KEYCODE" +EOF + chmod 644 "$PTT_CONFIG_FILE" + fi +} + +open_firewall_ports() { + if is_docker; then + # In Docker, firewall is managed on the host, not inside the container + # With network_mode: host, all ports are directly accessible + print_info "Docker mode: firewall is managed on the host" + return + fi + print_info "Configuring firewall ports..." + if command -v ufw &>/dev/null; then + if ufw status 2>/dev/null | grep -q "Status: active"; then + ufw allow 5060/udp comment "SIP UDP" 2>/dev/null || true + ufw allow 5061/tcp comment "SIP TLS" 2>/dev/null || true + ufw allow 10000:20000/udp comment "RTP Media" 2>/dev/null || true + ufw allow 8088/tcp comment "HTTP Provisioning" 2>/dev/null || true + ufw allow 8089/tcp comment "HTTPS Provisioning" 2>/dev/null || true + ufw reload 2>/dev/null || true + print_success "UFW firewall ports opened" + fi + fi +} + +# ================================================================ +# 2. UTILITY FUNCTIONS +# ================================================================ + +get_public_ip() { + local ip=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || curl -s -4 --connect-timeout 5 icanhazip.com 2>/dev/null || echo "") + echo "$ip" +} + +# ================================================================ +# 3. DEVICE MANAGEMENT +# ================================================================ + +initialize_default_categories() { + mkdir -p "$CONFIG_DIR" + if [[ ! -f "$CATEGORIES_FILE" ]]; then + cat > "$CATEGORIES_FILE" << 'EOF' +# Format: id|name|auto_answer(yes/no)|description +kiosk|Kiosks|yes|Fixed auto-answer intercoms +mobile|Mobile Devices|no|Phones and mobile devices +EOF + chmod 600 "$CATEGORIES_FILE" + fi + if [[ ! -f "$ROOMS_FILE" ]]; then + cat > "$ROOMS_FILE" << 'EOF' +# Format: ext|name|members|timeout|type(ring/page) +199|All Kiosks|101,102,103,104,105|60|page +299|All Mobile|201,202,203,204,205|60|ring +EOF + chmod 600 "$ROOMS_FILE" + fi +} + +list_categories() { + initialize_default_categories + local index=1 + while IFS='|' read -r cat_id cat_name auto_answer description; do + [[ "$cat_id" =~ ^# ]] && continue + [[ -z "$cat_id" ]] && continue + local auto_text="${RED}Ring${NC}" + [[ "$auto_answer" == "yes" ]] && auto_text="${GREEN}Auto-answer${NC}" + echo -e " ${CYAN}$index)${NC} ${BOLD}$cat_name${NC} ($cat_id) - $auto_text" + ((index++)) + done < "$CATEGORIES_FILE" +} + +get_category_by_index() { + local target_index=$1 + local index=1 + while IFS='|' read -r cat_id cat_name auto_answer description; do + [[ "$cat_id" =~ ^# ]] && continue + [[ -z "$cat_id" ]] && continue + if [[ $index -eq $target_index ]]; then + echo "$cat_id|$cat_name|$auto_answer" + return 0 + fi + ((index++)) + done < "$CATEGORIES_FILE" +} + +manage_categories() { + print_header "Manage Categories" + list_categories + echo "" + echo " 1) Add Category" + echo " 2) Rename Category" + echo " 3) Delete Category" + echo " 0) Back" + read -p "Select: " choice + case $choice in + 1) + read -p "ID (lowercase): " cid + read -p "Display Name: " cname + read -p "Auto Answer? [y/N]: " ca + local ans="no" + [[ "$ca" =~ ^[Yy]$ ]] && ans="yes" + echo "${cid}|${cname}|${ans}|Custom category" >> "$CATEGORIES_FILE" + print_success "Category added" + rebuild_dialplan + ;; + 2) + read -p "Number to rename: " num + local data=$(get_category_by_index "$num") + if [[ -z "$data" ]]; then + print_error "Invalid selection" + return + fi + local old_id=$(echo "$data" | cut -d'|' -f1) + local old_name=$(echo "$data" | cut -d'|' -f2) + local auto_answer=$(echo "$data" | cut -d'|' -f3) + + echo "Current: $old_name (ID: $old_id)" + read -p "New display name: " new_name + + if [[ -z "$new_name" ]]; then + print_error "Name cannot be empty" + return + fi + + # Backup + backup_config "$CATEGORIES_FILE" + + # Update category file + sed -i "s/^${old_id}|${old_name}|/${old_id}|${new_name}|/" "$CATEGORIES_FILE" + + print_success "Category renamed: ${old_name} → ${new_name}" + rebuild_dialplan + ;; + 3) + read -p "Number to delete: " num + local data=$(get_category_by_index "$num") + if [[ -z "$data" ]]; then + print_error "Invalid selection" + return + fi + local cid=$(echo "$data" | cut -d'|' -f1) + local cname=$(echo "$data" | cut -d'|' -f2) + + # Count devices in this category + local device_count=$(grep -c "; === Device:.* (${cid})" /etc/asterisk/pjsip.conf 2>/dev/null || echo "0") + + if [[ $device_count -gt 0 ]]; then + echo "" + echo -e "${YELLOW}Warning: This category has ${device_count} device(s)${NC}" + echo "" + echo " 1) Delete category only (reassign devices to 'uncategorized')" + echo " 2) Delete category AND all devices in it" + echo " 0) Cancel" + read -p "Select: " del_choice + + case $del_choice in + 1) + # Ensure uncategorized category exists + if ! grep -q "^uncategorized|" "$CATEGORIES_FILE" 2>/dev/null; then + echo "uncategorized|Uncategorized|no|Default category for orphaned devices" >> "$CATEGORIES_FILE" + fi + + # Reassign all devices to uncategorized + backup_config "/etc/asterisk/pjsip.conf" + sed -i "s/; === Device: \(.*\) (${cid})/; === Device: \1 (uncategorized)/" /etc/asterisk/pjsip.conf + + # Delete the category + sed -i "/^${cid}|/d" "$CATEGORIES_FILE" + + print_success "Category deleted, ${device_count} device(s) moved to 'uncategorized'" + rebuild_dialplan + ;; + 2) + echo "" + echo -e "${RED}WARNING: This will DELETE ${device_count} device(s)!${NC}" + read -p "Type 'DELETE ALL' to confirm: " confirm + + if [[ "$confirm" == "DELETE ALL" ]]; then + backup_config "/etc/asterisk/pjsip.conf" + + # Get all extensions in this category + local exts_to_delete="" + local in_device=0 + local current_ext="" + local current_cat="" + + while IFS= read -r line; do + if [[ "$line" == *"; === Device:"* ]]; then + local temp="${line#*; === Device: }" + temp="${temp% ===}" + [[ "$temp" == *"[AA:"* ]] && temp="${temp% \[AA:*\]}" + current_cat="${temp##* (}"; current_cat="${current_cat%)}" + fi + if [[ "$line" =~ ^\[([0-9]+)\] ]]; then + current_ext="${BASH_REMATCH[1]}" + if [[ "$current_cat" == "$cid" ]]; then + exts_to_delete="${exts_to_delete} ${current_ext}" + fi + fi + done < /etc/asterisk/pjsip.conf + + # Delete all device sections for this category + for ext in $exts_to_delete; do + sed -i "/^; === Device:.*${ext}.* (${cid})/,/^$/d" /etc/asterisk/pjsip.conf + sed -i "/^\[${ext}\]/,/^$/d" /etc/asterisk/pjsip.conf + done + + # Delete the category + sed -i "/^${cid}|/d" "$CATEGORIES_FILE" + + asterisk -rx "pjsip reload" 2>/dev/null + rebuild_dialplan + print_success "Category and ${device_count} device(s) deleted" + else + print_error "Cancelled" + fi + ;; + 0) + print_error "Cancelled" + return + ;; + esac + else + # No devices, just delete the category + sed -i "/^${cid}|/d" "$CATEGORIES_FILE" + print_success "Category deleted (no devices affected)" + rebuild_dialplan + fi + ;; + esac +} + + +manage_rooms() { + print_header "Manage Rooms" + initialize_default_categories + echo "Current Rooms:" + local index=1 + while IFS='|' read -r rext rname rmem rtime rtype; do + [[ "$rext" =~ ^# ]] && continue + [[ -z "$rext" ]] && continue + local type_text="Ring Group" + [[ "$rtype" == "page" ]] && type_text="${GREEN}PAGE/INTERCOM${NC}" + echo -e " ${CYAN}$index)${NC} ${BOLD}$rname${NC} ($rext) - $type_text" + echo -e " Members: $rmem" + ((index++)) + done < "$ROOMS_FILE" + echo "" + echo " 1) Add Room" + echo " 2) Rename Room" + echo " 3) Edit Room Members" + echo " 4) Delete Room" + echo " 0) Back" + read -p "Select: " choice + case $choice in + 1) + read -p "Room Extension: " new_ext + read -p "Room Name: " new_name + echo " 1) Ring Group (Phones ring)" + echo " 2) Page/Intercom (Auto-answer)" + read -p "Select [1]: " type_sel + local rtype="ring" + [[ "$type_sel" == "2" ]] && rtype="page" + read -p "Members (e.g. 101,102): " members + echo "${new_ext}|${new_name}|${members}|60|${rtype}" >> "$ROOMS_FILE" + rebuild_dialplan + print_success "Room Created" + ;; + 2) + read -p "Select Room #: " rnum + local target_line="" + local count=0 + while IFS= read -r line; do + if [[ ! "$line" =~ ^# ]] && [[ -n "$line" ]]; then + ((count++)) + if [[ $count -eq $rnum ]]; then target_line="$line"; break; fi + fi + done < "$ROOMS_FILE" + if [[ -n "$target_line" ]]; then + IFS='|' read -r rext old_name rmem rtime rtype <<< "$target_line" + echo "Current name: $old_name" + read -p "New name: " new_name + + if [[ -z "$new_name" ]]; then + print_error "Name cannot be empty" + return + fi + + backup_config "$ROOMS_FILE" + sed -i "/^${rext}|/d" "$ROOMS_FILE" + echo "${rext}|${new_name}|${rmem}|${rtime}|${rtype}" >> "$ROOMS_FILE" + rebuild_dialplan + print_success "Room renamed: ${old_name} → ${new_name}" + else + print_error "Invalid selection" + fi + ;; + 3) + read -p "Select Room #: " rnum + local target_line="" + local count=0 + while IFS= read -r line; do + if [[ ! "$line" =~ ^# ]] && [[ -n "$line" ]]; then + ((count++)) + if [[ $count -eq $rnum ]]; then target_line="$line"; break; fi + fi + done < "$ROOMS_FILE" + if [[ -n "$target_line" ]]; then + IFS='|' read -r rext rname rmem rtime rtype <<< "$target_line" + echo "Current members: $rmem" + read -p "New members: " new_mem + sed -i "/^${rext}|/d" "$ROOMS_FILE" + echo "${rext}|${rname}|${new_mem}|${rtime}|${rtype}" >> "$ROOMS_FILE" + rebuild_dialplan + print_success "Room Updated" + fi + ;; + 4) + read -p "Select Room #: " rnum + local count=0 + local target_ext="" + local target_name="" + while IFS='|' read -r rext rname rrest; do + if [[ ! "$rext" =~ ^# ]] && [[ -n "$rext" ]]; then + ((count++)) + if [[ $count -eq $rnum ]]; then + target_ext="$rext" + target_name="$rname" + break + fi + fi + done < "$ROOMS_FILE" + if [[ -n "$target_ext" ]]; then + echo "" + echo -e "${YELLOW}Note: Deleting a room only removes the group.${NC}" + echo -e "${YELLOW}Individual devices in this room are NOT deleted.${NC}" + echo "" + read -p "Delete room '${target_name}' (${target_ext})? [y/N]: " confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + sed -i "/^${target_ext}|/d" "$ROOMS_FILE" + rebuild_dialplan + print_success "Room deleted (devices unaffected)" + else + print_error "Cancelled" + fi + fi + ;; + esac +} + +add_device_menu() { + print_header "Add Device" + load_config # Load saved configuration to check ENABLE_TLS, DOMAIN_NAME, etc. + list_categories + read -p "Category number: " cat_num + local cat_data=$(get_category_by_index "$cat_num") + if [[ -z "$cat_data" ]]; then print_error "Invalid"; return; fi + local cat_id=$(echo "$cat_data" | cut -d'|' -f1) + local cat_name=$(echo "$cat_data" | cut -d'|' -f2) + local auto_answer=$(echo "$cat_data" | cut -d'|' -f3) + + local start_range=101 end_range=199 + case "$cat_id" in + kiosk) start_range=101; end_range=199 ;; + mobile) start_range=201; end_range=299 ;; + *) start_range=301; end_range=399 ;; + esac + + local suggested_ext="" + for ext in $(seq $start_range $end_range); do + if ! grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then + suggested_ext=$ext; break + fi + done + + read -p "Extension [$suggested_ext]: " ext + ext="${ext:-$suggested_ext}" + + if grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then + print_error "Extension exists!"; return + fi + + read -p "Name: " name + name="${name:-Device $ext}" + local pass=$(generate_password) + + local override_tag="" + if [[ "$auto_answer" == "no" ]]; then + read -p "Force AUTO-ANSWER? [y/N]: " force_aa + [[ "$force_aa" =~ ^[Yy]$ ]] && override_tag="[AA:yes]" && auto_answer="yes" + elif [[ "$auto_answer" == "yes" ]]; then + read -p "Force RING? [y/N]: " force_ring + [[ "$force_ring" =~ ^[Yy]$ ]] && override_tag="[AA:no]" && auto_answer="no" + fi + + # CONNECTION TYPE SELECTION + local conn_type="lan" + local transport_block="" + local encryption_block="" + local ice_block="" + local display_server="" + local display_port="5060" + local display_transport="UDP" + local display_encryption="None" + + # In Docker with FQDN: default to FQDN mode for all devices + if is_docker && [[ -n "$DOMAIN_NAME" ]]; then + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo -e " HOW WILL THIS DEVICE CONNECT?" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo -e " 1) ${CYAN}FQDN (recommended)${NC} - Via ${DOMAIN_NAME} (TLS) - works from any network" + echo -e " 2) ${GREEN}LAN only${NC} - Same local network (UDP)" + echo "" + read -p " Select [1]: " conn_choice + conn_choice="${conn_choice:-1}" + + if [[ "$conn_choice" == "2" ]]; then + transport_block="transport=transport-udp" + encryption_block="media_encryption=no" + display_server="$(hostname -I | awk '{print $1}')" + display_port="5060" + display_transport="UDP" + display_encryption="None" + ice_block="ice_support=yes" + else + conn_type="fqdn" + transport_block="transport=transport-tls" + encryption_block="media_encryption=sdes" + ice_block="ice_support=yes" + display_server="$DOMAIN_NAME" + display_port="5061" + display_transport="TLS" + display_encryption="SRTP (SDES)" + fi + else + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo -e " HOW WILL THIS DEVICE CONNECT?" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo -e " 1) ${GREEN}LAN/VPN${NC} - Same network or VPN tunnel (UDP)" + if [[ "$ENABLE_TLS" == "y" && -n "$DOMAIN_NAME" ]]; then + echo -e " 2) ${CYAN}FQDN${NC} - Internet or cross-VLAN via ${DOMAIN_NAME} (TLS)" + else + echo -e " 2) ${YELLOW}FQDN${NC} - Not configured (run 'Setup Internet Access' first)" + fi + echo "" + read -p " Select [1]: " conn_choice + conn_choice="${conn_choice:-1}" + + if [[ "$conn_choice" == "1" ]]; then + # LAN/VPN - UDP, no encryption (explicit transport prevents TLS fallback) + transport_block="transport=transport-udp" + encryption_block="media_encryption=no" + display_server="$(hostname -I | awk '{print $1}')" + display_port="5060" + display_transport="UDP" + display_encryption="None" + # Enable ICE for VPN devices if VPN ICE mode is active + if [[ "$VPN_ICE_ENABLED" == "y" ]]; then + ice_block="ice_support=yes" + fi + elif [[ "$conn_choice" == "2" ]]; then + if [[ "$ENABLE_TLS" != "y" || -z "$DOMAIN_NAME" ]]; then + print_error "FQDN access not configured. Run 'Setup Internet Access' first." + return + fi + conn_type="fqdn" + transport_block="transport=transport-tls" + encryption_block="media_encryption=sdes" + ice_block="ice_support=yes" + display_server="$DOMAIN_NAME" + display_port="5061" + display_transport="TLS" + display_encryption="SRTP (SDES)" + fi + fi + + backup_config "/etc/asterisk/pjsip.conf" + + # Mobile devices benefit from keepalive to maintain NAT mappings + # during WiFi/mobile data transitions + local keepalive_block="" + if [[ "$cat_id" == "mobile" ]]; then + keepalive_block="rtp_keepalive=15 +rtp_timeout=120 +rtp_timeout_hold=120" + fi + + cat >> /etc/asterisk/pjsip.conf << EOF + +; === Device: $name ($cat_id) $override_tag === +[${ext}] +type=endpoint +context=intercom +${transport_block} +disallow=all +allow=opus +allow=ulaw +allow=alaw +allow=g722 +${encryption_block} +direct_media=no +rtp_symmetric=yes +force_rport=yes +rewrite_contact=yes +${keepalive_block} +${ice_block} +auth=${ext} +aors=${ext} +callerid="${name}" <${ext}> + +[${ext}] +type=auth +auth_type=userpass +username=${ext} +password=${pass} + +[${ext}] +type=aor +max_contacts=5 +remove_existing=yes +qualify_frequency=30 +EOF + + chown -R asterisk:asterisk /etc/asterisk 2>/dev/null || true + asterisk -rx "pjsip reload" >/dev/null 2>&1 + rebuild_dialplan + + # Prepare provisioning URLs if HTTP server is configured + local server_ip=$(hostname -I | awk '{print $1}') + local prov_url_http="" + local prov_url_https="" + if [[ -f /etc/asterisk/http.conf ]] && grep -q "enabled=yes" /etc/asterisk/http.conf 2>/dev/null; then + prov_url_http="http://${server_ip}:8088/static/linphone.xml" + if [[ -n "$DOMAIN_NAME" ]]; then + prov_url_https="https://${DOMAIN_NAME}:8089/static/linphone.xml" + fi + fi + + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo " DEVICE ADDED: $name (Extension $ext)" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo -e " ${BOLD}Server Details:${NC}" + echo " Server: ${display_server}" + echo " Port: ${display_port}" + echo " Transport: ${display_transport}" + echo " Extension: $ext" + echo " Password: $pass" + echo " Encryption: ${display_encryption}" + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo -e " ${BOLD}LINPHONE SETUP${NC}" + echo "═══════════════════════════════════════════════════════════════" + echo "" + if [[ -n "$prov_url_http" ]]; then + echo " Remote Provisioning (Recommended):" + echo " 1. In Linphone → Settings → Remote provisioning" + echo " 2. Enter URL:" + echo " ${prov_url_http}" + [[ -n "$prov_url_https" ]] && echo " OR ${prov_url_https}" + echo " 3. Tap 'Fetch' to apply configuration" + echo "" + echo " OR Manual Setup:" + else + echo " Manual Setup:" + fi + echo " 1. Add Account → Use SIP account" + echo " 2. Username: $ext" + echo " 3. Password: $pass" + echo " 4. Domain: ${display_server}" + echo " 5. Transport: ${display_transport}" + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo -e " ${BOLD}BARESIP SETUP (if Linphone has audio issues)${NC}" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo " Baresip often works better on privacy-focused Android ROMs." + echo " Two-step manual configuration required:" + echo "" + echo " Step 1: Add Account" + echo " Menu (☰) → Accounts → Add (+)" + echo " SIP URI: ${ext}@${display_server}" + echo " Save (✓)" + echo "" + echo " Step 2: Edit Account (Complete Config)" + echo " Tap account → Edit" + echo " Auth Username: $ext (JUST the number!)" + echo " Auth Password: $pass" + echo " Outbound Proxy: ${display_server} (JUST the domain!)" + echo " Media Encryption: srtp (select from dropdown)" + echo " Register: ✓ (check box)" + echo " Save (✓)" + echo "" + echo " Verify: Look for green dot or 'Registered' status" + echo " To call: Just dial extension (101, 202, etc.)" + echo "" + echo " For detailed Baresip instructions:" + echo " Server Settings → Provisioning Manager → Create Baresip Config" + echo "" + echo "═══════════════════════════════════════════════════════════════" + + # Show TURN/STUN settings if enabled (for manual SIP app configuration) + if [[ "$TURN_ENABLED" == "y" && -n "$TURN_SERVER" ]]; then + echo "" + echo -e " ${BOLD}STUN/TURN SETTINGS (for NAT traversal)${NC}" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo " Configure these in your SIP app's Network/ICE settings:" + echo " ICE: Enabled" + echo " STUN server: ${TURN_SERVER}" + echo " TURN server: ${TURN_SERVER}" + echo " TURN username: ${TURN_USERNAME}" + echo " TURN password: ${TURN_PASSWORD}" + echo " TURN transport: UDP" + echo "" + echo " Linphone: Auto-provisioned via XML (no manual setup needed)" + echo " Sipnetic: Settings → Network → ICE/STUN/TURN" + echo " Olinuxino: Settings → Network → ICE/STUN/TURN" + echo "" + echo "═══════════════════════════════════════════════════════════════" + fi + + echo "" + echo " NOTE: These instructions work for most SIP apps (Zoiper," + echo " sipnetic, etc.) - just use the same credentials." + echo "" + echo "═══════════════════════════════════════════════════════════════" +} + +remove_device() { + print_header "Remove Device" + declare -A REMOVE_MAP + declare -A NAME_MAP + local count=1 + local current_name="" + echo "Select device to remove:" + echo "" + while IFS= read -r line; do + if [[ "$line" == *"; === Device:"* ]]; then + local temp="${line#*; === Device: }" + temp="${temp% ===}" + temp="${temp% \[AA:*\]}" + current_name="${temp% (*)}" + fi + if [[ "$line" =~ ^\[([0-9]+)\]$ && "$current_name" != "" ]]; then + local ext="${BASH_REMATCH[1]}" + echo " ${count}) Ext ${ext} - ${current_name}" + REMOVE_MAP[$count]=$ext + NAME_MAP[$count]="$current_name" + ((count++)) + current_name="" + fi + done < /etc/asterisk/pjsip.conf + echo "" + echo " 98) DELETE ALL DEVICES" + echo " 0) Cancel" + echo "" + read -p "Select: " choice + + if [[ "$choice" == "98" ]]; then + echo "" + print_warn "This will DELETE ALL DEVICES!" + read -p "Type 'DELETE ALL' to confirm: " confirm + if [[ "$confirm" == "DELETE ALL" ]]; then + backup_config "/etc/asterisk/pjsip.conf" + # Remove all device sections - use awk to properly handle all sections + awk ' + /^; === Device:/ { skip = 1; next } + /^\[[0-9]{3}\]$/ { if (skip) next } + /^type=(endpoint|auth|aor)/ { if (skip) next } + /^$/ { if (skip) { skip = 0; next } } + !skip { print } + ' /etc/asterisk/pjsip.conf > /etc/asterisk/pjsip.conf.tmp + mv /etc/asterisk/pjsip.conf.tmp /etc/asterisk/pjsip.conf + chown asterisk:asterisk /etc/asterisk/pjsip.conf + asterisk -rx "pjsip reload" 2>/dev/null + rebuild_dialplan + print_success "All devices deleted" + else + print_error "Cancelled" + fi + return + fi + + [[ "$choice" == "0" || -z "${REMOVE_MAP[$choice]}" ]] && return + + local ext="${REMOVE_MAP[$choice]}" + local name="${NAME_MAP[$choice]}" + read -p "Confirm removal of $ext ($name)? [y/N]: " confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + backup_config "/etc/asterisk/pjsip.conf" + # Use awk to remove the device comment and ALL three sections for this extension + awk -v ext="$ext" ' + BEGIN { skip = 0; found_ext = 0 } + # Match device comment line - start potential skip + /^; === Device:/ { pending_comment = $0; next } + # Check if this is the extension we want to delete + $0 ~ "^\\[" ext "\\]$" { + if (pending_comment != "") { + # This is our device - skip the comment and this section + skip = 1 + found_ext = 1 + pending_comment = "" + next + } else if (found_ext) { + # Additional sections for same extension (auth, aor) + skip = 1 + next + } + } + # If we have a pending comment for a different extension, print it + pending_comment != "" && $0 !~ "^\\[" ext "\\]$" { + print pending_comment + pending_comment = "" + } + # Skip lines until empty line + skip && /^$/ { skip = 0; next } + skip { next } + { print } + ' /etc/asterisk/pjsip.conf > /etc/asterisk/pjsip.conf.tmp + mv /etc/asterisk/pjsip.conf.tmp /etc/asterisk/pjsip.conf + chown asterisk:asterisk /etc/asterisk/pjsip.conf + asterisk -rx "pjsip reload" 2>/dev/null + rebuild_dialplan + print_success "Removed extension $ext ($name)" + fi +} + +rename_device() { + print_header "Rename Device" + declare -A DEVICE_MAP + declare -A NAME_MAP + declare -A AA_MAP + local count=1 + echo "Select device to rename:" + echo "" + while IFS= read -r line; do + if [[ "$line" == *"; === Device:"* ]]; then + local temp="${line#*; === Device: }" + temp="${temp% ===}" + local aa_tag="" + if [[ "$temp" == *"[AA:yes]"* ]]; then + aa_tag="[AA:yes]" + temp="${temp% \[AA:yes\]}" + elif [[ "$temp" == *"[AA:no]"* ]]; then + aa_tag="[AA:no]" + temp="${temp% \[AA:no\]}" + fi + local name="${temp% (*)}" + local cat="${temp##* (}"; cat="${cat%)}" + fi + if [[ "$line" =~ ^\[([0-9]+)\]$ && -n "$name" ]]; then + local ext="${BASH_REMATCH[1]}" + echo " ${count}) Ext ${ext} - ${name} (${cat})" + DEVICE_MAP[$count]=$ext + NAME_MAP[$count]="${name}|${cat}" + AA_MAP[$count]="${aa_tag}" + ((count++)) + name="" + fi + done < /etc/asterisk/pjsip.conf + echo "" + echo " 0) Cancel" + echo "" + read -p "Select: " choice + + [[ "$choice" == "0" || -z "${DEVICE_MAP[$choice]}" ]] && return + + local ext="${DEVICE_MAP[$choice]}" + local info="${NAME_MAP[$choice]}" + local aa_tag="${AA_MAP[$choice]}" + local old_name="${info%|*}" + local cat="${info##*|}" + + echo "" + echo "Current name: ${old_name}" + read -p "New name: " new_name + + if [[ -z "$new_name" ]]; then + print_error "Name cannot be empty" + return + fi + + # Backup config + backup_config "/etc/asterisk/pjsip.conf" + + # Use awk to properly update both the comment line (preserving AA tag) and callerid + awk -v ext="$ext" -v old_name="$old_name" -v new_name="$new_name" -v cat="$cat" -v aa_tag="$aa_tag" ' + # Update device comment line + /^; === Device:/ && $0 ~ old_name && $0 ~ cat { + if (aa_tag != "") { + print "; === Device: " new_name " (" cat ") " aa_tag " ===" + } else { + print "; === Device: " new_name " (" cat ") ===" + } + next + } + # Track when we are in the correct extension section + $0 ~ "^\\[" ext "\\]$" { in_ext = 1 } + /^$/ { in_ext = 0 } + # Update callerid in the extension section + in_ext && /^callerid=/ { + print "callerid=\"" new_name "\" <" ext ">" + next + } + { print } + ' /etc/asterisk/pjsip.conf > /etc/asterisk/pjsip.conf.tmp + mv /etc/asterisk/pjsip.conf.tmp /etc/asterisk/pjsip.conf + chown asterisk:asterisk /etc/asterisk/pjsip.conf + + # Reload Asterisk + asterisk -rx "pjsip reload" 2>/dev/null + rebuild_dialplan quiet + + print_success "Device renamed: ${old_name} → ${new_name}" +} + + +show_registered_devices() { + # Collect all device data + declare -A device_data + local dev_name="" dev_cat="" + while IFS= read -r line; do + if [[ "$line" == *"; === Device:"* ]]; then + # Remove the prefix and suffix, handling variable whitespace + local temp="${line#*; === Device: }" + temp="${temp%% ===}" # Use %% to handle multiple spaces before === + temp="${temp## }" # Trim leading spaces + temp="${temp%% }" # Trim trailing spaces + + [[ "$temp" == *"[AA:"* ]] && temp="${temp% \[AA:*\]}" + + # Extract category - everything inside the last (...) + dev_cat="${temp##*\(}" + dev_cat="${dev_cat%\)}" + dev_cat="${dev_cat## }" # Trim any leading spaces + dev_cat="${dev_cat%% }" # Trim any trailing spaces + + # Extract name - everything before the last ( + dev_name="${temp%% \(*}" + fi + if [[ "$line" =~ ^\[([0-9]+)\] ]]; then + local ext="${BASH_REMATCH[1]}" + if [[ -n "$dev_name" ]]; then + device_data[$ext]="${dev_name}|${dev_cat}" + dev_name="" + dev_cat="" + fi + fi + done < /etc/asterisk/pjsip.conf + + # Interactive loop + while true; do + # Group by category + declare -A categories + declare -A category_names + for ext in "${!device_data[@]}"; do + local info="${device_data[$ext]}" + local cat="${info##*|}" + categories[$cat]="${categories[$cat]} $ext" + done + + # Get full category names from categories file + while IFS='|' read -r cat_id cat_name auto_answer description; do + [[ "$cat_id" =~ ^# ]] && continue + [[ -z "$cat_id" ]] && continue + category_names[$cat_id]="$cat_name" + done < "$CATEGORIES_FILE" + + clear + print_header "Device Status" + + echo "Select category to view:" + echo " 1) All devices" + local i=2 + declare -A cat_menu + for cat in $(echo "${!categories[@]}" | tr ' ' '\n' | sort); do + local display_name="${category_names[$cat]:-$cat}" + echo " ${i}) ${display_name}" + cat_menu[$i]="$cat" + ((i++)) + done + echo " 0) Back to menu" + echo "" + read -p "Select [1]: " cat_choice + + [[ "$cat_choice" == "0" ]] && return + cat_choice="${cat_choice:-1}" + + clear + print_header "Device Status" + printf "${CYAN}%-6s %-25s %-15s %-15s %-15s${NC}\n" "Ext" "Name" "Category" "Status" "Password" + echo "--------------------------------------------------------------------------------------------" + + if [[ "$cat_choice" == "1" ]]; then + # Show all devices + for ext in $(echo "${!device_data[@]}" | tr ' ' '\n' | sort -n); do + local info="${device_data[$ext]}" + local name="${info%|*}" + local cat="${info##*|}" + local cat_display="${category_names[$cat]:-$cat}" + local status="${RED}Offline${NC}" + local avail=$(asterisk -rx "pjsip show endpoint ${ext}" 2>/dev/null | grep -E "Contact:.*(Avail|NonQual)" || true) + [[ -n "$avail" ]] && status="${GREEN}Online${NC}" + local password=$(grep -A 10 "^\[$ext\]" /etc/asterisk/pjsip.conf | grep "password=" | head -1 | cut -d= -f2) + printf "%-6s %-25s %-15s %b %-15s\n" "$ext" "${name:0:23}" "${cat_display:0:13}" "$status" "$password" + done + else + # Show specific category + local selected_cat="${cat_menu[$cat_choice]}" + if [[ -n "$selected_cat" ]]; then + local cat_display="${category_names[$selected_cat]:-$selected_cat}" + echo -e "${BOLD}Showing: ${cat_display}${NC}" + echo "" + for ext in $(echo "${categories[$selected_cat]}" | tr ' ' '\n' | sort -n); do + local info="${device_data[$ext]}" + local name="${info%|*}" + local cat="${info##*|}" + local cat_display="${category_names[$cat]:-$cat}" + local status="${RED}Offline${NC}" + local avail=$(asterisk -rx "pjsip show endpoint ${ext}" 2>/dev/null | grep -E "Contact:.*(Avail|NonQual)" || true) + [[ -n "$avail" ]] && status="${GREEN}Online${NC}" + local password=$(grep -A 10 "^\[$ext\]" /etc/asterisk/pjsip.conf | grep "password=" | head -1 | cut -d= -f2) + printf "%-6s %-25s %-15s %b %-15s\n" "$ext" "${name:0:23}" "${cat_display:0:13}" "$status" "$password" + done + fi + fi + + echo "" + echo "Connection Details:" + echo " Domain: ${DOMAIN_NAME:-$(hostname -I | awk '{print $1}')}" + echo " Port: ${DEFAULT_SIP_PORT}/udp (LAN) or ${DEFAULT_SIPS_PORT}/tcp (TLS)" + echo "" + read -p "Press Enter to select another category (or 0 to exit)... " + done +} + + +# ================================================================ +# 4. PTT WIZARD (Fixed: Mute by default) +# ================================================================ + +configure_ptt_menu() { + print_header "Configure PTT Button" + detect_ptt_button +} + +detect_ptt_button() { + # Ensure evtest is installed + if ! command -v evtest &>/dev/null; then + apt install -y evtest >/dev/null 2>&1 + fi + + # Add user to input group + [[ -n "$KIOSK_USER" ]] && usermod -aG input "$KIOSK_USER" 2>/dev/null || true + + print_info "Scanning input devices..." + echo "" + + declare -a SUGGESTED_DEVICES SUGGESTED_NAMES OTHER_DEVICES OTHER_NAMES + + for dev in /dev/input/event*; do + [[ -e "$dev" ]] || continue + local name=$(cat "/sys/class/input/$(basename $dev)/device/name" 2>/dev/null || echo "Unknown") + local lname=$(echo "$name" | tr '[:upper:]' '[:lower:]') + + # Filter out system devices that aren't PTT candidates + if [[ "$lname" =~ (power.button|sleep.button|lid.switch|virtual|video.bus|hdmi|dp,pcm|hotkey|touchpad|touchscreen) ]]; then + OTHER_DEVICES+=("$dev") + OTHER_NAMES+=("$name") + # Prioritize keyboards, USB HID devices, pedals + elif [[ "$lname" =~ (keyboard|sayo.*nano$|pedal|foot|^hid) ]]; then + SUGGESTED_DEVICES+=("$dev") + SUGGESTED_NAMES+=("$name") + else + OTHER_DEVICES+=("$dev") + OTHER_NAMES+=("$name") + fi + done + + # Display suggested devices first + if [[ ${#SUGGESTED_DEVICES[@]} -gt 0 ]]; then + echo -e "${GREEN}Keyboards and USB buttons:${NC}" + for i in "${!SUGGESTED_DEVICES[@]}"; do + printf " ${CYAN}%2d)${NC} %s - %s\n" "$((i+1))" "$(basename ${SUGGESTED_DEVICES[$i]})" "${SUGGESTED_NAMES[$i]}" + done + echo "" + fi + + # Display other devices + if [[ ${#OTHER_DEVICES[@]} -gt 0 ]]; then + echo -e "${YELLOW}Other devices:${NC}" + local offset=${#SUGGESTED_DEVICES[@]} + for i in "${!OTHER_DEVICES[@]}"; do + printf " ${CYAN}%2d)${NC} %s - %s\n" "$((offset+i+1))" "$(basename ${OTHER_DEVICES[$i]})" "${OTHER_NAMES[$i]}" + done + echo "" + fi + + local ALL_DEVICES=("${SUGGESTED_DEVICES[@]}" "${OTHER_DEVICES[@]}") + local total=${#ALL_DEVICES[@]} + + if [[ $total -eq 0 ]]; then + print_error "No input devices found" + return 1 + fi + + echo " 0) Back" + echo "" + read -p "Select device [1]: " selection + selection="${selection:-1}" + + [[ "$selection" == "0" ]] && return 0 + [[ "$selection" -lt 1 || "$selection" -gt "$total" ]] && { print_error "Invalid selection"; return 1; } + + PTT_DEVICE="${ALL_DEVICES[$((selection-1))]}" + local dev_name=$(cat "/sys/class/input/$(basename $PTT_DEVICE)/device/name" 2>/dev/null || echo "Unknown") + echo "" + print_success "Selected: $dev_name" + echo " ($PTT_DEVICE)" + echo "" + + # Key detection loop + while true; do + echo -e "${YELLOW}══════════════════════════════════════════════════${NC}" + echo -e "${YELLOW} DO NOT PRESS YET - wait for countdown${NC}" + echo -e "${YELLOW}══════════════════════════════════════════════════${NC}" + + for i in 5 4 3 2 1; do + echo -ne "\r Waiting... $i " + sleep 1 + done + echo "" + echo "" + echo -e "${GREEN}>>> NOW PRESS YOUR PTT BUTTON <<<${NC}" + echo "" + + local detected_code=$(timeout 10 evtest "$PTT_DEVICE" 2>/dev/null | grep -m1 "value 1$" | grep -oP 'code \K[0-9]+' || echo "") + + if [[ -n "$detected_code" && "$detected_code" -gt 0 ]]; then + # Map common key codes to friendly names + local key_name="Key $detected_code" + case "$detected_code" in + 1) key_name="Escape" ;; + 28) key_name="Enter" ;; + 57) key_name="Spacebar" ;; + 69) key_name="Num Lock" ;; + 113) key_name="Mute" ;; + 114) key_name="Volume Down" ;; + 115) key_name="Volume Up" ;; + 116) key_name="Power" ;; + 142) key_name="Sleep" ;; + 272) key_name="Left Click" ;; + 273) key_name="Right Click" ;; + esac + + print_success "Detected: $key_name (code $detected_code)" + echo "" + read -p "Use this key? [Y/n]: " use_key + + if [[ ! "$use_key" =~ ^[Nn]$ ]]; then + PTT_KEYCODE="$detected_code" + PTT_KEYNAME="$key_name" + break + fi + else + print_warn "No button press detected" + fi + + echo "" + echo " 1) Try again" + echo " 2) Enter key code manually" + echo " 3) Cancel" + read -p "Select [1]: " retry + + case "${retry:-1}" in + 2) + read -p "Enter key code: " PTT_KEYCODE + PTT_KEYNAME="Manual" + break + ;; + 3) + return 1 + ;; + esac + done + + # Ensure user is in input group (critical for PTT device access) + if [[ -n "$KIOSK_USER" ]]; then + if ! id -nG "$KIOSK_USER" | grep -qw "input"; then + print_info "Adding $KIOSK_USER to input group..." + usermod -aG input "$KIOSK_USER" + echo "" + print_error "IMPORTANT: User added to 'input' group" + echo " User must log out and log back in (or reboot) for group change to take effect." + echo " PTT will NOT work until then!" + echo "" + read -p "Press Enter to acknowledge..." + fi + fi + + # Save configuration via save_config (will set proper permissions) + save_config + + print_success "PTT configured: $PTT_KEYNAME on $(basename $PTT_DEVICE)" + echo "" + echo "═══════════════════════════════════════════════════════" + echo " PTT Configuration Complete" + echo "═══════════════════════════════════════════════════════" + echo " Device: $PTT_DEVICE" + echo " Button: $PTT_KEYNAME" + echo " User: ${KIOSK_USER:-not set}" + echo "" + echo " Testing PTT:" + echo " 1. Check logs: journalctl -t kiosk-ptt -f" + echo " 2. Press PTT button" + echo " 3. You should see: 'PTT pressed - mic unmuted'" + echo "" + echo " If you see 'Permission denied' errors:" + echo " - User needs to be in 'input' group (already added above)" + echo " - Log out and log back in, or reboot" + echo "═══════════════════════════════════════════════════════" + + # Restart PTT service if client is installed (bare metal only) + if [[ "$INSTALLED_CLIENT" == "y" && -n "$KIOSK_USER" ]] && ! is_docker; then + local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}" + echo "" + print_info "Restarting PTT service..." + sudo -u "$KIOSK_USER" $user_dbus systemctl --user daemon-reload 2>/dev/null + sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart kiosk-ptt 2>/dev/null || true + sleep 2 + echo "" + echo "Checking PTT status..." + journalctl -t kiosk-ptt -n 5 --no-pager 2>/dev/null || echo " No logs yet (check after logging out/in if needed)" + fi + + return 0 +} + +create_ptt_handler() { + cat > /usr/local/bin/kiosk-ptt << 'PTTSCRIPT' +#!/bin/bash +CONFIG="/etc/easy-asterisk/config" +PTT_CONFIG="/etc/easy-asterisk/ptt-device" +[[ -f "$CONFIG" ]] && source "$CONFIG" +[[ -f "$PTT_CONFIG" ]] && source "$PTT_CONFIG" + +# Exit if no PTT device configured - leave audio unmuted for normal kiosk operation +[[ -z "$PTT_DEVICE" ]] && exit 0 + +# Ensure we have the user's runtime directory +if [[ -z "$XDG_RUNTIME_DIR" ]]; then + # If running as systemd service, this should already be set + # But if not, try to detect it + if [[ -n "$KIOSK_UID" ]]; then + export XDG_RUNTIME_DIR="/run/user/${KIOSK_UID}" + else + # Fall back to current user + export XDG_RUNTIME_DIR="/run/user/$(id -u)" + fi +fi + +# Wait for PipeWire/PulseAudio to be ready +for i in {1..10}; do + if pactl info >/dev/null 2>&1; then + break + fi + sleep 1 +done + +# PTT mode: Mute audio source on start, unmute only when button pressed +pactl set-source-mute @DEFAULT_SOURCE@ 1 2>/dev/null || { + logger -t kiosk-ptt "ERROR: Failed to mute audio source" + exit 1 +} + +logger -t kiosk-ptt "PTT handler started, microphone muted, listening on $PTT_DEVICE" + +# Unmute on press, mute on release +evtest --grab "$PTT_DEVICE" 2>/dev/null | while read -r line; do + if [[ "$line" =~ "value 1" ]]; then + pactl set-source-mute @DEFAULT_SOURCE@ 0 2>/dev/null + logger -t kiosk-ptt "PTT pressed - mic unmuted" + fi + if [[ "$line" =~ "value 0" ]]; then + pactl set-source-mute @DEFAULT_SOURCE@ 1 2>/dev/null + logger -t kiosk-ptt "PTT released - mic muted" + fi +done +PTTSCRIPT + chmod +x /usr/local/bin/kiosk-ptt +} + +# ================================================================ +# 5. AUDIO DUCKING +# ================================================================ + +configure_audio_ducking() { + [[ -z "$KIOSK_USER" ]] && return + local wp_dir="/home/${KIOSK_USER}/.config/wireplumber/wireplumber.conf.d" + mkdir -p "$wp_dir" + cat > "${wp_dir}/50-intercom-ducking.conf" << 'EOF' +wireplumber.settings = { linking.allow-moving-streams = true } +EOF + chown -R ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.config" +} + +ensure_audio_unmuted() { + [[ -z "$KIOSK_USER" ]] && return + [[ -z "$KIOSK_UID" ]] && return + + # Only unmute if PTT is not configured + if [[ ! -f /etc/easy-asterisk/ptt-device ]]; then + local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}" + + # Wait a moment for PipeWire to initialize + sleep 2 + + # Unmute all sources and sinks + sudo -u "$KIOSK_USER" $user_dbus pactl set-source-mute @DEFAULT_SOURCE@ 0 2>/dev/null || true + sudo -u "$KIOSK_USER" $user_dbus pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null || true + + # Set reasonable volume levels if they're at 0 + local source_vol=$(sudo -u "$KIOSK_USER" $user_dbus pactl get-source-volume @DEFAULT_SOURCE@ 2>/dev/null | grep -oP '\d+%' | head -1 | tr -d '%') + local sink_vol=$(sudo -u "$KIOSK_USER" $user_dbus pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oP '\d+%' | head -1 | tr -d '%') + + [[ -n "$source_vol" && "$source_vol" -lt 50 ]] && sudo -u "$KIOSK_USER" $user_dbus pactl set-source-volume @DEFAULT_SOURCE@ 75% 2>/dev/null || true + [[ -n "$sink_vol" && "$sink_vol" -lt 50 ]] && sudo -u "$KIOSK_USER" $user_dbus pactl set-sink-volume @DEFAULT_SINK@ 75% 2>/dev/null || true + fi +} + +# ================================================================ +# 6. DIAGNOSTICS & FIREWALL +# ================================================================ + +show_port_requirements() { + print_header "Port / Firewall Requirements" + echo "This server needs traffic to pass from your Clients (Kiosks/Phones)." + echo "" + echo "Does your Asterisk server have a PUBLIC IP (VPS/Cloud)?" + echo " -> YES: You must use 'Forwarding' (DNAT) rules on your router." + echo " -> NO: You must use 'Allow/Pass' rules on your VLAN interfaces." + echo "" + echo "Required Ports:" + echo "┌──────────────────┬──────────┬───────────────────────────────┐" + echo "│ Port │ Protocol │ Purpose │" + echo "├──────────────────┼──────────┼───────────────────────────────┤" + echo "│ 5060 │ UDP │ SIP Signaling (Registration) │" + echo "│ 5061 │ TCP │ SIP-TLS Signaling (Secure) │" + echo "│ 10000-20000 │ UDP │ RTP Media (Audio/Video) │" + if [[ "$USE_COTURN" == "y" ]]; then + echo "│ ${DEFAULT_TURN_PORT} │ UDP/TCP │ TURN Signaling (Handshake) │" + echo "│ 49152-65535 │ UDP │ TURN Relay (Actual Media Path)│" + fi + echo "└──────────────────┴──────────┴───────────────────────────────┘" + echo "" + echo "NOTE: VPN Users" + echo "If ALL clients and server are on a VPN (Tailscale/Wireguard), you DO NOT" + echo "need port forwarding or COTURN. Just bind Asterisk to the VPN IP." +} + +show_firewall_guide() { + print_header "Interactive Firewall Guide (Hand-holding Mode)" + echo "For: Routers with VLAN support" + echo "" + echo "=== SCENARIO A: INTERNAL ONLY (VLAN to VLAN) ===" + echo "Example: Kiosks on VLAN 10, Server on VLAN 20" + echo "GOAL: Allow Kiosks to talk to Server." + echo "" + echo "STEP 1: Log in to Router. Go to Firewall > Rules > VLAN 10 Interface." + echo " (Do NOT use 'Port Forwarding' for internal VLANs!)" + echo "" + echo "STEP 2: Create Rule 1 (Signaling)" + echo " - Action: Pass (Allow)" + echo " - Protocol: UDP/TCP" + echo " - Source: VLAN 10 Net" + echo " - Dest: ${CURRENT_PUBLIC_IP:-Server_IP}" + echo " - Port: 3478 (or your TURN_PORT if changed)" + echo "" + echo "STEP 3: Create Rule 2 (The Relay Range - CRITICAL)" + echo " - Action: Pass (Allow)" + echo " - Protocol: UDP" + echo " - Source: VLAN 10 Net" + echo " - Dest: ${CURRENT_PUBLIC_IP:-Server_IP}" + echo " - Port Range:" + echo " From: 49152" + echo " To: 65535" + echo " (Note: Type these numbers in the Start/End boxes)" + echo "" + echo "================================================" + echo "" + echo "=== SCENARIO B: EXTERNAL ACCESS (Internet to LAN) ===" + echo "Example: Remote phone connecting from a hotel." + echo "GOAL: Forward traffic from Internet to Server." + echo "" + echo "STEP 1: Go to Firewall > NAT > Port Forwarding." + echo "STEP 2: Create Rule." + echo " - Interface: WAN" + echo " - Protocol: UDP" + echo " - Dest. Port: 3478 (or your TURN_PORT) and 49152-65535" + echo " - Redirect IP: ${CURRENT_PUBLIC_IP:-Server_IP}" + echo "" + read -p "Press Enter to return..." +} + +show_preflight_check() { + print_header "Pre-Flight Requirements Check" + echo "Modern browsers (Chrome, Safari, Kiosk Mode) have strict security settings." + echo "" + echo "1. HTTPS / SSL Certificate (Required for Camera/Mic)" + echo " - Browsers block Mic/Cam on 'Insecure Origins' (HTTP)." + echo " - Exception: http://localhost is allowed." + echo " - Solution: You NEED a domain (FQDN) and SSL Cert (LetsEncrypt)." + echo " - Workaround: Use the 'Caddy Cert Sync' option in this script." + echo "" + echo "2. Static vs Dynamic IP" + echo " - If your Public IP changes, COTURN will break." + echo " - Solution: Use the 'Update IP manually' or auto-script in the menu." + echo "" + echo "3. VPN Alternative" + echo " - A VPN (Tailscale) negates the need for COTURN and Port Forwarding." + echo " - It treats all devices as if they are on the same flat network." + echo "" + read -p "Press Enter to return..." +} + +test_sip_connectivity() { + print_header "SIP Connectivity Test" + if asterisk_running; then + print_success "Asterisk Running" + else + print_error "Asterisk Down" + fi + echo "" + echo "Listening ports:" + ss -ulnp | grep 5060 || echo " UDP 5060: Not listening" + ss -tlnp | grep 5061 || echo " TCP 5061: Not listening" + if [[ -n "$DOMAIN_NAME" ]]; then + echo "" + echo "TLS Certificate check:" + timeout 5 openssl s_client -connect localhost:5061 -servername "$DOMAIN_NAME" 2>/dev/null | grep "Verify return code" || echo " TLS test failed" + fi +} + +verify_cidr_config() { + print_header "CIDR Configuration" + local my_ip=$(hostname -I | cut -d' ' -f1) + echo "Server IP: $my_ip" + echo "" + echo "Current NAT settings in pjsip.conf:" + grep -E "external_|local_net" /etc/asterisk/pjsip.conf 2>/dev/null || echo " No NAT settings found" +} + +configure_vlan_subnets() { + print_header "VLAN / VPN Subnet Configuration" + load_config + + echo "Additional Subnet Support for Easy Asterisk" + echo "================================================" + echo "" + echo "If your network uses VLANs or VPNs, you need to tell" + echo "Asterisk about all the local subnets so that:" + echo " - Calls don't drop after 30 seconds (VLAN issue)" + echo " - VPN-connected mobile devices can register" + echo " - Audio works correctly for VPN users" + echo "" + echo "Example subnets:" + echo " 192.168.1.0/24 - Main network" + echo " 192.168.10.0/24 - IoT VLAN" + echo " 100.64.0.0/10 - Tailscale VPN" + echo " 10.0.0.0/8 - WireGuard/OpenVPN" + echo "" + + # Auto-detect VPN interfaces and their subnets + local detected_vpn_subnets="" + local vpn_info="" + while IFS= read -r line; do + local iface=$(echo "$line" | awk '{print $2}' | tr -d ':') + local addr=$(echo "$line" | awk '{print $4}') + if [[ -n "$addr" && -n "$iface" ]]; then + case "$iface" in + tailscale*|ts*) + vpn_info="${vpn_info} Detected: ${iface} -> ${addr} (Tailscale)\n" + detected_vpn_subnets="${detected_vpn_subnets} 100.64.0.0/10" + ;; + wg*) + vpn_info="${vpn_info} Detected: ${iface} -> ${addr} (WireGuard)\n" + detected_vpn_subnets="${detected_vpn_subnets} ${addr}" + ;; + tun*|tap*) + vpn_info="${vpn_info} Detected: ${iface} -> ${addr} (OpenVPN/VPN tunnel)\n" + detected_vpn_subnets="${detected_vpn_subnets} ${addr}" + ;; + nordlynx*|proton*) + vpn_info="${vpn_info} Detected: ${iface} -> ${addr} (VPN)\n" + detected_vpn_subnets="${detected_vpn_subnets} ${addr}" + ;; + esac + fi + done < <(ip -o -f inet addr show 2>/dev/null | grep -vE 'lo |docker|br-|veth') + detected_vpn_subnets=$(echo "$detected_vpn_subnets" | xargs -n1 2>/dev/null | sort -u | xargs 2>/dev/null) + + if [[ -n "$vpn_info" ]]; then + echo -e "${GREEN}VPN interfaces detected on this server:${NC}" + echo -e "$vpn_info" + echo " Suggested VPN subnets: ${detected_vpn_subnets}" + echo "" + echo " NOTE: If mobile devices connect via VPN (e.g., Tailscale on phones)," + echo " you MUST add the VPN subnet here for them to reach Asterisk." + echo "" + fi + + read -p "Does your network use VLANs or VPNs? (y/n) [${HAS_VLANS}]: " has_vlans + has_vlans=${has_vlans:-$HAS_VLANS} + + if [[ "$has_vlans" =~ ^[Yy] ]]; then + HAS_VLANS="y" + echo "" + echo "Current Subnets: ${VLAN_SUBNETS:-none}" + if [[ -n "$detected_vpn_subnets" ]]; then + echo "Detected VPN Subnets: ${detected_vpn_subnets}" + fi + echo "" + echo "Enter ALL additional subnets (VLAN + VPN) in CIDR notation, separated by spaces." + echo "Example: 192.168.10.0/24 100.64.0.0/10" + echo "" + local default_subnets="${VLAN_SUBNETS:-$detected_vpn_subnets}" + read -p "Subnets [${default_subnets}]: " vlan_input + vlan_input="${vlan_input:-$default_subnets}" + + if [[ -n "$vlan_input" ]]; then + VLAN_SUBNETS="$vlan_input" + save_config + print_success "VLAN configuration saved" + echo "" + echo "Rebuilding pjsip.conf to apply changes..." + generate_pjsip_conf + asterisk -rx "module reload res_pjsip.so" 2>/dev/null + print_success "Asterisk configuration updated" + + echo "" + echo "═══════════════════════════════════════════════════════════" + echo " VLAN DNS SETUP GUIDE (Split-Horizon)" + echo "═══════════════════════════════════════════════════════════" + echo "" + echo "For proper VLAN operation with FQDNs, you need split-horizon DNS." + echo "" + read -p "Display DNS setup guide? (y/n) [y]: " show_dns + show_dns=${show_dns:-y} + + if [[ "$show_dns" =~ ^[Yy]$ ]]; then + cat << 'DNSGUIDE' + +WHAT YOU'RE ACHIEVING: +• Devices on VLANs use router for DNS (ctrld) +• ctrld split-horizon rules send FQDNs to the right LAN servers +• Only ctrld (router) can talk to servers' DNS (protected by UFW) +• No inter-VLAN routing is opened, just DNS and service ports + +1. CTRLD.TOML (on OPNSense/Router): + +[listener.0] + ip = '0.0.0.0' + port = 53 + + [listener.0.policy] + networks = [ + { 'network.0' = ['upstream.0'] }, + { 'network.1' = ['upstream.1'] } + ] + rules = [ + { 'asterisk.mydomain.com' = ['upstream.4'] } + ] + +[network.0] + cidrs = ['192.168.1.0/24'] + +[network.1] + cidrs = ['192.168.200.0/24'] + +[upstream.0] + type = 'doh' + endpoint = 'https://dns.controld.com/your-profile' + timeout = 5000 + +[upstream.4] + type = 'legacy' + endpoint = '192.168.1.11' # This Asterisk server + timeout = 3000 + +2. DNSMASQ ON THIS SERVER: + +sudo apt-get install dnsmasq +echo "listen-address=127.0.0.1" >> /etc/dnsmasq.conf +echo "listen-address=$(hostname -I | cut -d' ' -f1)" >> /etc/dnsmasq.conf +echo "bind-interfaces" >> /etc/dnsmasq.conf +echo "address=/asterisk.mydomain.com/$(hostname -I | cut -d' ' -f1)" >> /etc/dnsmasq.conf +sudo systemctl restart dnsmasq + +3. UFW RULES ON THIS SERVER: + +sudo ufw allow from 192.168.1.1 to any port 53 proto udp +sudo ufw allow from 192.168.1.1 to any port 53 proto tcp +sudo ufw deny 53 +sudo ufw reload + +Replace 192.168.1.1 with your router's LAN IP. + +4. OPNSENSE FIREWALL RULES (for each VLAN): + +Rule 1 - Allow DNS from VLAN to Router: + Action: Pass + Source: VLANxx net + Destination: This Firewall + Port: 53 (DNS) + Protocol: TCP/UDP + +Rule 2 - Allow SIP/RTP from VLAN to Asterisk: + Source: VLANxx net + Destination: $(hostname -I | cut -d' ' -f1) + Ports: 5060/udp, 5061/tcp, 10000-20000/udp + +5. DHCP SETTINGS (OPNSense): + +For each VLAN, set DNS Servers to ONLY the router's VLAN IP. +Do NOT enter this server's IP as DNS. + +═══════════════════════════════════════════════════════════ +DNSGUIDE + fi + else + print_error "No subnets provided" + fi + else + HAS_VLANS="n" + VLAN_SUBNETS="" + save_config + print_success "VLAN support disabled" + fi +} + +# ================================================================ +# PROVISIONING MANAGER +# ================================================================ + +setup_http_provisioning() { + print_header "HTTP Provisioning Setup" + + echo "This will configure Asterisk's built-in HTTP server for" + echo "client provisioning (Linphone, etc.)." + echo "" + echo "Ports:" + echo " HTTP: 8088" + echo " HTTPS: 8089" + echo "" + + # Create http.conf + backup_config "/etc/asterisk/http.conf" 2>/dev/null + cat > /etc/asterisk/http.conf << 'EOF' +[general] +enabled=yes +bindaddr=0.0.0.0 +bindport=8088 + +tlsenable=yes +tlsbindaddr=0.0.0.0:8089 +tlscertfile=/etc/asterisk/certs/server.crt +tlsprivatekey=/etc/asterisk/certs/server.key + +; Serve static files from /var/lib/asterisk/static-http +enablestatic=yes +redirect=/static /var/lib/asterisk/static-http + +; Security +session_limit=100 +session_inactivity=30000 +session_keep_alive=15000 +EOF + + chown asterisk:asterisk /etc/asterisk/http.conf + + # Create provisioning directory + mkdir -p "$PROVISIONING_DIR" + chown asterisk:asterisk "$PROVISIONING_DIR" + + # Create symlink if needed (Ubuntu/Debian fix) + if [[ ! -L /usr/share/asterisk/static-http ]]; then + mkdir -p /usr/share/asterisk + ln -sf "$PROVISIONING_DIR" /usr/share/asterisk/static-http + print_info "Created symlink: /usr/share/asterisk/static-http -> $PROVISIONING_DIR" + fi + + # Reload Asterisk HTTP module + asterisk -rx "module reload res_http_post.so" 2>/dev/null || true + asterisk -rx "http show status" 2>/dev/null + + print_success "HTTP provisioning configured" + echo "" + echo "Access provisioning files at:" + echo " HTTP: http://$(hostname -I | cut -d' ' -f1):8088/static/" + echo " HTTPS: https://$(hostname -I | cut -d' ' -f1):8089/static/" +} + +create_linphone_xml() { + print_header "Create/Edit Linphone Provisioning XML" + load_config + + local xml_file="$PROVISIONING_DIR/linphone.xml" + local server_ip=$(hostname -I | cut -d' ' -f1) + local domain="${DOMAIN_NAME:-$server_ip}" + local transport="tcp" + + if [[ "$ENABLE_TLS" == "y" && -n "$DOMAIN_NAME" ]]; then + transport="tls" + fi + + echo "Current Configuration:" + echo " Domain: $domain" + echo " Transport: $transport" + echo " Server IP: $server_ip" + echo "" + + read -p "Create/Update linphone.xml? (y/n) [y]: " create_xml + create_xml=${create_xml:-y} + + if [[ "$create_xml" =~ ^[Yy]$ ]]; then + mkdir -p "$PROVISIONING_DIR" + + cat > "$xml_file" << EOF + + + + + +
+ 0 + 1 + 0 +
+ +
+ <sip:${domain};transport=${transport}> + sip:USERNAME@${domain} + 3600 + 0 + 0 +
+ +
+ USERNAME + PASSWORD + ${domain} +
+ +
+ 7078 + 60 +
+ +
+ ANDROID SND: Android Sound card + ANDROID SND: Android Sound card + ANDROID SND: Android Sound card +
+ +
+ 0 + 0 + 0 +
+ +
+ 1 + 0 + 1 + 0 + + 0 + + 1 + 1 +
+ +
+ + 0 + 1 +
+ +
+ 1300 + + 3 + ${TURN_SERVER:-${domain}:3478} +
+ +
+EOF + + # Add TURN credentials section if TURN is enabled + if [[ "$TURN_ENABLED" == "y" && -n "$TURN_SERVER" && -n "$TURN_USERNAME" && -n "$TURN_PASSWORD" ]]; then + # Insert TURN credentials into the net section before + sed -i "s|.*|${TURN_SERVER}\n 1\n ${TURN_USERNAME}\n ${TURN_PASSWORD}|" "$xml_file" + fi + + chown asterisk:asterisk "$xml_file" + chmod 644 "$xml_file" + + print_success "Created: $xml_file" + echo "" + echo "Provisioning URL:" + if [[ "$transport" == "tls" ]]; then + echo " https://${domain}:8089/static/linphone.xml" + else + echo " http://${server_ip}:8088/static/linphone.xml" + fi + echo "" + echo "IMPORTANT for Android:" + echo " 1. Use the URL above in Linphone's 'Remote provisioning'" + echo " 2. Replace USERNAME and PASSWORD in device-specific XML files" + echo " 3. Set Battery Optimization to 'Unrestricted' manually on phone" + echo " 4. The XML prevents audio pause when screen turns off" + echo "" + echo "FOR /e/OS (eFoundation) users:" + echo " See 'Troubleshoot /e/OS Audio' in Provisioning Manager menu" + fi +} + +edit_linphone_xml() { + local xml_file="$PROVISIONING_DIR/linphone.xml" + + if [[ ! -f "$xml_file" ]]; then + print_error "linphone.xml does not exist. Create it first." + return 1 + fi + + print_header "Edit Linphone XML" + echo "Opening in nano editor..." + echo "Press Ctrl+X to save and exit" + echo "" + read -p "Press Enter to continue..." + + nano "$xml_file" + + print_success "Changes saved" +} + +show_provisioning_status() { + print_header "Provisioning Status" + + # Check HTTP configuration + if [[ -f /etc/asterisk/http.conf ]] && grep -q "enabled=yes" /etc/asterisk/http.conf 2>/dev/null; then + echo -e "HTTP Server: ${GREEN}Enabled${NC}" + asterisk -rx "http show status" 2>/dev/null | head -10 + else + echo -e "HTTP Server: ${RED}Disabled${NC}" + fi + + echo "" + + # Check provisioning directory + if [[ -d "$PROVISIONING_DIR" ]]; then + echo -e "Provisioning Dir: ${GREEN}$PROVISIONING_DIR${NC}" + echo "Files:" + ls -lh "$PROVISIONING_DIR" 2>/dev/null | tail -n +2 || echo " (empty)" + else + echo -e "Provisioning Dir: ${RED}Not created${NC}" + fi + + echo "" + + # Check symlink + if [[ -L /usr/share/asterisk/static-http ]]; then + echo -e "Symlink: ${GREEN}OK${NC} (/usr/share/asterisk/static-http)" + else + echo -e "Symlink: ${YELLOW}Not created${NC}" + fi + + echo "" + local server_ip=$(hostname -I | cut -d' ' -f1) + echo "Provisioning URLs:" + echo " HTTP: http://${server_ip}:8088/static/" + echo " HTTPS: https://${server_ip}:8089/static/" +} + +troubleshoot_eos_audio() { + print_header "/e/OS Audio Troubleshooting" + + cat << 'EOSHELP' +PROBLEM: No audio sent by phone unless Linphone has focus +═══════════════════════════════════════════════════════════ + +This is a known issue with /e/OS (eFoundation OS) and privacy-focused +Android ROMs. /e/OS has stricter privacy controls that prevent apps +from accessing the microphone in the background. + +SOLUTIONS (Try in order): + +1. LINPHONE APP SETTINGS (In Linphone app itself): + ──────────────────────────────────────────────────── + a) Open Linphone → ☰ Menu → Settings → Audio + b) Change "Audio Route" to "Speaker" (not Earpiece) + c) Enable "Use Speaker for calls" + d) Disable "Echo Cancellation" (test if this helps) + e) Go to Settings → Network + f) Set "Media Encryption" to "None" (or match server) + +2. /e/OS PRIVACY SETTINGS: + ──────────────────────────────────────────────────── + a) Settings → Apps → Linphone + b) Permissions → Microphone → "Allow all the time" + c) Permissions → Camera → "Don't allow" (if not using video) + d) "Remove permissions if app isn't used" → DISABLE + +3. /e/OS ADVANCED PRIVACY SETTINGS: + ──────────────────────────────────────────────────── + a) Settings → Privacy (Advanced Privacy / Privacy Central) + b) Find Linphone in the list + c) Disable "Hide my IP" for Linphone + d) Set Location to "Real" (not fake location) + e) Disable any "Manage trackers" restrictions for Linphone + +4. /e/OS NETWORK PERMISSIONS: + ──────────────────────────────────────────────────── + a) Settings → Apps → Linphone → Mobile data & Wi-Fi + b) Enable "Background data" + c) Enable "Unrestricted data usage" + d) Make sure "Allow network access" is ON + +5. /e/OS AUTOSTART: + ──────────────────────────────────────────────────── + a) Settings → Apps → Linphone → Battery + b) Battery optimization → "Don't optimize" or "Unrestricted" + c) Settings → Apps → Linphone → Advanced + d) Enable "Autostart" if available + +6. LINPHONE XML PROVISIONING (Server-side fix): + ──────────────────────────────────────────────────── + Your linphone.xml should already have these settings: + • android_pause_calls_when_audio_focus_lost=0 + • keep_service_alive=1 + • start_at_boot=1 + • audio_route_speaker=1 + + To verify, check: $PROVISIONING_DIR/linphone.xml + +7. ALTERNATIVE: USE SPEAKER MODE DURING CALL: + ──────────────────────────────────────────────────── + As a workaround, during an active call: + • Tap the speaker icon to enable speakerphone + • This often forces audio to work even in background + • Not ideal but proves the audio path works + +8. NUCLEAR OPTION - DISABLE PRIVACY FEATURES: + ──────────────────────────────────────────────────── + If nothing works, temporarily disable /e/OS privacy features: + a) Settings → Privacy → Advanced Privacy + b) Toggle OFF "Advanced Privacy" + c) Test if Linphone audio works + d) If it works, re-enable and whitelist Linphone + +9. ALTERNATIVE SIP APP: + ──────────────────────────────────────────────────── + If Linphone continues to have issues on /e/OS, try: + • Zoiper (better /e/OS compatibility) + • CSipSimple (older but reliable) + • Grandstream Wave (commercial but works well) + +TESTING: +════════ +1. Make a call with Linphone in foreground → audio works +2. Press Home button → does audio continue? +3. If audio stops, the issue is confirmed + +WHAT'S HAPPENING: +═════════════════ +/e/OS restricts background microphone access for privacy. +Even with permissions granted, the OS may suspend audio +capture when the app loses focus. The XML settings and +speaker mode help work around this limitation. + +MORE HELP: +══════════ +• /e/OS Community: https://community.e.foundation +• Linphone Forums: https://forum.linphone.org +• Issue: "Background microphone access on /e/OS" + +═══════════════════════════════════════════════════════════ +EOSHELP +} + +create_baresip_config() { + print_header "Create Baresip Setup Instructions" + load_config + + local server_ip=$(hostname -I | cut -d' ' -f1) + local domain="${DOMAIN_NAME:-$server_ip}" + + echo "Baresip Setup Guide Generator" + echo "================================================" + echo "" + echo "Use Baresip if Linphone has audio issues (screen off, etc.)" + echo "Baresip often works better on privacy-focused Android ROMs." + echo "" + echo "NOTE: Baresip does NOT support remote provisioning." + echo " Manual configuration required." + echo "" + echo "Current Configuration:" + echo " Domain: $domain" + echo " Server IP: $server_ip" + echo "" + + read -p "Enter extension number (e.g., 202): " extension + [[ -z "$extension" ]] && { print_error "Extension required"; return 1; } + + read -p "Enter SIP password: " sip_password + [[ -z "$sip_password" ]] && { print_error "Password required"; return 1; } + + read -p "Enter display name (e.g., Kitchen Phone): " display_name + display_name=${display_name:-Extension $extension} + + local config_file="$PROVISIONING_DIR/baresip-${extension}.txt" + + mkdir -p "$PROVISIONING_DIR" + + cat > "$config_file" << BARESIPEOF +═══════════════════════════════════════════════════════════ +BARESIP SETUP INSTRUCTIONS +Generated by Easy Asterisk v${SCRIPT_VERSION} +═══════════════════════════════════════════════════════════ + +IMPORTANT: Baresip does NOT support remote provisioning. +You must configure manually following these steps. + +STEP 1: INSTALL BARESIP +════════════════════════════════════════════════════════════ +• Download Baresip from F-Droid or Play Store +• Open the Baresip app + +STEP 2: ADD ACCOUNT (Initial Entry) +════════════════════════════════════════════════════════════ +1. Tap Menu (☰ hamburger icon) → Accounts +2. Tap the Add (+) button at the top +3. In "SIP URI" field, enter: BARESIPEOF + echo "${extension}@${domain}" >> "$config_file" + cat >> "$config_file" << 'BARESIPEOF' +4. Tap the Save (✓ checkmark) icon at the top + +STEP 3: EDIT ACCOUNT (Complete Configuration) +════════════════════════════════════════════════════════════ +Now go back and edit the account to add authentication: + +1. Tap Menu (☰) → Accounts +2. Tap on the account you just created +3. Fill in the following fields: + +BARESIPEOF + cat >> "$config_file" << EOF + Display Name: ${display_name} + + Authentication Username: ${extension} + (CRITICAL: Just the extension number, NOT ${extension}@${domain}) + + Authentication Password: ${sip_password} + + Outbound Proxy URI: ${domain} + (CRITICAL: Just the domain, NOT sip:${server_ip}:5060) + + Media Encryption: srtp + (Select from dropdown menu) + + Register: ✓ (Check this box) + +4. Tap Save (✓ checkmark icon) + +STEP 4: VERIFY REGISTRATION +════════════════════════════════════════════════════════════ +• Wait a few seconds for registration +• You should see: + - Green dot next to account, OR + - "Registered" status text + +If registration FAILS: + ✗ Double-check "Authentication Username" is JUST "${extension}" + ✗ Double-check "Outbound Proxy URI" is JUST "${domain}" + ✗ Verify password is correct: ${sip_password} + +STEP 5: SET CALLING AS DEFAULT (Optional) +════════════════════════════════════════════════════════════ +To make tapping a contact initiate a call (not message): + +1. Tap Menu (☰) → Settings (or Preferences) +2. Look for "Default Action" or "Contact Action" +3. If available, select: "Audio Call" or "Call" +4. Save + +NOTE: This option may not exist in all Baresip versions. + If not available, you can still call by: + - Long-pressing a contact → Select "Call" + - Or using the phone icon during selection + +STEP 6: AUDIO SETTINGS (Recommended) +════════════════════════════════════════════════════════════ +1. Tap Menu (☰) → Settings → Audio +2. Configure: + Audio Module: opensles (or audiotrack if opensles doesn't work) + Echo Cancellation: ✓ Enabled + Noise Suppression: ✓ Enabled + +STEP 7: ANDROID PERMISSIONS +════════════════════════════════════════════════════════════ +Go to your phone's: +Settings → Apps → Baresip + +Set the following: +• Permissions → Microphone: Allow while using app +• Permissions → Phone: Allow +• Battery: Unrestricted (or Not optimized) +• Mobile data & Wi-Fi → Background data: Enabled + +DIALING EXTENSIONS +════════════════════════════════════════════════════════════ +To call other extensions: + +Method 1 (Try this first): + Just dial the extension number: 101, 202, etc. + +Method 2 (If method 1 doesn't work): + Full format: 101@${domain} + +Common Extensions: +• Individual devices: 101, 102, 201, 202, etc. +• Page groups (auto-answer broadcast): 199 +• Ring groups (rings all phones): 299 + +TOP BAR ICONS IN BARESIP +════════════════════════════════════════════════════════════ +☰ = Hamburger menu (Accounts, Settings, About, etc.) +✓ = Save/Confirm current action +⋮ = Additional options (context-dependent) +📞 = Answer incoming call / Place outgoing call +🔊 = Enable speakerphone (during active call) +🔇 = Mute microphone (during active call) +✕ = Hang up / End call + +TROUBLESHOOTING +════════════════════════════════════════════════════════════ + +PROBLEM: Registration fails +SOLUTION: + • Verify "Authentication Username" is JUST: ${extension} + • Verify "Outbound Proxy URI" is JUST: ${domain} + • Check password is correct + • Check phone has network connectivity + • Check firewall allows SIP traffic + +PROBLEM: Can't dial extensions +SOLUTION: + • Verify you're registered (green dot/status) + • Try dialing full format: ${extension}@${domain} + • Check extension exists on server + +PROBLEM: No audio / Audio doesn't work +SOLUTION: + • Menu → Settings → Audio → Try different "Audio Module" + • Check microphone permissions in Android settings + • During call, try tapping speaker icon + +PROBLEM: Audio cuts when screen turns off +SOLUTION: + • Settings → Apps → Baresip → Battery → Unrestricted + • Baresip handles this much better than Linphone! + • This issue is rare with Baresip + +PROBLEM: Can't find "Default Action" setting +SOLUTION: + • Not all Baresip versions have this option + • Alternative: Long-press contact → Select "Call" + • Or tap contact then tap phone icon + +═══════════════════════════════════════════════════════════ +QUICK REFERENCE +═══════════════════════════════════════════════════════════ +Display Name: ${display_name} +SIP URI (initial): ${extension}@${domain} +Auth Username: ${extension} +Auth Password: ${sip_password} +Outbound Proxy: ${domain} +Media Encryption: srtp +Register: ✓ +═══════════════════════════════════════════════════════════ + +EOF + + chown asterisk:asterisk "$config_file" + chmod 644 "$config_file" + + print_success "Created: $config_file" + echo "" + echo "═══════════════════════════════════════════════════════════" + echo "BARESIP SETUP - Extension ${extension}" + echo "═══════════════════════════════════════════════════════════" + echo "" + echo "Download instructions:" + echo " http://${server_ip}:8088/static/baresip-${extension}.txt" + echo "" + echo "QUICK SETUP SUMMARY:" + echo "" + echo "Step 1: Add Account" + echo " Menu → Accounts → Add (+)" + echo " SIP URI: ${extension}@${domain}" + echo " Save (✓)" + echo "" + echo "Step 2: Edit Account" + echo " Tap account → Edit" + echo " Auth Username: ${extension} (JUST the number!)" + echo " Auth Password: ${sip_password}" + echo " Outbound Proxy: ${domain} (JUST the domain!)" + echo " Media Encryption: srtp (select from dropdown)" + echo " Register: ✓" + echo " Save (✓)" + echo "" + echo "Step 3: Verify" + echo " Look for green dot or 'Registered' status" + echo "" + echo "Step 4: Dial Extensions" + echo " Just dial: 101, 202, etc." + echo "" + echo "Full details in the text file above." + echo "═══════════════════════════════════════════════════════════" +} + +provisioning_manager_menu() { + while true; do + clear + print_header "Provisioning Manager" + echo " 1) Setup HTTP Server (ports 8088/8089)" + echo " 2) Create/Update linphone.xml" + echo " 3) Edit linphone.xml" + echo " 4) Create Baresip Config" + echo " 5) Show Status" + echo " 6) Open Provisioning Directory" + echo " 7) Troubleshoot /e/OS Audio Issues" + echo " 0) Back" + read -p " Select: " choice + + case $choice in + 1) setup_http_provisioning ;; + 2) create_linphone_xml ;; + 3) edit_linphone_xml ;; + 4) create_baresip_config ;; + 5) show_provisioning_status ;; + 6) + if command -v mc &>/dev/null; then + mc "$PROVISIONING_DIR" + else + print_info "Opening with ls..." + ls -lah "$PROVISIONING_DIR" + fi + ;; + 7) troubleshoot_eos_audio ;; + 0) return ;; + esac + + [[ "$choice" != "0" ]] && read -p "Press Enter..." + done +} + +# ================================================================ +# MANUAL UPDATE SYSTEM +# ================================================================ + +manual_update_asterisk() { + if is_docker; then + print_header "Update Asterisk (Docker)" + echo " In Docker, Asterisk is updated by rebuilding the container image." + echo "" + echo " Steps:" + echo " 1. docker compose down" + echo " 2. docker compose build --no-cache" + echo " 3. docker compose up -d" + echo "" + echo " Your configuration is preserved in Docker volumes." + echo " Current version:" + asterisk -V 2>/dev/null || echo " Asterisk not running" + return + fi + + print_header "Manual Asterisk Update" + echo "WARNING: This will update Asterisk from the repository." + echo "A backup will be created automatically." + echo "" + asterisk -V 2>/dev/null || echo "Asterisk not currently running" + echo "" + read -p "Continue with update? (y/n) [n]: " confirm + confirm=${confirm:-n} + + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + print_info "Update cancelled" + return + fi + + # Backup configurations + local backup_dir="/root/asterisk-backup-$(date +%Y%m%d_%H%M%S)" + mkdir -p "$backup_dir" + echo "Creating backup in $backup_dir..." + cp -r /etc/asterisk "$backup_dir/" + cp -r /var/lib/asterisk "$backup_dir/" 2>/dev/null || true + + print_success "Backup created: $backup_dir" + + # Update + echo "" + print_info "Updating Asterisk..." + apt update + apt install --only-upgrade asterisk asterisk-modules -y + + # Restart + echo "" + restart_asterisk_safe + + if asterisk_running; then + print_success "Asterisk updated successfully" + asterisk -V + echo "" + echo "Backup location: $backup_dir" + echo "" + echo "To rollback if needed:" + echo " systemctl stop asterisk" + echo " cp -r $backup_dir/asterisk/* /etc/asterisk/" + echo " systemctl start asterisk" + else + print_error "Asterisk failed to start after update!" + echo "" + echo "Rolling back..." + cp -r "$backup_dir/asterisk/"* /etc/asterisk/ + restart_asterisk_safe + print_info "Rollback complete" + fi +} + +# ================================================================ +# ROOM DIRECTORY +# ================================================================ + +show_room_directory() { + print_header "Room Directory" + load_config + + if [[ ! -f "$ROOMS_FILE" ]]; then + print_error "Rooms file not found: $ROOMS_FILE" + return + fi + + echo "Ring Groups vs Page Groups:" + echo " • Ring Groups: Rings all members until one answers" + echo " • Page Groups: Auto-answer broadcast to all members" + echo "" + echo "═══════════════════════════════════════════════════════════" + + local has_rooms=false + while IFS='|' read -r ext name members timeout type; do + # Skip comments and empty lines + [[ "$ext" =~ ^[[:space:]]*# ]] && continue + [[ -z "$ext" ]] && continue + + has_rooms=true + + # Determine icon based on type + local icon="📞" + local type_label="Ring Group" + if [[ "$type" == "page" ]]; then + icon="📢" + type_label="Page Group" + fi + + echo "" + echo "$icon Extension: $ext - $name" + echo " Type: $type_label" + echo " Members: $members" + echo " Timeout: ${timeout}s" + done < "$ROOMS_FILE" + + if [[ "$has_rooms" == "false" ]]; then + echo "" + echo "No rooms configured yet." + echo "Use 'Device Management → Manage rooms' to create rooms." + fi + + echo "" + echo "═══════════════════════════════════════════════════════════" +} + +watch_live_logs() { + print_header "Live Debugging" + echo "Enabling PJSIP Logger..." + asterisk -rx "module load res_pjsip_logger.so" 2>/dev/null || true + asterisk -rx "pjsip set logger on" 2>/dev/null + echo "" + echo "Options:" + echo " 1) Asterisk Console (verbose)" + echo " 2) Packet Capture (tcpdump)" + read -p "Select [1]: " pcap + if [[ "$pcap" == "2" ]]; then + echo "Starting tcpdump. Press CTRL+C to stop." + tcpdump -i any port 5060 or port 5061 -nn -v + else + echo "Starting Console. Press CTRL+C to exit." + asterisk -rvvv + fi + asterisk -rx "pjsip set logger off" 2>/dev/null +} + +router_doctor() { + print_header "Router Traffic Doctor" + if ! asterisk_running; then + print_error "Asterisk is NOT RUNNING" + restart_asterisk_safe + return + fi + + print_success "Asterisk is UP" + echo "" + echo "Server Listening IPs:" + ip -o -4 addr show | awk '{print " " $2 ": " $4}' + echo "" + echo "Instructions:" + echo " 1. Take out your phone/laptop" + echo " 2. Attempt to REGISTER or CALL" + echo " 3. I will listen for 15 seconds" + echo "" + read -p "Press Enter to start listening..." + + if timeout 15 tcpdump -i any -c 1 "port 5060 or port 5061" 2>/dev/null; then + echo "" + print_success "PACKET RECEIVED! Router forwarding is working." + else + echo "" + print_error "NO PACKETS RECEIVED." + echo "Your router or firewall is blocking the connection." + fi +} + +configure_local_client() { + if is_docker; then + print_error "Local client not available in Docker. Use Sipnetic, Linphone, or Baresip on your phone/tablet." + return + fi + print_header "Configure Local Client" + load_config + + # If KIOSK_USER already set from config, show and ask if want to change + if [[ -n "$KIOSK_USER" ]]; then + echo "Current configured user: $KIOSK_USER" + read -p "Change user? [y/N]: " change_user + if [[ "$change_user" =~ ^[Yy]$ ]]; then + KIOSK_USER="" + KIOSK_UID="" + fi + fi + + # If still no user, select one + if [[ -z "$KIOSK_USER" ]]; then + echo "" + echo "Select the user to configure:" + echo "" + if ! select_user; then + print_error "User selection failed" + return 1 + fi + else + # Ensure KIOSK_UID is set + KIOSK_UID=$(id -u "$KIOSK_USER" 2>/dev/null) + fi + + echo "" + + if [[ ! -d "/home/${KIOSK_USER}/.baresip" ]]; then + print_error "Baresip not installed for $KIOSK_USER" + echo "" + read -p "Install Baresip client now? [Y/n]: " install_it + if [[ ! "$install_it" =~ ^[Nn]$ ]]; then + install_baresip_packages + configure_baresip + enable_client_services + INSTALLED_CLIENT="y" + save_config + print_success "Baresip installed" + echo "" + echo "Audio configured for $KIOSK_USER" + echo "If audio doesn't work, log out and back in or reboot." + echo "" + else + return + fi + fi + + read -p "Extension: " ext + read -p "Password: " pass + read -p "Server Domain/IP: " server + + local transport_str="udp" + local media_enc="" + + if [[ "$server" =~ [a-zA-Z] ]]; then + print_info "Domain detected. Using TLS." + transport_str="tls" + media_enc=";mediaenc=srtp" + fi + + echo "" + echo "Answer Mode:" + echo " 1) Manual (ring on incoming)" + echo " 2) Auto (auto-answer)" + read -p "Select [1]: " amode + local answermode="manual" + [[ "$amode" == "2" ]] && answermode="auto" + + echo "" + echo "Enable TURN? (Required if behind NAT/VLAN without VPN)" + read -p "Use TURN server? [y/N]: " use_turn + local turn_config="" + if [[ "$use_turn" =~ ^[Yy]$ ]]; then + read -p "TURN User [${TURN_USER}]: " t_user + t_user="${t_user:-$TURN_USER}" + read -p "TURN Pass [${TURN_PASS}]: " t_pass + t_pass="${t_pass:-$TURN_PASS}" + local turn_host="${server}" + if [[ ! "$turn_host" =~ [a-zA-Z] ]]; then + # If server is IP, ask if TURN host is different + read -p "TURN Host [${server}]: " th + turn_host="${th:-$server}" + fi + read -p "TURN Port [3478]: " t_port + t_port="${t_port:-3478}" + turn_config="turn_server turn:${t_user}:${t_pass}@${turn_host}:${t_port}" + fi + + # Update config file for TURN + local conf_file="/home/${KIOSK_USER}/.baresip/config" + if [[ -f "$conf_file" ]]; then + sed -i '/^turn_server/d' "$conf_file" + if [[ -n "$turn_config" ]]; then + echo "$turn_config" >> "$conf_file" + print_success "TURN configuration added" + fi + fi + + cat > "/home/${KIOSK_USER}/.baresip/accounts" << EOF +;auth_pass=${pass};answermode=${answermode}${media_enc} +EOF + chown ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.baresip/accounts" + chown ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.baresip/config" + + # Update main config + ASTERISK_HOST="$server" + KIOSK_EXTENSION="$ext" + CLIENT_ANSWERMODE="$answermode" + save_config + + local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}" + + # Reload systemd daemon in case services changed + sudo -u "${KIOSK_USER}" $user_dbus systemctl --user daemon-reload 2>/dev/null + + # Restart audio and client services + print_info "Restarting services..." + sudo -u "${KIOSK_USER}" $user_dbus systemctl --user restart pipewire pipewire-pulse 2>/dev/null || true + sleep 2 + sudo -u "${KIOSK_USER}" $user_dbus systemctl --user restart baresip 2>/dev/null + + # Ensure audio is unmuted if not in PTT mode + if [[ ! -f /etc/easy-asterisk/ptt-device ]]; then + sleep 1 + ensure_audio_unmuted + fi + + print_success "Client Reconfigured & Services Restarted" + echo "" + echo "Run Diagnostics to verify connection status." +} + +run_client_diagnostics() { + if is_docker; then + print_error "Client diagnostics not available in Docker. Run vpn-diagnostics for server-side checks." + return + fi + print_header "Client Diagnostics" + load_config + local t_user="${KIOSK_USER:-$SUDO_USER}" + t_user="${t_user:-$USER}" + local t_uid=$(id -u "$t_user" 2>/dev/null) + + echo -e "User: ${BOLD}$t_user${NC}" + echo "---------------------------------------------------" + if sudo -u "$t_user" XDG_RUNTIME_DIR=/run/user/$t_uid systemctl --user is-active baresip >/dev/null 2>&1; then + print_success "Baresip RUNNING" + else + print_error "Baresip STOPPED/FAILED" + fi + echo "---------------------------------------------------" + + echo "Audio Services:" + local user_dbus="XDG_RUNTIME_DIR=/run/user/$t_uid" + if sudo -u "$t_user" $user_dbus systemctl --user is-active pipewire >/dev/null 2>&1; then + print_success "PipeWire RUNNING" + else + print_error "PipeWire STOPPED" + fi + if sudo -u "$t_user" $user_dbus systemctl --user is-active pipewire-pulse >/dev/null 2>&1; then + print_success "PipeWire-Pulse RUNNING" + else + print_error "PipeWire-Pulse STOPPED" + fi + + echo "" + echo "Audio Status:" + local src_mute=$(sudo -u "$t_user" $user_dbus pactl get-source-mute @DEFAULT_SOURCE@ 2>/dev/null | awk '{print $2}') + local sink_mute=$(sudo -u "$t_user" $user_dbus pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null | awk '{print $2}') + local src_vol=$(sudo -u "$t_user" $user_dbus pactl get-source-volume @DEFAULT_SOURCE@ 2>/dev/null | grep -oP '\d+%' | head -1) + local sink_vol=$(sudo -u "$t_user" $user_dbus pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oP '\d+%' | head -1) + + echo " Microphone: ${src_mute:-unknown} (Volume: ${src_vol:-unknown})" + echo " Speaker: ${sink_mute:-unknown} (Volume: ${sink_vol:-unknown})" + + if [[ "$src_mute" == "yes" ]]; then + echo "" + print_error "MICROPHONE IS MUTED - No audio will be sent!" + echo " To fix: pactl set-source-mute @DEFAULT_SOURCE@ 0" + fi + + echo "" + echo "PTT Configuration:" + if [[ -f /etc/easy-asterisk/ptt-device ]]; then + echo " PTT Mode: ENABLED" + source /etc/easy-asterisk/ptt-device 2>/dev/null + echo " Device: ${PTT_DEVICE:-not set}" + else + echo " PTT Mode: DISABLED (normal intercom mode)" + fi + + echo "---------------------------------------------------" + + echo "Network Interface:" + grep "^net_interface" "/home/$t_user/.baresip/config" 2>/dev/null || echo " Not set" + + echo "---------------------------------------------------" + echo "Account Config:" + cat "/home/$t_user/.baresip/accounts" 2>/dev/null | sed 's/auth_pass=[^;]*/auth_pass=***/' || echo " Not found" + + echo "---------------------------------------------------" + if [[ -n "$ASTERISK_HOST" ]]; then + echo -n "Server ($ASTERISK_HOST): " + if ping -c 1 -W 2 "$ASTERISK_HOST" >/dev/null 2>&1; then + print_success "Reachable" + else + print_error "Unreachable" + fi + fi + echo "---------------------------------------------------" + echo "System Logs (launcher):" + journalctl -t baresip-launcher -n 10 --no-pager 2>/dev/null | tail -10 || echo " No launcher logs" + echo "" + echo "System Logs (PTT):" + journalctl -t kiosk-ptt -n 5 --no-pager 2>/dev/null | tail -5 || echo " No PTT logs" + echo "" + echo "Baresip Service Log:" + sudo -u "$t_user" journalctl --user -u baresip -n 10 --no-pager 2>/dev/null || echo " No logs" + echo "---------------------------------------------------" + echo "" + echo "To see live logs, run:" + echo " journalctl -t baresip-launcher -f # Launcher logs" + echo " journalctl -t kiosk-ptt -f # PTT logs" + echo " sudo -u $t_user journalctl --user -u baresip -f # Baresip logs" + echo "---------------------------------------------------" +} + +run_audio_test() { + print_header "Audio Test" + echo "Playing test tone..." + speaker-test -t sine -f 440 -c 2 -l 1 >/dev/null 2>&1 + echo "" + read -p "Did you hear audio? [y/N]: " res + if [[ "$res" =~ ^[Yy]$ ]]; then + print_success "Audio OK" + else + print_error "Check volume/connections" + fi +} + +verify_audio_setup() { + print_header "Audio Verification" + echo "=== Codecs ===" + asterisk -rx "core show codecs" 2>/dev/null | grep -E "(opus|ulaw|alaw|g722)" || echo " N/A" + echo "" + echo "=== PJSIP Modules ===" + asterisk -rx "module show like pjsip" 2>/dev/null | head -10 || echo " N/A" + echo "" + echo "=== Certificate ===" + if [[ -f /etc/asterisk/certs/server.crt ]]; then + openssl x509 -in /etc/asterisk/certs/server.crt -noout -subject -dates 2>/dev/null + else + echo " None" + fi +} + +# ================================================================ +# 7. ASTERISK CONFIG +# ================================================================ + +fix_asterisk_systemd() { + if is_docker; then + # No systemd in Docker - Asterisk runs as the main container process + return + fi + print_info "Configuring systemd..." + mkdir -p /etc/systemd/system/asterisk.service.d/ + cat > /etc/systemd/system/asterisk.service.d/override.conf << 'SVCEOF' +[Unit] +Wants=network-online.target +After=network-online.target + +[Service] +ExecStart= +ExecStart=/usr/sbin/asterisk -f -U asterisk -G asterisk +RuntimeDirectory=asterisk +RuntimeDirectoryMode=0750 +MemoryMax=infinity +TasksMax=infinity +KillMode=mixed +KillSignal=SIGTERM +TimeoutStartSec=60 +TimeoutStopSec=30 +SendSIGKILL=no +Restart=always +RestartSec=10 +Type=simple +SVCEOF + systemctl daemon-reload +} + +recover_xml_docs() { + mkdir -p /var/lib/asterisk/documentation/thirdparty + chown -R asterisk:asterisk /var/lib/asterisk/documentation 2>/dev/null +} + +repair_core_configs() { + print_info "Repairing configs..." + + # Copy modules (not symlink - AppArmor blocks symlinks) + if [[ -d "/usr/lib/x86_64-linux-gnu/asterisk/modules" ]]; then + mkdir -p /usr/lib/asterisk/modules + cp -rn /usr/lib/x86_64-linux-gnu/asterisk/modules/* /usr/lib/asterisk/modules/ 2>/dev/null || true + fi + + mkdir -p /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk + recover_xml_docs + + if [[ ! -f /etc/asterisk/asterisk.conf ]]; then + cat > /etc/asterisk/asterisk.conf << EOF +[directories] +astetcdir => /etc/asterisk +astmoddir => /usr/lib/asterisk/modules +astvarlibdir => /var/lib/asterisk +astdbdir => /var/lib/asterisk +astkeydir => /var/lib/asterisk +astdatadir => /var/lib/asterisk +astagidir => /var/lib/asterisk/agi-bin +astspooldir => /var/spool/asterisk +astrundir => /var/run/asterisk +astlogdir => /var/log/asterisk +EOF + fi + + cat > /etc/asterisk/modules.conf << EOF +[modules] +autoload=yes +noload => chan_sip.so +noload => chan_iax2.so +load => res_pjsip.so +load => res_pjsip_session.so +load => res_pjsip_logger.so +load => chan_pjsip.so +load => codec_ulaw.so +load => codec_alaw.so +load => codec_g722.so +load => codec_opus.so +load => res_rtp_asterisk.so +load => app_dial.so +load => app_page.so +load => pbx_config.so +EOF + + # Disable optional modules (NOT stasis - required in Asterisk 20.x) + for conf in ari http manager geolocation; do + cat > "/etc/asterisk/${conf}.conf" << EOF +[general] +enabled = no +EOF + done + + # Configure Stasis properly (required core module) + cat > /etc/asterisk/stasis.conf << EOF +[general] +; Stasis is required for Asterisk 20.x core functionality +EOF + + if [[ ! -f /etc/asterisk/sorcery.conf ]]; then + cat > /etc/asterisk/sorcery.conf << EOF +[res_pjsip] +endpoint=config,pjsip.conf,criteria=type=endpoint +auth=config,pjsip.conf,criteria=type=auth +aor=config,pjsip.conf,criteria=type=aor +transport=config,pjsip.conf,criteria=type=transport +EOF + fi + + # ICE configuration + # ICE is enabled so Asterisk participates in ICE negotiation with clients. + # stunaddr/turnaddr are NOT set because: + # - Asterisk knows its public IP via external_media_address in pjsip.conf + # - Its RTP ports are port-forwarded, so host candidates are sufficient + # - Setting stunaddr/turnaddr causes STUN/TURN gather timeouts (~27s delay) + # coturn (if running) is for SIP clients behind strict NAT — they configure + # TURN in their own app settings, independently of Asterisk's rtp.conf. + load_config + local ice_config="" + if [[ -n "$DOMAIN_NAME" ]] || [[ "$VPN_ICE_ENABLED" == "y" ]] || [[ "$TURN_ENABLED" == "y" ]]; then + ice_config="icesupport=yes" + else + ice_config="# icesupport disabled - LAN only mode" + fi + + cat > /etc/asterisk/rtp.conf << EOF +[general] +rtpstart=${RTP_START:-10000} +rtpend=${RTP_END:-20000} +strictrtp=yes +${ice_config} +EOF + + cat > /etc/asterisk/logger.conf << EOF +[general] +[logfiles] +console => notice,warning,error +EOF + + rm -f /var/lib/asterisk/.asterisk_history + chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk 2>/dev/null || true + chown -R asterisk:asterisk /usr/lib/asterisk/modules 2>/dev/null || true +} + +generate_pjsip_conf() { + print_info "Generating PJSIP..." + load_config + local conf_file="/etc/asterisk/pjsip.conf" + backup_config "$conf_file" + + # Prioritize CURRENT_PUBLIC_IP from coturn/updater if available, else detect + local public_ip="${CURRENT_PUBLIC_IP}" + if [[ -z "$public_ip" ]]; then + public_ip=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || echo "") + fi + + # Get server IP for transport binding info + local server_ip=$(hostname -I | cut -d' ' -f1) + + local raw_cidr=$(ip -o -f inet addr show | awk '/scope global/ {print $4}' | head -1) + local default_cidr="$raw_cidr" + if [[ "$raw_cidr" =~ \.([0-9]+)/24$ ]]; then default_cidr="${raw_cidr%.*}.0/24"; fi + + # Use stored CIDR if available + local local_net="${LOCAL_CIDR:-$default_cidr}" + + # Build local_net entries (main network + VLANs) + local all_local_nets="local_net=$local_net" + if [[ "$HAS_VLANS" == "y" && -n "$VLAN_SUBNETS" ]]; then + for vlan_subnet in $VLAN_SUBNETS; do + all_local_nets="${all_local_nets} +local_net=${vlan_subnet}" + done + print_info "VLAN subnets configured: $VLAN_SUBNETS" + fi + + local nat_settings="" + if [[ -n "$public_ip" && -n "$DOMAIN_NAME" ]]; then + # FQDN mode: full NAT settings with external addresses + nat_settings="external_media_address=$public_ip +external_signaling_address=$public_ip +${all_local_nets}" + print_info "NAT: Public IP=$public_ip, Server IP=$server_ip" + elif [[ "$HAS_VLANS" == "y" && -n "$VLAN_SUBNETS" ]]; then + # LAN/VPN mode with VLAN/VPN subnets: include local_net entries + # so Asterisk recognizes VPN traffic as local (prevents VPN devices + # appearing offline and fixes media routing for VPN-connected mobiles) + nat_settings="${all_local_nets}" + print_info "LAN mode with additional subnets: $VLAN_SUBNETS" + fi + + cat > "$conf_file" << EOF +; Easy Asterisk v${SCRIPT_VERSION} +[global] +type=global +user_agent=EasyAsterisk + +[transport-udp] +type=transport +protocol=udp +bind=0.0.0.0:${DEFAULT_SIP_PORT} +; Server IP: ${server_ip} +${nat_settings} + +[transport-tcp] +type=transport +protocol=tcp +bind=0.0.0.0:${DEFAULT_SIP_PORT} +; Server IP: ${server_ip} +${nat_settings} + +[transport-tls] +type=transport +protocol=tls +bind=0.0.0.0:${DEFAULT_SIPS_PORT} +; Server IP: ${server_ip} +cert_file=/etc/asterisk/certs/server.crt +priv_key_file=/etc/asterisk/certs/server.key +ca_list_file=/etc/ssl/certs/ca-certificates.crt +method=tlsv1_2 +${nat_settings} + +EOF + + local backup_file=$(ls -t "${conf_file}.backup-"* 2>/dev/null | head -1) + if [[ -f "$backup_file" ]]; then + awk '/^; === Device:/{flag=1} flag' "$backup_file" >> "$conf_file" + print_success "Restored devices from backup" + fi + chown asterisk:asterisk "$conf_file" +} + +rebuild_dialplan() { + local quiet=$1 + [[ "$quiet" != "quiet" ]] && print_info "Rebuilding dialplan..." + local conf_file="/etc/asterisk/extensions.conf" + backup_config "$conf_file" + + cat > "$conf_file" << EOF +[general] +static=yes +writeprotect=no +[default] +exten => _X.,1,Hangup() +[intercom] +EOF + + local dev_name="" dev_cat="" dev_auto="" dev_aa_override="" + local -A device_extensions=() + while IFS= read -r line; do + if [[ "$line" == *"; === Device:"* ]]; then + dev_aa_override="" + local temp="${line#*; === Device: }" + temp="${temp% ===}" + if [[ "$temp" == *"[AA:yes]"* ]]; then + dev_aa_override="yes"; temp="${temp% [AA:yes]}" + elif [[ "$temp" == *"[AA:no]"* ]]; then + dev_aa_override="no"; temp="${temp% [AA:no]}" + fi + dev_cat="${temp##* (}"; dev_cat="${dev_cat%)}" + dev_name="${temp% (*)}" + dev_auto="no" + local cat_data=$(grep "^${dev_cat}|" "$CATEGORIES_FILE" 2>/dev/null || true) + if [[ -n "$cat_data" ]]; then + local is_auto=$(echo "$cat_data" | cut -d'|' -f3) + [[ "$is_auto" == "yes" ]] && dev_auto="yes" + fi + [[ "$dev_aa_override" == "yes" ]] && dev_auto="yes" + [[ "$dev_aa_override" == "no" ]] && dev_auto="no" + fi + if [[ "$line" =~ ^\[([0-9]+)\] ]]; then + local ext="${BASH_REMATCH[1]}" + if [[ -n "$dev_name" ]]; then + device_extensions[$ext]=1 + if [[ "$dev_auto" == "yes" ]]; then + cat >> "$conf_file" << EOF +exten => ${ext},1,NoOp(Auto-Answer ${ext}) + same => n,Set(PJSIP_HEADER(add,Call-Info)=\;answer-after=0) + same => n,Set(PJSIP_HEADER(add,Alert-Info)=auto-answer) + same => n,Dial(PJSIP/${ext},60) + same => n,Hangup() + +EOF + else + cat >> "$conf_file" << EOF +exten => ${ext},1,NoOp(Call ${ext}) + same => n,Dial(PJSIP/${ext},60) + same => n,Hangup() + +EOF + fi + dev_name="" + fi + fi + done < /etc/asterisk/pjsip.conf + + # Add rooms (skip if extension already used by a device) + if [[ -f "$ROOMS_FILE" ]]; then + while IFS='|' read -r rext rname rmem rtime rtype; do + [[ "$rext" =~ ^# ]] && continue + [[ -z "$rext" ]] && continue + if [[ -n "${device_extensions[$rext]:-}" ]]; then + [[ "$quiet" != "quiet" ]] && print_warn "Room '$rname' ext $rext conflicts with device — skipping" + continue + fi + local dial_list="" + IFS=',' read -ra EXTS <<< "$rmem" + for ext in "${EXTS[@]}"; do + ext=$(echo "$ext" | tr -d ' ') + [[ -n "$dial_list" ]] && dial_list="${dial_list}&" + dial_list="${dial_list}PJSIP/${ext}" + done + if [[ "$rtype" == "page" ]]; then + cat >> "$conf_file" << EOF +; Room: ${rname} (Page) +exten => ${rext},1,NoOp(Page ${rname}) + same => n,Set(PJSIP_HEADER(add,Call-Info)=\;answer-after=0) + same => n,Page(${dial_list},i,${rtime}) + same => n,Hangup() + +EOF + else + cat >> "$conf_file" << EOF +; Room: ${rname} (Ring) +exten => ${rext},1,NoOp(Call ${rname}) + same => n,Dial(${dial_list},${rtime}) + same => n,Hangup() + +EOF + fi + done < "$ROOMS_FILE" + fi + + chown -R asterisk:asterisk /etc/asterisk + asterisk -rx "dialplan reload" &>/dev/null || true +} + +configure_asterisk() { + if ! id asterisk >/dev/null 2>&1; then + useradd -r -s /bin/false -d /var/lib/asterisk asterisk 2>/dev/null || true + fi + + print_info "Configuring Asterisk..." + fix_asterisk_systemd + initialize_default_categories + repair_core_configs + + mkdir -p /etc/asterisk/certs + if [[ ! -f /etc/asterisk/certs/server.crt ]]; then + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout /etc/asterisk/certs/server.key \ + -out /etc/asterisk/certs/server.crt \ + -subj "/CN=asterisk-local" 2>/dev/null + fi + + chown asterisk:asterisk /etc/asterisk/certs/server.* 2>/dev/null || true + chmod 644 /etc/asterisk/certs/server.crt 2>/dev/null || true + chmod 600 /etc/asterisk/certs/server.key 2>/dev/null || true + + generate_pjsip_conf + rebuild_dialplan "quiet" + + restart_asterisk_safe + if ! is_docker; then + systemctl enable asterisk + fi +} + +# ================================================================ +# 8. CLIENT CONFIG +# ================================================================ + +configure_baresip() { + if is_docker; then return; fi + local baresip_dir="/home/${KIOSK_USER}/.baresip" + mkdir -p "$baresip_dir" + + # Detect network interface + local found_iface="" + for target in 8.8.8.8 1.1.1.1 9.9.9.9; do + local iface=$(ip route get "$target" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | head -1) + if [[ -n "$iface" ]]; then + found_iface="$iface" + print_success "Network interface: $found_iface" + break + fi + done + + cat > "${baresip_dir}/config" << EOF +poll_method epoll +audio_player pulse +audio_source pulse +audio_alert pulse +sip_autoanswer yes +sip_cafile /etc/ssl/certs/ca-certificates.crt +rtp_timeout 0 +net_af ipv4 +module_path /usr/lib/baresip/modules +module srtp.so +module stdio.so +module pulse.so +module g711.so +module opus.so +module account.so +module stun.so +module ice.so +module turn.so +EOF + + [[ -n "$found_iface" ]] && echo "net_interface $found_iface" >> "${baresip_dir}/config" + + local transport="udp" + local mediaenc="" + if [[ "$ENABLE_TLS" == "y" ]]; then + transport="tls" + mediaenc=";mediaenc=srtp" + fi + + local amode="${CLIENT_ANSWERMODE:-auto}" + + cat > "${baresip_dir}/accounts" << EOF +;auth_pass=${SIP_PASSWORD};answermode=${amode}${mediaenc} +EOF + chown -R ${KIOSK_USER}:${KIOSK_USER} "$baresip_dir" + chmod 700 "$baresip_dir" + + configure_audio_ducking + create_ptt_handler + create_baresip_launcher +} + +create_baresip_launcher() { + local launcher_user="${KIOSK_USER}" + cat > /usr/local/bin/easy-asterisk-launcher << LAUNCHER +#!/bin/bash +CONFIG_FILE="/home/${launcher_user}/.baresip/config" +ACCOUNTS_FILE="/home/${launcher_user}/.baresip/accounts" +TARGETS=("8.8.8.8" "1.1.1.1" "9.9.9.9") +FOUND_IFACE="" + +logger -t baresip-launcher "Starting Baresip launcher for user ${launcher_user}" + +# Wait for network +for i in {1..6}; do + for target in "\${TARGETS[@]}"; do + IFACE=\$(ip route get "\$target" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if(\$i=="dev") print \$(i+1)}' | head -1) + if [[ -n "\$IFACE" ]]; then + FOUND_IFACE="\$IFACE" + logger -t baresip-launcher "Network found on interface: \$IFACE" + break 2 + fi + done + logger -t baresip-launcher "Waiting for network... (attempt \$i/6)" + sleep 5 +done + +if [[ -z "\$FOUND_IFACE" ]]; then + logger -t baresip-launcher "ERROR: No network interface found after 30 seconds" +fi + +# Update network interface in config +if [[ -f "\$CONFIG_FILE" && -n "\$FOUND_IFACE" ]]; then + sed -i '/^#*net_interface/d' "\$CONFIG_FILE" + echo "net_interface \${FOUND_IFACE}" >> "\$CONFIG_FILE" + logger -t baresip-launcher "Updated config with interface: \$FOUND_IFACE" +fi + +# Verify config files exist +if [[ ! -f "\$CONFIG_FILE" ]]; then + logger -t baresip-launcher "ERROR: Config file not found: \$CONFIG_FILE" + exit 1 +fi + +if [[ ! -f "\$ACCOUNTS_FILE" ]]; then + logger -t baresip-launcher "ERROR: Accounts file not found: \$ACCOUNTS_FILE" + exit 1 +fi + +logger -t baresip-launcher "Starting Baresip client..." +exec /usr/bin/baresip -f "/home/${launcher_user}/.baresip" +LAUNCHER + chmod +x /usr/local/bin/easy-asterisk-launcher +} + +enable_client_services() { + if is_docker; then + # No local audio client in Docker containers + return + fi + local systemd_dir="/home/${KIOSK_USER}/.config/systemd/user" + mkdir -p "$systemd_dir" + + # Ensure audio group membership + if ! id -nG "$KIOSK_USER" | grep -qw "audio"; then + usermod -aG audio "$KIOSK_USER" + fi + + # Ensure input group membership (for PTT device access) + if ! id -nG "$KIOSK_USER" | grep -qw "input"; then + usermod -aG input "$KIOSK_USER" + fi + + # Baresip service + cat > "${systemd_dir}/baresip.service" << EOF +[Unit] +Description=Baresip SIP Client +After=pipewire.service pipewire-pulse.service network-online.target +Wants=network-online.target pipewire.service pipewire-pulse.service +Requires=pipewire-pulse.service + +[Service] +Type=simple +ExecStartPre=/bin/sleep 5 +ExecStart=/usr/local/bin/easy-asterisk-launcher +Restart=always +RestartSec=10 +Environment=XDG_RUNTIME_DIR=/run/user/${KIOSK_UID} +Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${KIOSK_UID}/bus +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target +EOF + + # PTT service - only create if PTT is configured + cat > "${systemd_dir}/kiosk-ptt.service" << EOF +[Unit] +Description=PTT Button Handler +After=pipewire.service pipewire-pulse.service baresip.service +Requires=pipewire-pulse.service +ConditionPathExists=/etc/easy-asterisk/ptt-device + +[Service] +Type=simple +ExecStartPre=/bin/sleep 8 +ExecStart=/usr/local/bin/kiosk-ptt +Restart=always +RestartSec=10 +Environment=XDG_RUNTIME_DIR=/run/user/${KIOSK_UID} +Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${KIOSK_UID}/bus +Environment=KIOSK_UID=${KIOSK_UID} +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target +EOF + + chown -R ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.config" + + if [[ -n "$KIOSK_USER" ]]; then + loginctl enable-linger $KIOSK_USER 2>/dev/null || true + local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}" + + # Enable and start PipeWire services for the user + sudo -u "$KIOSK_USER" $user_dbus systemctl --user daemon-reload + sudo -u "$KIOSK_USER" $user_dbus systemctl --user enable pipewire pipewire-pulse 2>/dev/null || true + sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart pipewire pipewire-pulse 2>/dev/null || true + + # Enable baresip + sudo -u "$KIOSK_USER" $user_dbus systemctl --user enable baresip + + # Only enable PTT if configured + if [[ -f /etc/easy-asterisk/ptt-device ]]; then + sudo -u "$KIOSK_USER" $user_dbus systemctl --user enable kiosk-ptt + sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart baresip kiosk-ptt + else + sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart baresip + # Ensure audio is unmuted for normal kiosk operation + ensure_audio_unmuted + fi + fi +} + +# ================================================================ +# 9. CERTIFICATE HANDLING +# ================================================================ + +check_cert_coverage() { + local cert_file=$1 target_domain=$2 base_domain=$3 + [[ ! -f "$cert_file" ]] && return 1 + local sans=$(openssl x509 -in "$cert_file" -text -noout 2>/dev/null | grep -A1 "Subject Alternative Name" | tail -1) + echo "$sans" | grep -q "DNS:${target_domain}" && return 0 + echo "$sans" | grep -q "DNS:\*.${base_domain}" && return 0 + return 1 +} + +setup_caddy_cert_sync() { + local mode=$1 + [[ "$mode" == "force" ]] && print_header "Caddy Cert Sync" + + load_config + local domain=${DOMAIN_NAME:-sip.example.com} + if [[ "$mode" == "force" ]]; then + read -p "Domain [$domain]: " input_domain + domain="${input_domain:-$domain}" + fi + + local actual_user="${SUDO_USER:-$USER}" + local actual_home=$(eval echo ~"$actual_user") + local base_domain=$(echo "$domain" | awk -F. '{print $(NF-1)"."$NF}') + + local search_paths=( + "${actual_home}/docker/caddy/ssl" + "${actual_home}/docker/caddy/caddy_data" + "${actual_home}/docker/caddy/caddy_data/caddy/certificates/acme-v02.api.letsencrypt.org-directory" + "/var/lib/caddy" + "/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory" + "/data/caddy" + "/root/.local/share/caddy/certificates" + ) + local caddy_cert="" caddy_key="" + + [[ "$mode" == "force" ]] && echo "Searching for certificates..." + + for base_path in "${search_paths[@]}"; do + if ! sudo test -d "$base_path" 2>/dev/null; then continue; fi + [[ "$mode" == "force" ]] && echo " Checking: $base_path" + + local candidates=$(sudo find "$base_path" -maxdepth 5 -type f \( -name "fullchain.pem" -o -name "*.crt" \) 2>/dev/null) + + for cert in $candidates; do + sudo cp "$cert" /tmp/cert_check.pem 2>/dev/null || continue + if check_cert_coverage "/tmp/cert_check.pem" "$domain" "$base_domain"; then + [[ "$mode" == "force" ]] && print_success "Found matching cert: $cert" + caddy_cert="$cert" + local dir=$(dirname "$cert") + local name=$(basename "$cert") + if [[ "$name" == "fullchain.pem" ]]; then + caddy_key="${dir}/privkey.pem" + else + caddy_key=$(echo "$cert" | sed 's/\.crt/\.key/') + fi + if sudo test -f "$caddy_key"; then + rm -f /tmp/cert_check.pem + break 2 + fi + fi + rm -f /tmp/cert_check.pem + done + done + + if [[ -n "$caddy_cert" && -n "$caddy_key" ]]; then + mkdir -p /etc/asterisk/certs + sudo cat "$caddy_cert" > /etc/asterisk/certs/server.crt + sudo cat "$caddy_key" > /etc/asterisk/certs/server.key + + chown asterisk:asterisk /etc/asterisk/certs/server.* + chmod 644 /etc/asterisk/certs/server.crt + chmod 600 /etc/asterisk/certs/server.key + + DOMAIN_NAME="$domain" + ENABLE_TLS="y" + ASTERISK_HOST="$domain" + save_config + + generate_pjsip_conf + restart_asterisk_safe + + [[ "$mode" == "force" ]] && print_success "Certificates installed for $domain" + return 0 + else + [[ "$mode" == "force" ]] && print_warn "No matching certificates found" + return 1 + fi +} + +setup_internet_access() { + print_header "Setup Internet Access" + + echo "Select Certificate Source:" + echo " 1) Auto-Sync from Caddy (Docker/Native)" + echo " 2) Standalone Certbot (Requires Port 80 open)" + echo " 3) Self-Signed (Internal testing only)" + echo " 4) Manual Path" + echo " 0) Cancel" + read -p "Select: " cert_opt + + [[ "$cert_opt" == "0" ]] && return + + # Show port requirements + show_preflight_check + show_port_requirements + echo "" + read -p "Continue? [Y/n]: " cont + [[ "$cont" =~ ^[Nn]$ ]] && return + + load_config + read -p "FQDN [${DOMAIN_NAME:-sip.example.com}]: " fqdn + DOMAIN_NAME="${fqdn:-${DOMAIN_NAME:-sip.example.com}}" + ASTERISK_HOST="$DOMAIN_NAME" + + echo "" + echo "Do you have a separate domain for TURN? (e.g., turn.example.com)" + read -p "Enter TURN domain (leave empty to use $DOMAIN_NAME): " t_dom + TURN_DOMAIN="${t_dom:-$DOMAIN_NAME}" + + # CIDR Prompt + echo "" + print_header "Local Network CIDR" + local raw_cidr=$(ip -o -f inet addr show | awk '/scope global/ {print $4}' | head -1) + local default_cidr="$raw_cidr" + if [[ "$raw_cidr" =~ \.([0-9]+)/24$ ]]; then default_cidr="${raw_cidr%.*}.0/24"; fi + echo "This helps Asterisk distinguish local vs external traffic." + read -p "Local network CIDR [$default_cidr]: " local_net + LOCAL_CIDR="${local_net:-$default_cidr}" + + save_config + + case "$cert_opt" in + 1) # Caddy + # Show Caddy Helper text + echo "---------------------------------------------------------" + echo "CADDY HELPER: Ensure these are in your Caddyfile to get certs:" + echo "" + echo "${DOMAIN_NAME} {" + echo " respond \"Asterisk Cert Placeholder\" 200" + echo "}" + if [[ "$TURN_DOMAIN" != "$DOMAIN_NAME" ]]; then + echo "" + echo "${TURN_DOMAIN} {" + echo " respond \"TURN Cert Placeholder\" 200" + echo "}" + fi + echo "" + echo "Restart Caddy, wait 30s, then press Enter." + echo "---------------------------------------------------------" + read -p "Press Enter to sync..." + if setup_caddy_cert_sync "auto"; then + print_success "Setup complete using Caddy certificates!" + else + print_error "Caddy sync failed. Ensure Caddy is running." + return + fi + ;; + 2) # Certbot + print_info "Installing Certbot..." + apt install -y certbot + certbot certonly --standalone -d "$DOMAIN_NAME" --non-interactive --agree-tos --register-unsafely-without-email + if [[ -f "/etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem" ]]; then + mkdir -p /etc/asterisk/certs + cat "/etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem" > /etc/asterisk/certs/server.crt + cat "/etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem" > /etc/asterisk/certs/server.key + chown asterisk:asterisk /etc/asterisk/certs/server.* + print_success "Certbot Success" + else + print_error "Certbot failed" + return + fi + ;; + 3) # Self-Signed + mkdir -p /etc/asterisk/certs + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout /etc/asterisk/certs/server.key \ + -out /etc/asterisk/certs/server.crt \ + -subj "/CN=$DOMAIN_NAME" 2>/dev/null + chown asterisk:asterisk /etc/asterisk/certs/server.* + chmod 644 /etc/asterisk/certs/server.crt + chmod 600 /etc/asterisk/certs/server.key + print_success "Self-signed certificate generated" + print_warn "Clients will need to trust this certificate" + ;; + 4) # Manual + read -p "Certificate Path: " cp + read -p "Private Key Path: " kp + if [[ -f "$cp" && -f "$kp" ]]; then + mkdir -p /etc/asterisk/certs + cat "$cp" > /etc/asterisk/certs/server.crt + cat "$kp" > /etc/asterisk/certs/server.key + chown asterisk:asterisk /etc/asterisk/certs/server.* + print_success "Certificates installed" + else + print_error "Files not found!" + return + fi + ;; + esac + + ENABLE_TLS="y" + save_config + generate_pjsip_conf + restart_asterisk_safe + print_success "Internet access configuration complete" +} + +# ================================================================ +# 10. INSTALLATION +# ================================================================ + +install_full() { + if is_docker; then + # In Docker: server is pre-installed, just configure + print_header "Server Configuration" + install_asterisk_packages + configure_asterisk + INSTALLED_SERVER="y" + ENABLE_TLS="n" + save_config + print_success "Server configured" + return + fi + + print_header "Full Installation" + local default_user="${SUDO_USER:-$USER}" + read -p "Client User [$default_user]: " target_user + KIOSK_USER="${target_user:-$default_user}" + KIOSK_UID=$(id -u "$KIOSK_USER") + + if ! collect_common_config; then return; fi + collect_client_config + install_dependencies + INSTALLED_SERVER="y" + INSTALLED_CLIENT="y" + ENABLE_TLS="n" # LAN-only by default, set to "y" only if internet/certs setup is run + configure_asterisk + configure_baresip + enable_client_services + open_firewall_ports + save_config + + echo "" + echo "════════════════════════════════════════════════════════" + print_success "Local network install complete" + echo "" + echo "Server and devices are reachable over internal LAN network only." + echo "To add internet calling capability, continue with the setup below." + echo "════════════════════════════════════════════════════════" + echo "" + read -p "Run Internet/Certificate Setup wizard now? [Y/n]: " run_setup + [[ ! "$run_setup" =~ ^[Nn]$ ]] && setup_internet_access + + print_success "Installation complete" +} + +install_server_only() { + if is_docker; then + install_full + return + fi + + print_header "Server Installation" + ASTERISK_HOST="127.0.0.1" + ENABLE_TLS="n" # LAN-only by default, set to "y" only if internet/certs setup is run + install_asterisk_packages + configure_asterisk + open_firewall_ports + INSTALLED_SERVER="y" + save_config + + echo "" + echo "════════════════════════════════════════════════════════" + print_success "Local network install complete" + echo "" + echo "Server and devices are reachable over internal LAN network only." + echo "To add internet calling capability, continue with the setup below." + echo "════════════════════════════════════════════════════════" + echo "" + read -p "Run Internet/Certificate Setup wizard now? [Y/n]: " run_setup + [[ ! "$run_setup" =~ ^[Nn]$ ]] && setup_internet_access + + print_success "Server installed" +} + +install_client_only() { + print_header "Client Installation" + echo "Select the user to install the kiosk client for:" + echo "" + + if ! select_user; then + print_error "User selection failed" + return 1 + fi + + echo "" + read -p "Server (IP or domain): " ASTERISK_HOST + read -p "SIP Password: " SIP_PASSWORD + + if [[ "$ASTERISK_HOST" =~ [a-zA-Z] ]]; then + ENABLE_TLS="y" + else + ENABLE_TLS="n" + fi + + echo "" + echo "Answer Mode:" + echo " 1) Auto (auto-answer incoming calls)" + echo " 2) Manual (ring on incoming)" + read -p "Select [1]: " aa_sel + CLIENT_ANSWERMODE="auto" + [[ "$aa_sel" == "2" ]] && CLIENT_ANSWERMODE="manual" + + collect_client_config + install_baresip_packages + INSTALLED_CLIENT="y" + configure_baresip + enable_client_services + save_config + + print_success "Client installed" + echo "" + echo "════════════════════════════════════════════════════════" + echo " IMPORTANT: Audio Configuration" + echo "════════════════════════════════════════════════════════" + echo " User: $KIOSK_USER" + echo " - Audio group: Added" + echo " - PipeWire services: Enabled" + echo " - Microphone: Unmuted (for intercom mode)" + echo "" + echo " If audio doesn't work immediately:" + echo " 1. Log out and log back in as '$KIOSK_USER'" + echo " 2. Or reboot the system" + echo " 3. Check audio with: pactl list sources short" + echo "" + echo " PTT Mode: Not configured (normal intercom operation)" + echo " To configure PTT: Main Menu > Client Management > Configure PTT Button" + echo "════════════════════════════════════════════════════════" +} + +collect_common_config() { + SIP_PASSWORD="${SIP_PASSWORD:-$(generate_password)}" + ASTERISK_HOST="127.0.0.1" + return 0 +} + +collect_client_config() { + read -p "Extension [101]: " KIOSK_EXTENSION + KIOSK_EXTENSION="${KIOSK_EXTENSION:-101}" + KIOSK_NAME="kiosk-${KIOSK_EXTENSION}" +} + +install_dependencies() { + install_asterisk_packages + if ! is_docker; then + install_baresip_packages + fi +} + +install_asterisk_packages() { + if is_docker; then + # In Docker, packages are pre-installed via Dockerfile + print_info "Docker mode: packages pre-installed" + mkdir -p /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk + return + fi + echo "exit 101" > /usr/sbin/policy-rc.d + chmod +x /usr/sbin/policy-rc.d + apt update + # asterisk-opus removed (included in asterisk-modules on Ubuntu 24.04+) + apt install -y asterisk asterisk-core-sounds-en-gsm asterisk-modules openssl curl tcpdump sngrep || true + mkdir -p /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk + ldconfig + update-ca-certificates 2>/dev/null || true + rm -f /usr/sbin/policy-rc.d + fix_asterisk_systemd +} + +install_baresip_packages() { + if is_docker; then + # Baresip (local SIP client) is not used inside the container + print_info "Docker mode: Baresip not applicable (use mobile/desktop SIP clients)" + return + fi + apt update + apt install -y baresip baresip-core pipewire pipewire-alsa pipewire-pulse wireplumber alsa-utils evtest || true +} + +uninstall_menu() { + if is_docker; then + print_header "Reset Configuration" + echo " In Docker, the container is ephemeral." + echo " To fully uninstall: docker compose down -v" + echo "" + echo " 1) Reset all configs (keep container)" + echo " 2) Reset devices only" + echo " 0) Cancel" + read -p "Select: " ch + case $ch in + 1) + rm -rf /etc/easy-asterisk/* + print_success "Configuration reset. Restart container to regenerate defaults." + ;; + 2) + if [[ -f /etc/asterisk/pjsip.conf ]]; then + # Remove device sections, keep transport config + local temp="/tmp/pjsip_base_$$.conf" + awk '/^; === Device:/{exit} {print}' /etc/asterisk/pjsip.conf > "$temp" + mv "$temp" /etc/asterisk/pjsip.conf + chown asterisk:asterisk /etc/asterisk/pjsip.conf + asterisk -rx "pjsip reload" >/dev/null 2>&1 || true + fi + print_success "All devices removed" + ;; + esac + return + fi + + print_header "Uninstall" + echo " 1) Remove Everything" + echo " 2) Asterisk Only" + echo " 3) Baresip Only" + echo " 0) Cancel" + read -p "Select: " ch + case $ch in + 1) + systemctl stop asterisk 2>/dev/null || true + apt purge -y asterisk* baresip baresip-core 2>/dev/null || true + rm -rf /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /usr/lib/asterisk + rm -rf /etc/systemd/system/asterisk.service.d /etc/easy-asterisk + [[ -n "$KIOSK_USER" ]] && rm -rf "/home/${KIOSK_USER}/.baresip" + systemctl daemon-reload + INSTALLED_SERVER="n" + INSTALLED_CLIENT="n" + rm -f "$CONFIG_FILE" + print_success "Removed all" + ;; + 2) + systemctl stop asterisk 2>/dev/null || true + apt purge -y asterisk* 2>/dev/null || true + rm -rf /etc/asterisk /var/lib/asterisk + INSTALLED_SERVER="n" + save_config + print_success "Removed Asterisk" + ;; + 3) + apt purge -y baresip baresip-core 2>/dev/null || true + [[ -n "$KIOSK_USER" ]] && rm -rf "/home/${KIOSK_USER}/.baresip" + INSTALLED_CLIENT="n" + save_config + print_success "Removed Baresip" + ;; + esac +} + +# ================================================================ +# 11. MENU SYSTEM (Reordered: Server #2, Devices #3) +# ================================================================ + +show_main_menu() { + clear + print_header "Easy Asterisk v${SCRIPT_VERSION}" + + load_config + + if is_docker; then + # Docker status display + echo " Status:" + echo -e " Mode: ${CYAN}Docker Container${NC}" + if asterisk_running; then + echo -e " Asterisk: ${GREEN}Running${NC}" + else + echo -e " Asterisk: ${RED}Not running${NC}" + fi + if webadmin_running; then + echo -e " Web Admin: ${GREEN}Running${NC} (port ${WEB_ADMIN_PORT})" + else + echo -e " Web Admin: ${YELLOW}Stopped${NC}" + fi + [[ -n "$DOMAIN_NAME" ]] && echo -e " Domain: ${DOMAIN_NAME}" + if [[ "$TURN_ENABLED" == "y" ]]; then + echo -e " TURN: ${GREEN}Enabled${NC} (${TURN_SERVER:-auto})" + elif [[ "$VPN_ICE_ENABLED" == "y" ]]; then + echo -e " STUN/ICE: ${GREEN}Enabled${NC} (${CUSTOM_STUN_SERVER:-auto})" + fi + echo "" + + declare -A menu_map + local count=1 + + if [[ "$INSTALLED_SERVER" != "y" ]]; then + echo " ${count}) Configure Server"; menu_map[$count]="submenu_install"; ((count++)) + fi + echo " ${count}) Server Settings"; menu_map[$count]="submenu_server"; ((count++)) + echo " ${count}) Device Management"; menu_map[$count]="submenu_devices"; ((count++)) + echo " ${count}) Tools"; menu_map[$count]="submenu_tools"; ((count++)) + echo " 0) Exit" + else + # Bare metal status display + echo " Status:" + if [[ -f "$CONFIG_FILE" ]]; then + [[ "$INSTALLED_SERVER" == "y" ]] && echo -e " Server: ${GREEN}Installed${NC}" || echo -e " Server: ${YELLOW}Not installed${NC}" + [[ "$INSTALLED_CLIENT" == "y" ]] && echo -e " Client: ${GREEN}Installed${NC}" || echo -e " Client: ${YELLOW}Not installed${NC}" + [[ -n "$DOMAIN_NAME" ]] && echo -e " Domain: ${DOMAIN_NAME}" + else + echo -e " ${YELLOW}Not configured${NC}" + fi + echo "" + + declare -A menu_map + local count=1 + + echo " ${count}) Install/Configure"; menu_map[$count]="submenu_install"; ((count++)) + if [[ "$INSTALLED_SERVER" == "y" ]]; then + echo " ${count}) Server Settings"; menu_map[$count]="submenu_server"; ((count++)) + echo " ${count}) Device Management"; menu_map[$count]="submenu_devices"; ((count++)) + fi + echo " ${count}) Client Settings"; menu_map[$count]="submenu_client"; ((count++)) + echo " ${count}) Tools"; menu_map[$count]="submenu_tools"; ((count++)) + echo " 0) Exit" + fi + echo "" + + read -p " Select: " choice + [[ "$choice" == "0" ]] && exit 0 + local action=${menu_map[$choice]} + [[ -n "$action" ]] && $action + show_main_menu +} + +submenu_install() { + if is_docker; then + clear + print_header "Configure Server" + echo " 1) Configure/Reconfigure Server" + echo " 2) Reset Configuration" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) install_full; read -p "Press Enter..." ;; + 2) uninstall_menu; read -p "Press Enter..." ;; + esac + return + fi + + clear + print_header "Install" + echo " 1) Full (server + client)" + echo " 2) Server only" + echo " 3) Client only" + echo " 4) Uninstall" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) install_full; read -p "Press Enter..." ;; + 2) install_server_only; read -p "Press Enter..." ;; + 3) install_client_only; read -p "Press Enter..." ;; + 4) uninstall_menu; read -p "Press Enter..." ;; + esac +} + +# ================================================================ +# WEB ADMIN INTERFACE +# ================================================================ + +# WEB_ADMIN_PORT is set in load_config (default: 8080) +WEB_ADMIN_SCRIPT="/usr/local/bin/easy-asterisk-webadmin" +WEB_ADMIN_SERVICE="/etc/systemd/system/easy-asterisk-webadmin.service" +WEB_ADMIN_HTPASSWD="/etc/easy-asterisk/webadmin.htpasswd" + +create_web_admin_script() { + cat > "$WEB_ADMIN_SCRIPT" << 'WEBADMIN' +#!/usr/bin/env python3 +""" +Easy Asterisk Web Admin - Simple web interface for client management +""" + +import http.server +import socketserver +import json +import subprocess +import os +import re +import base64 +import hashlib +import html +from urllib.parse import parse_qs, urlparse +from functools import partial + +PORT = int(os.environ.get('WEBADMIN_PORT', 8080)) +AUTH_DISABLED = os.environ.get('WEBADMIN_AUTH_DISABLED', 'false').lower() == 'true' +HTPASSWD_FILE = "/etc/easy-asterisk/webadmin.htpasswd" +PJSIP_CONF = "/etc/asterisk/pjsip.conf" +CATEGORIES_FILE = "/etc/easy-asterisk/categories.conf" +ROOMS_FILE = "/etc/easy-asterisk/rooms.conf" +CONFIG_FILE = "/etc/easy-asterisk/config" + +def check_auth(headers): + """Verify HTTP Basic Auth against htpasswd file""" + if AUTH_DISABLED: + return True # Auth disabled for reverse proxy mode + + if not os.path.exists(HTPASSWD_FILE): + return True # No auth required if no htpasswd file + + auth_header = headers.get('Authorization', '') + if not auth_header.startswith('Basic '): + return False + + try: + credentials = base64.b64decode(auth_header[6:]).decode('utf-8') + username, password = credentials.split(':', 1) + + with open(HTPASSWD_FILE, 'r') as f: + for line in f: + line = line.strip() + if ':' in line: + stored_user, stored_hash = line.split(':', 1) + if stored_user == username: + # Support plain text (for simplicity) or SHA256 + if stored_hash.startswith('{SHA256}'): + expected = '{SHA256}' + hashlib.sha256(password.encode()).hexdigest() + return stored_hash == expected + else: + return stored_hash == password + return False + except: + return False + +def get_registered_endpoints(): + """Get list of registered endpoints from Asterisk - matches bash script logic""" + try: + # Get full endpoint details which shows Contact lines with Avail status + result = subprocess.run( + ['asterisk', '-rx', 'pjsip show endpoints'], + capture_output=True, text=True, timeout=10 + ) + endpoints = {} + current_endpoint = None + + for line in result.stdout.split('\n'): + # Match endpoint header line: " Endpoint: 101/101" + endpoint_match = re.match(r'\s*Endpoint:\s+(\d+)/', line) + if endpoint_match: + current_endpoint = endpoint_match.group(1) + endpoints[current_endpoint] = 'offline' # Default to offline + + # Match contact line with Avail status: " Contact: 101/sip:... Avail" + if current_endpoint and 'Contact:' in line: + if 'Avail' in line or 'NonQual' in line: + endpoints[current_endpoint] = 'online' + + return endpoints + except: + return {} + +def get_devices(): + """Parse pjsip.conf to get device information - matches bash script logic""" + devices = [] + if not os.path.exists(PJSIP_CONF): + return devices + + with open(PJSIP_CONF, 'r') as f: + lines = f.readlines() + + dev_name = None + dev_cat = None + dev_aa = None + + for line in lines: + line = line.strip() + + # Match device comment line + if '; === Device:' in line: + # Parse: ; === Device: Name (category) [AA:yes/no] === + temp = line.split('; === Device:')[1] if '; === Device:' in line else '' + temp = temp.split('===')[0].strip() # Remove trailing === + + # Check for AA tag + dev_aa = None + if '[AA:yes]' in temp: + dev_aa = 'yes' + temp = temp.replace('[AA:yes]', '').strip() + elif '[AA:no]' in temp: + dev_aa = 'no' + temp = temp.replace('[AA:no]', '').strip() + + # Extract category from parentheses + if '(' in temp and ')' in temp: + dev_cat = temp[temp.rfind('(')+1:temp.rfind(')')] + dev_name = temp[:temp.rfind('(')].strip() + else: + dev_name = temp + dev_cat = 'unknown' + + # Match extension line [xxx] + elif dev_name and re.match(r'^\[(\d+)\]$', line): + ext = re.match(r'^\[(\d+)\]$', line).group(1) + devices.append({ + 'name': dev_name, + 'category': dev_cat, + 'extension': ext, + 'auto_answer': dev_aa, + 'transport': 'udp', # Default, will check below + 'encryption': 'no' + }) + dev_name = None + dev_cat = None + dev_aa = None + + # Update transport/encryption for last added device + elif devices and line.startswith('transport=transport-'): + devices[-1]['transport'] = line.split('transport-')[1] + elif devices and line.startswith('media_encryption='): + val = line.split('=')[1] + if val == 'sdes' or val == 'dtls': + devices[-1]['encryption'] = val + # If encryption is set but no explicit transport, assume TLS + if devices[-1]['transport'] == 'udp': + devices[-1]['transport'] = 'tls' + elif val != 'no': + devices[-1]['encryption'] = val + + return devices + +def get_categories(): + """Get categories from config file""" + categories = [] + if os.path.exists(CATEGORIES_FILE): + with open(CATEGORIES_FILE, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + parts = line.split('|') + if len(parts) >= 3: + categories.append({ + 'id': parts[0], + 'name': parts[1], + 'auto_answer': parts[2], + 'description': parts[3] if len(parts) > 3 else '' + }) + return categories + +def get_rooms(): + """Get rooms from config file""" + rooms = [] + if os.path.exists(ROOMS_FILE): + with open(ROOMS_FILE, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + parts = line.split('|') + if len(parts) >= 5: + rooms.append({ + 'extension': parts[0], + 'name': parts[1], + 'members': parts[2], + 'timeout': parts[3], + 'type': parts[4] + }) + return rooms + +def delete_device(extension): + """Delete a device from pjsip.conf""" + if not os.path.exists(PJSIP_CONF): + return False, "Config file not found" + + with open(PJSIP_CONF, 'r') as f: + lines = f.readlines() + + new_lines = [] + skip = False + found = False + pending_comment = None + + for line in lines: + stripped = line.strip() + + if stripped.startswith('; === Device:'): + pending_comment = line + continue + + if re.match(rf'^\[{extension}\]$', stripped): + if pending_comment: + found = True + skip = True + pending_comment = None + continue + elif found: + skip = True + continue + + if pending_comment: + new_lines.append(pending_comment) + pending_comment = None + + if skip and stripped == '': + skip = False + continue + + if not skip: + new_lines.append(line) + + if found: + with open(PJSIP_CONF, 'w') as f: + f.writelines(new_lines) + subprocess.run(['asterisk', '-rx', 'pjsip reload'], capture_output=True) + return True, "Device deleted" + return False, "Device not found" + +def rename_device(extension, new_name): + """Rename a device in pjsip.conf""" + if not os.path.exists(PJSIP_CONF): + return False, "Config file not found" + + with open(PJSIP_CONF, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + in_device = False + device_ext = None + + for line in lines: + stripped = line.strip() + + # Match device comment and update name + if stripped.startswith('; === Device:'): + # Parse the comment to get category and AA tag + temp = stripped.split('; === Device:')[1].split('===')[0].strip() + aa_tag = '' + if '[AA:yes]' in temp: + aa_tag = ' [AA:yes]' + temp = temp.replace('[AA:yes]', '').strip() + elif '[AA:no]' in temp: + aa_tag = ' [AA:no]' + temp = temp.replace('[AA:no]', '').strip() + + if '(' in temp: + cat = temp[temp.rfind('(')+1:temp.rfind(')')] + else: + cat = 'unknown' + + # Store for next line check + pending_comment = (line, cat, aa_tag) + continue + + # Check if this is the extension we want + if 'pending_comment' in dir() and pending_comment: + match = re.match(r'^\[(\d+)\]$', stripped) + if match and match.group(1) == extension: + # This is our device - write updated comment + old_line, cat, aa_tag = pending_comment + new_lines.append(f'; === Device: {new_name} ({cat}){aa_tag} ===\n') + new_lines.append(line) + found = True + in_device = True + device_ext = extension + pending_comment = None + continue + else: + # Not our device, write original comment + new_lines.append(pending_comment[0]) + pending_comment = None + + # Update callerid line + if in_device and stripped.startswith('callerid='): + new_lines.append(f'callerid="{new_name}" <{device_ext}>\n') + continue + + # Reset on empty line after device + if in_device and stripped == '': + in_device = False + + new_lines.append(line) + + if found: + with open(PJSIP_CONF, 'w') as f: + f.writelines(new_lines) + subprocess.run(['asterisk', '-rx', 'pjsip reload'], capture_output=True) + return True, "Device renamed" + return False, "Device not found" + +def update_room_members(room_ext, new_members): + """Update room members""" + if not os.path.exists(ROOMS_FILE): + return False, "Rooms file not found" + + # Read all rooms + rooms = [] + found = False + with open(ROOMS_FILE, 'r') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + rooms.append(line) + continue + parts = line.split('|') + if len(parts) >= 5 and parts[0] == room_ext: + # Update this room's members + parts[2] = new_members + rooms.append('|'.join(parts)) + found = True + else: + rooms.append(line) + + if found: + with open(ROOMS_FILE, 'w') as f: + f.write('\n'.join(rooms) + '\n') + # Rebuild dialplan + subprocess.run(['/usr/local/bin/easy-asterisk', '--rebuild-dialplan'], capture_output=True) + return True, "Room members updated" + return False, "Room not found" + +def add_device_to_room(room_ext, device_ext): + """Add a device to a room""" + if not os.path.exists(ROOMS_FILE): + return False, "Rooms file not found" + + # Find the room and its current members + with open(ROOMS_FILE, 'r') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + parts = line.split('|') + if len(parts) >= 5 and parts[0] == room_ext: + current_members = parts[2].split(',') if parts[2] else [] + # Check if device is already a member + if device_ext in current_members: + return False, "Device already in room" + current_members.append(device_ext) + new_members = ','.join(current_members) + return update_room_members(room_ext, new_members) + return False, "Room not found" + +def remove_device_from_room(room_ext, device_ext): + """Remove a device from a room""" + if not os.path.exists(ROOMS_FILE): + return False, "Rooms file not found" + + # Find the room and its current members + with open(ROOMS_FILE, 'r') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + parts = line.split('|') + if len(parts) >= 5 and parts[0] == room_ext: + current_members = parts[2].split(',') if parts[2] else [] + # Check if device is a member + if device_ext not in current_members: + return False, "Device not in room" + current_members.remove(device_ext) + new_members = ','.join(current_members) + return update_room_members(room_ext, new_members) + return False, "Room not found" + +def change_device_category(extension, new_category): + """Change a device's category in pjsip.conf""" + if not os.path.exists(PJSIP_CONF): + return False, "Config file not found" + + with open(PJSIP_CONF, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + in_device = False + + for line in lines: + stripped = line.strip() + + # Match device comment line: ; === Device: Name (category) === + if stripped.startswith('; === Device:') and f'[{extension}]' in ''.join(lines[lines.index(line):lines.index(line)+3]): + # Parse and update category in comment + match = re.match(r'^; === Device: (.+?) \(([^)]+)\)(.*?)===', stripped) + if match: + name = match.group(1) + old_cat = match.group(2) + rest = match.group(3) + new_lines.append(f'; === Device: {name} ({new_category}){rest}===\n') + found = True + in_device = True + continue + + new_lines.append(line) + + if found: + with open(PJSIP_CONF, 'w') as f: + f.writelines(new_lines) + subprocess.run(['asterisk', '-rx', 'pjsip reload'], capture_output=True) + return True, "Category changed" + return False, "Device not found" + +def create_room(extension, name, room_type='ring', timeout='60'): + """Create a new room in rooms.conf""" + if not os.path.exists(ROOMS_FILE): + with open(ROOMS_FILE, 'w') as f: + f.write('# Format: ext|name|members|timeout|type(ring/page)\n') + + # Check if extension already exists + with open(ROOMS_FILE, 'r') as f: + for line in f: + if line.strip() and not line.startswith('#'): + parts = line.split('|') + if len(parts) >= 1 and parts[0] == extension: + return False, "Room extension already exists" + + # Add new room + with open(ROOMS_FILE, 'a') as f: + f.write(f'{extension}|{name}||{timeout}|{room_type}\n') + + # Rebuild dialplan + subprocess.run(['/usr/local/bin/easy-asterisk', '--rebuild-dialplan'], capture_output=True) + return True, "Room created" + +def delete_room(extension): + """Delete a room from rooms.conf""" + if not os.path.exists(ROOMS_FILE): + return False, "Rooms file not found" + + with open(ROOMS_FILE, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith('#'): + parts = stripped.split('|') + if len(parts) >= 1 and parts[0] == extension: + found = True + continue + new_lines.append(line) + + if found: + with open(ROOMS_FILE, 'w') as f: + f.writelines(new_lines) + subprocess.run(['/usr/local/bin/easy-asterisk', '--rebuild-dialplan'], capture_output=True) + return True, "Room deleted" + return False, "Room not found" + +def rename_room(extension, new_name): + """Rename a room in rooms.conf""" + if not os.path.exists(ROOMS_FILE): + return False, "Rooms file not found" + + with open(ROOMS_FILE, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith('#'): + parts = stripped.split('|') + if len(parts) >= 5 and parts[0] == extension: + # Update name (parts[1]) + parts[1] = new_name + new_lines.append('|'.join(parts) + '\n') + found = True + continue + new_lines.append(line) + + if found: + with open(ROOMS_FILE, 'w') as f: + f.writelines(new_lines) + subprocess.run(['/usr/local/bin/easy-asterisk', '--rebuild-dialplan'], capture_output=True) + return True, "Room renamed" + return False, "Room not found" + +def create_category(cat_id, name, auto_answer='', description=''): + """Create a new category in categories.conf""" + if not os.path.exists(CATEGORIES_FILE): + with open(CATEGORIES_FILE, 'w') as f: + f.write('# Format: id|name|auto_answer|description\n') + + # Check if category already exists + with open(CATEGORIES_FILE, 'r') as f: + for line in f: + if line.strip() and not line.startswith('#'): + parts = line.split('|') + if len(parts) >= 1 and parts[0] == cat_id: + return False, "Category ID already exists" + + # Add new category + with open(CATEGORIES_FILE, 'a') as f: + f.write(f'{cat_id}|{name}|{auto_answer}|{description}\n') + + return True, "Category created" + +def delete_category(cat_id): + """Delete a category from categories.conf""" + if not os.path.exists(CATEGORIES_FILE): + return False, "Categories file not found" + + with open(CATEGORIES_FILE, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith('#'): + parts = stripped.split('|') + if len(parts) >= 1 and parts[0] == cat_id: + found = True + continue + new_lines.append(line) + + if found: + with open(CATEGORIES_FILE, 'w') as f: + f.writelines(new_lines) + return True, "Category deleted" + return False, "Category not found" + +def rename_category(cat_id, new_name): + """Rename a category in categories.conf""" + if not os.path.exists(CATEGORIES_FILE): + return False, "Categories file not found" + + with open(CATEGORIES_FILE, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith('#'): + parts = stripped.split('|') + if len(parts) >= 2 and parts[0] == cat_id: + # Update name (parts[1]) + parts[1] = new_name + new_lines.append('|'.join(parts) + '\n') + found = True + continue + new_lines.append(line) + + if found: + with open(CATEGORIES_FILE, 'w') as f: + f.writelines(new_lines) + return True, "Category renamed" + return False, "Category not found" + +def generate_password(length=16): + """Generate a random password""" + import secrets + import string + chars = string.ascii_letters + string.digits + return ''.join(secrets.choice(chars) for _ in range(length)) + +def add_device(name, category, extension, conn_type='lan', auto_answer=None): + """Add a new device to pjsip.conf""" + if not os.path.exists(PJSIP_CONF): + return False, "Config file not found" + + # Check if extension exists + with open(PJSIP_CONF, 'r') as f: + if f'[{extension}]' in f.read(): + return False, "Extension already exists" + + password = generate_password() + + # Determine transport and encryption + # Check if VPN ICE mode is enabled (for third-party VPNs) + vpn_ice = 'n' + turn_enabled = 'n' + is_container = os.path.exists('/.dockerenv') + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, 'r') as cf: + for cline in cf: + if cline.startswith('VPN_ICE_ENABLED='): + vpn_ice = cline.strip().split('=', 1)[1].strip('"') + elif cline.startswith('TURN_ENABLED='): + turn_enabled = cline.strip().split('=', 1)[1].strip('"') + + # In Docker: always use FQDN mode for web-created devices + if is_container and conn_type == 'lan': + conn_type = 'fqdn' + + if conn_type == 'fqdn': + transport = 'transport=transport-tls' + encryption = 'media_encryption=sdes' + ice = 'ice_support=yes' + else: + transport = 'transport=transport-udp' + encryption = 'media_encryption=no' + ice = 'ice_support=yes' if (vpn_ice == 'y' or turn_enabled == 'y') else '' + + aa_tag = '' + if auto_answer == 'yes': + aa_tag = '[AA:yes] ' + elif auto_answer == 'no': + aa_tag = '[AA:no] ' + + # Mobile devices get keepalive settings for NAT traversal + keepalive = '' + if category == 'mobile': + keepalive = 'rtp_keepalive=15\nrtp_timeout=120\nrtp_timeout_hold=120' + + device_config = f''' +; === Device: {name} ({category}) {aa_tag}=== +[{extension}] +type=endpoint +context=intercom +{transport} +disallow=all +allow=opus +allow=ulaw +allow=alaw +allow=g722 +{encryption} +direct_media=no +rtp_symmetric=yes +force_rport=yes +rewrite_contact=yes +{keepalive} +{ice} +auth={extension} +aors={extension} +callerid="{name}" <{extension}> + +[{extension}] +type=auth +auth_type=userpass +username={extension} +password={password} + +[{extension}] +type=aor +max_contacts=5 +remove_existing=yes +qualify_frequency=30 +''' + + with open(PJSIP_CONF, 'a') as f: + f.write(device_config) + + subprocess.run(['asterisk', '-rx', 'pjsip reload'], capture_output=True) + subprocess.run(['chown', 'asterisk:asterisk', PJSIP_CONF], capture_output=True) + + return True, {'extension': extension, 'password': password, 'name': name} + +def get_server_info(): + """Get server configuration info including TURN/STUN details""" + info = { + 'domain': '', + 'tls_enabled': False, + 'server_ip': '', + 'turn_enabled': False, + 'turn_server': '', + 'turn_username': '', + 'turn_password': '' + } + + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, 'r') as f: + for line in f: + line = line.strip() + if line.startswith('DOMAIN_NAME='): + info['domain'] = line.split('=', 1)[1].strip().strip('"') + elif line.startswith('ENABLE_TLS='): + info['tls_enabled'] = 'y' in line.lower() + elif line.startswith('TURN_ENABLED='): + info['turn_enabled'] = 'y' in line.split('=', 1)[1].lower() + elif line.startswith('TURN_SERVER='): + info['turn_server'] = line.split('=', 1)[1].strip().strip('"') + elif line.startswith('TURN_USERNAME='): + info['turn_username'] = line.split('=', 1)[1].strip().strip('"') + elif line.startswith('TURN_PASSWORD='): + info['turn_password'] = line.split('=', 1)[1].strip().strip('"') + + try: + result = subprocess.run(['hostname', '-I'], capture_output=True, text=True) + info['server_ip'] = result.stdout.split()[0] if result.stdout else '' + except: + pass + + return info + +HTML_TEMPLATE = ''' + + + + + Easy Asterisk - Client Admin + + + +
+
+

Easy Asterisk - Client Admin

+

Manage SIP clients and extensions

+
+ +
+ +
+
Devices
+
Rooms
+
Categories
+
+ +
+
+
+

Registered Devices

+
+
+ + +
+ + +
+
+
+ + + + + + + + + + + + +
ExtensionNameCategoryTransportStatusActions
+
+
+
+ +
+
+
+

Rooms (Ring/Page Groups)

+
+ + +
+
+
+
+
+ +
+
+
+

Device Categories

+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +''' + +class WebAdminHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass # Suppress default logging + + def send_auth_required(self): + self.send_response(401) + self.send_header('WWW-Authenticate', 'Basic realm="Easy Asterisk Admin"') + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(b'

Authentication Required

') + + def do_GET(self): + if not check_auth(self.headers): + self.send_auth_required() + return + + path = urlparse(self.path).path + + if path == '/' or path == '/clients': + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(HTML_TEMPLATE.encode()) + + elif path == '/api/devices': + devices = get_devices() + self.send_json(devices) + + elif path == '/api/status': + status = get_registered_endpoints() + self.send_json(status) + + elif path == '/api/categories': + categories = get_categories() + self.send_json(categories) + + elif path == '/api/rooms': + rooms = get_rooms() + self.send_json(rooms) + + elif path == '/api/server': + info = get_server_info() + self.send_json(info) + + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if not check_auth(self.headers): + self.send_auth_required() + return + + path = urlparse(self.path).path + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length).decode('utf-8') + + if path == '/api/devices': + try: + data = json.loads(body) + success, result = add_device( + data['name'], + data['category'], + data['extension'], + data.get('conn_type', 'lan'), + data.get('auto_answer') + ) + if success: + self.send_json({'success': True, 'data': result}) + else: + self.send_json({'success': False, 'error': result}, 400) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + + elif path.startswith('/api/rooms/') and path.endswith('/members'): + # Add device to room: POST /api/rooms/{room_ext}/members with {device: ext} + room_match = re.match(r'/api/rooms/(\d+)/members', path) + if room_match: + try: + room_ext = room_match.group(1) + data = json.loads(body) + device_ext = data.get('device') + if not device_ext: + self.send_json({'success': False, 'error': 'Device extension required'}, 400) + return + success, msg = add_device_to_room(room_ext, device_ext) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + else: + self.send_response(404) + self.end_headers() + + elif path == '/api/rooms': + # Create room: POST /api/rooms + try: + data = json.loads(body) + success, msg = create_room( + data['extension'], + data['name'], + data.get('type', 'ring'), + data.get('timeout', '60') + ) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + + elif path == '/api/categories': + # Create category: POST /api/categories + try: + data = json.loads(body) + success, msg = create_category( + data['id'], + data['name'], + data.get('auto_answer', ''), + data.get('description', '') + ) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + + else: + self.send_response(404) + self.end_headers() + + def do_DELETE(self): + if not check_auth(self.headers): + self.send_auth_required() + return + + path = urlparse(self.path).path + + # Delete device + device_match = re.match(r'/api/devices/(\d+)$', path) + if device_match: + ext = device_match.group(1) + success, msg = delete_device(ext) + self.send_json({'success': success, 'message': msg}) + return + + # Remove device from room: DELETE /api/rooms/{room_ext}/members/{device_ext} + room_member_match = re.match(r'/api/rooms/(\d+)/members/(\d+)', path) + if room_member_match: + room_ext = room_member_match.group(1) + device_ext = room_member_match.group(2) + success, msg = remove_device_from_room(room_ext, device_ext) + self.send_json({'success': success, 'message': msg}) + return + + # Delete room: DELETE /api/rooms/{ext} + room_match = re.match(r'/api/rooms/(\d+)$', path) + if room_match: + ext = room_match.group(1) + success, msg = delete_room(ext) + self.send_json({'success': success, 'message': msg}) + return + + # Delete category: DELETE /api/categories/{id} + cat_match = re.match(r'/api/categories/([a-z0-9]+)$', path) + if cat_match: + cat_id = cat_match.group(1) + success, msg = delete_category(cat_id) + self.send_json({'success': success, 'message': msg}) + return + + self.send_response(404) + self.end_headers() + + def do_PUT(self): + if not check_auth(self.headers): + self.send_auth_required() + return + + path = urlparse(self.path).path + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length).decode('utf-8') + + # Change device category: PUT /api/devices/{ext}/category + cat_match = re.match(r'/api/devices/(\d+)/category$', path) + if cat_match: + ext = cat_match.group(1) + try: + data = json.loads(body) + new_cat = data.get('category', '').strip() + if not new_cat: + self.send_json({'success': False, 'error': 'Category required'}, 400) + return + success, msg = change_device_category(ext, new_cat) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + return + + # Rename device: PUT /api/devices/{ext} + rename_match = re.match(r'/api/devices/(\d+)$', path) + if rename_match: + ext = rename_match.group(1) + try: + data = json.loads(body) + new_name = data.get('name', '').strip() + if not new_name: + self.send_json({'success': False, 'error': 'Name required'}, 400) + return + success, msg = rename_device(ext, new_name) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + return + + # Rename room: PUT /api/rooms/{ext} + room_rename_match = re.match(r'/api/rooms/(\d+)$', path) + if room_rename_match: + ext = room_rename_match.group(1) + try: + data = json.loads(body) + new_name = data.get('name', '').strip() + if not new_name: + self.send_json({'success': False, 'error': 'Name required'}, 400) + return + success, msg = rename_room(ext, new_name) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + return + + # Rename category: PUT /api/categories/{id} + cat_rename_match = re.match(r'/api/categories/([a-z0-9]+)$', path) + if cat_rename_match: + cat_id = cat_rename_match.group(1) + try: + data = json.loads(body) + new_name = data.get('name', '').strip() + if not new_name: + self.send_json({'success': False, 'error': 'Name required'}, 400) + return + success, msg = rename_category(cat_id, new_name) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + return + + self.send_response(404) + self.end_headers() + + def send_json(self, data, status=200): + self.send_response(status) + self.send_header('Content-type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + +def main(): + with socketserver.TCPServer(("", PORT), WebAdminHandler) as httpd: + print(f"Easy Asterisk Web Admin running on port {PORT}") + httpd.serve_forever() + +if __name__ == "__main__": + main() +WEBADMIN + chmod +x "$WEB_ADMIN_SCRIPT" + print_success "Web admin script created" +} + +create_web_admin_service() { + if is_docker; then + # In Docker, web admin is managed as a background process, not a systemd service + print_success "Web admin service configured (Docker process mode)" + return + fi + cat > "$WEB_ADMIN_SERVICE" << EOF +[Unit] +Description=Easy Asterisk Web Admin +After=network.target asterisk.service + +[Service] +Type=simple +Environment=WEBADMIN_PORT=${WEB_ADMIN_PORT} +Environment=WEBADMIN_AUTH_DISABLED=${WEB_ADMIN_AUTH_DISABLED:-false} +ExecStart=/usr/bin/python3 ${WEB_ADMIN_SCRIPT} +Restart=on-failure +RestartSec=5 +User=root + +[Install] +WantedBy=multi-user.target +EOF + systemctl daemon-reload + print_success "Web admin service created" +} + +setup_web_admin_auth() { + print_header "Web Admin Authentication" + echo "Set up login credentials for the web admin interface." + echo "" + + read -p "Username [admin]: " wa_user + wa_user="${wa_user:-admin}" + + while true; do + read -s -p "Password: " wa_pass + echo "" + if [[ ${#wa_pass} -lt 6 ]]; then + print_error "Password must be at least 6 characters" + continue + fi + read -s -p "Confirm password: " wa_pass2 + echo "" + if [[ "$wa_pass" != "$wa_pass2" ]]; then + print_error "Passwords don't match" + continue + fi + break + done + + # Store with SHA256 hash + local hash=$(echo -n "$wa_pass" | sha256sum | awk '{print $1}') + echo "${wa_user}:{SHA256}${hash}" > "$WEB_ADMIN_HTPASSWD" + chmod 600 "$WEB_ADMIN_HTPASSWD" + print_success "Authentication configured for user: $wa_user" +} + +web_admin_menu() { + load_config + local server_ip=$(hostname -I | awk '{print $1}') + + print_header "Web Admin Management" + + # Check current status (Docker-aware) + local status="stopped" + if webadmin_running; then + status="running" + fi + + echo " Status: ${status^^}" + if [[ "$status" == "running" ]]; then + echo " URL: http://${server_ip}:${WEB_ADMIN_PORT}/clients" + [[ -n "$DOMAIN_NAME" ]] && echo " URL: http://${DOMAIN_NAME}:${WEB_ADMIN_PORT}/clients" + fi + if [[ "${WEB_ADMIN_AUTH_DISABLED:-}" == "true" ]]; then + echo " Auth: DISABLED (reverse proxy mode)" + else + echo " Auth: Internal basic auth" + fi + echo "" + echo " 1) Start Web Admin" + echo " 2) Stop Web Admin" + echo " 3) Restart Web Admin" + echo " 4) Configure Authentication" + echo " 5) Change Port (current: ${WEB_ADMIN_PORT})" + echo " 6) View Logs" + echo " 7) Reverse Proxy Setup (Caddy)" + echo " 0) Back" + echo "" + read -p " Select: " choice + + case $choice in + 1) + # Stop any existing instance first + stop_webadmin 2>/dev/null + print_info "Installing/updating web admin..." + start_webadmin + if webadmin_running; then + echo "" + echo " Access at: http://${server_ip}:${WEB_ADMIN_PORT}/clients" + [[ -n "$DOMAIN_NAME" ]] && echo " Or: http://${DOMAIN_NAME}:${WEB_ADMIN_PORT}/clients" + fi + ;; + 2) + print_info "Stopping web admin..." + stop_webadmin + if ! is_docker; then + systemctl disable easy-asterisk-webadmin 2>/dev/null || true + fi + ;; + 3) + restart_webadmin + ;; + 4) + setup_web_admin_auth + restart_webadmin + ;; + 5) + read -p "New port [${WEB_ADMIN_PORT}]: " new_port + new_port="${new_port:-$WEB_ADMIN_PORT}" + if [[ "$new_port" =~ ^[0-9]+$ ]] && [[ "$new_port" -ge 1024 ]] && [[ "$new_port" -le 65535 ]]; then + WEB_ADMIN_PORT="$new_port" + save_config + restart_webadmin + print_success "Port changed to $new_port" + else + print_error "Invalid port (must be 1024-65535)" + fi + ;; + 6) + if is_docker; then + echo " In Docker, check logs with: docker logs easy-asterisk" + else + journalctl -u easy-asterisk-webadmin -n 50 --no-pager + fi + ;; + 7) + print_header "Reverse Proxy Setup (Caddy)" + echo "" + echo " When using a reverse proxy like Caddy with HTTPS and its own" + echo " basic auth, you can disable internal authentication." + echo "" + echo " Current auth: $([[ "${WEB_ADMIN_AUTH_DISABLED:-}" == "true" ]] && echo "DISABLED" || echo "ENABLED")" + echo "" + echo " 1) Disable internal auth (for reverse proxy with its own auth)" + echo " 2) Enable internal auth (standalone use)" + echo " 3) Show Caddyfile example" + echo " 0) Back" + echo "" + read -p " Select: " rp_choice + case $rp_choice in + 1) + WEB_ADMIN_AUTH_DISABLED="true" + save_config + create_web_admin_script + restart_webadmin + print_success "Internal auth disabled. Use Caddy basic_auth for security." + ;; + 2) + WEB_ADMIN_AUTH_DISABLED="false" + save_config + create_web_admin_script + if [[ ! -f "$WEB_ADMIN_HTPASSWD" ]]; then + setup_web_admin_auth + fi + restart_webadmin + print_success "Internal auth enabled" + ;; + 3) + echo "" + echo " Add to your Caddyfile (docker-compose):" + echo "" + echo " ─────────────────────────────────────────" + echo " webadmin.yourdomain.com {" + echo " basicauth /* {" + echo " admin \$2a\$14\$... # use: caddy hash-password" + echo " }" + echo " reverse_proxy host.docker.internal:${WEB_ADMIN_PORT}" + echo " }" + echo " ─────────────────────────────────────────" + echo "" + echo " Generate password hash: docker exec -it caddy caddy hash-password" + echo " Then paste the hash after the username in Caddyfile." + echo "" + echo " If Caddy can't reach host.docker.internal, use your server's" + echo " LAN IP instead (e.g., 192.168.1.x:${WEB_ADMIN_PORT})" + echo "" + ;; + esac + ;; + 0) return ;; + esac +} + +configure_vpn_stun_ice() { + load_config + clear + print_header "VPN STUN/ICE Configuration" + + echo " This configures ICE (Interactive Connectivity Establishment) and" + echo " STUN (Session Traversal Utilities for NAT) for third-party VPNs." + echo "" + echo " ─────────────────────────────────────────────────────────────" + echo " When do you need this?" + echo "" + echo " • Your VPN does NAT between endpoints (audio fails or is one-way)" + echo " • Caller and receiver are on different VPN segments" + echo " • Direct VPN routing doesn't work for UDP/RTP traffic" + echo "" + echo " When do you NOT need this?" + echo "" + echo " • VPN gives both sides IPs on the same subnet (direct routing)" + echo " • Audio works fine without STUN" + echo " ─────────────────────────────────────────────────────────────" + echo "" + + local current_stun="${CUSTOM_STUN_SERVER:-Not configured}" + local current_ice="${VPN_ICE_ENABLED:-n}" + echo -e " Current Status:" + echo -e " VPN ICE: $([[ "$current_ice" == "y" ]] && echo "${GREEN}Enabled${NC}" || echo "${YELLOW}Disabled${NC}")" + echo -e " STUN Server: ${CYAN}${current_stun}${NC}" + echo "" + + echo " 1) Enable VPN ICE + self-hosted STUN (recommended for DNS filtering)" + echo " 2) Enable VPN ICE + Google STUN (requires DNS access)" + echo " 3) Enable VPN ICE + custom STUN server" + echo " 4) Disable VPN ICE (standard LAN mode)" + echo " 5) Test current STUN server" + echo " 6) Run VPN diagnostics" + echo " 7) Check DNS whitelist" + echo " 0) Back" + echo "" + read -p " Select: " stun_choice + + case $stun_choice in + 1) + # Self-hosted STUN via coturn + local server_ip=$(hostname -I | awk '{print $1}') + echo "" + echo " Self-hosted STUN uses coturn on this server (port 3478)." + echo " No external DNS dependencies - everything by IP." + echo "" + + # Detect VPN IPs for suggestion + local vpn_ip="" + while IFS= read -r line; do + local iface=$(echo "$line" | awk '{print $2}' | tr -d ':') + local ip_addr=$(echo "$line" | awk '{print $4}' | cut -d'/' -f1) + if [[ "$iface" =~ ^(tun|tap|wg|tailscale|utun|ppp|nordlynx) ]]; then + vpn_ip="$ip_addr" + break + fi + done < <(ip -o -f inet addr show scope global 2>/dev/null) + + local suggested_ip="${vpn_ip:-$server_ip}" + read -p " STUN server IP [${suggested_ip}]: " stun_ip + stun_ip="${stun_ip:-$suggested_ip}" + + read -p " STUN port [3478]: " stun_port + stun_port="${stun_port:-3478}" + + VPN_ICE_ENABLED="y" + CUSTOM_STUN_SERVER="${stun_ip}:${stun_port}" + save_config + repair_core_configs + generate_pjsip_conf + asterisk -rx "core reload" >/dev/null 2>&1 || true + + print_success "VPN ICE enabled with self-hosted STUN: ${CUSTOM_STUN_SERVER}" + echo "" + echo " Make sure coturn is running on port ${stun_port}:" + echo " Docker: docker compose --profile stun up -d" + echo " Manual: apt install coturn && systemctl start coturn" + echo "" + echo " Configure Sipnetic STUN server: ${CUSTOM_STUN_SERVER}" + ;; + 2) + # Google STUN + echo "" + echo -e " ${YELLOW}Requires DNS access to: stun.l.google.com${NC}" + echo " Add this domain to your DNS whitelist on all networks" + echo " (server, caller, and receiver)." + echo "" + read -p " Continue? [y/N]: " confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + VPN_ICE_ENABLED="y" + CUSTOM_STUN_SERVER="stun.l.google.com:19302" + save_config + repair_core_configs + generate_pjsip_conf + asterisk -rx "core reload" >/dev/null 2>&1 || true + print_success "VPN ICE enabled with Google STUN" + echo "" + echo " DNS whitelist required: stun.l.google.com (UDP 19302)" + fi + ;; + 3) + # Custom STUN + echo "" + read -p " STUN server address (host:port): " custom_stun + if [[ -n "$custom_stun" ]]; then + VPN_ICE_ENABLED="y" + CUSTOM_STUN_SERVER="$custom_stun" + save_config + repair_core_configs + generate_pjsip_conf + asterisk -rx "core reload" >/dev/null 2>&1 || true + print_success "VPN ICE enabled with custom STUN: ${custom_stun}" + else + print_error "No STUN server specified" + fi + ;; + 4) + # Disable + VPN_ICE_ENABLED="n" + CUSTOM_STUN_SERVER="" + save_config + repair_core_configs + generate_pjsip_conf + asterisk -rx "core reload" >/dev/null 2>&1 || true + print_success "VPN ICE disabled (standard LAN mode)" + ;; + 5) + # Test STUN + echo "" + if [[ -n "$CUSTOM_STUN_SERVER" ]]; then + local stun_host=$(echo "$CUSTOM_STUN_SERVER" | cut -d: -f1) + local stun_port=$(echo "$CUSTOM_STUN_SERVER" | cut -d: -f2) + stun_port="${stun_port:-3478}" + + echo " Testing STUN server: ${CUSTOM_STUN_SERVER}" + echo "" + + # DNS test + if [[ "$stun_host" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + print_success "STUN server is an IP address (no DNS needed)" + else + if nslookup "$stun_host" >/dev/null 2>&1; then + print_success "DNS resolves: ${stun_host}" + else + print_error "DNS BLOCKED: ${stun_host}" + echo " Add to DNS whitelist or use IP address instead" + fi + fi + + # Connectivity test + if ping -c 2 -W 3 "$stun_host" >/dev/null 2>&1; then + print_success "STUN host reachable: ${stun_host}" + else + print_warn "STUN host not pingable (may still work if ICMP blocked)" + fi + + # Port test via Asterisk + if command -v asterisk &>/dev/null; then + local rtp_check=$(asterisk -rx "rtp show settings" 2>/dev/null | grep -i "stun\|ice" || echo "") + if [[ -n "$rtp_check" ]]; then + echo "" + echo " Asterisk RTP settings:" + echo "$rtp_check" | while IFS= read -r line; do + echo " $line" + done + fi + fi + else + print_warn "No STUN server configured" + echo " Configure one using options 1-3 above" + fi + ;; + 6) + # VPN diagnostics + if command -v vpn-diagnostics &>/dev/null; then + vpn-diagnostics + elif [[ -f /usr/local/bin/vpn-diagnostics ]]; then + bash /usr/local/bin/vpn-diagnostics + else + print_error "vpn-diagnostics not found" + echo " Install: copy scripts/vpn-diagnostics.sh to /usr/local/bin/vpn-diagnostics" + fi + ;; + 7) + # DNS whitelist + if command -v dns-whitelist &>/dev/null; then + dns-whitelist --check + elif [[ -f /usr/local/bin/dns-whitelist ]]; then + bash /usr/local/bin/dns-whitelist --check + else + print_error "dns-whitelist not found" + echo " Install: copy scripts/dns-whitelist.sh to /usr/local/bin/dns-whitelist" + fi + ;; + 0) return ;; + esac +} + +submenu_server() { + clear + print_header "Server Settings" + echo " 1) Setup Internet Access (TLS/Certs/NAT)" + echo " 2) Force re-sync Caddy certs" + echo " 3) Show port/firewall requirements" + echo " 4) Interactive Firewall Guide" + echo " 5) Test SIP connectivity" + echo " 6) Verify CIDR/NAT config" + echo " 7) Watch Live Logs" + echo " 8) Router Doctor" + echo " 9) Configure VLAN/VPN Subnets" + echo " 10) Provisioning Manager" + echo " 11) Web Admin (Client Management)" + echo " 12) VPN STUN/ICE Configuration" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) setup_internet_access ;; + 2) setup_caddy_cert_sync "force" ;; + 3) show_port_requirements ;; + 4) show_firewall_guide ;; + 5) test_sip_connectivity ;; + 6) verify_cidr_config ;; + 7) watch_live_logs ;; + 8) router_doctor ;; + 9) configure_vlan_subnets ;; + 10) provisioning_manager_menu ;; + 11) web_admin_menu ;; + 12) configure_vpn_stun_ice ;; + 0) return ;; + esac + [[ "$choice" != "0" ]] && read -p "Press Enter..." + [[ "$choice" != "0" ]] && submenu_server +} + +submenu_devices() { + clear + print_header "Device Management" + echo " 1) Add device" + echo " 2) Remove device" + echo " 3) Rename device" + echo " 4) List devices" + echo " 5) Manage categories" + echo " 6) Manage rooms" + echo " 7) Export Clients" + echo " 8) Import Clients" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) add_device_menu ;; + 2) remove_device ;; + 3) rename_device ;; + 4) show_registered_devices ;; + 5) manage_categories ;; + 6) manage_rooms ;; + 7) export_clients ;; + 8) import_clients ;; + 0) return ;; + esac + [[ "$choice" != "0" ]] && read -p "Press Enter..." + [[ "$choice" != "0" ]] && submenu_devices +} + +export_clients() { + print_header "Export Client Configurations" + load_config + initialize_default_categories + + # Create export directory + local timestamp=$(date +%Y%m%d_%H%M%S) + local export_dir="/tmp/asterisk_export_${timestamp}" + local export_file="/root/asterisk-clients-${timestamp}.tar.gz" + + mkdir -p "$export_dir" + + # Check if there are any devices to export + if ! grep -q "^; === Device:" /etc/asterisk/pjsip.conf 2>/dev/null; then + print_error "No client devices found to export" + rm -rf "$export_dir" + return + fi + + # Export devices from pjsip.conf (everything after transport definitions) + echo "Extracting client devices..." + awk '/^; === Device:/{flag=1} flag' /etc/asterisk/pjsip.conf > "$export_dir/devices.conf" + + # Count devices + local device_count=$(grep -c "^; === Device:" "$export_dir/devices.conf") + + # Export categories + if [[ -f "$CATEGORIES_FILE" ]]; then + echo "Exporting categories..." + cp "$CATEGORIES_FILE" "$export_dir/categories.conf" + fi + + # Export rooms + if [[ -f "$ROOMS_FILE" ]]; then + echo "Exporting rooms..." + cp "$ROOMS_FILE" "$export_dir/rooms.conf" + fi + + # Create metadata file + cat > "$export_dir/export_info.txt" << EOF +Easy Asterisk Client Export +Export Date: $(date) +Device Count: $device_count +Domain: ${DOMAIN_NAME:-Not configured} +TLS Enabled: ${ENABLE_TLS:-no} +Exported by: $(whoami) +Hostname: $(hostname) +EOF + + # Create tar.gz archive + echo "Creating archive..." + tar -czf "$export_file" -C /tmp "asterisk_export_${timestamp}" 2>/dev/null + + # Cleanup temp directory + rm -rf "$export_dir" + + if [[ -f "$export_file" ]]; then + print_success "Export completed successfully!" + echo "" + echo " Exported: $device_count devices" + echo " File: $export_file" + echo " Size: $(du -h "$export_file" | cut -f1)" + echo "" + echo " To import on another system:" + echo " 1) Copy file to the target server" + echo " 2) Run Easy Asterisk" + echo " 3) Select 'Client Settings' -> 'Import Clients'" + else + print_error "Export failed" + fi +} + +import_clients() { + print_header "Import Client Configurations" + load_config + initialize_default_categories + + echo "Available export files in /root:" + local files=($(ls -t /root/asterisk-clients-*.tar.gz 2>/dev/null)) + + if [[ ${#files[@]} -eq 0 ]]; then + echo "" + read -p "Enter full path to export file: " import_file + else + echo "" + local i=1 + for f in "${files[@]}"; do + echo " $i) $(basename "$f") - $(du -h "$f" | cut -f1) - $(date -r "$f" '+%Y-%m-%d %H:%M')" + ((i++)) + done + echo " 0) Enter custom path" + echo "" + read -p "Select file [1]: " file_choice + file_choice="${file_choice:-1}" + + if [[ "$file_choice" == "0" ]]; then + read -p "Enter full path to export file: " import_file + elif [[ "$file_choice" -ge 1 && "$file_choice" -le ${#files[@]} ]]; then + import_file="${files[$((file_choice-1))]}" + else + print_error "Invalid selection" + return + fi + fi + + if [[ ! -f "$import_file" ]]; then + print_error "File not found: $import_file" + return + fi + + # Extract to temp directory + local timestamp=$(date +%Y%m%d_%H%M%S) + local import_dir="/tmp/asterisk_import_${timestamp}" + mkdir -p "$import_dir" + + echo "Extracting archive..." + tar -xzf "$import_file" -C "$import_dir" 2>/dev/null + + # Find the extracted directory + local extract_dir=$(find "$import_dir" -type d -name "asterisk_export_*" | head -1) + if [[ ! -d "$extract_dir" ]]; then + print_error "Invalid export file format" + rm -rf "$import_dir" + return + fi + + # Show export info + if [[ -f "$extract_dir/export_info.txt" ]]; then + echo "" + echo "═══════════════════════════════════════════════════════════════" + cat "$extract_dir/export_info.txt" + echo "═══════════════════════════════════════════════════════════════" + echo "" + fi + + # Count devices to import + local device_count=0 + if [[ -f "$extract_dir/devices.conf" ]]; then + device_count=$(grep -c "^; === Device:" "$extract_dir/devices.conf") + fi + + if [[ $device_count -eq 0 ]]; then + print_error "No devices found in export file" + rm -rf "$import_dir" + return + fi + + echo "This will import $device_count device(s)." + echo "" + read -p "Import mode [1=Merge, 2=Replace All]: " import_mode + import_mode="${import_mode:-1}" + + if [[ "$import_mode" == "2" ]]; then + echo "" + echo "${RED}WARNING: This will DELETE ALL existing devices!${NC}" + read -p "Type 'DELETE ALL' to confirm: " confirm + if [[ "$confirm" != "DELETE ALL" ]]; then + print_error "Import cancelled" + rm -rf "$import_dir" + return + fi + fi + + # Backup existing configurations + echo "Backing up current configuration..." + backup_config "/etc/asterisk/pjsip.conf" + backup_config "$CATEGORIES_FILE" + backup_config "$ROOMS_FILE" + + # Import devices + if [[ "$import_mode" == "2" ]]; then + # Replace mode - remove all existing devices + echo "Removing existing devices..." + local temp_pjsip="/tmp/pjsip_base_${timestamp}.conf" + awk '/^; === Device:/{exit} {print}' /etc/asterisk/pjsip.conf > "$temp_pjsip" + cat "$temp_pjsip" "$extract_dir/devices.conf" > /etc/asterisk/pjsip.conf + rm -f "$temp_pjsip" + print_success "Replaced all devices with imported devices" + else + # Merge mode - check for conflicts + echo "Checking for extension conflicts..." + local conflicts=0 + local conflict_list="" + + while IFS= read -r line; do + if [[ "$line" =~ ^\[([0-9]+)\]$ ]]; then + local ext="${BASH_REMATCH[1]}" + if grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then + conflicts=$((conflicts + 1)) + conflict_list="${conflict_list}${ext} " + fi + fi + done < "$extract_dir/devices.conf" + + if [[ $conflicts -gt 0 ]]; then + echo "" + echo "${YELLOW}Warning: Found $conflicts conflicting extension(s): $conflict_list${NC}" + read -p "Skip conflicting devices? [Y/n]: " skip_conflicts + skip_conflicts="${skip_conflicts:-Y}" + + if [[ ! "$skip_conflicts" =~ ^[Yy]$ ]]; then + print_error "Import cancelled" + rm -rf "$import_dir" + return + fi + + # Import only non-conflicting devices + echo "Importing non-conflicting devices..." + local temp_import="/tmp/import_filtered_${timestamp}.conf" + local skip_device=0 + local pending_header="" + + while IFS= read -r line; do + if [[ "$line" == "; === Device:"* ]]; then + skip_device=0 + pending_header="$line" + elif [[ "$line" =~ ^\[([0-9]+)\]$ ]]; then + local ext="${BASH_REMATCH[1]}" + if grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then + skip_device=1 + if [[ -n "$pending_header" ]]; then + echo " Skipping extension $ext (already exists)" + pending_header="" + fi + else + if [[ -n "$pending_header" ]]; then + echo "$pending_header" >> "$temp_import" + pending_header="" + fi + echo "$line" >> "$temp_import" + fi + elif [[ $skip_device -eq 0 ]]; then + echo "$line" >> "$temp_import" + fi + done < "$extract_dir/devices.conf" + + cat "$temp_import" >> /etc/asterisk/pjsip.conf + rm -f "$temp_import" + else + # No conflicts, import all + echo "No conflicts found, importing all devices..." + cat "$extract_dir/devices.conf" >> /etc/asterisk/pjsip.conf + fi + + print_success "Devices imported successfully" + fi + + # Import categories (merge, skip duplicates) + if [[ -f "$extract_dir/categories.conf" ]]; then + echo "Importing categories..." + while IFS='|' read -r cat_id cat_name auto_answer description; do + [[ "$cat_id" =~ ^# ]] && continue + [[ -z "$cat_id" ]] && continue + + # Skip if already exists + if grep -q "^${cat_id}|" "$CATEGORIES_FILE" 2>/dev/null; then + echo " Skipping category '$cat_id' (already exists)" + else + echo "${cat_id}|${cat_name}|${auto_answer}|${description}" >> "$CATEGORIES_FILE" + echo " Imported category: $cat_name" + fi + done < "$extract_dir/categories.conf" + fi + + # Import rooms (merge, skip duplicates) + if [[ -f "$extract_dir/rooms.conf" ]]; then + echo "Importing rooms..." + while IFS='|' read -r ext name members timeout type; do + [[ "$ext" =~ ^# ]] && continue + [[ -z "$ext" ]] && continue + + # Skip if already exists + if grep -q "^${ext}|" "$ROOMS_FILE" 2>/dev/null; then + echo " Skipping room '$name' (extension $ext already exists)" + else + echo "${ext}|${name}|${members}|${timeout}|${type}" >> "$ROOMS_FILE" + echo " Imported room: $name (ext $ext)" + fi + done < "$extract_dir/rooms.conf" + fi + + # Cleanup + rm -rf "$import_dir" + + # Reload Asterisk + echo "" + echo "Reloading Asterisk configuration..." + asterisk -rx "pjsip reload" >/dev/null 2>&1 + rebuild_dialplan quiet + + print_success "Import completed successfully!" + echo "" + echo " Run 'List devices' to verify imported clients" +} + +submenu_client() { + clear + print_header "Client Settings" + echo " 1) Configure Local Client" + echo " 2) Configure PTT Button" + echo " 3) Run Diagnostics" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) configure_local_client ;; + 2) configure_ptt_menu ;; + 3) run_client_diagnostics ;; + 0) return ;; + esac + [[ "$choice" != "0" ]] && read -p "Press Enter..." + [[ "$choice" != "0" ]] && submenu_client +} + +fix_audio_manually() { + if is_docker; then + print_error "Audio management not available in Docker (no local audio hardware)" + return + fi + print_header "Manual Audio Fix" + load_config + local t_user="${KIOSK_USER:-$SUDO_USER}" + t_user="${t_user:-$USER}" + local t_uid=$(id -u "$t_user" 2>/dev/null) + local user_dbus="XDG_RUNTIME_DIR=/run/user/$t_uid" + + echo "Fixing audio for user: $t_user" + echo "" + + # Restart PipeWire services + echo "Restarting PipeWire services..." + sudo -u "$t_user" $user_dbus systemctl --user restart pipewire pipewire-pulse 2>/dev/null || true + sleep 2 + + # Unmute audio + echo "Unmuting audio sources and sinks..." + sudo -u "$t_user" $user_dbus pactl set-source-mute @DEFAULT_SOURCE@ 0 2>/dev/null && echo " ✓ Microphone unmuted" || echo " ✗ Failed to unmute microphone" + sudo -u "$t_user" $user_dbus pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null && echo " ✓ Speaker unmuted" || echo " ✗ Failed to unmute speaker" + + # Set volume + echo "Setting volume levels to 75%..." + sudo -u "$t_user" $user_dbus pactl set-source-volume @DEFAULT_SOURCE@ 75% 2>/dev/null && echo " ✓ Microphone volume set" || echo " ✗ Failed to set microphone volume" + sudo -u "$t_user" $user_dbus pactl set-sink-volume @DEFAULT_SINK@ 75% 2>/dev/null && echo " ✓ Speaker volume set" || echo " ✗ Failed to set speaker volume" + + echo "" + echo "Current audio status:" + local src_mute=$(sudo -u "$t_user" $user_dbus pactl get-source-mute @DEFAULT_SOURCE@ 2>/dev/null | awk '{print $2}') + local sink_mute=$(sudo -u "$t_user" $user_dbus pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null | awk '{print $2}') + echo " Microphone: ${src_mute:-unknown}" + echo " Speaker: ${sink_mute:-unknown}" + + echo "" + echo "Restarting Baresip..." + sudo -u "$t_user" $user_dbus systemctl --user restart baresip 2>/dev/null && echo " ✓ Baresip restarted" || echo " ✗ Failed to restart Baresip" +} + +submenu_tools() { + clear + print_header "Tools" + + if is_docker; then + echo " 1) Room Directory" + echo " 2) Update Asterisk (Docker)" + echo " 3) VPN Diagnostics" + echo " 4) DNS Whitelist Check" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) show_room_directory ;; + 2) manual_update_asterisk ;; + 3) + if [[ -f /usr/local/bin/vpn-diagnostics ]]; then + bash /usr/local/bin/vpn-diagnostics + else + print_error "vpn-diagnostics not found" + fi + ;; + 4) + if [[ -f /usr/local/bin/dns-whitelist ]]; then + bash /usr/local/bin/dns-whitelist --check + else + print_error "dns-whitelist not found" + fi + ;; + 0) return ;; + esac + else + echo " 1) Audio Test" + echo " 2) Verify Audio/Codec Setup" + echo " 3) Fix Audio (Unmute & Restart)" + echo " 4) Room Directory" + echo " 5) Manual Update Asterisk" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) run_audio_test ;; + 2) verify_audio_setup ;; + 3) fix_audio_manually ;; + 4) show_room_directory ;; + 5) manual_update_asterisk ;; + 0) return ;; + esac + fi + [[ "$choice" != "0" ]] && read -p "Press Enter..." + [[ "$choice" != "0" ]] && submenu_tools +} + +main() { + check_root + load_config + show_main_menu +} + +main "$@" diff --git a/vendor/easy-asterisk/scripts/dns-whitelist.sh b/vendor/easy-asterisk/scripts/dns-whitelist.sh new file mode 100644 index 0000000..61da578 --- /dev/null +++ b/vendor/easy-asterisk/scripts/dns-whitelist.sh @@ -0,0 +1,280 @@ +#!/bin/bash +# ================================================================ +# DNS Whitelist Checker for Easy Asterisk +# +# Checks which domains need to be whitelisted when DNS filtering +# is active on the server, caller, or receiver networks. +# +# Usage: dns-whitelist [--check] [--sipnetic] [--linphone] +# ================================================================ + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +CONFIG_FILE="/etc/easy-asterisk/config" +CHECK_MODE=false +SHOW_SIPNETIC=false +SHOW_LINPHONE=false +SHOW_ALL=true + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) CHECK_MODE=true; shift ;; + --sipnetic) SHOW_SIPNETIC=true; SHOW_ALL=false; shift ;; + --linphone) SHOW_LINPHONE=true; SHOW_ALL=false; shift ;; + --help|-h) + echo "Usage: dns-whitelist [OPTIONS]" + echo "" + echo "Options:" + echo " --check Test reachability of each domain" + echo " --sipnetic Show Sipnetic-specific domains" + echo " --linphone Show Linphone-specific domains" + echo " --help Show this help" + exit 0 + ;; + *) shift ;; + esac +done + +print_header() { + echo "" + echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN} $1${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}" + echo "" +} + +check_dns() { + local domain="$1" + local port="$2" + local proto="${3:-tcp}" + + if $CHECK_MODE; then + # DNS resolution test + if nslookup "$domain" >/dev/null 2>&1; then + echo -e " ${GREEN}✓ DNS resolves${NC}" + else + echo -e " ${RED}✗ DNS BLOCKED - add to whitelist${NC}" + return 1 + fi + + # Connectivity test + if [[ "$proto" == "udp" ]]; then + # UDP - just check DNS resolution (can't reliably test UDP connectivity) + echo -e " ${CYAN}→ UDP port ${port} (cannot test remotely)${NC}" + else + if curl -s --connect-timeout 5 "https://${domain}" >/dev/null 2>&1 || \ + curl -s --connect-timeout 5 "http://${domain}" >/dev/null 2>&1; then + echo -e " ${GREEN}✓ Reachable${NC}" + else + echo -e " ${YELLOW}! Connection failed (may be expected)${NC}" + fi + fi + fi +} + +# Load config if available +source "$CONFIG_FILE" 2>/dev/null || true + +print_header "DNS Whitelist for Easy Asterisk" + +echo -e "${BOLD}Your Setup:${NC}" +if [[ -n "$DOMAIN_NAME" ]]; then + echo -e " Mode: FQDN/Internet (${DOMAIN_NAME})" +else + echo -e " Mode: LAN/VPN (no domain configured)" +fi +echo "" + +# ══════════════════════════════════════════════════════════════ +# SECTION 1: ASTERISK SERVER DOMAINS +# ══════════════════════════════════════════════════════════════ +if $SHOW_ALL; then + echo -e "${BOLD}━━━ 1. ASTERISK SERVER (whitelist on server's DNS filter) ━━━${NC}" + echo "" + + echo -e "${BOLD}Required for LAN/VPN mode:${NC}" + echo -e " ${GREEN}None${NC} - Asterisk needs no internet after installation" + echo -e " SIP operates over direct IP connections, no DNS involved" + echo "" + + echo -e "${BOLD}Required for FQDN/Internet mode only:${NC}" + echo "" + + echo -e " ${CYAN}ifconfig.me${NC} (HTTPS 443)" + echo -e " Purpose: Auto-detect public IP for NAT settings" + echo -e " When: Only during config regeneration" + check_dns "ifconfig.me" "443" + echo "" + + echo -e " ${CYAN}icanhazip.com${NC} (HTTPS 443)" + echo -e " Purpose: Fallback public IP detection" + check_dns "icanhazip.com" "443" + echo "" + + echo -e "${BOLD}Required if ICE/STUN enabled:${NC}" + echo "" + + # Check what STUN server is configured + stun_server="" + if [[ -f /etc/asterisk/rtp.conf ]]; then + stun_server=$(grep "^stunaddr=" /etc/asterisk/rtp.conf 2>/dev/null | cut -d= -f2) + fi + + if [[ -n "$stun_server" ]]; then + stun_host=$(echo "$stun_server" | cut -d: -f1) + stun_port=$(echo "$stun_server" | cut -d: -f2) + stun_port="${stun_port:-3478}" + echo -e " ${CYAN}${stun_host}${NC} (UDP ${stun_port})" + echo -e " Purpose: STUN NAT discovery" + echo -e " ${YELLOW}Tip: Use self-hosted coturn to avoid this dependency${NC}" + check_dns "$stun_host" "$stun_port" "udp" + else + echo -e " ${GREEN}No external STUN server configured${NC}" + echo -e " To use self-hosted: docker compose --profile stun up -d" + fi + echo "" + + echo -e "${BOLD}Required for package updates only:${NC}" + echo "" + echo -e " ${CYAN}archive.ubuntu.com${NC} / ${CYAN}security.ubuntu.com${NC} (HTTPS 443)" + echo -e " Purpose: apt package updates" + echo -e " When: Only during install/update (not runtime)" + echo "" + + echo -e "${BOLD}Required for TLS certificates:${NC}" + echo "" + echo -e " ${CYAN}acme-v02.api.letsencrypt.org${NC} (HTTPS 443)" + echo -e " Purpose: Let's Encrypt certificate issuance" + echo -e " When: Only if using Let's Encrypt / Certbot / Caddy" + if $CHECK_MODE; then + check_dns "acme-v02.api.letsencrypt.org" "443" + fi + echo "" +fi + +# ══════════════════════════════════════════════════════════════ +# SECTION 2: SIPNETIC (Mobile Client) DOMAINS +# ══════════════════════════════════════════════════════════════ +if $SHOW_ALL || $SHOW_SIPNETIC; then + echo -e "${BOLD}━━━ 2. SIPNETIC CLIENT (whitelist on caller/receiver DNS) ━━━${NC}" + echo "" + + echo -e "${BOLD}Required for SIP calls:${NC}" + echo -e " ${GREEN}None${NC} - Configure Sipnetic with the server's IP address directly" + echo -e " SIP registration and calls use IP:port, not DNS" + echo "" + + echo -e "${BOLD}Sipnetic app domains (for app functionality):${NC}" + echo "" + echo -e " ${CYAN}onesip.io${NC} / ${CYAN}api.onesip.io${NC}" + echo -e " Purpose: Sipnetic account/licensing (free tier works offline)" + echo -e " Required: Only for initial setup or account sync" + if $CHECK_MODE; then + check_dns "onesip.io" "443" + fi + echo "" + + echo -e " ${CYAN}play.google.com${NC} / ${CYAN}apps.apple.com${NC}" + echo -e " Purpose: App updates" + echo -e " Required: Only for installing/updating the app" + echo "" + + echo -e "${BOLD}If STUN configured in Sipnetic:${NC}" + echo "" + echo -e " The STUN server domain configured in Sipnetic's settings" + echo -e " needs to resolve on the mobile device's network." + echo "" + echo -e " ${YELLOW}Recommendation: Use the Asterisk server's VPN IP as STUN${NC}" + echo -e " ${YELLOW}server (if running self-hosted coturn), avoiding DNS entirely.${NC}" + echo "" + + echo -e "${BOLD}Sipnetic Configuration for DNS-Filtered Networks:${NC}" + echo "" + echo -e " Server: ${CYAN}${NC} (not a hostname)" + echo -e " Port: ${CYAN}5060${NC} (UDP, LAN/VPN mode)" + echo -e " Transport: ${CYAN}UDP${NC}" + echo -e " STUN: ${CYAN}:3478${NC} (if self-hosted coturn)" + echo -e " or leave blank if VPN provides direct routing" + echo "" +fi + +# ══════════════════════════════════════════════════════════════ +# SECTION 3: LINPHONE (Mobile Client) DOMAINS +# ══════════════════════════════════════════════════════════════ +if $SHOW_ALL || $SHOW_LINPHONE; then + echo -e "${BOLD}━━━ 3. LINPHONE CLIENT (whitelist on caller/receiver DNS) ━━━${NC}" + echo "" + + echo -e "${BOLD}Required for SIP calls:${NC}" + echo -e " ${GREEN}None${NC} - Same as Sipnetic, configure with server IP directly" + echo "" + + echo -e "${BOLD}Linphone app domains:${NC}" + echo "" + echo -e " ${CYAN}linphone.org${NC} / ${CYAN}sip.linphone.org${NC}" + echo -e " Purpose: Default Linphone SIP proxy (NOT needed for Easy Asterisk)" + echo -e " Required: ${GREEN}No${NC} - We use our own Asterisk server" + echo "" + echo -e " ${CYAN}subscribe.linphone.org${NC}" + echo -e " Purpose: Push notifications (may be needed for background calls)" + echo -e " Required: Only if you need calls to ring when app is backgrounded" + echo "" + + echo -e "${BOLD}For remote provisioning:${NC}" + echo "" + echo -e " If using Easy Asterisk's HTTP provisioning:" + echo -e " The phone must reach ${CYAN}http://:8088/static/linphone.xml${NC}" + echo -e " This is an IP address, so no DNS whitelist needed." + echo "" +fi + +# ══════════════════════════════════════════════════════════════ +# SECTION 4: SUMMARY +# ══════════════════════════════════════════════════════════════ +if $SHOW_ALL; then + print_header "Quick Reference - Minimum DNS Whitelist" + + echo -e "${BOLD}For LAN/VPN mode (no internet calling):${NC}" + echo "" + echo -e " Server DNS filter: ${GREEN}No domains needed${NC}" + echo -e " Client DNS filter: ${GREEN}No domains needed${NC}" + echo -e " (Configure everything by IP address)" + echo "" + + echo -e "${BOLD}For LAN/VPN + self-hosted STUN (coturn):${NC}" + echo "" + echo -e " Server DNS filter: ${GREEN}No domains needed${NC}" + echo -e " Client DNS filter: ${GREEN}No domains needed${NC}" + echo -e " (STUN server reached by VPN IP, not hostname)" + echo "" + + echo -e "${BOLD}For LAN/VPN + Google STUN:${NC}" + echo "" + echo -e " Server DNS filter: ${YELLOW}stun.l.google.com${NC}" + echo -e " Client DNS filter: ${YELLOW}stun.l.google.com${NC} (if also set in Sipnetic)" + echo "" + + echo -e "${BOLD}For FQDN/Internet mode:${NC}" + echo "" + echo -e " Server DNS filter: ${YELLOW}ifconfig.me, icanhazip.com, stun.l.google.com${NC}" + echo -e " ${YELLOW}acme-v02.api.letsencrypt.org${NC} (if using LE certs)" + echo -e " Client DNS filter: ${YELLOW}Your domain name (${DOMAIN_NAME:-yourdomain.com})${NC}" + echo "" + + print_header "Recommendation for DNS-Filtered Environments" + + echo -e " ${GREEN}Use LAN/VPN mode + self-hosted coturn (STUN-only)${NC}" + echo -e " ${GREEN}= Zero external DNS dependencies${NC}" + echo "" + echo -e " Setup: docker compose --profile stun up -d" + echo -e " Then configure STUN as your server's VPN IP:3478" + echo -e " No hostnames, no DNS, everything by IP." + echo "" +fi diff --git a/vendor/easy-asterisk/scripts/vpn-diagnostics.sh b/vendor/easy-asterisk/scripts/vpn-diagnostics.sh new file mode 100644 index 0000000..550f7b5 --- /dev/null +++ b/vendor/easy-asterisk/scripts/vpn-diagnostics.sh @@ -0,0 +1,366 @@ +#!/bin/bash +# ================================================================ +# VPN Diagnostics for Easy Asterisk +# +# Tests whether your third-party VPN setup needs STUN/TURN +# and validates connectivity between Asterisk and VPN clients. +# +# Usage: vpn-diagnostics [--auto] [--client-ip ] +# ================================================================ + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +CONFIG_FILE="/etc/easy-asterisk/config" +RESULTS=() +WARNINGS=() +CLIENT_IP="" +AUTO_MODE=false + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --auto) AUTO_MODE=true; shift ;; + --client-ip) CLIENT_IP="$2"; shift 2 ;; + --help|-h) + echo "Usage: vpn-diagnostics [OPTIONS]" + echo "" + echo "Options:" + echo " --auto Non-interactive mode" + echo " --client-ip Test connectivity to specific VPN client" + echo " --help Show this help" + exit 0 + ;; + *) shift ;; + esac +done + +print_header() { + echo "" + echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN} $1${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}" + echo "" +} + +pass() { echo -e " ${GREEN}✓${NC} $1"; RESULTS+=("PASS: $1"); } +fail() { echo -e " ${RED}✗${NC} $1"; RESULTS+=("FAIL: $1"); } +warn() { echo -e " ${YELLOW}!${NC} $1"; WARNINGS+=("$1"); } +info() { echo -e " ${CYAN}→${NC} $1"; } + +# ── Test 1: Detect network interfaces ──────────────────────── +print_header "VPN Diagnostics for Easy Asterisk" + +echo -e "${BOLD}1. Network Interface Detection${NC}" +echo "" + +# Detect primary LAN interface +primary_ip=$(hostname -I | awk '{print $1}') +info "Primary IP: ${primary_ip}" + +# Detect VPN interfaces (tun, tap, wg, tailscale, utun, ppp) +vpn_found=false +vpn_ips=() +vpn_ifaces=() + +while IFS= read -r line; do + iface=$(echo "$line" | awk '{print $2}' | tr -d ':') + ip_addr=$(echo "$line" | awk '{print $4}' | cut -d'/' -f1) + + # Check for VPN interface patterns + if [[ "$iface" =~ ^(tun|tap|wg|tailscale|utun|ppp|nordlynx|proton|mullvad) ]] || \ + [[ "$ip_addr" =~ ^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.64\.|100\.96\.|100\.100\.) ]]; then + vpn_found=true + vpn_ips+=("$ip_addr") + vpn_ifaces+=("$iface") + pass "VPN interface detected: ${iface} (${ip_addr})" + fi +done < <(ip -o -f inet addr show scope global 2>/dev/null) + +if ! $vpn_found; then + warn "No VPN interface detected on server" + info "If your VPN runs on the router (not this server), that's expected" + info "The VPN subnet should be added via VLAN/VPN subnet configuration" +fi + +# ── Test 2: Check Asterisk PJSIP transport configuration ───── +echo "" +echo -e "${BOLD}2. Asterisk Transport Configuration${NC}" +echo "" + +if [[ -f /etc/asterisk/pjsip.conf ]]; then + # Check local_net entries + local_nets=$(grep "^local_net=" /etc/asterisk/pjsip.conf 2>/dev/null | sort -u) + if [[ -n "$local_nets" ]]; then + while IFS= read -r net; do + info "Transport local_net: ${net#local_net=}" + done <<< "$local_nets" + + # Check if VPN subnets are included + for vpn_ip in "${vpn_ips[@]}"; do + vpn_subnet=$(echo "$vpn_ip" | sed 's/\.[0-9]*$/.0\/24/') + if echo "$local_nets" | grep -q "$vpn_subnet"; then + pass "VPN subnet ${vpn_subnet} included in transport" + else + fail "VPN subnet ${vpn_subnet} NOT in transport local_net" + warn "Add via: Server Settings → Configure VLAN/VPN Subnets" + fi + done + else + warn "No local_net entries found in transport (basic LAN mode)" + fi + + # Check transport types + if grep -q "transport=transport-udp" /etc/asterisk/pjsip.conf; then + pass "UDP transport configured for LAN/VPN devices" + fi + if grep -q "transport=transport-tls" /etc/asterisk/pjsip.conf; then + pass "TLS transport configured for FQDN devices" + fi +else + fail "pjsip.conf not found" +fi + +# ── Test 2b: TLS Certificate & Port Checks ──────────────────── +echo "" +echo -e "${BOLD}2b. TLS / Certificate Status${NC}" +echo "" + +# Check if port 5061 is actually listening +if command -v ss &>/dev/null; then + tls_listen=$(ss -tlnp 2>/dev/null | grep ":5061 " || true) +elif command -v netstat &>/dev/null; then + tls_listen=$(netstat -tlnp 2>/dev/null | grep ":5061 " || true) +else + tls_listen="" +fi + +if [[ -n "$tls_listen" ]]; then + pass "Port 5061 (TLS) is listening" +else + fail "Port 5061 (TLS) is NOT listening" + warn "Asterisk TLS transport failed to start — check certs and logs" +fi + +# Check TLS cert +cert_file="/etc/asterisk/certs/server.crt" +if [[ -f "$cert_file" ]]; then + pass "TLS certificate exists: $cert_file" + + # Check cert CN/SAN + cert_cn=$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | sed 's/.*CN *= *//') + cert_san=$(openssl x509 -in "$cert_file" -noout -ext subjectAltName 2>/dev/null | grep -oP 'DNS:\K[^,]+' || true) + cert_expiry=$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | cut -d= -f2) + + info "Cert CN: ${cert_cn:-unknown}" + if [[ -n "$cert_san" ]]; then + pass "Cert has SAN (Subject Alt Name): ${cert_san}" + else + fail "Cert has NO SAN — modern phones (iOS/Android) will reject it" + warn "Delete /etc/asterisk/certs/server.crt and restart to regenerate with SANs" + fi + info "Cert expires: ${cert_expiry:-unknown}" + + # Check if cert is self-signed + issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null | sed 's/.*CN *= *//') + if [[ "$issuer" == "$cert_cn" ]]; then + warn "Cert is SELF-SIGNED — phones must be set to accept self-signed certs" + info "In your SIP app: disable TLS certificate verification / allow self-signed" + fi + + # Verify PJSIP transport loaded it + if command -v asterisk &>/dev/null; then + transport_status=$(asterisk -rx "pjsip show transports" 2>/dev/null || true) + if echo "$transport_status" | grep -q "transport-tls"; then + pass "PJSIP TLS transport is loaded" + else + fail "PJSIP TLS transport NOT loaded — cert may be invalid" + fi + fi +else + fail "TLS certificate not found at $cert_file" +fi + +# ── Test 3: Check RTP and ICE/STUN configuration ───────────── +echo "" +echo -e "${BOLD}3. RTP / ICE / STUN Configuration${NC}" +echo "" + +if [[ -f /etc/asterisk/rtp.conf ]]; then + rtp_start=$(grep "^rtpstart=" /etc/asterisk/rtp.conf | cut -d= -f2) + rtp_end=$(grep "^rtpend=" /etc/asterisk/rtp.conf | cut -d= -f2) + info "RTP port range: ${rtp_start:-10000}-${rtp_end:-20000}" + + if grep -q "^icesupport=yes" /etc/asterisk/rtp.conf; then + pass "ICE support enabled" + stun_addr=$(grep "^stunaddr=" /etc/asterisk/rtp.conf | cut -d= -f2) + if [[ -n "$stun_addr" ]]; then + info "STUN server: ${stun_addr}" + + # Test STUN server reachability + stun_host=$(echo "$stun_addr" | cut -d: -f1) + stun_port=$(echo "$stun_addr" | cut -d: -f2) + stun_port="${stun_port:-3478}" + + if command -v nslookup &>/dev/null && nslookup "$stun_host" >/dev/null 2>&1; then + pass "STUN server DNS resolves: ${stun_host}" + else + fail "Cannot resolve STUN server: ${stun_host}" + warn "Add ${stun_host} to DNS whitelist" + fi + fi + else + info "ICE support disabled (standard for LAN/VPN mode)" + warn "If audio fails over VPN, enable ICE via: Server Settings → VPN STUN/ICE" + fi +else + warn "rtp.conf not found" +fi + +# ── Test 4: Check endpoint ICE settings ─────────────────────── +echo "" +echo -e "${BOLD}4. Per-Device ICE Configuration${NC}" +echo "" + +if [[ -f /etc/asterisk/pjsip.conf ]]; then + device_count=$(grep -c "^; === Device:" /etc/asterisk/pjsip.conf 2>/dev/null || echo 0) + ice_device_count=$(grep -c "^ice_support=yes" /etc/asterisk/pjsip.conf 2>/dev/null || echo 0) + info "Total devices: ${device_count}" + info "Devices with ICE: ${ice_device_count}" + + if [[ "$device_count" -gt 0 && "$ice_device_count" -eq 0 ]]; then + warn "No devices have ICE enabled" + info "For third-party VPNs with NAT, enable ICE via VPN STUN/ICE menu" + fi +fi + +# ── Test 5: VPN client connectivity ────────────────────────── +echo "" +echo -e "${BOLD}5. VPN Client Connectivity${NC}" +echo "" + +if [[ -z "$CLIENT_IP" ]] && ! $AUTO_MODE; then + echo " Enter a VPN client IP to test connectivity (or press Enter to skip):" + read -p " Client VPN IP: " CLIENT_IP +fi + +if [[ -n "$CLIENT_IP" ]]; then + # Ping test + if ping -c 2 -W 3 "$CLIENT_IP" >/dev/null 2>&1; then + pass "Ping to ${CLIENT_IP} succeeded" + else + fail "Ping to ${CLIENT_IP} failed" + warn "VPN routing issue - client may not be reachable" + fi + + # SIP port test (UDP 5060) + if command -v nc &>/dev/null; then + if nc -z -u -w 3 "$CLIENT_IP" 5060 2>/dev/null; then + pass "UDP 5060 reachable on ${CLIENT_IP}" + else + info "UDP 5060 probe inconclusive (normal for filtered VPNs)" + fi + fi +else + info "Skipping client connectivity test (no IP provided)" +fi + +# ── Test 6: NAT type detection ─────────────────────────────── +echo "" +echo -e "${BOLD}6. NAT Type Analysis${NC}" +echo "" + +# Check if server is behind NAT +if [[ -n "$primary_ip" ]]; then + public_ip=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || echo "") + if [[ -n "$public_ip" ]]; then + if [[ "$primary_ip" == "$public_ip" ]]; then + pass "Server has public IP (no NAT)" + else + info "Server behind NAT: ${primary_ip} → ${public_ip}" + info "This is normal for VPN setups where traffic stays on VPN" + fi + else + info "Cannot detect public IP (DNS filtering or no internet)" + info "Not needed for LAN/VPN mode" + fi +fi + +# ── Test 7: Asterisk registration status ───────────────────── +echo "" +echo -e "${BOLD}7. Asterisk Registration Status${NC}" +echo "" + +if command -v asterisk &>/dev/null; then + reg_output=$(asterisk -rx "pjsip show endpoints" 2>/dev/null || echo "") + if [[ -n "$reg_output" ]]; then + online_count=$(echo "$reg_output" | grep -c "Avail" 2>/dev/null || echo 0) + offline_count=$(echo "$reg_output" | grep -c "Unavail" 2>/dev/null || echo 0) + info "Endpoints online: ${online_count}" + info "Endpoints offline: ${offline_count}" + + if [[ "$offline_count" -gt 0 ]]; then + warn "Some endpoints are offline - check VPN connectivity" + echo "$reg_output" | grep "Unavail" | while IFS= read -r line; do + info " Offline: $line" + done + fi + else + info "Asterisk not running or no endpoints configured" + fi +else + info "Asterisk CLI not available" +fi + +# ── Summary ────────────────────────────────────────────────── +print_header "Diagnostic Summary" + +fail_count=0 +pass_count=0 +for result in "${RESULTS[@]}"; do + if [[ "$result" == FAIL* ]]; then + ((fail_count++)) + elif [[ "$result" == PASS* ]]; then + ((pass_count++)) + fi +done + +echo -e " Passed: ${GREEN}${pass_count}${NC}" +echo -e " Failed: ${RED}${fail_count}${NC}" +echo -e " Warnings: ${YELLOW}${#WARNINGS[@]}${NC}" + +if [[ ${#WARNINGS[@]} -gt 0 ]]; then + echo "" + echo -e "${BOLD}Recommendations:${NC}" + for w in "${WARNINGS[@]}"; do + echo -e " ${YELLOW}→${NC} $w" + done +fi + +# ── STUN Recommendation ───────────────────────────────────── +echo "" +echo -e "${BOLD}Do you need STUN?${NC}" +echo "" + +if $vpn_found; then + echo -e " VPN detected on this server." + echo -e " ${GREEN}If your VPN provides direct routing (both sides get VPN IPs),${NC}" + echo -e " ${GREEN}STUN is likely NOT needed.${NC}" + echo "" + echo -e " ${YELLOW}If audio works one-way or not at all, enable STUN:${NC}" + echo -e " 1. docker compose --profile stun up -d (self-hosted STUN)" + echo -e " 2. Or via easy-asterisk: Server Settings → VPN STUN/ICE" +else + echo -e " No VPN interface found on server." + echo -e " ${YELLOW}If VPN runs on router/firewall:${NC}" + echo -e " - Add VPN subnet via: Server Settings → VLAN/VPN Subnets" + echo -e " - If audio still fails, enable STUN for NAT traversal" +fi + +echo ""