Merge pull request #256 from outis1one/claude/asterisk-digital-ocean-consolidate-9dsrse
Read domain/TURN from Easy Asterisk's own config, and offer a setting…
This commit is contained in:
+167
-32
@@ -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 "<this box's IP>"
|
||||
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
|
||||
? ' <span class="muted">(no domain configured — using this host address; TLS cert is self-signed)</span>'
|
||||
: "";
|
||||
const turn = d.turn_server
|
||||
? `<tr><th>TURN server</th><td><code>${esc(d.turn_server)}</code></td></tr>
|
||||
<tr><th>TURN user</th><td><code>${esc(d.turn_username)}</code></td></tr>
|
||||
@@ -3763,7 +3881,8 @@ async function showEaDeviceDetails(ext) {
|
||||
<span class="pill ${d.status === "online" ? "online" : "offline"}">${esc(d.status)}</span>
|
||||
</div>
|
||||
<table style="margin-top:var(--sp-2)">
|
||||
<tr><th style="width:10rem">SIP server</th><td><code>${esc(server)}</code></td></tr>
|
||||
<tr><th style="width:10rem">SIP server</th><td><code>${esc(server)}</code>${serverNote}</td></tr>
|
||||
<tr><th>SIP URI</th><td><code>sip:${esc(d.extension)}@${esc(server)}:${esc(String(d.port))};transport=${esc(d.transport.toLowerCase())}</code></td></tr>
|
||||
<tr><th>Username</th><td><code>${esc(d.extension)}</code></td></tr>
|
||||
<tr><th>Password</th><td><code>${esc(d.password || "(not found)")}</code></td></tr>
|
||||
<tr><th>Transport</th><td><code>${esc(d.transport)}</code> on port <code>${esc(String(d.port))}</code>${d.encryption && d.encryption !== "no" ? " · SRTP " + esc(d.encryption) : ""}</td></tr>
|
||||
@@ -3775,6 +3894,7 @@ async function showEaDeviceDetails(ext) {
|
||||
Switch to ${d.transport === "TLS" ? "LAN (UDP 5060)" : "Remote/FQDN (TLS 5061)"}
|
||||
</button>
|
||||
<button class="action" onclick="resetEaPassword('${esc(d.extension)}')">Reset password</button>
|
||||
<a class="action" style="text-decoration:none" href="/api/ea-device-provisioning?ext=${encodeURIComponent(d.extension)}" download>Download settings</a>
|
||||
<button class="action" onclick="closeEaDeviceDetails()">Close</button>
|
||||
</div>
|
||||
${d.env_error ? `<p class="muted" style="margin:var(--sp-2) 0 0; color:var(--warn)">${esc(d.env_error)}</p>` : ""}
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user