From eaca45c83cdaf56564b384f89a3127dcdcd4558b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 20:19:46 +0000 Subject: [PATCH] Read domain/TURN from Easy Asterisk's own config, and offer a settings download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel kept showing "no domain set" and "TURN not configured" on a box that has both. The cause was reading the wrong file: I had it reading the host-side .env, a 600 file needing an ACL grant, a ProtectSystem exception and a systemd Environment= line to even locate — three things that each had to be right, and weren't. The vendored web admin gets this right because it reads /etc/easy-asterisk/config, written by the container's own entrypoint. That is mounted on the host inside ASTERISK_EA_CONFIG_DIR, a directory this dashboard is already granted read access to for rooms.conf. So it now reads that first, falls back to .env, and finally to `docker exec cat` of the same file — each source only filling gaps the previous left. No new permissions, and the value shown is what Asterisk is actually running with rather than what the installer asked for. Also adds the requested provisioning file: a "Download settings" link per extension serving a plain text file with server, username, password, display name, transport, port, SRTP, a ready-made SIP URI, and TURN server/user/ password. Deliberately not a vendor-specific format — Sipnetic, Linphone, Zoiper and Groundwire each want a different one and none could be verified from here, and a confidently-wrong .xml is worse than a file you can read. It warns in-file when the extension is UDP-only, when the cert is self-signed, and that it contains a password. The panel gains the SIP URI and says when the address shown is the host IP rather than a configured domain. Fixes a JS syntax error introduced with that note: an apostrophe escaped for Python's benefit left a bare quote inside a single-quoted JS string, which broke the whole page script — the table rendered empty. Now checked properly by extracting the script and running `node --check` over it, which catches this class of fault directly instead of inferring it from missing elements. Verified with only Easy Asterisk's config present and .env absent entirely — the state that failed before: domain, TURN server, user and password all resolve, the download serves with the right filename, and the page has no errors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh --- services/security-dashboard.sh | 199 +++++++++++++++++++++++++++------ 1 file changed, 167 insertions(+), 32 deletions(-) diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index ac00cac..3482b15 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -841,6 +841,7 @@ import json import os import re import subprocess +import time import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -1834,18 +1835,13 @@ EA_PJSIP_CONTAINER_PATH = "/etc/asterisk/pjsip.conf" ASTERISK_EA_ENV = os.environ.get("ASTERISK_EA_ENV", "") -def _read_ea_env(): - """DOMAIN_NAME / TURN_* out of the Asterisk install's .env. - - Everything a softphone needs beyond the extension itself lives here, and - it is the single reason the dashboard is granted read on that file (see - _secdash_grant_asterisk_access). Missing or unreadable is not an error — - the UI just shows what it can and says the rest is unavailable.""" +def _parse_env_file(path): + """KEY=value pairs out of a shell-style config file, quotes stripped.""" values = {} - if not ASTERISK_EA_ENV or not os.path.isfile(ASTERISK_EA_ENV): + if not path or not os.path.isfile(path): return values try: - with open(ASTERISK_EA_ENV, errors="replace") as f: + with open(path, errors="replace") as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: @@ -1853,25 +1849,71 @@ def _read_ea_env(): key, _, val = line.partition("=") values[key.strip()] = val.strip().strip('"').strip("'") except OSError: - pass + return {} + return values + + +def _ea_server_config_path(): + """Easy Asterisk's own runtime config — /etc/easy-asterisk/config inside + the container, which is this directory on the host. + + This, not the host .env, is what the vendored web admin reads for domain + and TURN details (see get_server_info() in + vendor/easy-asterisk/easy-asterisk-v0.10.0.sh), and it's the reason that + page shows them correctly. It's also written by the container's own + entrypoint, so it reflects what Asterisk is actually running with rather + than what .env asked for — and it sits in a directory this dashboard is + already granted read access to for rooms.conf, so it needs no extra + permissions, no ACL on a 600 file, and no ProtectSystem exception.""" + return os.path.join(ASTERISK_EA_CONFIG_DIR, "config") if ASTERISK_EA_CONFIG_DIR else "" + + +def _read_ea_env(): + """Domain/TURN settings, preferring Easy Asterisk's own config over .env. + + Three sources, in order of how much they reflect reality: + 1. /etc/easy-asterisk/config — what the running Asterisk was configured with + 2. the host .env — what the installer asked for + 3. `docker exec cat` — same file as (1), for when host-side + permissions get in the way anyway + Later sources only fill gaps; they never override a value already found.""" + values = _parse_env_file(_ea_server_config_path()) + for key, val in _parse_env_file(ASTERISK_EA_ENV).items(): + values.setdefault(key, val) + if not values.get("TURN_SERVER") or not values.get("DOMAIN_NAME"): + ok, out, _err = run_sudo( + ["docker", "exec", ASTERISK_EA_CONTAINER, "cat", "/etc/easy-asterisk/config"] + ) if ASTERISK_EA_CONTAINER else (False, "", "") + if ok and out: + for line in out.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + key = key.strip() + val = val.strip().strip('"').strip("'") + if val and not values.get(key): + values[key] = val return values def _ea_env_problem(): - """Why .env couldn't be read, in words, or "" if it was fine. + """Why domain/TURN couldn't be found, in words, or "" if they were. - "not configured" is the wrong message when the truth is "this service was - never told where the file is" or "permission denied" — both of which - happened in practice and both of which look identical from the UI unless - the reason is carried through.""" - if not ASTERISK_EA_ENV: - return ("This service doesn't know where Asterisk's .env is " - "(ASTERISK_EA_ENV unset). Re-run: sudo ./setup.sh security-dashboard") - if not os.path.isfile(ASTERISK_EA_ENV): - return "Asterisk's .env not found at %s" % ASTERISK_EA_ENV - if not os.access(ASTERISK_EA_ENV, os.R_OK): - return ("No permission to read %s. Re-run: sudo ./setup.sh security-dashboard" - % ASTERISK_EA_ENV) + "not configured" is the wrong message when the truth is "no source was + readable" — and it was, in practice, for a reason no one could see from + the page.""" + sources = [p for p in (_ea_server_config_path(), ASTERISK_EA_ENV) if p] + if not sources: + return ("This service doesn't know where Asterisk's config lives. " + "Re-run: sudo ./setup.sh security-dashboard") + existing = [p for p in sources if os.path.isfile(p)] + if not existing: + return "No Asterisk config found at: " + " or ".join(sources) + unreadable = [p for p in existing if not os.access(p, os.R_OK)] + if len(unreadable) == len(existing): + return ("No permission to read " + " or ".join(unreadable) + + ". Re-run: sudo ./setup.sh security-dashboard") return "" @@ -1887,14 +1929,17 @@ def ea_connection_defaults(): domain = env.get("DOMAIN_NAME", "") problem = _ea_env_problem() turn_server = env.get("TURN_SERVER", "") - # A readable .env with an empty TURN_SERVER is its own distinct case: the - # install simply never set one (LAN-only with no FQDN), which is not an - # error and shouldn't read like one. + # TURN_SERVER may be bare host:port or just a host; the port defaults to + # whatever coturn was given. + if turn_server and ":" not in turn_server: + turn_server = "%s:%s" % (turn_server, env.get("TURN_PORT", "3478")) if not problem and not turn_server: - problem = ("TURN_SERVER is empty in %s — set it there and restart Asterisk " - "if this phone needs a relay." % ASTERISK_EA_ENV) + problem = ("No TURN_SERVER in %s — this install was set up without one, so " + "phones have no relay to fall back on for NAT traversal." + % (_ea_server_config_path() or ASTERISK_EA_ENV)) return { "domain": domain, + "server_ip": _host_ip(), "default_conn_type": "fqdn" if domain else "lan", "turn_server": turn_server, "turn_username": env.get("TURN_USERNAME", ""), @@ -1904,6 +1949,16 @@ def ea_connection_defaults(): } +def _host_ip(): + """This box's own address, for when no domain is configured — the vendored + admin shows the same thing via `hostname -I`.""" + try: + out = subprocess.run(["hostname", "-I"], capture_output=True, text=True, timeout=5).stdout + return out.split()[0] if out.split() else "" + except Exception: # noqa: BLE001 + return "" + + def ea_device_details(extension): """Everything needed to configure a softphone for one extension. @@ -1947,7 +2002,8 @@ def ea_device_details(extension): "transport": "TLS" if tls else "UDP", "port": 5061 if tls else 5060, "encryption": device.get("encryption", "no"), - "server": conn["domain"] or "", + "server": conn["domain"] or conn.get("server_ip", ""), + "server_is_ip": not conn["domain"], "turn_server": conn["turn_server"], "turn_username": conn["turn_username"], "turn_password": conn["turn_password"], @@ -2199,6 +2255,66 @@ def ea_add_device(name, category, extension, conn_type="lan", auto_answer=None): } +def ea_device_provisioning(extension): + """Every setting needed to configure a softphone, as a plain text file. + + Deliberately not a vendor-specific provisioning format: Sipnetic, Linphone, + Zoiper and Groundwire each want a different one, none of which could be + verified from here, and a confidently-wrong .xml is worse than a file you + can read. This is the same data the panel shows, in a form that survives + being emailed to yourself and opened on the phone.""" + d = ea_device_details(extension) + if not d: + return None, None + host = d["server"] or "" + lines = [ + "Easy Asterisk — SIP account details", + "Extension %s (%s)" % (d["extension"], d["name"] or "unnamed"), + "Generated %s" % time.strftime("%Y-%m-%d %H:%M:%S %Z"), + "", + "ACCOUNT", + " SIP server / domain : %s" % host, + " Username : %s" % d["extension"], + " Auth username : %s" % d["extension"], + " Password : %s" % (d["password"] or "(not found in pjsip.conf)"), + " Display name : %s" % (d["name"] or d["extension"]), + "", + "TRANSPORT", + " Transport : %s" % d["transport"], + " Port : %s" % d["port"], + " Media encryption : %s" % ("SRTP (%s)" % d["encryption"] if d["encryption"] and d["encryption"] != "no" else "none"), + " SIP URI : sip:%s@%s:%s;transport=%s" % ( + d["extension"], host, d["port"], d["transport"].lower()), + "", + "NAT TRAVERSAL (TURN/STUN)", + ] + if d["turn_server"]: + lines += [ + " TURN/STUN server : %s" % d["turn_server"], + " TURN username : %s" % d["turn_username"], + " TURN password : %s" % d["turn_password"], + " ICE : enable", + ] + else: + lines += [" Not configured on this install — leave TURN/STUN off."] + lines += [ + "", + "NOTES", + " * This file contains a password. Delete it once the phone is set up.", + ] + if d.get("server_is_ip"): + lines.append(" * No domain is configured, so the address above is this box's IP " + "and the TLS certificate is self-signed — the phone must be told to accept it.") + if d["transport"] == "UDP": + lines.append(" * This extension is UDP-only. A phone configured for TLS on 5061 " + "will not register against it.") + if d.get("mobile"): + lines.append(" * Tagged as a mobile/cellular device: RTP keepalive is on to hold " + "the NAT binding open.") + return "%s-extension-%s.txt" % ( + re.sub(r"[^A-Za-z0-9._-]", "-", host), d["extension"]), "\n".join(lines) + "\n" + + def _ea_edit_device_block(extension, mutate): """Rewrite one device's endpoint stanza in place. @@ -3746,8 +3862,10 @@ async function showEaDeviceDetails(ext) { openDetailsExt = ext; const colspan = anchor.children.length; - const server = d.server - || "(no domain in Asterisk's .env — use this box's public IP)"; + const server = d.server || "(unknown — no domain or IP found)"; + const serverNote = d.server_is_ip + ? ' (no domain configured — using this host address; TLS cert is self-signed)' + : ""; const turn = d.turn_server ? `TURN server${esc(d.turn_server)} TURN user${esc(d.turn_username)} @@ -3763,7 +3881,8 @@ async function showEaDeviceDetails(ext) { ${esc(d.status)} - + + @@ -3775,6 +3894,7 @@ async function showEaDeviceDetails(ext) { Switch to ${d.transport === "TLS" ? "LAN (UDP 5060)" : "Remote/FQDN (TLS 5061)"} + Download settings ${d.env_error ? `

${esc(d.env_error)}

` : ""} @@ -4274,6 +4394,21 @@ class Handler(BaseHTTPRequestHandler): payload.update(ea_connection_defaults()) payload.pop("turn_password", None) # not needed until a row asks self._json(payload) + elif self.path.startswith("/api/ea-device-provisioning?"): + qs = urllib.parse.parse_qs(self.path.split("?", 1)[1]) + filename, body = ea_device_provisioning((qs.get("ext") or [""])[0]) + if not body: + self._json({"error": "not found"}, 404) + else: + raw = body.encode() + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Disposition", + 'attachment; filename="%s"' % filename) + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(raw) elif self.path.startswith("/api/ea-device-details?"): qs = urllib.parse.parse_qs(self.path.split("?", 1)[1]) details = ea_device_details((qs.get("ext") or [""])[0])
SIP server${esc(server)}
SIP server${esc(server)}${serverNote}
SIP URIsip:${esc(d.extension)}@${esc(server)}:${esc(String(d.port))};transport=${esc(d.transport.toLowerCase())}
Username${esc(d.extension)}
Password${esc(d.password || "(not found)")}
Transport${esc(d.transport)} on port ${esc(String(d.port))}${d.encryption && d.encryption !== "no" ? " · SRTP " + esc(d.encryption) : ""}