Merge pull request #213 from outis1one/claude/sip-voip-integration-atsins

Claude/sip voip integration atsins
This commit is contained in:
Outis
2026-07-23 13:43:29 -04:00
committed by GitHub
3 changed files with 516 additions and 164 deletions
+26
View File
@@ -469,6 +469,30 @@ _asterisk_do_patch_messaging_vendor_files() {
log_success "Vendor generator functions patched for internal SIP messaging."
}
# Confirmed live (2026-07-23, via a real pstn-trunk.sh failure that hit this
# same mechanism): the vendor-generator patch above only takes effect on a
# FUTURE regeneration, and Easy Asterisk's own entrypoint only regenerates
# extensions.conf if it doesn't already exist (docker/entrypoint.sh guards
# it behind `[[ ! -f ... ]]`) — a box that already has devices configured,
# which is the normal case here, never regenerates it on a plain restart.
# Patches the LIVE file directly instead, so it takes effect immediately
# regardless of whether Easy Asterisk ever regenerates it on its own.
_asterisk_do_ensure_live_messaging_include() {
local EA_DIR="$1"
local EXT_LIVE="$EA_DIR/config/asterisk/extensions.conf"
[[ -f "$EXT_LIVE" ]] || return 0
if ! grep -q 'messaging-dialplan.conf' "$EXT_LIVE"; then
if grep -q '^\[intercom\]$' "$EXT_LIVE"; then
sed -i '/^\[intercom\]$/a #include messaging-dialplan.conf' "$EXT_LIVE"
log_success "Patched the messaging #include directly into the live extensions.conf."
else
log_warning "Couldn't find '[intercom]' in the live extensions.conf — add"
log_warning "'#include messaging-dialplan.conf' manually, then: docker exec easy-asterisk-do asterisk -rx \"dialplan reload\""
fi
fi
docker exec easy-asterisk-do asterisk -rx "dialplan reload" &>/dev/null || true
}
# One-time migration for devices that already existed before the patch above
# — new devices pick up message_context=sip-messaging automatically from now
# on, but anything already in pjsip.conf was written before that existed.
@@ -741,6 +765,7 @@ install_asterisk-digital-ocean() {
_asterisk_do_write_logrotate "$EA_DIR"
_asterisk_do_patch_messaging_vendor_files "$EA_DIR"
_asterisk_do_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf"
_asterisk_do_ensure_live_messaging_include "$EA_DIR"
_asterisk_do_migrate_existing_devices_message_context "$EA_DIR/config/asterisk/pjsip.conf"
ensure_docker_dir_ownership "$EA_DIR/config/asterisk"
chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf"
@@ -817,6 +842,7 @@ install_asterisk-digital-ocean() {
_asterisk_do_write_logrotate "$EA_DIR"
_asterisk_do_patch_messaging_vendor_files "$EA_DIR"
_asterisk_do_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf"
_asterisk_do_ensure_live_messaging_include "$EA_DIR"
ensure_docker_dir_ownership "$EA_DIR/config/asterisk"
chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf"
+394
View File
@@ -257,6 +257,358 @@ _asterisk_refresh_vendor_files() {
./scripts/vpn-diagnostics.sh ./scripts/dns-whitelist.sh
}
# ── Shared: extension presence (online/offline) ntfy alerts ────────────────
# Polls PJSIP registration state and alerts only on a CHANGE from the last
# check (never on every poll) — same periodic-check shape as pstn-trunk.sh's
# usage-alert script, but purely informational, so a looser 2-minute
# interval is fine here (nothing enforces/blocks anything off the back of
# this one). UNVERIFIED: the `pjsip show contacts` column layout below is
# parsed defensively (grep for the Avail/Unavail keyword rather than a fixed
# column position) specifically because it hasn't been confirmed against a
# live install's actual output yet — run
# `docker exec easy-asterisk asterisk -rx "pjsip show contacts"` yourself
# after enabling this to confirm extensions/status actually show up as
# expected, same as any other not-yet-live-tested piece in this project.
_asterisk_write_presence_alert_script() {
local FILE="$1" CONTAINER_NAME="$2" NTFY_URL="$3" STATE_FILE="$4"
cat > "$FILE" << 'SCRIPT'
#!/bin/bash
# Auto-generated by services/asterisk.sh — rerun the installer's
# presence-alert step to change settings instead of editing this directly.
CONTAINER_NAME="__PRESENCE_CONTAINER__"
NTFY_URL="__PRESENCE_NTFY_URL__"
STATE_FILE="__PRESENCE_STATE_FILE__"
[[ -z "$NTFY_URL" ]] && exit 0
send_ntfy() {
curl -m 5 -s -d "$1" "$NTFY_URL" >/dev/null 2>&1
}
CURRENT="$(docker exec "$CONTAINER_NAME" asterisk -rx "pjsip show contacts" 2>/dev/null | grep '^ Contact:' | while read -r _ aor rest; do
ext="${aor%%/*}"
status="Unknown"
case "$rest" in
*Unavail*) status="Unavail" ;;
*Avail*) status="Avail" ;;
esac
echo "${ext}:${status}"
done)"
[[ -z "$CURRENT" ]] && exit 0
touch "$STATE_FILE"
declare -A OLD_STATE
while IFS=: read -r ext status; do
[[ -n "$ext" ]] && OLD_STATE["$ext"]="$status"
done < "$STATE_FILE"
: > "${STATE_FILE}.new"
while IFS=: read -r ext status; do
[[ -z "$ext" ]] && continue
echo "${ext}:${status}" >> "${STATE_FILE}.new"
old="${OLD_STATE[$ext]:-}"
if [[ -n "$old" && "$old" != "$status" && "$status" != "Unknown" ]]; then
if [[ "$status" == "Avail" ]]; then
send_ntfy "Extension $ext is back online."
elif [[ "$old" == "Avail" ]]; then
send_ntfy "Extension $ext went offline."
fi
fi
done <<< "$CURRENT"
mv "${STATE_FILE}.new" "$STATE_FILE"
SCRIPT
sed -i "s#__PRESENCE_CONTAINER__#${CONTAINER_NAME}#g; s#__PRESENCE_NTFY_URL__#${NTFY_URL}#g; s#__PRESENCE_STATE_FILE__#${STATE_FILE}#g" "$FILE"
chmod 755 "$FILE"
}
_asterisk_install_presence_timer() {
local EA_DIR="$1"
mkdir -p "$EA_DIR/logs"
if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then
cat > /etc/systemd/system/asterisk-presence-alert.service << SVCEOF
[Unit]
Description=Asterisk extension presence (online/offline) check
[Service]
Type=oneshot
ExecStart=/bin/bash $EA_DIR/asterisk-presence-alert.sh
StandardOutput=append:$EA_DIR/logs/asterisk-presence-alert.log
StandardError=append:$EA_DIR/logs/asterisk-presence-alert.log
SVCEOF
cat > /etc/systemd/system/asterisk-presence-alert.timer << SVCEOF
[Unit]
Description=Run the Asterisk presence check every 2 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=2min
AccuracySec=10s
[Install]
WantedBy=timers.target
SVCEOF
systemctl daemon-reload
systemctl enable --now asterisk-presence-alert.timer
log_success "Presence check installed (systemd timer, every 2 minutes)."
elif command -v cron >/dev/null 2>&1 || [[ -d /etc/cron.d ]]; then
cat > /etc/cron.d/asterisk-presence-alert << CRON
*/2 * * * * root /bin/bash $EA_DIR/asterisk-presence-alert.sh >> $EA_DIR/logs/asterisk-presence-alert.log 2>&1
CRON
log_success "Presence check installed (cron.d fallback — systemd not detected)."
else
log_warning "Neither systemd nor cron available — run $EA_DIR/asterisk-presence-alert.sh manually/periodically."
fi
}
# ── Shared: internal SIP MESSAGE routing/enforcement ────────────────────────
# Confirmed live against a real install's pjsip.conf/extensions.conf
# (2026-07-23): every endpoint sets context=intercom and leaves
# message_context blank, so PJSIP messaging falls back to context=intercom —
# and [intercom] already owns an exact-match `exten => <ext>,1,...` per
# device, freshly regenerated by the vendor's own rebuild_dialplan() on
# every dialplan rebuild. A competing priority-1 declaration for the same
# extension number in a #include'd file would race that (Asterisk doesn't
# merge two independent priority-1 declarations for the same context+exten —
# one silently wins) and risks breaking normal internal calling entirely.
# So this uses its own dedicated [sip-messaging] context instead, reached by
# explicitly setting message_context=sip-messaging on every endpoint, so
# there is never any overlap with [intercom]'s own per-device call routing.
#
# The vendor's device-creation code has exactly two independent code paths
# that write a fresh endpoint block (confirmed via grep — both contain the
# literal line "context=intercom" exactly once): the CLI menu's bash heredoc,
# and the web admin's Python add_device(). Patching the vendor's own
# generator source (same technique as _pstn_patch_vendor_files) makes every
# device added FROM NOW ON pick this up automatically, in either path.
# Devices that already existed before this was installed need one one-time
# migration pass over the live pjsip.conf (below) since they were written
# before the patch existed.
_asterisk_patch_messaging_vendor_files() {
local EA_DIR="$1"
local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh"
local EASY1="$EA_DIR/easy-asterisk.sh"
local EASY2
EASY2="$(find "$EA_DIR" -maxdepth 1 -name 'easy-asterisk-v*.sh' | head -1)"
[[ -z "$EASY2" ]] && EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh"
local f
for f in "$EASY1" "$EASY2"; do
[[ -f "$f" ]] || { log_error "$f not found — is the base Asterisk install fully set up?"; return 1; }
done
# Device-creation templates: both occurrences of "context=intercom" in
# these two files (identical vendor source, copied twice) are the CLI
# and web-admin device-creation code paths — a single anchor on the bare
# line patches both in one pass.
for f in "$EASY1" "$EASY2"; do
if ! grep -q '^message_context=sip-messaging$' "$f"; then
if grep -q '^context=intercom$' "$f"; then
sed -i '/^context=intercom$/a message_context=sip-messaging' "$f"
else
log_warning "$(basename "$f"): 'context=intercom' anchor not found — vendor template changed upstream."
log_warning " Add 'message_context=sip-messaging' manually after every 'context=intercom' line in this file's device-creation code."
fi
fi
done
# extensions.conf: same [intercom] anchor _pstn_patch_vendor_files uses,
# a SEPARATE #include so this coexists whether or not pstn-trunk is
# installed — messaging is independent of the PSTN trunk entirely.
for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do
[[ -f "$f" ]] || continue
if ! grep -q 'messaging-dialplan.conf' "$f"; then
if grep -q '^\[intercom\]$' "$f"; then
sed -i '/^\[intercom\]$/a #include messaging-dialplan.conf' "$f"
else
log_warning "$(basename "$f"): '[intercom]' anchor not found — vendor template changed upstream."
log_warning " Add '#include messaging-dialplan.conf' manually after [intercom] in this file's extensions.conf heredoc."
fi
fi
done
log_success "Vendor generator functions patched for internal SIP messaging."
}
# Confirmed live (2026-07-23, via a real pstn-trunk.sh failure that hit this
# same mechanism): the vendor-generator patch above only takes effect on a
# FUTURE regeneration, and Easy Asterisk's own entrypoint only regenerates
# extensions.conf if it doesn't already exist (docker/entrypoint.sh guards
# it behind `[[ ! -f ... ]]`) — a box that already has devices configured,
# which is the normal case here, never regenerates it on a plain restart.
# Patches the LIVE file directly instead, so it takes effect immediately
# regardless of whether Easy Asterisk ever regenerates it on its own.
_asterisk_ensure_live_messaging_include() {
local EA_DIR="$1"
local EXT_LIVE="$EA_DIR/config/asterisk/extensions.conf"
[[ -f "$EXT_LIVE" ]] || return 0
if ! grep -q 'messaging-dialplan.conf' "$EXT_LIVE"; then
if grep -q '^\[intercom\]$' "$EXT_LIVE"; then
sed -i '/^\[intercom\]$/a #include messaging-dialplan.conf' "$EXT_LIVE"
log_success "Patched the messaging #include directly into the live extensions.conf."
else
log_warning "Couldn't find '[intercom]' in the live extensions.conf — add"
log_warning "'#include messaging-dialplan.conf' manually, then: docker exec easy-asterisk asterisk -rx \"dialplan reload\""
fi
fi
docker exec easy-asterisk asterisk -rx "dialplan reload" &>/dev/null || true
}
# One-time migration for devices that already existed before the patch above
# — new devices pick up message_context=sip-messaging automatically from now
# on, but anything already in pjsip.conf was written before that existed.
# Idempotent: buffers the file and only inserts where the very next line
# isn't already the exact value, so reruns (every "update") never duplicate it.
_asterisk_migrate_existing_devices_message_context() {
local PJSIP_FILE="$1"
[[ -f "$PJSIP_FILE" ]] || return 0
grep -q '^context=intercom$' "$PJSIP_FILE" || return 0
local TMP_FILE
TMP_FILE="$(mktemp)"
awk '
{ lines[NR] = $0 }
END {
for (i = 1; i <= NR; i++) {
print lines[i]
if (lines[i] == "context=intercom" && lines[i+1] != "message_context=sip-messaging") {
print "message_context=sip-messaging"
}
}
}
' "$PJSIP_FILE" > "$TMP_FILE"
if ! diff -q "$PJSIP_FILE" "$TMP_FILE" >/dev/null 2>&1; then
cp "$PJSIP_FILE" "$PJSIP_FILE.backup.$(date +%Y%m%d-%H%M%S)"
mv "$TMP_FILE" "$PJSIP_FILE"
chown asterisk:asterisk "$PJSIP_FILE" 2>/dev/null || true
log_success "Existing devices migrated to message_context=sip-messaging (backup saved alongside pjsip.conf)."
else
rm -f "$TMP_FILE"
fi
}
# The actual enforcement — gated on the SENDER's own "messaging" flag in
# pstn-permissions.conf (the exact file/flag the Security Dashboard's
# "Internal SIP messaging" checkbox writes, independent of whether the PSTN
# trunk is installed), read live via AST_CONFIG() on every message, same
# mechanism pstn-trunk.sh's own dialplan already relies on for permission
# tiers — no restart needed to take effect. Off by default: an extension
# with no entry, or messaging=no, is denied. UNVERIFIED: MESSAGE(from)'s
# exact format hasn't been confirmed on a live install — the CUT()-based
# extraction below is written to tolerate a display name (e.g. this
# project's "name0" <999> callerid format) but if it ever fails to parse,
# FROM_EXT ends up empty/wrong and the AST_CONFIG() lookup simply finds no
# match, which denies by default (same fail-closed behavior as an
# unlisted extension) rather than silently allowing anything through.
_asterisk_write_messaging_dialplan() {
local FILE="$1"
cat > "$FILE" << 'EOF'
; Internal SIP MESSAGE routing/enforcement — services/asterisk.sh.
; Regenerated on every install/update; edit there, not here directly.
;
; Reached via each endpoint's message_context=sip-messaging (patched into
; Easy Asterisk's own device-creation code — see
; _asterisk_patch_messaging_vendor_files) instead of falling back to
; [intercom], which already owns an exact-match "exten => <ext>,1,..." per
; device for CALLS, regenerated fresh on every dialplan rebuild — a
; competing priority-1 declaration for the same extension number here would
; race that and risk breaking normal internal calling. This context ONLY
; ever receives MESSAGE requests, never calls.
[sip-messaging]
exten => _X.,1,NoOp(SIP MESSAGE to ${EXTEN})
same => n,Set(FROM_URI=${MESSAGE(from)})
same => n,Set(FROM_PART=${CUT(FROM_URI,@,1)})
same => n,Set(FROM_EXT=${CUT(FROM_PART,:,2)})
same => n,Set(SENDER_OK=${AST_CONFIG(pstn-permissions.conf,${FROM_EXT},messaging)})
same => n,GotoIf($["${SENDER_OK}" = "yes"]?deliver:deny)
same => n(deliver),MessageSend(pjsip:${EXTEN},${FROM_URI})
same => n,Hangup()
same => n(deny),NoOp(Denied — extension ${FROM_EXT} is not messaging-enabled)
same => n,Hangup()
EOF
}
_asterisk_remove_presence_timer() {
systemctl disable --now asterisk-presence-alert.timer 2>/dev/null || true
rm -f /etc/systemd/system/asterisk-presence-alert.timer /etc/systemd/system/asterisk-presence-alert.service
rm -f /etc/cron.d/asterisk-presence-alert
systemctl daemon-reload 2>/dev/null || true
}
# Interactive step — called from both the fresh-install flow and "update in
# place" (always asked either way, same reasoning as pstn-trunk.sh's
# international-calling step: this is a live-editable extra, not a
# structural setting, so it doesn't belong exclusively to one path).
_asterisk_run_presence_step() {
local EA_DIR="$1"
local SETTINGS_FILE="$EA_DIR/.presence-alert.env"
local STATE_FILE="$EA_DIR/.presence-alert.state"
echo ""
local _CUR_ENABLED="n" _CUR_NTFY=""
if [[ -f "$SETTINGS_FILE" ]]; then
# shellcheck disable=SC1090
source "$SETTINGS_FILE"
_CUR_ENABLED="${PRESENCE_ENABLED:-n}"
_CUR_NTFY="${PRESENCE_NTFY_URL:-}"
fi
if [[ "$_CUR_ENABLED" == "y" ]]; then
echo " Extension online/offline ntfy alerts are ON (topic: $_CUR_NTFY)."
local _CHANGE=""
prompt_yn " Change or disable this? (y/n):" "n" _CHANGE
[[ "$_CHANGE" =~ ^[Yy]$ ]] || return 0
local _DISABLE=""
prompt_yn " Disable presence alerts entirely? (y/n):" "n" _DISABLE
if [[ "$_DISABLE" =~ ^[Yy]$ ]]; then
_asterisk_remove_presence_timer
rm -f "$EA_DIR/asterisk-presence-alert.sh" "$STATE_FILE"
cat > "$SETTINGS_FILE" << ENV
PRESENCE_ENABLED="n"
PRESENCE_NTFY_URL=""
ENV
log_success "Presence alerts disabled."
return 0
fi
else
local _WANT=""
prompt_yn "Send an ntfy alert when an extension's SIP registration goes offline / comes back online? (y/n):" "n" _WANT
[[ "$_WANT" =~ ^[Yy]$ ]] || return 0
fi
local _ntfy_default="${_CUR_NTFY:-https://ntfy.sh/asterisk-presence}"
if [[ -z "$_CUR_NTFY" ]] && [[ -f "$DOCKER_DIR/ntfy/config/server.yml" ]]; then
local _local_base_url
_local_base_url="$(grep -oP '(?<=base-url: ")[^"]+' "$DOCKER_DIR/ntfy/config/server.yml" 2>/dev/null || true)"
if [[ -n "$_local_base_url" ]] && [[ "$_local_base_url" != "https://ntfy.example.com" ]]; then
_ntfy_default="${_local_base_url}/asterisk-presence"
log_info "Detected a configured local ntfy instance at $_local_base_url — using it as the default."
fi
fi
local PRESENCE_NTFY_URL=""
prompt_text " ntfy topic URL:" "$_ntfy_default" PRESENCE_NTFY_URL
if [[ -z "$PRESENCE_NTFY_URL" ]]; then
log_warning "No topic entered — presence alerts not enabled."
return 0
fi
_asterisk_write_presence_alert_script "$EA_DIR/asterisk-presence-alert.sh" "easy-asterisk" "$PRESENCE_NTFY_URL" "$STATE_FILE"
_asterisk_install_presence_timer "$EA_DIR"
cat > "$SETTINGS_FILE" << ENV
PRESENCE_ENABLED="y"
PRESENCE_NTFY_URL="${PRESENCE_NTFY_URL}"
ENV
chown "$ACTUAL_USER:$ACTUAL_USER" "$SETTINGS_FILE" 2>/dev/null || true
log_success "Presence alerts enabled (checked every 2 minutes) — topic: $PRESENCE_NTFY_URL"
log_info "Fires only on a state CHANGE, never every check — the first check after enabling"
log_info "never alerts by itself, since there's no prior state to compare against yet."
}
# ── Shared: docker-compose.yml ─────────────────────────────────────────────
# Same reasoning as above — one copy of the template used by both fresh
# installs and updates. Must be called with $PWD already at $EA_DIR.
@@ -341,6 +693,13 @@ install_asterisk() {
echo "[DRY-RUN] Would scan for a free web admin port starting at 8081 (avoids e.g. CrowdSec's 8080)"
echo "[DRY-RUN] Would open UFW ports: 5060, 5061, <web admin port>, 8088, 8089, 3478, 10000-20000, 49152-49252"
echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if $EA_DIR already exists"
echo "[DRY-RUN] Would patch vendor device-creation code + extensions.conf generator to route"
echo "[DRY-RUN] internal SIP MESSAGE through a dedicated [sip-messaging] dialplan context,"
echo "[DRY-RUN] gated live on each sender's 'messaging' flag in pstn-permissions.conf — migrates"
echo "[DRY-RUN] any already-existing devices too"
echo "[DRY-RUN] Would offer optional ntfy alerts on extension registration going offline/online"
echo "[DRY-RUN] (checked every 2 minutes via systemd timer, cron.d fallback; always asked,"
echo "[DRY-RUN] update mode included)"
return 0
fi
@@ -363,6 +722,12 @@ install_asterisk() {
_asterisk_refresh_vendor_files
_asterisk_write_compose
_asterisk_patch_messaging_vendor_files "$EA_DIR"
_asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf"
_asterisk_ensure_live_messaging_include "$EA_DIR"
_asterisk_migrate_existing_devices_message_context "$EA_DIR/config/asterisk/pjsip.conf"
ensure_docker_dir_ownership "$EA_DIR/config/asterisk"
chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf"
log_info "Rebuilding and restarting containers..."
if docker compose up -d --build --force-recreate; then
@@ -371,6 +736,8 @@ install_asterisk() {
log_warning "docker compose up failed — check: docker compose -f $EA_DIR/docker-compose.yml logs"
fi
_asterisk_run_presence_step "$EA_DIR"
local _EXISTING_DOMAIN _EXISTING_PORT
_EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .env | cut -d= -f2-)"
_EXISTING_PORT="$(grep -E '^WEB_ADMIN_PORT=' .env | cut -d= -f2-)"
@@ -402,6 +769,10 @@ install_asterisk() {
cd "$EA_DIR" || return 1
_asterisk_refresh_vendor_files
_asterisk_patch_messaging_vendor_files "$EA_DIR"
_asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf"
ensure_docker_dir_ownership "$EA_DIR/config/asterisk"
chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf"
# ── Networking mode ───────────────────────────────────────────────────────
echo ""
@@ -542,6 +913,9 @@ ENV
log_success "UFW rules added."
fi
# ── Extension presence (online/offline) ntfy alerts ────────────────────────
_asterisk_run_presence_step "$EA_DIR"
# ── README ────────────────────────────────────────────────────────────────
write_readme "$EA_DIR" << 'MD'
# Easy Asterisk PBX + coturn
@@ -624,6 +998,26 @@ value.)
| spool/ | /var/spool/asterisk |
| lib/ | /var/lib/asterisk |
## Internal SIP messaging (no PSTN trunk needed)
Every extension can send/receive Asterisk's native SIP MESSAGE (no carrier
SMS, no PSTN, no cost) once its "messaging" flag is set to yes in
\`pstn-permissions.conf\` — via the Security Dashboard's "Internal SIP
messaging" card, or by hand. This works independent of \`pstn-trunk.sh\`
entirely. Under the hood: every device endpoint gets
\`message_context=sip-messaging\`, routing messages to a dedicated
\`config/asterisk/messaging-dialplan.conf\` context instead of \`[intercom]\`
(which already owns per-device call routing) — this install/update patches
both the device-creation code (so new extensions pick it up automatically)
and any devices that already existed.
## Extension presence (online/offline) alerts
Optional ntfy alert when an extension's SIP registration changes state —
offered on both fresh install and "update in place". Checked every 2
minutes (systemd timer, cron.d fallback); fires only on a change, never on
every check.
## Ports
| Port | Protocol | Purpose |
+96 -164
View File
@@ -109,6 +109,51 @@ _pstn_patch_vendor_files() {
log_success "Vendor generator functions patched to include the PSTN trunk config."
}
# ── Shared: force the #include lines into the LIVE config files ────────────
# Confirmed live (2026-07-23): _pstn_patch_vendor_files above patches the
# *generator functions* so a FUTURE full regeneration includes the trunk
# config — but Easy Asterisk's entrypoint only calls generate_pjsip_conf()/
# rebuild_dialplan() when pjsip.conf/extensions.conf DON'T ALREADY EXIST
# (docker/entrypoint.sh guards both behind `[[ ! -f ... ]]`). Any box that
# already has devices configured — which is the common case, since this
# service is added on top of an existing Asterisk install — never
# regenerates either file on a plain `docker compose restart`, so the
# #include lines patched into the generator never actually reach the live
# files. Confirmed by a real failure: an outbound call got "extension not
# found in context 'intercom'" because pstn-trunk-dialplan.conf was never
# actually #include'd, despite the generator patch having succeeded.
# This directly patches the LIVE files too (idempotent, same anchors), so
# it takes effect immediately regardless of whether Easy Asterisk ever
# regenerates them on its own.
_pstn_ensure_live_includes() {
local ASTERISK_DIR="$1" CONTAINER_NAME="$2"
local PJSIP_LIVE="$ASTERISK_DIR/pjsip.conf"
local EXT_LIVE="$ASTERISK_DIR/extensions.conf"
if [[ -f "$PJSIP_LIVE" ]] && ! grep -q 'pstn-trunk-pjsip.conf' "$PJSIP_LIVE"; then
if grep -q '^user_agent=EasyAsterisk$' "$PJSIP_LIVE"; then
sed -i '/^user_agent=EasyAsterisk$/a #include pstn-trunk-pjsip.conf' "$PJSIP_LIVE"
log_success "Patched the trunk's #include directly into the live pjsip.conf."
else
log_warning "Couldn't find 'user_agent=EasyAsterisk' in the live pjsip.conf — add"
log_warning "'#include pstn-trunk-pjsip.conf' manually after [global], then reload."
fi
fi
if [[ -f "$EXT_LIVE" ]] && ! grep -q 'pstn-trunk-dialplan.conf' "$EXT_LIVE"; then
if grep -q '^\[intercom\]$' "$EXT_LIVE"; then
sed -i '/^\[intercom\]$/a #include pstn-trunk-dialplan.conf' "$EXT_LIVE"
log_success "Patched the trunk's #include directly into the live extensions.conf."
else
log_warning "Couldn't find '[intercom]' in the live extensions.conf — add"
log_warning "'#include pstn-trunk-dialplan.conf' manually, then reload."
fi
fi
docker exec "$CONTAINER_NAME" asterisk -rx "module reload res_pjsip.so" &>/dev/null || true
docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true
}
# ── Shared: pjsip trunk config (aor/identify/endpoint, IP-authenticated) ───
# SERVER_IPS is space-separated — one IP is the common case (one POP, one
# hostname resolution, e.g. VoIP.ms), but some providers (e.g. Anveo Direct)
@@ -1179,14 +1224,13 @@ install_pstn-trunk() {
echo "[DRY-RUN] Would require an existing asterisk-digital-ocean OR asterisk (LAN) install"
echo "[DRY-RUN] Would prompt for: known-provider quick-pick (Anveo Direct/VoIP.ms pre-fill known"
echo "[DRY-RUN] server/signaling-IP values; still editable) or manual entry, SIP provider name, DID,"
echo "[DRY-RUN] full-PSTN extensions, restricted-PSTN extensions + their approved numbers,"
echo "[DRY-RUN] internal SIP messaging extensions (separate from PSTN calling permission),"
echo "[DRY-RUN] optional personal-number assignments (DID -> owner extension, additive to the"
echo "[DRY-RUN] shared trunk DID), max concurrent outbound/inbound calls (default 10/10),"
echo "[DRY-RUN] inbound ring-group extensions,"
echo "[DRY-RUN] max concurrent outbound/inbound calls (default 10/10), inbound ring-group extensions,"
echo "[DRY-RUN] ntfy alert topic (optional), international-calling allow-list (CLI-only,"
echo "[DRY-RUN] always asked, never on the web dashboard), per-minute rate + monthly/hourly"
echo "[DRY-RUN] alert thresholds, and an optional hard monthly spend-cap kill-switch"
echo "[DRY-RUN] Would NOT prompt for who can call/be called, messaging, or personal numbers —"
echo "[DRY-RUN] all managed live via the Security Dashboard's PSTN Trunk tab instead; every"
echo "[DRY-RUN] extension defaults to 'internal' (no PSTN, no messaging) until granted there"
echo "[DRY-RUN] Would resolve the server hostname to an IP, plus prompt for any additional"
echo "[DRY-RUN] known source IPs (some providers publish a fixed list), for inbound call matching"
echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config"
@@ -1197,7 +1241,10 @@ install_pstn-trunk() {
echo "[DRY-RUN] pstn-permissions.conf, pstn-limits.conf, or the kill-switch trip state)"
echo "[DRY-RUN] instead of a fresh install if already configured; the international-calling"
echo "[DRY-RUN] review/change question is still asked every run either way"
echo "[DRY-RUN] Would restart the asterisk container to apply"
echo "[DRY-RUN] Would restart the asterisk container to apply, AND directly patch the live"
echo "[DRY-RUN] pjsip.conf/extensions.conf with the #include lines regardless — Easy Asterisk"
echo "[DRY-RUN] only regenerates those files if they don't already exist, so an existing"
echo "[DRY-RUN] install (the common case) would otherwise never actually load the trunk config"
return 0
fi
@@ -1251,6 +1298,7 @@ install_pstn-trunk() {
( cd "$EA_DIR" && docker compose restart asterisk ) \
&& log_success "Updated — settings unchanged (server $TRUNK_SERVER, DID $TRUNK_DID, ring exts: $RING_EXTS)." \
|| log_warning "Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs asterisk"
_pstn_ensure_live_includes "$ASTERISK_DIR" "$CONTAINER_NAME"
log_info "pstn-permissions.conf and pstn-limits.conf were NOT touched — edit them"
log_info "directly, via the Security Dashboard, or choose FRESH reinstall to reset them."
# Always asked, every run, update mode included — see
@@ -1378,78 +1426,25 @@ install_pstn-trunk() {
return 1
fi
# ── Permission tiers ───────────────────────────────────────────────────
# ── Permission tiers, messaging, personal numbers — all managed via the
# Security Dashboard, not prompted here ────────────────────────────────
# This used to prompt for full/restricted extensions, approved numbers,
# messaging extensions, and personal-DID assignments right here at
# install time. All four are live-editable, no-restart-needed settings
# in pstn-permissions.conf / pstn-personal-dids.conf that the Security
# Dashboard's PSTN Trunk tab already manages end to end — duplicating
# that as a wall of CLI prompts (that you'd then have to redo via a full
# reinstall to change) added friction the dashboard already solves
# better. Every extension defaults to "internal" (no PSTN, no
# messaging, no personal number) until granted otherwise there.
echo ""
echo " Three PSTN permission tiers. Below, you'll enter EXTENSION NUMBERS at each"
echo " prompt (e.g. 999, 213) — never the tier name itself:"
echo " internal — call/receive other Asterisk extensions + internal ring"
echo " groups only. No PSTN at all. The default for any extension"
echo " not entered at either prompt below — nothing to type for it."
echo " restricted — internal, PLUS call/receive ONLY pre-approved US numbers."
echo " full — internal, PLUS call/receive ANY US number."
echo " Live-editable after install (pstn-permissions.conf) — via the Security"
echo " Dashboard web UI if installed, or by hand — no restart/reinstall needed."
local FULL_EXTS=""
prompt_text "Extension NUMBERS to grant FULL PSTN access (space-separated, e.g. '999 213', blank = none):" "" FULL_EXTS
local RESTRICTED_EXTS=""
prompt_text "Extension NUMBERS to grant RESTRICTED PSTN access (space-separated, e.g. '301', blank = none):" "" RESTRICTED_EXTS
local RESTRICTED_ARGS=()
if [[ -n "$RESTRICTED_EXTS" ]]; then
# Shared pool, entered once — faster than retyping the same numbers
# per extension when several extensions overlap. Picking per
# extension then uses a whiptail checklist (multi-select, toggle
# with space) against this pool if whiptail is available and this
# isn't an unattended run; otherwise falls back to typing numbers
# directly (or "all" for the whole pool) per extension, same as
# before this existed.
echo ""
echo " Optional: enter a shared pool of approved numbers ONCE below, then pick"
echo " which ones apply to each restricted extension next — instead of retyping"
echo " the same numbers for every extension that shares them."
local MASTER_NUMS_RAW="" MASTER_NUMS=()
prompt_text " Approved-numbers pool (comma/space-separated, 11-digit US numbers, e.g. '15551234567 15559876543', blank = enter per-extension instead):" "" MASTER_NUMS_RAW
if [[ -n "$MASTER_NUMS_RAW" ]]; then
local _pool_n
while IFS= read -r _pool_n; do
[[ -n "$_pool_n" ]] && MASTER_NUMS+=("$_pool_n")
done < <(echo "$MASTER_NUMS_RAW" | tr ', ' '\n\n' | grep -E '^[0-9]{11}$' | sort -u)
if [[ ${#MASTER_NUMS[@]} -eq 0 ]]; then
log_warning "No valid 11-digit numbers found in that pool — falling back to per-extension entry."
else
log_success "Pool: ${#MASTER_NUMS[@]} number(s) — ${MASTER_NUMS[*]}"
fi
fi
local _ext _raw_nums _clean_nums
for _ext in $RESTRICTED_EXTS; do
_clean_nums=""
if [[ ${#MASTER_NUMS[@]} -gt 0 ]] && command -v whiptail >/dev/null 2>&1 && [[ "$UNATTENDED" != true ]]; then
local _wt_args=() _wt_n _selected
for _wt_n in "${MASTER_NUMS[@]}"; do
_wt_args+=("$_wt_n" "" "off")
done
_selected="$(whiptail --title "Extension $_ext" --checklist \
"Approved numbers for extension $_ext (space to toggle, Enter to confirm):" \
20 70 10 "${_wt_args[@]}" 3>&1 1>&2 2>&3)"
[[ -n "$_selected" ]] && _clean_nums="$(echo "$_selected" | tr -d '"' | tr ' ' '\n' | paste -sd'|' -)"
else
prompt_text " Approved numbers for extension $_ext (comma/space-separated, 11-digit US numbers, e.g. 15551234567, or 'all' for the whole pool above):" "" _raw_nums
if [[ "$_raw_nums" == "all" && ${#MASTER_NUMS[@]} -gt 0 ]]; then
_clean_nums="$(printf '%s\n' "${MASTER_NUMS[@]}" | paste -sd'|' -)"
else
_clean_nums="$(echo "$_raw_nums" | tr ', ' '\n\n' | grep -E '^[0-9]{11}$' | paste -sd'|' - 2>/dev/null)"
fi
fi
if [[ -z "$_clean_nums" ]]; then
log_warning "No valid 11-digit numbers entered for $_ext — it will be restricted with an EMPTY"
log_warning "approved list, meaning no PSTN number can currently reach/be reached by it until"
log_warning "you add some (via the Security Dashboard or by editing pstn-permissions.conf)."
fi
RESTRICTED_ARGS+=("$_ext" "$_clean_nums")
done
fi
log_info "Who can call/be called, internal SIP messaging, and personal numbers are"
log_info "all managed from the Security Dashboard's PSTN Trunk tab (not here) — install"
log_info "it if you haven't: sudo ./setup.sh security-dashboard. Every extension starts"
log_info "at 'internal' (no PSTN, no messaging) until you grant it there; changes apply"
log_info "live, no restart or reinstall needed."
local FULL_EXTS="" RESTRICTED_EXTS="" RESTRICTED_ARGS=()
local MESSAGING_EXTS="" PERSONAL_DID_PAIRS=() PERSONAL_DID_ASSIGNMENTS=""
echo ""
echo " Concurrent-call caps (both directions) are also live — changeable later via"
@@ -1467,70 +1462,13 @@ install_pstn-trunk() {
MAX_INBOUND=10
fi
local _suggested_ring
_suggested_ring="$(echo "$FULL_EXTS $RESTRICTED_EXTS" | xargs)"
local RING_EXTS=""
prompt_text "Extensions to ring for inbound PSTN calls (space-separated — one, or several for a ring group; only full/restricted-tier members will actually ring):" "$_suggested_ring" RING_EXTS
prompt_text "Extensions to ring for inbound PSTN calls (space-separated — one, or several for a ring group; only full/restricted-tier members will actually ring, once granted via the dashboard):" "" RING_EXTS
if [[ -z "$RING_EXTS" ]]; then
log_error "At least one extension is required for inbound routing — aborting."
return 1
fi
# ── Internal SIP messaging — a separate axis from PSTN calling ─────────
# Asterisk's native SIP MESSAGE (extension-to-extension texting) has no
# cost/carrier involvement at all, unlike PSTN calling, so it gets its
# own independent flag in pstn-permissions.conf rather than being folded
# into the internal/restricted/full tiers above — an extension can be
# "internal" for calling (no PSTN) and still messaging-enabled, or vice
# versa. Off by default, same "opt in" posture as PSTN access.
echo ""
echo " Asterisk also supports native SIP texting between extensions (no carrier"
echo " SMS, no PSTN, no cost) — a separate permission from PSTN calling above."
local MESSAGING_EXTS=""
prompt_text "Extensions allowed to use internal SIP messaging (space-separated, blank = none):" "" MESSAGING_EXTS
# ── Personal numbers — optional, additive to the shared trunk DID ──────
# Multiple DIDs can share this one trunk/account. Assigning one to a
# specific extension makes inbound calls to it ring ONLY that extension
# (still gated by that extension's own tier/approved-numbers — a
# personal DID doesn't bypass PSTN permission, it just narrows routing
# from "the shared ring group" to "this one owner"), and makes that
# extension's outbound calls show its own DID as Caller-ID instead of
# the shared one. The shared DID/ring-group above is unaffected either
# way — this is purely additive.
echo ""
echo " Personal numbers (optional): assign specific DIDs to specific extensions."
echo " Inbound calls to that DID ring only its owner; outbound calls from that"
echo " extension show its own DID as Caller-ID. Requires the owner to also be"
echo " full or restricted tier to actually receive anything on it."
local WANT_PERSONAL_DIDS=""
prompt_yn "Assign any personal DIDs now? (y/n):" "n" WANT_PERSONAL_DIDS
local PERSONAL_DID_PAIRS=() PERSONAL_DID_ASSIGNMENTS=""
if [[ "$WANT_PERSONAL_DIDS" =~ ^[Yy]$ ]]; then
local _pd_more="y"
while [[ "$_pd_more" =~ ^[Yy]$ ]]; do
local _pd_did="" _pd_owner=""
prompt_text " DID (10-digit US number, digits only):" "" _pd_did
if [[ "$_pd_did" =~ ^[0-9]{10}$ ]]; then
prompt_text " Owner extension for $_pd_did:" "" _pd_owner
if [[ "$_pd_owner" =~ ^[0-9]+$ ]]; then
PERSONAL_DID_PAIRS+=("$_pd_did" "$_pd_owner")
PERSONAL_DID_ASSIGNMENTS="${PERSONAL_DID_ASSIGNMENTS} ${_pd_owner}=${_pd_did}"
if [[ " $FULL_EXTS $RESTRICTED_EXTS " != *" $_pd_owner "* ]]; then
log_warning "Extension $_pd_owner isn't full/restricted tier yet — it won't actually"
log_warning "receive calls on $_pd_did until you also grant it one of those tiers."
fi
log_success "Will assign $_pd_did to extension $_pd_owner."
else
log_warning "Not a valid extension — skipped."
fi
else
log_warning "Not a valid 10-digit DID — skipped."
fi
prompt_yn " Assign another? (y/n):" "n" _pd_more
done
fi
echo ""
local WANT_NTFY=""
prompt_yn "Send an ntfy alert when a call is denied (permission tier/approved-number check failed) or rejected (concurrency cap hit)? (y/n):" "y" WANT_NTFY
@@ -1699,11 +1637,9 @@ background, cost estimate, and toll-fraud reasoning.
| Server/POP | ${TRUNK_SERVER} (inbound match IPs: ${TRUNK_SERVER_IPS}) |
| DID | ${TRUNK_DID} |
| Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all, minus 27 non-US/premium NANP area codes (see below) |
| Full-PSTN extensions | ${FULL_EXTS:-none} |
| Restricted-PSTN extensions | ${RESTRICTED_EXTS:-none} |
| Permission tiers, messaging, personal numbers | Managed live via the Security Dashboard's PSTN Trunk tab — not set at install, so not shown here (this file isn't regenerated when you change them there). Everyone starts at \`internal\` (no PSTN, no messaging) until granted. |
| Concurrency caps | ${MAX_OUTBOUND} outbound / ${MAX_INBOUND} inbound simultaneous calls (live — see \`pstn-limits.conf\` below) |
| Inbound ring-group | ${RING_EXTS} |
| Internal SIP messaging extensions | ${MESSAGING_EXTS:-none} (separate from PSTN calling permission — see below) |
| ntfy alerts | ${NTFY_URL:-disabled} |
| Estimated rate | \$${RATE_PER_MIN}/min |
| Monthly spend alert threshold | \$${MONTH_THRESHOLD} |
@@ -1867,31 +1803,19 @@ permission tiers.
Asterisk's native SIP \`MESSAGE\` support (extension-to-extension texting —
no carrier SMS, no PSTN, no cost) is gated by a \`messaging=yes\` flag per
extension in \`pstn-permissions.conf\`, independent of the PSTN calling
tiers above — off by default, same "opt in" posture. Currently enabled for:
${MESSAGING_EXTS:-none}. Live-editable any time via the Security
Dashboard's "PSTN Trunk" tab, in its own always-available "Internal SIP
messaging" card — no need to re-run this installer, and no dependency on
this trunk (or any PSTN trunk at all) being installed, unlike the
calling-permissions table below it in that same tab.
tiers above — off by default, same "opt in" posture. Live-editable any
time via the Security Dashboard's "PSTN Trunk" tab, in its own
always-available "Internal SIP messaging" card — no dependency on this
trunk (or any PSTN trunk at all) being installed.
**Won't show up in Easy Asterisk's own web admin, by design** — same as
the PSTN calling tiers, this is a permission this repo layers on top,
not an Easy Asterisk feature, so it's only manageable here or via the
Security Dashboard.
**Known gap:** the flag above is real and live-editable, but the actual
SIP \`MESSAGE\` routing dialplan wiring — does Asterisk actually deliver/
gate a message using this flag — depends on how Easy Asterisk's own
generated \`extensions.conf\`/\`pjsip.conf\` route inbound messages, which
needs to be verified against a live install before it's safely automated
here. Shipping a guessed pattern risked either silently not working or
interfering with call-routing precedence in the same \`[intercom]\`
context, so it hasn't been guessed at. If you want this working end to
end, the fastest path is checking a few things on a live box (e.g.
whether an endpoint has \`message_context\` set, and what happens when you
send a test SIP MESSAGE to one) so the dialplan gate can be built against
real behavior instead of assumption — ask if you want to walk through
that.
Actually enforced, not just a flag — \`services/asterisk-digital-ocean.sh\`
(and \`services/asterisk.sh\` for the LAN edition) routes messages through a
dedicated \`[sip-messaging]\` dialplan context (separate from \`[intercom]\`'s
own per-device call routing, so there's no collision risk) and checks this
same flag via \`AST_CONFIG()\` before delivering. One caveat still flagged
rather than papered over: the \`MESSAGE(from)\` sender-extraction hasn't
been confirmed against real MESSAGE traffic on a live install — it fails
closed (denies) if it ever parses wrong, but worth a live test.
## Personal numbers
@@ -1963,6 +1887,12 @@ MD
else
log_info "Apply later with: docker compose -f $EA_DIR/docker-compose.yml restart asterisk"
fi
# Patches the live config files directly regardless of the restart choice
# above — see _pstn_ensure_live_includes's own comment for why this is
# necessary even after a restart, on a box that already had devices
# configured. Its final reload commands just no-op harmlessly if
# Asterisk isn't up yet (e.g. restart declined above).
_pstn_ensure_live_includes "$ASTERISK_DIR" "$CONTAINER_NAME"
echo ""
log_success "PSTN trunk configured."
@@ -1971,10 +1901,12 @@ MD
echo " DID: $TRUNK_DID"
echo " Outbound: US/NANP only, max $MAX_OUTBOUND concurrent calls"
echo " Inbound: max $MAX_INBOUND concurrent calls"
echo " Full-PSTN extensions: ${FULL_EXTS:-none}"
echo " Restricted extensions: ${RESTRICTED_EXTS:-none}"
echo " Inbound ring-group: $RING_EXTS"
echo " ntfy alerts: ${NTFY_URL:-disabled}"
echo " Docs: $DOC_FILE"
echo ""
log_info "Everyone's at 'internal' tier (no PSTN, no messaging) until you grant access"
log_info "via the Security Dashboard's PSTN Trunk tab — sudo ./setup.sh security-dashboard"
log_info "if it isn't installed yet."
echo ""
}