From 4f216fc8bbb2d4d7247c840b2a888ac2d3124d6e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 19:55:34 +0000 Subject: [PATCH 1/2] Correct two docs left describing SMS as ntfy push, not AMI delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit services/sms-inbound.sh was rebuilt to deliver texts into Asterisk over AMI, landing in the softphone's own thread, and ntfy was dropped from that path entirely. Two pieces of prose written against the earlier design survived and now contradict the working implementation: - docs/anveo-direct-setup-guide.md still ended its "native Messages app" section with "Codes arrive as ntfy push notifications instead". The point of that section — no SIP client can write into Android Messages or iOS Messages — is unchanged, but the place texts actually land is Sipnetic's message thread. - services/pstn-trunk.sh's generated README claimed inbound SMS "doesn't touch Asterisk at all" and set up ntfy notifications. It is now the exact opposite: sms-inbound reads this service's pstn-personal-dids.conf and pstn-groups.conf to resolve DID ownership, the same files the inbound-voice ring logic uses, which also makes install order matter — noted there now. Documentation only; no behaviour change, and services/sms-inbound.sh is not touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh --- docs/anveo-direct-setup-guide.md | 7 ++++--- services/pstn-trunk.sh | 16 +++++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/anveo-direct-setup-guide.md b/docs/anveo-direct-setup-guide.md index b8c063c..f219370 100644 --- a/docs/anveo-direct-setup-guide.md +++ b/docs/anveo-direct-setup-guide.md @@ -297,9 +297,10 @@ them. Android's Messages app reads the telephony SMS provider, which only the cellular radio (or whichever app holds the default-SMS-app role) writes to; -iOS lets nothing write to Messages at all. Codes arrive as ntfy push -notifications instead — which, for a passcode you're about to read and type, -is the more useful place anyway. +iOS lets nothing write to Messages at all. No SIP client can inject into +either, so texts to this number arrive in **Sipnetic's own message thread** +(or whichever softphone is registered to the owning extension) and nowhere +else. That's a platform limit, not something the delivery path chooses. ## Provider risk and keeping the number diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 429a7c3..dff3b18 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -2503,11 +2503,17 @@ Dashboard's Extensions tab, live, no restart needed. ## Receiving SMS on the trunk DID -Not handled by this service — SMS and voice are configured separately at the -provider, and inbound SMS doesn't touch Asterisk at all. \`sudo ./setup.sh -sms-inbound\` sets up verification codes arriving as ntfy push notifications; -see that service's README for the short-code and mobile-DID caveats that -decide whether codes actually get through. +Configured separately at the provider (voice and SMS are independent settings +on an Anveo DID), then handled by \`sudo ./setup.sh sms-inbound\` — which +reads THIS service's \`pstn-personal-dids.conf\`/\`pstn-groups.conf\` to work +out which extension or Ring Group owns the destination DID, exactly as the +inbound-voice ring logic above does, and delivers the text into Asterisk over +AMI as a SIP MESSAGE. Install this service first; that one depends on these +files existing. + +See that service's README for the short-code and mobile-DID caveats that +decide whether codes actually get through, and for why outbound SMS isn't a +path yet. ## Managing this from a web UI From 1b3e7a6110fbcda8da9113ee21c35b2c88d9f99e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 20:08:49 +0000 Subject: [PATCH 2/2] Fix extensions created from the dashboard, and show details by their row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh --- services/security-dashboard.sh | 185 +++++++++++++++++++++++++-------- 1 file changed, 144 insertions(+), 41 deletions(-) diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index d11b747..ac00cac 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -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 tee ` (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 = """ 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 = """
-
-
- -
-
@@ -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", `${extSort.dir === 1 ? "▲" : "▼"}`); } + // 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() { - + `; @@ -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 ? `TURN server${esc(d.turn_server)} TURN user${esc(d.turn_username)} TURN password${esc(d.turn_password)}` - : `TURNnot configured in Asterisk's .env`; - document.getElementById("ext-details-text").innerHTML = ` - Extension ${esc(d.extension)} — ${esc(d.name)} - ${esc(d.status)} - - - - - - ${turn} -
SIP server${esc(server)}
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) : ""}
-
- - + : `TURN${esc(d.env_error || "not set in Asterisk's .env")}`; + + const tr = document.createElement("tr"); + tr.className = "detail-row"; + tr.innerHTML = ` +
+
+ Extension ${esc(d.extension)} — ${esc(d.name)} + ${esc(d.status)} +
+ + + + + + + ${turn} +
SIP server${esc(server)}
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) : ""}
Device type${d.mobile ? "mobile / cellular (RTP keepalive on)" : "standard"}
+
+ + + +
+ ${d.env_error ? `

${esc(d.env_error)}

` : ""} +

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.

-

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.

`; - document.getElementById("ext-details-callout").classList.add("show"); + `; + 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) {