From 24827a326f5d17655f7227c455f0bab9e3fb2aba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:00:28 +0000 Subject: [PATCH 1/3] Show full SIP client config in web admin's device-created popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "device created" modal only ever showed extension/password/name, so setting up a client (e.g. Sipnetic) meant hunting down the server domain, port, and transport separately — and the password is only ever shown this once, so re-checking it later isn't an option. Now shows everything a SIP client needs in one place: display name, server, port, transport, username, password, plus TURN/STUN details when enabled. The backend reports the actual transport/port used (the container always forces TLS/FQDN mode regardless of what's selected in the form, so the frontend no longer has to guess). Added "Copy All" and "Copy Password" buttons, with a document. execCommand fallback for contexts where the Clipboard API isn't available (e.g. plain-HTTP self-signed-cert access). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi --- vendor/easy-asterisk/easy-asterisk-v0.10.0.sh | 118 +++++++++++++++--- 1 file changed, 101 insertions(+), 17 deletions(-) diff --git a/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh b/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh index 958c8ce..3cd0981 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

-
+
+ +
@@ -5702,24 +5710,49 @@ HTML_TEMPLATE = ''' if (result.success) { closeModal(); - let credHtml = ` -

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; From 52db0fa92163a593c973373ff43cb09d11158446 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:13:39 +0000 Subject: [PATCH 2/3] Auto-start the web admin on every container start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web admin script (/usr/local/bin/easy-asterisk-webadmin) is generated on demand by the interactive CLI, but only ever lived in the container's writable layer — not baked into the image, not bind-mounted. Every docker compose down/up wiped it, and the entrypoint's start logic only ran "if the file already exists", so it silently never started again until someone manually ran the CLI's Web Admin menu once per recreate. Added a --write-web-admin-script non-interactive entry point (same pattern as --rebuild-dialplan) and call it unconditionally before the existence check, so the web admin comes back on its own every time the container starts. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi --- vendor/easy-asterisk/docker/entrypoint.sh | 11 +++++++++-- vendor/easy-asterisk/easy-asterisk-v0.10.0.sh | 12 ++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) 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 3cd0981..4aad948 100755 --- a/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh +++ b/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh @@ -7024,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 "$@" From fe7129c97403fc7619c2d6997d47f4e7d9b0a07a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:13:46 +0000 Subject: [PATCH 3/3] Stop exposing the web admin port publicly when Caddy fronts it locally UFW and the DO Cloud Firewall both opened the web admin port to 0.0.0.0/0 unconditionally, even when Caddy+Authelia was configured to protect it on the actual domain. Caddy reaches the container over the host's internal network (host.docker.internal), not the public internet, so that direct port was pure attack surface: anyone could hit http://:/clients directly, fully bypassing Authelia and the built-in web admin auth (which gets disabled whenever Authelia is handling it instead). Reordered the install flow so the Caddy reverse-proxy decision is made before the firewall rules are built, and only open the web admin port publicly when there's no local Caddy actually fronting it (no domain, Caddy not installed, proxy declined, or a remote Caddy machine that needs to reach it over the public IP instead). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015X1jRGHwrvovz2qkhKfDZi --- services/asterisk-digital-ocean.sh | 152 ++++++++++++++++------------- 1 file changed, 85 insertions(+), 67 deletions(-) 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 |