Fix extensions created from the dashboard, and show details by their row

Three separate faults, reported together as "the old web admin made
extensions correctly and this doesn't".

1. Every write through ea_docker_write left pjsip.conf owned by root. `tee`
   runs as root inside the container while Asterisk runs as `asterisk` and
   expects to own its own config — the vendored admin's add_device() chowns
   it back immediately after writing, and services/asterisk.sh's device
   migration does too. This was the one writer in the project that didn't,
   and is the most likely reason an extension created here behaves
   differently from one created in the vendored admin. Now chowned after
   every write, with a scoped sudoers entry for it, and a warning logged if
   the chown itself fails rather than passing silently.

2. The dashboard's update path rewrote the systemd unit and then restarted
   the service without daemon-reload, so systemd kept running the cached
   unit. Any Environment= line added since the last FRESH install was written
   to disk and ignored — which is exactly how a box with DOMAIN_NAME and TURN
   both set in .env still reported "no domain set" and "TURN not configured".

3. The connection panel rendered at the top of the card, so on any table long
   enough to scroll, the answer appeared off-screen above the row that was
   clicked. It is now a table row injected directly beneath its own
   extension, toggled by the same button, and it survives the transport and
   password actions by reopening after the reload they trigger.

Also: LAN devices now get ice_support=yes when a TURN server is configured,
matching the vendored admin (a device given TURN credentials but no ICE can't
use the relay); the panel reports the device type, so a Mobile extension can
be confirmed as such; and an unreadable .env now says which of "path not
set", "file missing", "permission denied" or "TURN_SERVER empty" applies
instead of the flat "not configured" that covered all four.

Verified: the three .env failure states each produce their own message; the
detail row lands directly after its anchor, only one is ever open, it clears
on re-render and reopens rather than sticking closed; no duplicate or missing
element IDs and no JS errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
This commit is contained in:
Claude
2026-07-28 20:08:49 +00:00
parent 4f216fc8bb
commit 1b3e7a6110
+144 -41
View File
@@ -142,6 +142,13 @@ install_security-dashboard() {
_secdash_write_asn_helper "$APP_DIR"
_secdash_write_sudoers "$SVC_USER" "$ASTERISK_EA_CONTAINER"
_secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_EA_CONFIG_DIR" "$ASTERISK_EA_CONTAINER"
# systemd caches unit files; a plain restart re-runs the OLD
# one. Without this, any Environment= or ReadOnlyPaths line
# added since the last FRESH install is written to disk and
# then silently ignored — which is exactly how the Extensions
# tab ended up reporting "no domain set" and "TURN not
# configured" on a box whose .env had both.
systemctl daemon-reload
systemctl restart security-dashboard 2>/dev/null \
&& log_success "security-dashboard restarted" \
|| log_warning "Restart failed — check: systemctl status security-dashboard"
@@ -540,6 +547,8 @@ _secdash_write_sudoers() {
if [ -n "$_ea_container" ]; then
_ea_lines="$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/asterisk/pjsip.conf
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/easy-asterisk/rooms.conf
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container chown asterisk\:asterisk /etc/asterisk/pjsip.conf
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container chown asterisk\:asterisk /etc/easy-asterisk/rooms.conf
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container asterisk -rx module\ reload\ res_pjsip.so
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container asterisk -rx pjsip\ show\ endpoints
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container /usr/local/bin/easy-asterisk --rebuild-dialplan
@@ -1848,6 +1857,24 @@ def _read_ea_env():
return values
def _ea_env_problem():
"""Why .env couldn't be read, in words, or "" if it was fine.
"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)
return ""
def ea_connection_defaults():
"""Server/transport guidance for the add form and the per-extension panel.
@@ -1858,13 +1885,22 @@ def ea_connection_defaults():
the phone reports nothing more useful than a timeout."""
env = _read_ea_env()
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.
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)
return {
"domain": domain,
"default_conn_type": "fqdn" if domain else "lan",
"turn_server": env.get("TURN_SERVER", ""),
"turn_server": turn_server,
"turn_username": env.get("TURN_USERNAME", ""),
"turn_password": env.get("TURN_PASSWORD", ""),
"env_readable": bool(env),
"env_readable": not _ea_env_problem(),
"env_error": problem,
}
@@ -1905,6 +1941,8 @@ def ea_device_details(extension):
return {
"extension": extension,
"name": device.get("name", ""),
"mobile": device.get("category") == "mobile",
"env_error": conn.get("env_error", ""),
"password": password,
"transport": "TLS" if tls else "UDP",
"port": 5061 if tls else 5060,
@@ -1934,12 +1972,29 @@ def _ea_rooms_host_path():
def ea_docker_write(container_path, content):
"""Writes content to a file INSIDE the Easy Asterisk container via
`docker exec -i <container> tee <path>` (root, sudo-gated) — see the
module-level comment above for why this isn't a direct host-side write."""
module-level comment above for why this isn't a direct host-side write.
Then hands ownership back to asterisk:asterisk. `tee` runs as root inside
the container, so every write silently re-owned the file to root; Asterisk
itself runs as `asterisk` and expects to own its config. The vendored
admin's own add_device() does this same chown immediately after writing
pjsip.conf, and services/asterisk.sh's device migration does it too — this
was the one writer in the project that didn't, which is a strong candidate
for extensions created here behaving differently from ones created in the
vendored admin."""
ok, _out, err = run_sudo(
["docker", "exec", "-i", ASTERISK_EA_CONTAINER, "tee", container_path],
input_text=content,
)
return ok, ("" if ok else (err or "Write failed"))
if not ok:
return False, (err or "Write failed")
chowned, _o, cerr = run_sudo(
["docker", "exec", ASTERISK_EA_CONTAINER, "chown", "asterisk:asterisk", container_path]
)
if not chowned:
print("WARNING: wrote %s but could not chown it to asterisk:asterisk: %s"
% (container_path, cerr), flush=True)
return True, ""
def ea_reload_pjsip():
@@ -2068,7 +2123,11 @@ def ea_add_device(name, category, extension, conn_type="lan", auto_answer=None):
else:
transport = "transport=transport-udp"
encryption = "media_encryption=no"
ice = ""
# The vendored admin also turns ICE on for LAN devices whenever a TURN
# server or VPN-ICE mode is configured — without it such a device has
# no way to use the relay it was given credentials for. Keyed off the
# same TURN_SERVER this dashboard already reads for the details panel.
ice = "ice_support=yes" if ea_connection_defaults().get("turn_server") else ""
aa_tag = ""
if auto_answer == "yes":
@@ -2802,6 +2861,16 @@ INDEX_HTML = """<!doctype html>
they stay out of the way until asked for. */
.add-advanced { display: none; width: 100%; gap: var(--sp-2); align-items: center; flex-wrap: wrap; }
.add-advanced.open { display: flex; }
/* Connection details expand under their own row, so they stay next to the
extension being asked about however far down the table it is. */
tr.detail-row > td { background: rgba(79,140,255,0.06); padding: 0; }
.detail-panel {
border-left: 3px solid var(--accent); padding: var(--sp-3) var(--sp-4);
}
.detail-panel table { font-size: 0.85rem; }
.detail-panel th { text-transform: none; letter-spacing: 0; position: static; }
.detail-panel td, .detail-panel th { border-bottom: none; padding: 0.2rem 0.6rem 0.2rem 0; }
.detail-panel code { background: rgba(0,0,0,0.35); padding: 0.1rem 0.35rem; border-radius: 4px; user-select: all; }
button.link {
background: none; border: none; color: var(--accent); font: inherit;
font-size: 0.85rem; cursor: pointer; padding: 0.2rem 0;
@@ -2951,11 +3020,6 @@ INDEX_HTML = """<!doctype html>
<div class="row" style="justify-content:flex-end; margin-bottom:var(--sp-2)">
<button class="action ea-only" id="ext-add-toggle">+ Add extension</button>
</div>
<div class="callout" id="ext-details-callout" style="border-color:rgba(79,140,255,0.35); background:rgba(79,140,255,0.07)">
<div id="ext-details-text" style="flex:1"></div>
<button class="icon" id="ext-details-dismiss" title="Dismiss">&times;</button>
</div>
<div class="callout" id="ext-password-callout">
<div class="grow" id="ext-password-text" style="flex:1"></div>
<button class="icon" id="ext-password-dismiss" title="Dismiss">&times;</button>
@@ -3306,12 +3370,14 @@ async function initExtensionsTab() {
// where the choice is rather than quietly switching it.
const connHint = document.getElementById("ext-add-conn-hint");
if (connHint) {
connHint.textContent = ea.domain
? "Phones register against " + ea.domain + ":5061."
: "No DOMAIN_NAME set in Asterisk's .env — TLS will use a self-signed certificate the phone must be told to accept. LAN only (UDP) avoids that on a local network.";
connHint.textContent = ea.env_error
? ea.env_error
: (ea.domain
? "Phones register against " + ea.domain + ":5061."
: "No DOMAIN_NAME set in Asterisk's .env — TLS will use a self-signed certificate the phone must be told to accept. LAN only (UDP) avoids that on a local network.");
}
if (ea.installed && ea.env_readable === false) {
toast("Can't read Asterisk's .env — connection details will be incomplete. Re-run: sudo ./setup.sh security-dashboard", "err");
toast(ea.env_error || "Can't read Asterisk's .env — connection details will be incomplete.", "err");
}
document.body.classList.toggle("no-ea", !eaInstalled);
document.body.classList.toggle("no-pstn", !pstnInstalled);
@@ -3397,6 +3463,7 @@ function renderRoomMemberPicker() {
let extRows = [];
let extSort = { key: null, dir: 1 };
let openDetailsExt = null;
// Sort by how much reach the mode grants, not alphabetically — "full" would
// otherwise land between "internal" and "restricted".
@@ -3458,6 +3525,10 @@ function renderExtensions() {
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${extSort.dir === 1 ? "▲" : "▼"}</span>`);
}
// Re-rendering the body discards any injected detail row, so the toggle's
// idea of what's open has to go with it — otherwise the next click on that
// extension's button toggles "closed" against a row that isn't there.
openDetailsExt = null;
tbody.innerHTML = rows.map(e => {
const status = e.status || "unknown";
// Rows for an extension the Easy Asterisk container doesn't know about
@@ -3480,7 +3551,7 @@ function renderExtensions() {
<td class="pstn-only"><input type="text" class="ext-numbers" value="${esc(e.allowed_numbers)}" placeholder="5551234567,5559876543" ${restrictUsesList(e.restrict) ? "" : "disabled"} style="width:16rem" aria-label="Whitelist for extension ${esc(e.ext)}"></td>
<td style="text-align:center"><input type="checkbox" class="ext-messaging" ${e.messaging ? "checked" : ""} aria-label="Messaging for extension ${esc(e.ext)}"></td>
<td class="actions">
<button class="icon ea-only" title="Connection details for ${esc(e.ext)}" onclick="showEaDeviceDetails('${esc(e.ext)}')">&#9432;</button>
<button class="icon ea-only" title="Connection details for ${esc(e.ext)}" onclick="toggleEaDeviceDetails('${esc(e.ext)}')">&#9432;</button>
<button class="icon ea-only" title="Delete extension ${esc(e.ext)}" onclick="deleteEaDevice('${esc(e.ext)}')">&times;</button>
</td>
</tr>`;
@@ -3644,50 +3715,82 @@ document.getElementById("ext-add-advanced-toggle").addEventListener("click", ()
document.getElementById("ext-password-dismiss").addEventListener("click", () => {
document.getElementById("ext-password-callout").classList.remove("show");
});
document.getElementById("ext-details-dismiss").addEventListener("click", () => {
document.getElementById("ext-details-callout").classList.remove("show");
});
// Everything needed to configure a softphone, in one place. Previously the
// dashboard could create an extension but never tell you the server, the
// transport it had actually been written with, or the TURN credentials — so
// a correctly-created extension and a misconfigured one looked identical.
// Rendered as an extra table row immediately under the extension it
// describes, not as a panel at the top of the card. The panel version was
// genuinely missed on a long list — you clicked a row near the bottom and the
// answer appeared off-screen above you.
async function toggleEaDeviceDetails(ext) {
if (openDetailsExt === ext) {
closeEaDeviceDetails();
return;
}
await showEaDeviceDetails(ext);
}
function closeEaDeviceDetails() {
openDetailsExt = null;
document.querySelectorAll("#ext-table tr.detail-row").forEach(r => r.remove());
}
async function showEaDeviceDetails(ext) {
const res = await fetch("/api/ea-device-details?ext=" + encodeURIComponent(ext));
if (!res.ok) { toast("No details for extension " + ext, "err"); return; }
const d = await res.json();
const server = d.server || "(no domain set — use this box's IP)";
closeEaDeviceDetails();
const anchor = document.querySelector(`#ext-table tr[data-ext="${ext}"]`);
if (!anchor) return;
openDetailsExt = ext;
const colspan = anchor.children.length;
const server = d.server
|| "(no domain in Asterisk's .env — use this box's public IP)";
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>
<tr><th>TURN password</th><td><code>${esc(d.turn_password)}</code></td></tr>`
: `<tr><th>TURN</th><td class="empty">not configured in Asterisk's .env</td></tr>`;
document.getElementById("ext-details-text").innerHTML = `
<b>Extension ${esc(d.extension)} — ${esc(d.name)}</b>
<span class="pill ${d.status === "online" ? "online" : "offline"}" style="margin-left:var(--sp-2)">${esc(d.status)}</span>
<table style="margin-top:var(--sp-2)">
<tr><th style="width:9rem">SIP server</th><td><code>${esc(server)}</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>
${turn}
</table>
<div class="row" style="margin-top:var(--sp-3)">
<button class="action" onclick="setEaTransport('${esc(d.extension)}','${d.transport === "TLS" ? "lan" : "fqdn"}')">
Switch to ${d.transport === "TLS" ? "LAN (UDP 5060)" : "Remote/FQDN (TLS 5061)"}
</button>
<button class="action" onclick="resetEaPassword('${esc(d.extension)}')">Reset password</button>
: `<tr><th>TURN</th><td class="empty">${esc(d.env_error || "not set in Asterisk's .env")}</td></tr>`;
const tr = document.createElement("tr");
tr.className = "detail-row";
tr.innerHTML = `<td colspan="${colspan}">
<div class="detail-panel">
<div class="row" style="justify-content:space-between; align-items:flex-start">
<b>Extension ${esc(d.extension)} — ${esc(d.name)}</b>
<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>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>
<tr><th>Device type</th><td>${d.mobile ? "mobile / cellular (RTP keepalive on)" : "standard"}</td></tr>
${turn}
</table>
<div class="row" style="margin-top:var(--sp-3)">
<button class="action" onclick="setEaTransport('${esc(d.extension)}','${d.transport === "TLS" ? "lan" : "fqdn"}')">
Switch to ${d.transport === "TLS" ? "LAN (UDP 5060)" : "Remote/FQDN (TLS 5061)"}
</button>
<button class="action" onclick="resetEaPassword('${esc(d.extension)}')">Reset password</button>
<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>` : ""}
<p class="muted" style="margin:var(--sp-2) 0 0">A phone that never registers is most often the transport above: an extension
written LAN-only while the phone dials in over TLS from outside. If the Security Log shows nothing at all for it, the traffic
isn't reaching Asterisk — check the firewall and the TLS certificate before the extension itself.</p>
</div>
<p class="muted" style="margin:var(--sp-2) 0 0">A phone that never registers is most often this: an extension written
LAN-only while the phone dials in over TLS from outside. If the Security Log shows nothing at all for it, the traffic
isn't reaching Asterisk — check the firewall and the TLS certificate before the extension itself.</p>`;
document.getElementById("ext-details-callout").classList.add("show");
</td>`;
anchor.insertAdjacentElement("afterend", tr);
tr.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
async function setEaTransport(ext, connType) {
const data = await postJSON("/api/ea-devices/transport", {extension: ext, conn_type: connType});
toast(data.message || (data.ok ? "Transport changed" : "Failed"), data.ok ? "ok" : "err");
if (data.ok) { await loadExtensions(); showEaDeviceDetails(ext); }
if (data.ok) { await loadExtensions(); await showEaDeviceDetails(ext); }
}
async function resetEaPassword(ext) {