#!/bin/bash # app_amd_ws installer - AMD via WebSocket for Asterisk (ViciDial dialers) # # Generated by tools/gen-installer.sh from commit e327c19 on 2026-10-07 (installer version v2.0.1-5-ge327c19). DO NOT EDIT: run 'make installer'. # # One-liner (as root): curl -fsSL /install.sh | bash # Usage: bash install.sh [options] (bash install.sh --help) # # Self-contained: this file carries app_amd_ws.c, the Makefile, ast-detect.sh and # amd_ws.conf.sample. It never adds package repositories and never upgrades Asterisk. # Everything it prints also goes to /var/log/app_amd_ws-install.log. # # Exit codes: 0 ok | 1 failure | 2 usage / not root | 3 installed but the OLD module is still # loaded (in use; activation pending) | 4 no matching Asterisk headers | 5 build/load failed | # 6 dependencies missing set -Eeuo pipefail # -E: the ERR trap (on_err, names the failing line and command) is inherited by functions; without # it an unexpected 'set -e' exit inside a function would print nothing but the exit code. # NOTE: never 'cmd | grep -q' in a condition here - with pipefail the writer's SIGPIPE makes the # pipeline fail although grep matched. Use 'cmd | grep pattern >/dev/null' (reads all input). # Likewise never put a command whose non-zero status carries information (rpm -V, dpkg --verify) # in a pipeline: capture its output first ('out=$(cmd || true)'), then test it. AMD_WS_INSTALLER_VERSION='v2.0.1-5-ge327c19' AMD_WS_SOURCE_COMMIT='e327c19' MODULE=app_amd_ws APP=AMD_WS # ---------------------------------------------------------------------------------------------- # defaults / options # ---------------------------------------------------------------------------------------------- OPT_UNINSTALL=0; OPT_DEPS_ONLY=0; OPT_BUILD_ONLY=0; OPT_NO_DB=0; OPT_NO_LOAD=0; OPT_DRY_RUN=0; OPT_PLAYFILE=""; OPT_UPDATE_8370=0 AUDIO_HOST="${AMD_WS_AUDIO_HOST:-https://download.amdy.io}" AST_USER="${AMD_WS_AST_USER:-}"; PLAYFILE_INSTALLED="" SOUNDS_DIR="${AMD_WS_SOUNDS_DIR:-/var/lib/asterisk/sounds/amdy}" OPT_YES=0; OPT_ALLOW_CONFIGURE=0; OPT_REMOVE_BACKUPS=0; OPT_KEEP_BUILD=0 OPT_HEADERS=''; OPT_ASTERISK_SRC=''; OPT_VERSION=''; OPT_TARBALL_FILE=''; OPT_OUTPUT='' OPT_WAIT=30 BUNDLE_URL=${AMD_WS_BUNDLE_URL:-https://download.amdy.io/asterisk-headers} CACHE_DIR=${AMD_WS_CACHE_DIR:-/var/cache/app_amd_ws} LOG_FILE=${AMD_WS_LOG_FILE:-/var/log/app_amd_ws-install.log} ETC_DIR=${AMD_WS_ETCDIR:-/etc/asterisk} # https (or a local file:// mirror) only, never a downgrade redirect; retry transient errors CURL_OPTS=(-fsSL --connect-timeout 15 --max-time 900 --retry 3 --retry-delay 2 --proto '=https,file' --proto-redir '=https') BUILD_DIR='' HEADERS_HOW='' CACHE_DIR_TEMP=0 # 1 = CACHE_DIR is a mktemp directory of this run (non-root), removed on exit LAST_FETCH_VERIFIED=0 # fetch_verified: 1 when the download matched a published or pinned sha256 # sha256 of source tarballs whose origin publishes no checksum (download.vicidial.com), pinned # here so that a mirror or redirect cannot hand us a different file. Checked before the sidecar. declare -A KNOWN_SHA256=( [asterisk-16.30.1-vici.tar.gz]=101c5a005bc20a193726610664af5883e7295fe0a9749ec7cafd37527aa910df [asterisk-18.21.0-vici.tar.gz]=cadf952504cdf924cdbaa3c636cabb997b9a3a521b6d3459df35c39507103bdc [asterisk-16.30.1.tar.gz]=8a91b0f8412d8e3dbd7172c118c52ba4ed4e016696f2778a466db094ec4b8b22 ) usage() { cat <, dialplan reload + check. With --dry-run: show the diff only. With --uninstall: put the original step back --keep-build keep the temporary build directory -y, --yes no confirmation prompts -h, --help this text Environment overrides: ASTERISK ASTVERSION ASTBUILDSUM ASTINCDIR ASTTOPDIR ASTMODDIR AST_SRC_ROOTS AST_INC_ROOTS ASTNOCHECK=1 AMD_WS_BUNDLE_URL AMD_WS_CACHE_DIR AMD_WS_LOG_FILE AMD_WS_ETCDIR MYSQL_CFLAGS MYSQL_LIBS Exit codes: 0 ok, 1 failure, 2 usage/not root, 3 installed but old module still loaded (in use), 4 no matching headers, 5 build or load failed, 6 dependencies missing EOF } # ---------------------------------------------------------------------------------------------- # logging: console (info -> stdout, warn/error -> stderr) AND the log file. Functions never # return values through stdout, so logging can never pollute a captured result. # ---------------------------------------------------------------------------------------------- _ts() { date '+%Y-%m-%d %H:%M:%S'; } _logfile() { { printf '%s %s\n' "$(_ts)" "$*" >> "$LOG_FILE"; } 2>/dev/null || true; } log() { printf '[amd_ws] %s\n' "$*"; _logfile "INFO $*"; } warn() { printf '[amd_ws] WARNING: %s\n' "$*" >&2; _logfile "WARN $*"; } err() { printf '[amd_ws] ERROR: %s\n' "$*" >&2; _logfile "ERROR $*"; } die() { local rc=$1; shift; err "$@"; exit "$rc"; } run_logged() { # run a command, tee its output to the log; rc preserved (pipefail) _logfile "RUN $*" "$@" 2>&1 | tee -a "$LOG_FILE" } init_log() { if ! { : >> "$LOG_FILE"; } 2>/dev/null; then # not writable (non-root run): a private temporary log, never a predictable /tmp name LOG_FILE=$(mktemp "${TMPDIR:-/tmp}/app_amd_ws-install.XXXXXX.log" 2>/dev/null) || LOG_FILE=/dev/null fi _logfile "===== install.sh $AMD_WS_INSTALLER_VERSION (commit $AMD_WS_SOURCE_COMMIT) args: $*" } # ---------------------------------------------------------------------------------------------- # traps: the EXIT trap must never turn a successful run into rc 1 (if-form, explicit return 0) # ---------------------------------------------------------------------------------------------- on_exit() { local rc=$? if [ -n "$BUILD_DIR" ] && [ -d "$BUILD_DIR" ]; then if [ "$OPT_KEEP_BUILD" = 1 ]; then log "build directory kept: $BUILD_DIR"; else rm -rf "$BUILD_DIR"; fi fi if [ "$CACHE_DIR_TEMP" = 1 ] && [ -d "$CACHE_DIR" ]; then if [ "$OPT_KEEP_BUILD" = 1 ]; then log "header cache kept: $CACHE_DIR"; else rm -rf "$CACHE_DIR"; fi fi _logfile "===== exit $rc" return 0 } on_err() { err "command failed at line $1 (exit $2): $3; see $LOG_FILE"; } # ============================================================================================== # embedded ast-detect.sh (identical to the repository file; also written into the build dir) # ============================================================================================== # ast-detect.sh - find the Asterisk that is RUNNING (else installed) on this box and a header # tree that matches it. Shared by the Makefile and by install.sh (embedded verbatim), so both # always take the same decision. # # POSIX sh (tested with dash and bash). Every diagnostic goes to STDERR, so callers can use # $(...) safely; nothing but the requested output ever appears on stdout. # # Usage as a library: # . ./ast-detect.sh; ast_detect || exit 1 # make ASTERISK="$ASTERISK" ASTVERSION="$ASTVERSION" ASTBUILDSUM="$ASTBUILDSUM" ASTINCDIR="$ASTINCDIR" ... # Usage standalone: # sh ast-detect.sh -> KEY=VALUE lines (shell syntax) on stdout, rc 0 = usable headers found # sh ast-detect.sh --make -> KEY := VALUE lines (GNU make syntax) on stdout, rc 0 = usable headers found # sh ast-detect.sh --running -> rc 0 if an Asterisk daemon answers 'core show version' within AST_TIMEOUT # # Overrides (environment, or on the make command line): # ASTERISK=/path/to/asterisk the binary to fingerprint (default: the running daemon's image) # ASTERISK_OPTS='-C /etc/asterisk/asterisk.conf' extra options for 'asterisk -rx' (auto: the daemon's -C) # ASTVERSION=16.30.1-vici skip version detection # ASTBUILDSUM=<32 hex> skip AST_BUILDOPT_SUM detection # ASTINCDIR=/path/include use exactly this include dir (still validated unless ASTNOCHECK=1) # ASTTOPDIR=/path/asterisk- use /include (same rules) # ASTMODDIR=/path/modules skip module-directory detection # AST_SRC_ROOTS="/usr/src ..." where versioned source trees are searched # AST_INC_ROOTS="/usr/include ..." where installed headers are searched # ASTNOCHECK=1 downgrade a buildopt-sum mismatch / unverifiable headers to a WARNING # AST_TIMEOUT=5 seconds allowed for every 'asterisk -rx' / 'asterisk -V' call # # Why all this: an out-of-tree module needs only headers, but they must belong to the same # Asterisk version as the running core AND carry the same AST_BUILDOPT_SUM (main/loader.c refuses # the module otherwise). /usr/include, a distro -dev package and stray trees in /usr/src may all # be from a different Asterisk than the one that is running, so every candidate is validated. LC_ALL=C; export LC_ALL AST_TIMEOUT=${AST_TIMEOUT:-5} AST_SRC_ROOTS=${AST_SRC_ROOTS:-"/usr/src /usr/src/asterisk /usr/local/src /usr/local/src/asterisk"} AST_INC_ROOTS=${AST_INC_ROOTS:-"/usr/include /usr/local/include"} ASTNOCHECK=${ASTNOCHECK:-0} ad_log() { printf '[ast-detect] %s\n' "$*" >&2; } ad_warn() { printf '[ast-detect] WARNING: %s\n' "$*" >&2; } ad_err() { printf '[ast-detect] ERROR: %s\n' "$*" >&2; } ad_run() { timeout "$AST_TIMEOUT" "$@" 2>/dev/null; } # never hang on a broken/deadlocked binary # --------------------------------------------------------------------------------------------- # 1. binary: image of the running daemon (skipping 'asterisk -r' consoles) -> PATH -> well-known # --------------------------------------------------------------------------------------------- ast_daemon_pid() { # prints the PID of the daemon (not of a remote console), or nothing for p in $(pgrep -x asterisk 2>/dev/null); do tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null | grep -- ' -r' >/dev/null && continue printf '%s\n' "$p"; return 0 done return 1 } # '-C ' of the daemon's command line, so that 'asterisk -rx' reaches a daemon started with a # non-default asterisk.conf (its control socket lives wherever that file says) ast_ctl_opts_of() { tr '\0' '\n' < "/proc/$1/cmdline" 2>/dev/null | awk '$0 == "-C" { getline f; printf "-C %s", f; exit }'; } ast_find_binary() { ASTERISK_OPTS=${ASTERISK_OPTS:-} [ -n "${ASTERISK:-}" ] && { ASTERISK_SRC=override; return 0; } ASTERISK=''; ASTERISK_SRC='' p=$(ast_daemon_pid) || p='' if [ -n "$p" ]; then ASTERISK=$(readlink "/proc/$p/exe" 2>/dev/null | sed 's/ (deleted)$//') [ -n "$ASTERISK" ] && { ASTERISK_SRC="running process $p"; ASTERISK_OPTS=$(ast_ctl_opts_of "$p"); } fi [ -z "$ASTERISK" ] && { ASTERISK=$(command -v asterisk 2>/dev/null); ASTERISK_SRC=PATH; } if [ -z "$ASTERISK" ]; then for b in /usr/sbin/asterisk /usr/local/sbin/asterisk; do [ -x "$b" ] && { ASTERISK=$b; ASTERISK_SRC="well-known path"; break; } done fi [ -n "$ASTERISK" ] || ASTERISK_SRC="none found" [ -n "$ASTERISK" ] } # rc 0 when a daemon answers the CLI (the only reliable definition of "running") ast_running() { [ -n "${ASTERISK:-}" ] || ast_find_binary || return 1 # shellcheck disable=SC2086 ad_run "$ASTERISK" ${ASTERISK_OPTS:-} -rx 'core show version' | grep '^Asterisk ' >/dev/null } # --------------------------------------------------------------------------------------------- # 2. version: 'core show version' -> 'asterisk -V' -> embedded string pair ("16.30.1-vici" + "163001") # --------------------------------------------------------------------------------------------- ast_version_filter() { sed -n 's/^Asterisk \([^ ][^ ]*\).*/\1/p' | head -n 1; } # main/version.c stores the version string and its numeric form next to each other; requiring both # avoids false hits like "10.0" from unrelated strings. ast_version_from_strings() { strings -a "$1" 2>/dev/null | awk ' /^[0-9][0-9]*\.[0-9][0-9]*(\.[0-9][0-9]*)?(-[A-Za-z0-9.]+)?$/ || /^certified\/[0-9][0-9]*\.[0-9][0-9]*(-cert[0-9]+)?$/ { v[$0]=1 } /^[0-9][0-9][0-9][0-9][0-9][0-9]?$/ { n[$0]=1 } END { for (s in v) { t=s; sub(/^certified\//,"",t); sub(/-cert/,".",t); split(t,a,/[.-]/); if (sprintf("%d%02d%02d",a[1],a[2],a[3]) in n) print s } }' | sort -u | head -n 1 } ast_find_version() { if [ -n "${ASTVERSION:-}" ]; then ASTVERSION_SRC=override else ASTVERSION=''; ASTVERSION_SRC=unknown if [ -n "${ASTERISK:-}" ]; then # shellcheck disable=SC2086 ASTVERSION=$(ad_run "$ASTERISK" ${ASTERISK_OPTS:-} -rx 'core show version' | ast_version_filter); ASTVERSION_SRC="running daemon (core show version)" if [ -z "$ASTVERSION" ]; then ASTVERSION=$(ad_run "$ASTERISK" -V | ast_version_filter); ASTVERSION_SRC="'$ASTERISK -V' (daemon not running; version on disk)"; fi if [ -z "$ASTVERSION" ]; then ASTVERSION=$(ast_version_from_strings "$ASTERISK"); ASTVERSION_SRC="strings(1) on $ASTERISK (binary does not run here)"; fi [ -n "$ASTVERSION" ] || ASTVERSION_SRC=unknown # binary on disk replaced after the daemon started? Build for the RUNNING one, but say so. case $ASTVERSION_SRC in "running daemon"*) dv=$(ad_run "$ASTERISK" -V | ast_version_filter) [ -n "$dv" ] && [ "$dv" != "$ASTVERSION" ] && ad_warn "running Asterisk is $ASTVERSION but the binary on disk is $dv (restart pending?) - building for the RUNNING one";; esac fi fi # 16.30.1-vici -> 16.30.1 ; certified/18.9-cert1 -> 18.9 ; 18.10.0~dfsg... -> 18.10.0 ; GIT-... -> empty ASTVERBASE=$(printf '%s\n' "$ASTVERSION" | sed -n 's|^certified/||; s|^\([0-9][0-9]*\(\.[0-9][0-9]*\)\{1,2\}\).*|\1|p') ASTMAJOR=${ASTVERBASE%%.*} [ -n "$ASTVERSION" ] } # --------------------------------------------------------------------------------------------- # 3. module dir: 'core show settings' -> asterisk.conf astmoddir -> first known dir holding pbx_config.so # --------------------------------------------------------------------------------------------- AST_MODDIR_CANDS="/usr/lib64/asterisk/modules /usr/lib/asterisk/modules /usr/lib/x86_64-linux-gnu/asterisk/modules /usr/local/lib/asterisk/modules /usr/local/lib64/asterisk/modules" ast_find_moddir() { [ -n "${ASTMODDIR:-}" ] && { ASTMODDIR_SRC=override; return 0; } ASTMODDIR=''; ASTMODDIR_SRC='' if [ -n "${ASTERISK:-}" ]; then # shellcheck disable=SC2086 ASTMODDIR=$(ad_run "$ASTERISK" ${ASTERISK_OPTS:-} -rx 'core show settings' | sed -n 's/^ *Module directory: *//p' | head -n 1) [ -n "$ASTMODDIR" ] && ASTMODDIR_SRC="running daemon (core show settings)" fi if [ -z "$ASTMODDIR" ]; then ASTMODDIR=$(sed -n 's/^ *astmoddir *=> *\([^ ;]*\).*/\1/p' /etc/asterisk/asterisk.conf 2>/dev/null | head -n 1) [ -n "$ASTMODDIR" ] && ASTMODDIR_SRC="/etc/asterisk/asterisk.conf" fi if [ -z "$ASTMODDIR" ]; then for d in $AST_MODDIR_CANDS; do [ -f "$d/pbx_config.so" ] && { ASTMODDIR=$d; ASTMODDIR_SRC="contains pbx_config.so"; break; } done fi [ -n "$ASTMODDIR" ] || ASTMODDIR_SRC="not found" [ -n "$ASTMODDIR" ] } # --------------------------------------------------------------------------------------------- # 4. AST_BUILDOPT_SUM of the core: the most frequent 32-hex string in the binary (one copy per # built-in module), else in a stock module. NOT the 'Build Options' line of 'core show # settings' - that string also lists non-ABI flags (BUILD_NATIVE, ...) and is not the md5 input. # --------------------------------------------------------------------------------------------- ast_sum_of() { strings -a "$1" 2>/dev/null | grep -xE '[0-9a-f]{32}' | sort | uniq -c | sort -rn | awk 'NR==1{print $2}'; } ast_find_buildsum() { [ -n "${ASTBUILDSUM:-}" ] && { ASTBUILDSUM_SRC=override; return 0; } ASTBUILDSUM=''; ASTBUILDSUM_SRC='' if [ -n "${ASTERISK:-}" ] && [ -r "$ASTERISK" ]; then ASTBUILDSUM=$(ast_sum_of "$ASTERISK"); ASTBUILDSUM_SRC="strings(1) on $ASTERISK" fi if [ -z "$ASTBUILDSUM" ] && [ -n "${ASTMODDIR:-}" ]; then for m in "$ASTMODDIR/pbx_config.so" "$ASTMODDIR/app_dial.so" "$ASTMODDIR/res_http_websocket.so"; do [ -f "$m" ] || continue ASTBUILDSUM=$(ast_sum_of "$m"); ASTBUILDSUM_SRC="strings(1) on $m" [ -n "$ASTBUILDSUM" ] && break done fi [ -n "$ASTBUILDSUM" ] || ASTBUILDSUM_SRC="unknown (no readable core binary or stock module)" [ -n "$ASTBUILDSUM" ] } # md5 of the ', '-joined ABI-relevant menuselect options + newline (build_tools/make_buildopts_h) ast_sum_for_opts() { printf '%s\n' "$1" | md5sum | cut -c1-32; } # Print the option list behind a known AST_BUILDOPT_SUM, or fail (rc 1) for an unknown sum. # The candidates are the ABI-relevant options of menuselect (build_tools/cflags.xml order), in # every combination, so any Asterisk >= 16 built from stock menuselect flags is covered. # Asterisk 13 puts MALLOC_DEBUG/REF_DEBUG/LOW_MEMORY into the sum as well; 13 is out of scope. ast_opts_for_sum() { _sum=$1 case $_sum in da6642af068ee5e6490c5b1d2cc1d238) printf 'OPTIONAL_API\n'; return 0;; # stock 16/18/20/21/22 (ViciDial, ViciBox RPM) fa819827cbff2ea35341af5458859233) printf 'LOADABLE_MODULES, OPTIONAL_API\n'; return 0;; # stock 13 esac # brute force over the ordered option set (2^9 = 512 md5 calls, about a second) _n=0 while [ $_n -lt 512 ]; do _opts=''; _i=0 for _o in DEBUG_THREADLOCALS DO_CRASH TEST_FRAMEWORK DEBUG_THREADS DEBUG_FD_LEAKS LOADABLE_MODULES OPTIONAL_API G711_NEW_ALGORITHM INTEGER_CALLERID; do if [ $(( (_n >> _i) & 1 )) -eq 1 ]; then _opts="${_opts:+$_opts, }$_o" fi _i=$((_i+1)) done if [ -n "$_opts" ] && [ "$(ast_sum_for_opts "$_opts")" = "$_sum" ]; then printf '%s\n' "$_opts"; return 0; fi _n=$((_n+1)) done return 1 } # ast_synth_buildopts : write /asterisk/buildopts.h for a known sum. # Used when only pristine headers are available (vendor header bundle, headers-only tarball extract). ast_synth_buildopts() { _inc=$1; _sum=$2 [ -n "$_sum" ] || { ad_err "cannot synthesise buildopts.h: AST_BUILDOPT_SUM of the running Asterisk is unknown"; return 1; } _opts=$(ast_opts_for_sum "$_sum") || { ad_err "AST_BUILDOPT_SUM $_sum is not one produced by stock menuselect options (custom build) - use the real configured+built source tree: ASTTOPDIR=/path/to/asterisk-"; return 1; } [ "$(ast_sum_for_opts "$_opts")" = "$_sum" ] || { ad_err "internal error: option list '$_opts' does not hash to $_sum"; return 1; } mkdir -p "$_inc/asterisk" || return 1 { printf '/*\n * buildopts.h\n * Synthesised by ast-detect.sh for AST_BUILDOPT_SUM %s\n * (the running Asterisk was built with: %s)\n */\n\n' "$_sum" "$_opts" for _o in $(printf '%s' "$_opts" | tr -d ','); do printf '#define %s 1\n' "$_o"; done printf '#define AST_BUILDOPT_SUM "%s"\n' "$_sum" printf '#define AST_BUILDOPTS "%s"\n' "$_opts" printf '#define AST_BUILDOPTS_ALL "%s"\n' "$_opts" } > "$_inc/asterisk/buildopts.h" || return 1 ad_log "synthesised $_inc/asterisk/buildopts.h (AST_BUILDOPTS \"$_opts\", sum $_sum)" } # --------------------------------------------------------------------------------------------- # 5. validate one include dir. rc 0 = usable; one '[ast-detect] accept/reject : ' line # on stderr. Rules: asterisk.h + asterisk/autoconfig.h (configured) + asterisk/buildopts.h # (built or synthesised) must exist; the tree's .version must agree with the running version # when present; AST_BUILDOPT_SUM must equal the core's (mismatch = reject, ASTNOCHECK=1 = warn). # Installed headers without .version are tied to the binary by package version or by # install-time proximity (make install writes both within seconds); otherwise a WARNING. # --------------------------------------------------------------------------------------------- ast_check_incdir() { d=$1; why=''; note='' if [ ! -f "$d/asterisk.h" ]; then why='no asterisk.h' elif [ ! -f "$d/asterisk/autoconfig.h" ]; then why='not configured (no asterisk/autoconfig.h)' elif [ ! -f "$d/asterisk/buildopts.h" ]; then why='not built (no asterisk/buildopts.h - ./configure alone is not enough)' else sum=$(sed -n 's/^#define AST_BUILDOPT_SUM "\([0-9a-f]*\)".*/\1/p' "$d/asterisk/buildopts.h" | head -n 1) # the candidate's OWN tree dir (not '$d/..', which would follow a symlinked include/ elsewhere) tv=$(sed -n 1p "${d%/include}/.version" 2>/dev/null) if [ -n "$tv" ] && [ -n "${ASTVERSION:-}" ] && [ "$tv" != "$ASTVERSION" ] && [ "$tv" != "$ASTVERBASE" ]; then why="tree version $tv != running $ASTVERSION" elif [ -n "${ASTBUILDSUM:-}" ] && [ "$sum" != "$ASTBUILDSUM" ]; then if [ "$ASTNOCHECK" = 1 ]; then note="WARNING: AST_BUILDOPT_SUM ${sum:-none} != core $ASTBUILDSUM, accepted because ASTNOCHECK=1" else why="AST_BUILDOPT_SUM ${sum:-none} != core $ASTBUILDSUM (Asterisk would refuse to load the module)"; fi elif [ -z "$tv" ]; then pv='' if command -v rpm >/dev/null 2>&1; then pv=$(rpm -qf --qf '%{VERSION}' "$d/asterisk.h" 2>/dev/null | grep -v 'not owned'); fi if [ -z "$pv" ] && command -v dpkg-query >/dev/null 2>&1; then pkg=$(dpkg -S "$d/asterisk.h" 2>/dev/null | sed 's/:.*//;q') [ -n "$pkg" ] && pv=$(dpkg-query -W -f '${Version}' "$pkg" 2>/dev/null) fi pv=$(printf '%s' "$pv" | sed 's/^[0-9]*://') bm=$(stat -c %Y "${ASTERISK:-/nonexistent}" 2>/dev/null || echo 0); hm=$(stat -c %Y "$d/asterisk/buildopts.h" 2>/dev/null || echo 0) delta=$(( bm - hm )); [ $delta -lt 0 ] && delta=$(( -delta )) if [ -n "$pv" ] && [ -n "$ASTVERBASE" ] && [ "${pv#"$ASTVERBASE"}" != "$pv" ]; then note="installed headers owned by package version $pv (matches running $ASTVERSION)" elif [ -n "$pv" ] && [ -n "$ASTVERBASE" ] && [ $delta -gt 3600 ]; then if [ "$ASTNOCHECK" = 1 ]; then note="WARNING: installed headers belong to package version $pv, running $ASTVERSION; accepted because ASTNOCHECK=1" else why="installed headers belong to package version $pv, running $ASTVERSION (and not rewritten by the same make install)"; fi elif [ $delta -le 3600 ] && [ "$bm" -gt 0 ]; then note="installed headers written within ${delta}s of the binary (same make install); sum matches" elif [ -z "${ASTBUILDSUM:-}" ]; then note="WARNING: unversioned headers and core sum unknown - cannot verify they match the running Asterisk" else note="WARNING: unversioned installed headers (not from a package, not installed together with the binary); AST_BUILDOPT_SUM matches but the Asterisk major version cannot be verified - prefer ASTTOPDIR="; fi else note="tree .version $tv matches"; fi fi if [ -n "$why" ]; then ad_log "reject $d: $why"; return 1; fi ad_log "accept $d: ${note:-ok}"; return 0 } # --------------------------------------------------------------------------------------------- # 6. candidates in priority order; the first valid one wins. # ASTINCDIR / ASTTOPDIR override the list; then trees named asterisk-, trees of # the same base version (asterisk-, asterisk--*, asterisk-certified-*) under # AST_SRC_ROOTS - only directories with include/asterisk.h, so *.tar.gz, sound dirs and # asterisk-perl-* never match - then /include next to the binary, then AST_INC_ROOTS. # --------------------------------------------------------------------------------------------- ad_add() { case " $cands " in *" $1 "*) ;; *) cands="$cands $1";; esac; } ast_find_incdir() { cands=''; ASTINCDIR_SRC='' if [ -n "${ASTINCDIR:-}" ]; then cands=$ASTINCDIR; ASTINCDIR_SRC="ASTINCDIR override" elif [ -n "${ASTTOPDIR:-}" ]; then cands="$ASTTOPDIR/include"; ASTINCDIR_SRC="ASTTOPDIR override" else if [ -n "${ASTVERSION:-}" ]; then for r in $AST_SRC_ROOTS; do t="$r/asterisk-$ASTVERSION"; [ -f "$t/include/asterisk.h" ] && ad_add "$t/include"; done if [ -n "$ASTVERBASE" ]; then for r in $AST_SRC_ROOTS; do for t in "$r/asterisk-$ASTVERBASE" "$r"/asterisk-"$ASTVERBASE"-* "$r"/asterisk-certified-"$ASTVERBASE"*; do [ -f "$t/include/asterisk.h" ] && ad_add "$t/include" done done fi else for r in $AST_SRC_ROOTS; do for t in "$r"/asterisk-[0-9]* "$r"/asterisk-certified-*; do [ -f "$t/include/asterisk.h" ] && ad_add "$t/include"; done done fi case ${ASTERISK:-} in */sbin/asterisk) p=${ASTERISK%/sbin/asterisk}; [ -f "$p/include/asterisk.h" ] && ad_add "$p/include";; esac for r in $AST_INC_ROOTS; do [ -f "$r/asterisk.h" ] && ad_add "$r"; done cands=${cands# } fi ok=''; n=0 for d in $cands; do ast_check_incdir "$d" && { ok="$ok $d"; n=$((n+1)); }; done ok=${ok# } if [ -n "${ASTINCDIR:-}" ]; then [ $n -eq 1 ] || [ "$ASTNOCHECK" = 1 ] || { ad_err "ASTINCDIR=$ASTINCDIR failed validation (ASTNOCHECK=1 forces it)"; return 1; } return 0 fi if [ -n "${ASTTOPDIR:-}" ]; then [ $n -eq 1 ] || [ "$ASTNOCHECK" = 1 ] || { ad_err "ASTTOPDIR=$ASTTOPDIR failed validation (ASTNOCHECK=1 forces it)"; return 1; } ASTINCDIR="$ASTTOPDIR/include"; return 0 fi if [ -z "${ASTVERSION:-}" ]; then # unknown version: accept only an unambiguous candidate that is tied to the core by its sum if [ $n -eq 1 ] && [ -n "${ASTBUILDSUM:-}" ]; then ASTINCDIR=$ok; ASTINCDIR_SRC="only sum-matching candidate (version unknown)" ad_warn "Asterisk version unknown; using the only header dir whose AST_BUILDOPT_SUM matches the core: $ASTINCDIR (pass ASTVERSION=... to be sure)"; return 0 fi if [ $n -ge 1 ] && [ "$ASTNOCHECK" = 1 ]; then ASTINCDIR=${ok%% *}; ASTINCDIR_SRC="first candidate (version unknown, ASTNOCHECK=1)" ad_warn "Asterisk version unknown and ASTNOCHECK=1: using $ASTINCDIR unverified"; return 0 fi ad_err "Asterisk version could not be detected ($n header dir(s) validated: ${ok:-none}) - pass ASTVERSION= or ASTTOPDIR=/path/to/configured+built source tree (ASTNOCHECK=1 forces the first candidate)" return 1 fi ASTINCDIR=${ok%% *}; ASTINCDIR_SRC="first validated candidate" [ -n "$ASTINCDIR" ] && return 0 ad_err "no configured+built Asterisk $ASTVERSION headers with AST_BUILDOPT_SUM ${ASTBUILDSUM:-?} found (candidates: ${cands:-none}; roots: $AST_SRC_ROOTS $AST_INC_ROOTS)" ast_hint_no_headers return 1 } # next steps when nothing validates (kept in one place so Makefile and installer say the same) ast_hint_no_headers() { v=${ASTVERSION:-} case $v in *-vici) tb="https://download.vicidial.com/required-apps/asterisk-$v.tar.gz"; tree="asterisk-$v";; certified/*) tb="https://downloads.asterisk.org/pub/telephony/certified-asterisk/releases/asterisk-certified-${v#certified/}.tar.gz"; tree="asterisk-certified-${v#certified/}";; *) tb="https://downloads.asterisk.org/pub/telephony/asterisk/releases/asterisk-${ASTVERBASE:-$v}.tar.gz (or old-releases/)"; tree="asterisk-${ASTVERBASE:-$v}";; esac ad_err "fix one of: (a) run install.sh, which fetches a header bundle or the headers of $tb and synthesises buildopts.h;" ad_err " (b) extract + ./configure + 'make include/asterisk/buildopts.h' that tarball and pass ASTTOPDIR=/path/to/$tree;" ad_err " (c) install the asterisk-devel/asterisk-dev package of EXACTLY the running version (only when Asterisk itself is the distro package);" ad_err " (d) ASTNOCHECK=1 to accept mismatching headers at your own risk (the loader may refuse the module)." } # --------------------------------------------------------------------------------------------- # all-in-one: sets ASTERISK ASTVERSION ASTVERBASE ASTMAJOR ASTBUILDSUM ASTMODDIR ASTINCDIR (+ *_SRC) # --------------------------------------------------------------------------------------------- ast_detect_core() { ast_find_binary || ad_warn "no asterisk binary found (pass ASTERISK=/path/to/asterisk)" ast_find_version || ad_warn "cannot determine the Asterisk version${ASTERISK:+ from $ASTERISK}" ast_find_moddir || ad_warn "no Asterisk module directory found (pass ASTMODDIR=/path)" ast_find_buildsum || ad_warn "cannot read AST_BUILDOPT_SUM from the core or its modules" ad_log "binary : ${ASTERISK:-none}${ASTERISK_OPTS:+ $ASTERISK_OPTS} ($ASTERISK_SRC)" ad_log "version : ${ASTVERSION:-unknown} (base ${ASTVERBASE:-?}, major ${ASTMAJOR:-?}) via $ASTVERSION_SRC" ad_log "buildsum : ${ASTBUILDSUM:-unknown} via $ASTBUILDSUM_SRC" ad_log "moddir : ${ASTMODDIR:-unknown} ($ASTMODDIR_SRC)" } ast_detect() { ast_detect_core ast_find_incdir || return 1 ad_log "headers : $ASTINCDIR ($ASTINCDIR_SRC)" export ASTERISK ASTERISK_OPTS ASTVERSION ASTVERBASE ASTMAJOR ASTBUILDSUM ASTMODDIR ASTINCDIR return 0 } AST_DETECT_VARS="ASTERISK ASTERISK_OPTS ASTVERSION ASTVERBASE ASTMAJOR ASTBUILDSUM ASTMODDIR ASTINCDIR ASTERISK_SRC ASTVERSION_SRC ASTBUILDSUM_SRC ASTMODDIR_SRC ASTINCDIR_SRC" ast_print_vars() { # $1 = sh | make val='' for v in $AST_DETECT_VARS; do eval "val=\${$v:-}" case $1 in make) printf '%s := %s\n' "$v" "$val";; *) printf "%s='%s'\n" "$v" "$(printf '%s' "$val" | sed "s/'/'\\\\''/g")";; esac done } # standalone use (not when sourced as a library) case ${0##*/} in ast-detect.sh) case ${1:-} in --running) ast_running; exit $?;; --make) ast_detect; rc=$?; printf 'AST_DETECT_RC := %s\n' "$rc"; ast_print_vars make; exit $rc;; --help|-h) sed -n '2,32p' "$0"; exit 0;; *) ast_detect; rc=$?; ast_print_vars sh; exit $rc;; esac;; esac # ============================================================================================== # end of embedded ast-detect.sh # ============================================================================================== # route the library's diagnostics into the log file as well ad_log() { printf '[ast-detect] %s\n' "$*" >&2; _logfile "DETECT $*"; } ad_warn() { printf '[ast-detect] WARNING: %s\n' "$*" >&2; _logfile "DETECT WARNING: $*"; } ad_err() { printf '[ast-detect] ERROR: %s\n' "$*" >&2; _logfile "DETECT ERROR: $*"; } # ---------------------------------------------------------------------------------------------- # distro / package manager # ---------------------------------------------------------------------------------------------- PKG=''; DISTRO='' detect_distro() { local id='' like='' if [ -r /etc/os-release ]; then id=$(sed -n 's/^ID="\{0,1\}\([^"]*\)"\{0,1\}/\1/p' /etc/os-release | head -n1) like=$(sed -n 's/^ID_LIKE="\{0,1\}\([^"]*\)"\{0,1\}/\1/p' /etc/os-release | head -n1) DISTRO=$(sed -n 's/^PRETTY_NAME="\{0,1\}\([^"]*\)"\{0,1\}/\1/p' /etc/os-release | head -n1) fi [ -n "$DISTRO" ] || DISTRO=$(uname -sr) if command -v zypper >/dev/null 2>&1 && case " $id $like " in *suse*|*sles*) true;; *) false;; esac; then PKG=zypper elif command -v dnf >/dev/null 2>&1; then PKG=dnf elif command -v yum >/dev/null 2>&1; then PKG=yum elif command -v apt-get >/dev/null 2>&1; then PKG=apt elif command -v zypper >/dev/null 2>&1; then PKG=zypper else PKG=none; fi } # tools the build needs -> the package that provides them, per package manager need_pkgs() { # sets NEED_PKGS (array) from missing commands NEED_PKGS=() local c p for c in gcc make pkg-config strings tar curl; do command -v "$c" >/dev/null 2>&1 && continue case $c in gcc) p='gcc';; make) p='make';; tar) p='tar';; curl) p='curl';; strings) p='binutils';; pkg-config) case $PKG in dnf|yum) p='pkgconfig';; *) p='pkg-config';; esac;; esac NEED_PKGS+=("$p") done } have_mysql_dev() { [ -n "${MYSQL_CFLAGS:-}${MYSQL_LIBS:-}" ] && return 0 local p for p in libmariadb mariadb mysqlclient; do pkg-config --exists "$p" 2>/dev/null && return 0; done mariadb_config --cflags >/dev/null 2>&1 && return 0 mysql_config --cflags >/dev/null 2>&1 && return 0 return 1 } mysql_dev_candidates() { # sets MYSQL_DEV_PKGS (array), most likely first case $PKG in zypper) MYSQL_DEV_PKGS=(libmariadb-devel mariadb-devel libmysqlclient-devel);; dnf|yum) MYSQL_DEV_PKGS=(mariadb-connector-c-devel mariadb-devel mysql-devel);; apt) MYSQL_DEV_PKGS=(libmariadb-dev default-libmysqlclient-dev libmysqlclient-dev libmariadbclient-dev);; *) MYSQL_DEV_PKGS=();; esac } pkg_install() { # install the given packages; never touches repositories; rc from the manager [ $# -gt 0 ] || return 0 log "installing packages: $*" case $PKG in zypper) run_logged zypper --non-interactive install --no-recommends "$@";; dnf) run_logged dnf -y install "$@";; yum) run_logged yum -y install "$@";; apt) export DEBIAN_FRONTEND=noninteractive # no package index at all (fresh install, 'apt-get clean', container): fetch one first; # 'lists/partial' and 'lock' always exist, so test for actual *_Packages index files if ! ls /var/lib/apt/lists/*_Packages* >/dev/null 2>&1; then run_logged apt-get update -qq || warn "apt-get update failed - trying to install from the existing package lists" fi run_logged apt-get install -y --no-install-recommends "$@";; *) err "no supported package manager (zypper/dnf/yum/apt-get); install by hand: $*"; return 1;; esac } install_deps() { need_pkgs local rc=0 if [ ${#NEED_PKGS[@]} -gt 0 ]; then pkg_install "${NEED_PKGS[@]}" || rc=1 need_pkgs [ ${#NEED_PKGS[@]} -eq 0 ] || { err "still missing after install: ${NEED_PKGS[*]}"; rc=1; } else log "build tools present (gcc make pkg-config strings tar curl)" fi if [ "$OPT_NO_DB" = 1 ]; then log "--no-db: MySQL/MariaDB client development files not needed" elif have_mysql_dev; then log "MySQL/MariaDB client development files present" else mysql_dev_candidates local p ok=0 for p in ${MYSQL_DEV_PKGS[@]+"${MYSQL_DEV_PKGS[@]}"}; do # (empty-array safe on bash 4.2) if pkg_install "$p" && have_mysql_dev; then ok=1; break; fi done if [ $ok = 0 ]; then warn "no MySQL/MariaDB client development package could be installed (tried: ${MYSQL_DEV_PKGS[*]:-none}) - building WITHOUT the vicidial_auto_calls phone/country lookup (use --no-db to silence this)" OPT_NO_DB=1 fi fi return $rc } # ---------------------------------------------------------------------------------------------- # header resolution. Order: (1) local trees / installed headers validated by ast-detect; # (2) the distro's -devel package, only when the RUNNING binary is that distro package and is # unmodified, pinned to the exact installed version; (3) header bundle from BUNDLE_URL; # (4) headers-only extraction of the upstream / ViciDial source tarball + synthesised buildopts.h. # Result: ASTINCDIR (validated) and HEADERS_HOW. rc 1 when everything failed. # ---------------------------------------------------------------------------------------------- resolve_headers() { ASTINCDIR=${ASTINCDIR:-$OPT_HEADERS} [ -n "${ASTTOPDIR:-}" ] || ASTTOPDIR=$OPT_ASTERISK_SRC if ast_find_incdir; then HEADERS_HOW="local: $ASTINCDIR ($ASTINCDIR_SRC)"; return 0; fi [ -z "${ASTINCDIR:-}${ASTTOPDIR:-}" ] || return 1 # an explicit --headers/--asterisk-src that fails is final [ -n "${ASTVERSION:-}" ] || { err "cannot fetch headers without knowing the Asterisk version - pass --version (e.g. 16.30.1-vici) or --asterisk-src DIR"; return 1; } if [ "$OPT_DRY_RUN" = 1 ]; then HEADERS_HOW="none locally; would try: distro devel package (if Asterisk is the distro package), $(bundle_urls | tr '\n' ' '), then $(tarball_urls | tr '\n' ' ')" return 0 fi if try_devel_package; then ASTINCDIR=''; if ast_find_incdir; then HEADERS_HOW="distro devel package: $ASTINCDIR"; return 0; fi; fi if fetch_header_bundle; then HEADERS_HOW="header bundle: $ASTINCDIR"; return 0; fi if fetch_tarball_headers; then HEADERS_HOW="source tarball headers + synthesised buildopts.h: $ASTINCDIR"; return 0; fi return 1 } # (2) -devel package pinned to the running binary's package version try_devel_package() { local bin=${ASTERISK:-} pkg='' evr='' ver='' [ -n "$bin" ] && [ -e "$bin" ] || return 1 local verify if command -v rpm >/dev/null 2>&1 && pkg=$(rpm -qf "$bin" 2>/dev/null) && [ -n "$pkg" ] && [ "${pkg#file }" = "$pkg" ]; then # rpm -V exits 1 whenever ANY file of the package differs, so it must not sit in a pipeline # under pipefail (the pipeline would be false exactly when grep matched): capture, then test verify=$(rpm -Vf "$bin" 2>/dev/null || true) if printf '%s\n' "$verify" | grep " $bin\$" >/dev/null; then log "$bin is owned by $pkg but was modified (source build over the package) - not using the distro devel package"; return 1 fi evr=$(rpm -q --qf '%{VERSION}-%{RELEASE}' "$pkg") log "Asterisk is the distro package $pkg - installing asterisk-devel-$evr (exact same version, never an upgrade)" case $PKG in zypper) run_logged zypper --non-interactive install --no-recommends --oldpackage "asterisk-devel-$evr" || return 1;; dnf) run_logged dnf -y install "asterisk-devel-$evr" || return 1;; yum) run_logged yum -y install "asterisk-devel-$evr" || return 1;; *) return 1;; esac return 0 fi if command -v dpkg-query >/dev/null 2>&1 && pkg=$(dpkg -S "$bin" 2>/dev/null | sed 's/:.*//;q') && [ -n "$pkg" ]; then verify=$(dpkg --verify "$pkg" 2>/dev/null || true) if printf '%s\n' "$verify" | grep " $bin\$" >/dev/null; then log "$bin belongs to $pkg but was modified (source build over the package) - not using asterisk-dev"; return 1 fi ver=$(dpkg-query -W -f '${Version}' "$pkg" 2>/dev/null) log "Asterisk is the distro package $pkg $ver - installing asterisk-dev=$ver" export DEBIAN_FRONTEND=noninteractive run_logged apt-get install -y --no-install-recommends "asterisk-dev=$ver" || return 1 return 0 fi log "Asterisk binary is not owned by a distro package - skipping the devel-package route" return 1 } # pure helpers (stdout = data, no logging): candidate URLs for the running version. # Bundle and cache names never contain a slash: 'certified/18.9-cert1' -> 'certified-18.9-cert1' # (the name tools/make-header-bundle.sh produces); the .version INSIDE a tree keeps the real string. bundle_name_version() { printf '%s\n' "${ASTVERSION/certified\//certified-}"; } bundle_urls() { local v bv bv=$(bundle_name_version) printf '%s/asterisk-%s-headers.tar.gz\n' "$BUNDLE_URL" "$bv" v=${ASTVERSION#certified/} [ -n "${ASTVERBASE:-}" ] && [ "$ASTVERBASE" != "$v" ] && printf '%s/asterisk-%s-headers.tar.gz\n' "$BUNDLE_URL" "$ASTVERBASE" return 0 } tarball_urls() { local v=$ASTVERSION base=${ASTVERBASE:-} case $v in *-vici) printf 'https://download.vicidial.com/required-apps/asterisk-%s.tar.gz\n' "$v";; certified/*) printf 'https://downloads.asterisk.org/pub/telephony/certified-asterisk/releases/asterisk-certified-%s.tar.gz\n' "${v#certified/}" printf 'https://downloads.asterisk.org/pub/telephony/certified-asterisk/old-releases/asterisk-certified-%s.tar.gz\n' "${v#certified/}"; return 0;; esac [ -n "$base" ] || return 0 printf 'https://downloads.asterisk.org/pub/telephony/asterisk/releases/asterisk-%s.tar.gz\n' "$base" printf 'https://downloads.asterisk.org/pub/telephony/asterisk/old-releases/asterisk-%s.tar.gz\n' "$base" } # download $1 to $2; verify against the pinned KNOWN_SHA256 entry for its file name, else against the # sidecar checksum $3 (URL, optional, may 404) when present. LAST_FETCH_VERIFIED says which happened. fetch_verified() { local url=$1 out=$2 sumurl=${3:-} want='' have how='' LAST_FETCH_VERIFIED=0 log "downloading $url" curl "${CURL_OPTS[@]}" -o "$out" "$url" || { rm -f "$out"; return 1; } if [ -n "${KNOWN_SHA256[${url##*/}]:-}" ]; then want=${KNOWN_SHA256[${url##*/}]}; how="pinned in this installer" elif [ -n "$sumurl" ]; then if want=$(curl "${CURL_OPTS[@]}" "$sumurl" 2>/dev/null | awk 'NF{print $1; exit}') && [ -n "$want" ]; then how="published at $sumurl" else want='' fi fi if [ -n "$want" ]; then have=$(sha256sum "$out" | cut -c1-64) [ "$have" = "$want" ] || { err "sha256 mismatch for $url (got $have, expected $want, $how)"; rm -f "$out"; return 1; } log "sha256 verified ($want, $how)" LAST_FETCH_VERIFIED=1 else log "no checksum published for $url and none pinned - continuing unverified" fi return 0 } # (3) header bundle: asterisk--headers.tar.gz (+ .sha256) -> CACHE_DIR/asterisk-/include fetch_header_bundle() { local url tmp dir name tmp=$(mktemp) || return 1 while read -r url; do [ -n "$url" ] || continue fetch_verified "$url" "$tmp" "$url.sha256" || { log "no header bundle at $url"; continue; } name=${url##*/}; name=${name%-headers.tar.gz} dir="$CACHE_DIR/$name" rm -rf "$dir"; mkdir -p "$dir" if ! tar xzf "$tmp" -C "$dir" --strip-components=1; then err "cannot extract $url"; rm -rf "$dir"; continue; fi rm -f "$tmp" [ -f "$dir/include/asterisk.h" ] || { err "bundle $url has no include/asterisk.h"; rm -rf "$dir"; continue; } rm -f "$dir/include/asterisk/buildopts.h" ast_synth_buildopts "$dir/include" "${ASTBUILDSUM:-}" || { rm -rf "$dir"; return 1; } ASTINCDIR="$dir/include" if ast_check_incdir "$ASTINCDIR"; then return 0; fi ASTINCDIR=''; rm -rf "$dir" done < <(bundle_urls) rm -f "$tmp" return 1 } # (4) source tarball, headers only: */include/asterisk.h, */include/asterisk/*.h, */.version fetch_tarball_headers() { local tmp='' url got=0 dir ac trusted=0 dir="$CACHE_DIR/asterisk-$(bundle_name_version)" if [ -n "$OPT_TARBALL_FILE" ]; then [ -f "$OPT_TARBALL_FILE" ] || { err "--tarball-file $OPT_TARBALL_FILE: no such file"; return 1; } tmp=$OPT_TARBALL_FILE; got=1; trusted=1; log "using local tarball $tmp (supplied by you, taken as trusted)" else tmp=$(mktemp) || return 1 while read -r url; do [ -n "$url" ] || continue case $url in *downloads.asterisk.org*) fetch_verified "$url" "$tmp" "${url%.tar.gz}.sha256" && { got=1; break; };; *) fetch_verified "$url" "$tmp" '' && { got=1; break; };; esac log "not available: $url" done < <(tarball_urls) [ $got = 1 ] || { rm -f "$tmp"; err "no source tarball found for Asterisk $ASTVERSION (tried: $(tarball_urls | tr '\n' ' '))"; return 1; } trusted=$LAST_FETCH_VERIFIED fi rm -rf "$dir"; mkdir -p "$dir" log "extracting headers only (include/ and .version) into $dir" if ! tar xzf "$tmp" -C "$dir" --strip-components=1 --wildcards '*/include/asterisk.h' '*/include/asterisk/*.h' '*/.version' 2>>"$LOG_FILE"; then [ -f "$dir/include/asterisk.h" ] || { err "tar could not extract include/ from the tarball"; rm -rf "$dir"; [ -n "$OPT_TARBALL_FILE" ] || rm -f "$tmp"; return 1; } fi rm -f "$dir/include/asterisk/buildopts.h" "$dir/include/asterisk/build.h" [ -f "$dir/.version" ] || printf '%s\n' "$ASTVERSION" > "$dir/.version" # autoconfig.h: the tarball's own (built-tree tarball) > a cached bundle of the same major > ./configure if [ -f "$dir/include/asterisk/autoconfig.h" ]; then log "using the autoconfig.h shipped inside the tarball" elif ac=$(ls "$CACHE_DIR"/asterisk-"${ASTMAJOR:-x}".*/include/asterisk/autoconfig.h 2>/dev/null | head -n1) && [ -n "$ac" ]; then log "using autoconfig.h from the cached header bundle $ac (same major version, same platform)" cp -p "$ac" "$dir/include/asterisk/autoconfig.h" elif [ "$OPT_ALLOW_CONFIGURE" = 1 ] && [ "$trusted" = 1 ]; then run_configure "$tmp" "$dir" || { rm -rf "$dir"; [ -n "$OPT_TARBALL_FILE" ] || rm -f "$tmp"; return 1; } elif [ "$OPT_ALLOW_CONFIGURE" = 1 ]; then # ./configure executes code from the tarball as root: never from an unverified download err "--allow-configure refused: the tarball's sha256 could neither be verified against a published checksum nor against one pinned in this installer" err "download it yourself, verify it out of band, then re-run with --tarball-file /path/asterisk-$ASTVERSION.tar.gz --allow-configure" rm -rf "$dir"; rm -f "$tmp"; return 1 else err "the tarball carries no include/asterisk/autoconfig.h (it is generated by ./configure) and no header bundle for Asterisk ${ASTMAJOR:-?}.x is cached" err "options: re-run with --allow-configure (runs ./configure in $CACHE_DIR/src; needs gcc-c++, libedit/ncurses, libuuid, jansson >= 2.11, libxml2, sqlite3 -devel packages)," err " or configure the tree yourself and pass --asterisk-src DIR, or pass --headers DIR" rm -rf "$dir"; [ -n "$OPT_TARBALL_FILE" ] || rm -f "$tmp"; return 1 fi [ -n "$OPT_TARBALL_FILE" ] || rm -f "$tmp" ast_synth_buildopts "$dir/include" "${ASTBUILDSUM:-}" || { rm -rf "$dir"; return 1; } ASTINCDIR="$dir/include" ast_check_incdir "$ASTINCDIR" && return 0 ASTINCDIR=''; return 1 } run_configure() { # $1 = tarball, $2 = header dir to receive autoconfig.h local tb=$1 dir=$2 src src="$CACHE_DIR/src-$(bundle_name_version)" rm -rf "$src"; mkdir -p "$src" log "--allow-configure: extracting the full source into $src and running ./configure (this takes a minute)" tar xzf "$tb" -C "$src" --strip-components=1 || { err "full extraction failed"; return 1; } if ! ( cd "$src" && ./configure --quiet >"$src/configure.log" 2>&1 ); then err "./configure failed - last lines of $src/configure.log:"; tail -n 15 "$src/configure.log" >&2 err "install the missing -devel packages named there and re-run, or pass --headers/--asterisk-src" return 1 fi cp -p "$src/include/asterisk/autoconfig.h" "$dir/include/asterisk/autoconfig.h" || return 1 log "autoconfig.h generated by ./configure" } # ---------------------------------------------------------------------------------------------- # build in a temporary directory with the embedded sources; the Makefile runs the gates # ---------------------------------------------------------------------------------------------- write_sources() { local d=$1 cat > "$d/app_amd_ws.c" <<'__APP_AMD_WS_EMBED_EOF__' /* * app_amd_ws.c -- Answering Machine Detection over WebSocket for Asterisk * * Copyright (C) 2024-2026, amdy.io / Nik VB * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2 as * published by the Free Software Foundation (GPL-2.0). * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * SPDX-License-Identifier: GPL-2.0-only */ /*! \file * * \brief AMD_WS() -- stream call audio to an AMD classification service over * WebSocket and set AMDSTATUS/AMDCAUSE like the stock AMD() application. * * \author amdy.io * * \ingroup applications */ /* * ===================================================================== * Architecture * ===================================================================== * * One dialplan application, AMD_WS(), executed on the channel's PBX thread. * Everything a call needs lives in a stack struct (struct amd_call) plus two * heap buffers (audio accumulator, server-text buffer). There are no * per-call globals; shared state is limited to the configuration snapshot * (mutex), the optional persistent MySQL connection (mutex), rate-limit * timestamps (mutex) and statistics counters (atomic). * * Transport is Asterisk's own WebSocket client (res_http_websocket, * OPTIONAL_API). It gives us a real connect timeout, wss:// through the * core's OpenSSL, RFC 6455 client masking, ping/pong handling and a file * descriptor that plugs straight into ast_waitfor_nandfds(). No third-party * library is linked. * * Phases of a call (all waits are bounded by ast_tvdiff_ms() deadlines): * * 1. SETUP parse args + config snapshot, answer (unless 'A'), * set read format slin. * 2. CONNECT ast_websocket_client_create_with_options() is *blocking* * (DNS, TCP, HTTP upgrade), so it runs on a short-lived helper * thread while the PBX thread keeps reading the channel: * audio is accumulated from the very first frame, hangup is * seen immediately and a stalled handshake cannot park the * call -- the PBX thread abandons the job at the connect * deadline and the helper disposes of whatever it produces. * The optional DB enrichment (bounded by db_timeout_ms) runs * on the same helper right before the connect, so a stalled * DB can cost that call its connect window (CONNECTION_ERROR) * but never stops the PBX thread from reading the channel. * The config JSON is built when the helper hands the socket * over. * 3. STREAM ast_waitfor_nandfds(chan + ws fd, <= 20 ms budget). * Voice frames are appended to the accumulator (never * truncated). Sends follow the time schedule measured from * the first captured frame (amd.py SEND_TIMES); after the * last mark a send happens whenever >= chunk_bytes are * pending or fallback_interval_ms passed since the last send * with something pending (amd.py's fallback rule). A mark at * which nothing is pending is a "no audio data" mark; after * eof_no_audio_streak of them in a row, with audio sent * before, the EOF finalisation starts (phase 4). Server TEXT * frames are classified as they arrive, exactly like amd.py: * 'HUMAN' in text -> HUMAN, else 'AMD'/'MACHINE' in text -> * MACHINE (the brand "AMDY" does not count), else an ack. * 4. EOF_WAIT {"eof":1} was sent to make the server finalise; wait up to * eof_wait_ms for ONE reply, still servicing the channel * (amd.py:404-435). HUMAN/MACHINE are honoured, any other * reply is EOF_INCONCLUSIVE, no reply/error is EOF_ERROR. * 5. GRACE when timeout_ms expires without a result and * result_grace_ms > 0 (default 0 = return at once like amd.py * at MAX_WAIT_TIME) the remaining audio is flushed and we wait * that long for the server's reply, still servicing the channel. * 6. EXIT stop playback, send {"eof":1} (again, as amd.py's cleanup * does), CLOSE 1000, unref, restore the read format, set * AMDSTATUS/AMDCAUSE/AMDSTATS/AMDRESPONSE/AMDELAPSED, bump * counters, one verbose summary line. * * Outcome vocabulary (production amd.py Jul 2026 + stock app_amd/VD_amd.agi): * HUMAN/HUMAN, MACHINE/, HUMAN/CONNECTION_ERROR (cannot connect), * HUMAN/PROCESSING_ERROR (ws lost after connect), HUMAN/FATAL_ERROR * (internal: alloc, format, thread, option A), NOTSURE/SERVER_TIMEOUT, NOTSURE/NOAUDIODATA-, HANGUP/HANGUP, * NOTSURE/EOF_INCONCLUSIVE, NOTSURE/EOF_ERROR. Errors default to HUMAN * "for safety" (the call reaches an agent), as amd.py does. * * Parallel playback: the optional playfile list is started with * ast_streamfile() after playdelay_ms; the file stream is driven by the * channel's timing fd inside ast_read() (or by the channel scheduler which we * run ourselves), exactly like ast_waitstream() does, so audio capture and * playback share one loop. Playback is stopped the moment a terminal result * arrives or the application exits. * * Timing model: * t_app application entry * t_connect the moment the connect job is started (after answer/format) * t_first first captured voice frame (0 if none) * marks conf send_schedule, measured from t_first (amd.py measures from * its stream start, which on a Local/SIP leg delivering audio at * once is the same instant) * t_last_send last successful audio send (fallback interval runs from it) * detection deadline = (t_first ? t_first : t_app) + timeout_ms * connect deadline = min(t_connect + connect_timeout_ms, detection deadline) * eof deadline = t_eof + eof_wait_ms (not capped by the detection * deadline: amd.py's 3 s recv timeout is independent) * grace deadline = detection deadline + result_grace_ms * playback start = t_app + playdelay_ms * AMDELAPSED = exit - (t_first ? t_first : t_app) * AMDSTATS = --- * (VD_amd.agi reads the first field as run_time) * * File layout: config -> db -> json/escape -> ws helpers -> audio * accumulator -> playback helpers -> exec -> cli -> load/unload/reload. */ /*** MODULEINFO res_http_websocket extended ***/ #include "asterisk.h" #include #include #include #include /* * Asterisk 18/20 strings.h declares "static int force_inline ..." which trips * -Wold-style-declaration under -Wextra; that is the core's header, not ours. */ #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wold-style-declaration" #include "asterisk/module.h" #include "asterisk/channel.h" #include "asterisk/pbx.h" #include "asterisk/app.h" #include "asterisk/file.h" #include "asterisk/sched.h" #include "asterisk/format_cache.h" #include "asterisk/callerid.h" #include "asterisk/lock.h" #include "asterisk/config.h" #include "asterisk/cli.h" #include "asterisk/utils.h" #include "asterisk/strings.h" #include "asterisk/time.h" #include "asterisk/tcptls.h" #include "asterisk/http_websocket.h" #pragma GCC diagnostic pop #ifdef HAVE_MYSQL #include #include #endif /*** DOCUMENTATION Answering Machine Detection via an external WebSocket service (amdy.io). AMD service host name or IP. Default from amd_ws.conf host= (127.0.0.1). AMD service TCP port. Default from amd_ws.conf port= (2700). Invalid values fall back to the default with a warning. Call tracking id sent as VID. Default: CALLERID(name) when valid and non-empty, else Unknown. Overall detection window in milliseconds. Default from amd_ws.conf timeout_ms= (10000). Values <= 0 use the default. Optional sound file(s) to play into the channel while audio is being captured, with Playback() semantics: path relative to the sounds directory, no extension, &-separated list played sequentially, language taken from the channel. Playback starts after playdelay_ms and is stopped as soon as a terminal result arrives or the application exits. End of file does not end detection. Streams 8 kHz signed-linear audio from the channel to the AMD service over a WebSocket and sets channel variables with the classification, using the vocabulary of the production amdy.io EAGI client (amd.py) and of the stock AMD() application. The connection is made with Asterisk's own WebSocket client (res_http_websocket). Channel variables set on every exit path: the server said HUMAN, or an error occurred (errors default to HUMAN for safety, as amd.py does) the server said MACHINE or AMD no decision (timeout, EOF finalisation inconclusive) the channel hung up before a result with AMDSTATUS=HUMAN: the server's decision with AMDSTATUS=MACHINE: the server's reply text (printable ASCII, max 255 chars), e.g. MACHINE or AMD cannot connect: DNS/TCP/upgrade/TLS failure, connect timeout, per-host connect cap, res_http_websocket not loaded (AMDSTATUS=HUMAN) WebSocket error or close after the connect, before a result (AMDSTATUS=HUMAN) internal failure: allocation, read format, thread creation, config unusable, not answered with option A (AMDSTATUS=HUMAN) timeout_ms elapsed, audio was sent, no result (AMDSTATUS=NOTSURE) timeout_ms elapsed and no audio was ever captured, ms = the elapsed window (AMDSTATUS=NOTSURE; like the stock AMD()) channel hung up before a result no audio at eof_no_audio_streak consecutive schedule marks, {"eof":1} was sent and the server's reply was neither HUMAN nor MACHINE (AMDSTATUS=NOTSURE) as above, but no reply within eof_wait_ms or the WebSocket failed (AMDSTATUS=NOTSURE) elapsed_ms-audio_ms_sent-chunks_sent-bytes_sent (integers; ViciDial reads the first field as run_time). Raw last server text (printable ASCII only, max 255 chars). Milliseconds from the first captured audio frame to exit (from application start when no audio was captured). Always returns 0; the PBX detects hangup itself. AMD Playback ***/ #define AMD_WS_VERSION "2.0.0" static const char app[] = "AMD_WS"; static const char synopsis[] = "Answering Machine Detection via an external WebSocket service (amdy.io)"; static const char description[] = " AMD_WS([host[,port[,vid[,timeout_ms[,playfile[,options]]]]]])\n" "\n" "Streams 8 kHz signed-linear audio from the channel to the AMD service over a\n" "WebSocket (Asterisk's own res_http_websocket client, ws:// or wss://) and sets\n" "channel variables with the classification. Version " AMD_WS_VERSION ".\n" "\n" "Parameters (all optional, defaults from /etc/asterisk/amd_ws.conf):\n" " host AMD service host name or IP (conf host=, default 127.0.0.1)\n" " port AMD service TCP port (conf port=, default 2700)\n" " vid Call tracking id sent as VID; default CALLERID(name) if valid and\n" " non-empty, else 'Unknown'\n" " timeout_ms Overall detection window (conf timeout_ms=, default 10000)\n" " playfile Sound file(s) played INTO the channel while audio is captured,\n" " Playback() semantics: relative to the sounds dir, no extension,\n" " '&'-separated list played in order, channel language. Starts\n" " after playdelay_ms; stopped when a result arrives or on exit.\n" " End of file does not end detection.\n" " options n no DB lookup for this call\n" " s TLS (wss://); verification per conf tls_verify\n" " d(ms) playdelay_ms override\n" " c(ms) connect timeout override (conf connect_timeout_ms, 10000)\n" " p(phone) phone number sent as \"phone\" (p or k skips the DB lookup)\n" " k(code) country/phone code sent as \"country_code\"\n" " i(cid) caller id sent as \"caller_id\" (default: CALLERID(num)\n" " when non-empty and not Unknown; conf send_caller_id=no\n" " turns the default off)\n" " a answer the channel if not up (default)\n" " A do NOT answer; HUMAN/FATAL_ERROR if the channel is not up\n" " v trace: one verbose-3 line per event (connect, first audio,\n" " each chunk sent, each reply, result) with +ms offsets;\n" " conf trace=yes does it for every call\n" "\n" "Channel variables set on EVERY exit path (vocabulary of the production amdy.io\n" "EAGI client amd.py and of the stock AMD(); errors default to HUMAN for safety):\n" " AMDSTATUS / AMDCAUSE\n" " HUMAN / HUMAN the server said HUMAN\n" " MACHINE / the server said MACHINE or AMD; AMDCAUSE is the\n" " reply text itself (printable ASCII, max 255)\n" " HUMAN / CONNECTION_ERROR cannot connect: DNS, TCP, HTTP upgrade or TLS\n" " failure, connect timeout, per-host connect cap,\n" " res_http_websocket not loaded\n" " HUMAN / PROCESSING_ERROR WebSocket error or close after the connect,\n" " before a result\n" " HUMAN / FATAL_ERROR internal failure: allocation, read format,\n" " thread creation, config unusable, option A on\n" " a channel that is not up\n" " NOTSURE / SERVER_TIMEOUT timeout_ms elapsed, audio was sent, no result\n" " NOTSURE / NOAUDIODATA- timeout_ms elapsed, no audio ever captured\n" " ( = elapsed window, like the stock AMD())\n" " HANGUP / HANGUP channel hung up before a result\n" " NOTSURE / EOF_INCONCLUSIVE EOF finalisation reply was neither HUMAN nor\n" " MACHINE\n" " NOTSURE / EOF_ERROR EOF finalisation: no reply within eof_wait_ms\n" " or WebSocket failure\n" " AMDSTATS ---\n" " (ViciDial's VD_amd.agi reads the first field as run_time)\n" " AMDRESPONSE raw last server text (printable ASCII, max 255 chars)\n" " AMDELAPSED ms from the first captured audio frame to exit (from start if none)\n" " AMDPHONE / AMDCOUNTRYCODE number and country code sent to the service (DB lookup or\n" " p()/k()); set only when known - lets the dialplan redial the lead\n" "\n" "Wire protocol (amd.py, Jul 2026): on connect a TEXT frame\n" " {\"config\":{\"sample_rate\":8000,\"VID\":\"\"[,\"phone\":\"..\"][,\"country_code\":\"..\"]\n" " [,\"caller_id\":\"..\"]}}\n" "then BINARY slin chunks at the conf send_schedule marks (default 500,1000,1500,\n" "2000,3000,...,9000 ms from the first frame), then whenever chunk_bytes (8000)\n" "are pending or fallback_interval_ms (1000) passed since the last send. Each\n" "server TEXT reply is classified exactly like amd.py: 'HUMAN' in the text ->\n" "HUMAN; else 'AMD' or 'MACHINE' in the text -> MACHINE (the brand \"AMDY\" does\n" "not count); anything else is an ack (case-sensitive; HONEYPOT etc. are acks).\n" "When nothing was captured at eof_no_audio_streak (2) consecutive marks after\n" "audio was sent, {\"eof\":1} is sent and ONE reply is awaited for eof_wait_ms\n" "(3000) -> HUMAN / MACHINE / EOF_INCONCLUSIVE / EOF_ERROR. {\"eof\":1} + CLOSE\n" "1000 on exit.\n" "\n" "Always returns 0. Fallback to the stock AMD() in the dialplan with\n" " GotoIf($[\"${AMDCAUSE}\"=\"CONNECTION_ERROR\" | \"${AMDCAUSE}\"=\"PROCESSING_ERROR\"\n" " | \"${AMDCAUSE}\"=\"FATAL_ERROR\"]?amd_fallback)\n" "CLI: amd_ws show settings. See also: AMD, Playback.\n"; /* ------------------------------------------------------------------------ * Compile-time limits * ---------------------------------------------------------------------- */ #define SAMPLE_RATE 8000 #define BYTES_PER_MS 16 /* 8 kHz * 16 bit mono */ #define MAX_SCHEDULE 64 /* entries in send_schedule (a 0.5 s cadence to 30 s is 60) */ #define STATUS_TOKEN_LEN 32 /* AMDSTATUS */ #define LOOP_BUDGET_MS 20 /* max ast_waitfor_nandfds budget */ /* * Per-write bound on the socket. res_http_websocket turns a write that does * not complete within this time into a CLOSE 1011 (= PROCESSING_ERROR for * us), so it must cover one RTT of ACK clocking for a multi-frame flush on a * fresh connection over a WAN, while staying far below the ~1.9 s after which * the Local channel's read queue overflows. */ #define WS_WRITE_TIMEOUT_MS 500 #define WS_MAX_FRAME_BYTES 16000 /* split larger sends (1 s of audio); the core alloca()s a frame copy */ #define WS_READ_DRAIN_MAX 8 /* frames read per readiness before the channel is serviced again */ #define ACC_HARD_CAP (1024 * 1024) #define RX_CAP 16384 /* server text buffer */ #define MAX_RESPONSE 255 /* AMDRESPONSE length; also AMDCAUSE for MACHINE (raw reply) */ #define CAUSE_LEN (MAX_RESPONSE + 1) #define MAX_CALLERID_LEN 127 /* caller_id sent in the config JSON */ #define CONNECT_WARN_S 10 /* connect failure warning per host */ #define PENDING_WARN_S 60 /* "cap reached" warning per host */ #define DEF_MAX_PENDING 64 /* default max_pending_connects (per host) */ #define MAX_PENDING_HOSTS 16 /* distinct hosts tracked at once */ #define DB_WARN_S 60 /* DB warning rate limit */ #define DB_BACKOFF_S 5 /* skip the DB this long after a failure */ #define MAX_VID_LEN 255 /* ------------------------------------------------------------------------ * Configuration * ---------------------------------------------------------------------- */ struct amd_ws_conf { char host[256]; int port; int tls; int tls_verify; int tls_check_hostname; char tls_cafile[256]; int timeout_ms; int connect_timeout_ms; int result_grace_ms; int schedule[MAX_SCHEDULE]; int n_schedule; int chunk_bytes; int fallback_interval_ms; int eof_no_audio_streak; /* 0 = EOF finalisation disabled */ int eof_wait_ms; int send_caller_id; int trace; /* per-call event timeline at verbose 3 (option v) */ int playdelay_ms; int db; int db_timeout_ms; char astguiclient_conf[256]; char extra_config[512]; /* raw JSON object merged into the config frame, e.g. {"short_no_greeting":true} */ int max_pending_connects; }; static struct amd_ws_conf g_conf; AST_MUTEX_DEFINE_STATIC(conf_lock); /* Statistics (ast_atomic_fetchadd_int), one per outcome of the vocabulary */ static int cnt_calls, cnt_human, cnt_machine, cnt_hangups; static int cnt_connection_error, cnt_processing_error, cnt_fatal_error; static int cnt_server_timeout, cnt_noaudiodata, cnt_eof_inconclusive, cnt_eof_error; /* * Connect helper threads. inflight_helpers counts every live helper (a * legitimate burst of answers creates as many for a few milliseconds; that is * never capped). A server that accepts TCP but never answers the HTTP * upgrade (and never closes) keeps its helper blocked in the core's handshake * read (res_http_websocket gives that read no timeout) after the call gave up * at connect_timeout_ms: such PARKED helpers are counted per host, and beyond * max_pending_connects new calls to THAT host fail fast with CONNECTION_ERROR * instead of piling up threads; one dead test host cannot disable production. * A parked helper ends only when the peer closes the socket or Asterisk * restarts. */ static int inflight_helpers; struct pending_slot { char host[256]; int count; /* parked helpers for this host */ time_t last_warn; /* "cap reached" warning, once per PENDING_WARN_S */ }; static struct pending_slot pending_hosts[MAX_PENDING_HOSTS]; static int pending_total; /* sum over the slots, for the CLI */ AST_MUTEX_DEFINE_STATIC(pending_lock); /* Rate limiting of repeated warnings */ struct warn_slot { char host[256]; time_t last; }; static struct warn_slot connect_warns[8]; AST_MUTEX_DEFINE_STATIC(warn_lock); static void conf_set_defaults(struct amd_ws_conf *c) { /* amd.py SEND_TIMES (Jul 2026), MAX_WAIT_TIME, CONNECTION_TIMEOUT, FALLBACK_CHUNK_SIZE */ static const int def_sched[] = { 500, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000 }; int i; memset(c, 0, sizeof(*c)); ast_copy_string(c->host, "127.0.0.1", sizeof(c->host)); c->port = 2700; c->tls = 0; c->tls_verify = 1; c->tls_check_hostname = 0; c->timeout_ms = 10000; c->connect_timeout_ms = 10000; c->result_grace_ms = 0; for (i = 0; i < (int) ARRAY_LEN(def_sched); i++) { c->schedule[i] = def_sched[i]; } c->n_schedule = ARRAY_LEN(def_sched); c->chunk_bytes = 8000; c->fallback_interval_ms = 1000; c->eof_no_audio_streak = 2; c->eof_wait_ms = 3000; c->send_caller_id = 1; c->trace = 0; c->playdelay_ms = 0; #ifdef HAVE_MYSQL c->db = 1; #else c->db = 0; #endif c->db_timeout_ms = 1000; ast_copy_string(c->astguiclient_conf, "/etc/astguiclient.conf", sizeof(c->astguiclient_conf)); c->extra_config[0] = '\0'; c->max_pending_connects = DEF_MAX_PENDING; } /*! \brief Parse a positive int; returns 0 and leaves *out untouched on error */ static int parse_int(const char *val, int min, int max, int *out) { char *end; long v; if (ast_strlen_zero(val)) { return 0; } v = strtol(val, &end, 10); while (*end && isspace((unsigned char) *end)) { end++; } if (*end || v < min || v > max) { return 0; } *out = (int) v; return 1; } /*! \brief "500,1000,1500" -> strictly increasing ms marks; returns 0 on error */ static int parse_schedule(const char *val, int *sched, int *n) { char *copy = ast_strdupa(S_OR(val, "")); char *tok; int tmp[MAX_SCHEDULE]; int count = 0, prev = 0; /* parse into a scratch array: on any error the caller's schedule stays untouched */ while ((tok = strsep(©, ", "))) { int v; if (ast_strlen_zero(tok)) { continue; } if (count >= MAX_SCHEDULE) { ast_log(LOG_WARNING, "AMD_WS: send_schedule has more than %d entries\n", MAX_SCHEDULE); return 0; } if (!parse_int(tok, 1, 600000, &v) || v <= prev) { return 0; } tmp[count++] = v; prev = v; } if (!count) { return 0; } memcpy(sched, tmp, count * sizeof(*sched)); *n = count; return 1; } /*! \brief Load amd_ws.conf into g_conf. Missing file = defaults. */ static int load_config(int reload) { struct ast_flags flags = { reload ? CONFIG_FLAG_FILEUNCHANGED : 0 }; struct ast_config *cfg; struct ast_variable *v; struct amd_ws_conf c; cfg = ast_config_load("amd_ws.conf", flags); if (cfg == CONFIG_STATUS_FILEUNCHANGED) { return 0; } conf_set_defaults(&c); if (cfg == CONFIG_STATUS_FILEMISSING) { ast_debug(1, "AMD_WS: amd_ws.conf not found, using built-in defaults\n"); } else if (cfg == CONFIG_STATUS_FILEINVALID) { ast_log(LOG_WARNING, "AMD_WS: amd_ws.conf is invalid, using built-in defaults\n"); cfg = NULL; } for (v = cfg ? ast_variable_browse(cfg, "general") : NULL; v; v = v->next) { const char *name = v->name, *val = v->value; int ok = 1; if (!strcasecmp(name, "host")) { if (!ast_strlen_zero(val)) { ast_copy_string(c.host, val, sizeof(c.host)); } } else if (!strcasecmp(name, "port")) { ok = parse_int(val, 1, 65535, &c.port); } else if (!strcasecmp(name, "tls")) { c.tls = ast_true(val) ? 1 : 0; } else if (!strcasecmp(name, "tls_verify")) { c.tls_verify = ast_false(val) ? 0 : 1; } else if (!strcasecmp(name, "tls_check_hostname")) { c.tls_check_hostname = ast_true(val) ? 1 : 0; } else if (!strcasecmp(name, "tls_cafile")) { ast_copy_string(c.tls_cafile, S_OR(val, ""), sizeof(c.tls_cafile)); } else if (!strcasecmp(name, "timeout_ms")) { ok = parse_int(val, 1, 600000, &c.timeout_ms); } else if (!strcasecmp(name, "connect_timeout_ms")) { ok = parse_int(val, 1, 600000, &c.connect_timeout_ms); } else if (!strcasecmp(name, "result_grace_ms")) { ok = parse_int(val, 0, 600000, &c.result_grace_ms); } else if (!strcasecmp(name, "send_schedule")) { ok = parse_schedule(val, c.schedule, &c.n_schedule); } else if (!strcasecmp(name, "chunk_bytes")) { ok = parse_int(val, 320, 1000000, &c.chunk_bytes); } else if (!strcasecmp(name, "fallback_interval_ms")) { ok = parse_int(val, 1, 600000, &c.fallback_interval_ms); } else if (!strcasecmp(name, "eof_no_audio_streak")) { ok = parse_int(val, 0, MAX_SCHEDULE, &c.eof_no_audio_streak); } else if (!strcasecmp(name, "eof_wait_ms")) { ok = parse_int(val, 1, 600000, &c.eof_wait_ms); } else if (!strcasecmp(name, "send_caller_id")) { c.send_caller_id = ast_false(val) ? 0 : 1; } else if (!strcasecmp(name, "trace")) { c.trace = ast_true(val) ? 1 : 0; } else if (!strcasecmp(name, "playdelay_ms")) { ok = parse_int(val, 0, 600000, &c.playdelay_ms); } else if (!strcasecmp(name, "db")) { c.db = ast_true(val) ? 1 : 0; #ifndef HAVE_MYSQL if (c.db) { ast_log(LOG_NOTICE, "AMD_WS: db=yes requested but the module was built without MySQL support\n"); c.db = 0; } #endif } else if (!strcasecmp(name, "db_timeout_ms")) { ok = parse_int(val, 1, 60000, &c.db_timeout_ms); } else if (!strcasecmp(name, "astguiclient_conf")) { if (!ast_strlen_zero(val)) { ast_copy_string(c.astguiclient_conf, val, sizeof(c.astguiclient_conf)); } } else if (!strcasecmp(name, "extra_config")) { /* must be a JSON object: {"key":value,...}; spliced verbatim into "config" */ const char *v0 = ast_skip_blanks(S_OR(val, "")); size_t vl = strlen(v0); while (vl > 0 && isspace((unsigned char) v0[vl - 1])) { vl--; } if (vl == 0) { c.extra_config[0] = '\0'; } else if (vl >= 2 && v0[0] == '{' && v0[vl - 1] == '}' && vl < sizeof(c.extra_config)) { ast_copy_string(c.extra_config, v0, vl + 1); } else { ok = 0; } } else if (!strcasecmp(name, "max_pending_connects")) { ok = parse_int(val, 8, 1024, &c.max_pending_connects); } else { ast_log(LOG_WARNING, "AMD_WS: amd_ws.conf: unknown option '%s' at line %d, ignored (removed in 2.0? see amd_ws.conf.sample)\n", name, v->lineno); } if (!ok) { ast_log(LOG_WARNING, "AMD_WS: amd_ws.conf: invalid value '%s' for '%s' at line %d, ignored\n", S_OR(val, ""), name, v->lineno); } } if (cfg) { ast_config_destroy(cfg); } ast_mutex_lock(&conf_lock); g_conf = c; ast_mutex_unlock(&conf_lock); return 0; } static void conf_snapshot(struct amd_ws_conf *out) { ast_mutex_lock(&conf_lock); *out = g_conf; ast_mutex_unlock(&conf_lock); } /*! \brief Return 1 if a warning for this host may be logged now (once per CONNECT_WARN_S) */ static int connect_warn_allowed(const char *host) { time_t now = time(NULL); int i, oldest = 0, allowed = 0; ast_mutex_lock(&warn_lock); for (i = 0; i < (int) ARRAY_LEN(connect_warns); i++) { if (!strcmp(connect_warns[i].host, host)) { if (now - connect_warns[i].last >= CONNECT_WARN_S) { connect_warns[i].last = now; allowed = 1; } ast_mutex_unlock(&warn_lock); return allowed; } if (connect_warns[i].last < connect_warns[oldest].last) { oldest = i; } } ast_copy_string(connect_warns[oldest].host, host, sizeof(connect_warns[oldest].host)); connect_warns[oldest].last = now; ast_mutex_unlock(&warn_lock); return 1; } /*! \brief Slot of host (pending_lock held); create one when create is set. -1 when none. */ static int pending_slot_of(const char *host, int create) { int i, free_slot = -1; for (i = 0; i < MAX_PENDING_HOSTS; i++) { if (pending_hosts[i].count > 0 && !strcmp(pending_hosts[i].host, host)) { return i; } if (free_slot < 0 && pending_hosts[i].count == 0) { free_slot = i; } } if (!create || free_slot < 0) { return -1; /* > MAX_PENDING_HOSTS hosts parked at once: never counted, never refused */ } ast_copy_string(pending_hosts[free_slot].host, host, sizeof(pending_hosts[free_slot].host)); pending_hosts[free_slot].last_warn = 0; return free_slot; } /*! * \brief Is the per-host cap of parked helpers reached? *warn is set when the * rate-limited (PENDING_WARN_S) warning may be logged. */ static int pending_cap_reached(const char *host, int max, int *warn) { int slot, reached = 0; *warn = 0; ast_mutex_lock(&pending_lock); slot = pending_slot_of(host, 0); if (slot >= 0 && pending_hosts[slot].count >= max) { time_t now = time(NULL); reached = 1; if (now - pending_hosts[slot].last_warn >= PENDING_WARN_S) { pending_hosts[slot].last_warn = now; *warn = 1; } } ast_mutex_unlock(&pending_lock); return reached; } /*! \brief The call gave up on a still-pending connect: its helper is now parked. */ static void pending_park(const char *host) { int slot; ast_mutex_lock(&pending_lock); slot = pending_slot_of(host, 1); if (slot >= 0) { pending_hosts[slot].count++; pending_total++; } ast_mutex_unlock(&pending_lock); } /*! \brief A parked helper ended (the peer closed, or the connect finally completed). */ static void pending_unpark(const char *host) { int slot; ast_mutex_lock(&pending_lock); slot = pending_slot_of(host, 0); if (slot >= 0) { pending_hosts[slot].count--; pending_total--; } ast_mutex_unlock(&pending_lock); } /* ------------------------------------------------------------------------ * DB lookup (ViciDial phone / country enrichment) -- optional * ---------------------------------------------------------------------- */ struct db_creds { char server[256]; char database[128]; char user[128]; char pass[256]; int port; int loaded; /* 1 if the file was read */ int keys; /* VARDB_* lines found; 0 = no config, no lookup (amd.py:95-97) */ }; static struct db_creds g_db_creds; /* protected by conf_lock */ /*! * \brief Parse /etc/astguiclient.conf VARDB_* lines the way ViciDial does. * * Tolerates tabs, trailing whitespace, inline '#'/';' comments (when preceded by * whitespace or at line start) and '=>' inside values (split on the first one). * Credentials are never logged. When the file cannot be read, loaded stays 0; * when it has no VARDB_ line at all, keys stays 0. Either way db_lookup() * skips the DB entirely (no connection attempts with the built-in defaults), * exactly as amd.py's "DB ERROR: no config" (amd.py:95-97); with the lookup * enabled this is said once at load/reload. */ static void load_db_creds(const char *path, int db_enabled) { struct db_creds c; FILE *f; char line[1024]; memset(&c, 0, sizeof(c)); ast_copy_string(c.server, "localhost", sizeof(c.server)); ast_copy_string(c.database, "asterisk", sizeof(c.database)); ast_copy_string(c.user, "cron", sizeof(c.user)); c.port = 3306; f = fopen(path, "r"); if (f) { c.loaded = 1; while (fgets(line, sizeof(line), f)) { char *p, *arrow, *key, *val; line[strcspn(line, "\r\n")] = '\0'; /* strip inline comments: '#' or ';' at line start or after whitespace */ for (p = line; *p; p++) { if ((*p == '#' || *p == ';') && (p == line || isspace((unsigned char) p[-1]))) { *p = '\0'; break; } } key = ast_skip_blanks(line); if (strncmp(key, "VARDB_", 6)) { continue; } arrow = strstr(key, "=>"); if (!arrow) { continue; } *arrow = '\0'; val = ast_strip(arrow + 2); key = ast_strip(key); c.keys++; if (!strcmp(key, "VARDB_server")) { ast_copy_string(c.server, val, sizeof(c.server)); } else if (!strcmp(key, "VARDB_database")) { ast_copy_string(c.database, val, sizeof(c.database)); } else if (!strcmp(key, "VARDB_user")) { ast_copy_string(c.user, val, sizeof(c.user)); } else if (!strcmp(key, "VARDB_pass")) { ast_copy_string(c.pass, val, sizeof(c.pass)); } else if (!strcmp(key, "VARDB_port")) { if (!parse_int(val, 1, 65535, &c.port)) { c.port = 3306; } } } fclose(f); if (!c.keys && db_enabled) { ast_log(LOG_NOTICE, "AMD_WS: %s has no VARDB_ lines - the phone/country DB lookup is skipped until they exist and the module is reloaded\n", path); } } else if (db_enabled) { ast_log(LOG_NOTICE, "AMD_WS: cannot read %s: %s - the phone/country DB lookup is skipped until the file is readable and the module reloaded\n", path, strerror(errno)); } else { ast_debug(1, "AMD_WS: cannot read %s: %s (DB lookup disabled anyway)\n", path, strerror(errno)); } ast_mutex_lock(&conf_lock); g_db_creds = c; ast_mutex_unlock(&conf_lock); } #ifdef HAVE_MYSQL static MYSQL *db_conn; /* persistent connection, under db_lock */ static time_t db_last_warn; /* under db_lock */ static time_t db_fail_until; /* under db_lock: skip the DB until then */ AST_MUTEX_DEFINE_STATIC(db_lock); static void db_warn(const char *fmt, ...) __attribute__((format(printf, 1, 2))); static void db_warn(const char *fmt, ...) { /* called with db_lock held */ time_t now = time(NULL); va_list ap; char buf[512]; if (now - db_last_warn < DB_WARN_S) { return; } db_last_warn = now; va_start(ap, fmt); vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); ast_log(LOG_WARNING, "AMD_WS: %s (further DB warnings suppressed for %d s)\n", buf, DB_WARN_S); } /*! \brief Connect the persistent connection; db_lock held. Returns 0 on success. */ static int db_connect_locked(int timeout_ms) { struct db_creds creds; unsigned int secs = (timeout_ms + 999) / 1000; if (secs < 1) { secs = 1; } ast_mutex_lock(&conf_lock); creds = g_db_creds; ast_mutex_unlock(&conf_lock); db_conn = mysql_init(NULL); if (!db_conn) { db_warn("mysql_init failed"); return -1; } mysql_options(db_conn, MYSQL_OPT_CONNECT_TIMEOUT, &secs); mysql_options(db_conn, MYSQL_OPT_READ_TIMEOUT, &secs); mysql_options(db_conn, MYSQL_OPT_WRITE_TIMEOUT, &secs); if (!mysql_real_connect(db_conn, creds.server, creds.user, creds.pass, creds.database, creds.port, NULL, 0)) { db_warn("DB connect to %s:%d failed: %s", creds.server, creds.port, mysql_error(db_conn)); mysql_close(db_conn); db_conn = NULL; db_fail_until = time(NULL) + DB_BACKOFF_S; return -1; } ast_debug(1, "AMD_WS: DB connected to %s:%d/%s\n", creds.server, creds.port, creds.database); return 0; } /*! * \brief Look up phone_code/phone_number for a ViciDial callerid (VID). * * Runs on the connect helper thread, never on the PBX thread. Bounded: the * mutex is acquired with a deadline of timeout_ms, the connection uses * connect/read/write timeouts of ceil(timeout_ms / 1000) s each, and after a * failure the DB is skipped for DB_BACKOFF_S so a dead DB costs at most one * timeout, not one per call. A connection the server dropped meanwhile * (wait_timeout: CR_SERVER_GONE_ERROR / CR_SERVER_LOST) is reconnected once * within the same deadline instead of starting the backoff. Any problem is * logged at WARNING once per minute and the call continues without phone. * Returns 0 when a row was found. */ static int db_lookup(const char *vid, int timeout_ms, char *phone, size_t phone_sz, char *code, size_t code_sz) { struct timeval start = ast_tvnow(); char escaped[2 * MAX_VID_LEN + 1]; char query[sizeof(escaped) + 160]; MYSQL_RES *res; MYSQL_ROW row; unsigned long elen; int found = -1, loaded; phone[0] = '\0'; code[0] = '\0'; ast_mutex_lock(&conf_lock); loaded = g_db_creds.loaded && g_db_creds.keys; ast_mutex_unlock(&conf_lock); if (!loaded) { return -1; /* astguiclient.conf unreadable or without VARDB_ lines: said once at load/reload */ } /* Acquire the lock with a deadline: never wait longer than db_timeout_ms */ while (ast_mutex_trylock(&db_lock)) { if (ast_tvdiff_ms(ast_tvnow(), start) >= timeout_ms) { ast_debug(1, "AMD_WS: DB busy for %d ms, skipping lookup\n", timeout_ms); return -1; } usleep(1000); } if (db_fail_until && time(NULL) < db_fail_until) { ast_mutex_unlock(&db_lock); return -1; } mysql_thread_init(); if (!db_conn && db_connect_locked(timeout_ms)) { goto done; } escaped[0] = '\0'; elen = mysql_real_escape_string(db_conn, escaped, vid, strlen(vid)); if (elen == (unsigned long) -1) { db_warn("mysql_real_escape_string failed"); goto done; } snprintf(query, sizeof(query), "SELECT phone_code,phone_number FROM vicidial_auto_calls WHERE callerid='%s' ORDER BY auto_call_id DESC LIMIT 1", escaped); if (mysql_query(db_conn, query)) { unsigned int e = mysql_errno(db_conn); if ((e == CR_SERVER_GONE_ERROR || e == CR_SERVER_LOST) && ast_tvdiff_ms(ast_tvnow(), start) < timeout_ms) { /* idle connection dropped by the server (wait_timeout): one reconnect, same deadline */ ast_debug(1, "AMD_WS: DB connection gone (%u), reconnecting\n", e); mysql_close(db_conn); db_conn = NULL; if (db_connect_locked(timeout_ms)) { goto done; /* backoff set by db_connect_locked */ } if (!mysql_query(db_conn, query)) { goto fetch; } } db_warn("DB query failed: %s", mysql_error(db_conn)); mysql_close(db_conn); /* manual reconnect on the next call */ db_conn = NULL; db_fail_until = time(NULL) + DB_BACKOFF_S; goto done; } fetch: res = mysql_store_result(db_conn); if (res) { row = mysql_fetch_row(res); if (row) { if (row[0]) { ast_copy_string(code, row[0], code_sz); } if (row[1]) { ast_copy_string(phone, row[1], phone_sz); } found = 0; } mysql_free_result(res); } done: mysql_thread_end(); ast_mutex_unlock(&db_lock); return found; } static void db_shutdown(void) { ast_mutex_lock(&db_lock); if (db_conn) { mysql_close(db_conn); db_conn = NULL; } ast_mutex_unlock(&db_lock); /* No mysql_library_end(): other modules (res_config_mysql, cdr_mysql) may share the library. */ } #endif /* HAVE_MYSQL */ static const char *db_availability(void) { #ifdef HAVE_MYSQL return "available"; #else return "unavailable (built without MySQL)"; #endif } /* ------------------------------------------------------------------------ * JSON escaping / classification / sanitising * ---------------------------------------------------------------------- */ /*! * \brief Length of a valid UTF-8 sequence starting at s (1..4), or 0 if invalid. */ static int utf8_seq_len(const unsigned char *s) { unsigned char c = s[0]; if (c < 0x80) { return 1; } if (c >= 0xC2 && c <= 0xDF) { return (s[1] & 0xC0) == 0x80 ? 2 : 0; } if (c >= 0xE0 && c <= 0xEF) { if ((s[1] & 0xC0) != 0x80 || (s[2] & 0xC0) != 0x80) { return 0; } if (c == 0xE0 && s[1] < 0xA0) { return 0; /* overlong */ } if (c == 0xED && s[1] >= 0xA0) { return 0; /* UTF-16 surrogate */ } return 3; } if (c >= 0xF0 && c <= 0xF4) { if ((s[1] & 0xC0) != 0x80 || (s[2] & 0xC0) != 0x80 || (s[3] & 0xC0) != 0x80) { return 0; } if (c == 0xF0 && s[1] < 0x90) { return 0; /* overlong */ } if (c == 0xF4 && s[1] >= 0x90) { return 0; /* > U+10FFFF */ } return 4; } return 0; } /*! * \brief JSON-escape src into dst. * * Escapes '"', '\\' and control characters; passes valid UTF-8 through and * replaces invalid bytes with '?' so the TEXT frame is always valid UTF-8. * \retval length written (excluding NUL), -1 if dst is too small. */ static int json_escape(char *dst, size_t dst_size, const char *src) { const unsigned char *s = (const unsigned char *) src; size_t pos = 0; while (*s) { const char *esc = NULL; char hex[8]; int n; switch (*s) { case '"': esc = "\\\""; break; case '\\': esc = "\\\\"; break; case '\b': esc = "\\b"; break; case '\f': esc = "\\f"; break; case '\n': esc = "\\n"; break; case '\r': esc = "\\r"; break; case '\t': esc = "\\t"; break; default: if (*s < 0x20) { snprintf(hex, sizeof(hex), "\\u%04x", *s); esc = hex; } } if (esc) { size_t elen = strlen(esc); if (pos + elen >= dst_size) { return -1; } memcpy(dst + pos, esc, elen); pos += elen; s++; continue; } n = utf8_seq_len(s); if (!n) { if (pos + 1 >= dst_size) { return -1; } dst[pos++] = '?'; s++; continue; } if (pos + n >= dst_size) { return -1; } memcpy(dst + pos, s, n); pos += n; s += n; } dst[pos] = '\0'; return (int) pos; } /*! \brief Case-sensitive substring search over a byte range (embedded NULs allowed, like python's "in") */ static const char *find_sub(const char *hay, size_t len, const char *needle) { size_t nlen = strlen(needle); const char *p, *end; if (nlen > len) { return NULL; } for (p = hay, end = hay + len - nlen; p <= end; p++) { if (*p == needle[0] && !memcmp(p, needle, nlen)) { return p; } } return NULL; } enum classification { CLASS_ACK = 0, /* keep going */ CLASS_HUMAN, CLASS_MACHINE, }; /*! * \brief Classify a server TEXT message exactly like amd.py (Jul 2026): * * if 'HUMAN' in text: HUMAN * elif 'AMD' in text or 'MACHINE' in text: MACHINE * else: ack, keep going * * Case-sensitive substring semantics, so "NOT_HUMAN" is HUMAN and "amd" is an * ack, as in production. One guard on top of amd.py: an "AMD" immediately * followed by 'Y' is the brand name ("AMDY ack") and does not count. Any * other reply (HONEYPOT, "{}", "ack", ...) is an acknowledgement. */ static enum classification classify_text(const char *text, size_t len) { const char *p = text, *end = text + len; /* amd_server "stage_results" progress frames (STAGE----) are interim */ if (len >= 6 && !strncmp(text, "STAGE-", 6)) { return CLASS_ACK; } if (find_sub(text, len, "HUMAN")) { return CLASS_HUMAN; } while ((p = find_sub(p, end - p, "AMD"))) { if (p + 3 >= end || p[3] != 'Y') { return CLASS_MACHINE; } p += 3; } if (find_sub(text, len, "MACHINE")) { return CLASS_MACHINE; } return CLASS_ACK; } /*! \brief Copy printable ASCII only (max MAX_RESPONSE chars) for AMDRESPONSE */ static void sanitize_response(const char *text, size_t len, char *out, size_t out_sz) { size_t i, o = 0; for (i = 0; i < len && o + 1 < out_sz && o < MAX_RESPONSE; i++) { unsigned char c = (unsigned char) text[i]; if (c >= 0x20 && c <= 0x7E) { out[o++] = (char) c; } } out[o] = '\0'; } /* ------------------------------------------------------------------------ * WebSocket helpers * ---------------------------------------------------------------------- */ static const char *ws_result_str(enum ast_websocket_result r) { switch (r) { case WS_OK: return "ok"; case WS_ALLOCATE_ERROR: return "allocation error"; case WS_KEY_ERROR: return "key error"; case WS_URI_PARSE_ERROR: return "URI parse error"; case WS_URI_RESOLVE_ERROR: return "DNS resolve error"; case WS_BAD_STATUS: return "bad HTTP status"; case WS_INVALID_RESPONSE: return "invalid HTTP response"; case WS_BAD_REQUEST: return "400 bad request"; case WS_URL_NOT_FOUND: return "404 not found"; case WS_HEADER_MISMATCH: return "handshake header mismatch"; case WS_HEADER_MISSING: return "handshake header missing"; case WS_NOT_SUPPORTED: return "not supported"; case WS_WRITE_ERROR: return "write error"; case WS_CLIENT_START_ERROR: return "connect failed/timed out"; default: break; /* newer cores add values (e.g. WS_UNAUTHORIZED) */ } return "unknown"; } /*! \brief Free a TLS config that the WebSocket client did NOT take ownership of */ static void tls_cfg_free(struct ast_tls_config *tls) { if (!tls) { return; } ast_free(tls->certfile); ast_free(tls->pvtfile); ast_free(tls->cipher); ast_free(tls->cafile); ast_free(tls->capath); ast_free(tls); } /*! * \brief Build the TLS config for wss://. * * The websocket client's session args take ownership of the struct and free * every member with ast_free(), so all members must be ast_strdup()ed or NULL. * Hostname verification is off by default: Asterisk 16's client never fills * the hostname used for the CN check, so it would always fail there. Set * tls_check_hostname=yes on 18+ if wanted. */ static struct ast_tls_config *tls_cfg_create(const struct amd_ws_conf *conf) { static const char *ca_bundles[] = { "/etc/ssl/certs/ca-certificates.crt", /* Debian/Ubuntu, openSUSE */ "/etc/pki/tls/certs/ca-bundle.crt", /* RHEL family */ "/etc/ssl/ca-bundle.pem", /* SLES/openSUSE */ "/etc/ssl/cert.pem", }; struct ast_tls_config *tls = ast_calloc(1, sizeof(*tls)); size_t i; if (!tls) { return NULL; } tls->enabled = 1; if (conf->tls_verify) { if (!ast_strlen_zero(conf->tls_cafile)) { tls->cafile = ast_strdup(conf->tls_cafile); } else { for (i = 0; i < ARRAY_LEN(ca_bundles); i++) { if (!access(ca_bundles[i], R_OK)) { tls->cafile = ast_strdup(ca_bundles[i]); break; } } if (!tls->cafile) { tls->capath = ast_strdup("/etc/ssl/certs"); } } if (!conf->tls_check_hostname) { ast_set_flag(&tls->flags, AST_SSL_IGNORE_COMMON_NAME); } } else { ast_set_flag(&tls->flags, AST_SSL_DONT_VERIFY_SERVER); ast_set_flag(&tls->flags, AST_SSL_IGNORE_COMMON_NAME); } return tls; } /*! * \brief A connect job handed to a helper thread. * * ast_websocket_client_create_with_options() blocks for DNS + TCP + handshake. * Only the TCP connect honours .timeout; a server that accepts the TCP * connection and never answers the HTTP upgrade would block forever. The PBX * thread therefore waits on this job with its own deadline while servicing * the channel, and abandons it when the deadline passes. Whoever drops the * last reference frees the job; an abandoned job's websocket is closed by * the helper. * * The optional DB enrichment runs on the helper right before the connect * (results in phone/country, valid once state == JOB_DONE), so a stalled DB * never blocks the channel thread. */ enum job_state { JOB_PENDING = 0, JOB_DONE, JOB_ABANDONED, }; struct connect_job { ast_mutex_t lock; int refs; enum job_state state; struct ast_websocket *ws; enum ast_websocket_result wr; struct ast_tls_config *tls_cfg; /* owned by the ws client once args are created */ int timeout_ms; char host[256]; /* for the parked-helper accounting */ char uri[600]; char chan_name[AST_CHANNEL_NAME]; /* DB enrichment (helper thread) */ int do_db; int db_timeout_ms; char vid[MAX_VID_LEN + 1]; char phone[64]; char country[32]; }; static void job_unref(struct connect_job *job) { int left; ast_mutex_lock(&job->lock); left = --job->refs; ast_mutex_unlock(&job->lock); if (!left) { ast_mutex_destroy(&job->lock); ast_free(job); } } /*! * \brief Run the optional DB lookup and the blocking connect; result into job->ws / job->wr. * \retval JOB_DONE the outcome was handed to the call * \retval JOB_ABANDONED the call had given up meanwhile (a late socket was closed here) */ static enum job_state job_connect(struct connect_job *job) { struct ast_websocket_client_options opts = { .uri = job->uri, .protocols = NULL, .timeout = job->timeout_ms, .tls_cfg = job->tls_cfg, }; enum ast_websocket_result wr = (enum ast_websocket_result) -1; /* stays -1 if the OPTIONAL_API stub ran */ struct ast_websocket *ws; #ifdef HAVE_MYSQL if (job->do_db) { if (!db_lookup(job->vid, job->db_timeout_ms, job->phone, sizeof(job->phone), job->country, sizeof(job->country))) { ast_debug(2, "AMD_WS: %s DB phone=%s country=%s\n", job->chan_name, job->phone, job->country); } else { ast_debug(2, "AMD_WS: %s no DB row for vid=%s\n", job->chan_name, job->vid); } } #endif ws = ast_websocket_client_create_with_options(&opts, &wr); if (!ws && job->tls_cfg) { /* * Ownership of tls_cfg passes to the client's session args. When the * client failed before creating them (allocation, key, URI parse, or * the API stub) nobody else frees it. */ if ((int) wr == -1 || wr == WS_ALLOCATE_ERROR || wr == WS_KEY_ERROR || wr == WS_URI_PARSE_ERROR) { tls_cfg_free(job->tls_cfg); } } job->tls_cfg = NULL; ast_mutex_lock(&job->lock); job->wr = wr; if (job->state == JOB_ABANDONED) { ast_mutex_unlock(&job->lock); if (ws) { ast_debug(1, "AMD_WS: %s late connect to %s discarded\n", job->chan_name, job->uri); ast_websocket_close(ws, 1000); ast_websocket_unref(ws); } return JOB_ABANDONED; } job->ws = ws; job->state = JOB_DONE; ast_mutex_unlock(&job->lock); return JOB_DONE; } static void *connect_thread(void *data) { struct connect_job *job = data; if (job_connect(job) == JOB_ABANDONED) { pending_unpark(job->host); /* always after job_abandon()'s pending_park(): see there */ } job_unref(job); ast_atomic_fetchadd_int(&inflight_helpers, -1); ast_module_unref(AST_MODULE_SELF); return NULL; } /*! * \brief Poll the job: returns JOB_DONE (ws taken into *ws, may be NULL; *wr and * job->phone/country valid) or JOB_PENDING. */ static enum job_state job_poll(struct connect_job *job, struct ast_websocket **ws, enum ast_websocket_result *wr) { enum job_state st; ast_mutex_lock(&job->lock); st = job->state; if (st == JOB_DONE) { *ws = job->ws; *wr = job->wr; job->ws = NULL; } ast_mutex_unlock(&job->lock); return st; } /*! * \brief Give up on the job. If it completed meanwhile the outcome is handed * over exactly like job_poll() (JOB_DONE, ws may be NULL with the real * failure in *wr); otherwise it is marked JOB_ABANDONED and the helper * disposes of whatever it still produces. */ static enum job_state job_abandon(struct connect_job *job, struct ast_websocket **ws, enum ast_websocket_result *wr) { enum job_state st; ast_mutex_lock(&job->lock); if (job->state == JOB_DONE) { *ws = job->ws; *wr = job->wr; job->ws = NULL; } else { job->state = JOB_ABANDONED; /* * Counted while job->lock is held: the helper only sees ABANDONED * after this unlock, so its pending_unpark() always follows this * pending_park() (lock order job->lock -> pending_lock, nowhere else). */ pending_park(job->host); } st = job->state; ast_mutex_unlock(&job->lock); return st; } /* ------------------------------------------------------------------------ * Per-call state * ---------------------------------------------------------------------- */ enum { OPT_NODB = (1 << 0), OPT_TLS = (1 << 1), OPT_PLAYDELAY = (1 << 2), OPT_CONNTO = (1 << 3), OPT_PHONE = (1 << 4), OPT_CODE = (1 << 5), OPT_ANSWER = (1 << 6), OPT_NOANSWER = (1 << 7), OPT_CALLERID = (1 << 8), OPT_TRACE = (1 << 9), }; enum { OPT_ARG_PLAYDELAY = 0, OPT_ARG_CONNTO, OPT_ARG_PHONE, OPT_ARG_CODE, OPT_ARG_CALLERID, OPT_ARG_ARRAY_SIZE, }; AST_APP_OPTIONS(amd_ws_options, { AST_APP_OPTION('n', OPT_NODB), AST_APP_OPTION('s', OPT_TLS), AST_APP_OPTION_ARG('d', OPT_PLAYDELAY, OPT_ARG_PLAYDELAY), AST_APP_OPTION_ARG('c', OPT_CONNTO, OPT_ARG_CONNTO), AST_APP_OPTION_ARG('p', OPT_PHONE, OPT_ARG_PHONE), AST_APP_OPTION_ARG('k', OPT_CODE, OPT_ARG_CODE), AST_APP_OPTION_ARG('i', OPT_CALLERID, OPT_ARG_CALLERID), AST_APP_OPTION('a', OPT_ANSWER), AST_APP_OPTION('A', OPT_NOANSWER), AST_APP_OPTION('v', OPT_TRACE), }); enum call_phase { PHASE_CONNECT = 0, /* helper thread connecting; channel already read */ PHASE_STREAM, /* connected, config sent, sending on schedule */ PHASE_EOF_WAIT, /* {"eof":1} sent after empty marks; waiting eof_wait_ms for ONE reply */ PHASE_GRACE, /* timeout_ms elapsed; waiting result_grace_ms for a reply */ }; enum play_state { PLAY_NONE = 0, /* no playfile given */ PLAY_WAIT_DELAY, /* waiting for playdelay_ms */ PLAY_PLAYING, /* a file stream is active */ PLAY_DONE, /* list exhausted or stopped */ }; struct amd_call { struct ast_channel *chan; struct amd_ws_conf conf; /* snapshot for this call */ /* effective parameters */ char host[256]; int port; char vid[MAX_VID_LEN + 1]; int timeout_ms; int connect_timeout_ms; int playdelay_ms; int use_tls; char phone[64]; char country[32]; char caller_id[MAX_CALLERID_LEN + 1]; char *playlist; /* '&'-separated, consumed by strsep */ const char *playfile_display; /* transport */ struct connect_job *job; struct ast_websocket *ws; int wsfd; int ws_lost; /* CLOSE/error seen */ /* clocks */ struct timeval t_app; struct timeval t_connect; /* connect job started (connect deadline runs from here) */ struct timeval t_first; /* first voice frame; zero if none yet */ struct timeval t_last_send; /* last audio send (t_first until then): fallback interval */ struct timeval t_eof; /* EOF finalisation started */ int have_audio; /* audio accumulator (heap) */ unsigned char *acc; size_t acc_cap; size_t acc_len; int sched_idx; /* next schedule mark to fire */ int no_audio_streak; /* consecutive marks with nothing pending (amd.py no_audio_streak) */ /* server text (heap) */ char *rx; size_t rx_len; /* stats */ long bytes_captured; long bytes_sent; long bytes_dropped; int chunks; /* binary frames sent */ int acks; /* non-terminal replies */ int replies; /* complete text replies of any kind */ /* outcome */ enum call_phase phase; int got_result; char status[STATUS_TOKEN_LEN]; char cause[CAUSE_LEN]; char response[MAX_RESPONSE + 1]; /* playback */ enum play_state play; int trace; /* option v / conf trace: timeline lines at verbose 3 */ }; /*! * \brief One timeline line per event: "AMD_WS: + " at verbose 3 when * tracing is on (option v or conf trace=yes), otherwise at debug 2. The offset is * measured from the start of the application, so a call reads as a timeline. */ #define amd_trace(c, fmt, ...) do { \ if ((c)->trace) { \ ast_verb(3, "AMD_WS: %s vid=%s +%" PRId64 "ms " fmt "\n", ast_channel_name((c)->chan), (c)->vid, \ ast_tvdiff_ms(ast_tvnow(), (c)->t_app), ##__VA_ARGS__); \ } else { \ ast_debug(2, "AMD_WS: %s vid=%s " fmt "\n", ast_channel_name((c)->chan), (c)->vid, ##__VA_ARGS__); \ } \ } while (0) /* ------------------------------------------------------------------------ * Audio accumulator * ---------------------------------------------------------------------- */ /*! * \brief Append captured PCM; never truncates a frame. * * The accumulator is sized for connect_timeout + the largest schedule gap * (+ slack), so it only fills when something is badly wrong; then it grows * up to ACC_HARD_CAP and beyond that drops are counted and logged once. */ static void acc_append(struct amd_call *c, const unsigned char *data, size_t len) { if (c->acc_len + len > c->acc_cap) { size_t ncap = c->acc_cap * 2; unsigned char *n; while (ncap < c->acc_len + len) { ncap *= 2; } if (ncap > ACC_HARD_CAP || !(n = ast_realloc(c->acc, ncap))) { if (!c->bytes_dropped) { ast_log(LOG_WARNING, "AMD_WS: %s audio backlog full (%zu bytes), dropping audio\n", ast_channel_name(c->chan), c->acc_len); } c->bytes_dropped += len; return; } c->acc = n; c->acc_cap = ncap; } memcpy(c->acc + c->acc_len, data, len); c->acc_len += len; c->no_audio_streak = 0; /* amd.py: "Reset streak when audio arrives" */ } /*! * \brief Send everything accumulated as BINARY frame(s). * \retval 0 ok, -1 write error (connection is lost). */ static int acc_flush(struct amd_call *c, struct timeval now) { size_t off = 0; if (!c->ws || c->ws_lost) { return -1; } while (off < c->acc_len) { size_t n = c->acc_len - off; if (n > WS_MAX_FRAME_BYTES) { n = WS_MAX_FRAME_BYTES; } if (ast_websocket_write(c->ws, AST_WEBSOCKET_OPCODE_BINARY, (char *) c->acc + off, n)) { ast_debug(1, "AMD_WS: %s websocket write of %zu bytes failed\n", ast_channel_name(c->chan), n); c->ws_lost = 1; c->acc_len = 0; return -1; } c->bytes_sent += n; c->chunks++; off += n; } amd_trace(c, "sent chunk #%d: %zu bytes (audio %ld ms total, %ld bytes)", c->chunks, c->acc_len, c->bytes_sent / 16, c->bytes_sent); c->acc_len = 0; c->t_last_send = now; return 0; } enum sched_result { SCHED_OK = 0, /* nothing decisive (sent or not) */ SCHED_EOF = 1, /* start the EOF finalisation */ SCHED_LOST = -1, /* write error: connection lost */ }; /*! * \brief Fire due schedule marks / fallback sends. Called when connected. * * amd.py (Jul 2026) semantics: at each mark send everything accumulated so * far; a mark with nothing pending is a "NO AUDIO DATA" mark and counts * toward no_audio_streak (any captured audio resets it); when the streak * reaches eof_no_audio_streak and audio was sent before, the EOF * finalisation starts. After the last mark a send happens whenever * >= chunk_bytes are pending, or fallback_interval_ms passed since the last * send with anything pending. * * Several marks can be due at once only right after a slow connect (the * schedule clock started at the first captured frame, before the socket was * up): they are one event then - one flush covers them all and none of them * is an empty mark. In steady state the loop runs every <= 20 ms, so marks * are processed one at a time exactly like amd.py. */ static enum sched_result acc_service_schedule(struct amd_call *c, struct timeval now) { int64_t since_first; int sent_now = 0; if (!c->have_audio) { return SCHED_OK; } since_first = ast_tvdiff_ms(now, c->t_first); while (c->sched_idx < c->conf.n_schedule && since_first >= c->conf.schedule[c->sched_idx]) { int mark = c->conf.schedule[c->sched_idx++]; if (c->acc_len) { if (acc_flush(c, now)) { return SCHED_LOST; } sent_now = 1; } else if (!sent_now) { c->no_audio_streak++; ast_debug(2, "AMD_WS: %s mark %d ms: no audio data (streak %d, sent %ld)\n", ast_channel_name(c->chan), mark, c->no_audio_streak, c->bytes_sent); if (c->conf.eof_no_audio_streak && c->no_audio_streak >= c->conf.eof_no_audio_streak && c->bytes_sent > 0) { return SCHED_EOF; } } } if (c->sched_idx >= c->conf.n_schedule && c->acc_len && (c->acc_len >= (size_t) c->conf.chunk_bytes || ast_tvdiff_ms(now, c->t_last_send) >= c->conf.fallback_interval_ms)) { if (acc_flush(c, now)) { return SCHED_LOST; } } return SCHED_OK; } /*! \brief ms until the next time-driven send: schedule mark or fallback interval (-1 if none pending) */ static int acc_ms_to_next_mark(const struct amd_call *c, struct timeval now) { int64_t ms; if (!c->have_audio) { return -1; } if (c->sched_idx < c->conf.n_schedule) { ms = c->conf.schedule[c->sched_idx] - ast_tvdiff_ms(now, c->t_first); } else if (c->acc_len) { ms = c->conf.fallback_interval_ms - ast_tvdiff_ms(now, c->t_last_send); } else { return -1; } return ms < 0 ? 0 : (int) ms; } /* ------------------------------------------------------------------------ * Playback helpers (parallel to capture) * ---------------------------------------------------------------------- */ /*! \brief Start the next file of the list, or mark the list done */ static void play_start_next(struct amd_call *c) { char *file; while ((file = strsep(&c->playlist, "&"))) { file = ast_strip(file); if (ast_strlen_zero(file)) { continue; } if (!ast_streamfile(c->chan, file, ast_channel_language(c->chan))) { c->play = PLAY_PLAYING; return; } ast_log(LOG_WARNING, "AMD_WS: %s cannot play '%s', skipping\n", ast_channel_name(c->chan), file); } c->play = PLAY_DONE; } /*! * \brief Drive the playback state machine once per loop iteration. * * End of file is detected exactly as ast_waitstream() does: the stream is * still attached but nothing is scheduled and no timing function is armed. */ static void play_service(struct amd_call *c, struct timeval now) { struct ast_sched_context *sched; switch (c->play) { case PLAY_WAIT_DELAY: if (ast_tvdiff_ms(now, c->t_app) >= c->playdelay_ms) { play_start_next(c); } break; case PLAY_PLAYING: if (!ast_channel_stream(c->chan)) { play_start_next(c); /* zero-length file or stopped elsewhere */ break; } sched = ast_channel_sched(c->chan); if (sched) { ast_sched_runq(sched); } if ((!sched || ast_sched_wait(sched) < 0) && !ast_channel_timingfunc(c->chan)) { ast_stopstream(c->chan); play_start_next(c); } break; default: break; } } /*! \brief Upper bound for the wait budget imposed by playback timing */ static int play_ms_budget(const struct amd_call *c, struct timeval now) { struct ast_sched_context *sched; int ms; switch (c->play) { case PLAY_WAIT_DELAY: ms = c->playdelay_ms - (int) ast_tvdiff_ms(now, c->t_app); return ms < 0 ? 0 : ms; case PLAY_PLAYING: sched = ast_channel_sched(c->chan); if (sched && !ast_channel_timingfunc(c->chan)) { ms = ast_sched_wait(sched); return ms < 0 ? -1 : ms; } return -1; default: return -1; } } static void play_stop(struct amd_call *c) { if (c->play == PLAY_PLAYING) { ast_stopstream(c->chan); } if (c->play != PLAY_NONE) { c->play = PLAY_DONE; } } /* ------------------------------------------------------------------------ * Exec helpers * ---------------------------------------------------------------------- */ static void set_outcome(struct amd_call *c, const char *status, const char *cause) { ast_copy_string(c->status, status, sizeof(c->status)); ast_copy_string(c->cause, cause, sizeof(c->cause)); } /*! \brief Append ,"key":"" when value is non-empty; returns the new length or -1 */ static int json_append_kv(char *out, size_t out_sz, int n, const char *key, const char *value) { char esc[MAX_VID_LEN * 6 + 1]; /* phone/country/caller_id are all shorter than a vid */ int m; if (ast_strlen_zero(value) || json_escape(esc, sizeof(esc), value) < 0) { return n; /* omitted, as amd.py omits a missing value */ } m = snprintf(out + n, out_sz - n, ",\"%s\":\"%s\"", key, esc); if (m < 0 || (size_t) m >= out_sz - n) { return -1; } return n + m; } /*! * \brief Build the config JSON (amd.py Jul 2026, keys in this order): * {"config":{"sample_rate":8000,"VID":""[,"phone":".."][,"country_code":".."][,"caller_id":".."]}} * Returns 0 on success. */ static int build_config_json(struct amd_call *c, char *out, size_t out_sz) { char vid_esc[MAX_VID_LEN * 6 + 1]; int n; if (json_escape(vid_esc, sizeof(vid_esc), c->vid) < 0) { ast_copy_string(vid_esc, "Unknown", sizeof(vid_esc)); } n = snprintf(out, out_sz, "{\"config\":{\"sample_rate\":%d,\"VID\":\"%s\"", SAMPLE_RATE, vid_esc); if (n < 0 || (size_t) n >= out_sz) { return -1; } if ((n = json_append_kv(out, out_sz, n, "phone", c->phone)) < 0 || (n = json_append_kv(out, out_sz, n, "country_code", c->country)) < 0 || (n = json_append_kv(out, out_sz, n, "caller_id", c->caller_id)) < 0) { return -1; } if (!ast_strlen_zero(c->conf.extra_config)) { /* {"a":1} -> ,"a":1 (the object braces are stripped, the rest is the operator's JSON) */ size_t el = strlen(c->conf.extra_config); const char *inner = c->conf.extra_config + 1; size_t il = el - 2; while (il > 0 && isspace((unsigned char) inner[il - 1])) { il--; } if (il > 0) { if ((size_t) n + il + 4 > out_sz) { return -1; } out[n++] = ','; memcpy(out + n, inner, il); n += il; } } if ((size_t) n + 3 > out_sz) { return -1; } memcpy(out + n, "}}", 3); return 0; } /*! * \brief Attach a freshly connected websocket: non-blocking fd, write timeout, * config TEXT frame. Returns 0 on success (-1 = CONNECTION_ERROR: amd.py * sends the config inside its connect try-block). */ static int ws_attach(struct amd_call *c, struct ast_websocket *ws, const char *config_json) { c->ws = ws; ast_websocket_set_nonblock(ws); ast_websocket_set_timeout(ws, WS_WRITE_TIMEOUT_MS); c->wsfd = ast_websocket_fd(ws); if (c->wsfd < 0) { c->ws_lost = 1; return -1; } if (ast_websocket_write_string(ws, config_json)) { ast_debug(1, "AMD_WS: %s failed to send config frame\n", ast_channel_name(c->chan)); c->ws_lost = 1; return -1; } ast_debug(2, "AMD_WS: %s connected, config sent\n", ast_channel_name(c->chan)); return 0; } /*! * \brief Read one WebSocket frame (fd was reported readable) and act on it. * * ast_websocket_read() reconstructs fragmented messages internally up to * 64 KB and only reports fragmented=1 beyond that; we still concatenate such * pieces into rx. PING is answered by res_http_websocket itself. CLOSE or * any read error marks the connection lost. * * Bound: when only part of a frame has arrived the core's ws_safe_read() * waits for the rest in 1 s steps, up to 10 s (see docs/troubleshooting.md, * "Known limitations"); server replies are expected to be small. * * \retval 1 terminal result received, 0 nothing decisive, -1 connection lost */ static int ws_service_read(struct amd_call *c) { char *payload = NULL; uint64_t payload_len = 0; enum ast_websocket_opcode opcode = 0; int fragmented = 0; if (ast_websocket_read(c->ws, &payload, &payload_len, &opcode, &fragmented)) { ast_debug(1, "AMD_WS: %s websocket read error/closed\n", ast_channel_name(c->chan)); c->ws_lost = 1; return -1; } switch (opcode) { case AST_WEBSOCKET_OPCODE_CLOSE: ast_debug(1, "AMD_WS: %s server closed the websocket\n", ast_channel_name(c->chan)); c->ws_lost = 1; return -1; case AST_WEBSOCKET_OPCODE_PING: case AST_WEBSOCKET_OPCODE_PONG: return 0; case AST_WEBSOCKET_OPCODE_BINARY: ast_debug(2, "AMD_WS: %s ignoring %" PRIu64 "-byte binary frame from server\n", ast_channel_name(c->chan), payload_len); return 0; case AST_WEBSOCKET_OPCODE_CONTINUATION: if (!payload_len) { return 0; /* reconstruction in progress inside res_http_websocket */ } /* fall through: a reconstructed piece */ case AST_WEBSOCKET_OPCODE_TEXT: break; default: return 0; } if (payload_len) { size_t room = RX_CAP - 1 - c->rx_len; size_t n = payload_len > room ? room : (size_t) payload_len; memcpy(c->rx + c->rx_len, payload, n); c->rx_len += n; } if (fragmented) { return 0; /* wait for the final piece */ } c->rx[c->rx_len] = '\0'; sanitize_response(c->rx, c->rx_len, c->response, sizeof(c->response)); c->replies++; amd_trace(c, "reply #%d: \"%s\"", c->replies, c->response); switch (classify_text(c->rx, c->rx_len)) { case CLASS_HUMAN: /* amd.py:293 - AMDCAUSE is the literal HUMAN, the reply goes to AMDRESPONSE */ set_outcome(c, "HUMAN", "HUMAN"); c->got_result = 1; c->rx_len = 0; return 1; case CLASS_MACHINE: /* amd.py:298 - AMDCAUSE is the raw reply text (sanitised, <= 255) */ set_outcome(c, "MACHINE", c->response); c->got_result = 1; c->rx_len = 0; return 1; case CLASS_ACK: break; } c->acks++; c->rx_len = 0; return 0; } /*! * \brief Service the websocket after poll reported it readable. * * Reads one frame, then drains what is already buffered: over wss:// a TLS * record may carry two frames of which the second sits decrypted inside * OpenSSL where poll() on the fd cannot see it (ast_websocket_wait_for_input() * checks SSL_pending()); over ws:// two coalesced frames are read in one * iteration instead of two. Bounded by WS_READ_DRAIN_MAX so the channel is * serviced again quickly. A close the core initiated itself (PONG write * failure, unknown opcode) is detected through ast_websocket_fd() < 0. * * \retval 1 terminal result received, 0 nothing decisive, -1 connection lost */ static int ws_service(struct amd_call *c) { int n; for (n = 0; n < WS_READ_DRAIN_MAX; n++) { int r = ws_service_read(c); if (r) { return r; } if (ast_websocket_fd(c->ws) < 0) { ast_debug(1, "AMD_WS: %s websocket closed by the core\n", ast_channel_name(c->chan)); c->ws_lost = 1; return -1; } if (ast_websocket_wait_for_input(c->ws, 0) <= 0) { break; } } return 0; } /*! \brief Best-effort protocol goodbye + close + unref. Never leaves the fd open. */ static void ws_release(struct amd_call *c) { if (!c->ws) { return; } if (!c->ws_lost) { ast_websocket_write_string(c->ws, "{\"eof\":1}"); } ast_websocket_close(c->ws, 1000); ast_websocket_unref(c->ws); c->ws = NULL; c->wsfd = -1; } /*! * \brief Start the connect job on a helper thread. * * The connect is never run on the PBX thread: besides blocking it, the core's * tcptls client marks the calling thread with ast_thread_inhibit_escalations(), * which would break a later System() in the same dialplan. * * \retval 0 started, 1 refused because too many connects to this host are in * flight (CONNECTION_ERROR), -1 internal failure (FATAL_ERROR) */ static int start_connect(struct amd_call *c, int do_db) { struct connect_job *job; pthread_t tid; int warn, probe; const char *scheme = c->use_tls ? "wss" : "ws"; /* * File-descriptor probe. The core's client path calls * ast_tcptls_client_start_timeout(ast_tcptls_client_create(...)) and * dereferences the NULL that ast_tcptls_client_create() returns when * socket() fails (EMFILE/ENFILE) -- that would take the whole Asterisk * down. A socket we can open now is no guarantee for the helper a few * microseconds later, but it catches sustained exhaustion. */ probe = socket(AF_INET, SOCK_STREAM, 0); if (probe < 0) { if (connect_warn_allowed(c->host)) { ast_log(LOG_WARNING, "AMD_WS: cannot open a socket: %s - out of file descriptors? (raise maxfiles in asterisk.conf; suppressed for %d s)\n", strerror(errno), CONNECT_WARN_S); } return -1; } close(probe); if (pending_cap_reached(c->host, c->conf.max_pending_connects, &warn)) { if (warn) { ast_log(LOG_WARNING, "AMD_WS: %d connects to %s still pending (max_pending_connects), failing fast with CONNECTION_ERROR until the server closes them (suppressed for %d s)\n", c->conf.max_pending_connects, c->host, PENDING_WARN_S); } return 1; } job = ast_calloc(1, sizeof(*job)); if (!job) { return -1; } ast_mutex_init(&job->lock); job->refs = 2; /* caller + thread */ job->state = JOB_PENDING; job->timeout_ms = c->connect_timeout_ms; ast_copy_string(job->host, c->host, sizeof(job->host)); ast_copy_string(job->chan_name, ast_channel_name(c->chan), sizeof(job->chan_name)); if (strchr(c->host, ':') && c->host[0] != '[') { /* IPv6 literal: the URI parser needs brackets */ snprintf(job->uri, sizeof(job->uri), "%s://[%s]:%d/", scheme, c->host, c->port); } else { snprintf(job->uri, sizeof(job->uri), "%s://%s:%d/", scheme, c->host, c->port); } job->do_db = do_db; job->db_timeout_ms = c->conf.db_timeout_ms; ast_copy_string(job->vid, c->vid, sizeof(job->vid)); if (c->use_tls) { job->tls_cfg = tls_cfg_create(&c->conf); if (!job->tls_cfg) { ast_mutex_destroy(&job->lock); ast_free(job); return -1; } } /* The helper holds a module reference so an unload cannot race its tail. */ ast_module_ref(AST_MODULE_SELF); ast_atomic_fetchadd_int(&inflight_helpers, 1); if (ast_pthread_create_detached_background(&tid, NULL, connect_thread, job)) { ast_log(LOG_ERROR, "AMD_WS: %s cannot create connect thread: %s\n", ast_channel_name(c->chan), strerror(errno)); ast_module_unref(AST_MODULE_SELF); ast_atomic_fetchadd_int(&inflight_helpers, -1); tls_cfg_free(job->tls_cfg); ast_mutex_destroy(&job->lock); ast_free(job); return -1; } c->job = job; return 0; } /*! \brief Take the helper's DB enrichment (valid once the job is JOB_DONE) unless p()/k() gave values */ static void job_take_db(struct amd_call *c) { if (!c->job || !c->job->do_db) { return; } if (ast_strlen_zero(c->phone)) { ast_copy_string(c->phone, c->job->phone, sizeof(c->phone)); } if (ast_strlen_zero(c->country)) { ast_copy_string(c->country, c->job->country, sizeof(c->country)); } } /*! \brief One counter per outcome of the vocabulary (MACHINE by status: its cause is the raw reply) */ static void count_outcome(const struct amd_call *c) { static const struct { const char *cause; int *counter; } by_cause[] = { { "HUMAN", &cnt_human }, { "HANGUP", &cnt_hangups }, { "CONNECTION_ERROR", &cnt_connection_error }, { "PROCESSING_ERROR", &cnt_processing_error }, { "FATAL_ERROR", &cnt_fatal_error }, { "SERVER_TIMEOUT", &cnt_server_timeout }, { "EOF_INCONCLUSIVE", &cnt_eof_inconclusive }, { "EOF_ERROR", &cnt_eof_error }, }; size_t i; ast_atomic_fetchadd_int(&cnt_calls, 1); if (!strcmp(c->status, "MACHINE")) { ast_atomic_fetchadd_int(&cnt_machine, 1); return; } if (!strncmp(c->cause, "NOAUDIODATA-", 12)) { ast_atomic_fetchadd_int(&cnt_noaudiodata, 1); return; } for (i = 0; i < ARRAY_LEN(by_cause); i++) { if (!strcmp(c->cause, by_cause[i].cause)) { ast_atomic_fetchadd_int(by_cause[i].counter, 1); return; } } } /* ------------------------------------------------------------------------ * The application * ---------------------------------------------------------------------- */ static int amd_ws_exec(struct ast_channel *chan, const char *data) { struct amd_call c; struct ast_format *orig_readformat = NULL; struct ast_flags opts = { 0 }; char *opt_args[OPT_ARG_ARRAY_SIZE] = { NULL }; char *parse; char config_json[(MAX_VID_LEN + sizeof(c.phone) + sizeof(c.country) + sizeof(c.caller_id)) * 6 + 128]; int64_t elapsed_ms; char elapsed_str[24]; char stats_str[96]; int v; AST_DECLARE_APP_ARGS(args, AST_APP_ARG(host); AST_APP_ARG(port); AST_APP_ARG(vid); AST_APP_ARG(timeout); AST_APP_ARG(playfile); AST_APP_ARG(options); ); memset(&c, 0, sizeof(c)); c.chan = chan; c.wsfd = -1; c.t_app = ast_tvnow(); conf_snapshot(&c.conf); /* amd.py:537 - anything that goes wrong before the connect is FATAL_ERROR, and errors are HUMAN */ set_outcome(&c, "HUMAN", "FATAL_ERROR"); /* ---- arguments -------------------------------------------------- */ parse = ast_strdupa(S_OR(data, "")); AST_STANDARD_APP_ARGS(args, parse); if (!ast_strlen_zero(args.options) && ast_app_parse_options(amd_ws_options, &opts, opt_args, args.options)) { /* p() may be inside: never write digits to the log at normal verbosity */ char masked[128]; size_t i; ast_copy_string(masked, args.options, sizeof(masked)); for (i = 0; masked[i]; i++) { if (isdigit((unsigned char) masked[i])) { masked[i] = 'X'; } } ast_log(LOG_WARNING, "AMD_WS: %s invalid options '%s' (digits masked; all options ignored)\n", ast_channel_name(chan), masked); /* "ignored" means ignored: not the half of them parsed before the error */ memset(&opts, 0, sizeof(opts)); memset(opt_args, 0, sizeof(opt_args)); } ast_copy_string(c.host, !ast_strlen_zero(args.host) ? args.host : c.conf.host, sizeof(c.host)); c.port = c.conf.port; if (!ast_strlen_zero(args.port) && !parse_int(args.port, 1, 65535, &c.port)) { ast_log(LOG_WARNING, "AMD_WS: %s invalid port '%s', using %d\n", ast_channel_name(chan), args.port, c.conf.port); c.port = c.conf.port; } if (!ast_strlen_zero(args.vid)) { ast_copy_string(c.vid, args.vid, sizeof(c.vid)); } else { const char *cid_name; ast_channel_lock(chan); cid_name = S_COR(ast_channel_caller(chan)->id.name.valid, ast_channel_caller(chan)->id.name.str, NULL); ast_copy_string(c.vid, S_OR(cid_name, "Unknown"), sizeof(c.vid)); ast_channel_unlock(chan); } c.timeout_ms = c.conf.timeout_ms; if (!ast_strlen_zero(args.timeout)) { if (parse_int(args.timeout, INT_MIN + 1, INT_MAX, &v)) { if (v > 0) { c.timeout_ms = v; /* <= 0 means "use the default" */ } } else { ast_log(LOG_WARNING, "AMD_WS: %s invalid timeout '%s', using %d\n", ast_channel_name(chan), args.timeout, c.conf.timeout_ms); } } c.connect_timeout_ms = c.conf.connect_timeout_ms; if (ast_test_flag(&opts, OPT_CONNTO) && !parse_int(opt_args[OPT_ARG_CONNTO], 1, 600000, &c.connect_timeout_ms)) { ast_log(LOG_WARNING, "AMD_WS: %s invalid c(%s), using %d\n", ast_channel_name(chan), S_OR(opt_args[OPT_ARG_CONNTO], ""), c.conf.connect_timeout_ms); } c.playdelay_ms = c.conf.playdelay_ms; if (ast_test_flag(&opts, OPT_PLAYDELAY) && !parse_int(opt_args[OPT_ARG_PLAYDELAY], 0, 600000, &c.playdelay_ms)) { ast_log(LOG_WARNING, "AMD_WS: %s invalid d(%s), using %d\n", ast_channel_name(chan), S_OR(opt_args[OPT_ARG_PLAYDELAY], ""), c.conf.playdelay_ms); } c.use_tls = ast_test_flag(&opts, OPT_TLS) ? 1 : c.conf.tls; c.trace = ast_test_flag(&opts, OPT_TRACE) ? 1 : c.conf.trace; if (ast_test_flag(&opts, OPT_PHONE) && !ast_strlen_zero(opt_args[OPT_ARG_PHONE])) { ast_copy_string(c.phone, opt_args[OPT_ARG_PHONE], sizeof(c.phone)); } if (ast_test_flag(&opts, OPT_CODE) && !ast_strlen_zero(opt_args[OPT_ARG_CODE])) { ast_copy_string(c.country, opt_args[OPT_ARG_CODE], sizeof(c.country)); } /* * caller_id (amd.py:197-200): i(cid), else CALLERID(num) unless the conf says * send_caller_id=no; sent only when non-empty and != "Unknown", exactly * amd.py's test (a number the channel does not have is empty here, never * the AGI environment's "unknown"). */ if (ast_test_flag(&opts, OPT_CALLERID)) { ast_copy_string(c.caller_id, S_OR(opt_args[OPT_ARG_CALLERID], ""), sizeof(c.caller_id)); } else if (c.conf.send_caller_id) { const char *cid_num; ast_channel_lock(chan); cid_num = S_COR(ast_channel_caller(chan)->id.number.valid, ast_channel_caller(chan)->id.number.str, NULL); ast_copy_string(c.caller_id, S_OR(cid_num, ""), sizeof(c.caller_id)); ast_channel_unlock(chan); } if (!strcmp(c.caller_id, "Unknown")) { c.caller_id[0] = '\0'; } if (!ast_strlen_zero(args.playfile)) { c.playlist = ast_strdupa(args.playfile); c.playfile_display = args.playfile; c.play = PLAY_WAIT_DELAY; } ast_verb(3, "AMD_WS: %s vid=%s host=%s:%d play=%s\n", ast_channel_name(chan), c.vid, c.host, c.port, S_OR(c.playfile_display, "none")); /* ---- answer / format -------------------------------------------- */ if (ast_channel_state(chan) != AST_STATE_UP) { if (ast_test_flag(&opts, OPT_NOANSWER)) { ast_log(LOG_WARNING, "AMD_WS: %s channel not answered and option A given\n", ast_channel_name(chan)); goto finish; } if (ast_answer(chan)) { ast_log(LOG_WARNING, "AMD_WS: %s failed to answer\n", ast_channel_name(chan)); if (ast_check_hangup(chan)) { set_outcome(&c, "HANGUP", "HANGUP"); } goto finish; } } orig_readformat = ao2_bump(ast_channel_readformat(chan)); if (ast_set_read_format(chan, ast_format_slin)) { ast_log(LOG_WARNING, "AMD_WS: %s unable to set read format to slin\n", ast_channel_name(chan)); goto finish; } /* ---- buffers ---------------------------------------------------- */ { int gap = c.conf.schedule[0], i; for (i = 1; i < c.conf.n_schedule; i++) { if (c.conf.schedule[i] - c.conf.schedule[i - 1] > gap) { gap = c.conf.schedule[i] - c.conf.schedule[i - 1]; } } if (c.conf.chunk_bytes / BYTES_PER_MS > gap) { gap = c.conf.chunk_bytes / BYTES_PER_MS; } /* connect wait + largest gap + 2 x 60 ms frames + slack */ c.acc_cap = (size_t) (c.connect_timeout_ms + gap + 200) * BYTES_PER_MS + 2 * 960; if (c.acc_cap > ACC_HARD_CAP) { c.acc_cap = ACC_HARD_CAP; } } c.acc = ast_malloc(c.acc_cap); c.rx = ast_malloc(RX_CAP); if (!c.acc || !c.rx) { goto finish; } /* ---- connect (helper thread; the DB enrichment runs there too) --- */ /* conf db=no, option n, or explicit p()/k() values skip the query (spec 5) */ v = c.conf.db && !ast_test_flag(&opts, OPT_NODB | OPT_PHONE | OPT_CODE); c.t_connect = ast_tvnow(); amd_trace(&c, "connecting to %s://%s:%d (connect timeout %d ms, window %d ms)", c.use_tls ? "wss" : "ws", c.host, c.port, c.connect_timeout_ms, c.timeout_ms); v = start_connect(&c, v); if (v) { if (v > 0) { set_outcome(&c, "HUMAN", "CONNECTION_ERROR"); } goto finish; } /* ---- main loop -------------------------------------------------- */ c.phase = PHASE_CONNECT; /* amd.py:214 - "AMD service unavailable - defaulting to HUMAN for safety" */ set_outcome(&c, "HUMAN", "CONNECTION_ERROR"); for (;;) { struct timeval now = ast_tvnow(); struct timeval t_ref = c.have_audio ? c.t_first : c.t_app; int64_t detect_left = c.timeout_ms - ast_tvdiff_ms(now, t_ref); struct ast_channel *winner; struct ast_frame *f; int ms, m, outfd = -1; int fds[1]; int nfds = 0; if (ast_check_hangup(chan)) { set_outcome(&c, "HANGUP", "HANGUP"); break; } /* -- phase transitions -- */ if (c.phase == PHASE_CONNECT) { struct ast_websocket *ws = NULL; enum ast_websocket_result wr = WS_OK; int64_t conn_left = c.connect_timeout_ms - ast_tvdiff_ms(now, c.t_connect); enum job_state st = job_poll(c.job, &ws, &wr); if (st != JOB_DONE && (conn_left <= 0 || detect_left <= 0)) { /* deadline: give up, unless it finished exactly now (then use the outcome) */ st = job_abandon(c.job, &ws, &wr); if (st != JOB_DONE) { job_unref(c.job); c.job = NULL; if (connect_warn_allowed(c.host)) { ast_log(LOG_WARNING, "AMD_WS: connect to %s://%s:%d timed out after %d ms (suppressed for %d s)\n", c.use_tls ? "wss" : "ws", c.host, c.port, c.connect_timeout_ms, CONNECT_WARN_S); } break; } } if (st == JOB_DONE) { job_take_db(&c); if (c.job->do_db) { if (!ast_strlen_zero(c.job->phone) || !ast_strlen_zero(c.job->country)) { amd_trace(&c, "DB FOUND: phone=%s, code=%s", S_OR(c.job->phone, ""), S_OR(c.job->country, "")); } else { amd_trace(&c, "DB lookup: no row for vid=%s", c.vid); } } job_unref(c.job); c.job = NULL; if (!ws) { if ((int) wr == -1) { /* the OPTIONAL_API stub ran: no client at all = cannot connect (addendum table A) */ ast_log(LOG_ERROR, "AMD_WS: %s res_http_websocket is not loaded, cannot connect\n", ast_channel_name(chan)); } else if (connect_warn_allowed(c.host)) { ast_log(LOG_WARNING, "AMD_WS: connect to %s://%s:%d failed: %s (suppressed for %d s)\n", c.use_tls ? "wss" : "ws", c.host, c.port, ws_result_str(wr), CONNECT_WARN_S); } break; /* CONNECTION_ERROR */ } if (build_config_json(&c, config_json, sizeof(config_json))) { ast_log(LOG_WARNING, "AMD_WS: %s config JSON too large\n", ast_channel_name(chan)); c.ws = ws; c.ws_lost = 1; /* nothing was sent; ws_release() still closes it */ set_outcome(&c, "HUMAN", "FATAL_ERROR"); break; } if (ws_attach(&c, ws, config_json)) { break; /* CONNECTION_ERROR */ } c.phase = PHASE_STREAM; /* amd.py:310,476 - from here on a lost socket is a PROCESSING_ERROR */ set_outcome(&c, "HUMAN", "PROCESSING_ERROR"); amd_trace(&c, "connected to %s:%d after %" PRId64 " ms; config sent: sample_rate=%d, VID=%s, phone=%s, country=%s, caller_id=%s%s%s", c.host, c.port, ast_tvdiff_ms(now, c.t_connect), SAMPLE_RATE, c.vid, S_OR(c.phone, "N/A"), S_OR(c.country, "N/A"), S_OR(c.caller_id, "N/A"), ast_strlen_zero(c.conf.extra_config) ? "" : ", extra=", S_OR(c.conf.extra_config, "")); } } if (c.phase == PHASE_STREAM) { if (detect_left <= 0) { /* amd.py:377-388 - MAX_WAIT_TIME; stock app_amd vocabulary for the no-audio case */ if (!c.have_audio) { char cause[STATUS_TOKEN_LEN]; snprintf(cause, sizeof(cause), "NOAUDIODATA-%" PRId64, ast_tvdiff_ms(now, t_ref)); set_outcome(&c, "NOTSURE", cause); break; } set_outcome(&c, "NOTSURE", "SERVER_TIMEOUT"); if (c.conf.result_grace_ms <= 0) { break; /* amd.py returns at once */ } /* v2 extra: flush what is left and give the server result_grace_ms */ if (c.acc_len && acc_flush(&c, now)) { set_outcome(&c, "HUMAN", "PROCESSING_ERROR"); break; } c.phase = PHASE_GRACE; ast_debug(1, "AMD_WS: %s timeout, waiting %d ms grace for a result\n", ast_channel_name(chan), c.conf.result_grace_ms); } else { enum sched_result sr = acc_service_schedule(&c, now); if (sr == SCHED_LOST) { break; /* PROCESSING_ERROR */ } if (sr == SCHED_EOF) { /* amd.py:408-416 - force the server to finalise, then wait for ONE reply */ ast_debug(1, "AMD_WS: %s no audio at %d consecutive marks after %ld bytes sent: sending eof to force finalisation\n", ast_channel_name(chan), c.no_audio_streak, c.bytes_sent); set_outcome(&c, "NOTSURE", "EOF_ERROR"); if (ast_websocket_write_string(c.ws, "{\"eof\":1}")) { ast_debug(1, "AMD_WS: %s eof write failed\n", ast_channel_name(chan)); c.ws_lost = 1; break; /* EOF_ERROR */ } c.phase = PHASE_EOF_WAIT; c.t_eof = now; } } } if (c.phase == PHASE_EOF_WAIT) { if (ast_tvdiff_ms(now, c.t_eof) >= c.conf.eof_wait_ms) { ast_debug(1, "AMD_WS: %s no eof finalisation reply within %d ms\n", ast_channel_name(chan), c.conf.eof_wait_ms); break; /* EOF_ERROR */ } } if (c.phase == PHASE_GRACE) { if (-detect_left >= c.conf.result_grace_ms) { break; /* SERVER_TIMEOUT */ } } /* -- playback -- */ play_service(&c, now); /* -- wait budget: never more than LOOP_BUDGET_MS -- */ ms = LOOP_BUDGET_MS; if (c.phase == PHASE_GRACE) { m = c.conf.result_grace_ms + (int) detect_left; } else if (c.phase == PHASE_EOF_WAIT) { m = c.conf.eof_wait_ms - (int) ast_tvdiff_ms(now, c.t_eof); } else { m = (int) detect_left; } if (m >= 0 && m < ms) { ms = m; } if (c.phase == PHASE_CONNECT) { m = c.connect_timeout_ms - (int) ast_tvdiff_ms(now, c.t_connect); if (m >= 0 && m < ms) { ms = m; } } if (c.phase == PHASE_STREAM) { m = acc_ms_to_next_mark(&c, now); if (m >= 0 && m < ms) { ms = m; } } m = play_ms_budget(&c, now); if (m >= 0 && m < ms) { ms = m; } if (ms < 0) { ms = 0; } if (c.ws && !c.ws_lost) { fds[0] = c.wsfd; nfds = 1; } winner = ast_waitfor_nandfds(&chan, 1, fds, nfds, NULL, &outfd, &ms); /* -- websocket readable -- */ if (nfds && outfd == c.wsfd) { int r = ws_service(&c); if (r > 0) { break; /* result (HUMAN / MACHINE), in any phase */ } if (r < 0) { if (!c.got_result && c.phase != PHASE_EOF_WAIT) { set_outcome(&c, "HUMAN", "PROCESSING_ERROR"); } break; /* EOF_WAIT keeps EOF_ERROR (amd.py:431-433) */ } /* * amd.py's send->recv lockstep has consumed every chunk reply before * it sends the eof, so the next reply is the finalisation answer. * Replies are read asynchronously here: a text that is still owed * for a chunk is a chunk ack; the first one beyond that answers * the eof, and when it was not HUMAN/MACHINE it is inconclusive. */ if (c.phase == PHASE_EOF_WAIT && c.replies > c.chunks) { ast_debug(1, "AMD_WS: %s eof finalisation inconclusive: %s\n", ast_channel_name(chan), c.response); set_outcome(&c, "NOTSURE", "EOF_INCONCLUSIVE"); break; } } /* -- channel frame -- */ if (winner == chan) { f = ast_read(chan); if (!f) { set_outcome(&c, "HANGUP", "HANGUP"); break; } if (f->frametype == AST_FRAME_CONTROL && f->subclass.integer == AST_CONTROL_HANGUP) { ast_frfree(f); set_outcome(&c, "HANGUP", "HANGUP"); break; } if (f->frametype == AST_FRAME_VOICE && f->datalen > 0 && f->data.ptr) { if (!c.have_audio) { c.have_audio = 1; c.t_first = ast_tvnow(); c.t_last_send = c.t_first; /* amd.py last_send_time = 0 */ amd_trace(&c, "first audio frame (%d bytes); schedule and detection window start here", f->datalen); } c.bytes_captured += f->datalen; /* in GRACE / EOF_WAIT nothing is sent any more: count, do not accumulate */ if (c.phase != PHASE_GRACE && c.phase != PHASE_EOF_WAIT) { acc_append(&c, f->data.ptr, f->datalen); } } ast_frfree(f); } else if (!winner && ms < 0) { /* poll error (not EINTR) */ if (ast_check_hangup(chan)) { set_outcome(&c, "HANGUP", "HANGUP"); break; } ast_debug(1, "AMD_WS: %s ast_waitfor_nandfds error: %s\n", ast_channel_name(chan), strerror(errno)); } } if (c.got_result) { amd_trace(&c, "result %s after %d acks", c.status, c.acks); } finish: /* ---- teardown (order matters: playback, socket, thread, format) ---- */ play_stop(&c); if (c.job) { struct ast_websocket *late = NULL; enum ast_websocket_result wr = WS_OK; job_abandon(c.job, &late, &wr); job_unref(c.job); c.job = NULL; if (late) { c.ws = late; c.ws_lost = 1; /* nothing was ever sent on it */ } } ws_release(&c); if (orig_readformat) { if (ast_set_read_format(chan, orig_readformat)) { ast_log(LOG_WARNING, "AMD_WS: %s unable to restore read format\n", ast_channel_name(chan)); } ao2_ref(orig_readformat, -1); } elapsed_ms = ast_tvdiff_ms(ast_tvnow(), c.have_audio ? c.t_first : c.t_app); snprintf(elapsed_str, sizeof(elapsed_str), "%" PRId64, elapsed_ms); /* stock app_amd style dash-separated integers; VD_amd.agi takes the first field as run_time */ snprintf(stats_str, sizeof(stats_str), "%" PRId64 "-%ld-%d-%ld", elapsed_ms, c.bytes_sent / BYTES_PER_MS, c.chunks, c.bytes_sent); pbx_builtin_setvar_helper(chan, "AMDSTATUS", c.status); pbx_builtin_setvar_helper(chan, "AMDCAUSE", c.cause); pbx_builtin_setvar_helper(chan, "AMDSTATS", stats_str); pbx_builtin_setvar_helper(chan, "AMDRESPONSE", c.response); pbx_builtin_setvar_helper(chan, "AMDELAPSED", elapsed_str); /* the number/country that were sent to the service (DB lookup or p()/k()): lets the * dialplan redial the lead, e.g. after a call-screening verdict */ if (!ast_strlen_zero(c.phone)) { pbx_builtin_setvar_helper(chan, "AMDPHONE", c.phone); } if (!ast_strlen_zero(c.country)) { pbx_builtin_setvar_helper(chan, "AMDCOUNTRYCODE", c.country); } amd_trace(&c, "Variables set - Status: %s, Cause: %s, Stats: %s", c.status, c.cause, stats_str); count_outcome(&c); ast_verb(3, "AMD_WS: %s vid=%s status=%s cause=%s elapsed=%" PRId64 " sent=%ld chunks=%d\n", ast_channel_name(chan), c.vid, c.status, c.cause, elapsed_ms, c.bytes_sent, c.chunks); if (c.bytes_dropped) { ast_log(LOG_WARNING, "AMD_WS: %s dropped %ld bytes of audio (backlog)\n", ast_channel_name(chan), c.bytes_dropped); } ast_free(c.acc); ast_free(c.rx); return 0; } /* ------------------------------------------------------------------------ * CLI * ---------------------------------------------------------------------- */ static char *cli_show_settings(struct ast_cli_entry *e, int cmd, struct ast_cli_args *a) { struct amd_ws_conf c; struct db_creds creds; char buf[256]; int i, n; switch (cmd) { case CLI_INIT: e->command = "amd_ws show settings"; e->usage = "Usage: amd_ws show settings\n" " Show the effective AMD_WS configuration, DB availability and counters.\n"; return NULL; case CLI_GENERATE: return NULL; } conf_snapshot(&c); ast_mutex_lock(&conf_lock); creds = g_db_creds; ast_mutex_unlock(&conf_lock); ast_cli(a->fd, "\nAMD_WS %s (res_http_websocket client)\n", AMD_WS_VERSION); ast_cli(a->fd, "----------------------------------------------------\n"); ast_cli(a->fd, " host : %s\n", c.host); ast_cli(a->fd, " port : %d\n", c.port); ast_cli(a->fd, " tls : %s\n", AST_CLI_YESNO(c.tls)); ast_cli(a->fd, " tls_verify : %s\n", AST_CLI_YESNO(c.tls_verify)); ast_cli(a->fd, " tls_check_hostname : %s\n", AST_CLI_YESNO(c.tls_check_hostname)); ast_cli(a->fd, " tls_cafile : %s\n", S_OR(c.tls_cafile, "(system default)")); ast_cli(a->fd, " timeout_ms : %d\n", c.timeout_ms); ast_cli(a->fd, " connect_timeout_ms : %d\n", c.connect_timeout_ms); ast_cli(a->fd, " result_grace_ms : %d\n", c.result_grace_ms); buf[0] = '\0'; for (i = 0, n = 0; i < c.n_schedule && n < (int) sizeof(buf) - 8; i++) { n += snprintf(buf + n, sizeof(buf) - n, "%s%d", i ? "," : "", c.schedule[i]); } ast_cli(a->fd, " send_schedule : %s\n", buf); ast_cli(a->fd, " chunk_bytes : %d\n", c.chunk_bytes); ast_cli(a->fd, " fallback_interval_ms: %d\n", c.fallback_interval_ms); ast_cli(a->fd, " eof_no_audio_streak : %d%s\n", c.eof_no_audio_streak, c.eof_no_audio_streak ? "" : " (EOF finalisation disabled)"); ast_cli(a->fd, " eof_wait_ms : %d\n", c.eof_wait_ms); ast_cli(a->fd, " send_caller_id : %s\n", AST_CLI_YESNO(c.send_caller_id)); ast_cli(a->fd, " trace : %s (per-call timeline at verbose 3; option v)\n", AST_CLI_YESNO(c.trace)); ast_cli(a->fd, " playdelay_ms : %d\n", c.playdelay_ms); ast_cli(a->fd, " db : %s (%s)\n", AST_CLI_YESNO(c.db), db_availability()); ast_cli(a->fd, " db_timeout_ms : %d\n", c.db_timeout_ms); ast_cli(a->fd, " astguiclient_conf : %s (%s)\n", c.astguiclient_conf, !creds.loaded ? "NOT READ - DB lookup skipped" : !creds.keys ? "read, NO VARDB_ LINES - DB lookup skipped" : "read"); ast_cli(a->fd, " extra_config : %s\n", S_OR(c.extra_config, "(none - amd.py-exact config frame)")); ast_cli(a->fd, " db server : %s:%d/%s user=%s\n", creds.server, creds.port, creds.database, creds.user); ast_cli(a->fd, " max_pending_connects: %d (per host)\n", c.max_pending_connects); ast_cli(a->fd, "\nCounters (AMDSTATUS/AMDCAUSE)\n"); ast_cli(a->fd, " calls : %d\n", cnt_calls); ast_cli(a->fd, " human : %d (HUMAN/HUMAN)\n", cnt_human); ast_cli(a->fd, " machine : %d (MACHINE/)\n", cnt_machine); ast_cli(a->fd, " hangups : %d (HANGUP/HANGUP)\n", cnt_hangups); ast_cli(a->fd, " connection_error : %d (HUMAN/CONNECTION_ERROR)\n", cnt_connection_error); ast_cli(a->fd, " processing_error : %d (HUMAN/PROCESSING_ERROR)\n", cnt_processing_error); ast_cli(a->fd, " fatal_error : %d (HUMAN/FATAL_ERROR)\n", cnt_fatal_error); ast_cli(a->fd, " server_timeout : %d (NOTSURE/SERVER_TIMEOUT)\n", cnt_server_timeout); ast_cli(a->fd, " noaudiodata : %d (NOTSURE/NOAUDIODATA-)\n", cnt_noaudiodata); ast_cli(a->fd, " eof_inconclusive : %d (NOTSURE/EOF_INCONCLUSIVE)\n", cnt_eof_inconclusive); ast_cli(a->fd, " eof_error : %d (NOTSURE/EOF_ERROR)\n", cnt_eof_error); ast_cli(a->fd, " connects in flight : %d (helper threads currently connecting)\n", inflight_helpers); ast_mutex_lock(&pending_lock); ast_cli(a->fd, " parked connects : %d (max %d per host: the call gave up, the thread waits for the peer to close)\n", pending_total, c.max_pending_connects); for (i = 0; i < MAX_PENDING_HOSTS; i++) { if (pending_hosts[i].count > 0) { ast_cli(a->fd, " %-18s: %d%s\n", pending_hosts[i].host, pending_hosts[i].count, pending_hosts[i].count >= c.max_pending_connects ? " (cap reached: calls to this host fail fast with CONNECTION_ERROR)" : ""); } } ast_mutex_unlock(&pending_lock); ast_cli(a->fd, "\n"); return CLI_SUCCESS; } static struct ast_cli_entry cli_amd_ws[] = { AST_CLI_DEFINE(cli_show_settings, "Show AMD_WS settings and counters"), }; /* ------------------------------------------------------------------------ * Module lifecycle * ---------------------------------------------------------------------- */ static void load_all_config(int reload) { char path[sizeof(g_conf.astguiclient_conf)]; int db; load_config(reload); ast_mutex_lock(&conf_lock); ast_copy_string(path, g_conf.astguiclient_conf, sizeof(path)); db = g_conf.db; ast_mutex_unlock(&conf_lock); load_db_creds(path, db); } static int load_module(void) { conf_set_defaults(&g_conf); load_all_config(0); #ifdef HAVE_MYSQL if (mysql_library_init(0, NULL, NULL)) { ast_log(LOG_ERROR, "AMD_WS: mysql_library_init failed\n"); return AST_MODULE_LOAD_DECLINE; } #endif if (ast_register_application(app, amd_ws_exec, synopsis, description)) { ast_log(LOG_ERROR, "AMD_WS: unable to register application %s\n", app); return AST_MODULE_LOAD_DECLINE; } if (ast_cli_register_multiple(cli_amd_ws, ARRAY_LEN(cli_amd_ws))) { ast_log(LOG_WARNING, "AMD_WS: unable to register CLI commands\n"); } ast_verb(2, "AMD_WS %s loaded (host %s:%d, db %s)\n", AMD_WS_VERSION, g_conf.host, g_conf.port, db_availability()); return AST_MODULE_LOAD_SUCCESS; } static int unload_module(void) { int res; ast_cli_unregister_multiple(cli_amd_ws, ARRAY_LEN(cli_amd_ws)); res = ast_unregister_application(app); #ifdef HAVE_MYSQL db_shutdown(); #endif return res; } static int reload_module(void) { load_all_config(1); ast_verb(2, "AMD_WS configuration reloaded\n"); return AST_MODULE_LOAD_SUCCESS; } AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_LOAD_ORDER, "AMD via WebSocket (amdy.io)", .support_level = AST_MODULE_SUPPORT_EXTENDED, .load = load_module, .unload = unload_module, .reload = reload_module, .load_pri = AST_MODPRI_DEFAULT, .requires = "res_http_websocket", ); __APP_AMD_WS_EMBED_EOF__ cat > "$d/Makefile" <<'__APP_AMD_WS_EMBED_EOF__' # Makefile for app_amd_ws - AMD via WebSocket (res_http_websocket) module for Asterisk >= 16 # # make build app_amd_ws.so for the Asterisk that is running / installed on this box # make show-config print what was detected (binary, version, buildopt sum, headers, module dir, MySQL) # make check compile-only matrix against every header bundle under $(BUNDLES) (+ the local build) # make install back up the old module (.so.bak.) and install the new one + conf sample # make uninstall remove the module from the Asterisk module directory (backups are kept) # make load|unload|reload via 'asterisk -rx' WITH reply parsing (its exit code is always 0) # make installer regenerate install.sh from the sources (tools/gen-installer.sh) # make test run test/run.sh if present # make clean # # Detection is done by ./ast-detect.sh (the same file install.sh embeds), so make and the installer # always agree. Overrides, all optional, on the command line or in the environment: # ASTERISK=/path/asterisk ASTVERSION=16.30.1-vici ASTBUILDSUM=<32hex> ASTMODDIR=/path/modules # ASTTOPDIR=/usr/src/asterisk-16.30.1-vici | ASTINCDIR=/path/include AST_SRC_ROOTS=.. AST_INC_ROOTS=.. # ASTNOCHECK=1 accept headers whose AST_BUILDOPT_SUM / version cannot be verified (loader may refuse) # MYSQL=auto|1|0 MYSQL_CFLAGS=.. MYSQL_LIBS=.. phone/country lookup in vicidial_auto_calls (-DHAVE_MYSQL) # WERROR=1 treat warnings as errors (CI) CFLAGS=.. EXTRA_CPPFLAGS=.. BUNDLES=/path/to/bundles # # Every detected value is assigned with ':=' exactly once per make invocation. SHELL := /bin/sh MODULE := app_amd_ws MYSQL ?= auto ASTNOCHECK ?= 0 BUNDLES ?= bundles ASTETCDIR ?= /etc/asterisk CONF_SAMPLE := amd_ws.conf.sample DETECT_SH := ./ast-detect.sh # goals that need neither headers nor a daemon skip detection entirely (instant 'make clean') NODETECT_GOALS := clean distclean help installer ifeq ($(filter-out $(NODETECT_GOALS),$(or $(MAKECMDGOALS),all)),) DETECT := 0 else DETECT := 1 endif HDR_GOALS := all $(MODULE).so $(MODULE).o check install test ifeq ($(DETECT),1) # ---- Asterisk: run the detector once, import its result ------------------------------------ # Overrides are passed explicitly; the detector treats an empty value as "not set". _det := $(shell ASTERISK='$(ASTERISK)' ASTVERSION='$(ASTVERSION)' ASTBUILDSUM='$(ASTBUILDSUM)' \ ASTINCDIR='$(ASTINCDIR)' ASTTOPDIR='$(ASTTOPDIR)' ASTMODDIR='$(ASTMODDIR)' \ AST_SRC_ROOTS='$(AST_SRC_ROOTS)' AST_INC_ROOTS='$(AST_INC_ROOTS)' \ ASTNOCHECK='$(ASTNOCHECK)' AST_TIMEOUT='$(AST_TIMEOUT)' \ $(SHELL) $(DETECT_SH) --make > .ast-detect.mk || echo FAIL) include .ast-detect.mk # .ast-detect.mk redefines the override variables from what the detector settled on ifneq ($(AST_DETECT_RC),0) ifneq ($(filter $(HDR_GOALS),$(or $(MAKECMDGOALS),all)),) $(error no usable Asterisk headers for the running Asterisk - read the [ast-detect] lines above) endif endif $(info [ast-detect] Asterisk $(or $(ASTVERSION),unknown) via $(ASTVERSION_SRC); binary $(or $(ASTERISK),none) ($(ASTERISK_SRC)); modules $(or $(ASTMODDIR),unknown)) $(info [ast-detect] AST_BUILDOPT_SUM $(or $(ASTBUILDSUM),unknown) via $(ASTBUILDSUM_SRC); headers $(or $(ASTINCDIR),none) ($(ASTINCDIR_SRC))) # ---- MySQL/MariaDB client: MYSQL=auto|1|0, overridable with MYSQL_CFLAGS/MYSQL_LIBS ---------- ifneq ($(MYSQL),0) ifneq ($(strip $(MYSQL_CFLAGS)$(MYSQL_LIBS)),) MYSQL_HOW := MYSQL_CFLAGS/MYSQL_LIBS else MYSQL_PC := $(firstword $(foreach p,libmariadb mariadb mysqlclient,$(if $(shell pkg-config --exists $(p) 2>/dev/null && echo y),$(p)))) ifneq ($(MYSQL_PC),) MYSQL_CFLAGS := $(shell pkg-config --cflags $(MYSQL_PC) 2>/dev/null) MYSQL_LIBS := $(shell pkg-config --libs $(MYSQL_PC) 2>/dev/null) MYSQL_HOW := pkg-config $(MYSQL_PC) else MYSQL_CONFIG := $(firstword $(shell command -v mariadb_config mysql_config 2>/dev/null)) ifneq ($(MYSQL_CONFIG),) MYSQL_CFLAGS := $(shell $(MYSQL_CONFIG) --cflags 2>/dev/null) MYSQL_LIBS := $(shell $(MYSQL_CONFIG) --libs 2>/dev/null) MYSQL_HOW := $(MYSQL_CONFIG) endif endif endif ifeq ($(strip $(MYSQL_LIBS)),) ifeq ($(MYSQL),1) $(error MYSQL=1 but no MySQL/MariaDB client development files found (pkg-config libmariadb/mariadb/mysqlclient, mariadb_config or mysql_config). Install libmariadb-devel / mariadb-connector-c-devel / libmariadb-dev, or pass MYSQL_CFLAGS= MYSQL_LIBS=) endif HAVE_MYSQL := 0 $(info [ast-detect] MySQL: no (client development files not found; phone/country lookup disabled)) else HAVE_MYSQL := 1 $(info [ast-detect] MySQL: yes ($(MYSQL_HOW)) -> $(MYSQL_LIBS)) endif else HAVE_MYSQL := 0 MYSQL_CFLAGS := MYSQL_LIBS := $(info [ast-detect] MySQL: no (MYSQL=0)) endif endif # DETECT # ---- flags ---------------------------------------------------------------------------------- CC := $(if $(filter default undefined,$(origin CC)),gcc,$(CC)) CFLAGS ?= -O2 -g # -MD (not -MMD): the .d must list system headers too, so the provenance gate below can see an # accidental fall-through to /usr/include/asterisk/*.h override CFLAGS += -pthread -fPIC -std=gnu99 -Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers -Wformat=2 -Wshadow -MD -MP ifeq ($(WERROR),1) override CFLAGS += -Werror endif # Asterisk headers as system headers (-isystem) so -Wextra does not report Asterisk's own header # style (e.g. 'inline not at beginning of declaration' in strings.h); /usr/include and # /usr/local/include stay -I because -isystem would reorder the compiler's own include chain AST_INC_FLAG = $(if $(filter /usr/include /usr/local/include,$(1)),-I$(1),-isystem $(1)) CPPFLAGS += $(call AST_INC_FLAG,$(ASTINCDIR)) -DAST_MODULE=\"$(MODULE)\" -DAST_MODULE_SELF_SYM=__internal_$(MODULE)_self ifeq ($(HAVE_MYSQL),1) CPPFLAGS += -DHAVE_MYSQL $(MYSQL_CFLAGS) endif CPPFLAGS += $(EXTRA_CPPFLAGS) LDFLAGS += -pthread -shared # LIBS is composed from independent parts; nothing ever re-assigns it LIBS = $(if $(filter 1,$(HAVE_MYSQL)),$(MYSQL_LIBS)) $(EXTRA_LIBS) # symbols an Asterisk module may leave undefined: provided by the core at dlopen (ast_*, ao2, pbx_*, # plus the two option_* globals the ast_debug()/ast_verb() macros read; stock app_amd.so has the same) # or by res_http_websocket.so (ast_websocket_*, a required module that exports global symbols). # The pattern is only the fallback: when the core binary is readable, its 'nm -D' export list decides. CORE_SYM_RE := ^(ast_|__ast_|ao2_|__ao2_|pbx_|ast_websocket_|option_debug$$|option_verbose$$) # stamp with everything that influences the object: a change of headers/version/sum/flags rebuilds BUILDFLAGS := $(ASTVERSION) $(ASTINCDIR) $(ASTBUILDSUM) $(CC) $(CFLAGS) $(CPPFLAGS) $(LDFLAGS) $(LIBS) .PHONY: all check install uninstall clean distclean show-config installer test load unload reload help FORCE all: $(MODULE).so .buildflags: FORCE @echo '$(BUILDFLAGS)' | cmp -s - $@ 2>/dev/null || echo '$(BUILDFLAGS)' > $@ $(MODULE).o: $(MODULE).c .buildflags @echo " [CC] $< -> $@ (Asterisk $(ASTVERSION), headers $(ASTINCDIR))" $(CC) $(CPPFLAGS) $(CFLAGS) -c -o $@ $< @# gate 1: every Asterisk header actually used must come from ASTINCDIR (no silent /usr/include mix) @bad=$$(tr ' \\' '\n\n' < $(MODULE).d | grep -E '/asterisk(\.h|/[^/]*\.h)$$' | grep -v "^$(ASTINCDIR)/" | sort -u); \ if [ -n "$$bad" ]; then echo "ERROR: Asterisk headers pulled from outside $(ASTINCDIR):"; echo "$$bad" | sed 's/^/ /'; rm -f $@; exit 1; fi @# gate 2: the object embeds the core's AST_BUILDOPT_SUM (the loader compares it) @if [ -n '$(ASTBUILDSUM)' ]; then strings -a $@ | grep -qx '$(ASTBUILDSUM)' || { echo "ERROR: $@ does not embed AST_BUILDOPT_SUM $(ASTBUILDSUM)"; rm -f $@; exit 1; }; fi $(MODULE).so: $(MODULE).o @echo " [LD] $< -> $@" $(CC) $(LDFLAGS) -o $@ $< $(LIBS) @# post-link gate 3: every symbol still undefined must be one the core (or res_http_websocket) provides @# ('ldd -r' prints "undefined symbol: name, version libfoo_3 (file)" for versioned symbols: strip the comma) @und=$$(ldd -r $@ 2>&1 | awk '/undefined symbol/{s=$$3; sub(/,$$/,"",s); sub(/\(.*/,"",s); print s}' | sort -u); \ core=''; \ if [ -n '$(ASTERISK)' ] && [ -r '$(ASTERISK)' ]; then core=$$(nm -D --defined-only '$(ASTERISK)' 2>/dev/null | awk '{print $$NF}' | sed 's/@.*//' | sort -u); fi; \ if [ -n "$$core" ]; then \ missing=$$(printf '%s\n' "$$und" | grep -v '^ast_websocket_' | grep -vxF "$$core"); \ else \ echo " [..] no readable core binary (or no export list) - symbol check limited to the name pattern"; \ missing=$$(printf '%s\n' "$$und" | grep -v -E '$(CORE_SYM_RE)'); \ fi; \ missing=$$(printf '%s\n' "$$missing" | grep -v '^$$' || true); \ if [ -n "$$missing" ]; then echo "ERROR: $@ needs symbols that neither its libraries nor Asterisk provide (dlopen would fail):"; echo "$$missing" | sed 's/^/ /'; rm -f $@; exit 1; fi; \ echo " [OK] undefined symbols of $@ are all provided by Asterisk$(if $(filter 1,$(HAVE_MYSQL)), or the MySQL client library)" @# post-link gate 4: the .so embeds the core's sum @if [ -n '$(ASTBUILDSUM)' ]; then strings -a $@ | grep -qx '$(ASTBUILDSUM)' && echo " [OK] $@ embeds AST_BUILDOPT_SUM $(ASTBUILDSUM)" || { echo "ERROR: $@ does not embed AST_BUILDOPT_SUM $(ASTBUILDSUM)"; rm -f $@; exit 1; }; \ else echo " [..] core AST_BUILDOPT_SUM unknown - not verified"; fi # ---- check: compile-only matrix against header bundles (tools/make-header-bundle.sh output) ---- # Bundles are directories $(BUNDLES)/asterisk-/include (tarballs asterisk--headers.tar.gz # found there are extracted first). Bundles ship no buildopts.h: one is synthesised for the core's # sum (or the stock OPTIONAL_API sum when unknown), exactly as install.sh does on a customer box. CHECK_SUM := $(or $(ASTBUILDSUM),da6642af068ee5e6490c5b1d2cc1d238) CHECK_CPPFLAGS := $(filter-out -I$(ASTINCDIR) -isystem $(ASTINCDIR),$(CPPFLAGS)) check: all @set -e; found=0; \ for tb in $(BUNDLES)/asterisk-*-headers.tar.gz; do \ [ -f "$$tb" ] || continue; d=$${tb%-headers.tar.gz}; \ [ -f "$$d/include/asterisk.h" ] || { echo " [check] extracting $$tb"; mkdir -p "$$d"; tar xzf "$$tb" -C "$$d" --strip-components=1; }; \ done; \ for inc in $(BUNDLES)/asterisk-*/include; do \ [ -f "$$inc/asterisk.h" ] || continue; found=$$((found+1)); tree=$${inc%/include}; name=$${tree##*/}; \ [ -f "$$inc/asterisk/buildopts.h" ] || { . $(DETECT_SH); ast_synth_buildopts "$$inc" '$(CHECK_SUM)'; }; \ printf ' [check] %-28s ' "$$name"; \ $(CC) $(CHECK_CPPFLAGS) -isystem "$$inc" $(CFLAGS) -c -o "$$tree/$(MODULE).o" $(MODULE).c || { echo "FAILED (compile)"; exit 1; }; \ bad=$$(tr ' \\' '\n\n' < "$$tree/$(MODULE).d" | grep -E '/asterisk(\.h|/[^/]*\.h)$$' | grep -v "^$$inc/" | sort -u); \ [ -z "$$bad" ] || { echo "FAILED (headers from outside the bundle: $$bad)"; exit 1; }; \ echo "OK ($$(sed -n 1p "$$tree/.version" 2>/dev/null || echo 'no .version'))"; \ done; \ if [ $$found -eq 0 ]; then echo " [check] no header bundles under $(BUNDLES) (see tools/make-header-bundle.sh) - only the local build was checked"; fi # ---- install / uninstall ------------------------------------------------------------------- install: $(MODULE).so @[ -n "$(ASTMODDIR)" ] && [ -d "$(DESTDIR)$(ASTMODDIR)" ] || { echo "ERROR: Asterisk module directory not found - pass ASTMODDIR=/path/to/modules"; exit 1; } @if [ -f "$(DESTDIR)$(ASTMODDIR)/$(MODULE).so" ]; then \ b="$(DESTDIR)$(ASTMODDIR)/$(MODULE).so.bak.$$(date +%Y%m%d%H%M%S)"; cp -p "$(DESTDIR)$(ASTMODDIR)/$(MODULE).so" "$$b"; echo " [BACKUP] $$b"; fi install -m 755 $(MODULE).so "$(DESTDIR)$(ASTMODDIR)/.$(MODULE).so.new" mv -f "$(DESTDIR)$(ASTMODDIR)/.$(MODULE).so.new" "$(DESTDIR)$(ASTMODDIR)/$(MODULE).so" @echo " [INSTALL] $(DESTDIR)$(ASTMODDIR)/$(MODULE).so" @if [ -f $(CONF_SAMPLE) ] && [ -d "$(DESTDIR)$(ASTETCDIR)" ]; then install -m 644 $(CONF_SAMPLE) "$(DESTDIR)$(ASTETCDIR)/$(CONF_SAMPLE)"; echo " [INSTALL] $(DESTDIR)$(ASTETCDIR)/$(CONF_SAMPLE)"; fi @echo " next: make reload (or: asterisk -rx 'module load $(MODULE).so')" uninstall: @[ -n "$(ASTMODDIR)" ] || { echo "ERROR: Asterisk module directory not found - pass ASTMODDIR=/path/to/modules"; exit 1; } rm -f "$(DESTDIR)$(ASTMODDIR)/$(MODULE).so" @echo " [REMOVED] $(DESTDIR)$(ASTMODDIR)/$(MODULE).so (backups $(MODULE).so.bak.* kept, amd_ws.conf kept)" # ---- load / unload / reload: 'asterisk -rx' exits 0 whatever happened, so parse the reply ------ # Recipes already run in $(SHELL): plain commands, no nested 'sh -c' (the quotes would not nest). ast_rx = timeout $(or $(AST_TIMEOUT),5) "$(ASTERISK)" $(ASTERISK_OPTS) -rx "$(1)" 2>&1 ast_status = $(call ast_rx,module show like $(MODULE)) | awk -v m="$(MODULE).so" '$$1==m' load: @[ -n "$(ASTERISK)" ] || { echo "ERROR: no asterisk binary"; exit 1; } @out=$$($(call ast_rx,module load $(MODULE).so)); echo " $$out"; \ case "$$out" in "Loaded "*) ;; *) echo "ERROR: module load failed (check /var/log/asterisk/messages)"; exit 1;; esac @st=$$($(ast_status)); case "$$st" in \ *"Not Running"*) echo "ERROR: $(MODULE).so is loaded but Not Running: $$st"; exit 1;; \ *" Running"*) echo " [OK] $(MODULE).so is Running";; \ *) echo "ERROR: $(MODULE).so not Running: $${st:-}"; exit 1;; esac unload: @[ -n "$(ASTERISK)" ] || { echo "ERROR: no asterisk binary"; exit 1; } @out=$$($(call ast_rx,module unload $(MODULE).so)); echo " $$out"; \ case "$$out" in "Unloaded "*) ;; *) echo "ERROR: module unload refused (module in use by a call? try again when idle)"; exit 1;; esac # unload THEN load, also under 'make -j' (a plain prerequisite list would run them in parallel) reload: @$(MAKE) --no-print-directory unload @$(MAKE) --no-print-directory load # ---- misc --------------------------------------------------------------------------------- show-config: @echo "ASTERISK=$(ASTERISK)"; echo "ASTVERSION=$(ASTVERSION)"; echo "ASTVERBASE=$(ASTVERBASE)"; echo "ASTMAJOR=$(ASTMAJOR)" @echo "ASTBUILDSUM=$(ASTBUILDSUM)"; echo "ASTINCDIR=$(ASTINCDIR)"; echo "ASTMODDIR=$(ASTMODDIR)" @echo "MySQL: $(if $(filter 1,$(HAVE_MYSQL)),yes ($(MYSQL_HOW)),no)" @echo "CC=$(CC)"; echo "CFLAGS=$(CFLAGS)"; echo "CPPFLAGS=$(CPPFLAGS)"; echo "LIBS=$(LIBS)" installer: $(SHELL) tools/gen-installer.sh -o install.sh test: @if [ -x test/run.sh ]; then test/run.sh; else echo "no test/run.sh in this checkout"; fi clean: rm -f $(MODULE).o $(MODULE).so $(MODULE).d .buildflags .ast-detect.mk distclean: clean rm -rf bundles/asterisk-*/$(MODULE).o bundles/asterisk-*/$(MODULE).d help: @sed -n '2,22p' Makefile -include $(MODULE).d FORCE: __APP_AMD_WS_EMBED_EOF__ cat > "$d/ast-detect.sh" <<'__APP_AMD_WS_EMBED_EOF__' #!/bin/sh # ast-detect.sh - find the Asterisk that is RUNNING (else installed) on this box and a header # tree that matches it. Shared by the Makefile and by install.sh (embedded verbatim), so both # always take the same decision. # # POSIX sh (tested with dash and bash). Every diagnostic goes to STDERR, so callers can use # $(...) safely; nothing but the requested output ever appears on stdout. # # Usage as a library: # . ./ast-detect.sh; ast_detect || exit 1 # make ASTERISK="$ASTERISK" ASTVERSION="$ASTVERSION" ASTBUILDSUM="$ASTBUILDSUM" ASTINCDIR="$ASTINCDIR" ... # Usage standalone: # sh ast-detect.sh -> KEY=VALUE lines (shell syntax) on stdout, rc 0 = usable headers found # sh ast-detect.sh --make -> KEY := VALUE lines (GNU make syntax) on stdout, rc 0 = usable headers found # sh ast-detect.sh --running -> rc 0 if an Asterisk daemon answers 'core show version' within AST_TIMEOUT # # Overrides (environment, or on the make command line): # ASTERISK=/path/to/asterisk the binary to fingerprint (default: the running daemon's image) # ASTERISK_OPTS='-C /etc/asterisk/asterisk.conf' extra options for 'asterisk -rx' (auto: the daemon's -C) # ASTVERSION=16.30.1-vici skip version detection # ASTBUILDSUM=<32 hex> skip AST_BUILDOPT_SUM detection # ASTINCDIR=/path/include use exactly this include dir (still validated unless ASTNOCHECK=1) # ASTTOPDIR=/path/asterisk- use /include (same rules) # ASTMODDIR=/path/modules skip module-directory detection # AST_SRC_ROOTS="/usr/src ..." where versioned source trees are searched # AST_INC_ROOTS="/usr/include ..." where installed headers are searched # ASTNOCHECK=1 downgrade a buildopt-sum mismatch / unverifiable headers to a WARNING # AST_TIMEOUT=5 seconds allowed for every 'asterisk -rx' / 'asterisk -V' call # # Why all this: an out-of-tree module needs only headers, but they must belong to the same # Asterisk version as the running core AND carry the same AST_BUILDOPT_SUM (main/loader.c refuses # the module otherwise). /usr/include, a distro -dev package and stray trees in /usr/src may all # be from a different Asterisk than the one that is running, so every candidate is validated. LC_ALL=C; export LC_ALL AST_TIMEOUT=${AST_TIMEOUT:-5} AST_SRC_ROOTS=${AST_SRC_ROOTS:-"/usr/src /usr/src/asterisk /usr/local/src /usr/local/src/asterisk"} AST_INC_ROOTS=${AST_INC_ROOTS:-"/usr/include /usr/local/include"} ASTNOCHECK=${ASTNOCHECK:-0} ad_log() { printf '[ast-detect] %s\n' "$*" >&2; } ad_warn() { printf '[ast-detect] WARNING: %s\n' "$*" >&2; } ad_err() { printf '[ast-detect] ERROR: %s\n' "$*" >&2; } ad_run() { timeout "$AST_TIMEOUT" "$@" 2>/dev/null; } # never hang on a broken/deadlocked binary # --------------------------------------------------------------------------------------------- # 1. binary: image of the running daemon (skipping 'asterisk -r' consoles) -> PATH -> well-known # --------------------------------------------------------------------------------------------- ast_daemon_pid() { # prints the PID of the daemon (not of a remote console), or nothing for p in $(pgrep -x asterisk 2>/dev/null); do tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null | grep -- ' -r' >/dev/null && continue printf '%s\n' "$p"; return 0 done return 1 } # '-C ' of the daemon's command line, so that 'asterisk -rx' reaches a daemon started with a # non-default asterisk.conf (its control socket lives wherever that file says) ast_ctl_opts_of() { tr '\0' '\n' < "/proc/$1/cmdline" 2>/dev/null | awk '$0 == "-C" { getline f; printf "-C %s", f; exit }'; } ast_find_binary() { ASTERISK_OPTS=${ASTERISK_OPTS:-} [ -n "${ASTERISK:-}" ] && { ASTERISK_SRC=override; return 0; } ASTERISK=''; ASTERISK_SRC='' p=$(ast_daemon_pid) || p='' if [ -n "$p" ]; then ASTERISK=$(readlink "/proc/$p/exe" 2>/dev/null | sed 's/ (deleted)$//') [ -n "$ASTERISK" ] && { ASTERISK_SRC="running process $p"; ASTERISK_OPTS=$(ast_ctl_opts_of "$p"); } fi [ -z "$ASTERISK" ] && { ASTERISK=$(command -v asterisk 2>/dev/null); ASTERISK_SRC=PATH; } if [ -z "$ASTERISK" ]; then for b in /usr/sbin/asterisk /usr/local/sbin/asterisk; do [ -x "$b" ] && { ASTERISK=$b; ASTERISK_SRC="well-known path"; break; } done fi [ -n "$ASTERISK" ] || ASTERISK_SRC="none found" [ -n "$ASTERISK" ] } # rc 0 when a daemon answers the CLI (the only reliable definition of "running") ast_running() { [ -n "${ASTERISK:-}" ] || ast_find_binary || return 1 # shellcheck disable=SC2086 ad_run "$ASTERISK" ${ASTERISK_OPTS:-} -rx 'core show version' | grep '^Asterisk ' >/dev/null } # --------------------------------------------------------------------------------------------- # 2. version: 'core show version' -> 'asterisk -V' -> embedded string pair ("16.30.1-vici" + "163001") # --------------------------------------------------------------------------------------------- ast_version_filter() { sed -n 's/^Asterisk \([^ ][^ ]*\).*/\1/p' | head -n 1; } # main/version.c stores the version string and its numeric form next to each other; requiring both # avoids false hits like "10.0" from unrelated strings. ast_version_from_strings() { strings -a "$1" 2>/dev/null | awk ' /^[0-9][0-9]*\.[0-9][0-9]*(\.[0-9][0-9]*)?(-[A-Za-z0-9.]+)?$/ || /^certified\/[0-9][0-9]*\.[0-9][0-9]*(-cert[0-9]+)?$/ { v[$0]=1 } /^[0-9][0-9][0-9][0-9][0-9][0-9]?$/ { n[$0]=1 } END { for (s in v) { t=s; sub(/^certified\//,"",t); sub(/-cert/,".",t); split(t,a,/[.-]/); if (sprintf("%d%02d%02d",a[1],a[2],a[3]) in n) print s } }' | sort -u | head -n 1 } ast_find_version() { if [ -n "${ASTVERSION:-}" ]; then ASTVERSION_SRC=override else ASTVERSION=''; ASTVERSION_SRC=unknown if [ -n "${ASTERISK:-}" ]; then # shellcheck disable=SC2086 ASTVERSION=$(ad_run "$ASTERISK" ${ASTERISK_OPTS:-} -rx 'core show version' | ast_version_filter); ASTVERSION_SRC="running daemon (core show version)" if [ -z "$ASTVERSION" ]; then ASTVERSION=$(ad_run "$ASTERISK" -V | ast_version_filter); ASTVERSION_SRC="'$ASTERISK -V' (daemon not running; version on disk)"; fi if [ -z "$ASTVERSION" ]; then ASTVERSION=$(ast_version_from_strings "$ASTERISK"); ASTVERSION_SRC="strings(1) on $ASTERISK (binary does not run here)"; fi [ -n "$ASTVERSION" ] || ASTVERSION_SRC=unknown # binary on disk replaced after the daemon started? Build for the RUNNING one, but say so. case $ASTVERSION_SRC in "running daemon"*) dv=$(ad_run "$ASTERISK" -V | ast_version_filter) [ -n "$dv" ] && [ "$dv" != "$ASTVERSION" ] && ad_warn "running Asterisk is $ASTVERSION but the binary on disk is $dv (restart pending?) - building for the RUNNING one";; esac fi fi # 16.30.1-vici -> 16.30.1 ; certified/18.9-cert1 -> 18.9 ; 18.10.0~dfsg... -> 18.10.0 ; GIT-... -> empty ASTVERBASE=$(printf '%s\n' "$ASTVERSION" | sed -n 's|^certified/||; s|^\([0-9][0-9]*\(\.[0-9][0-9]*\)\{1,2\}\).*|\1|p') ASTMAJOR=${ASTVERBASE%%.*} [ -n "$ASTVERSION" ] } # --------------------------------------------------------------------------------------------- # 3. module dir: 'core show settings' -> asterisk.conf astmoddir -> first known dir holding pbx_config.so # --------------------------------------------------------------------------------------------- AST_MODDIR_CANDS="/usr/lib64/asterisk/modules /usr/lib/asterisk/modules /usr/lib/x86_64-linux-gnu/asterisk/modules /usr/local/lib/asterisk/modules /usr/local/lib64/asterisk/modules" ast_find_moddir() { [ -n "${ASTMODDIR:-}" ] && { ASTMODDIR_SRC=override; return 0; } ASTMODDIR=''; ASTMODDIR_SRC='' if [ -n "${ASTERISK:-}" ]; then # shellcheck disable=SC2086 ASTMODDIR=$(ad_run "$ASTERISK" ${ASTERISK_OPTS:-} -rx 'core show settings' | sed -n 's/^ *Module directory: *//p' | head -n 1) [ -n "$ASTMODDIR" ] && ASTMODDIR_SRC="running daemon (core show settings)" fi if [ -z "$ASTMODDIR" ]; then ASTMODDIR=$(sed -n 's/^ *astmoddir *=> *\([^ ;]*\).*/\1/p' /etc/asterisk/asterisk.conf 2>/dev/null | head -n 1) [ -n "$ASTMODDIR" ] && ASTMODDIR_SRC="/etc/asterisk/asterisk.conf" fi if [ -z "$ASTMODDIR" ]; then for d in $AST_MODDIR_CANDS; do [ -f "$d/pbx_config.so" ] && { ASTMODDIR=$d; ASTMODDIR_SRC="contains pbx_config.so"; break; } done fi [ -n "$ASTMODDIR" ] || ASTMODDIR_SRC="not found" [ -n "$ASTMODDIR" ] } # --------------------------------------------------------------------------------------------- # 4. AST_BUILDOPT_SUM of the core: the most frequent 32-hex string in the binary (one copy per # built-in module), else in a stock module. NOT the 'Build Options' line of 'core show # settings' - that string also lists non-ABI flags (BUILD_NATIVE, ...) and is not the md5 input. # --------------------------------------------------------------------------------------------- ast_sum_of() { strings -a "$1" 2>/dev/null | grep -xE '[0-9a-f]{32}' | sort | uniq -c | sort -rn | awk 'NR==1{print $2}'; } ast_find_buildsum() { [ -n "${ASTBUILDSUM:-}" ] && { ASTBUILDSUM_SRC=override; return 0; } ASTBUILDSUM=''; ASTBUILDSUM_SRC='' if [ -n "${ASTERISK:-}" ] && [ -r "$ASTERISK" ]; then ASTBUILDSUM=$(ast_sum_of "$ASTERISK"); ASTBUILDSUM_SRC="strings(1) on $ASTERISK" fi if [ -z "$ASTBUILDSUM" ] && [ -n "${ASTMODDIR:-}" ]; then for m in "$ASTMODDIR/pbx_config.so" "$ASTMODDIR/app_dial.so" "$ASTMODDIR/res_http_websocket.so"; do [ -f "$m" ] || continue ASTBUILDSUM=$(ast_sum_of "$m"); ASTBUILDSUM_SRC="strings(1) on $m" [ -n "$ASTBUILDSUM" ] && break done fi [ -n "$ASTBUILDSUM" ] || ASTBUILDSUM_SRC="unknown (no readable core binary or stock module)" [ -n "$ASTBUILDSUM" ] } # md5 of the ', '-joined ABI-relevant menuselect options + newline (build_tools/make_buildopts_h) ast_sum_for_opts() { printf '%s\n' "$1" | md5sum | cut -c1-32; } # Print the option list behind a known AST_BUILDOPT_SUM, or fail (rc 1) for an unknown sum. # The candidates are the ABI-relevant options of menuselect (build_tools/cflags.xml order), in # every combination, so any Asterisk >= 16 built from stock menuselect flags is covered. # Asterisk 13 puts MALLOC_DEBUG/REF_DEBUG/LOW_MEMORY into the sum as well; 13 is out of scope. ast_opts_for_sum() { _sum=$1 case $_sum in da6642af068ee5e6490c5b1d2cc1d238) printf 'OPTIONAL_API\n'; return 0;; # stock 16/18/20/21/22 (ViciDial, ViciBox RPM) fa819827cbff2ea35341af5458859233) printf 'LOADABLE_MODULES, OPTIONAL_API\n'; return 0;; # stock 13 esac # brute force over the ordered option set (2^9 = 512 md5 calls, about a second) _n=0 while [ $_n -lt 512 ]; do _opts=''; _i=0 for _o in DEBUG_THREADLOCALS DO_CRASH TEST_FRAMEWORK DEBUG_THREADS DEBUG_FD_LEAKS LOADABLE_MODULES OPTIONAL_API G711_NEW_ALGORITHM INTEGER_CALLERID; do if [ $(( (_n >> _i) & 1 )) -eq 1 ]; then _opts="${_opts:+$_opts, }$_o" fi _i=$((_i+1)) done if [ -n "$_opts" ] && [ "$(ast_sum_for_opts "$_opts")" = "$_sum" ]; then printf '%s\n' "$_opts"; return 0; fi _n=$((_n+1)) done return 1 } # ast_synth_buildopts : write /asterisk/buildopts.h for a known sum. # Used when only pristine headers are available (vendor header bundle, headers-only tarball extract). ast_synth_buildopts() { _inc=$1; _sum=$2 [ -n "$_sum" ] || { ad_err "cannot synthesise buildopts.h: AST_BUILDOPT_SUM of the running Asterisk is unknown"; return 1; } _opts=$(ast_opts_for_sum "$_sum") || { ad_err "AST_BUILDOPT_SUM $_sum is not one produced by stock menuselect options (custom build) - use the real configured+built source tree: ASTTOPDIR=/path/to/asterisk-"; return 1; } [ "$(ast_sum_for_opts "$_opts")" = "$_sum" ] || { ad_err "internal error: option list '$_opts' does not hash to $_sum"; return 1; } mkdir -p "$_inc/asterisk" || return 1 { printf '/*\n * buildopts.h\n * Synthesised by ast-detect.sh for AST_BUILDOPT_SUM %s\n * (the running Asterisk was built with: %s)\n */\n\n' "$_sum" "$_opts" for _o in $(printf '%s' "$_opts" | tr -d ','); do printf '#define %s 1\n' "$_o"; done printf '#define AST_BUILDOPT_SUM "%s"\n' "$_sum" printf '#define AST_BUILDOPTS "%s"\n' "$_opts" printf '#define AST_BUILDOPTS_ALL "%s"\n' "$_opts" } > "$_inc/asterisk/buildopts.h" || return 1 ad_log "synthesised $_inc/asterisk/buildopts.h (AST_BUILDOPTS \"$_opts\", sum $_sum)" } # --------------------------------------------------------------------------------------------- # 5. validate one include dir. rc 0 = usable; one '[ast-detect] accept/reject : ' line # on stderr. Rules: asterisk.h + asterisk/autoconfig.h (configured) + asterisk/buildopts.h # (built or synthesised) must exist; the tree's .version must agree with the running version # when present; AST_BUILDOPT_SUM must equal the core's (mismatch = reject, ASTNOCHECK=1 = warn). # Installed headers without .version are tied to the binary by package version or by # install-time proximity (make install writes both within seconds); otherwise a WARNING. # --------------------------------------------------------------------------------------------- ast_check_incdir() { d=$1; why=''; note='' if [ ! -f "$d/asterisk.h" ]; then why='no asterisk.h' elif [ ! -f "$d/asterisk/autoconfig.h" ]; then why='not configured (no asterisk/autoconfig.h)' elif [ ! -f "$d/asterisk/buildopts.h" ]; then why='not built (no asterisk/buildopts.h - ./configure alone is not enough)' else sum=$(sed -n 's/^#define AST_BUILDOPT_SUM "\([0-9a-f]*\)".*/\1/p' "$d/asterisk/buildopts.h" | head -n 1) # the candidate's OWN tree dir (not '$d/..', which would follow a symlinked include/ elsewhere) tv=$(sed -n 1p "${d%/include}/.version" 2>/dev/null) if [ -n "$tv" ] && [ -n "${ASTVERSION:-}" ] && [ "$tv" != "$ASTVERSION" ] && [ "$tv" != "$ASTVERBASE" ]; then why="tree version $tv != running $ASTVERSION" elif [ -n "${ASTBUILDSUM:-}" ] && [ "$sum" != "$ASTBUILDSUM" ]; then if [ "$ASTNOCHECK" = 1 ]; then note="WARNING: AST_BUILDOPT_SUM ${sum:-none} != core $ASTBUILDSUM, accepted because ASTNOCHECK=1" else why="AST_BUILDOPT_SUM ${sum:-none} != core $ASTBUILDSUM (Asterisk would refuse to load the module)"; fi elif [ -z "$tv" ]; then pv='' if command -v rpm >/dev/null 2>&1; then pv=$(rpm -qf --qf '%{VERSION}' "$d/asterisk.h" 2>/dev/null | grep -v 'not owned'); fi if [ -z "$pv" ] && command -v dpkg-query >/dev/null 2>&1; then pkg=$(dpkg -S "$d/asterisk.h" 2>/dev/null | sed 's/:.*//;q') [ -n "$pkg" ] && pv=$(dpkg-query -W -f '${Version}' "$pkg" 2>/dev/null) fi pv=$(printf '%s' "$pv" | sed 's/^[0-9]*://') bm=$(stat -c %Y "${ASTERISK:-/nonexistent}" 2>/dev/null || echo 0); hm=$(stat -c %Y "$d/asterisk/buildopts.h" 2>/dev/null || echo 0) delta=$(( bm - hm )); [ $delta -lt 0 ] && delta=$(( -delta )) if [ -n "$pv" ] && [ -n "$ASTVERBASE" ] && [ "${pv#"$ASTVERBASE"}" != "$pv" ]; then note="installed headers owned by package version $pv (matches running $ASTVERSION)" elif [ -n "$pv" ] && [ -n "$ASTVERBASE" ] && [ $delta -gt 3600 ]; then if [ "$ASTNOCHECK" = 1 ]; then note="WARNING: installed headers belong to package version $pv, running $ASTVERSION; accepted because ASTNOCHECK=1" else why="installed headers belong to package version $pv, running $ASTVERSION (and not rewritten by the same make install)"; fi elif [ $delta -le 3600 ] && [ "$bm" -gt 0 ]; then note="installed headers written within ${delta}s of the binary (same make install); sum matches" elif [ -z "${ASTBUILDSUM:-}" ]; then note="WARNING: unversioned headers and core sum unknown - cannot verify they match the running Asterisk" else note="WARNING: unversioned installed headers (not from a package, not installed together with the binary); AST_BUILDOPT_SUM matches but the Asterisk major version cannot be verified - prefer ASTTOPDIR="; fi else note="tree .version $tv matches"; fi fi if [ -n "$why" ]; then ad_log "reject $d: $why"; return 1; fi ad_log "accept $d: ${note:-ok}"; return 0 } # --------------------------------------------------------------------------------------------- # 6. candidates in priority order; the first valid one wins. # ASTINCDIR / ASTTOPDIR override the list; then trees named asterisk-, trees of # the same base version (asterisk-, asterisk--*, asterisk-certified-*) under # AST_SRC_ROOTS - only directories with include/asterisk.h, so *.tar.gz, sound dirs and # asterisk-perl-* never match - then /include next to the binary, then AST_INC_ROOTS. # --------------------------------------------------------------------------------------------- ad_add() { case " $cands " in *" $1 "*) ;; *) cands="$cands $1";; esac; } ast_find_incdir() { cands=''; ASTINCDIR_SRC='' if [ -n "${ASTINCDIR:-}" ]; then cands=$ASTINCDIR; ASTINCDIR_SRC="ASTINCDIR override" elif [ -n "${ASTTOPDIR:-}" ]; then cands="$ASTTOPDIR/include"; ASTINCDIR_SRC="ASTTOPDIR override" else if [ -n "${ASTVERSION:-}" ]; then for r in $AST_SRC_ROOTS; do t="$r/asterisk-$ASTVERSION"; [ -f "$t/include/asterisk.h" ] && ad_add "$t/include"; done if [ -n "$ASTVERBASE" ]; then for r in $AST_SRC_ROOTS; do for t in "$r/asterisk-$ASTVERBASE" "$r"/asterisk-"$ASTVERBASE"-* "$r"/asterisk-certified-"$ASTVERBASE"*; do [ -f "$t/include/asterisk.h" ] && ad_add "$t/include" done done fi else for r in $AST_SRC_ROOTS; do for t in "$r"/asterisk-[0-9]* "$r"/asterisk-certified-*; do [ -f "$t/include/asterisk.h" ] && ad_add "$t/include"; done done fi case ${ASTERISK:-} in */sbin/asterisk) p=${ASTERISK%/sbin/asterisk}; [ -f "$p/include/asterisk.h" ] && ad_add "$p/include";; esac for r in $AST_INC_ROOTS; do [ -f "$r/asterisk.h" ] && ad_add "$r"; done cands=${cands# } fi ok=''; n=0 for d in $cands; do ast_check_incdir "$d" && { ok="$ok $d"; n=$((n+1)); }; done ok=${ok# } if [ -n "${ASTINCDIR:-}" ]; then [ $n -eq 1 ] || [ "$ASTNOCHECK" = 1 ] || { ad_err "ASTINCDIR=$ASTINCDIR failed validation (ASTNOCHECK=1 forces it)"; return 1; } return 0 fi if [ -n "${ASTTOPDIR:-}" ]; then [ $n -eq 1 ] || [ "$ASTNOCHECK" = 1 ] || { ad_err "ASTTOPDIR=$ASTTOPDIR failed validation (ASTNOCHECK=1 forces it)"; return 1; } ASTINCDIR="$ASTTOPDIR/include"; return 0 fi if [ -z "${ASTVERSION:-}" ]; then # unknown version: accept only an unambiguous candidate that is tied to the core by its sum if [ $n -eq 1 ] && [ -n "${ASTBUILDSUM:-}" ]; then ASTINCDIR=$ok; ASTINCDIR_SRC="only sum-matching candidate (version unknown)" ad_warn "Asterisk version unknown; using the only header dir whose AST_BUILDOPT_SUM matches the core: $ASTINCDIR (pass ASTVERSION=... to be sure)"; return 0 fi if [ $n -ge 1 ] && [ "$ASTNOCHECK" = 1 ]; then ASTINCDIR=${ok%% *}; ASTINCDIR_SRC="first candidate (version unknown, ASTNOCHECK=1)" ad_warn "Asterisk version unknown and ASTNOCHECK=1: using $ASTINCDIR unverified"; return 0 fi ad_err "Asterisk version could not be detected ($n header dir(s) validated: ${ok:-none}) - pass ASTVERSION= or ASTTOPDIR=/path/to/configured+built source tree (ASTNOCHECK=1 forces the first candidate)" return 1 fi ASTINCDIR=${ok%% *}; ASTINCDIR_SRC="first validated candidate" [ -n "$ASTINCDIR" ] && return 0 ad_err "no configured+built Asterisk $ASTVERSION headers with AST_BUILDOPT_SUM ${ASTBUILDSUM:-?} found (candidates: ${cands:-none}; roots: $AST_SRC_ROOTS $AST_INC_ROOTS)" ast_hint_no_headers return 1 } # next steps when nothing validates (kept in one place so Makefile and installer say the same) ast_hint_no_headers() { v=${ASTVERSION:-} case $v in *-vici) tb="https://download.vicidial.com/required-apps/asterisk-$v.tar.gz"; tree="asterisk-$v";; certified/*) tb="https://downloads.asterisk.org/pub/telephony/certified-asterisk/releases/asterisk-certified-${v#certified/}.tar.gz"; tree="asterisk-certified-${v#certified/}";; *) tb="https://downloads.asterisk.org/pub/telephony/asterisk/releases/asterisk-${ASTVERBASE:-$v}.tar.gz (or old-releases/)"; tree="asterisk-${ASTVERBASE:-$v}";; esac ad_err "fix one of: (a) run install.sh, which fetches a header bundle or the headers of $tb and synthesises buildopts.h;" ad_err " (b) extract + ./configure + 'make include/asterisk/buildopts.h' that tarball and pass ASTTOPDIR=/path/to/$tree;" ad_err " (c) install the asterisk-devel/asterisk-dev package of EXACTLY the running version (only when Asterisk itself is the distro package);" ad_err " (d) ASTNOCHECK=1 to accept mismatching headers at your own risk (the loader may refuse the module)." } # --------------------------------------------------------------------------------------------- # all-in-one: sets ASTERISK ASTVERSION ASTVERBASE ASTMAJOR ASTBUILDSUM ASTMODDIR ASTINCDIR (+ *_SRC) # --------------------------------------------------------------------------------------------- ast_detect_core() { ast_find_binary || ad_warn "no asterisk binary found (pass ASTERISK=/path/to/asterisk)" ast_find_version || ad_warn "cannot determine the Asterisk version${ASTERISK:+ from $ASTERISK}" ast_find_moddir || ad_warn "no Asterisk module directory found (pass ASTMODDIR=/path)" ast_find_buildsum || ad_warn "cannot read AST_BUILDOPT_SUM from the core or its modules" ad_log "binary : ${ASTERISK:-none}${ASTERISK_OPTS:+ $ASTERISK_OPTS} ($ASTERISK_SRC)" ad_log "version : ${ASTVERSION:-unknown} (base ${ASTVERBASE:-?}, major ${ASTMAJOR:-?}) via $ASTVERSION_SRC" ad_log "buildsum : ${ASTBUILDSUM:-unknown} via $ASTBUILDSUM_SRC" ad_log "moddir : ${ASTMODDIR:-unknown} ($ASTMODDIR_SRC)" } ast_detect() { ast_detect_core ast_find_incdir || return 1 ad_log "headers : $ASTINCDIR ($ASTINCDIR_SRC)" export ASTERISK ASTERISK_OPTS ASTVERSION ASTVERBASE ASTMAJOR ASTBUILDSUM ASTMODDIR ASTINCDIR return 0 } AST_DETECT_VARS="ASTERISK ASTERISK_OPTS ASTVERSION ASTVERBASE ASTMAJOR ASTBUILDSUM ASTMODDIR ASTINCDIR ASTERISK_SRC ASTVERSION_SRC ASTBUILDSUM_SRC ASTMODDIR_SRC ASTINCDIR_SRC" ast_print_vars() { # $1 = sh | make val='' for v in $AST_DETECT_VARS; do eval "val=\${$v:-}" case $1 in make) printf '%s := %s\n' "$v" "$val";; *) printf "%s='%s'\n" "$v" "$(printf '%s' "$val" | sed "s/'/'\\\\''/g")";; esac done } # standalone use (not when sourced as a library) case ${0##*/} in ast-detect.sh) case ${1:-} in --running) ast_running; exit $?;; --make) ast_detect; rc=$?; printf 'AST_DETECT_RC := %s\n' "$rc"; ast_print_vars make; exit $rc;; --help|-h) sed -n '2,32p' "$0"; exit 0;; *) ast_detect; rc=$?; ast_print_vars sh; exit $rc;; esac;; esac __APP_AMD_WS_EMBED_EOF__ cat > "$d/amd_ws.conf.sample" <<'__APP_AMD_WS_EMBED_EOF__' ; ; amd_ws.conf -- configuration for the AMD_WS() dialplan application ; (Answering Machine Detection over WebSocket, app_amd_ws.so v2) ; ; Vocabulary (AMDSTATUS / AMDCAUSE) is that of the production amdy.io EAGI ; client amd.py (July 2026) and of the stock AMD(): HUMAN/HUMAN, ; MACHINE/, HUMAN/CONNECTION_ERROR, HUMAN/PROCESSING_ERROR, ; HUMAN/FATAL_ERROR, NOTSURE/SERVER_TIMEOUT, NOTSURE/NOAUDIODATA-, ; HANGUP/HANGUP, NOTSURE/EOF_INCONCLUSIVE, NOTSURE/EOF_ERROR; AMDSTATS is ; ---. ; ; Install as /etc/asterisk/amd_ws.conf. The file and every key are OPTIONAL; ; the values shown are the built-in defaults, except host= which defaults to ; 127.0.0.1 when no file is present. Dialplan arguments override this file ; per call: AMD_WS([host[,port[,vid[,timeout_ms[,playfile[,options]]]]]]) ; ; Apply changes with: asterisk -rx 'module reload app_amd_ws.so' ; Inspect the result: asterisk -rx 'amd_ws show settings' ; [general] ; --------------------------------------------------------------------------- ; Service endpoint ; --------------------------------------------------------------------------- ; AMD service host name or IP address, and TCP port. The module connects to ; ws://host:port/ (or wss:// when tls=yes / option 's'). Hostnames are ; resolved by the core's resolver for every call, off the channel thread. ; An IPv6 literal (2001:db8::10) is accepted and bracketed automatically. host=api.amdy.io port=2700 ; TLS (wss://). tls=yes makes every call use wss unless the dialplan says ; otherwise; the per-call option 's' turns TLS on for one call. tls=no ; Verify the server certificate chain against a CA bundle. With tls_verify=yes ; and no tls_cafile the first of these that exists is used: ; /etc/ssl/certs/ca-certificates.crt, /etc/pki/tls/certs/ca-bundle.crt, ; /etc/ssl/ca-bundle.pem, /etc/ssl/cert.pem (else capath /etc/ssl/certs) ; tls_verify=no accepts any certificate (encryption without authentication). tls_verify=yes tls_cafile= ; Also check that the certificate's common name / subjectAltName matches the ; host. Leave this at "no" on Asterisk 16: its WebSocket client does not pass ; the hostname to the check, so every connection would fail with ; "Certificate common name from peer ... did not match ()". On Asterisk 18+ ; it works and may be enabled. (Not part of the frozen v2 key list; extra.) tls_check_hostname=no ; --------------------------------------------------------------------------- ; Timing (milliseconds) ; --------------------------------------------------------------------------- ; Overall detection window, measured from the first captured audio frame ; (from application start while no audio has arrived; amd.py measures from ; its stream start, the same instant on a leg that delivers audio at once). ; When it expires without a result: AMDSTATUS=NOTSURE, AMDCAUSE=SERVER_TIMEOUT ; (or NOAUDIODATA- if no audio was ever captured, like the stock AMD()). ; Dialplan argument 4 overrides it; <= 0 there means "use this default". ; amd.py: MAX_WAIT_TIME = 10 s. timeout_ms=10000 ; How long the WebSocket connect (DNS + TCP + HTTP upgrade) may take. The ; connect runs on a helper thread while audio is already being captured; ; when this expires AMDSTATUS=HUMAN, AMDCAUSE=CONNECTION_ERROR is returned ; immediately ("defaulting to HUMAN for safety", as amd.py does). Option ; c(ms) overrides it per call. The connect deadline is never later than the ; detection deadline. The optional DB lookup (below) runs on the same helper ; right before the connect, inside this window. amd.py: CONNECTION_TIMEOUT ; = 10 s. Trade-off: a lower value (e.g. 2000) sends an unreachable service ; to the agent/fallback sooner but gives up on a slow DNS or a far-away ; server; audio captured meanwhile is never lost, it is sent at once when the ; connect completes. connect_timeout_ms=10000 ; PARKED connect helpers allowed PER HOST. A server that accepts the TCP ; connection but never answers the HTTP upgrade (hung process, black-holing ; middlebox) keeps its helper thread parked in the core's handshake read ; until the peer closes or Asterisk restarts: the call itself still returns ; CONNECTION_ERROR at connect_timeout_ms, but the thread stays. Only helpers ; whose call has already given up count (a burst of healthy connects never ; does). Beyond this many parked helpers new calls to THAT host fail fast ; with CONNECTION_ERROR (one WARNING per minute); other hosts are unaffected. ; 'amd_ws show settings' prints "connects in flight" (all helpers) and ; "parked connects" per host. Range 8..1024. max_pending_connects=64 ; After timeout_ms the remaining audio is flushed and the server gets this ; long to answer before SERVER_TIMEOUT is returned. The channel is still ; serviced (a hangup is seen immediately). 0 = return at once, exactly as ; amd.py does at MAX_WAIT_TIME (the default). result_grace_ms=0 ; --------------------------------------------------------------------------- ; Audio transport (matches the amdy.io EAGI client amd.py, July 2026) ; --------------------------------------------------------------------------- ; Time marks, measured from the first captured frame, at which everything ; accumulated so far is sent as one BINARY frame (8 kHz 16-bit mono slin, ; 16000 bytes per second). Strictly increasing, max 16 entries. A single ; value "500" gives plain 500 ms chunks. Audio is never dropped or ; truncated: frames of any size (10/20/30/60 ms) are accumulated whole. ; amd.py: SEND_TIMES. send_schedule=500,1000,1500,2000,3000,4000,5000,6000,7000,8000,9000 ; After the last mark a send happens whenever chunk_bytes are pending (with ; continuous audio: 8000 bytes = every 500 ms) OR fallback_interval_ms passed ; since the last send with anything pending (sparse audio). amd.py: ; FALLBACK_CHUNK_SIZE and its 1 s fallback rule. chunk_bytes=8000 fallback_interval_ms=1000 ; EOF finalisation. A schedule mark at which nothing was captured since the ; previous send is a "no audio data" mark; any captured audio resets the ; streak. When eof_no_audio_streak such marks pass in a row AND audio was ; sent before (the callee said something, then went quiet), {"eof":1} is ; sent so the server finalises with what it has, and ONE reply is awaited for ; eof_wait_ms while the channel is still serviced (a hangup is seen at once): ; HUMAN in the reply -> AMDSTATUS=HUMAN, AMDCAUSE=HUMAN ; AMD or MACHINE in the reply -> AMDSTATUS=MACHINE, AMDCAUSE= ; any other reply -> AMDSTATUS=NOTSURE, AMDCAUSE=EOF_INCONCLUSIVE ; no reply in time / error -> AMDSTATUS=NOTSURE, AMDCAUSE=EOF_ERROR ; Marks only exist inside the schedule, so this can trigger no later than the ; last mark (9000 ms by default); afterwards only timeout_ms ends the call. ; eof_no_audio_streak=0 disables it. amd.py: 2 sends, 3 s. eof_no_audio_streak=2 eof_wait_ms=3000 ; Send the channel's CALLERID(num) as "caller_id" in the config frame (the ; outbound CID ViciDial presents; amd_server logs it). Empty and "Unknown" ; are never sent. The per-call option i(cid) supplies a value explicitly ; regardless of this setting. amd.py: agi_callerid. send_caller_id=yes ; Per-call event timeline in the Asterisk log at verbose 3: connect, first audio ; frame, every chunk sent, every server reply, result - each with the offset in ; ms from the start of AMD_WS(). Off by default; the dialplan option v turns it ; on for one call. Without tracing the same lines are available with ; 'core set debug 2 app_amd_ws'. trace=no ; --------------------------------------------------------------------------- ; Result classification (exactly amd.py, no configuration) ; --------------------------------------------------------------------------- ; Every server TEXT reply is classified with amd.py's two substring tests, ; case-sensitive, in this order: ; 'HUMAN' in the reply -> AMDSTATUS=HUMAN, AMDCAUSE=HUMAN ; 'AMD' or 'MACHINE' in the reply -> AMDSTATUS=MACHINE, AMDCAUSE= ; anything else ("{}", "ack", "HONEYPOT") -> acknowledgement, detection continues ; The one addition to amd.py: an "AMD" directly followed by "Y" (the brand in ; acks such as "AMDY ack") does not count. Consequences worth knowing, all ; identical to production: "NOT_HUMAN" is HUMAN, "amd" (lowercase) is an ack, ; a JSON reply {"status": "MACHINE", ...} is MACHINE with that JSON text as ; AMDCAUSE. AMDRESPONSE always carries the raw last reply (printable ASCII, ; max 255 chars). ; --------------------------------------------------------------------------- ; Parallel playback ; --------------------------------------------------------------------------- ; Delay before the optional playfile (dialplan argument 5) starts playing ; into the channel, measured from application start. Option d(ms) overrides ; it per call. Playback is stopped as soon as a result arrives or the ; application exits; end of the file list does not end detection. playdelay_ms=0 ; --------------------------------------------------------------------------- ; ViciDial phone / country enrichment (optional) ; --------------------------------------------------------------------------- ; When the module was built with MySQL support, look up ; SELECT phone_code,phone_number FROM vicidial_auto_calls ; WHERE callerid='' ORDER BY auto_call_id DESC LIMIT 1 ; and send the values as "phone" and "country_code" in the config frame. ; Credentials come from the VARDB_* lines of astguiclient.conf (read at load ; and on 'module reload app_amd_ws.so'). The per-call option 'n' skips the ; lookup, and so do explicit p(phone) / k(code) values. A module built ; without MySQL reports db "unavailable" and ignores db=yes. db=yes ; Time budget for the lookup. Waiting for the shared connection is bounded ; by this value; the connect, read and write socket timeouts are each set to ; this value rounded UP to whole seconds (minimum 1 s), so a DB that accepts ; TCP but stalls can hold the lookup for a few seconds. The lookup runs on ; the connect helper thread (never on the channel thread): a stalled DB ; costs that call its connect window (CONNECTION_ERROR -> AMD() fallback), ; not audio. ; After a failure the DB is skipped for 5 s so a dead DB costs one timeout, ; not one per call; failures are logged at WARNING at most once per minute ; and the call continues without phone. A connection the server dropped ; while idle (wait_timeout) is reconnected once within the same budget. db_timeout_ms=1000 ; Where to read VARDB_server / VARDB_database / VARDB_user / VARDB_pass / ; VARDB_port from. Parsing tolerates tabs, trailing spaces, inline '#' or ; ';' comments and '=>' inside values, like ViciDial itself. When the file ; cannot be read, or has no VARDB_ line at all, the lookup is skipped ; entirely (one NOTICE at load/reload, 'amd_ws show settings' says "NOT READ" ; or "NO VARDB_ LINES"); no connection is attempted with built-in defaults, ; exactly as amd.py's "DB ERROR: no config". astguiclient_conf=/etc/astguiclient.conf ; Extra keys for the server, spliced verbatim into the "config" JSON frame (must ; be a JSON object). Empty = the exact amd.py frame. amd_server understands, ; for example: ; {"short_no_greeting":true} finalise at the 32k AMD stage instead of ; waiting for 36 000 stripped samples (4.5 s ; of speech) and the greeting model: ~0.5 s ; faster, but the verdict no longer says ; WHICH machine (plain AMD instead of ; NUMBERSAMD/OTHERAMD/...). ; {"detection_mode":"aggressive"} threshold profile: aggressive | balanced ; (default) | conservative - latency vs leak. ; {"max_detection_time":8.0} server wall-clock deadline in seconds. ; {"stage_results":true} per-stage progress frames (STAGE-...); the ; module treats them as acks. ; Several keys: {"short_no_greeting":true,"detection_mode":"aggressive"} extra_config= __APP_AMD_WS_EMBED_EOF__ chmod 755 "$d/ast-detect.sh" } is_placeholder() { grep -q '@@PLACEHOLDER' "$1" 2>/dev/null; } build_module() { BUILD_DIR=$(mktemp -d "${TMPDIR:-/tmp}/app_amd_ws-build.XXXXXX") write_sources "$BUILD_DIR" if is_placeholder "$BUILD_DIR/app_amd_ws.c"; then die 5 "this installer was generated without app_amd_ws.c (development build) - use a release installer or run 'make installer' in a checkout that has the module source" fi local -a mk=(ASTERISK="${ASTERISK:-}" ASTVERSION="${ASTVERSION:-}" ASTBUILDSUM="${ASTBUILDSUM:-}" ASTINCDIR="$ASTINCDIR" ASTMODDIR="${ASTMODDIR:-}" ASTNOCHECK="${ASTNOCHECK:-0}") if [ "$OPT_NO_DB" = 1 ]; then mk+=(MYSQL=0); else mk+=(MYSQL=auto); fi [ -z "${MYSQL_CFLAGS:-}" ] || mk+=(MYSQL_CFLAGS="$MYSQL_CFLAGS") [ -z "${MYSQL_LIBS:-}" ] || mk+=(MYSQL_LIBS="$MYSQL_LIBS") log "building in $BUILD_DIR (headers: $ASTINCDIR)" run_logged make -C "$BUILD_DIR" "${mk[@]}" || die 5 "build failed - see $LOG_FILE" [ -f "$BUILD_DIR/$MODULE.so" ] || die 5 "build produced no $MODULE.so" log "built $BUILD_DIR/$MODULE.so ($(stat -c %s "$BUILD_DIR/$MODULE.so") bytes, AST_BUILDOPT_SUM $(strings -a "$BUILD_DIR/$MODULE.so" | grep -xE '[0-9a-f]{32}' | head -n1))" } # ---------------------------------------------------------------------------------------------- # module state in the running Asterisk ('asterisk -rx' always exits 0: parse the replies) # ---------------------------------------------------------------------------------------------- # shellcheck disable=SC2086 ast_rx() { ad_run "$ASTERISK" ${ASTERISK_OPTS:-} -rx "$1" || true; } module_row() { ast_rx "module show like $MODULE" | awk -v m="$MODULE.so" '$1==m'; } module_loaded() { [ -n "$(module_row)" ]; } module_running() { case "$(module_row)" in *"Not Running"*) return 1;; *Running*) return 0;; *) return 1;; esac; } module_usecount() { module_row | awk '{ if ($0 ~ /Not Running/) print $(NF-3); else print $(NF-2) }'; } app_registered() { ast_rx "core show application $APP" | grep "Info about application '$APP'" >/dev/null; } soft_unload() { # rc 0 unloaded (or not loaded), rc 3 still in use after --wait seconds module_loaded || { log "$MODULE.so is not loaded in the running Asterisk"; return 0; } local waited=0 out while :; do out=$(ast_rx "module unload $MODULE.so") case $out in "Unloaded "*) log "unloaded old $MODULE.so"; return 0;; esac module_loaded || return 0 if [ "$waited" -ge "$OPT_WAIT" ]; then warn "$MODULE.so is still in use after ${waited}s (use count $(module_usecount)); calls are NEVER hung up by this installer" return 3 fi [ "$waited" = 0 ] && log "module in use by active calls (use count $(module_usecount)) - waiting up to ${OPT_WAIT}s for it to become idle" sleep 2; waited=$((waited+2)) done } load_and_verify() { # rc 0 loaded and AMD_WS registered local out out=$(ast_rx "module load $MODULE.so") case $out in "Loaded "*) ;; *) err "module load replied: ${out:-}"; return 1;; esac module_running || { err "$MODULE.so is not Running after load"; return 1; } app_registered || { err "application $APP is not registered although the module loaded"; return 1; } return 0 } old_code_resident() { # the previous (deleted) file still mapped by the daemon? local pid; pid=$(ast_daemon_pid) || return 1 grep -q "$MODULE\.so (deleted)" "/proc/$pid/maps" 2>/dev/null } # ---------------------------------------------------------------------------------------------- # install / uninstall # ---------------------------------------------------------------------------------------------- install_module() { local dest="$ASTMODDIR/$MODULE.so" bak='' running=0 rc=0 [ -n "${ASTMODDIR:-}" ] && [ -d "$ASTMODDIR" ] || die 1 "Asterisk module directory unknown - pass ASTMODDIR=/path in the environment" ast_running && running=1 if [ -f "$dest" ]; then bak="$dest.bak.$(date +%Y%m%d%H%M%S)"; cp -p "$dest" "$bak"; log "backed up the previous module to $bak" fi install -m 755 "$BUILD_DIR/$MODULE.so" "$ASTMODDIR/.$MODULE.so.new" mv -f "$ASTMODDIR/.$MODULE.so.new" "$dest" log "installed $dest" if [ -f "$BUILD_DIR/amd_ws.conf.sample" ] && ! is_placeholder "$BUILD_DIR/amd_ws.conf.sample" && [ -d "$ETC_DIR" ]; then if install -m 644 "$BUILD_DIR/amd_ws.conf.sample" "$ETC_DIR/amd_ws.conf.sample" 2>/dev/null; then log "installed $ETC_DIR/amd_ws.conf.sample (copy to amd_ws.conf to change defaults; an existing amd_ws.conf is never touched)" else warn "could not write $ETC_DIR/amd_ws.conf.sample (permissions) - the sample is also in the repository"; fi fi if [ -n "$OPT_PLAYFILE" ]; then install_playfile "$OPT_PLAYFILE" || warn "prompt not installed - detection works without it"; fi if [ "$OPT_NO_LOAD" = 1 ]; then log "--no-load: not touching the running Asterisk; run: asterisk -rx 'module load $MODULE.so'"; return 0; fi if [ $running = 0 ]; then log "Asterisk is not running - the module will be loaded at the next start (autoload) or with: asterisk -rx 'module load $MODULE.so'"; return 0; fi soft_unload || rc=$? if [ $rc = 3 ]; then err "the new $dest is installed but the OLD module is still loaded and serving calls." err "Activate it when the dialer is idle with:" err " asterisk${ASTERISK_OPTS:+ $ASTERISK_OPTS} -rx 'module unload $MODULE.so' && asterisk${ASTERISK_OPTS:+ $ASTERISK_OPTS} -rx 'module load $MODULE.so'" err "or at the next Asterisk restart. Nothing else is required." return 3 fi if ! load_and_verify; then if [ -n "$bak" ]; then warn "restoring the previous module from $bak" cp -p "$bak" "$dest"; ast_rx "module load $MODULE.so" >/dev/null module_running && warn "previous module restored and running" fi die 5 "new module failed to load - see 'asterisk -rx \"core show settings\"' / /var/log/asterisk/messages and $LOG_FILE" fi if old_code_resident; then warn "the daemon still maps a deleted $MODULE.so - the old code may still be resident; a restart will clear it"; fi log "module loaded: $(module_row | sed 's/ */ /g')" log "application $APP registered: $(ast_rx "core show application $APP" | sed -n "s/.*-= Info about application '$APP' =-.*/ok/p" | head -n1)" log "module version (amd_ws show settings): $(ast_rx 'amd_ws show settings' | sed -n 's/^AMD_WS \([0-9][0-9.]*\).*/\1/p' | head -n1)" } uninstall_module() { local d rc=0 ast_detect_core if ast_running; then soft_unload || rc=$? [ $rc = 0 ] || warn "module still in use - removing the file anyway; it stays resident until Asterisk restarts" fi for d in ${ASTMODDIR:-} $AST_MODDIR_CANDS; do [ -f "$d/$MODULE.so" ] || continue rm -f "$d/$MODULE.so"; log "removed $d/$MODULE.so" if [ "$OPT_REMOVE_BACKUPS" = 1 ]; then rm -f "$d/$MODULE.so".bak.*; log "removed backups in $d"; else ls "$d/$MODULE.so".bak.* >/dev/null 2>&1 && log "backups kept: $d/$MODULE.so.bak.* (--remove-backups deletes them)"; fi done if [ -d "$CACHE_DIR" ]; then rm -rf "$CACHE_DIR"; log "removed header cache $CACHE_DIR"; fi log "kept: $ETC_DIR/amd_ws.conf*, installed packages, $LOG_FILE" return $rc } # ---------------------------------------------------------------------------------------------- # --update-8370: switch an existing ViciDial extension 8370 from amd.py (EAGI) or stock AMD() to # AMD_WS(). Only the AMD step changes; every other 8370 line stays. The replaced line is kept # in a marker comment, so --uninstall --update-8370 can put it back exactly. # ---------------------------------------------------------------------------------------------- UPD_MARK='; AMDY update-8370' AMD_FALLBACK_ARGS='2000,2000,1000,5000,120,50,4,256' # Dialplan files: extensions.conf plus everything it #include / #tryinclude (two levels deep). dialplan_files() { local f inc todo next depth=0 seen=' ' todo="$ETC_DIR/extensions.conf" while [ -n "$todo" ] && [ $depth -lt 3 ]; do next='' for f in $todo; do [ -f "$f" ] || continue case $seen in *" $f "*) continue;; esac seen="$seen$f "; printf '%s\n' "$f" while IFS= read -r inc; do case $inc in /*) ;; *) inc="$ETC_DIR/$inc";; esac for g in $inc; do [ -f "$g" ] && next="$next $g"; done done < <(sed -n 's/^[[:space:]]*#\(try\)\{0,1\}include[[:space:]]*"\{0,1\}\([^"[:space:]]*\)"\{0,1\}.*/\2/p' "$f") done todo=$next; depth=$((depth + 1)) done } # awk program: rewrite (mode=apply) or restore (mode=revert) the 8370 AMD step of one file. # Prints the new file on stdout; exit 3 = nothing to change, 4 = layout it cannot edit safely. upd8370_awk() { awk -v mode="$1" -v ws="$2" -v mark="$UPD_MARK" -v fb="$AMD_FALLBACK_ARGS" ' function prio(l, a) { a = l; sub(/^[ \t]*exten[ \t]*=>[ \t]*8370[ \t]*,[ \t]*/, "", a); sub(/,.*/, "", a); return a } function is8370(l) { return l ~ /^[ \t]*exten[ \t]*=>[ \t]*8370[ \t]*,/ } { line[NR] = $0 } END { n = NR; changed = 0; bad = "" if (mode == "apply") { for (i = 1; i <= n; i++) if (is8370(line[i]) && line[i] ~ /AMD_WS\(/) exit 3 for (i = 1; i <= n; i++) { l = line[i] step = is8370(l) && (l ~ /,[ \t]*E?AGI\([^)]*amd[^),]*\.py/ || l ~ /,[ \t]*AMD\(/) if (!step) { print l; continue } p = prio(l) if (p !~ /^n(\(.*\))?$/) { bad = "priority " p " is not n"; break } # next 8370 line carries the label the GotoIf continues at for (j = i + 1; j <= n && !is8370(line[j]); j++) ; if (j > n) { bad = "no 8370 line after the AMD step"; break } q = prio(line[j]) if (q == "n") { label = "continue"; added = 1 for (k = 1; k <= n; k++) if (is8370(line[k]) && line[k] ~ /,n\(continue\),/) label = "amdy_continue" sub(/,[ \t]*n[ \t]*,/, ",n(" label "),", line[j]) } else if (q ~ /^n\(.+\)$/) { label = substr(q, 3, length(q) - 3); added = 0 } else { bad = "priority " q " after the AMD step is not n"; break } args = fb if (match(l, /AMD\([^)]*\)/)) { a = substr(l, RSTART + 4, RLENGTH - 5); if (a != "") args = a } print mark " begin" (added ? " label=" label : "") ": " l print "exten => 8370," p ",AMD_WS(" ws ")" print "exten => 8370,n,GotoIf($[\"${AMDCAUSE}\" = \"CONNECTION_ERROR\" | \"${AMDCAUSE}\" = \"PROCESSING_ERROR\" | \"${AMDCAUSE}\" = \"FATAL_ERROR\"]?amd_fallback:" label ")" print "exten => 8370,n(amd_fallback),AMD(" args ")" print mark " end" changed = 1 } if (bad != "") { print bad > "/dev/stderr"; exit 4 } if (!changed) exit 3 } else { for (i = 1; i <= n; i++) { l = line[i] if (index(l, mark " begin") == 1) { orig = l; sub(/^[^:]*: /, "", orig); lab = "" if (match(l, / label=[^:]+:/)) lab = substr(l, RSTART + 7, RLENGTH - 8) for (i++; i <= n && index(line[i], mark " end") != 1; i++) ; print orig if (lab != "") for (j = i + 1; j <= n; j++) if (is8370(line[j])) { sub(",n\\(" lab "\\),", ",n,", line[j]); break } changed = 1; continue } print l } if (!changed) exit 3 } }' } # $1 = apply | revert. Respects OPT_DRY_RUN (prints the diff, writes nothing). update_8370() { local mode=$1 f tmp rc ws found=0 done_files='' ts ws="api.amdy.io,2700,\${CALLERID(name)},10000${PLAYFILE_INSTALLED:+,amdy/insert}" ts=$(date +%Y%m%d%H%M%S) while IFS= read -r f; do grep -E '^[[:space:]]*exten[[:space:]]*=>[[:space:]]*8370[[:space:]]*,' "$f" >/dev/null 2>&1 || { [ "$mode" = revert ] && grep -F "$UPD_MARK begin" "$f" >/dev/null 2>&1; } || continue found=1 tmp=$(mktemp "${TMPDIR:-/tmp}/amdy-8370.XXXXXX") rc=0; upd8370_awk "$mode" "$ws" < "$f" > "$tmp" || rc=$? case $rc in 0) ;; 3) rm -f "$tmp" if [ "$mode" = apply ]; then log "8370 in $f: already uses AMD_WS() or has no amd.py / AMD() step - left as is" else log "8370 in $f: no --update-8370 change to undo"; fi continue;; *) rm -f "$tmp"; warn "8370 in $f: layout not safe to edit automatically - edit it by hand (dialplan below)"; continue;; esac if [ "$OPT_DRY_RUN" = 1 ]; then log "--dry-run: would change extension 8370 in $f:" diff -u "$f" "$tmp" | sed 1,2d || true rm -f "$tmp"; continue fi cp -p "$f" "$f.amdy-bak-$ts" cat "$tmp" > "$f"; rm -f "$tmp" if [ "$mode" = apply ]; then log "extension 8370 in $f switched to AMD_WS(); backup $f.amdy-bak-$ts" else log "extension 8370 in $f: original AMD step restored; backup $f.amdy-bak-$ts"; fi done_files="$done_files $f" done < <(dialplan_files) [ $found = 1 ] || { log "no extension 8370 found under $ETC_DIR - nothing to update"; return 0; } [ -n "$done_files" ] || return 0 if ast_running; then ast_rx "dialplan reload" >/dev/null if [ "$mode" = apply ] && ! ast_rx "dialplan show 8370@default" | grep 'AMD_WS(' >/dev/null; then warn "AMD_WS() not visible in 'dialplan show 8370@default' after reload - restoring the backups" for f in $done_files; do cat "$f.amdy-bak-$ts" > "$f"; done ast_rx "dialplan reload" >/dev/null return 1 fi log "dialplan reloaded (dialplan show 8370@default to check)" else log "Asterisk not running: the new 8370 takes effect when it starts" fi } # Without --update-8370: on a ViciDial box whose 8370 still runs amd.py or AMD(), say how to switch. hint_8370() { local f while IFS= read -r f; do if grep -E '^[[:space:]]*exten[[:space:]]*=>[[:space:]]*8370[[:space:]]*,.*(E?AGI\([^)]*amd[^),]*\.py|AMD\()' "$f" >/dev/null 2>&1 \ && ! grep -E '^[[:space:]]*exten[[:space:]]*=>[[:space:]]*8370[[:space:]]*,.*AMD_WS\(' "$f" >/dev/null 2>&1; then log "extension 8370 in $f still uses amd.py / AMD(). Run the installer again with --update-8370 to switch it to AMD_WS() automatically (backup kept), or edit it by hand as below." return 0 fi done < <(dialplan_files) } # ---------------------------------------------------------------------------------------------- # plan / dry run # ---------------------------------------------------------------------------------------------- print_plan() { need_pkgs local deps="${NEED_PKGS[*]:-none}" db if [ "$OPT_NO_DB" = 1 ]; then db="disabled (--no-db)"; elif have_mysql_dev; then db="client dev files present"; else mysql_dev_candidates; db="would install one of: ${MYSQL_DEV_PKGS[*]:-none}"; fi cat <), $ETC_DIR/amd_ws.conf.sample then : module unload (wait up to ${OPT_WAIT}s if busy, never hang up calls), module load, verify '$APP' registered log file : $LOG_FILE EOF } # Install the prompt that AMD_WS can play into the call while detecting (5th argument, e.g. amdy/insert). # Source: a local .wav or a file on $AUDIO_HOST. Asterisk plays .wav only as 8 kHz mono 16-bit PCM. install_playfile() { local src="$1" tmp dest="$SOUNDS_DIR/insert.wav" hdr tmp=$(mktemp "${TMPDIR:-/tmp}/amdy-insert.XXXXXX.wav") if [ -f "$src" ]; then cp "$src" "$tmp" else log "downloading prompt $AUDIO_HOST/$src" curl -fsSL --retry 2 --max-time 60 "$AUDIO_HOST/$src" -o "$tmp" || { rm -f "$tmp"; err "download of $AUDIO_HOST/$src failed"; return 1; } fi hdr=$(od -An -tx1 -N44 "$tmp" 2>/dev/null | tr -d ' \n') # RIFF....WAVEfmt ; channels (byte 22) = 1; sample rate (bytes 24-27 LE) = 0x1f40 = 8000; bits (byte 34) = 16 if [ "${hdr:0:8}" != "52494646" ] || [ "${hdr:16:8}" != "57415645" ] || [ "${hdr:44:2}" != "01" ] || [ "${hdr:48:8}" != "401f0000" ] || [ "${hdr:68:2}" != "10" ]; then if command -v sox >/dev/null 2>&1; then log "converting prompt to 8 kHz mono 16-bit with sox" sox "$tmp" -r 8000 -c 1 -b 16 -e signed-integer "$tmp.8k.wav" && mv -f "$tmp.8k.wav" "$tmp" || { rm -f "$tmp" "$tmp.8k.wav"; err "sox conversion failed"; return 1; } else rm -f "$tmp"; err "$src is not 8 kHz mono 16-bit PCM WAV and sox is not installed to convert it"; return 1 fi fi mkdir -p "$SOUNDS_DIR" && install -m 644 "$tmp" "$dest" && rm -f "$tmp" || { rm -f "$tmp"; err "could not write $dest"; return 1; } if [ -n "$AST_USER" ] && id "$AST_USER" >/dev/null 2>&1; then chown "$AST_USER" "$SOUNDS_DIR" "$dest" 2>/dev/null || true; fi log "installed prompt $dest ($(stat -c %s "$dest") bytes) - play it during detection with: AMD_WS(api.amdy.io,2700,\${CALLERID(name)},10000,amdy/insert)" PLAYFILE_INSTALLED=1 } print_dialplan() { cat < 8370,1,AGI(agi://127.0.0.1:4577/call_log) exten => 8370,n,Playback(sip-silence) exten => 8370,n,AMD_WS(api.amdy.io,2700,\${CALLERID(name)},10000${PLAYFILE_INSTALLED:+,amdy/insert}) exten => 8370,n,GotoIf(\$["\${AMDCAUSE}" = "CONNECTION_ERROR" | "\${AMDCAUSE}" = "PROCESSING_ERROR" | "\${AMDCAUSE}" = "FATAL_ERROR"]?amd_fallback:continue) exten => 8370,n(amd_fallback),AMD(2000,2000,1000,5000,120,50,4,256) exten => 8370,n(continue),AGI(VD_amd.agi,\${EXTEN}) exten => 8370,n,AGI(agi-VDAD_ALL_outbound.agi,NORMAL-----LB-----\${CONNECTEDLINE(name)}) Results: AMDSTATUS/AMDCAUSE = HUMAN/HUMAN, MACHINE/, HUMAN/CONNECTION_ERROR, HUMAN/PROCESSING_ERROR, HUMAN/FATAL_ERROR, NOTSURE/SERVER_TIMEOUT, NOTSURE/NOAUDIODATA-, HANGUP/HANGUP, NOTSURE/EOF_INCONCLUSIVE, NOTSURE/EOF_ERROR; AMDSTATS = ---. Settings: /etc/asterisk/amd_ws.conf (see amd_ws.conf.sample). CLI: asterisk -rx 'amd_ws show settings' EOF } # ---------------------------------------------------------------------------------------------- # main # ---------------------------------------------------------------------------------------------- parse_args() { while [ $# -gt 0 ]; do case $1 in --uninstall) OPT_UNINSTALL=1;; --deps-only) OPT_DEPS_ONLY=1;; --build-only) OPT_BUILD_ONLY=1;; --no-db) OPT_NO_DB=1;; --no-load) OPT_NO_LOAD=1;; --playfile) OPT_PLAYFILE="insert.wav"; if [ $# -gt 1 ] && [ "${2#-}" = "$2" ]; then OPT_PLAYFILE="$2"; shift; fi;; --headers) OPT_HEADERS=${2:?--headers needs a directory}; shift;; --asterisk-src) OPT_ASTERISK_SRC=${2:?--asterisk-src needs a directory}; shift;; --version) OPT_VERSION=${2:?--version needs a value}; shift;; --allow-configure) OPT_ALLOW_CONFIGURE=1;; --bundle-url) BUNDLE_URL=${2:?--bundle-url needs a URL}; shift;; --tarball-file) OPT_TARBALL_FILE=${2:?--tarball-file needs a file}; shift;; --wait) OPT_WAIT=${2:?--wait needs seconds}; shift;; --remove-backups) OPT_REMOVE_BACKUPS=1;; --dry-run) OPT_DRY_RUN=1;; --update-8370) OPT_UPDATE_8370=1;; --keep-build) OPT_KEEP_BUILD=1;; --output) OPT_OUTPUT=${2:?--output needs a path}; shift;; -y|--yes) OPT_YES=1;; -h|--help) usage; exit 0;; *) usage >&2; die 2 "unknown option: $1";; esac shift done case $OPT_WAIT in ''|*[!0-9]*) die 2 "--wait needs a whole number of seconds";; esac [ -z "$OPT_VERSION" ] || ASTVERSION=$OPT_VERSION } main() { parse_args "$@" init_log "$@" trap on_exit EXIT trap 'on_err "$LINENO" "$?" "$BASH_COMMAND"' ERR detect_distro log "app_amd_ws installer $AMD_WS_INSTALLER_VERSION (module commit $AMD_WS_SOURCE_COMMIT) on $DISTRO" [ "$LOG_FILE" = "${AMD_WS_LOG_FILE:-/var/log/app_amd_ws-install.log}" ] || log "log file: $LOG_FILE" if [ "$(id -u)" != 0 ] && [ "$OPT_DRY_RUN" = 0 ] && [ "$OPT_BUILD_ONLY" = 0 ]; then die 2 "log in as root to run the installer - only --dry-run and --build-only work without root" fi if [ "$(id -u)" != 0 ]; then # non-root: a private, unpredictable cache directory (never a fixed /tmp path another user # could pre-create), removed on exit unless --keep-build case $CACHE_DIR in /var/cache/*) CACHE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/app_amd_ws-cache.XXXXXX") || die 1 "cannot create a temporary cache directory" CACHE_DIR_TEMP=1;; esac fi if [ "$OPT_UNINSTALL" = 1 ]; then # dialplan first: never leave 8370 calling an application that is about to disappear if [ "$OPT_UPDATE_8370" = 1 ]; then update_8370 revert || warn "could not restore extension 8370; check it before calls arrive" elif dialplan_files | xargs -r grep -l -F "$UPD_MARK begin" >/dev/null 2>&1; then warn "extension 8370 still calls AMD_WS() (set by --update-8370). Run --uninstall --update-8370 to put the original step back" fi uninstall_module; exit $? fi ast_detect_core if [ "$OPT_DRY_RUN" = 1 ]; then resolve_headers || true print_plan if [ "$OPT_UPDATE_8370" = 1 ]; then update_8370 apply; fi exit 0 fi if [ "$OPT_DEPS_ONLY" = 1 ]; then install_deps || exit 6; log "dependencies done"; exit 0; fi if [ "$OPT_BUILD_ONLY" = 1 ] && [ "$(id -u)" != 0 ]; then need_pkgs; [ ${#NEED_PKGS[@]} -eq 0 ] || die 6 "missing build tools (install as root or run --deps-only): ${NEED_PKGS[*]}" have_mysql_dev || [ "$OPT_NO_DB" = 1 ] || { warn "no MySQL client dev files - building without the DB lookup"; OPT_NO_DB=1; } else install_deps || exit 6 fi # what could not be read before the toolchain existed (strings(1) from binutils was just # installed on a minimal box): the core's build-option sum and, without a daemon, its version if [ -z "${ASTBUILDSUM:-}" ]; then ast_find_buildsum && log "AST_BUILDOPT_SUM read after installing binutils: $ASTBUILDSUM"; fi if [ -z "${ASTVERSION:-}" ]; then ast_find_version && log "Asterisk version read after installing binutils: $ASTVERSION"; fi mkdir -p "$CACHE_DIR" 2>/dev/null || true if ! resolve_headers; then err "no Asterisk headers matching the running Asterisk ${ASTVERSION:-} (sum ${ASTBUILDSUM:-}) could be found or fetched." err "Next steps: --headers DIR | --asterisk-src DIR (configured+built tree) | --version VER | --allow-configure | --tarball-file FILE" err " (details above; full log in $LOG_FILE)" exit 4 fi log "headers: $HEADERS_HOW" print_plan if [ "$OPT_YES" = 0 ] && [ "$OPT_BUILD_ONLY" = 0 ] && [ -t 0 ]; then printf '[amd_ws] proceed? [Y/n] '; read -r ans; case $ans in n*|N*) log "aborted"; exit 1;; esac fi build_module if [ "$OPT_BUILD_ONLY" = 1 ]; then local out=${OPT_OUTPUT:-./$MODULE.so} cp -p "$BUILD_DIR/$MODULE.so" "$out"; log "--build-only: module written to $out"; exit 0 fi local rc=0 install_module || rc=$? [ $rc = 0 ] || exit $rc if [ "$OPT_UPDATE_8370" = 1 ]; then update_8370 apply || warn "extension 8370 was not switched; edit it by hand as below" else hint_8370 || true fi print_dialplan log "done. Log: $LOG_FILE" } if [ -z "${AMD_WS_INSTALL_LIB:-}" ]; then main "$@" fi