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 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) {