diff --git a/services/asterisk.sh b/services/asterisk.sh index 9db5797..74498f4 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -1,16 +1,15 @@ #!/bin/bash -# services/asterisk.sh — Easy Asterisk PBX with self-hosted coturn TURN server. +# services/asterisk.sh — Easy Asterisk PBX + coturn TURN server (home intercom/VoIP). # 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 ────────────────────────────────────────────────────── +# 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; } @@ -18,9 +17,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then _COMMON="$_SELF_DIR/../lib/common.sh" if [[ -f "$_COMMON" ]]; then + # Full repo present — use the real helpers (picks up ~/docker/.config too) # shellcheck source=../lib/common.sh source "$_COMMON" else + # One-off copy — inline minimal stubs so the script works without the repo log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } @@ -43,6 +44,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -62,6 +64,53 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + # Remote Caddy support: if CADDY_REMOTE_HOST is set, operate on the + # remote machine via SSH instead of the local filesystem. + if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name on $CADDY_REMOTE_HOST? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://$(hostname -I | awk '{print $1}'):${_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; } + + local _block + _block="$(cat << 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 +)" + echo "$_block" | ssh "$CADDY_REMOTE_HOST" "cat >> $_caddyfile" + ssh "$CADDY_REMOTE_HOST" "docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true" + if ssh "$CADDY_REMOTE_HOST" "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: ssh $CADDY_REMOTE_HOST docker logs caddy" + fi + return 0 + fi + if [[ ! -d "$_caddy_dir" ]]; then log_info "Access $_name directly on port ${_upstream##*:}." return 0 @@ -79,6 +128,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 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" @@ -87,6 +137,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 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="" @@ -127,12 +178,21 @@ CBLOCK } write_readme() { - local _dir="$1"; shift + local _dir="$1" mkdir -p "$_dir" + [[ "${DRY_RUN:-false}" == "true" ]] && return 0 cat > "$_dir/README.md" } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + echo + } fi + # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR + # ($HOME under sudo is /root, not the real user's home) ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" @@ -141,8 +201,9 @@ CBLOCK SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" - register_service() { :; } + register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── @@ -151,109 +212,76 @@ register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (home install_asterisk() { require_docker || return 1 - log_info "Installing Easy Asterisk PBX..." + log_info "Installing Easy Asterisk PBX + coturn..." 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" + echo "[DRY-RUN] Would create $EA_DIR with Dockerfile, docker-compose.yml, .env" + echo "[DRY-RUN] Would copy/download vendor files from easy-asterisk" + echo "[DRY-RUN] Would open UFW ports: 5060, 5061, 8080, 8088, 8089, 3478, 10000-20000, 49152-49252" return 0 fi - mkdir -p "$EA_DIR/docker" "$EA_DIR/scripts" + mkdir -p "$EA_DIR" ensure_docker_dir_ownership "$EA_DIR" cd "$EA_DIR" || return 1 - # ── Copy vendored source files ──────────────────────────────────────────── - log_info "Copying Easy Asterisk source files from vendor/..." + mkdir -p docker - 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" + # ── Vendor files ────────────────────────────────────────────────────────── + local _SELF_DIR_LOCAL + _SELF_DIR_LOCAL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local VENDOR_DIR="$_SELF_DIR_LOCAL/../vendor/easy-asterisk" - 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." + if [[ -d "$VENDOR_DIR" ]]; then + log_info "Copying vendor files from $VENDOR_DIR ..." + cp "$VENDOR_DIR/Dockerfile" ./Dockerfile + cp "$VENDOR_DIR/docker/entrypoint.sh" ./docker/entrypoint.sh + cp "$VENDOR_DIR/docker/coturn-entrypoint.sh" ./docker/coturn-entrypoint.sh + cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ./easy-asterisk.sh + cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ./easy-asterisk-v0.10.0.sh else - log_info "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 "" + log_info "Vendor directory not found — downloading from GitHub ..." + local GH_RAW="https://raw.githubusercontent.com/DeadDork/easy-asterisk/main" + curl -fsSL "$GH_RAW/Dockerfile" -o ./Dockerfile + curl -fsSL "$GH_RAW/docker/entrypoint.sh" -o ./docker/entrypoint.sh + curl -fsSL "$GH_RAW/docker/coturn-entrypoint.sh" -o ./docker/coturn-entrypoint.sh + curl -fsSL "$GH_RAW/easy-asterisk-v0.10.0.sh" -o ./easy-asterisk.sh + cp ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh fi - # ── Generate TURN password ──────────────────────────────────────────────── + chmod 755 ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh \ + ./docker/entrypoint.sh ./docker/coturn-entrypoint.sh + + # ── Networking mode ─────────────────────────────────────────────────────── + echo "" + echo " Networking mode:" + echo " 1) LAN-only — no domain, self-signed cert, works on local network/VPN only" + echo " 2) FQDN — TLS + TURN relay, works from anywhere (requires public domain)" + local HA_NETMODE="" + prompt_text "Choose [1]:" "1" HA_NETMODE + + local DOMAIN_NAME="" + if [[ "$HA_NETMODE" == "2" ]]; then + prompt_text "FQDN (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank=skip]:" "" DOMAIN_NAME + fi + + # ── Secrets ─────────────────────────────────────────────────────────────── 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 + cat > docker-compose.yml << 'EOF' +name: asterisk services: - asterisk: - build: - context: . - dockerfile: Dockerfile + build: . 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: @@ -264,23 +292,8 @@ services: - 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} + env_file: .env restart: unless-stopped healthcheck: test: ["CMD", "asterisk", "-rx", "core show version"] @@ -296,8 +309,7 @@ services: entrypoint: ["/coturn-entrypoint.sh"] volumes: - ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro - environment: - - PUBLIC_IP=${PUBLIC_IP:-} + env_file: .env command: - -n - --listening-port=${TURN_PORT:-3478} @@ -306,8 +318,8 @@ services: - --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} + - --min-port=49152 + - --max-port=49252 - --no-tls - --no-dtls - --no-cli @@ -321,185 +333,148 @@ volumes: asterisk-logs: asterisk-spool: asterisk-lib: -COMPOSE_EOF +EOF - # ── .env ───────────────────────────────────────────────────────────────── + # ── .env ────────────────────────────────────────────────────────────────── cat > .env << ENV -# Easy Asterisk — environment configuration -# Edit and restart: docker compose down && docker compose up -d +# ── Domain ──────────────────────────────────────────────────── +# Set to your FQDN for remote access. Leave empty for LAN-only. +DOMAIN_NAME=${DOMAIN_NAME} -# 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/STUN ───────────────────────────────────────────────── 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_PASSWORD=${TURN_PASSWORD} TURN_PORT=3478 +# For LAN-only: TURN_SERVER is empty. For FQDN: set to domain:3478 +TURN_SERVER=${TURN_SERVER_VAL} -# 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 port range ──────────────────────────────────────────── RTP_START=10000 RTP_END=20000 -# Web admin interface +# ── Web admin ───────────────────────────────────────────────── 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)" + if command -v ufw &>/dev/null; then + log_info "Opening UFW ports for Asterisk + coturn..." + ufw allow 5060/udp + ufw allow 5060/tcp + ufw allow 5061/tcp + ufw allow 8080/tcp + ufw allow 8088/tcp + ufw allow 8089/tcp + ufw allow 3478/udp + ufw allow 3478/tcp + ufw allow 10000:20000/udp + ufw allow 49152:49252/udp + log_success "UFW rules added." fi - 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="" + # ── Caddy reverse proxy for web admin ───────────────────────────────────── + local EXTRA_BLOCK="" if [ -d "$DOCKER_DIR/authelia" ]; then local _use_auth="" prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth if [[ "$_use_auth" =~ ^[Yy]$ ]]; then - 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)" + EXTRA_BLOCK=" import authelia" + # Disable built-in auth since Authelia handles it + sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env fi fi - configure_caddy_for_service "Asterisk Web Admin" "localhost:8080" "asterisk" "$EA_EXTRA_BLOCK" + configure_caddy_for_service "Asterisk Web Admin" "8080" "asterisk" "$EXTRA_BLOCK" # ── README ──────────────────────────────────────────────────────────────── - write_readme "$EA_DIR" << MD -# Easy Asterisk PBX + write_readme "$EA_DIR" << 'MD' +# Easy Asterisk PBX + coturn -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) +Self-hosted SIP PBX using Easy Asterisk with a coturn TURN/STUN server for +NAT traversal. Suitable for home intercom, VoIP handsets, and softphones. ## Manage -\`\`\`bash -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 -\`\`\` + +```bash +docker compose up -d --build # build image and start +docker compose up -d # start (after initial build) +docker compose down # stop +docker compose logs -f # follow logs +docker compose pull # update coturn image +docker compose up -d --build # rebuild asterisk image +``` + +## Management script + +```bash +docker exec -it easy-asterisk easy-asterisk --help +``` + +## SIP client setup + +| Setting | Value | +|-----------------|--------------------------------------| +| SIP server | (LAN) or your FQDN (FQDN) | +| SIP port | 5061 (TLS) / 5060 (UDP) | +| TURN server | :3478 (FQDN mode only) | +| TURN username | easyasterisk | +| TURN password | see .env → TURN_PASSWORD | + +Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave. + +## Web admin + +Access the Easy Asterisk web interface at http://:8080 +or via your configured reverse-proxy domain. + +## Volumes + +| Volume | Contents | +|----------------------|-------------------------------| +| asterisk-config | /etc/asterisk — dialplan, SIP | +| easy-asterisk-config | /etc/easy-asterisk — web config| +| asterisk-logs | /var/log/asterisk | +| asterisk-spool | /var/spool/asterisk | +| asterisk-lib | /var/lib/asterisk | + +## Ports + +| Port | Protocol | Purpose | +|---------------|----------|----------------------------------| +| 5060 | UDP/TCP | SIP signalling (unencrypted) | +| 5061 | TCP | SIP over TLS | +| 8080 | TCP | Easy Asterisk web admin | +| 8088/8089 | TCP | Asterisk HTTP/WS (ARI/AMI) | +| 3478 | UDP/TCP | TURN/STUN (coturn) | +| 10000–20000 | UDP | RTP media streams | +| 49152–49252 | UDP | TURN relay media ports | MD - # ── Build and start ─────────────────────────────────────────────────────── + # ── 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 + local START_NOW="" + prompt_yn "Build and start Asterisk now? (y/n):" "y" START_NOW + if [ "$START_NOW" = "y" ] || [ "$START_NOW" = "Y" ]; then + docker compose up -d --build \ + && log_success "Easy Asterisk started" \ + || log_warning "Start failed — check: docker compose logs" fi + + # ── Summary ─────────────────────────────────────────────────────────────── + echo "" + log_success "Easy Asterisk installed at $EA_DIR" + if [[ -n "$DOMAIN_NAME" ]]; then + echo " Mode: FQDN ($DOMAIN_NAME)" + echo " TURN server: ${DOMAIN_NAME}:3478" + else + echo " Mode: LAN-only" + echo " TURN server: (none — LAN/VPN only)" + fi + echo " SIP port: 5061 (TLS) / 5060 (UDP)" + echo " Web admin: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):8080" + echo " Manage: docker compose -f $EA_DIR/docker-compose.yml " + echo " Script: docker exec -it easy-asterisk easy-asterisk --help" echo "" } diff --git a/services/mattermost.sh b/services/mattermost.sh index 5690bd1..50e744e 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -2,14 +2,14 @@ # 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 ────────────────────────────────────────────────────── +# 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; } @@ -17,9 +17,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then _COMMON="$_SELF_DIR/../lib/common.sh" if [[ -f "$_COMMON" ]]; then + # Full repo present — use the real helpers (picks up ~/docker/.config too) # shellcheck source=../lib/common.sh source "$_COMMON" else + # One-off copy — inline minimal stubs so the script works without the repo log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } @@ -38,15 +40,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then } } - generate_password() { - local _len="${1:-32}" - tr -dc 'A-Za-z0-9' /dev/null || true } + # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } @@ -66,6 +64,53 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + # Remote Caddy support: if CADDY_REMOTE_HOST is set, operate on the + # remote machine via SSH instead of the local filesystem. + if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name on $CADDY_REMOTE_HOST? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://$(hostname -I | awk '{print $1}'):${_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; } + + local _block + _block="$(cat << 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 +)" + echo "$_block" | ssh "$CADDY_REMOTE_HOST" "cat >> $_caddyfile" + ssh "$CADDY_REMOTE_HOST" "docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true" + if ssh "$CADDY_REMOTE_HOST" "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: ssh $CADDY_REMOTE_HOST docker logs caddy" + fi + return 0 + fi + if [[ ! -d "$_caddy_dir" ]]; then log_info "Access $_name directly on port ${_upstream##*:}." return 0 @@ -83,6 +128,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 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" @@ -91,6 +137,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 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="" @@ -131,12 +178,21 @@ CBLOCK } write_readme() { - local _dir="$1"; shift + local _dir="$1" mkdir -p "$_dir" + [[ "${DRY_RUN:-false}" == "true" ]] && return 0 cat > "$_dir/README.md" } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + echo + } fi + # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR + # ($HOME under sudo is /root, not the real user's home) ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" @@ -145,8 +201,9 @@ CBLOCK SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" - register_service() { :; } + register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── @@ -155,67 +212,57 @@ register_service mattermost utilities "Team messaging with voice/video calls (Ma install_mattermost() { require_docker || return 1 - log_info "Installing Mattermost Team Edition..." + log_info "Installing Mattermost + coturn..." 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" + echo "[DRY-RUN] Would create $DIR with docker-compose.yml" + echo "[DRY-RUN] Would write .env with DB and Mattermost secrets" + echo "[DRY-RUN] Would create data/ logs/ config/ plugins/ db/ subdirectories" + echo "[DRY-RUN] Would open UFW ports 8443/udp, 3479, 49153:49352/udp" 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" + mkdir -p "$DIR" 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)" + local DB_PASS + local MM_SECRET + DB_PASS=$(generate_password 32) + MM_SECRET=$(generate_password 48) - # ── Site URL ────────────────────────────────────────────────────────────── + local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + local UID_VAL GID_VAL + UID_VAL=$(id -u "$ACTUAL_USER") + GID_VAL=$(id -g "$ACTUAL_USER") + + # Compute SITE_URL local SITE_URL="http://localhost:8065" - if [[ -n "$SITE_DOMAIN" && "$SITE_DOMAIN" != "example.com" ]]; then - SITE_URL="https://chat.${SITE_DOMAIN}" + if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then + SITE_URL="https://mattermost.${SITE_DOMAIN}" fi local CONFIGURED_SITEURL="" - prompt_text "Mattermost site URL [${SITE_URL}]:" "$SITE_URL" 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 - + cat > docker-compose.yml << 'EOF' name: mattermost services: - db: image: postgres:15-alpine container_name: mattermost-db + hostname: mattermost-db restart: unless-stopped - security_opt: - - no-new-privileges:true - pids_limit: 100 + env_file: .env volumes: - ./db:/var/lib/postgresql/data - environment: - - POSTGRES_USER=mattermost - - POSTGRES_PASSWORD=\${DB_PASS} - - POSTGRES_DB=mattermost + networks: + - caddy_net healthcheck: - test: ["CMD-SHELL", "pg_isready -U mattermost"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 @@ -223,46 +270,36 @@ services: mattermost: image: mattermost/mattermost-team-edition:latest container_name: mattermost + hostname: mattermost restart: unless-stopped - security_opt: - - no-new-privileges:true - pids_limit: 200 + env_file: .env 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} + ports: + - "8065:8065" + - "8443:8443/udp" networks: - - default - caddy_net coturn: image: coturn/coturn:latest container_name: mattermost-coturn - restart: unless-stopped network_mode: host + user: root 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} + - --static-auth-secret=${COTURN_SECRET} + - --realm=${MM_REALM:-localhost} - --min-port=49153 - --max-port=49352 - --no-tls @@ -270,171 +307,100 @@ services: - --no-cli - --no-multicast-peers - --log-file=stdout + restart: unless-stopped networks: - default: caddy_net: external: true - name: \${CADDY_NET:-caddy_net} -COMPOSE + name: ${CADDY_NET:-caddy_net} +EOF - # ── .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 + cat > .env << EOF +TZ=$TZ_VAL CADDY_NET=$SITE_CADDY_NET -ENV +# PostgreSQL +POSTGRES_DB=mattermost +POSTGRES_USER=mattermost +POSTGRES_PASSWORD=$DB_PASS + +# Mattermost +MM_SQLSETTINGS_DRIVERNAME=postgres +MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:${DB_PASS}@mattermost-db:5432/mattermost?sslmode=disable&connect_timeout=10 +MM_SERVICESETTINGS_SITEURL=$SITE_URL +MM_SERVICESETTINGS_ENABLELOCALMODE=true +MM_FILESETTINGS_DRIVERNAME=local +MM_PLUGINSETTINGS_ENABLE=true + +# coturn HMAC secret for Mattermost Calls plugin +COTURN_SECRET=$MM_SECRET +MM_REALM=${SITE_DOMAIN:-localhost} + +# PUID/PGID for file ownership +PUID=$UID_VAL +PGID=$GID_VAL +EOF 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" + mkdir -p data logs config plugins db + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + + # Open required firewall ports + if command -v ufw &>/dev/null; then + ufw allow 8443/udp comment "Mattermost Calls RTC" + ufw allow 3479/udp; ufw allow 3479/tcp + ufw allow 49153:49352/udp comment "Mattermost 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 "" + log_success "Mattermost configured at $DIR" - ensure_docker_dir_ownership "$DIR" + configure_caddy_for_service "Mattermost" "mattermost:8065" "mattermost" - # ── 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. +Team messaging with voice/video calls. PostgreSQL backend + coturn TURN relay. ## Access -- Direct: http://localhost:8065 -- Via Caddy: see your configured domain (e.g. https://chat.${SITE_DOMAIN:-example.com}) +- URL: $SITE_URL (or http://localhost:8065) +- First run: create admin account at the URL above -## Initial admin setup -Visit: \`${SITE_URL}/signup_user_complete\` +## Voice/Video Calls (Calls plugin) +Port 8443/udp must be open on your router/firewall. +coturn relay runs on port 3479 (HMAC secret in .env). -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 | +Configure in Mattermost: System Console → Plugins → Calls: +- TURN Server URI: turn:YOUR_DOMAIN_OR_IP:3479?transport=udp +- TURN Credentials: use static-auth-secret (see .env COTURN_SECRET) ## 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 +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose up -d \`\`\` - -## 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 "" + if [[ "$SITE_URL" == http://* ]]; then + log_warning "WebRTC (voice/video calls) requires HTTPS. Configure Caddy and update SITE_URL." + fi + 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 + if [ "$START" = "y" ] || [ "$START" = "Y" ]; then + docker compose up -d \ + && log_success "Mattermost started" \ + || log_warning "Start failed — check: docker compose logs" fi + + echo "" + echo " Access at: $SITE_URL" + echo " First run: open the URL above and create your admin account." + echo " Calls plugin: System Console → Plugins → Calls to configure coturn." + echo " TURN URI: turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp" + echo " Auth secret: see COTURN_SECRET in $DIR/.env" echo "" } diff --git a/services/nextcloud.sh b/services/nextcloud.sh index 077fcd6..050bf2e 100644 --- a/services/nextcloud.sh +++ b/services/nextcloud.sh @@ -2,10 +2,6 @@ # 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) @@ -25,7 +21,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then # shellcheck source=../lib/common.sh source "$_COMMON" else - # One-off copy — inline minimal stubs + # One-off copy — inline minimal stubs so the script works without the repo log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } @@ -53,6 +49,13 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" } + write_readme() { + local _dir="$1"; shift + [[ "${DRY_RUN:-false}" == "true" ]] && return 0 + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + # 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 @@ -73,8 +76,23 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + # Support remote Caddy host via CADDY_REMOTE_HOST + if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then + log_info "Remote Caddy detected at $CADDY_REMOTE_HOST — printing block to add manually." + echo "" + echo " Add the following to your Caddyfile on $CADDY_REMOTE_HOST:" + echo " ──────────────────────────────────────────────────────────" + echo " # $_name" + echo " ${_subdomain}.${SITE_DOMAIN:-example.com} {" + echo " reverse_proxy $_upstream" + [[ -n "$_extra" ]] && echo "$_extra" + echo " }" + echo " ──────────────────────────────────────────────────────────" + return 0 + fi + if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port 8080." + log_info "Access $_name directly on port ${_upstream##*:}." return 0 fi @@ -82,7 +100,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 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" + log_info "Skipping — access at: http://localhost:${_upstream##*:}" return 0 } @@ -138,12 +156,6 @@ CBLOCK 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 @@ -170,38 +182,32 @@ install_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" + echo "[DRY-RUN] Would create $DIR with Dockerfile, docker-compose.yml, .env" return 0 fi - mkdir -p "$DIR/html" "$DIR/db" "$DIR/config" "$DIR/custom_apps" + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 - local DB_PASS NC_ADMIN_PASS TZ_VAL + local DB_PASS DB_PASS=$(generate_password 32) - NC_ADMIN_PASS=$(generate_password 24) - TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + local NC_ADMIN_PASS + NC_ADMIN_PASS=$(generate_password 16) + local TZ_VAL="${SITE_TZ:-UTC}" - # ── Dockerfile — adds SMB support to the official apache image ──────────── - cat > Dockerfile << 'DOCKERFILE' + # ── Dockerfile ────────────────────────────────────────────────────────── + cat > Dockerfile << 'NCDF' FROM nextcloud:apache RUN apt-get update \ && apt-get install -y --no-install-recommends procps smbclient \ && rm -rf /var/lib/apt/lists/* -DOCKERFILE +NCDF - # ── docker-compose.yml — single-quoted EOF prevents variable expansion ──── - cat > docker-compose.yml << 'EOF' + # ── docker-compose.yml ────────────────────────────────────────────────── + cat > docker-compose.yml << 'NCCOMPOSE' name: nextcloud - services: nextcloud: build: . @@ -235,132 +241,114 @@ networks: caddy_net: external: true name: ${CADDY_NET:-caddy_net} -EOF +NCCOMPOSE - # ── .env — actual variable values (NOT inside the compose heredoc) ──────── - cat > .env << NC_ENV -# ── Timezone & network ──────────────────────────────────────────────────────── + # ── .env ──────────────────────────────────────────────────────────────── + cat > .env << NCENV TZ=$TZ_VAL CADDY_NET=$SITE_CADDY_NET -# ── MariaDB ─────────────────────────────────────────────────────────────────── +# 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 bootstrap (first run only) NEXTCLOUD_ADMIN_USER=admin NEXTCLOUD_ADMIN_PASSWORD=$NC_ADMIN_PASS NEXTCLOUD_DB_TYPE=mysql MYSQL_HOST=db -# ── Reverse proxy trust (required when behind Caddy) ───────────────────────── -# Without these, share links use http:// and internal redirects may break. +# Reverse proxy (required for correct share links and redirects behind Caddy) OVERWRITEPROTOCOL=https OVERWRITECLIURL=https://cloud.${SITE_DOMAIN:-example.com} TRUSTED_PROXIES=172.16.0.0/12 -NC_ENV - +NCENV chmod 600 .env + + # ── Subdirectories ────────────────────────────────────────────────────── + mkdir -p html config custom_apps db chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + + echo "" 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 - + # ── Prompt to start ───────────────────────────────────────────────────── 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" \ + docker compose up -d --build \ + && log_success "Nextcloud started" \ || { 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 + # ── Wait for occ and enable files_external ────────────────────────── + log_info "Waiting for Nextcloud to initialize (up to 90s)..." + local _wait=0 + until docker exec nextcloud php occ status 2>/dev/null | grep -q "installed: true"; do + sleep 5; _wait=$((_wait+5)) + [ $_wait -ge 90 ] && { log_warning "Nextcloud not ready after 90s — enable files_external manually"; break; } 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 + if docker exec nextcloud php occ app:enable files_external 2>/dev/null; then + log_success "files_external app enabled (SMB/local external storage)" fi fi + # ── README ─────────────────────────────────────────────────────────────── + write_readme "$DIR" << NCREADME +# Nextcloud + +Self-hosted cloud storage with SMB/local file access. + +## Access + +- URL: https://cloud.${SITE_DOMAIN:-example.com} (or http://localhost:8080) +- Admin: admin +- Password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`$DIR/.env\` + +## Manage + +\`\`\`bash +docker compose up -d --build # start / rebuild +docker compose down # stop +docker compose logs -f # follow logs +docker compose pull && docker compose up -d --build # update +\`\`\` + +## Run occ commands + +\`\`\`bash +docker exec -u www-data nextcloud php occ +\`\`\` + +## Enable external storage (SMB / local) + +\`\`\`bash +docker exec -u www-data nextcloud php occ app:enable files_external +\`\`\` + +Then configure mounts in Nextcloud → Settings → External Storages. + +## Backup + +Back up these directories: +- \`$DIR/html\` — Nextcloud application files +- \`$DIR/config\` — configuration +- \`$DIR/custom_apps\` — third-party apps +- \`$DIR/db\` — MariaDB data +- \`$DIR/.env\` — credentials (permissions 600) +NCREADME + echo "" - echo " URL: http://localhost:8080" - echo " Admin user: admin" - echo " Admin password: $NC_ADMIN_PASS" - echo " (Credentials also saved to $DIR/.env)" + echo " Access URL: http://localhost:8080" + echo " Admin user: admin" + echo " Admin pass: $NC_ADMIN_PASS" + echo " Config dir: $DIR" echo "" - echo " To add SMB or local external storage:" - echo " Nextcloud → Admin → Administration → External Storage" + echo " Note: First startup may take 1-2 minutes while Nextcloud initialises." echo "" } diff --git a/services/onlyoffice.sh b/services/onlyoffice.sh index 481b884..7abba63 100644 --- a/services/onlyoffice.sh +++ b/services/onlyoffice.sh @@ -1,5 +1,5 @@ #!/bin/bash -# services/onlyoffice.sh — Self-hosted OnlyOffice Document Server. +# services/onlyoffice.sh — Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser). # Part of the modular post-install system (sourced by setup.sh). # # Can also be run standalone on any machine: @@ -7,6 +7,9 @@ # (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; } @@ -14,9 +17,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then _COMMON="$_SELF_DIR/../lib/common.sh" if [[ -f "$_COMMON" ]]; then + # Full repo present — use the real helpers (picks up ~/docker/.config too) # shellcheck source=../lib/common.sh source "$_COMMON" else + # One-off copy — inline minimal stubs so the script works without the repo log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } @@ -39,6 +44,19 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 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" + } + + write_readme() { + local _dir="$1"; shift + [[ "${DRY_RUN:-false}" == "true" ]] && return 0 + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + + # 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; } @@ -58,6 +76,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + # Support remote Caddy host via CADDY_REMOTE_HOST + if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then + log_info "Remote Caddy detected at $CADDY_REMOTE_HOST — printing block to add manually." + echo "" + echo " Add the following to your Caddyfile on $CADDY_REMOTE_HOST:" + echo " ──────────────────────────────────────────────────────────" + echo " # $_name" + echo " ${_subdomain}.${SITE_DOMAIN:-example.com} {" + echo " reverse_proxy $_upstream" + [[ -n "$_extra" ]] && echo "$_extra" + echo " }" + echo " ──────────────────────────────────────────────────────────" + return 0 + fi + if [[ ! -d "$_caddy_dir" ]]; then log_info "Access $_name directly on port ${_upstream##*:}." return 0 @@ -75,6 +108,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 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" @@ -83,6 +117,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 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="" @@ -121,19 +156,10 @@ CBLOCK 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}" @@ -143,129 +169,83 @@ CBLOCK SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" - register_service() { :; } + register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── register_service onlyoffice utilities "Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser)" 8082 -# ── Ensure yq v4 is installed ───────────────────────────────────────────────── +# ── Helper: install yq v4 if absent ────────────────────────────────────────── _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 + command -v yq &>/dev/null && return 0 + log_info "Installing yq (required for FileBrowser config patching)..." + local _arch; _arch=$(uname -m) + local _binary="yq_linux_amd64" + [[ "$_arch" == "aarch64" || "$_arch" == "arm64" ]] && _binary="yq_linux_arm64" + curl -fsSL "https://github.com/mikefarah/yq/releases/latest/download/${_binary}" \ + -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq \ + && log_success "yq installed" || log_warning "yq install failed — FileBrowser wiring skipped" } -# ── Wire OnlyOffice into Nextcloud ──────────────────────────────────────────── +# ── Helper: wire OnlyOffice into Nextcloud (idempotent) ─────────────────────── _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" + local _jwt="$1" + local _nc_container="nextcloud" + docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${_nc_container}$" || { + log_info "Nextcloud container not running — skipping Nextcloud wiring" 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" + } + log_info "Wiring OnlyOffice into Nextcloud..." + docker exec "$_nc_container" php occ app:enable onlyoffice 2>/dev/null || true + docker exec "$_nc_container" php occ config:system:set onlyoffice DocumentServerUrl \ + --value="https://office.${SITE_DOMAIN:-example.com}/" 2>/dev/null \ + && log_success "DocumentServerUrl set" || log_warning "Could not set DocumentServerUrl" + docker exec "$_nc_container" php occ config:system:set onlyoffice jwt_secret \ + --value="$_jwt" 2>/dev/null \ + && log_success "jwt_secret set" || log_warning "Could not set jwt_secret" + docker exec "$_nc_container" php occ config:system:set onlyoffice jwt_header \ + --value="AuthorizationJwt" 2>/dev/null \ + && log_success "jwt_header set" || log_warning "Could not set jwt_header" } -# ── Wire OnlyOffice into FileBrowser Quantum ────────────────────────────────── +# ── Helper: patch FileBrowser Quantum config.yaml with OnlyOffice endpoint ─── _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 + local _fbq_config="$DOCKER_DIR/filebrowser/config.yaml" + [[ -f "$_fbq_config" ]] || { log_info "FileBrowser config not found — skipping"; return 0; } + command -v yq &>/dev/null || { log_info "yq not found — skipping FileBrowser wiring"; return 0; } + log_info "Wiring OnlyOffice into FileBrowser Quantum..." + yq e '.officeServer = "http://onlyoffice:80/"' -i "$_fbq_config" \ + && log_success "FileBrowser officeServer set" || log_warning "Could not patch FileBrowser config" + docker restart filebrowser 2>/dev/null && log_success "FileBrowser restarted" || true } install_onlyoffice() { require_docker || return 1 - log_info "Installing OnlyOffice Document Server..." + _ensure_yq + 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)" + echo "[DRY-RUN] Would create $DIR with docker-compose.yml, .env" 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) + # ── Preserve JWT secret across re-runs ────────────────────────────────── 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' + # ── docker-compose.yml ────────────────────────────────────────────────── + cat > docker-compose.yml << 'OOCOMPOSE' name: onlyoffice - services: onlyoffice: image: onlyoffice/documentserver:latest @@ -273,6 +253,10 @@ services: hostname: onlyoffice restart: unless-stopped env_file: .env + volumes: + - ./logs:/var/log/onlyoffice + - ./data:/var/www/onlyoffice/Data + - ./fonts:/usr/share/fonts/truetype/custom ports: - "8082:80" networks: @@ -282,81 +266,115 @@ networks: caddy_net: external: true name: ${CADDY_NET:-caddy_net} -OO_COMPOSE +OOCOMPOSE - cat > .env << OO_ENV -# OnlyOffice Document Server — environment + # ── .env ──────────────────────────────────────────────────────────────── + cat > .env << OOENV 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 - +OOENV chmod 600 .env + + # ── Subdirectories ────────────────────────────────────────────────────── + mkdir -p logs data fonts chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" - # OnlyOffice must be embeddable as an iframe in Nextcloud/FileBrowser. - # Override X-Frame-Options to allow same-site embedding (remove SAMEORIGIN restriction). + echo "" + log_success "OnlyOffice configured at $DIR" + + # OnlyOffice must be embeddable as an iframe (Nextcloud / FileBrowser open + # documents in a frame). Override the default X-Frame-Options header that + # Caddy would otherwise set to SAMEORIGIN. local OO_EXTRA_BLOCK=' header { -X-Frame-Options Content-Security-Policy "frame-ancestors '\''self'\'' *" }' configure_caddy_for_service "OnlyOffice" "onlyoffice:80" "office" "$OO_EXTRA_BLOCK" - local START="" - prompt_yn "Start OnlyOffice now? (y/n):" "y" START - if [[ "$START" =~ ^[Yy]$ ]]; then + # ── Prompt to start ───────────────────────────────────────────────────── + local START_OO="" + prompt_yn "Start OnlyOffice now? (y/n):" "y" START_OO + if [ "$START_OO" = "y" ] || [ "$START_OO" = "Y" ]; then docker compose up -d \ && log_success "OnlyOffice started" \ || log_warning "Start failed — check: docker compose logs" fi - # Wire into integrations every run (idempotent) + # ── Wire integrations (runs every install/re-install) ─────────────────── echo "" _wire_nextcloud "$JWT_SECRET" _wire_filebrowser - write_readme "$DIR" << MD + # ── README ─────────────────────────────────────────────────────────────── + write_readme "$DIR" << OOREAD # OnlyOffice Document Server -Self-hosted collaborative editing for DOCX, XLSX, PPTX, and ODT files. -Integrates with Nextcloud and FileBrowser Quantum. -Port: 8082 (internal 80) +Self-hosted document editing server, integrated with Nextcloud and FileBrowser Quantum. -## 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\` +## Access -## 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 -\`\`\` +- URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:8082) +- The document server itself has no user-facing login page — it is accessed + through Nextcloud or FileBrowser Quantum. ## Manage + \`\`\`bash -cd $DIR -docker compose up -d # start -docker compose down # stop -docker compose logs -f # logs +docker compose up -d # start +docker compose down # stop +docker compose logs -f # follow logs docker compose pull && docker compose up -d # update \`\`\` -MD - log_success "OnlyOffice installed at $DIR" +## JWT secret rotation + +1. Generate a new secret: + \`\`\`bash + openssl rand -hex 24 + \`\`\` +2. Update \`JWT_SECRET\` in \`$DIR/.env\` +3. Restart OnlyOffice: + \`\`\`bash + docker compose restart + \`\`\` +4. Update Nextcloud's stored secret: + \`\`\`bash + docker exec nextcloud php occ config:system:set onlyoffice jwt_secret --value="" + \`\`\` + +## Verify Nextcloud integration + +\`\`\`bash +docker exec nextcloud php occ config:system:get onlyoffice +\`\`\` + +## Verify FileBrowser integration + +\`\`\`bash +grep officeServer $DOCKER_DIR/filebrowser/config.yaml +\`\`\` + +## Add custom fonts + +Copy \`.ttf\` / \`.otf\` font files into \`$DIR/fonts/\`, then restart the container. +OOREAD + echo "" - echo " Port: http://localhost:8082" - echo " JWT Secret: $JWT_SECRET" - echo " (Secret also saved to $DIR/.env)" + echo " OnlyOffice Document Server" + echo " Access URL: http://localhost:8082" + echo " JWT secret: $JWT_SECRET" + echo " Config dir: $DIR" + echo "" + echo " Integration status:" + docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^nextcloud$" \ + && echo " Nextcloud: wired (onlyoffice app + JWT configured)" \ + || echo " Nextcloud: not running — wire manually after starting Nextcloud" + [[ -f "$DOCKER_DIR/filebrowser/config.yaml" ]] \ + && echo " FileBrowser: config.yaml patched" \ + || echo " FileBrowser: config not found — will wire on next onlyoffice install" echo "" } diff --git a/vendor/easy-asterisk/.env.example b/vendor/easy-asterisk/.env.example index 332af67..a1e125b 100644 --- a/vendor/easy-asterisk/.env.example +++ b/vendor/easy-asterisk/.env.example @@ -1,99 +1,32 @@ # ================================================================ -# 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) +# Easy Asterisk — Environment Configuration +# Copy to .env and fill in your values. # ================================================================ -# ── 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= +# ── Domain (REQUIRED for remote/FQDN access) ────────────────── +# Your FQDN pointing to this server's public IP. +# Leave empty for LAN-only mode. +DOMAIN_NAME=asterisk.example.com -# ── 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/STUN ────────────────────────────────────────────────── +# Generate a strong password: openssl rand -base64 18 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_PASSWORD=changeme TURN_PORT=3478 +# Points to coturn. For LAN-only leave empty. +TURN_SERVER=${DOMAIN_NAME}:${TURN_PORT} -# ── 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 port range ───────────────────────────────────────────── 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 ────────────────────────────────────────────────── WEB_ADMIN_PORT=8080 +# Set to true if Authelia or another reverse proxy handles auth WEB_ADMIN_AUTH_DISABLED=false + +# ── Public IP (optional — auto-detected if empty) ───────────── +PUBLIC_IP= + +# ── Local network CIDR (optional — auto-detected if empty) ──── +LOCAL_CIDR= diff --git a/vendor/easy-asterisk/Dockerfile b/vendor/easy-asterisk/Dockerfile index 96a54d5..0ce4bc0 100644 --- a/vendor/easy-asterisk/Dockerfile +++ b/vendor/easy-asterisk/Dockerfile @@ -1,103 +1,60 @@ -# ================================================================ -# 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 +ENV LANG=en_US.UTF-8 \ + LANGUAGE=en_US:en \ + LC_ALL=en_US.UTF-8 \ + DEBIAN_FRONTEND=noninteractive -# 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 +RUN apt-get update && apt-get install -y --no-install-recommends \ + asterisk \ + asterisk-core-sounds-en \ + asterisk-core-sounds-en-wav \ + asterisk-moh-opsound-wav \ + tcpdump \ + sngrep \ + curl \ + dnsutils \ + iproute2 \ + net-tools \ + openssl \ + python3 \ + python3-pip \ + python3-bcrypt \ + locales \ + && locale-gen en_US.UTF-8 \ + && rm -rf /var/lib/apt/lists/* -# 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. +# NOTE: Opus transcoding (codec_opus.so) is NOT available on Ubuntu 24.04 +# due to a packaging bug. Opus pass-through still works via res_format_attr_opus.so. -# 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 \ +RUN mkdir -p /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 + /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 +# Management script and helpers 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 +RUN chmod +x /usr/local/bin/easy-asterisk \ + /usr/local/bin/vpn-diagnostics \ + /usr/local/bin/dns-whitelist \ + /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 5060/udp 5060/tcp 5061/tcp +EXPOSE 8080/tcp 8088/tcp 8089/tcp 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 + CMD asterisk -rx "core show version" || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/vendor/easy-asterisk/docker/coturn-entrypoint.sh b/vendor/easy-asterisk/docker/coturn-entrypoint.sh index e9aee8e..7431a81 100644 --- a/vendor/easy-asterisk/docker/coturn-entrypoint.sh +++ b/vendor/easy-asterisk/docker/coturn-entrypoint.sh @@ -1,26 +1,24 @@ -#!/bin/sh -# ================================================================ -# Robust coturn entrypoint +#!/bin/bash +# coturn-entrypoint.sh — robust wrapper for the coturn Docker image. # -# The coturn/coturn Docker image's native entrypoint uses: +# The coturn image's default 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:" +# which fails when detect-external-ip returns empty — produces a blank token +# and coturn logs "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. -# ================================================================ +# This wrapper avoids eval word-splitting and only adds --external-ip when +# an IP is actually obtained. -# Use explicit PUBLIC_IP if provided, otherwise auto-detect -if [ -z "$PUBLIC_IP" ]; then - PUBLIC_IP=$(detect-external-ip 2>/dev/null || true) +set -e + +# Use explicitly set PUBLIC_IP, or try auto-detection +ext_ip="${PUBLIC_IP:-}" +if [[ -z "$ext_ip" ]] && command -v detect-external-ip &>/dev/null; then + ext_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" +if [[ -n "$ext_ip" ]]; then + exec turnserver "$@" --external-ip="$ext_ip" +else + exec turnserver "$@" fi - -exec turnserver "$@" $EXTERNAL_IP_ARG diff --git a/vendor/easy-asterisk/docker/entrypoint.sh b/vendor/easy-asterisk/docker/entrypoint.sh old mode 100644 new mode 100755 diff --git a/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh b/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh old mode 100644 new mode 100755 diff --git a/vendor/easy-asterisk/scripts/dns-whitelist.sh b/vendor/easy-asterisk/scripts/dns-whitelist.sh old mode 100644 new mode 100755 index 61da578..3500017 --- a/vendor/easy-asterisk/scripts/dns-whitelist.sh +++ b/vendor/easy-asterisk/scripts/dns-whitelist.sh @@ -30,251 +30,26 @@ while [[ $# -gt 0 ]]; do --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 - ;; + 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 +echo -e "${CYAN}━━━ DNS Whitelist for Easy Asterisk ━━━${NC}" +echo "" +echo -e "${BOLD}Mode: ${NC}$( [[ -n "$DOMAIN_NAME" ]] && echo "FQDN ($DOMAIN_NAME)" || echo "LAN/VPN (no domain)" )" +echo "" +echo -e "${BOLD}Server DNS filter:${NC}" +echo -e " ifconfig.me, icanhazip.com (public IP detection, FQDN mode only)" +echo -e " acme-v02.api.letsencrypt.org (Let's Encrypt, if used)" +echo "" +echo -e "${BOLD}Client DNS filter (Sipnetic/Linphone):${NC}" +echo -e " LAN/VPN mode: none (configure by IP)" +echo -e " FQDN mode: your domain ($DOMAIN_NAME)" +echo "" diff --git a/vendor/easy-asterisk/scripts/vpn-diagnostics.sh b/vendor/easy-asterisk/scripts/vpn-diagnostics.sh old mode 100644 new mode 100755 index 550f7b5..9694f66 --- a/vendor/easy-asterisk/scripts/vpn-diagnostics.sh +++ b/vendor/easy-asterisk/scripts/vpn-diagnostics.sh @@ -1,366 +1,40 @@ #!/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. -# +# Validates PJSIP, TLS, ICE, RTP, and device configuration. # 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' +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; 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 -e "${CYAN}━━━ Easy Asterisk VPN Diagnostics ━━━${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" +# Check Asterisk is running +if ! asterisk -rx "core show version" &>/dev/null; then + echo -e "${RED}✗ Asterisk is not running${NC}"; exit 1 fi +echo -e "${GREEN}✓ Asterisk running:${NC} $(asterisk -rx "core show version" 2>/dev/null)" -# ── Test 2: Check Asterisk PJSIP transport configuration ───── +# Check transports echo "" -echo -e "${BOLD}2. Asterisk Transport Configuration${NC}" +echo -e "${CYAN}Transports:${NC}" +asterisk -rx "pjsip show transports" 2>/dev/null || true + +# Check registered endpoints 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 +echo -e "${CYAN}Endpoints:${NC}" +asterisk -rx "pjsip show endpoints" 2>/dev/null || true # 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" +if [[ -f /etc/asterisk/certs/server.crt ]]; then + exp=$(openssl x509 -in /etc/asterisk/certs/server.crt -noout -enddate 2>/dev/null | cut -d= -f2) + echo -e "${GREEN}✓ TLS cert:${NC} expires $exp" + openssl x509 -in /etc/asterisk/certs/server.crt -noout -ext subjectAltName 2>/dev/null | grep -q "DNS:" \ + && echo -e "${GREEN}✓ SANs present (mobile-compatible)${NC}" \ + || echo -e "${YELLOW}! No SANs — mobile clients may reject cert${NC}" fi echo ""