Merge pull request #254 from outis1one/claude/asterisk-digital-ocean-consolidate-9dsrse
Claude/asterisk digital ocean consolidate 9dsrse
This commit is contained in:
+391
-10
@@ -256,7 +256,24 @@ which tab a given extension's settings live on.
|
||||
never scrolls sideways on a phone.
|
||||
- **Extensions** — add/rename/delete a SIP extension, tag it as a mobile
|
||||
device (enables RTP NAT-keepalive tuning); live registered/unregistered
|
||||
status per device. This is a native reimplementation of Easy Asterisk's
|
||||
status per device. The **ⓘ** button on each row opens everything a
|
||||
softphone needs — SIP server, username, password (read back from
|
||||
\`pjsip.conf\`, not regenerated, so re-pairing a handset doesn't mean
|
||||
recreating the extension), transport and port, and the TURN
|
||||
server/credentials from Asterisk's \`.env\`. From there you can also
|
||||
switch an extension between LAN (UDP 5060) and Remote/FQDN (TLS 5061),
|
||||
or reset its password without losing its room membership or PSTN
|
||||
permissions.
|
||||
|
||||
New extensions are created Remote/FQDN (TLS 5061) without asking, with
|
||||
transport and auto-answer behind an **Advanced…** disclosure. That
|
||||
choice is the usual reason a phone which looks correctly configured
|
||||
never registers: an endpoint written \`transport=transport-udp\` does
|
||||
not refuse a TLS registration, it ignores it, so the phone times out
|
||||
and nothing is logged anywhere. With no \`DOMAIN_NAME\` set that TLS
|
||||
certificate can only be self-signed and the phone has to be told to
|
||||
accept it, which the disclosure says where the choice is rather than
|
||||
silently picking UDP for you. This is a native reimplementation of Easy Asterisk's
|
||||
own vendored web admin (\`vendor/easy-asterisk/easy-asterisk-v0.10.0.sh\`'s
|
||||
device/room management), not a link or an iframe to that separate
|
||||
process — one page, one login. Reads \`pjsip.conf\`/\`rooms.conf\`
|
||||
@@ -431,6 +448,24 @@ _secdash_grant_asterisk_access() {
|
||||
chmod 770 "$_config_dir" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Asterisk's .env (chmod 600, owned by the real user) holds DOMAIN_NAME and
|
||||
# the TURN server/credentials. The Extensions tab needs them to show what
|
||||
# to type into a softphone — without this the dashboard can create an
|
||||
# extension but can't tell you how to connect it, which is exactly the gap
|
||||
# that made a new extension look broken rather than misconfigured. Read
|
||||
# only, and only this one file. It is not a meaningful escalation: the
|
||||
# dashboard already reads every extension's SIP password out of
|
||||
# pjsip.conf.
|
||||
local _ea_root="${_config_dir%/config/asterisk}"
|
||||
if [ -n "$_ea_root" ] && [ -f "$_ea_root/.env" ]; then
|
||||
_secdash_grant_ancestor_traversal "$_svc_user" "$_ea_root"
|
||||
if [ "$_have_acl" = true ]; then
|
||||
setfacl -m "u:${_svc_user}:r" "$_ea_root/.env" 2>/dev/null \
|
||||
&& log_success "Granted the dashboard read access to Asterisk's .env (TURN/domain details)." \
|
||||
|| log_warning "Couldn't grant read on $_ea_root/.env — the Extensions tab won't be able to show TURN details."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Systemd unit — separate function so "update" mode can refresh it too
|
||||
@@ -446,6 +481,9 @@ _secdash_write_systemd_unit() {
|
||||
local _read_only_paths="" _read_write_paths="/etc/crowdsec/scenarios"
|
||||
[ -n "$_log_dir" ] && _read_only_paths="$_log_dir"
|
||||
[ -n "$_ea_config_dir" ] && _read_only_paths="$_read_only_paths $_ea_config_dir"
|
||||
# ProtectSystem=strict hides everything not listed, so the .env grant above
|
||||
# is only half the story — the unit has to be told it may read the file too.
|
||||
[ -n "$_config_dir" ] && _read_only_paths="$_read_only_paths ${_config_dir%/config/asterisk}/.env"
|
||||
[ -n "$_config_dir" ] && _read_write_paths="$_read_write_paths $_config_dir"
|
||||
|
||||
cat > /etc/systemd/system/security-dashboard.service << SDSVC
|
||||
@@ -462,6 +500,7 @@ Environment=ASTERISK_LOG=${_log_dir:+$_log_dir/full}
|
||||
Environment=ASTERISK_CONFIG_DIR=$_config_dir
|
||||
Environment=ASTERISK_EA_CONFIG_DIR=$_ea_config_dir
|
||||
Environment=ASTERISK_EA_CONTAINER=$_ea_container
|
||||
Environment=ASTERISK_EA_ENV=${_config_dir%/config/asterisk}/.env
|
||||
ExecStart=/usr/bin/python3 $_app_dir/app.py
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
@@ -793,6 +832,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
PORT = int(os.environ.get("DASHBOARD_PORT", "8092"))
|
||||
@@ -1782,6 +1822,99 @@ ASTERISK_EA_CONFIG_DIR = os.environ.get("ASTERISK_EA_CONFIG_DIR", "")
|
||||
ASTERISK_EA_CONTAINER = os.environ.get("ASTERISK_EA_CONTAINER", "")
|
||||
|
||||
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."""
|
||||
values = {}
|
||||
if not ASTERISK_EA_ENV or not os.path.isfile(ASTERISK_EA_ENV):
|
||||
return values
|
||||
try:
|
||||
with open(ASTERISK_EA_ENV, errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
values[key.strip()] = val.strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
return values
|
||||
|
||||
|
||||
def ea_connection_defaults():
|
||||
"""Server/transport guidance for the add form and the per-extension panel.
|
||||
|
||||
A box with DOMAIN_NAME set is reachable from anywhere and its phones
|
||||
should register over TLS on 5061; without one there's only the LAN path.
|
||||
Getting this default wrong is not cosmetic — an endpoint written with
|
||||
transport=transport-udp simply will not answer a TLS registration, and
|
||||
the phone reports nothing more useful than a timeout."""
|
||||
env = _read_ea_env()
|
||||
domain = env.get("DOMAIN_NAME", "")
|
||||
return {
|
||||
"domain": domain,
|
||||
"default_conn_type": "fqdn" if domain else "lan",
|
||||
"turn_server": env.get("TURN_SERVER", ""),
|
||||
"turn_username": env.get("TURN_USERNAME", ""),
|
||||
"turn_password": env.get("TURN_PASSWORD", ""),
|
||||
"env_readable": bool(env),
|
||||
}
|
||||
|
||||
|
||||
def ea_device_details(extension):
|
||||
"""Everything needed to configure a softphone for one extension.
|
||||
|
||||
Includes the SIP password, read back from pjsip.conf rather than
|
||||
regenerated: a password shown only once at creation is lost the moment
|
||||
the page is closed, which in practice means deleting and recreating the
|
||||
extension just to re-pair a handset. The dashboard already holds this
|
||||
file, so surfacing it behind an explicit click adds no access it didn't
|
||||
have — and it sits behind whatever auth fronts the dashboard."""
|
||||
extension = str(extension).strip()
|
||||
if not EA_EXT_RE.match(extension):
|
||||
return None
|
||||
device = next((d for d in ea_list_devices() if d["extension"] == extension), None)
|
||||
if not device:
|
||||
return None
|
||||
|
||||
password = ""
|
||||
path = _ea_pjsip_host_path()
|
||||
if path and os.path.isfile(path):
|
||||
try:
|
||||
with open(path, errors="replace") as f:
|
||||
in_auth = False
|
||||
for line in f:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("["):
|
||||
in_auth = stripped == "[%s]" % extension
|
||||
continue
|
||||
if in_auth and stripped.startswith("password="):
|
||||
password = stripped.split("=", 1)[1]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
conn = ea_connection_defaults()
|
||||
tls = device.get("transport") == "tls"
|
||||
return {
|
||||
"extension": extension,
|
||||
"name": device.get("name", ""),
|
||||
"password": password,
|
||||
"transport": "TLS" if tls else "UDP",
|
||||
"port": 5061 if tls else 5060,
|
||||
"encryption": device.get("encryption", "no"),
|
||||
"server": conn["domain"] or "",
|
||||
"turn_server": conn["turn_server"],
|
||||
"turn_username": conn["turn_username"],
|
||||
"turn_password": conn["turn_password"],
|
||||
"status": ea_get_status().get(extension, "unknown"),
|
||||
}
|
||||
EA_ROOMS_CONTAINER_PATH = "/etc/easy-asterisk/rooms.conf"
|
||||
EA_EXT_RE = re.compile(r"^\d{1,10}$")
|
||||
|
||||
@@ -1961,6 +2094,13 @@ def ea_add_device(name, category, extension, conn_type="lan", auto_answer=None):
|
||||
endpoint_lines = [
|
||||
"type=endpoint",
|
||||
"context=intercom",
|
||||
# services/asterisk.sh patches the VENDOR's two device-creation paths
|
||||
# to add this, but this function is a third, independent writer — so
|
||||
# without it here, an extension created from this dashboard silently
|
||||
# had no internal SIP messaging while one created from the vendor
|
||||
# admin did. See _asterisk_write_messaging_dialplan for what the
|
||||
# context does.
|
||||
"message_context=sip-messaging",
|
||||
transport,
|
||||
"disallow=all",
|
||||
"allow=opus",
|
||||
@@ -2000,6 +2140,121 @@ def ea_add_device(name, category, extension, conn_type="lan", auto_answer=None):
|
||||
}
|
||||
|
||||
|
||||
def _ea_edit_device_block(extension, mutate):
|
||||
"""Rewrite one device's endpoint stanza in place.
|
||||
|
||||
Block boundary is the same heuristic the vendored admin uses and that
|
||||
ea_delete_device relies on: the "; === Device:" comment starts it and the
|
||||
first blank line ends it. mutate() receives the endpoint body as a list of
|
||||
lines and returns the replacement."""
|
||||
path = _ea_pjsip_host_path()
|
||||
if not path or not os.path.isfile(path):
|
||||
return False, "Config file not found"
|
||||
with open(path, errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
out, body, state, found = [], [], "before", False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if state == "before":
|
||||
if stripped == "[%s]" % extension and out and out[-1].strip().startswith("; === Device:"):
|
||||
found = True
|
||||
state = "in"
|
||||
out.append(line)
|
||||
continue
|
||||
out.append(line)
|
||||
elif state == "in":
|
||||
if stripped == "":
|
||||
out.extend(l if l.endswith("\n") else l + "\n" for l in mutate(body))
|
||||
out.append(line)
|
||||
state = "after"
|
||||
continue
|
||||
body.append(stripped)
|
||||
else:
|
||||
out.append(line)
|
||||
if state == "in": # block ran to EOF with no blank line
|
||||
out.extend(l if l.endswith("\n") else l + "\n" for l in mutate(body))
|
||||
if not found:
|
||||
return False, "Device not found"
|
||||
|
||||
ok, err = ea_docker_write(EA_PJSIP_CONTAINER_PATH, "".join(out))
|
||||
if not ok:
|
||||
return False, err
|
||||
ea_reload_pjsip()
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def ea_set_device_transport(extension, conn_type):
|
||||
"""Switch an extension between LAN (UDP/5060) and Remote (TLS/5061).
|
||||
|
||||
The single most common reason a phone that looks correctly configured
|
||||
never registers: the endpoint was created LAN-only and the phone is
|
||||
dialling in over TLS from outside. Changing it used to mean deleting and
|
||||
recreating the extension, which also changed its password."""
|
||||
extension = str(extension).strip()
|
||||
if not EA_EXT_RE.match(extension):
|
||||
return False, "Invalid extension"
|
||||
if conn_type not in ("lan", "fqdn"):
|
||||
return False, "Invalid connection type"
|
||||
|
||||
tls = conn_type == "fqdn"
|
||||
|
||||
def mutate(body):
|
||||
kept = [l for l in body
|
||||
if not l.startswith(("transport=", "media_encryption=", "ice_support="))]
|
||||
new = []
|
||||
for line in kept:
|
||||
new.append(line)
|
||||
if line.startswith("context="):
|
||||
new.append("transport=transport-tls" if tls else "transport=transport-udp")
|
||||
new.append("media_encryption=sdes" if tls else "media_encryption=no")
|
||||
if tls:
|
||||
new.append("ice_support=yes")
|
||||
return new
|
||||
|
||||
ok, err = _ea_edit_device_block(extension, mutate)
|
||||
if not ok:
|
||||
return False, err
|
||||
return True, "Now %s on port %d" % ("TLS" if tls else "UDP", 5061 if tls else 5060)
|
||||
|
||||
|
||||
def ea_reset_device_password(extension):
|
||||
"""New random SIP password for an existing extension, keeping everything
|
||||
else — the alternative being delete-and-recreate, which loses the
|
||||
extension's category, room membership and PSTN permissions."""
|
||||
extension = str(extension).strip()
|
||||
if not EA_EXT_RE.match(extension):
|
||||
return False, "Invalid extension"
|
||||
path = _ea_pjsip_host_path()
|
||||
if not path or not os.path.isfile(path):
|
||||
return False, "Config file not found"
|
||||
|
||||
password = _ea_generate_password(16)
|
||||
with open(path, errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
out, in_auth, changed = [], False, False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("["):
|
||||
in_auth = stripped == "[%s]" % extension
|
||||
out.append(line)
|
||||
continue
|
||||
if in_auth and stripped.startswith("password="):
|
||||
out.append("password=%s\n" % password)
|
||||
changed = True
|
||||
continue
|
||||
out.append(line)
|
||||
|
||||
if not changed:
|
||||
return False, "No auth section found for that extension"
|
||||
ok, err = ea_docker_write(EA_PJSIP_CONTAINER_PATH, "".join(out))
|
||||
if not ok:
|
||||
return False, err
|
||||
ea_reload_pjsip()
|
||||
return True, password
|
||||
|
||||
|
||||
def ea_delete_device(extension):
|
||||
"""Same block-removal logic as the vendored delete_device()."""
|
||||
path = _ea_pjsip_host_path()
|
||||
@@ -2543,6 +2798,16 @@ INDEX_HTML = """<!doctype html>
|
||||
background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||
}
|
||||
.add-form.open { display: flex; }
|
||||
/* Transport and auto-answer are answers most people never need to change;
|
||||
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; }
|
||||
button.link {
|
||||
background: none; border: none; color: var(--accent); font: inherit;
|
||||
font-size: 0.85rem; cursor: pointer; padding: 0.2rem 0;
|
||||
text-decoration: underline dotted; text-underline-offset: 3px;
|
||||
}
|
||||
button.link:hover { color: #7aa9ff; }
|
||||
|
||||
/* ── Save bar ─────────────────────────────────────────────────────────── */
|
||||
/* One commit point for the whole table, pinned to the bottom of the viewport
|
||||
@@ -2686,6 +2951,11 @@ 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">×</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">×</button>
|
||||
@@ -2694,17 +2964,26 @@ INDEX_HTML = """<!doctype html>
|
||||
<div class="add-form ea-only" id="ext-add-form">
|
||||
<input type="text" id="ea-dev-name" placeholder="Name, e.g. Front Desk" style="width:11rem">
|
||||
<input type="text" id="ea-dev-ext" placeholder="Extension, e.g. 202" style="width:9rem">
|
||||
<select id="ea-dev-conn">
|
||||
<option value="lan">LAN (UDP)</option>
|
||||
<option value="fqdn">Remote/FQDN (TLS)</option>
|
||||
</select>
|
||||
<select id="ea-dev-aa">
|
||||
<option value="no">Auto-answer: no</option>
|
||||
<option value="yes">Auto-answer: yes</option>
|
||||
</select>
|
||||
<label class="muted" style="white-space:nowrap"><input type="checkbox" id="ea-dev-mobile"> Mobile/cellular device</label>
|
||||
<button class="primary" id="ea-dev-save">Add</button>
|
||||
<button class="action" id="ext-add-cancel">Cancel</button>
|
||||
<button class="link" id="ext-add-advanced-toggle" type="button">Advanced…</button>
|
||||
<!-- TLS is listed first so it is the selected default before any
|
||||
script runs. A phone registering over TLS against a UDP-only
|
||||
endpoint just times out with nothing logged, which is the
|
||||
single most confusing way for a new extension to fail, so the
|
||||
safe transport is the one you get without choosing. -->
|
||||
<div class="add-advanced" id="ext-add-advanced">
|
||||
<select id="ea-dev-conn">
|
||||
<option value="fqdn">Remote/FQDN (TLS 5061) — default</option>
|
||||
<option value="lan">LAN only (UDP 5060)</option>
|
||||
</select>
|
||||
<select id="ea-dev-aa">
|
||||
<option value="no">Auto-answer: no</option>
|
||||
<option value="yes">Auto-answer: yes</option>
|
||||
</select>
|
||||
<span class="muted" id="ext-add-conn-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="help">
|
||||
@@ -3019,6 +3298,21 @@ async function initExtensionsTab() {
|
||||
]);
|
||||
eaInstalled = !!ea.installed;
|
||||
pstnInstalled = !!pstn.installed;
|
||||
// The form defaults to TLS in the markup and stays there — no box is worse
|
||||
// off for it, and the failure mode of the other default (a UDP-only
|
||||
// endpoint silently refusing TLS registrations) is far harder to diagnose
|
||||
// than a phone being told to trust a self-signed certificate. Without a
|
||||
// domain configured that certificate can only be self-signed, so say so
|
||||
// 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.";
|
||||
}
|
||||
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");
|
||||
}
|
||||
document.body.classList.toggle("no-ea", !eaInstalled);
|
||||
document.body.classList.toggle("no-pstn", !pstnInstalled);
|
||||
await refreshExtensionsTab();
|
||||
@@ -3186,6 +3480,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)}')">ⓘ</button>
|
||||
<button class="icon ea-only" title="Delete extension ${esc(e.ext)}" onclick="deleteEaDevice('${esc(e.ext)}')">×</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
@@ -3337,10 +3632,70 @@ document.getElementById("ext-add-toggle").addEventListener("click", () => {
|
||||
});
|
||||
document.getElementById("ext-add-cancel").addEventListener("click", () => {
|
||||
extAddForm.classList.remove("open");
|
||||
document.getElementById("ext-add-advanced").classList.remove("open");
|
||||
document.getElementById("ext-add-advanced-toggle").textContent = "Advanced…";
|
||||
});
|
||||
document.getElementById("ext-add-advanced-toggle").addEventListener("click", () => {
|
||||
const adv = document.getElementById("ext-add-advanced");
|
||||
adv.classList.toggle("open");
|
||||
document.getElementById("ext-add-advanced-toggle").textContent =
|
||||
adv.classList.contains("open") ? "Hide advanced" : "Advanced…";
|
||||
});
|
||||
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.
|
||||
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)";
|
||||
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>
|
||||
</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");
|
||||
}
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
async function resetEaPassword(ext) {
|
||||
if (!confirm("Reset the SIP password for extension " + ext + "? The phone will stop registering until you enter the new one.")) return;
|
||||
const data = await postJSON("/api/ea-devices/password", {extension: ext});
|
||||
if (data.ok) { toast("Password reset for " + ext, "ok"); showEaDeviceDetails(ext); }
|
||||
else toast(data.message || "Failed", "err");
|
||||
}
|
||||
|
||||
document.getElementById("ea-dev-save").addEventListener("click", async () => {
|
||||
const name = document.getElementById("ea-dev-name").value.trim();
|
||||
@@ -3362,6 +3717,12 @@ document.getElementById("ea-dev-save").addEventListener("click", async () => {
|
||||
document.getElementById("ea-dev-ext").value = "";
|
||||
document.getElementById("ea-dev-mobile").checked = false;
|
||||
extAddForm.classList.remove("open");
|
||||
document.getElementById("ext-add-advanced").classList.remove("open");
|
||||
document.getElementById("ext-add-advanced-toggle").textContent = "Advanced…";
|
||||
// The password alone isn't enough to configure a phone — open the full
|
||||
// details (server, transport, TURN) immediately rather than making the
|
||||
// user hunt for them.
|
||||
showEaDeviceDetails(data.data.extension);
|
||||
} else {
|
||||
toast(data.message || "Failed to add extension", "err");
|
||||
}
|
||||
@@ -3805,7 +4166,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif self.path == "/api/crowdsec-status":
|
||||
self._json({"installed": crowdsec_installed()})
|
||||
elif self.path == "/api/ea-status":
|
||||
self._json({"installed": ea_installed()})
|
||||
payload = {"installed": ea_installed()}
|
||||
if payload["installed"]:
|
||||
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-details?"):
|
||||
qs = urllib.parse.parse_qs(self.path.split("?", 1)[1])
|
||||
details = ea_device_details((qs.get("ext") or [""])[0])
|
||||
if details:
|
||||
self._json(details)
|
||||
else:
|
||||
self._json({"error": "not found"}, 404)
|
||||
elif self.path == "/api/ea-devices":
|
||||
self._json({"devices": ea_list_devices(), "status": ea_get_status()})
|
||||
elif self.path == "/api/ea-rooms":
|
||||
@@ -3866,6 +4238,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif self.path == "/api/ea-devices/rename":
|
||||
ok, message = ea_rename_device(payload.get("extension", ""), payload.get("name", ""))
|
||||
self._json({"ok": ok, "message": message})
|
||||
elif self.path == "/api/ea-devices/transport":
|
||||
ok, message = ea_set_device_transport(payload.get("extension", ""), payload.get("conn_type", ""))
|
||||
self._json({"ok": ok, "message": message})
|
||||
elif self.path == "/api/ea-devices/password":
|
||||
ok, result = ea_reset_device_password(payload.get("extension", ""))
|
||||
if ok:
|
||||
self._json({"ok": True, "password": result})
|
||||
else:
|
||||
self._json({"ok": False, "message": result})
|
||||
elif self.path == "/api/ea-devices/category":
|
||||
ok, message = ea_change_device_category(payload.get("extension", ""), payload.get("category", ""))
|
||||
self._json({"ok": ok, "message": message})
|
||||
|
||||
Reference in New Issue
Block a user