diff --git a/services/asterisk-digital-ocean.sh b/services/asterisk-digital-ocean.sh index 0f93e63..e45a2c2 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -543,78 +543,20 @@ WEB_ADMIN_AUTH_DISABLED=false ENV chmod 600 .env - # ── UFW firewall rules (host-level) ─────────────────────────────────────── - if command -v ufw &>/dev/null; then - log_info "Opening UFW ports for Asterisk + coturn..." - ufw allow 5060/udp - ufw allow 5060/tcp - ufw allow 5061/tcp - ufw allow "${WEB_ADMIN_PORT_VAL}/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 - fi - - if command -v ufw &>/dev/null; then - log_success "UFW rules added." - fi - - # ── DigitalOcean Cloud Firewall (network edge, in front of the droplet) ─── - local DO_FW_RULES=( - "protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0" - "protocol:tcp,ports:5060,address:0.0.0.0/0,address:::/0" - "protocol:udp,ports:5060,address:0.0.0.0/0,address:::/0" - "protocol:tcp,ports:5061,address:0.0.0.0/0,address:::/0" - "protocol:tcp,ports:${WEB_ADMIN_PORT_VAL},address:0.0.0.0/0,address:::/0" - "protocol:tcp,ports:8088-8089,address:0.0.0.0/0,address:::/0" - "protocol:tcp,ports:3478,address:0.0.0.0/0,address:::/0" - "protocol:udp,ports:3478,address:0.0.0.0/0,address:::/0" - "protocol:udp,ports:10000-20000,address:0.0.0.0/0,address:::/0" - "protocol:udp,ports:49152-49252,address:0.0.0.0/0,address:::/0" - ) - - echo "" - if [[ -n "$DROPLET_ID" ]] && command -v doctl &>/dev/null && doctl account get &>/dev/null; then - local EXISTING_FW - EXISTING_FW="$(doctl compute firewall list --format ID,DropletIDs --no-header 2>/dev/null \ - | grep -E "(^|[, ])${DROPLET_ID}([, ]|\$)" | awk '{print $1}' | head -1)" - - if [[ -n "$EXISTING_FW" ]]; then - log_warning "A Cloud Firewall (id $EXISTING_FW) is already attached to this droplet — not touching it." - log_warning "Add these inbound rules to it yourself (Networking → Firewalls in the DO console):" - printf ' %s\n' "${DO_FW_RULES[@]}" - else - local DO_FW="" - prompt_yn "Create a DigitalOcean Cloud Firewall for this droplet via doctl now? (y/n):" "y" DO_FW - if [[ "$DO_FW" =~ ^[Yy]$ ]]; then - if doctl compute firewall create \ - --name "asterisk-digital-ocean" \ - --droplet-ids "$DROPLET_ID" \ - --inbound-rules "$(IFS=' '; echo "${DO_FW_RULES[*]}")" \ - --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0 protocol:icmp,ports:0,address:0.0.0.0/0,address:::/0" \ - &>/dev/null; then - log_success "Cloud Firewall 'asterisk-digital-ocean' created and attached (SSH/22 included so you don't get locked out)." - log_info "Verify it in the DO console — adjust the SSH rule if you use a non-default SSH port." - else - log_warning "doctl firewall create failed — add the rules manually (see README)." - fi - fi - fi - else - log_info "doctl not installed/authenticated — configure a DigitalOcean Cloud Firewall manually:" - log_info "Control Panel → Networking → Firewalls → create, attach to this droplet, allow:" - printf ' %s\n' "${DO_FW_RULES[@]}" - fi - # ── Caddy: reverse-proxy the web admin on the SAME FQDN used for SIP ────── # Caddy only holds a cert for domains it's actively serving. If the web # admin were proxied on a different "admin" subdomain, Caddy would obtain # a cert for THAT domain instead — the sync earlier would never find one # matching $DOMAIN_NAME, and SIP TLS would silently stay self-signed. So # there's no separate domain prompt: this always targets $DOMAIN_NAME. + # + # Decided before the firewall rules below so they can be scoped + # correctly: if Caddy ends up fronting the web admin locally, there's no + # reason to also expose it directly to the internet — Caddy already + # reaches it over the host's internal network (host.docker.internal), + # and leaving the bare IP:port open would let anyone bypass Caddy/ + # Authelia entirely. + local WEB_ADMIN_PUBLIC_ACCESS_NEEDED=true if [[ -z "$DOMAIN_NAME" ]]; then log_info "No FQDN set — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL} (nothing for Caddy to do)." elif [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -z "${CADDY_REMOTE_HOST:-}" ]]; then @@ -705,6 +647,9 @@ CADDY_BLOCK )" if [[ "$_CADDY_MODE" == "local" ]]; then + # Caddy reaches this over the host's internal network — no + # need to keep the port open to the public internet. + WEB_ADMIN_PUBLIC_ACCESS_NEEDED=false local _CADDYFILE="$DOCKER_DIR/caddy/Caddyfile" local _CADDY_BACKUP="$_CADDYFILE.backup.$(date +%Y%m%d-%H%M%S)" if [[ -f "$_CADDYFILE" ]]; then @@ -739,10 +684,83 @@ CADDY_BLOCK chown "$ACTUAL_USER:$ACTUAL_USER" "$_SNIPPET_DIR/asterisk-digital-ocean.caddy" 2>/dev/null || true log_success "Snippet saved: $_SNIPPET_DIR/asterisk-digital-ocean.caddy" log_info "Copy to your Caddy machine: scp $_SNIPPET_DIR/asterisk-digital-ocean.caddy caddy-host:~/caddy-snippets/" + log_info "Remote Caddy reaches this droplet over its public IP, so the web admin port stays open below." fi fi fi + # ── UFW firewall rules (host-level) ─────────────────────────────────────── + if command -v ufw &>/dev/null; then + log_info "Opening UFW ports for Asterisk + coturn..." + ufw allow 5060/udp + ufw allow 5060/tcp + ufw allow 5061/tcp + if [[ "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" == true ]]; then + ufw allow "${WEB_ADMIN_PORT_VAL}/tcp" + else + ufw delete allow "${WEB_ADMIN_PORT_VAL}/tcp" 2>/dev/null || true + log_info "Web admin port ${WEB_ADMIN_PORT_VAL} kept closed to the internet — Caddy fronts it locally." + fi + ufw allow 8088/tcp + ufw allow 8089/tcp + ufw allow 3478/udp + ufw allow 3478/tcp + ufw allow 10000:20000/udp + ufw allow 49152:49252/udp + log_success "UFW rules added." + fi + + # ── DigitalOcean Cloud Firewall (network edge, in front of the droplet) ─── + local DO_FW_RULES=( + "protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0" + "protocol:tcp,ports:5060,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:5060,address:0.0.0.0/0,address:::/0" + "protocol:tcp,ports:5061,address:0.0.0.0/0,address:::/0" + ) + if [[ "$WEB_ADMIN_PUBLIC_ACCESS_NEEDED" == true ]]; then + DO_FW_RULES+=("protocol:tcp,ports:${WEB_ADMIN_PORT_VAL},address:0.0.0.0/0,address:::/0") + fi + DO_FW_RULES+=( + "protocol:tcp,ports:8088-8089,address:0.0.0.0/0,address:::/0" + "protocol:tcp,ports:3478,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:3478,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:10000-20000,address:0.0.0.0/0,address:::/0" + "protocol:udp,ports:49152-49252,address:0.0.0.0/0,address:::/0" + ) + + echo "" + if [[ -n "$DROPLET_ID" ]] && command -v doctl &>/dev/null && doctl account get &>/dev/null; then + local EXISTING_FW + EXISTING_FW="$(doctl compute firewall list --format ID,DropletIDs --no-header 2>/dev/null \ + | grep -E "(^|[, ])${DROPLET_ID}([, ]|\$)" | awk '{print $1}' | head -1)" + + if [[ -n "$EXISTING_FW" ]]; then + log_warning "A Cloud Firewall (id $EXISTING_FW) is already attached to this droplet — not touching it." + log_warning "Add these inbound rules to it yourself (Networking → Firewalls in the DO console):" + printf ' %s\n' "${DO_FW_RULES[@]}" + else + local DO_FW="" + prompt_yn "Create a DigitalOcean Cloud Firewall for this droplet via doctl now? (y/n):" "y" DO_FW + if [[ "$DO_FW" =~ ^[Yy]$ ]]; then + if doctl compute firewall create \ + --name "asterisk-digital-ocean" \ + --droplet-ids "$DROPLET_ID" \ + --inbound-rules "$(IFS=' '; echo "${DO_FW_RULES[*]}")" \ + --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0 protocol:icmp,ports:0,address:0.0.0.0/0,address:::/0" \ + &>/dev/null; then + log_success "Cloud Firewall 'asterisk-digital-ocean' created and attached (SSH/22 included so you don't get locked out)." + log_info "Verify it in the DO console — adjust the SSH rule if you use a non-default SSH port." + else + log_warning "doctl firewall create failed — add the rules manually (see README)." + fi + fi + fi + else + log_info "doctl not installed/authenticated — configure a DigitalOcean Cloud Firewall manually:" + log_info "Control Panel → Networking → Firewalls → create, attach to this droplet, allow:" + printf ' %s\n' "${DO_FW_RULES[@]}" + fi + # ── CrowdSec note ────────────────────────────────────────────────────────── # Not installed here — select it separately from the whiptail menu, or # `sudo ./setup.sh crowdsec`. Its own installer (services/crowdsec.sh) @@ -833,7 +851,7 @@ plan for the admin panel. | 22 | TCP | SSH (keep this open or you're locked out) | | 5060 | UDP/TCP | SIP signalling (unencrypted) | | 5061 | TCP | SIP over TLS | -| ${WEB_ADMIN_PORT_VAL} | TCP | Easy Asterisk web admin (auto-picked — see \`.env\`) | +| ${WEB_ADMIN_PORT_VAL} | TCP | Easy Asterisk web admin (auto-picked — see \`.env\`). Only opened publicly if Caddy isn't fronting it locally — otherwise it's reachable only via \`https://${DOMAIN_NAME:-your-domain}/\`, not the bare IP:port. | | 8088/8089 | TCP | Asterisk HTTP/WS (ARI/AMI) | | 3478 | UDP/TCP | TURN/STUN (coturn) | | 10000–20000 | UDP | RTP media streams | diff --git a/vendor/easy-asterisk/docker/entrypoint.sh b/vendor/easy-asterisk/docker/entrypoint.sh index 1cadc6d..943b3d6 100755 --- a/vendor/easy-asterisk/docker/entrypoint.sh +++ b/vendor/easy-asterisk/docker/entrypoint.sh @@ -431,8 +431,15 @@ fi chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk 2>/dev/null || true # ── 10. Start Web Admin in background ───────────────────────── -# The web admin script is generated by the 'easy-asterisk' management tool. -# On first run: docker exec -it easy-asterisk easy-asterisk → Web Admin menu → Start +# The web admin script is generated by the 'easy-asterisk' management tool, +# but it only lives in the container's writable layer (not baked into the +# image or bind-mounted), so it's gone every time the container is +# recreated. Regenerate it unconditionally instead of only starting it if +# it happens to already exist — otherwise the web admin never comes back +# on its own after a restart, requiring a manual CLI trip every time. +if [[ -x /usr/local/bin/easy-asterisk ]]; then + /usr/local/bin/easy-asterisk --write-web-admin-script >/dev/null 2>&1 || true +fi if [[ -f "$WEB_ADMIN_SCRIPT" ]]; then log_info "Starting Web Admin on port ${WEB_ADMIN_PORT:-8080}..." WEBADMIN_PORT="${WEB_ADMIN_PORT:-8080}" \ diff --git a/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh b/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh index 958c8ce..4aad948 100755 --- a/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh +++ b/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh @@ -4779,7 +4779,13 @@ qualify_frequency=30 subprocess.run(['chown', 'asterisk:asterisk', PJSIP_CONF], capture_output=True) subprocess.run(['/usr/local/bin/easy-asterisk', '--rebuild-dialplan'], capture_output=True) - return True, {'extension': extension, 'password': password, 'name': name} + return True, { + 'extension': extension, + 'password': password, + 'name': name, + 'transport': 'tls' if conn_type == 'fqdn' else 'udp', + 'port': 5061 if conn_type == 'fqdn' else 5060, + } def get_server_info(): """Get server configuration info including TURN/STUN details""" @@ -5099,7 +5105,9 @@ HTML_TEMPLATE = '''
Save these credentials - the password cannot be retrieved later
-Extension: ${result.data.extension}
-Password: ${result.data.password}
-Name: ${result.data.name}
- `; - // Fetch server info to show TURN details + const d = result.data; + const transportLabel = d.transport === 'tls' ? 'TLS' : 'UDP'; + + // Fetch server info for the domain and TURN details + let srv = {}; try { const srvRes = await fetch(API_BASE + '/server'); - const srv = await srvRes.json(); - if (srv.turn_enabled && srv.turn_server) { - credHtml += `STUN/TURN (configure in app Network settings)
-STUN/TURN server: ${srv.turn_server}
-TURN username: ${srv.turn_username}
-TURN password: ${srv.turn_password}
- `; - } + srv = await srvRes.json(); } catch(e) {} + const server = srv.domain || srv.server_ip || ''; + + // Everything a SIP client (e.g. Sipnetic) needs, kept in one + // object so both the display and the copy buttons read from + // the same source instead of re-parsing rendered HTML. + lastDeviceCreds = { + name: d.name, + server: server, + port: d.port, + transport: transportLabel, + username: d.extension, + password: d.password, + turnEnabled: !!(srv.turn_enabled && srv.turn_server), + turnServer: srv.turn_server || '', + turnUsername: srv.turn_username || '', + turnPassword: srv.turn_password || '' + }; + + let credHtml = ` +Display Name: ${lastDeviceCreds.name}
+Server: ${lastDeviceCreds.server}
+Port: ${lastDeviceCreds.port}
+Transport: ${lastDeviceCreds.transport}
+Username: ${lastDeviceCreds.username}
+Password: ${lastDeviceCreds.password}
+ `; + if (lastDeviceCreds.turnEnabled) { + credHtml += `STUN/TURN (configure in app Network settings)
+STUN/TURN server: ${lastDeviceCreds.turnServer}
+TURN username: ${lastDeviceCreds.turnUsername}
+TURN password: ${lastDeviceCreds.turnPassword}
+ `; + } document.getElementById('credentials-display').innerHTML = credHtml; document.getElementById('credentials-modal').classList.add('active'); } else { @@ -5730,6 +5763,57 @@ HTML_TEMPLATE = ''' } } + // Holds the most recently created device's credentials so the + // Copy All / Copy Password buttons don't need to re-parse the DOM. + let lastDeviceCreds = null; + + function copyToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + // Fallback for non-HTTPS/non-secure contexts where the Clipboard + // API is unavailable (e.g. plain-HTTP self-signed-cert access). + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { + document.execCommand('copy'); + } finally { + document.body.removeChild(ta); + } + return Promise.resolve(); + } + + function copyAllCredentials() { + if (!lastDeviceCreds) return; + const c = lastDeviceCreds; + let text = `Display Name: ${c.name}\n` + + `Server: ${c.server}\n` + + `Port: ${c.port}\n` + + `Transport: ${c.transport}\n` + + `Username: ${c.username}\n` + + `Password: ${c.password}\n`; + if (c.turnEnabled) { + text += `STUN/TURN server: ${c.turnServer}\n` + + `TURN username: ${c.turnUsername}\n` + + `TURN password: ${c.turnPassword}\n`; + } + copyToClipboard(text) + .then(() => showAlert('All settings copied to clipboard', 'success')) + .catch(() => showAlert('Copy failed — select and copy manually', 'error')); + } + + function copyDevicePassword() { + if (!lastDeviceCreds) return; + copyToClipboard(lastDeviceCreds.password) + .then(() => showAlert('Password copied to clipboard', 'success')) + .catch(() => showAlert('Copy failed — select and copy manually', 'error')); + } + async function deleteDevice(ext, name) { if (!confirm(`Delete device ${ext} (${name})?`)) return; @@ -6940,4 +7024,16 @@ if [[ "${1:-}" == "--rebuild-dialplan" ]]; then exit 0 fi +# Non-interactive entry point used by the container entrypoint on every +# start. create_web_admin_script() writes /usr/local/bin/easy-asterisk- +# webadmin, but that file lives only in the container's writable layer — +# it isn't baked into the image or bind-mounted anywhere — so it's wiped +# on every recreate. Without regenerating it here, the web admin only +# ever starts after someone manually runs it once from the interactive +# CLI menu, which defeats the point of it auto-starting at all. +if [[ "${1:-}" == "--write-web-admin-script" ]]; then + create_web_admin_script + exit 0 +fi + main "$@"