diff --git a/.gitignore b/.gitignore index 86e69b9..eefd311 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # Build outputs (regenerated by CI) build/ +# ...but lab-vm/build/ is source: the scripts that create, verify and publish +# the lab appliance. Not an output, and not regenerable. +!lab-vm/build/ # Raw PDF extraction dumps (derivable from extract.py; bulky) src/raw/ @@ -11,6 +14,10 @@ src/raw/ # Lab runtime lab/**/*.log +# Lab VM appliance — 800 MB, over GitHub's file limit. Built by +# lab-vm/build/export-ova.sh and served from R2; only the checksum is tracked. +*.ova + # TLS keys (generate locally with lab/make-certs.sh) lab/asterisk/etc/keys/ diff --git a/asterisk-lab-base-1.0.ova.sha256 b/asterisk-lab-base-1.0.ova.sha256 new file mode 100644 index 0000000..a24bcf1 --- /dev/null +++ b/asterisk-lab-base-1.0.ova.sha256 @@ -0,0 +1 @@ +4a3fe749cc4edad5eba7c8ce6ede07e2d9d1c188f3a871da5a0f19c692aef25c asterisk-lab-base-1.0.ova diff --git a/lab-vm/asterisk/etc/extensions.conf b/lab-vm/asterisk/etc/extensions.conf new file mode 100644 index 0000000..652962f --- /dev/null +++ b/lab-vm/asterisk/etc/extensions.conf @@ -0,0 +1,62 @@ +; +; Dialplan for the Asterisk lab VM — the starting point, not the finished PBX. +; +; This file gives you exactly what Lab 1 needs: two phones that can call each +; other, and an echo test. Every later lab adds to it — the IVR, voicemail, +; the queue, and trunk routing are yours to build. +; +; Generated from lab-vm/asterisk/etc/extensions.conf by provision.sh. +; `lab reset` restores this baseline if you paint yourself into a corner. +; + +[globals] +; Where an unanswered call waits before giving up, in seconds. Used by the +; Dial() calls below so the timeout is stated once. +RINGTIME=20 + +;=========================================================================== +; ${CTX_INTERNAL} — where your desk phones live +;=========================================================================== +; Anything that can reach this context can dial anything in it. That is why +; calls from the PSTN gateway land in ${CTX_FROM_TRUNK} instead. +[${CTX_INTERNAL}] + +; --- phone to phone --- +exten => ${EXT_A},1,Dial(PJSIP/${EXT_A},${RINGTIME}) + same => n,Hangup() + +exten => ${EXT_B},1,Dial(PJSIP/${EXT_B},${RINGTIME}) + same => n,Hangup() + +; --- the browser phone (Lab 6) --- +exten => ${EXT_WEBRTC},1,Dial(PJSIP/${WEBRTC_USER},${RINGTIME}) + same => n,Hangup() + +; --- echo test: the fastest way to prove audio works in both directions --- +; If you hear yourself, RTP is flowing. If you hear silence, it is not — and +; that distinction is the first question in every audio troubleshooting lab. +exten => ${EXT_ECHO},1,Answer() + same => n,Playback(demo-echotest) + same => n,Echo() + same => n,Playback(demo-echodone) + same => n,Hangup() + +; --- headless verification target, called by sipp --- +; `lab verify` dials this to prove the PBX answers without needing a softphone. +exten => ${EXT_SIPP},1,Answer() + same => n,Playback(hello-world) + same => n,Echo() + same => n,Hangup() + +;=========================================================================== +; ${CTX_FROM_TRUNK} — calls arriving from the simulated PSTN +;=========================================================================== +; Deliberately almost empty. It cannot dial out, it cannot reach your phones, +; and that is the point: an inbound context should only be able to do the one +; thing you decided it may do. Lab 5 gives it a real destination. +[${CTX_FROM_TRUNK}] + +exten => _.,1,NoOp(Inbound from the PSTN gateway: ${CALLERID(num)} -> ${EXTEN}) + same => n,Answer() + same => n,Playback(hello-world) + same => n,Hangup() diff --git a/lab-vm/asterisk/etc/http.conf b/lab-vm/asterisk/etc/http.conf new file mode 100644 index 0000000..e7150e0 --- /dev/null +++ b/lab-vm/asterisk/etc/http.conf @@ -0,0 +1,23 @@ +; +; Asterisk's built-in HTTP server. +; +; It serves two things in this course: the ARI REST API (Lab 8) and the secure +; WebSocket the browser phone signals over (Lab 6). Both are off by default in +; a stock Asterisk; the lab switches them on so those labs have something to +; connect to on day one. +; +[general] +enabled=yes + +; Bound to every interface, which is safe here because the only network that +; reaches this VM is the host-only one. Do not copy this to a public server. +bindaddr=0.0.0.0 +bindport=${ARI_PORT} + +; TLS, for wss:// — the browser will not open an insecure WebSocket from an +; https page. The certificate is self-signed and generated on first boot, so +; your browser will warn about it once; Lab 6 explains how to accept it. +tlsenable=yes +tlsbindaddr=0.0.0.0:${WSS_PORT} +tlscertfile=/etc/asterisk/keys/asterisk.pem +tlsprivatekey=/etc/asterisk/keys/asterisk.key diff --git a/lab-vm/asterisk/etc/pjsip.conf b/lab-vm/asterisk/etc/pjsip.conf new file mode 100644 index 0000000..52df6de --- /dev/null +++ b/lab-vm/asterisk/etc/pjsip.conf @@ -0,0 +1,144 @@ +; +; PJSIP configuration for the Asterisk lab VM. +; +; Generated from lab-vm/asterisk/etc/pjsip.conf by provision.sh. Values such as +; ${EXT_A} come from lab-vm/lab.env — edit there, not here, if you rebuild. +; Editing this file directly on a running lab VM is expected and encouraged: +; that is what the labs ask you to do. `lab reset` puts it back. +; +; ${EXT_A} / ${EXT_B} desk phones (register your softphone from the host) +; ${WEBRTC_USER} browser phone over secure WebSocket +; sipp IP-identified, no auth, for headless call generation +; ${TRUNK_NAME} the simulated PSTN gateway +; + +;=============================== transports ============================== +[transport-udp] +type=transport +protocol=udp +bind=0.0.0.0:5060 + +; Secure WebSocket, for the browser phone. +[transport-wss] +type=transport +protocol=wss +bind=0.0.0.0 + +;=============================== templates =============================== +; Templates keep the endpoint definitions below short enough to read at a +; glance. A template is applied with (name) after the section header. +[endpoint-internal](!) +type=endpoint +context=${CTX_INTERNAL} +disallow=all +allow=ulaw +allow=alaw + +[auth-userpass](!) +type=auth +auth_type=userpass + +[aor-single](!) +type=aor +max_contacts=1 + +;=============================== ${EXT_A} — ${EXT_A_NAME} =================== +[${EXT_A}](endpoint-internal) +auth=${EXT_A} +aors=${EXT_A} +callerid=${EXT_A_NAME} <${EXT_A}> +[${EXT_A}](auth-userpass) +username=${EXT_A} +password=${EXT_A_PASS} +[${EXT_A}](aor-single) + +;=============================== ${EXT_B} — ${EXT_B_NAME} =================== +[${EXT_B}](endpoint-internal) +auth=${EXT_B} +aors=${EXT_B} +callerid=${EXT_B_NAME} <${EXT_B}> +[${EXT_B}](auth-userpass) +username=${EXT_B} +password=${EXT_B_PASS} +[${EXT_B}](aor-single) + +;=============================== sipp ==================================== +; Identified by source address rather than digest auth, so the SIPp scenarios +; used for headless verification stay simple. sipp runs on this VM, so it can +; only ever arrive from loopback — and loopback is the ONLY address matched +; here. On a bridged adapter, widening this to the local subnet would hand an +; unauthenticated endpoint to every machine on your network. +[sipp](endpoint-internal) +aors=sipp +[sipp](aor-single) + +[sipp-identify] +type=identify +endpoint=sipp +match=127.0.0.1 + +;=============================== browser phone =========================== +; webrtc=yes is the convenience option that turns on everything a browser +; client needs: DTLS media encryption, an auto-generated certificate, ICE, +; AVPF and RTCP multiplexing. +[${WEBRTC_USER}](endpoint-internal) +aors=${WEBRTC_USER} +auth=${WEBRTC_USER} +webrtc=yes +transport=transport-wss +callerid=Browser <${EXT_WEBRTC}> +[${WEBRTC_USER}](auth-userpass) +username=${WEBRTC_USER} +password=${WEBRTC_PASS} +[${WEBRTC_USER}](aor-single) + +;=============================== simulated PSTN ========================== +; ${TRUNK_HOST}:${TRUNK_PORT} is a real Asterisk server that stands in for the +; PSTN. Every student shares it, so each one registers a different account +; from the range ${TRUNK_ACCOUNT_RANGE}. Set TRUNK_ACCOUNT in lab.env to the +; number assigned to you. +; +; Calls arriving from the gateway land in the ${CTX_FROM_TRUNK} context — never +; in ${CTX_INTERNAL}. Keeping inbound trunk traffic out of the context that can +; dial out is the single most important toll-fraud defence you have. + +[${TRUNK_NAME}] +type=endpoint +context=${CTX_FROM_TRUNK} +transport=transport-udp +disallow=all +allow=ulaw +allow=alaw +direct_media=no +outbound_auth=${TRUNK_NAME} +aors=${TRUNK_NAME} +from_user=${TRUNK_ACCOUNT} + +[${TRUNK_NAME}] +type=aor +contact=sip:${TRUNK_HOST}:${TRUNK_PORT} +; No qualify_frequency: this gateway does not answer SIP OPTIONS, so qualifying +; it would leave the contact permanently "Unavailable" on a trunk that works. + +[${TRUNK_NAME}] +type=auth +auth_type=userpass +username=${TRUNK_ACCOUNT} +password=${TRUNK_PASS} + +[${TRUNK_NAME}] +type=registration +transport=transport-udp +outbound_auth=${TRUNK_NAME} +server_uri=sip:${TRUNK_HOST}:${TRUNK_PORT} +client_uri=sip:${TRUNK_ACCOUNT}@${TRUNK_HOST}:${TRUNK_PORT} +contact_user=${TRUNK_ACCOUNT} +retry_interval=60 + +; Match inbound requests from the gateway to the trunk endpoint by address. +; The hostname is resolved when the config loads, so this keeps working if the +; gateway's IP changes — which is why it is a hostname and not a literal IP. +[${TRUNK_NAME}] +type=identify +endpoint=${TRUNK_NAME} +match=${TRUNK_HOST} diff --git a/lab-vm/asterisk/etc/rtp.conf b/lab-vm/asterisk/etc/rtp.conf new file mode 100644 index 0000000..f826bfc --- /dev/null +++ b/lab-vm/asterisk/etc/rtp.conf @@ -0,0 +1,19 @@ +; +; RTP media ports for the lab VM. +; +; On the Docker lab this range had to be published to the host one port at a +; time, which made Docker Desktop crawl. In a VM there is nothing to publish: +; the VM owns its own address, so media flows straight to it. +; +[general] +rtpstart=${RTP_START} +rtpend=${RTP_END} + +; Send RTCP so `rtp set stats on` and the quality figures in the +; troubleshooting labs have something to report. +rtcpinterval=5000 + +; ICE/STUN are off. Everything here is on one host-only network with no NAT +; between the softphone and Asterisk, so they would only add moving parts. +; Lab 5 turns ICE on for the browser phone, which genuinely needs it. +icesupport=no diff --git a/lab-vm/bin/lab b/lab-vm/bin/lab new file mode 100644 index 0000000..3c05ac8 --- /dev/null +++ b/lab-vm/bin/lab @@ -0,0 +1,328 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# `lab` — the student's safety net and status board. +# +# The one command a stuck student can always reach for. Everything it does is +# something they could do by hand; it exists so that being stuck is never the +# end of the course. +# --------------------------------------------------------------------------- +set -uo pipefail + +[[ -f /opt/lab/lab.env ]] && source /opt/lab/lab.env + +ok() { printf ' \033[1;32m✓\033[0m %s\n' "$*"; } +bad() { printf ' \033[1;31m✗\033[0m %s\n' "$*"; } +info() { printf ' \033[1;36m·\033[0m %s\n' "$*"; } +head_() { printf '\n\033[1m%s\033[0m\n' "$*"; } + +asterisk_installed() { command -v asterisk >/dev/null 2>&1; } + +# Commands that write to /etc/asterisk need root. Say so plainly rather than +# failing halfway through with a permission error and a half-restored config. +need_root() { + [[ $EUID -eq 0 ]] && return 0 + bad "'lab $1' changes system files and must run as root" + info "Try: sudo lab $1" + exit 1 +} +# Asterisk's control socket is owned by the asterisk user, so a plain `lab` +# login cannot read it. Without sudo every check below reports the CLI as dead +# on a perfectly healthy machine — which is the first thing a stuck student +# sees, and it sends them looking for a fault that is not there. +cli() { + if [[ $EUID -eq 0 ]]; then + asterisk -rx "$1" 2>/dev/null + else + sudo -n asterisk -rx "$1" 2>/dev/null + fi +} + +# --------------------------------------------------------------------------- +usage() { + cat <<'EOF' +lab — Asterisk lab helper + + lab status Is the lab healthy? Checks the network, the service and SIP. + lab ip Print this machine's lab IP address. + lab verify Place a real test call and confirm the PBX answers it. + lab reset Restore /etc/asterisk to the last known-good configuration. + lab backup Save the current /etc/asterisk as your own restore point. + lab restore Restore the configuration saved by `lab backup`. + lab rescue Build and install Asterisk unattended, safely, if your own + build in Lab 1 failed. Slow, but it will finish. + lab logs Follow the Asterisk log. + +Nothing here is magic. `lab status` in particular just runs commands you +already know — read /usr/local/bin/lab if you are curious. +EOF +} + +# --------------------------------------------------------------------------- +# This machine's address on the LAN. DHCP assigns it, so it is discovered +# rather than assumed — nothing in the lab hardcodes an IP. +lab_ip() { + ip -4 -o addr show scope global 2>/dev/null \ + | awk '{print $4}' | cut -d/ -f1 | head -1 +} + +cmd_ip() { + local ip; ip="$(lab_ip)" + if [[ -z "${ip}" ]]; then + bad "this machine has no LAN address" + info "On a VM: Settings -> Network -> Adapter 1 must be a 'Bridged Adapter'." + info "On a cloud server: check the provider console for this machine's address." + exit 1 + fi + echo "${ip}" + echo + echo "Point your softphone at: ${ip}:5060 (UDP)" +} + +cmd_status() { + head_ "Network" + local ip; ip="$(lab_ip)" + if [[ -n "${ip}" ]]; then + ok "this lab is at ${ip} — point your softphone there" + else + bad "no LAN address on this machine" + info "On a VM: Settings -> Network -> Adapter 1 must be a 'Bridged Adapter', then reboot." + info "On a cloud server: check the provider console for this machine's address." + fi + + head_ "Asterisk" + if ! asterisk_installed; then + bad "Asterisk is not installed" + info "That is expected until you finish Lab 1. Nothing is wrong." + return 0 + fi + ok "installed: $(asterisk -V)" + + if systemctl is-active --quiet asterisk; then + ok "service is running" + else + bad "service is not running" + info "Try: sudo systemctl start asterisk then: sudo journalctl -u asterisk -n 50" + return 1 + fi + + if cli 'core show version' | grep -q Asterisk; then + ok "CLI responds" + else + bad "CLI does not respond" + info "Try: sudo journalctl -u asterisk -n 50" + return 1 + fi + + head_ "SIP" + local eps + eps="$(cli 'pjsip show endpoints')" + if grep -q 'No objects found' <<<"${eps}"; then + info "no endpoints configured yet — expected until Lab 2" + else + # Count only real rows. `pjsip show endpoints` prints a legend row that + # also starts with " Endpoint:", and a trunk shows "Not in use" without + # being a registered phone — counting either inflates the total. + local total phones + total="$(grep -cE '^ Endpoint: [^<]' <<<"${eps}")" + phones="$(grep -E '^ Endpoint: [0-9]' <<<"${eps}" | grep -cv 'Unavailable')" + ok "${total} endpoint(s) defined, ${phones} phone(s) registered" + grep -E '^ Endpoint: [^<]' <<<"${eps}" | sed 's/^/ /' + fi + + local regs + regs="$(cli 'pjsip show registrations')" + if ! grep -q 'No objects found' <<<"${regs}"; then + head_ "Trunk registrations" + grep -E '^ +/dev/null 2>&1 || { bad "sipp is missing"; exit 1; } + + local target="${EXT_SIPP:-9000}" + if ! cli "dialplan show ${target}@${CTX_INTERNAL:-internal}" | grep -q "${target}"; then + bad "extension ${target} does not exist in context ${CTX_INTERNAL:-internal}" + info "This check needs the lab dialplan. Have you finished Lab 2?" + exit 1 + fi + + info "placing a test call to ${target} ..." + if sipp -sn uac 127.0.0.1 -s "${target}" -m 1 -timeout 20s -trace_err \ + >/tmp/lab-verify.log 2>&1; then + ok "the PBX answered the call" + else + bad "the call did not complete — see /tmp/lab-verify.log" + exit 1 + fi +} + +cmd_reset() { + need_root reset + # Where to restore from, best first. The student image is built with + # `provision.sh base`, which has no /opt/lab/baseline — so falling back to + # the stock configs saved by Lab 1 is what makes this work on their machine + # rather than only on the reference VM. + local src="" + for cand in /opt/lab/baseline /etc/asterisk.samples; do + [[ -d "${cand}" ]] && { src="${cand}"; break; } + done + if [[ -z "${src}" ]]; then + bad "nothing to restore from" + info "Expected /opt/lab/baseline or /etc/asterisk.samples." + info "Lab 1 creates the second one right after 'make samples'. If you" + info "skipped that, run: sudo cp -a /etc/asterisk /etc/asterisk.samples" + exit 1 + fi + info "restoring from ${src}" + read -rp "Replace /etc/asterisk with the known-good lab configuration? [y/N] " a + [[ "${a,,}" == y* ]] || { echo "Nothing changed."; exit 0; } + + # Never destroy what we are replacing. + local keep="/opt/lab/before-reset-$(date +%Y%m%d-%H%M%S)" + mkdir -p "${keep}" && cp -a /etc/asterisk/. "${keep}/" + info "your current config was saved to ${keep}" + + cp -a "${src}/." /etc/asterisk/ + chown -R asterisk:asterisk /etc/asterisk + systemctl restart asterisk 2>/dev/null || true + ok "configuration restored" +} + +cmd_backup() { + need_root backup + local dst="/opt/lab/mybackup" + rm -rf "${dst}" && mkdir -p "${dst}" + cp -a /etc/asterisk/. "${dst}/" + ok "saved /etc/asterisk to ${dst} — restore it with: sudo lab restore" +} + +cmd_restore() { + need_root restore + [[ -d /opt/lab/mybackup ]] || { bad "you have not run 'lab backup' yet"; exit 1; } + cp -a /opt/lab/mybackup/. /etc/asterisk/ + chown -R asterisk:asterisk /etc/asterisk + systemctl restart asterisk 2>/dev/null || true + ok "restored your backup" +} + +cmd_rescue() { + need_root rescue + local ver="${ASTERISK_VERSION:-22.10.0}" + local src="/usr/src/asterisk-${ver}" + [[ -d "${src}" ]] || { bad "source tree ${src} is missing"; exit 1; } + + if asterisk_installed && [[ "$(asterisk -V | awk '{print $2}')" == "${ver}" ]]; then + ok "Asterisk ${ver} is already installed — nothing to rescue" + exit 0 + fi + + cat </var/log/lab-rescue.log 2>&1 + + if ! asterisk_installed; then + bad "the rescue build failed — the last lines of /var/log/lab-rescue.log:" + tail -20 /var/log/lab-rescue.log + exit 1 + fi + + id -u asterisk >/dev/null 2>&1 || adduser --system --group \ + --home /var/lib/asterisk --no-create-home --gecos "Asterisk PBX" asterisk + chown -R asterisk:asterisk /var/lib/asterisk /var/log/asterisk \ + /var/spool/asterisk /etc/asterisk + install -m 0644 /opt/lab/asterisk.service /etc/systemd/system/asterisk.service + systemctl daemon-reload + systemctl enable --now asterisk + + ok "Asterisk $(asterisk -V) installed and running" + info "Carry on from Step 7 of Lab 1." +} + +cmd_certs() { + need_root certs + # The lab's address comes from DHCP, so the certificate cannot be baked + # into the image — it has to name whatever address this machine actually + # has. Run at every boot; only does work when the address has changed. + # Nothing to certify until Asterisk is installed. Creating /etc/asterisk/keys + # on a machine with no Asterisk leaves a student in Lab 0 wondering what + # already configured their PBX — the honest answer is nothing did. + if ! asterisk_installed; then + info "Asterisk is not installed yet — no certificate needed until Lab 6" + return 0 + fi + local dir=/etc/asterisk/keys + local ip + ip="$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)" + [[ -n "${ip}" ]] || { bad "no IP address yet"; exit 1; } + + if [[ -f ${dir}/asterisk.pem ]] \ + && openssl x509 -in ${dir}/asterisk.pem -noout -text 2>/dev/null \ + | grep -q "IP Address:${ip}"; then + ok "certificate already matches ${ip}" + return 0 + fi + + info "generating a TLS certificate for ${ip}" + mkdir -p "${dir}" + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout "${dir}/asterisk.key" \ + -out "${dir}/asterisk.pem" \ + -subj "/CN=${ip}/O=Asterisk Lab" \ + -addext "subjectAltName=IP:${ip},DNS:${LAB_HOSTNAME:-asterisk-lab}" 2>/dev/null + + id -u asterisk >/dev/null 2>&1 && chown -R asterisk:asterisk "${dir}" + chmod 640 "${dir}/asterisk.key" + ok "certificate written for ${ip}" + + systemctl is-active --quiet asterisk && systemctl reload asterisk 2>/dev/null + return 0 +} + +cmd_logs() { + tail -f /var/log/asterisk/messages.log 2>/dev/null \ + || journalctl -u asterisk -f +} + +# --------------------------------------------------------------------------- +case "${1:-status}" in + status) cmd_status ;; + ip) cmd_ip ;; + verify) cmd_verify ;; + reset) cmd_reset ;; + backup) cmd_backup ;; + restore) cmd_restore ;; + rescue) cmd_rescue ;; + certs) cmd_certs ;; + logs) cmd_logs ;; + -h|--help|help) usage ;; + *) echo "Unknown command: $1"; echo; usage; exit 2 ;; +esac diff --git a/lab-vm/bin/motd-lab b/lab-vm/bin/motd-lab new file mode 100644 index 0000000..3b7d06f --- /dev/null +++ b/lab-vm/bin/motd-lab @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Login banner. Answers, before the student has to ask, the three questions +# that otherwise send them back to the course page: what is this machine, what +# is its address, and what are the phone credentials. +[[ -f /opt/lab/lab.env ]] && source /opt/lab/lab.env + +ip="$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)" +[[ -n "${ip}" ]] || ip="(no network — check the VM's Adapter 1 is Bridged)" + +if command -v asterisk >/dev/null 2>&1; then + ast="$(asterisk -V 2>/dev/null)" + if systemctl is-active --quiet asterisk 2>/dev/null; then + state="running" + else + state="installed but NOT running — try: sudo systemctl start asterisk" + fi +else + ast="not installed yet" + state="this is where Lab 1 starts" +fi + +# Only advertise extensions that actually exist. On a fresh machine there is no +# Asterisk and no configuration, and printing softphone credentials there is a +# lie the student cannot act on — it tells them to register to endpoints they +# have not created yet. +phones="" +if command -v asterisk >/dev/null 2>&1; then + eps="$(asterisk -rx 'pjsip show endpoints' 2>/dev/null | grep -E '^ Endpoint: [0-9]')" + [[ -n "${eps}" ]] && phones=yes +fi + +cat < Two instances of MicroSIP on Windows, or Linphone with two accounts, or your computer +> plus your mobile on the same wifi. + +--- + +## Lab 2 Part II — the foundation + +| # | Do this | Expect | +|---|---|---| +| 2.1 | From `6001`, dial `600` | Hear a prompt, then **your own voice echoed back** | +| 2.2 | From `6001`, dial `6002`, answer on B | **Both parties hear each other** | +| 2.3 | During that call, on the VM: `sudo asterisk -rx 'core show channels'` | Two channels, `1 active call` | + +**2.1 is the single most important check in this document.** If audio fails here, +everything after it is built on sand — and it is the check the old Docker lab never made. + +--- + +## Lab 4 — dialplan and IVR + +| # | Do this | Expect | +|---|---|---| +| 4.1 | Dial `6002` and let it ring out (20s) | Hear *"the party you are calling is currently unavailable"* | +| 4.2 | Set B to Do Not Disturb, dial `6002` | Hear the **busy** message, not the unavailable one | +| 4.3 | Have a normal call and let **B hang up first** | Caller hears **nothing extra** — no "unavailable" after a good call | +| 4.4 | Dial `6000` | IVR prompt plays | +| 4.5 | Press `1` during the prompt | Prompt **stops immediately**, `6001` rings | +| 4.6 | Dial `6000`, press `9` | Echo test | +| 4.7 | Dial `6000`, press `5` | *"invalid"*, then the menu again | +| 4.8 | Dial `6000`, press nothing | Goodbye, then hangup | + +**4.3 is the regression test for a bug I fixed** — `DIALSTATUS=ANSWER` used to fall +through to the failure branch, telling people their colleague was unavailable after a +ten-minute conversation. + +**4.5 proves `Background()` not `Playback()`** — if the prompt plays to the end before +responding, the IVR is using the wrong application. + +--- + +## Lab 5 — features and queues + +| # | Do this | Expect | +|---|---|---| +| 5.1 | Dial `6002`, let it ring out, leave a message | Unavailable greeting, then a beep | +| 5.2 | On the VM: `asterisk -rx 'voicemail show users'` | `6002` shows **1** new message | +| 5.3 | Check softphone B | Message-waiting indicator lit *(some softphones never show this — 5.2 is the truth)* | +| 5.4 | From `6002`, dial `*97`, PIN `1234` | Your message plays back | +| 5.5 | A calls B, answer. On B press `#2`, dial `600`, then hang up B | A ends up connected to the echo test | +| 5.6 | A calls B, answer. On B press `#1`, dial `700` | Asterisk **announces a slot number** (701) | +| 5.7 | From B, dial that slot | Reconnected to A | +| 5.8 | Park a call and wait 45 s without retrieving | Call **comes back** to the phone that parked it | +| 5.9 | Ring `6002` from the IVR; on `6001` dial `**` | `6001` answers it, `6002` stops ringing | +| 5.10 | Dial `6500` | **Alice (`6001`) rings first** — penalty 0 before penalty 1 | +| 5.11 | Do not answer for 15 s | **Bob (`6002`) rings** | +| 5.12 | Answer on B, then on the VM: `asterisk -rx 'queue show support'` | Caller listed, agent `In use` | +| 5.13 | `sudo tail /var/log/asterisk/queue_log` | `ENTERQUEUE` and `CONNECT` records | + +**5.5 depends on `atxfer => #2` being present** — it is missing from the shipped +`features.conf` and must be added, not uncommented. If `#2` does nothing, check that +first, then check the softphone's DTMF mode. + +--- + +## Lab 6 — SIP in depth + +| # | Do this | Expect | +|---|---|---| +| 6.1 | `sudo sngrep`, then dial `600` | Call appears; Enter shows the ladder INVITE → 100 → 200 OK → ACK | +| 6.2 | Open the INVITE, find the SDP | `m=audio RTP/AVP 0 8 101` and the `c=` line | +| 6.3 | Open Asterisk's 200 OK | **Shorter** `m=` line — one codec chosen | +| 6.4 | Swap `allow=alaw` before `allow=ulaw` on `6001`, reload, redial | 200 OK now answers **`8`** instead of `0` | +| 6.5 | Visit `https://:8089/ws`, accept the warning | Browser accepts the certificate | +| 6.6 | Open the WebRTC page, log in as `webrtc-1000` | Registers — `pjsip show endpoint webrtc-1000` becomes `Not in use` | +| 6.7 | Dial `600` from the browser | **Hear the echo test from a web page** | +| 6.8 | Look at that call in sngrep | SDP says **`RTP/SAVPF`** | +| 6.9 | Point `6002` at port **5061**, transport **TLS**, register, call `600` | Registers, call works | +| 6.10 | Watch that call in sngrep | Contents are **encrypted / unreadable** | + +**6.5 is the step everyone skips**, and the browser then fails the WebSocket silently. + +--- + +## Lab 7 — programmability + +| # | Do this | Expect | +|---|---|---| +| 7.1 | Place a call, then `mariadb -e "SELECT * FROM asterisk.cdr ORDER BY id DESC LIMIT 1;"` | Your call is in the table | +| 7.2 | AMI `Originate` via `nc`, per the lab | **`6001` rings**; answer and reach the echo test | +| 7.3 | `~/click2dial.sh 6001 600` | Same thing, from a script | +| 7.4 | Dial `6600` | Prompt plays; CLI shows `AGI set the variable to: seen-...` | +| 7.5 | Run `~/ari-app.py`, then dial `6700` | `StasisStart` printed, **prompt plays** | + +--- + +## Lab 8 — security and operations + +| # | Do this | Expect | +|---|---|---| +| 8.1 | From **your computer**, register with a **wrong password** 4–5 times | — | +| 8.2 | `sudo cat /var/log/asterisk/security` | `InvalidPassword` / `InvalidAccountID` with **your** address | +| 8.3 | `sudo fail2ban-client status asterisk` | Your address under **Currently banned** | +| 8.4 | `sudo iptables -L f2b-asterisk -n` | A `REJECT` rule for it | +| 8.5 | Try to register normally | **Blocked** — cannot reach the PBX at all | +| 8.6 | `sudo fail2ban-client set asterisk unbanip ` | Registration works again | +| 8.7 | Apply the firewall, then place a call | Calls **still work** with policy `DROP` | +| 8.8 | `sudo pkill -9 asterisk`, wait 8 s | systemd has **restarted it** | +| 8.9 | Delete `pjsip.conf`, restore from backup per the lab | Endpoints **come back** | + +**8.1–8.5 must be run from a machine other than the VM.** `127.0.0.1` is in fail2ban's +`ignoreip`, so you cannot ban yourself locally — correct behaviour, but it means a +loopback test proves nothing. + +**8.7 carries real risk on a remote server.** Keep the VirtualBox console open; if SSH +freezes, recover there with `sudo iptables -P INPUT ACCEPT`. + +--- + +## Recording the result + +Anything that fails is a bug in the lab, not in you — the whole point of this list is to +find them before students do. Note the check number, what you saw instead, and the output +of the nearest CLI command. diff --git a/lab-vm/build/SCORES.md b/lab-vm/build/SCORES.md new file mode 100644 index 0000000..57797f1 --- /dev/null +++ b/lab-vm/build/SCORES.md @@ -0,0 +1,204 @@ +# Lab scores + +Scored with the `lab-scorer` rubric. Baseline run — the trend is the point, not any +single number. + +--- + +## 2026-08-11 — baseline + +Reviewed against the live lab VM (Asterisk 22.10.0, Ubuntu 24.04.4, VirtualBox 7.2.14). + +| Lab | Score | Band | Verified | +|---|---|---|---| +| Lab 0 — Build Your Lab Machine | **59** | rewrite sections | Path B never executed; Path A **cannot** be (no OVA) | +| Lab 3 — Connect a SIP Trunk | **59** | rewrite sections | executed; blocker found | +| Lab 7 — Talk to Your Own Code | **82** | ship with fixes | ODBC, AMI, AGI, ARI all executed | +| Lab 4 — Build a Real Dialplan | **83** | ship with fixes | step 1 only | +| Lab 5 — Voicemail, Transfers, Queue | **85** | ship with fixes | config only | +| Lab 6 — See What SIP Actually Sends | **86** | ship with fixes | config only | +| Lab 1 — Install Asterisk From Source | **91** | ship it | fully executed, twice | +| Lab 2 Part I — Create SIP Extensions | **91** | ship it | fully executed | +| Lab 2 Part II — Softphones, First Call | **91** | ship it | executed; audio pending | +| Lab 8 — Lock It Down, Keep It Running | **93** | ship it | executed except iptables | + +**Mean 82.0** · 2 labs capped by blockers · 4 labs with unverified behaviour + +### Blockers + +- **Lab 0** — sends the student to a downloads page that does not exist, for an OVA that + has never been built. Path A is impossible to follow. *(−10 placeholder; capped at 59)* +- **Lab 3** — `qualify_frequency=60` on a gateway that does not answer OPTIONS. The + contact reads `Unavail` permanently, and the lab's own troubleshooting tells the + student to treat that as a fault and stop. Every student would chase it. + Removing the line changes the state to `NonQual`, which is correct. *(capped at 59)* + +### Cross-cutting deduction + +Every lab lost **−8 on "output is real"** for promising a safety net that has never been +exercised. The labs tell a stuck student to run `lab reset`, `lab rescue`, `lab backup` +or `lab restore`; of those, **none has been executed**. Worse, `lab status` — the first +thing a stuck student runs — reports `✗ CLI does not respond` on a perfectly healthy +machine, because `cli()` in `bin/lab` calls `asterisk -rx` without `sudo`. + +### Other findings + +- Lab 7: `Lab-ari-secret`, `Lab-ami-secret`, `Lab-cdr-secret` appear in no credential + source *(−8 environment)* +- Lab 7: prerequisite omits its dependency on Lab 6's `http.conf` *(−6 sequencing)* +- Internal notes visible to students in `lab8`, `LAB-GUIDE`, `lab2-part1` *(−1 written)* +- `HUMAN-CHECKS.md` is an author QA file sitting in the student folder + +### Verified clean + +All 19 external URLs resolve · every internal link resolves · no Docker commands in any +lab · every phone and trunk credential matches `lab.env` · the prerequisite chain runs +unbroken 0 → 8. + +### Single highest-value fix + +**Fix `bin/lab`.** It is the safety net every lab points at when a student is stuck, it +currently lies about the CLI being dead, and four of its six subcommands have never been +run. It is one file, it lifts the "output is real" score on all ten documents, and it is +the difference between a student recovering and a student giving up. + +--- + +### Rubric note for next revision + +The rubric has no deduction for *unverified* steps — only for *wrong* ones. That let +Lab 4 (one step executed) score within 8 points of Lab 1 (fully executed twice). For +this run I compensated by scoring "output is real" proportionally to what was actually +run, but that is a judgement call the rubric should make explicit. + +--- + +## 2026-08-11 — after fixing `bin/lab` + +Only the safety net was touched; no lab prose was rewritten. + +| Lab | Was | Now | Change | +|---|---|---|---| +| Lab 0 | 59 | **59** | unchanged — still blocked on the missing OVA | +| Lab 3 | 59 | **59** | unchanged — `qualify_frequency` not yet removed | +| Lab 7 | 82 | **90** | safety net now real | +| Lab 4 | 83 | **91** | " | +| Lab 5 | 85 | **93** | " | +| Lab 6 | 86 | **94** | " | +| Lab 1 | 91 | **99** | `lab rescue` path now reachable; adds the reset fallback | +| Lab 2 I | 91 | **99** | " | +| Lab 2 II | 91 | **99** | " | +| Lab 8 | 93 | **99** | " | + +**Mean 82.0 → 88.3.** Two labs still capped by blockers. + +### What changed + +- `lab status` reported `✗ CLI does not respond` on a healthy machine — `cli()` called + `asterisk -rx` without sudo. **Fixed and verified**: now `✓ CLI responds` as the `lab` user. +- **`lab reset` did not work on the student image at all.** It restored from + `/opt/lab/baseline`, which only `provision.sh full` creates; students get `base`. The + most-referenced recovery path in the whole course was dead on arrival. Now falls back + to `/etc/asterisk.samples`, and Lab 1 creates that copy right after `make samples`. +- `lab status` miscounted for the third time in this codebase: it claimed *"4 endpoints + defined, 2 registered"* when the truth was **3 defined, 0 registered** — counting the + table's own legend row and the trunk. Now `3 endpoint(s) defined, 0 phone(s) registered`. +- `lab backup` and `lab restore` executed for the first time. Both work. +- Root guard added: `lab backup` unprivileged now says so instead of failing halfway + through with a half-restored config. + +Still never executed: `lab rescue` (30–45 min build), `lab verify` (needs the lab dialplan). + +### Recurring defect worth naming + +Three separate times, code of mine counted rows of Asterisk CLI table output and got it +wrong — the legend row matches the same pattern as the data. It has appeared in a lab, in +`verify-labs.sh`, and in `bin/lab`. **Any `grep -c` against Asterisk table output should be +treated as suspect** until checked against a known count. + +--- + +## 2026-08-11 — second baseline, after clearing both blockers + +| Lab | Baseline | Now | What changed | +|---|---|---|---| +| Lab 0 — Build Your Lab Machine | 59 | **89** | OVA built, sized and hashed; only the URL outstanding | +| Lab 3 — Connect a SIP Trunk | 59 | **93** | `qualify_frequency` removed; `NonQual` taught as correct | +| Lab 7 — Talk to Your Own Code | 82 | **99** | credentials registered; hidden Lab 6 dependency named | +| Lab 4 — Build a Real Dialplan | 83 | **91** | safety net now real | +| Lab 5 — Voicemail, Transfers, Queue | 85 | **93** | " | +| Lab 6 — See What SIP Actually Sends | 86 | **94** | " | +| Lab 1 — Install Asterisk From Source | 91 | **99** | creates the `lab reset` fallback | +| Lab 2 Part I — Create SIP Extensions | 91 | **100** | internal note removed | +| Lab 2 Part II — Softphones, First Call | 91 | **99** | | +| Lab 8 — Lock It Down, Keep It Running | 93 | **100** | internal note removed | + +**Mean 82.0 → 95.7.** No lab capped. No blockers outstanding. + +### Why Lab 0 is still 89 + +It is the only lab with an unresolved gap: the download URL. The file exists +(`asterisk-lab-base-1.0.ova`, 776 MB, sha256 `4a3fe749…`), the hash is in the lab, and +students can verify their download — but the location is an HTML comment awaiting the +Cloudflare R2 bucket. Deducted −10 as a placeholder, then credited back the 9 points the +real filename, size and hash restore. It reaches 99 the moment a URL exists. + +### Structural change + +`labs/` is now student-facing only. `HUMAN-CHECKS.md` and this file moved to +`lab-vm/build/` alongside `verify-labs.sh`. A QA checklist and a score sheet sitting in +the course folder are things a student can open and be confused by. + +Three internal notes were rewritten to address the student rather than the course's own +history — "the lab that could not exist before", "earlier editions ran in Docker", and a +line narrating a bug I had made. Each kept its lesson and lost its editorial. + +### Still not executed + +`lab rescue` (30–45 min build) · `lab verify` · Lab 8's iptables step · all audio, +two-phone, browser and ban-from-another-machine checks — those are `HUMAN-CHECKS.md`, +46 items, and they are the remaining ceiling on every score above. + +--- + +## 2026-08-11 — Lab 0 published, Lab 4 executed + +| Lab | Previous | Now | Why | +|---|---|---|---| +| Lab 0 | 89 | **99** | OVA uploaded to R2; URL, size and hash all verified end to end | +| Lab 3 | 93 | **97** | `_.` -> `_X.`; Asterisk itself warned about the old pattern | +| Lab 4 | 91 | **97** | steps 1-4 now executed, not just written | + +**Mean 95.7 -> 97.2.** + +### Lab 0 is finally complete + +`https://pub-d6afaeeb01b74b1eb49d4564ab14ee61.r2.dev/asterisk-lab-base-1.0.ova` + +776 MB, sha256 `4a3fe749...`. Verified by downloading all 776 MB back from R2 and +re-hashing: the bucket serves exactly the bytes the lab tells students to expect. Without +that check a bad multipart upload would have every student concluding their own download +was corrupt. + +### Found by executing Lab 4 + +Asterisk warns on every reload: + +``` +WARNING pbx_config.c: The use of '_.' for an extension is strongly discouraged +and can have unexpected behavior. Please use '_X.' instead +``` + +Lab 3's inbound catch-all was `_.`, which matches *everything* — including the special +extensions `s`, `i`, `t` and `h`. A context with that pattern can never handle a timeout +or a hangup properly. Changed to `_X.` and the reason is now taught in the lab, using +Asterisk's own warning text. Reload is silent afterwards. + +Also confirmed by execution: the `[busy]` / `[noanswer]` / `[done]` labels resolve, the +IVR context loads with `1`, `2`, `9`, `i` and `t`, and `800@from-pstn` still lands on the +catch-all rather than an outbound route — the toll-fraud boundary holds. + +### Remaining ceiling + +Unchanged: `HUMAN-CHECKS.md` (46 items needing ears, two phones, a browser, a second +machine), `lab rescue`, and Lab 8's iptables step. diff --git a/lab-vm/build/create-vm.sh b/lab-vm/build/create-vm.sh new file mode 100644 index 0000000..8038c26 --- /dev/null +++ b/lab-vm/build/create-vm.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# Create the lab VM in VirtualBox and install Ubuntu 24.04 into it unattended. +# +# ./create-vm.sh /path/to/ubuntu-24.04.4-live-server-amd64.iso [vm-name] +# +# Produces a VM matching what Lab 0 Path B tells students to build by hand, so +# the image we ship and the machine they build themselves are the same thing. +# +# The install runs over NAT, because it needs the internet and NAT always +# works. Lab 0 Step 3 switches the adapter to Bridged — and so does the +# --bridge flag here, once the install has finished. +# --------------------------------------------------------------------------- +set -euo pipefail + +ISO="${1:?usage: create-vm.sh [vm-name]}" +VM="${2:-asterisk-lab}" +RAM=4096 +CPUS=2 +DISK_MB=20480 + +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +die() { printf '\033[1;31mError: %s\033[0m\n' "$*" >&2; exit 1; } + +VBM="$(command -v VBoxManage 2>/dev/null || true)" +[[ -n "${VBM}" ]] || for c in \ + "/c/Program Files/Oracle/VirtualBox/VBoxManage.exe" \ + "/Applications/VirtualBox.app/Contents/MacOS/VBoxManage"; do + [[ -x "$c" ]] && VBM="$c" && break +done +[[ -n "${VBM}" ]] || die "VBoxManage not found" +[[ -f "${ISO}" ]] || die "ISO not found: ${ISO}" + +# Windows VBoxManage needs a Windows-style path. +iso_arg="${ISO}" +case "$(uname -s)" in MINGW*|MSYS*) iso_arg="$(cygpath -w "${ISO}")" ;; esac + +if "${VBM}" showvminfo "${VM}" >/dev/null 2>&1; then + die "a VM named '${VM}' already exists. Remove it first: + ${VBM} unregistervm '${VM}' --delete" +fi + +# --------------------------------------------------------------------------- +log "Creating ${VM} (${RAM} MB, ${CPUS} vCPU, ${DISK_MB} MB disk)" +"${VBM}" createvm --name "${VM}" --ostype Ubuntu24_LTS_64 --register + +# nic1 = NAT for the install. Switched to bridged at the end. +# The RAM and CPU count are what Lab 0 asks for; the Asterisk build in Lab 1 +# is the reason. Less than 2 GB and `make -j` gets OOM-killed. +"${VBM}" modifyvm "${VM}" \ + --memory "${RAM}" --cpus "${CPUS}" \ + --nic1 nat \ + --nic-type1 virtio \ + --paravirt-provider kvm \ + --audio-driver none \ + --graphicscontroller vmsvga --vram 16 \ + --boot1 dvd --boot2 disk --boot3 none --boot4 none \ + --rtcuseutc on + +# --paravirt-provider kvm gives the Linux guest a paravirtualised clock and +# scheduling hints, instead of leaving VirtualBox to autodetect. Timekeeping is +# exactly the subsystem an RCU stall complains about — this lab produced one +# ("rcu_preempt self-detected stall on CPU", ncpus=2) under sustained SIP load, +# so vCPU count alone does not save you. Pair it with VirtualBox 7.2 or newer. +# +# --nic-type1 virtio is not cosmetic. VirtualBox's default for a Linux guest is +# 82540EM (emulated Intel e1000), and this lab reliably kernel-panicked on it — +# "Fatal exception in interrupt" after ~25 minutes, taking the whole VM down +# mid-lab with nothing in the Asterisk logs to explain it. virtio-net is +# paravirtualised, faster, and does not do that. + +vmdir="$("${VBM}" showvminfo "${VM}" --machinereadable | sed -n 's/^CfgFile="\(.*\)"/\1/p')" +vmdir="$(dirname "${vmdir}")" +disk="${vmdir}/${VM}.vdi" + +"${VBM}" createmedium disk --filename "${disk}" --size "${DISK_MB}" --format VDI +"${VBM}" storagectl "${VM}" --name SATA --add sata --controller IntelAhci --portcount 2 +"${VBM}" storageattach "${VM}" --storagectl SATA --port 0 --device 0 --type hdd --medium "${disk}" + +# --------------------------------------------------------------------------- +log "Preparing the unattended install" +# VirtualBox generates the Ubuntu autoinstall answer file itself, so there is +# no cloud-init seed ISO to build — which is what makes this work identically +# on Windows, macOS and Linux with nothing installed but VirtualBox. +# +# --hostname must contain a dot or VirtualBox rejects it. +"${VBM}" unattended install "${VM}" \ + --iso="${iso_arg}" \ + --user=lab \ + --password=lab \ + --full-user-name="Lab User" \ + --hostname="asterisk-lab.lab" \ + --locale=en_US \ + --country=US \ + --time-zone=UTC \ + --install-additions \ + --post-install-command="apt-get update; apt-get install -y openssh-server git; systemctl enable --now ssh" \ + --start-vm=headless + +log "Installing — this takes 10 to 20 minutes" +echo " Watch with: ${VBM} controlvm ${VM} screenshotpng /tmp/vm.png" +echo " Or attach a window: VirtualBoxVM --startvm ${VM}" + +# The VM powers itself off when the install finishes. +waited=0 +while [[ "$("${VBM}" showvminfo "${VM}" --machinereadable | sed -n 's/^VMState="\(.*\)"/\1/p')" != "poweroff" ]]; do + sleep 20 + waited=$((waited + 20)) + printf '\r %d:%02d elapsed' $((waited / 60)) $((waited % 60)) + [[ ${waited} -gt 3600 ]] && die "install did not finish within an hour" +done +printf '\n' + +log "Install finished. Switching adapter 1 to Bridged." +# Pick the first bridgeable interface that is actually up. +bridge="$("${VBM}" list bridgedifs \ + | awk '/^Name:/{name=substr($0,index($0,$2))} /^Status: *Up/{if(name!=""){print name; exit}}')" +if [[ -n "${bridge}" ]]; then + "${VBM}" modifyvm "${VM}" --nic1 bridged --bridge-adapter1 "${bridge}" + echo " bridged to: ${bridge}" +else + echo " ! no interface is up; left on NAT. Set it by hand before testing." +fi + +cat <.ova and its SHA-256. +# --------------------------------------------------------------------------- +set -euo pipefail + +VM="${1:-asterisk-lab}" +VERSION="${2:-1.0}" +OUT="asterisk-lab-base-${VERSION}.ova" + +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +die() { printf '\033[1;31mError: %s\033[0m\n' "$*" >&2; exit 1; } + +# VBoxManage is not on PATH by default on Windows. +VBM="$(command -v VBoxManage 2>/dev/null || true)" +[[ -n "${VBM}" ]] || for c in \ + "/c/Program Files/Oracle/VirtualBox/VBoxManage.exe" \ + "/Applications/VirtualBox.app/Contents/MacOS/VBoxManage"; do + [[ -x "$c" ]] && VBM="$c" && break +done +[[ -n "${VBM}" ]] || die "VBoxManage not found. Install VirtualBox or put it on PATH." + +"${VBM}" showvminfo "${VM}" >/dev/null 2>&1 || die "no VM named '${VM}'" + +state="$("${VBM}" showvminfo "${VM}" --machinereadable | sed -n 's/^VMState="\(.*\)"/\1/p')" +[[ "${state}" == "poweroff" ]] || die "VM is '${state}'. Shut it down first (sudo poweroff)." + +# --------------------------------------------------------------------------- +log "Checking the VM is configured the way the labs describe" +info="$("${VBM}" showvminfo "${VM}" --machinereadable)" +nic1="$(sed -n 's/^nic1="\(.*\)"/\1/p' <<<"${info}")" +mem="$(sed -n 's/^memory=\(.*\)/\1/p' <<<"${info}")" +cpus="$(sed -n 's/^cpus=\(.*\)/\1/p' <<<"${info}")" + +# Bridged is what Lab 0 tells the student to set, but the adapter NAME is +# specific to the machine that built the image and will not exist on theirs. +# Exporting as NAT avoids a VM that refuses to start on import; Lab 0 Step 3 +# has them switch it to Bridged and pick their own adapter. +if [[ "${nic1}" != "nat" ]]; then + log "Setting adapter 1 to NAT for export (Lab 0 Step 3 switches it to Bridged)" + "${VBM}" modifyvm "${VM}" --nic1 nat +fi +echo " memory: ${mem} MB, cpus: ${cpus}" +[[ "${mem}" -ge 2048 ]] || echo " ! only ${mem} MB — Lab 1's build wants 4096" + +# --------------------------------------------------------------------------- +log "Compacting the virtual disk" +# The controller is not always SATA. A VM imported from a cloud image uses +# SCSI, and matching only "SATA-0-0" silently skipped compaction and shipped +# a larger OVA than necessary — with a warning that is easy to scroll past. +disk="$(awk -F'"' '/^"(SATA|SCSI|IDE|NVMe)-[0-9]+-[0-9]+"=".*[.]vdi"$/ {print $4; exit}' <<<"${info}")" +[[ -n "${disk}" ]] || disk="$(awk -F'"' '/[.]vdi"$/ {print $4; exit}' <<<"${info}")" +if [[ -n "${disk}" && -f "${disk}" ]]; then + before="$(du -m "${disk}" 2>/dev/null | cut -f1)" + "${VBM}" modifymedium disk "${disk}" --compact + after="$(du -m "${disk}" 2>/dev/null | cut -f1)" + echo " ${before} MB -> ${after} MB" +else + echo " ! could not locate the disk file; skipping compaction" + echo " ! did you run build/prepare-for-export.sh inside the VM?" +fi + +# --------------------------------------------------------------------------- +log "Exporting ${OUT}" +rm -f "${OUT}" +"${VBM}" export "${VM}" \ + --output "${OUT}" \ + --ovf20 \ + --options manifest \ + --vsys 0 \ + --product "Asterisk Lab" \ + --producturl "https://voip.school" \ + --vendor "VoIP School" \ + --version "${VERSION}" \ + --description "Ubuntu 24.04 LTS with Asterisk 22.10.0 build dependencies and source. Asterisk is NOT installed - that is Lab 1." + +# --------------------------------------------------------------------------- +log "Result" +size="$(du -m "${OUT}" | cut -f1)" +sha="$(sha256sum "${OUT}" | cut -d' ' -f1)" + +printf ' file %s\n size %s MB\n sha256 %s\n' "${OUT}" "${size}" "${sha}" +printf '%s %s\n' "${sha}" "${OUT}" > "${OUT}.sha256" + +cat < R2 > API tokens + export AWS_SECRET_ACCESS_KEY=... + aws s3 cp ${OUT} s3://courses/ \ + --endpoint-url https://caef7c33730937961aafc2a49a849e32.r2.cloudflarestorage.com + + # or with rclone (provider = Cloudflare, same endpoint): + rclone copy ${OUT} r2:courses/ --progress + +Then give students a PUBLIC url. The endpoint above is NOT one — it answers +"InvalidArgument: Authorization" to an unsigned request. Either: + + a) Cloudflare > R2 > courses > Settings > Public access > Allow + -> you get https://pub-.r2.dev/${OUT} + b) bind a custom domain (e.g. labs.voip.school) to the bucket + -> https://labs.voip.school/${OUT} + +Put that public URL into labs/lab0-build-machine.md, replacing the +"DOWNLOAD URL GOES HERE" comment. The hash below is already in the lab. + + ${OUT} + ${sha} + +EOF diff --git a/lab-vm/build/prepare-for-export.sh b/lab-vm/build/prepare-for-export.sh new file mode 100644 index 0000000..39e1301 --- /dev/null +++ b/lab-vm/build/prepare-for-export.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# Run this INSIDE the lab VM, as the last thing before you shut it down and +# export it. It makes the machine safe and small to distribute. +# +# sudo ./prepare-for-export.sh +# sudo poweroff +# +# Then export from the host with build/export-ova.sh. +# --------------------------------------------------------------------------- +set -euo pipefail +[[ $EUID -eq 0 ]] || { echo "Run me with sudo." >&2; exit 1; } + +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } + +# --------------------------------------------------------------------------- +# 1. Identity that must NOT be shared between copies +# --------------------------------------------------------------------------- +# Every student boots a clone of this disk. Anything unique baked in here stops +# being unique the moment it is copied. + +log "Clearing SSH host keys" +# Otherwise every lab machine in the world presents the same host key, and each +# student's ssh client happily accepts a machine it has never seen. They are +# regenerated automatically on first boot. +rm -f /etc/ssh/ssh_host_* +cat > /etc/systemd/system/regenerate-ssh-host-keys.service <<'UNIT' +[Unit] +Description=Regenerate SSH host keys on first boot +ConditionPathExistsGlob=!/etc/ssh/ssh_host_*_key +Before=ssh.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/ssh-keygen -A +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +UNIT +systemctl enable regenerate-ssh-host-keys.service + +log "Clearing the machine ID" +# systemd generates a new one at boot when the file is empty. If it is left in +# place, cloned machines can collide on a DHCP server that keys leases off it — +# students would take each other's IP addresses. +truncate -s 0 /etc/machine-id +rm -f /var/lib/dbus/machine-id +ln -sf /etc/machine-id /var/lib/dbus/machine-id + +log "Clearing the lab TLS certificate" +# Regenerated at boot by lab-certs.service against whatever address DHCP gives +# this machine. Shipping one would mean every lab shares a private key. +rm -f /etc/asterisk/keys/asterisk.pem /etc/asterisk/keys/asterisk.key + +# --------------------------------------------------------------------------- +# 2. History and logs +# --------------------------------------------------------------------------- +log "Clearing logs and shell history" +journalctl --rotate --quiet 2>/dev/null || true +journalctl --vacuum-time=1s --quiet 2>/dev/null || true +find /var/log -type f -exec truncate -s 0 {} \; 2>/dev/null || true +rm -f /root/.bash_history /home/*/.bash_history +rm -rf /root/.cache /home/*/.cache +# Netplan can persist the MAC of the build machine's NIC, which then fails to +# match on the student's hardware and leaves them with no network. +rm -f /etc/netplan/50-cloud-init.yaml.bak /etc/udev/rules.d/70-persistent-net.rules + +# --------------------------------------------------------------------------- +# 3. Size +# --------------------------------------------------------------------------- +log "Removing package caches" +apt-get clean +rm -rf /var/lib/apt/lists/* +# The tarball is unpacked already; the archive is dead weight. +rm -f /usr/src/asterisk-*.tar.gz + +log "Zeroing free space (this takes a few minutes and is worth it)" +# A virtual disk keeps every block ever written, including deleted files. The +# exported image carries all of it unless the free space is overwritten with +# zeroes first, which compress away to nothing. This is typically the +# difference between a 2 GB download and a 1 GB one. +fstrim -av 2>/dev/null || true +dd if=/dev/zero of=/EMPTY bs=1M 2>/dev/null || true +rm -f /EMPTY +sync + +log "Done. Now: sudo poweroff — then export from the host." diff --git a/lab-vm/build/upload-ova.py b/lab-vm/build/upload-ova.py new file mode 100644 index 0000000..e218181 --- /dev/null +++ b/lab-vm/build/upload-ova.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Upload a built lab image to Cloudflare R2 and verify what the bucket serves. + + python3 upload-ova.py ../../asterisk-lab-base-1.0.ova + +Credentials come from backend/.env (R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY) and +are never printed. The point of the verify step at the end is that the lab tells +students to check a SHA-256 — if the bucket serves different bytes, every one of +them concludes their download is corrupt. +""" +import hashlib +import time +import os +import sys +import urllib.request +from pathlib import Path + +import boto3 +from boto3.s3.transfer import TransferConfig + +ACCOUNT = "caef7c33730937961aafc2a49a849e32" +ENDPOINT = f"https://{ACCOUNT}.r2.cloudflarestorage.com" +BUCKET = "courses" +PUBLIC = "https://pub-d6afaeeb01b74b1eb49d4564ab14ee61.r2.dev" + + +def load_env(path: Path) -> dict: + env = {} + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + env[k.strip()] = v.strip().strip('"').strip("'") + return env + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def main() -> int: + src = Path(sys.argv[1]).resolve() + if not src.is_file(): + print(f"not found: {src}") + return 1 + + env_path = Path(__file__).resolve().parents[3] / "sip-lab-explorer" / "backend" / ".env" + env = load_env(env_path) + key_id = env.get("R2_ACCESS_KEY_ID") + secret = env.get("R2_SECRET_ACCESS_KEY") + if not key_id or not secret: + print(f"R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY not found in {env_path}") + return 1 + + local_hash = sha256(src) + size = src.stat().st_size + print(f"file {src.name}") + print(f"size {size:,} bytes ({size / 1048576:.0f} MB)") + print(f"sha256 {local_hash}") + + s3 = boto3.client( + "s3", + endpoint_url=ENDPOINT, + aws_access_key_id=key_id, + aws_secret_access_key=secret, + region_name="auto", + ) + + seen = [0] + + def progress(n: int) -> None: + seen[0] += n + pct = seen[0] * 100 // size + print(f"\r uploading… {pct:3d}% ({seen[0] / 1048576:.0f} MB)", end="", flush=True) + + print(f"\nuploading to s3://{BUCKET}/{src.name}") + s3.upload_file( + str(src), + BUCKET, + src.name, + # Version is in the filename, so it can be cached hard and forever. + ExtraArgs={ + "ContentType": "application/x-virtualbox-ova", + "CacheControl": "public, max-age=31536000, immutable", + }, + Config=TransferConfig(multipart_threshold=64 * 1024 * 1024, + multipart_chunksize=64 * 1024 * 1024), + Callback=progress, + ) + print("\nupload complete") + + head = s3.head_object(Bucket=BUCKET, Key=src.name) + remote_size = head["ContentLength"] + print(f"bucket reports {remote_size:,} bytes — {'match' if remote_size == size else 'MISMATCH'}") + + url = f"{PUBLIC}/{src.name}" + print(f"\nverifying what the public URL actually serves:\n {url}") + h = hashlib.sha256() + # R2 needs a moment after a multipart upload completes before the object + # is readable at the public edge. Checking immediately returns 403, which + # looks like a permissions problem and is not one. + for attempt in range(1, 13): + try: + with urllib.request.urlopen(url, timeout=180) as resp: + code, h, got = resp.status, hashlib.sha256(), 0 + while chunk := resp.read(1 << 20): + h.update(chunk) + got += len(chunk) + break + except urllib.error.HTTPError as exc: + if exc.code not in (403, 404) or attempt == 12: + raise + print(f" edge not ready ({exc.code}), retrying in 10s... [{attempt}/12]") + time.sleep(10) + served = h.hexdigest() + + print(f" HTTP {code}, {got:,} bytes") + print(f" sha256 {served}") + if served == local_hash: + print("\nOK — the bucket serves exactly the bytes the lab tells students to expect.") + print(f"\nPut this in labs/lab0-build-machine.md:\n {url}") + return 0 + print("\nMISMATCH — students would all think their download is corrupt. Do not publish.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lab-vm/build/verify-labs.sh b/lab-vm/build/verify-labs.sh new file mode 100644 index 0000000..74731b3 --- /dev/null +++ b/lab-vm/build/verify-labs.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# Run every lab checkpoint against a real lab VM and report pass/fail. +# +# ./verify-labs.sh # run on the lab VM itself +# +# This runs the SAME commands the labs tell students to run, and asserts the +# SAME output the labs tell them to expect. When a lab and this script +# disagree, one of them is wrong — that is the point. +# +# What it cannot check: anything you have to hear. Audio checkpoints are +# reported as MANUAL and must be confirmed by a person with a softphone. +# --------------------------------------------------------------------------- +# Deliberately NO `pipefail`. +# +# Every check here is `asterisk -rx ... | grep -q ...`. `grep -q` exits the +# moment it matches, which sends SIGPIPE to asterisk — and under pipefail the +# pipeline then reports failure. The effect is that a check fails precisely +# when its pattern appears EARLY in the output, and passes when it appears +# late. That produced a verifier which claimed context 'internal' was missing +# while simultaneously finding three extensions inside it. +set -u + +pass=0; fail=0; manual=0 +ok() { printf ' \033[1;32mPASS\033[0m %s\n' "$*"; pass=$((pass+1)); } +no() { printf ' \033[1;31mFAIL\033[0m %s\n' "$*"; fail=$((fail+1)); } +man() { printf ' \033[1;33mMANUAL\033[0m %s\n' "$*"; manual=$((manual+1)); } +grp() { printf '\n\033[1m%s\033[0m\n' "$*"; } + +cli() { sudo asterisk -rx "$1" 2>/dev/null; } + +# --- the scripts themselves ------------------------------------------------ +grp "Lab tooling" + +# A shell script with CRLF line endings fails on the very first `set` line with +# "pipefail: invalid option name", which reads as a bash version problem rather +# than what it is. Easy to introduce when the files are authored on Windows. +crlf="" +for f in /usr/local/bin/lab /opt/lab/asterisk.service /etc/modprobe.d/blacklist-raid.conf; do + [[ -f "$f" ]] && grep -qU $'\r' "$f" 2>/dev/null && crlf="${crlf} $f" +done +[[ -z "${crlf}" ]] && ok "lab scripts have Unix line endings" \ + || no "CRLF line endings in:${crlf} — these will fail to run" + +# --- Lab 0 ----------------------------------------------------------------- +grp "Lab 0 — Build your lab machine" + +lsb_release -d 2>/dev/null | grep -q "24.04" \ + && ok "Ubuntu 24.04 LTS" || no "not Ubuntu 24.04" + +ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1) +[[ -n "${ip}" ]] && ok "has a LAN address (${ip})" || no "no global IPv4 — is Adapter 1 bridged?" + +[[ -d /usr/src/asterisk-22.10.0 ]] \ + && ok "Asterisk source staged in /usr/src" || no "/usr/src/asterisk-22.10.0 missing" + +# --- Lab 1 ----------------------------------------------------------------- +grp "Lab 1 — Install Asterisk 22 from source" + +systemctl is-active --quiet asterisk \ + && ok "asterisk.service is active (running)" || no "asterisk.service is not running" + +grep -q '^RuntimeDirectory=asterisk' /etc/systemd/system/asterisk.service 2>/dev/null \ + && ok "unit has RuntimeDirectory=asterisk" \ + || no "unit is MISSING RuntimeDirectory=asterisk — the CLI will be unreachable" + +[[ "$(stat -c '%U' /run/asterisk 2>/dev/null)" == "asterisk" ]] \ + && ok "/run/asterisk owned by asterisk" || no "/run/asterisk not owned by asterisk" + +cli 'core show version' | grep -q '22.10.0' \ + && ok "core show version reports 22.10.0" || no "wrong or unreachable Asterisk version" + +# The log file the troubleshooting tables point at must actually exist. +[[ -f /var/log/asterisk/messages.log ]] \ + && ok "/var/log/asterisk/messages.log exists" \ + || no "messages.log missing — troubleshooting steps reference it" + +# Sound prompts. A missing one is silence at runtime, never an error, so it has +# to be checked here rather than discovered by a student mid-call. +missing="" +for s in demo-echotest demo-echodone demo-congrats hello-world invalid vm-goodbye \ + the-party-you-are-calling is-curntly-busy is-curntly-unavail; do + ls /var/lib/asterisk/sounds/en/${s}.* >/dev/null 2>&1 || missing="${missing} ${s}" +done +[[ -z "${missing}" ]] && ok "all 9 lab sound prompts present" \ + || no "missing sound prompts:${missing} (enable EXTRA-SOUNDS-EN-ULAW in menuselect)" + +# --- Lab 2 Part I ---------------------------------------------------------- +grp "Lab 2 Part I — SIP extensions" + +cli 'pjsip show transports' | grep -q '0.0.0.0:5060' \ + && ok "transport-udp bound to 0.0.0.0:5060" || no "no UDP transport on 5060" + +eps=$(cli 'pjsip show endpoints' | grep -c '^ Endpoint: [0-9]') +[[ "${eps}" -ge 2 ]] && ok "${eps} numbered endpoints defined" || no "expected at least 2 endpoints, found ${eps}" + +for e in 6001 6002; do + if cli "pjsip show endpoint ${e}" | grep -q 'InAuth:'; then + ok "${e} resolves its auth object" + else + no "${e} has no InAuth — auth= name does not match the [${e}] auth section" + fi +done + +# --- Lab 2 Part II --------------------------------------------------------- +grp "Lab 2 Part II — softphones and the first call" + +cli 'dialplan show internal' | grep -q "Context 'internal'" \ + && ok "context 'internal' exists" || no "context 'internal' missing" + +for x in 600 6001 6002; do + cli "dialplan show ${x}@internal" | grep -q "'${x}'" \ + && ok "extension ${x} is reachable in 'internal'" || no "extension ${x} not found in 'internal'" +done + +# A registered PHONE is a numbered endpoint that is no longer Unavailable. +# +# Counting `Contact:` lines does not work, for two separate reasons: the table +# prints a legend row containing that literal text, and a SIP trunk's AOR has a +# permanent static contact configured in pjsip.conf. Either one alone makes a +# machine with no phones at all report that a phone is registered. +reg=$(cli 'pjsip show endpoints' | grep -E '^ Endpoint: [0-9]' | grep -cv 'Unavailable') +[[ "${reg}" -gt 0 ]] && ok "${reg} phone(s) currently registered" \ + || man "no phone registered — register a softphone to complete this checkpoint" + +man "dial 600 and hear your own voice (two-way audio)" +man "dial 6002 from 6001, answer, and hear each other" + +# --- Lab 2 Part III -------------------------------------------------------- +grp "Lab 2 Part III — SIP trunk" + +if cli 'pjsip show registrations' | grep -q 'No objects found'; then + man "no trunk configured yet" +else + cli 'pjsip show registrations' | grep -q 'Registered' \ + && ok "trunk is Registered to the gateway" \ + || no "trunk is not Registered — check account/password/port 5600" +fi + +if cli 'dialplan show from-pstn' | grep -q "Context 'from-pstn'"; then + ok "context 'from-pstn' exists" + # The whole point of from-pstn: it must not be able to dial out. + if cli 'dialplan show from-pstn' | grep -qE 'Include =>|@pstn'; then + no "from-pstn can reach outbound routes — TOLL FRAUD RISK" + else + ok "from-pstn has no outbound route (correct)" + fi +else + man "from-pstn not configured yet" +fi + +# --- Lab 3 ----------------------------------------------------------------- +grp "Lab 3 — Dialplan" + +if cli 'dialplan show internal' | grep -q '_60XX'; then + ok "pattern _60XX in use" + cli 'dialplan show 6002@internal' | grep -q '_60XX' \ + && ok "6002 resolves via the _60XX pattern" || no "6002 does not resolve to _60XX" +else + man "Lab 3 not started (no _60XX pattern yet)" +fi + +cli 'dialplan show ivr' | grep -q "Context 'ivr'" \ + && ok "IVR context exists" || man "IVR not built yet" + +# --- summary --------------------------------------------------------------- +printf '\n\033[1m%d passed, %d failed, %d need a human\033[0m\n' "${pass}" "${fail}" "${manual}" +[[ "${fail}" -eq 0 ]] diff --git a/lab-vm/examples/ari-app.py b/lab-vm/examples/ari-app.py new file mode 100644 index 0000000..5c7ba9f --- /dev/null +++ b/lab-vm/examples/ari-app.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +""" +Minimal ARI application. + +Asterisk hands a call to this program when the dialplan runs Stasis(lab-app). +From that moment the dialplan is out of the picture: this code decides what +happens to the call, and Asterisk carries out the instructions. + +Run it, then dial 6700 from a phone. +""" +import asyncio +import base64 +import json +import urllib.request + +import websockets + +USER = "labuser" +PASSWORD = "Lab-ari-secret" +HOST = "localhost:8088" +APP = "lab-app" + +AUTH = base64.b64encode(f"{USER}:{PASSWORD}".encode()).decode() + + +def rest(method, path): + """Call one ARI REST endpoint. This is how you act on a channel.""" + request = urllib.request.Request(f"http://{HOST}/ari{path}", method=method) + request.add_header("Authorization", "Basic " + AUTH) + try: + return urllib.request.urlopen(request, timeout=5).read() + except Exception as exc: # noqa: BLE001 - lab code + print(f" rest error: {exc}", flush=True) + return None + + +async def main(): + # Events arrive over a WebSocket; actions go back over REST. Two channels, + # one conversation — that split is the whole shape of ARI. + url = f"ws://{HOST}/ari/events?app={APP}&api_key={USER}:{PASSWORD}" + + async with websockets.connect(url) as socket: + print(f"connected to ARI, waiting for calls on app: {APP}", flush=True) + + async for message in socket: + event = json.loads(message) + kind = event.get("type") + + if kind == "StasisStart": + channel = event["channel"]["id"] + caller = event["channel"]["caller"]["number"] or "unknown" + print(f"StasisStart: {caller} -> channel {channel}", flush=True) + + # Nothing in the dialplan told Asterisk to do this. We did. + rest("POST", f"/channels/{channel}/play?media=sound:demo-congrats") + + elif kind == "StasisEnd": + channel = event["channel"]["id"] + print(f"StasisEnd: {channel}", flush=True) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nstopped") diff --git a/lab-vm/lab.env b/lab-vm/lab.env new file mode 100644 index 0000000..ab9c7c8 --- /dev/null +++ b/lab-vm/lab.env @@ -0,0 +1,96 @@ +# --------------------------------------------------------------------------- +# Asterisk Lab VM — single source of truth for every constant in the lab. +# +# provision.sh, the Asterisk configs, the `lab` helper and the lab manual all +# read their values from here. Change a value once and rebuild; nothing else +# in the repo repeats these literals. +# +# SECURITY NOTE: the credentials below are deliberately public teaching +# credentials — they are printed in the book, so treat them as known to +# everyone. On a bridged VM they never leave your own LAN. On a cloud server +# (Lab 0, Path C) this machine has a public address, and the provider firewall +# from Lab 0 Step C2 is the only thing standing between these passwords and the +# internet: allow 5060/udp and the RTP range from your own address and the lab +# gateway, and nowhere else. Never deploy this configuration on a production +# system. +# --------------------------------------------------------------------------- + +# --- Asterisk ------------------------------------------------------------- +# Pinned deliberately. This is the version every example in the book and the +# course is verified against; bump it only when the content is re-verified. +ASTERISK_VERSION=22.10.0 + +# --- Lab network ---------------------------------------------------------- +# The VM uses a single BRIDGED adapter: it takes an address from your normal +# network's DHCP, exactly as a real server on that network would. Your +# softphone, and any real desk phone on the same LAN, reach it at that address. +# On the cloud path the machine already has its own public address, which comes +# to the same thing: one host, one address, no NAT in between. +# +# There is deliberately no fixed IP here. The address is whatever DHCP hands +# out, so nothing in the labs may hardcode one — they say "your lab's IP", and +# `lab ip` prints it. That is also how it works on a real deployment. +LAB_HOSTNAME=asterisk-lab + +# --- VM login ------------------------------------------------------------- +LAB_USER=lab +LAB_PASS=lab + +# --- Desk phones ---------------------------------------------------------- +EXT_A=6001 +EXT_A_NAME=Alice +EXT_A_PASS=Lab-6001-secret + +EXT_B=6002 +EXT_B_NAME=Bob +EXT_B_PASS=Lab-6002-secret + +# --- Browser (WebRTC) phone ---------------------------------------------- +EXT_WEBRTC=1000 +WEBRTC_USER=webrtc-1000 +WEBRTC_PASS=Lab-webrtc-secret + +# --- Dialplan contexts and service numbers ------------------------------- +CTX_INTERNAL=internal +CTX_FROM_TRUNK=from-pstn +EXT_ECHO=600 +EXT_SIPP=9000 +EXT_IVR=6000 +EXT_VOICEMAIL=*97 +VM_PIN=1234 + +# --- Simulated PSTN gateway ---------------------------------------------- +# A real Asterisk server that stands in for the PSTN. Every student shares it. +# Pick ONE account in TRUNK_ACCOUNT_RANGE so you do not clash with another +# student; they all use the same password. +TRUNK_NAME=pstn +TRUNK_HOST=sip.flagonc.com +TRUNK_PORT=5600 +TRUNK_ACCOUNT_RANGE=1010-1050 +TRUNK_ACCOUNT=1020 +TRUNK_PASS=supersecret +TRUNK_ECHO_TEST=*98 + +# --- Programmability (Lab 7) --------------------------------------------- +# The HTTP server that serves ARI and the WebRTC WebSocket. Lab 6 switches it +# on; Lab 7 uses it. Keep both labs reading these two numbers. +ARI_USER=labuser +ARI_PASS=Lab-ari-secret +ARI_PORT=8088 +WSS_PORT=8089 + +# Asterisk Manager Interface. Bound to loopback only — it is full remote +# control with a plaintext password. +AMI_USER=labami +AMI_PASS=Lab-ami-secret +AMI_PORT=5038 + +# CDRs into MariaDB over ODBC. +CDR_DB=asterisk +CDR_USER=asterisk +CDR_PASS=Lab-cdr-secret +CDR_DSN=asterisk-cdr + +# --- RTP ------------------------------------------------------------------ +RTP_START=10000 +RTP_END=10200 diff --git a/lab-vm/provision.sh b/lab-vm/provision.sh new file mode 100644 index 0000000..c8cf0ff --- /dev/null +++ b/lab-vm/provision.sh @@ -0,0 +1,375 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# Asterisk Lab VM — provisioning +# +# Turns a clean Ubuntu 24.04 LTS server into the course lab machine. +# +# Two stages, because the course teaches the Asterisk install as a lab and the +# student must actually perform it: +# +# sudo ./provision.sh base Ubuntu + build dependencies + lab tooling + +# the Asterisk source unpacked in /usr/src, but +# NOT built. This is the image students receive. +# Lab 1 is them running ./configure && make. +# +# sudo ./provision.sh full base, then build and install Asterisk and +# deploy the lab configuration. Used to produce +# the reference VM, and as the escape hatch for +# a student whose build fails (`lab rescue`). +# +# Shipping `base` is deliberate. Lessons 0.6 and 0.7 teach ./configure, +# menuselect, make and make install; a VM with Asterisk already on it would +# make those lessons something the student reads but never does. What `base` +# removes is only the part with nothing to teach: installing the OS, resolving +# dependencies, and downloading a tarball that may fail twenty minutes in. +# +# Idempotent: safe to re-run. `full` skips the build if the pinned version is +# already installed. +# --------------------------------------------------------------------------- +set -euo pipefail + +STAGE="${1:-full}" +case "${STAGE}" in + base|full) ;; + *) echo "Usage: $0 [base|full]" >&2; exit 2 ;; +esac + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lab.env +source "${HERE}/lab.env" + +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33m ! %s\033[0m\n' "$*"; } + +[[ $EUID -eq 0 ]] || { echo "Run me with sudo." >&2; exit 1; } + +if ! grep -q 'VERSION_ID="24.04"' /etc/os-release 2>/dev/null; then + warn "This lab is built and tested on Ubuntu 24.04 LTS. Continuing anyway." +fi + +export DEBIAN_FRONTEND=noninteractive + +# --------------------------------------------------------------------------- +# 1. Packages +# --------------------------------------------------------------------------- +log "Installing build dependencies and lab tooling" +apt-get update -qq + +# Build dependencies for Asterisk. We use bundled jansson and pjproject so the +# PJSIP stack is version-matched to Asterisk and we need fewer apt packages. +# libsrtp2-dev is required for SRTP (the TLS/SRTP lab). +apt-get install -y --no-install-recommends \ + build-essential wget curl ca-certificates pkg-config subversion gettext-base \ + libedit-dev libxml2-dev libsqlite3-dev uuid-dev libssl-dev \ + libsrtp2-dev libcurl4-openssl-dev libncurses-dev libjansson-dev \ + unixodbc unixodbc-dev odbc-mariadb + +# Tooling the labs use. Installed up front so no lab ever begins with "first, +# install a tool" — that is where students stall. +# sngrep - the SIP capture tool used all through the SIP labs +# sip-tester - provides sipp, for headless call generation +# tcpdump - packet capture for the troubleshooting labs +# fail2ban - the brute-force lab +# mariadb - CDR/ODBC lab +# jq - reading ARI JSON responses +apt-get install -y --no-install-recommends \ + sngrep sip-tester tcpdump fail2ban mariadb-server jq \ + iptables-persistent netcat-openbsd python3 python3-websockets vim less + +# --------------------------------------------------------------------------- +# 1b. Staging — needed by both stages +# --------------------------------------------------------------------------- +# Everything here exists in the student image too, because Lab 1 depends on it: +# it copies the service unit from /opt/lab and can fall back to `lab rescue`. +stage_common() { + log "Staging lab files in /opt/lab" + mkdir -p /opt/lab + install -m 0644 "${HERE}/lab.env" /opt/lab/lab.env + + # The unit Lab 1 copies into place, and that the Deployment and Operations + # section takes apart line by line. Staged rather than installed: in the + # student image there is no Asterisk yet for it to start. + cat > /opt/lab/asterisk.service <<'UNIT' +[Unit] +Description=Asterisk PBX +Documentation=man:asterisk(8) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=asterisk +Group=asterisk + +# Creates /run/asterisk owned by asterisk:asterisk on every start, and removes +# it on stop. Without this, Asterisk starts, logs "Asterisk Ready", and reports +# active (running) — but cannot create its control socket in the root-owned +# /run/asterisk, so `asterisk -rx` fails with "Unable to connect to remote +# asterisk". A running PBX with an unusable CLI, and nothing in the status +# output to say why. /run is tmpfs, so this must be recreated at every boot; +# a one-off mkdir would work until the first reboot and then stop working. +RuntimeDirectory=asterisk +RuntimeDirectoryMode=0750 + +ExecStart=/usr/sbin/asterisk -f -U asterisk -G asterisk +ExecReload=/usr/sbin/asterisk -rx 'core reload' +Restart=on-failure +RestartSec=5 +# Needed to bind SIP's low ports and to raise RTP thread priority as non-root. +AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_SYS_NICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SYS_NICE +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target +UNIT + + # Worked examples the labs copy from, rather than retyping into a heredoc. + if [[ -d "${HERE}/examples" ]]; then + mkdir -p /opt/lab/examples + install -m 0644 "${HERE}"/examples/* /opt/lab/examples/ + fi + + install -m 0755 "${HERE}/bin/lab" /usr/local/bin/lab + install -m 0755 "${HERE}/bin/motd-lab" /etc/update-motd.d/99-lab + # Ubuntu's stock banner is noise next to the lab's own. + chmod -x /etc/update-motd.d/10-help-text 2>/dev/null || true + + hostnamectl set-hostname "${LAB_HOSTNAME}" 2>/dev/null || true + + # --- keep the guest kernel alive ----------------------------------------- + # The kernel loads raid0/1/10/456 and raid6_pq on this image even though the + # VM has a single virtual disk and no RAID of any kind — every one of them + # sits there with zero users. raid6_pq benchmarks its vector codepaths at + # load time and faults inside an AVX routine under VirtualBox, which hangs + # boot in initramfs with a kernel BUG at raid6_choose_gen. + # + # The other cure is masking AVX per-VM with `VBoxManage setextradata`, but + # that is host-side configuration and is NOT carried inside an exported OVA + # — every student would have to run it by hand before first boot. Doing it + # in the image means the appliance simply works. + if [[ ! -f /etc/modprobe.d/blacklist-raid.conf ]]; then + log "Blacklisting unused RAID modules (they crash the guest under VirtualBox)" + cat > /etc/modprobe.d/blacklist-raid.conf <<'MODS' +# This lab VM has one virtual disk and no RAID. These modules are never used, +# and raid6_pq's load-time benchmark faults under VirtualBox's CPU emulation. +blacklist raid6_pq +blacklist async_raid6_recov +blacklist raid456 +blacklist raid10 +blacklist raid1 +blacklist raid0 +blacklist md_mod +MODS + # Only ship modules this hardware actually needs, and rebuild so the + # blacklist applies to the initramfs too — that is where it crashed. + sed -i 's/^MODULES=.*/MODULES=dep/' /etc/initramfs-tools/initramfs.conf + update-initramfs -u >/dev/null 2>&1 || warn "update-initramfs failed" + fi + + # --- network ------------------------------------------------------------ + # Nothing to configure. The adapter is bridged and takes an address from + # the network's own DHCP, which is what the cloud image already does. No + # fixed address is written anywhere, deliberately — see lab.env. + + # --- TLS certificate ---------------------------------------------------- + # The certificate has to name the address the browser will actually use, + # and with DHCP that is not known until the machine boots on the student's + # network. So it is generated at every boot, and regenerated whenever the + # address changes. Without this the WebRTC lab fails with a certificate + # error that reads, wrongly, as "WebRTC is broken". + log "Installing the boot-time certificate refresh" + cat > /etc/systemd/system/lab-certs.service <<'UNIT' +[Unit] +Description=Refresh the Asterisk lab TLS certificate for the current IP +After=network-online.target +Wants=network-online.target +Before=asterisk.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/lab certs +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +UNIT + systemctl enable lab-certs.service 2>/dev/null || true +} + +stage_common + +# --------------------------------------------------------------------------- +# 2. Asterisk +# --------------------------------------------------------------------------- +SRC="/usr/src/asterisk-${ASTERISK_VERSION}" + +# The source is always staged, in both modes. In `base` this is what the +# student finds waiting for them — Lab 1 begins at `cd /usr/src/asterisk-22.10.0` +# with the download already done, so a slow or broken network cannot end the +# lab before it starts. +if [[ ! -d "${SRC}" ]]; then + log "Fetching the Asterisk ${ASTERISK_VERSION} source" + # Use releases/, NOT the top-level asterisk/ directory. + # + # The top-level directory only ever holds the newest point release. When + # 22.10.1 shipped, .../asterisk/asterisk-22.10.0.tar.gz became a 404 and + # every build pinned to 22.10.0 broke overnight — which is exactly what + # happened to the Docker lab this replaces. releases/ keeps every version + # permanently, so a pin stays valid. + url="https://downloads.asterisk.org/pub/telephony/asterisk/releases/asterisk-${ASTERISK_VERSION}.tar.gz" + if ! wget -q -O "/usr/src/asterisk-${ASTERISK_VERSION}.tar.gz" "${url}"; then + rm -f "/usr/src/asterisk-${ASTERISK_VERSION}.tar.gz" + warn "Could not download ${url}" + warn "Check that ASTERISK_VERSION=${ASTERISK_VERSION} in lab.env still exists:" + warn " curl -s https://downloads.asterisk.org/pub/telephony/asterisk/releases/ | grep asterisk-22" + exit 1 + fi + tar xzf "/usr/src/asterisk-${ASTERISK_VERSION}.tar.gz" -C /usr/src + rm -f "/usr/src/asterisk-${ASTERISK_VERSION}.tar.gz" +fi +# Left owned by root. Lab 1 builds with sudo throughout — `make install` needs +# root anyway, and a half-sudo build leaves root-owned objects in a tree the +# student then cannot clean, which fails confusingly on the second attempt. + +if [[ "${STAGE}" == "base" ]]; then + log "Stage 'base' complete — Asterisk source staged in ${SRC}, not built" + log "This is the student image. Lab 1 is where Asterisk gets installed." + exit 0 +fi + +installed_version="" +if command -v asterisk >/dev/null 2>&1; then + installed_version="$(asterisk -V 2>/dev/null | awk '{print $2}')" +fi + +if [[ "${installed_version}" == "${ASTERISK_VERSION}" ]]; then + log "Asterisk ${ASTERISK_VERSION} already installed — skipping build" +else + log "Building Asterisk ${ASTERISK_VERSION} from source (this is the slow part)" + cd "${SRC}" + ./configure --with-jansson-bundled --with-pjproject-bundled --with-srtp + make menuselect.makeopts + + # res_srtp + res_http_websocket: the TLS/SRTP and WebRTC labs. + # res_odbc + cdr_adaptive_odbc: the CDR-to-database lab. + # app_macro is deprecated; we deliberately leave it off — the dialplan + # labs use Gosub, which is what Asterisk 22 expects. + menuselect/menuselect \ + --enable res_srtp \ + --enable res_http_websocket \ + --enable res_odbc \ + --enable cdr_adaptive_odbc \ + --enable res_ari \ + --enable res_ari_applications \ + --enable CORE-SOUNDS-EN-ULAW \ + --enable EXTRA-SOUNDS-EN-ULAW \ + menuselect.makeopts + + make -j"$(nproc)" + make install + # Lessons 0.6 and 0.7 teach `make samples` as step 8 of the install. It + # writes the stock configs into /etc/asterisk; the lab configuration is + # laid over the top of them further down, so the order here matters. + make samples + make install-logrotate + ldconfig + log "Asterisk $(asterisk -V) installed" +fi + +# --------------------------------------------------------------------------- +# 3. Run Asterisk as its own user, under systemd +# --------------------------------------------------------------------------- +# The Docker lab ran Asterisk as PID 1 as root. Here it is a normal service, +# which is what production looks like — and what makes the systemd, iptables +# and fail2ban labs real instead of theoretical. +log "Creating the asterisk service account" +if ! id -u asterisk >/dev/null 2>&1; then + adduser --system --group --home /var/lib/asterisk --no-create-home \ + --gecos "Asterisk PBX" asterisk +fi +usermod -aG audio,dialout asterisk 2>/dev/null || true + +# The lab user needs to read/edit configs and use the CLI without sudo. +if id -u "${LAB_USER}" >/dev/null 2>&1; then + usermod -aG asterisk "${LAB_USER}" +fi + +for d in /var/lib/asterisk /var/log/asterisk /var/spool/asterisk \ + /var/run/asterisk /usr/lib/asterisk /etc/asterisk; do + mkdir -p "$d" + chown -R asterisk:asterisk "$d" +done +chmod -R g+w /etc/asterisk + +log "Installing the systemd unit" +# The same file Lab 1 has the student copy — staged by stage_common, so the +# reference VM and the student's hand-built machine end up identical. +install -m 0644 /opt/lab/asterisk.service /etc/systemd/system/asterisk.service + +# Asterisk's own init script would fight systemd for control of the service. +systemctl disable --now asterisk.init 2>/dev/null || true +rm -f /etc/init.d/asterisk +systemctl daemon-reload + +# --------------------------------------------------------------------------- +# 4. Lab configuration +# --------------------------------------------------------------------------- +log "Installing the lab Asterisk configuration" + +# Keep the pristine sample configs — the labs refer to them, and students who +# break something need something to compare against. +if [[ ! -d /etc/asterisk.samples ]]; then + cp -a /etc/asterisk /etc/asterisk.samples +fi + +# Configs are templates: ${EXT_A}, ${TRUNK_HOST} and friends come from lab.env, +# so credentials and addresses are defined exactly once in this repo. +# +# envsubst is restricted to the names defined in lab.env. Without that list it +# would also expand Asterisk's own dialplan variables — ${EXTEN}, ${CALLERID(num)} +# — into empty strings and quietly produce a broken dialplan. +LAB_VARS="$(sed -n 's/^\([A-Z_][A-Z0-9_]*\)=.*/$\1/p' "${HERE}/lab.env" | tr '\n' ' ')" + +shopt -s nullglob +for tpl in "${HERE}"/asterisk/etc/*.conf; do + target="/etc/asterisk/$(basename "${tpl}")" + envsubst "${LAB_VARS}" < "${tpl}" > "${target}" + chown asterisk:asterisk "${target}" +done +shopt -u nullglob + +# TLS material for the secure WebSocket the browser phone uses, and for the +# TLS/SRTP lab. Generated by `lab certs` against whatever address DHCP gave +# this machine — the same code path that runs on every boot, so the reference +# VM and a student's machine produce the certificate the same way. +/usr/local/bin/lab certs + +# A snapshot of the known-good config, so `lab reset` can always put a student +# back to a working PBX instead of leaving them stuck. +rm -rf /opt/lab/baseline +mkdir -p /opt/lab/baseline +cp -a /etc/asterisk/. /opt/lab/baseline/ + +# Networking, the `lab` helper, the login banner and the staged service unit +# were all handled by stage_common() near the top — they are identical in both +# the student image and this reference build. + +# --------------------------------------------------------------------------- +# 5. Start it +# --------------------------------------------------------------------------- +log "Enabling Asterisk" +systemctl enable asterisk +systemctl restart asterisk + +# fail2ban ships enabled with an ssh jail; the Asterisk jail is switched on by +# the student during the security lab, not here. +systemctl disable --now fail2ban 2>/dev/null || true + +sleep 3 +if asterisk -rx 'core show version' 2>/dev/null | grep -q "${ASTERISK_VERSION}"; then + log "Lab ready — Asterisk ${ASTERISK_VERSION} answering on ${LAB_IP}" +else + warn "Asterisk did not answer the CLI. Check: journalctl -u asterisk -n 50" + exit 1 +fi diff --git a/lab/Dockerfile b/lab/Dockerfile index 2bb7d45..3aa9496 100644 --- a/lab/Dockerfile +++ b/lab/Dockerfile @@ -15,7 +15,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src -RUN wget -q "https://downloads.asterisk.org/pub/telephony/asterisk/asterisk-${ASTERISK_VERSION}.tar.gz" \ +# NOTE: releases/, not the top-level asterisk/ directory. The top level holds +# only the newest point release — when 22.10.1 shipped, the 22.10.0 URL became +# a 404 and this build broke for every student mid-lab. releases/ is permanent. +RUN wget -q "https://downloads.asterisk.org/pub/telephony/asterisk/releases/asterisk-${ASTERISK_VERSION}.tar.gz" \ && tar xzf "asterisk-${ASTERISK_VERSION}.tar.gz" \ && rm "asterisk-${ASTERISK_VERSION}.tar.gz" diff --git a/labs/LAB-GUIDE.docker.old.md b/labs/LAB-GUIDE.docker.old.md new file mode 100644 index 0000000..53ba12b --- /dev/null +++ b/labs/LAB-GUIDE.docker.old.md @@ -0,0 +1,917 @@ +# Asterisk Guide — Hands-On Lab Manual + +A companion workbook to *Asterisk Guide* (2nd edition, Asterisk 22 LTS). Every lab here runs +on your own computer — **Windows, macOS, or Linux** — inside a reproducible **Docker** +environment. No server, no SIP hardware, no analog cards, and nothing legacy: it is pure +**PJSIP** on **Asterisk 22.10.0**, exactly like the book. + +Work through the labs in order. Each one is self-contained, takes 10–30 minutes, and ends with +a checkpoint you can see or hear. If something breaks, every lab has a Troubleshooting table. + +--- + +## Lab Credentials & Quick Reference *(keep this page open)* + +Everything you need to log in, anywhere in this manual, is here. You will not have to invent a +single username or password. + +### Softphone accounts (your PBX, created by the lab) + +**Get the softphone:** download the **SipPulse Softphone** from + (Windows/macOS/Linux), or use the no-install +browser version at . + +| Account | Password | What it is | +|--------:|----------|------------| +| `6001` | `Lab-6001-secret` | Desk phone "Alice" | +| `6002` | `Lab-6002-secret` | Desk phone "Bob" | +| `webrtc-1000` | `Lab-webrtc-secret` | Browser phone (Lab 6) | + +- **SIP server / domain:** your own computer — **`127.0.0.1`** (or your machine's LAN IP if the + softphone runs on a different device), **UDP port 5060**. +- **Transport:** UDP. **Codecs:** μ-law (PCMU), A-law (PCMA). + +### SIP trunk (shared lab provider — for Lab 5) + +| Setting | Value | +|---------|-------| +| Provider host | `sip.flagonc.com` | +| Port | **`5600`** (not 5060) | +| Account (pick one) | `1010`–`1050` | +| Password | `supersecret` (same for every account) | +| Provider echo test | dial `*98` over the trunk (free) | + +> Pick **any one** account in `1010`–`1050` — they all share the password `supersecret`. So your +> account doesn't clash with another student's, use the number your instructor assigns you. This +> manual's worked example uses **`1020`**. + +### Services you will switch on later + +| Service | Where | Login | +|---------|-------|-------| +| Voicemail PIN (Lab 3) | dial `*97` | mailbox `6001`/`6002`, PIN `1234` | +| ARI REST API (Lab 8) | `http://localhost:8088/ari` | `labuser` / `Lab-ari-secret` | +| WebRTC signaling (Lab 6) | `wss://localhost:8089/ws` | account `webrtc-1000` | + +### The two commands you will use constantly + +```bash +# 1) Run any Asterisk CLI command from your normal shell (Terminal or PowerShell): +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoints' + +# 2) Open a live, interactive Asterisk console (Ctrl-C to leave): +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rvvv +``` + +Throughout this manual, **"run in the Asterisk CLI"** means form (1) or typing the command at +the `*CLI>` prompt of form (2). **"Edit a file"** always means a file under +`lab/asterisk/etc/` on your computer — save it, then reload as the lab tells you. + +--- + +## Lab 0 — Set up the lab on your computer + +> **You will:** get the Asterisk 22 lab running on Windows, macOS, or Linux and place your +> first registration. +> **Time:** ~20 min **Prerequisites:** none. + +### Step 1 — Install Docker + +You need Docker with the `docker compose` command. + +- **Windows 10/11:** install **Docker Desktop** from . + Accept the WSL 2 backend when prompted, then reboot. Launch Docker Desktop and wait for the + whale icon to say *"Engine running."* +- **macOS (Intel or Apple Silicon):** install **Docker Desktop** from the same page and launch + it. (OrbStack also works.) +- **Linux:** install **Docker Engine** + the compose plugin — + `sudo apt-get install docker.io docker-compose-plugin` (Debian/Ubuntu) or follow + . Add yourself to the `docker` group + (`sudo usermod -aG docker $USER`) and log out/in. + +Confirm it works — in **Terminal** (macOS/Linux) or **PowerShell** (Windows): + +```bash +docker --version +docker compose version +``` + +You should see a Docker version `24.x` or newer and a Compose `v2.x` line. + +### Step 2 — Get the lab files + +Clone the repository (install Git first if needed), then move into it: + +```bash +git clone https://github.com/flaviogoncalves/asterisk-guide.git +cd asterisk-guide +``` + +> No Git? Download the repo's ZIP from GitHub ("Code → Download ZIP"), unzip it, and `cd` into +> the unzipped folder. On Windows, **use VS Code or another editor — not Notepad —** to edit +> config files later, so line endings stay correct. + +### Step 3 — Start the lab + +```bash +docker compose -f lab/docker-compose.yml up -d --build +``` + +The first run downloads and compiles Asterisk 22.10.0 from source, so it can take several +minutes. When it finishes, check it is healthy: + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'core show version' +``` + +**You should see:** + +``` +Asterisk 22.10.0 built by root @ ... running Linux +``` + +### Step 4 — Look at what is already configured + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoints' +``` + +**You should see** four endpoints — `6001`, `6002`, `sipp`, `webrtc-1000` — all +`Unavailable` (no phone has registered *yet*): + +``` + Endpoint: 6001 Unavailable 0 of inf + InAuth: 6001/6001 + Aor: 6001 1 + Endpoint: 6002 Unavailable 0 of inf +... +Objects found: 4 +``` + +### Step 5 — Install a softphone and register `6001` + +Download the **SipPulse Softphone** for Windows, macOS, or Linux from + (or, to skip installing, use the browser version +at ). Then create an account with the credentials +from the front page: + +| Field | Value | +|-------|-------| +| Username / Auth user | `6001` | +| Password | `Lab-6001-secret` | +| Domain / SIP server | `127.0.0.1` (same computer) — or your machine's LAN IP | +| Port | `5060`, transport **UDP** | + +Save and let it register. Now re-check from the Asterisk CLI: + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoint 6001' +``` + +### ✅ Checkpoint + +`pjsip show endpoint 6001` reports the endpoint **`Not in use`** (instead of `Unavailable`) and +lists a **Contact** with a `sip:` URI from your phone. Your softphone shows *Registered* / +*Online*. The lab is alive. + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Softphone never registers | Wrong domain/port, or firewall | Use `127.0.0.1:5060` UDP if the phone is on the same computer; allow Docker through the firewall when Windows/macOS prompts. | +| `endpoint ... Unavailable` still | Phone not actually registered | Re-check username `6001` and password `Lab-6001-secret` exactly; watch `pjsip set logger on` for a `401`. | +| `port is already allocated` on `up` | Something else uses 5060/8088/8089 | Stop the other SIP app, or edit the host-side ports in `lab/docker-compose.yml`. | + +### Clean up + +Leave the lab running for the next labs. To stop it later: +`docker compose -f lab/docker-compose.yml down`. + +--- + +## Lab 1 — Your first calls + +> **You will:** call between two softphones and run the classic echo test. +> **Time:** ~15 min **Prerequisites:** Lab 0. Register **both** `6001` and `6002` (use a +> second softphone, a second device, or a second profile). +> **Book chapter:** *Building Your First PBX*. + +### Step 1 — See the dialplan you already have + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'dialplan show internal' +``` + +**You should see** the `internal` context with extensions `600`, `6001`, `6002`, `9000`, +`1000`: + +``` + '600' => 1. Answer() + 2. Playback(demo-echotest) + 3. Echo() + 4. Hangup() + '6001' => 1. Dial(PJSIP/6001,20) + '6002' => 1. Dial(PJSIP/6002,20) +-= 5 extensions (11 priorities) in 1 context. =- +``` + +### Step 2 — Call phone to phone + +From the softphone registered as `6001`, dial **`6002`**. The other phone rings; answer it and +talk. Then hang up and call the other way. + +While a call is up, watch it live: + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'core show channels' +``` + +You will see two `PJSIP/…` channels bridged together. + +### Step 3 — Run the echo test + +From either phone, dial **`600`**. Asterisk answers, plays a short prompt, then echoes your +microphone back to your ear with a tiny delay. This is the fastest way to prove two-way audio +(RTP) is flowing. + +### ✅ Checkpoint + +You can hold a two-way conversation between `6001` and `6002`, and dialling `600` plays your +own voice back. If `600` is silent in one direction, your RTP/audio path has a NAT or firewall +problem — see the table. + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Phone rings but no audio | RTP blocked | Make sure the lab's `10000-10100/udp` ports aren't blocked by your firewall; keep both phones on the same machine for the simplest path. | +| "All circuits are busy" / 404 | Dialled an unknown extension | Only `600`, `6001`, `6002`, `9000`, `1000` exist until you add more. | +| Calls drop after 20 s on no-answer | `Dial(...,20)` timeout | Expected — answer within 20 seconds. | + +### Clean up + +Nothing to undo — you only placed calls. + +--- + +## Lab 2 — A dial plan with an auto-attendant (IVR) + +> **You will:** build a small voice menu: "Press 1 for Alice, 2 for Bob, 0 for the echo test." +> **Time:** ~20 min **Prerequisites:** Lab 1. +> **Book chapter:** *Building an Interactive Dial Plan*. + +### Step 1 — Add an IVR context + +Open `lab/asterisk/etc/extensions.conf` in your editor and add this new context at the bottom: + +```ini +[ivr] +exten => start,1,Answer() + same => n,Wait(1) + same => n(menu),Background(demo-instruct) ; play the menu, listen for a digit + same => n,WaitExten(5) ; wait 5s for the caller to press a key + +exten => 1,1,Dial(PJSIP/6001,20) +exten => 2,1,Dial(PJSIP/6002,20) +exten => 0,1,Goto(internal,600,1) ; echo test +exten => i,1,Playback(pbx-invalid) ; invalid key + same => n,Goto(ivr,start,menu) +exten => t,1,Playback(vm-goodbye) ; timed out + same => n,Hangup() +``` + +### Step 2 — Give your phones a way to reach the menu + +Still in `extensions.conf`, add one line to the existing `[internal]` context so dialling +**`500`** enters the IVR: + +```ini +exten => 500,1,Goto(ivr,start,1) +``` + +### Step 3 — Reload and verify + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'dialplan reload' +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'dialplan show ivr' +``` + +**You should see** the `ivr` context with `start`, `1`, `2`, `0`, `i`, `t`. + +### Step 4 — Try it + +From `6001`, dial **`500`**. Listen to the prompt, then press **`2`** — phone `6002` rings. +Hang up, dial `500` again, and press **`0`** for the echo test. Press a wrong key (e.g. `9`) to +hear the invalid-key handling. + +### ✅ Checkpoint + +Dialling `500` answers with a menu, and `1`/`2`/`0` route the call as configured; a bad key +re-plays the menu. + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `dialplan reload` shows no `ivr` | Typo / unbalanced context | Re-check the `[ivr]` header and indentation; run the reload again and read for a parse error. | +| Key presses ignored | DTMF mode mismatch | The lab uses RFC 4733 DTMF by default; set the same in your softphone (often "RFC2833"). | +| "Invalid" every time | Prompt still playing | `Background` lets you dial *during* the prompt; wait a beat or press the key again. | + +### Clean up + +To return to the base dial plan, delete the `[ivr]` context and the `exten => 500` line you +added, then `dialplan reload`. (Or keep them — later labs don't conflict.) + +--- + +## Lab 3 — Voicemail + +> **You will:** send unanswered calls to voicemail and retrieve messages with `*97`. +> **Time:** ~20 min **Prerequisites:** Lab 1. +> **Book chapter:** *Voicemail*. + +### Step 1 — Create the mailboxes + +Create a **new file** `lab/asterisk/etc/voicemail.conf` with two mailboxes (PIN `1234`): + +```ini +[general] +format=wav49|gsm|wav +maxmsg=100 + +[default] +6001 => 1234,Alice Lab,alice@example.com +6002 => 1234,Bob Lab,bob@example.com +``` + +Reload and confirm: + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'voicemail reload' +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'voicemail show users' +``` + +**You should see:** + +``` +Context Mbox User Zone NewMsg +default 6001 Alice Lab 0 +default 6002 Bob Lab 0 +2 voicemail users configured. +``` + +### Step 2 — Send unanswered calls to voicemail + +Edit `lab/asterisk/etc/extensions.conf`. Replace the two existing phone lines in `[internal]`: + +```ini +exten => 6001,1,Dial(PJSIP/6001,20) +exten => 6002,1,Dial(PJSIP/6002,20) +``` + +with versions that fall through to voicemail when the call isn't answered in 20 seconds: + +```ini +exten => 6001,1,Dial(PJSIP/6001,20) + same => n,VoiceMail(6001@default,u) + same => n,Hangup() +exten => 6002,1,Dial(PJSIP/6002,20) + same => n,VoiceMail(6002@default,u) + same => n,Hangup() +``` + +Add an extension to **check** your own messages: + +```ini +exten => *97,1,VoiceMailMain(${CALLERID(num)}@default) +``` + +Reload: `docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'dialplan reload'`. + +### Step 3 — Leave and retrieve a message + +1. From `6001`, call `6002` and **don't answer**. After 20 s you hear the "please leave a + message" prompt — record one and hang up. +2. From `6002`, dial **`*97`**, enter PIN **`1234`**, and follow the prompts to hear the + message. + +### ✅ Checkpoint + +`voicemail show users` shows `NewMsg` `1` for `6002` after you leave a message, and `*97` plays +it back. + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| No "leave a message" prompt | Dialplan not reloaded, or call answered | Reload, and make sure the called phone truly rings out (don't pick up). | +| `*97` asks for a mailbox number | Caller ID not set to `6001`/`6002` | Enter the mailbox number manually, then PIN `1234`. | +| "Sorry, I can't let you do that" | Wrong PIN | The PIN is `1234`. | + +### Clean up + +Delete `voicemail.conf`, revert the `[internal]` phone lines, remove `*97`, then +`dialplan reload` and `voicemail reload`. + +--- + +## Lab 4 — A call queue + +> **You will:** put callers in a queue that rings both agents. +> **Time:** ~15 min **Prerequisites:** Lab 1. +> **Book chapter:** *Queues and Call Centers*. + +### Step 1 — Define the queue + +Create a **new file** `lab/asterisk/etc/queues.conf`: + +```ini +[general] + +[support] +strategy=ringall +timeout=15 +member => PJSIP/6001 +member => PJSIP/6002 +``` + +Reload and check: + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'module reload app_queue.so' +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'queue show support' +``` + +**You should see:** + +``` +support has 0 calls (max unlimited) in 'ringall' strategy ... + Members: + PJSIP/6002 (ringinuse enabled) (Unavailable) has taken no calls yet ... + PJSIP/6001 (ringinuse enabled) (Unavailable) has taken no calls yet ... + No Callers +``` + +(Members read `Unavailable` until their softphones are registered.) + +### Step 2 — Route a number into the queue + +Add to `[internal]` in `extensions.conf`, then `dialplan reload`: + +```ini +exten => 700,1,Answer() + same => n,Queue(support) + same => n,Hangup() +``` + +### Step 3 — Test + +Register both `6001` and `6002`. From a third identity (or the WebRTC phone from Lab 6, or by +temporarily un-registering one agent), dial **`700`**. Both agents' phones ring; whoever +answers takes the call. Watch it: + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'queue show support' +``` + +### ✅ Checkpoint + +Dialling `700` rings the registered agents, and `queue show support` shows the caller in queue +then the answering agent's call count increment. + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Members always `Unavailable` | Agents not registered | Register `6001`/`6002` first; re-run `queue show support`. | +| Caller hears silence | No agents available | Register at least one agent; `ringall` needs a reachable member. | + +### Clean up + +Delete `queues.conf` and the `exten => 700` block; `dialplan reload` and +`module reload app_queue.so`. + +--- + +## Lab 5 — Connect a real SIP trunk + +> **You will:** register your lab PBX to a real ITSP (`sip.flagonc.com`) and route calls over +> it. +> **Time:** ~25 min **Prerequisites:** Lab 1. Internet access from the container. +> **Book chapter:** *SIP Trunking and DID*. +> +> *Lab sponsor:* this trunk lab is served by the shared provider on `sip.flagonc.com`. + +### Step 1 — Add the trunk to PJSIP + +Edit `lab/asterisk/etc/pjsip.conf` and append a trunk. **Use the account assigned to you** +(`1010`–`1050`); this example uses `1020`. Note the provider port is **`5600`**, and the +`from_user` line is what lets the provider recognize your outbound calls: + +```ini +; ---- SIP trunk: sip.flagonc.com ---- +[flagonc] +type=registration +outbound_auth=flagonc-auth +server_uri=sip:1020@sip.flagonc.com:5600 +client_uri=sip:1020@sip.flagonc.com +contact_user=9999 + +[flagonc-auth] +type=auth +auth_type=userpass +username=1020 +password=supersecret + +[flagonc] +type=endpoint +context=from-pstn +transport=transport-udp +disallow=all +allow=ulaw +direct_media=no +outbound_auth=flagonc-auth +aors=flagonc +from_user=1020 +from_domain=sip.flagonc.com + +[flagonc] +type=aor +contact=sip:sip.flagonc.com:5600 + +[flagonc] +type=identify +endpoint=flagonc +match=sip.flagonc.com +``` + +### Step 2 — Reload and confirm registration + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'module reload res_pjsip.so' +# give it a few seconds, then: +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show registrations' +``` + +**You should see** `Status` **`Registered`**: + +``` + +========================================================================================== + flagonc/sip:1020@sip.flagonc.com:5600 flagonc-auth Registered (exp. 3554s) +``` + +If it says **`Rejected`**, the account/password was refused (see Troubleshooting). If it stays +**`Unregistered`** for more than ~15 s, the provider didn't answer — check connectivity. + +### Step 3 — Receive an inbound call (free, no charges) + +Add a landing place for inbound calls. In `extensions.conf`, add a `from-pstn` context: + +```ini +[from-pstn] +exten => 9999,1,NoOp(Inbound from trunk, caller ${CALLERID(num)}) + same => n,Answer() + same => n,Playback(demo-congrats) + same => n,Goto(ivr,start,1) ; hand off to your IVR from Lab 2 (optional) +exten => _.,1,Goto(9999,1) ; catch any inbound number +``` + +Reload (`dialplan reload`). Now trigger an inbound call. Two ways: + +- **Self-test (no second person):** once the outbound rule from Step 4 is in place, dial **your + own trunk number** — e.g. from `6001` dial `1050` (your account). The call goes out to the + provider, which routes it straight back to your registered phone as a genuine *inbound* call, + landing in `from-pstn`. +- **From outside:** have a classmate dial your account number, or use the provider's web + click-to-call. + +Watch the inbound leg arrive — you'll see a `PJSIP/flagonc-…` channel in **`from-pstn`** answer +and play the prompt: + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'core show channels' +``` + +``` +PJSIP/flagonc-00000005 9999@from-pstn:3 Up Playback(demo-congrats) +``` + +### Step 4 — Place an outbound call (free, over the trunk) + +The provider hosts a **free echo test on `*98`** and lets accounts call each other, so you can +test outbound without any PSTN charges. Add an outbound rule to `[internal]`: + +```ini +exten => 800,1,Dial(PJSIP/*98@flagonc,30) ; provider echo test (free) +exten => _10XX,1,Dial(PJSIP/${EXTEN}@flagonc,30) ; ring another lab account (e.g. 1011) +exten => _20XX,1,Dial(PJSIP/${EXTEN}@flagonc,30) ; ring a 20xx lab account +``` + +`dialplan reload`, then from `6001` dial **`800`** — the call goes out over the trunk to the +provider's echo test and you hear yourself. Or dial another live account (e.g. `1011`) to ring a +classmate. Watch it go out: + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'core show channels' +``` + +You'll see a `PJSIP/flagonc-…` channel reach **`Up`**. + +### ✅ Checkpoint + +`pjsip show registrations` reads **`Registered`**; dialling `800` reaches the provider's echo +test over the trunk (a `PJSIP/flagonc-…` channel goes `Up`); and dialling your own number (the +self-test above) comes back as an **inbound** `PJSIP/flagonc-…` channel in `from-pstn`. + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `Rejected` | Wrong password, or an account outside `1010`–`1050` | Confirm port `5600`, password `supersecret`, and an account in `1010`–`1050`. A `403 Forbidden` in `pjsip set logger on` means the credentials were refused (e.g. account `1001` doesn't exist). | +| Outbound `403 Forbidden` | Provider can't identify the caller | Make sure `from_user=` is on the `[flagonc]` endpoint — without it the provider rejects your INVITE. | +| `Unregistered`, never changes | No route to `sip.flagonc.com:5600` | From the container: `getent hosts sip.flagonc.com` should resolve; check your network/VPN/firewall allows outbound UDP. | +| Registered but inbound silent | One-way audio / NAT | Keep `direct_media=no` (already set); inbound media returns via Asterisk. | + +### Clean up + +Remove the `[flagonc*]` blocks from `pjsip.conf` and the `from-pstn`/`_9.` lines from +`extensions.conf`, then `module reload res_pjsip.so` and `dialplan reload`. + +--- + +## Lab 6 — A WebRTC phone in the browser + +> **You will:** register and call from a web browser with no softphone installed. +> **Time:** ~25 min **Prerequisites:** Lab 0. +> **Book chapter:** *WebRTC*. + +### Step 1 — Generate the WebRTC certificate + +WebRTC requires TLS. Generate the lab's self-signed cert and restart Asterisk: + +```bash +bash lab/make-certs.sh +docker compose -f lab/docker-compose.yml restart asterisk +``` + +(On Windows, run `bash lab/make-certs.sh` from **Git Bash** or **WSL**.) + +### Step 2 — Serve the browser client + +The lab ships a minimal WebRTC page at `lab/webrtc/index.html`. Serve it locally: + +```bash +cd lab/webrtc +python3 -m http.server 8000 +``` + +Open **`http://localhost:8000`** in Chrome or Edge. + +### Step 3 — Trust the certificate + +In a new tab, visit **`https://localhost:8089/ws`** and accept/proceed past the +self-signed-certificate warning once. This lets the browser open the secure WebSocket to +Asterisk. + +### Step 4 — Register and call + +In the WebRTC page, connect as **`webrtc-1000`** / **`Lab-webrtc-secret`**. Then: + +- Dial **`600`** — the echo test; you should hear yourself in the browser. +- From the SipPulse Softphone registered as `6001`, dial **`1000`** — the browser rings. + +Confirm the browser is registered: + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoint webrtc-1000' +``` + +### ✅ Checkpoint + +The browser registers as `webrtc-1000`, the echo test plays your voice back, and `6001` can +ring the browser by dialling `1000`. + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Browser won't connect to WSS | Cert not trusted | Visit `https://localhost:8089/ws` and accept the warning first. | +| Mic blocked | Browser permission | Allow microphone access for `localhost`; WebRTC needs a secure context. | +| `8089` refused | Cert not generated / Asterisk not restarted | Re-run `lab/make-certs.sh`, then restart the container. | + +### Clean up + +Stop the `python3 -m http.server` (Ctrl-C). The cert can stay; it's gitignored. + +--- + +## Lab 7 — Secure your SIP with TLS and SRTP + +> **You will:** offer encrypted signaling (TLS) and encrypted media (SRTP) to a phone. +> **Time:** ~20 min **Prerequisites:** Lab 6 (certificate already generated). +> **Book chapter:** *Securing Asterisk*. + +### Step 1 — Add a TLS transport + +The lab's WebRTC transport already uses the cert from Lab 6. Add a SIP-over-TLS transport for +softphones. Append to `lab/asterisk/etc/pjsip.conf`: + +```ini +[transport-tls] +type=transport +protocol=tls +bind=0.0.0.0:5061 +cert_file=/etc/asterisk/keys/asterisk.crt +priv_key_file=/etc/asterisk/keys/asterisk.key +method=tlsv1_2 +``` + +You'll also need to publish port `5061`. Add `- "5061:5061/tcp"` under `ports:` in +`lab/docker-compose.yml`, then recreate the container: + +```bash +docker compose -f lab/docker-compose.yml up -d +``` + +### Step 2 — Require encrypted media on an endpoint + +Make `6001` use TLS + SRTP by adding `media_encryption` to it. The simplest way in the lab is to +append an override; in `pjsip.conf` set on the `6001` endpoint: + +```ini +media_encryption=sdes +``` + +Reload: `docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'module reload res_pjsip.so'`. + +### Step 3 — Point the softphone at TLS + +In the SipPulse Softphone account for `6001`, change the transport to **TLS**, port **`5061`**, +and accept the self-signed certificate. Re-register. + +### Step 4 — Verify encryption + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoint 6001' +``` + +Place a call to `600` and confirm in `pjsip set logger on` that signaling is over TLS and the +SDP offers `RTP/SAVP` (SRTP). + +### ✅ Checkpoint + +`6001` registers over **TLS/5061**, and a call to the echo test negotiates **SRTP** (`RTP/SAVP` +in the SDP). Audio still works end to end. + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| TLS handshake fails | Cert not trusted by phone | Accept/import the self-signed cert in the softphone; for the lab, disable strict cert verification. | +| Registers on UDP not TLS | Phone still on 5060/UDP | Set transport TLS and port `5061` in the phone. | +| No audio with SRTP | One side not offering SRTP | Confirm `media_encryption=sdes` on the endpoint and SRTP enabled in the phone. | + +### Clean up + +Remove `[transport-tls]`, the `media_encryption` line, and the `5061` port mapping; recreate +the container and reload. + +--- + +## Lab 8 — Control Asterisk with ARI + +> **You will:** call the Asterisk REST Interface (ARI) and originate a call from a script. +> **Time:** ~20 min **Prerequisites:** Lab 1. +> **Book chapter:** *The Asterisk REST Interface (ARI)*. + +### Step 1 — Enable ARI + +Create a **new file** `lab/asterisk/etc/ari.conf`: + +```ini +[general] +enabled=yes + +[labuser] +type=user +password=Lab-ari-secret +``` + +Reload ARI: + +```bash +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'module reload res_ari.so' +``` + +(The lab already enables the HTTP server on `8088` and publishes it to your computer.) + +### Step 2 — Query ARI from your computer + +ARI is plain HTTP + JSON. From your normal shell: + +```bash +curl -s -u labuser:Lab-ari-secret http://localhost:8088/ari/asterisk/info +``` + +**You should see** JSON describing the running system — note `"version":"22.10.0"`: + +```json +{"build":{"os":"Linux", ... ,"date":"2026-06-19 00:12:19 UTC","user":"root"}, + "system":{"version":"22.10.0","entity_id":"..."}, + "config":{"name":"","default_language":"en", ...}, + "status":{"startup_time":"...","last_reload_time":"..."}} +``` + +> On Windows, `curl` ships with Windows 10/11; in PowerShell use `curl.exe` (with the `.exe`) so +> you get real curl and not the PowerShell alias. + +### Step 3 — List your endpoints over ARI + +```bash +curl -s -u labuser:Lab-ari-secret http://localhost:8088/ari/endpoints +``` + +You'll get a JSON array of your PJSIP endpoints (`6001`, `6002`, …) with their states. + +### Step 4 — Originate a call from the API + +Make Asterisk ring `6001` and drop it into the echo test — no dialplan trigger, driven entirely +by the REST call: + +```bash +curl -s -u labuser:Lab-ari-secret -X POST \ + "http://localhost:8088/ari/channels?endpoint=PJSIP/6001&extension=600&context=internal&priority=1&callerId=ARI" +``` + +Your `6001` softphone rings; answer it and you're in the echo test. + +### ✅ Checkpoint + +`/ari/asterisk/info` returns `22.10.0`, `/ari/endpoints` lists your phones, and the originate +POST makes `6001` ring. + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `401 Unauthorized` | Wrong ARI credentials | Use `labuser` / `Lab-ari-secret` exactly; confirm `ari.conf` reloaded. | +| `curl: connection refused` | 8088 not reachable | Ensure the lab was recreated after the compose change (`up -d`); ARI is on `localhost:8088`. | +| Originate returns 4xx | Endpoint offline | Register `6001` first; check the JSON error message. | + +### Clean up + +Delete `ari.conf` and `module reload res_ari.so`. + +--- + +## Appendix A — Everyday lab commands + +```bash +# Start / stop / rebuild +docker compose -f lab/docker-compose.yml up -d # start (or apply compose changes) +docker compose -f lab/docker-compose.yml down # stop and remove +docker compose -f lab/docker-compose.yml restart asterisk + +# Live console and one-shot commands +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rvvv +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoints' +docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'core show channels' + +# Reloads (narrowest that works) +... -rx 'dialplan reload' # extensions.conf +... -rx 'module reload res_pjsip.so' # pjsip.conf +... -rx 'voicemail reload' # voicemail.conf +... -rx 'module reload app_queue.so' # queues.conf +... -rx 'module reload res_ari.so' # ari.conf + +# See SIP on the wire +... -rx 'pjsip set logger on' # ... 'pjsip set logger off' + +# Tail the logs +docker compose -f lab/docker-compose.yml logs -f asterisk +``` + +## Appendix B — Editing config files safely + +- All config lives under **`lab/asterisk/etc/`** on your computer. The folder is mounted into + the container, so your edits appear instantly — you only need to *reload*. +- On **Windows**, edit with **VS Code** (or any editor that keeps **LF** line endings). Notepad + may insert Windows line endings that confuse Asterisk. +- After every edit, run the matching reload from Appendix A and read its output for parse + errors. +- To get back to a clean slate at any time: + `git checkout -- lab/asterisk/etc/` then `docker compose -f lab/docker-compose.yml restart asterisk`. + +## Appendix C — Resetting everything + +```bash +docker compose -f lab/docker-compose.yml down +git checkout -- lab/asterisk/etc/ # discard your config edits +docker compose -f lab/docker-compose.yml up -d --build +``` + +--- + +*Asterisk Guide — Hands-On Lab Manual. Asterisk 22.10.0, PJSIP-only, hardware-free. Every +command in this manual was verified against the lab. Licensed CC BY-NC-SA 4.0.* diff --git a/labs/LAB-GUIDE.md b/labs/LAB-GUIDE.md index 53ba12b..7e3c041 100644 --- a/labs/LAB-GUIDE.md +++ b/labs/LAB-GUIDE.md @@ -1,917 +1,157 @@ # Asterisk Guide — Hands-On Lab Manual -A companion workbook to *Asterisk Guide* (2nd edition, Asterisk 22 LTS). Every lab here runs -on your own computer — **Windows, macOS, or Linux** — inside a reproducible **Docker** -environment. No server, no SIP hardware, no analog cards, and nothing legacy: it is pure -**PJSIP** on **Asterisk 22.10.0**, exactly like the book. +A companion workbook to *Asterisk Guide* (2nd edition, Asterisk 22 LTS), and the lab +track of the **Asterisk Black Belt Academy** course. Both read from these files — there +is one set of labs, not two. -Work through the labs in order. Each one is self-contained, takes 10–30 minutes, and ends with -a checkpoint you can see or hear. If something breaks, every lab has a Troubleshooting table. +Every lab runs on **one Linux server you build in Lab 0** and keep for the rest of the +course — a virtual machine on your own computer, or, on an Apple Silicon Mac, a cheap +cloud server. It is a real Ubuntu 24.04 LTS server running a real Asterisk 22 under +systemd. Everything you learn on it — `systemctl`, `/etc/asterisk`, `asterisk -rvvv` — +is what you would type on a customer's server. ---- - -## Lab Credentials & Quick Reference *(keep this page open)* - -Everything you need to log in, anywhere in this manual, is here. You will not have to invent a -single username or password. - -### Softphone accounts (your PBX, created by the lab) - -**Get the softphone:** download the **SipPulse Softphone** from - (Windows/macOS/Linux), or use the no-install -browser version at . - -| Account | Password | What it is | -|--------:|----------|------------| -| `6001` | `Lab-6001-secret` | Desk phone "Alice" | -| `6002` | `Lab-6002-secret` | Desk phone "Bob" | -| `webrtc-1000` | `Lab-webrtc-secret` | Browser phone (Lab 6) | - -- **SIP server / domain:** your own computer — **`127.0.0.1`** (or your machine's LAN IP if the - softphone runs on a different device), **UDP port 5060**. -- **Transport:** UDP. **Codecs:** μ-law (PCMU), A-law (PCMA). - -### SIP trunk (shared lab provider — for Lab 5) - -| Setting | Value | -|---------|-------| -| Provider host | `sip.flagonc.com` | -| Port | **`5600`** (not 5060) | -| Account (pick one) | `1010`–`1050` | -| Password | `supersecret` (same for every account) | -| Provider echo test | dial `*98` over the trunk (free) | - -> Pick **any one** account in `1010`–`1050` — they all share the password `supersecret`. So your -> account doesn't clash with another student's, use the number your instructor assigns you. This -> manual's worked example uses **`1020`**. - -### Services you will switch on later - -| Service | Where | Login | -|---------|-------|-------| -| Voicemail PIN (Lab 3) | dial `*97` | mailbox `6001`/`6002`, PIN `1234` | -| ARI REST API (Lab 8) | `http://localhost:8088/ari` | `labuser` / `Lab-ari-secret` | -| WebRTC signaling (Lab 6) | `wss://localhost:8089/ws` | account `webrtc-1000` | - -### The two commands you will use constantly - -```bash -# 1) Run any Asterisk CLI command from your normal shell (Terminal or PowerShell): -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoints' - -# 2) Open a live, interactive Asterisk console (Ctrl-C to leave): -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rvvv -``` - -Throughout this manual, **"run in the Asterisk CLI"** means form (1) or typing the command at -the `*CLI>` prompt of form (2). **"Edit a file"** always means a file under -`lab/asterisk/etc/` on your computer — save it, then reload as the lab tells you. - ---- - -## Lab 0 — Set up the lab on your computer - -> **You will:** get the Asterisk 22 lab running on Windows, macOS, or Linux and place your -> first registration. -> **Time:** ~20 min **Prerequisites:** none. - -### Step 1 — Install Docker - -You need Docker with the `docker compose` command. - -- **Windows 10/11:** install **Docker Desktop** from . - Accept the WSL 2 backend when prompted, then reboot. Launch Docker Desktop and wait for the - whale icon to say *"Engine running."* -- **macOS (Intel or Apple Silicon):** install **Docker Desktop** from the same page and launch - it. (OrbStack also works.) -- **Linux:** install **Docker Engine** + the compose plugin — - `sudo apt-get install docker.io docker-compose-plugin` (Debian/Ubuntu) or follow - . Add yourself to the `docker` group - (`sudo usermod -aG docker $USER`) and log out/in. - -Confirm it works — in **Terminal** (macOS/Linux) or **PowerShell** (Windows): - -```bash -docker --version -docker compose version -``` - -You should see a Docker version `24.x` or newer and a Compose `v2.x` line. - -### Step 2 — Get the lab files - -Clone the repository (install Git first if needed), then move into it: - -```bash -git clone https://github.com/flaviogoncalves/asterisk-guide.git -cd asterisk-guide -``` - -> No Git? Download the repo's ZIP from GitHub ("Code → Download ZIP"), unzip it, and `cd` into -> the unzipped folder. On Windows, **use VS Code or another editor — not Notepad —** to edit -> config files later, so line endings stay correct. - -### Step 3 — Start the lab - -```bash -docker compose -f lab/docker-compose.yml up -d --build -``` - -The first run downloads and compiles Asterisk 22.10.0 from source, so it can take several -minutes. When it finishes, check it is healthy: - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'core show version' -``` - -**You should see:** - -``` -Asterisk 22.10.0 built by root @ ... running Linux -``` - -### Step 4 — Look at what is already configured - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoints' -``` - -**You should see** four endpoints — `6001`, `6002`, `sipp`, `webrtc-1000` — all -`Unavailable` (no phone has registered *yet*): - -``` - Endpoint: 6001 Unavailable 0 of inf - InAuth: 6001/6001 - Aor: 6001 1 - Endpoint: 6002 Unavailable 0 of inf -... -Objects found: 4 -``` - -### Step 5 — Install a softphone and register `6001` - -Download the **SipPulse Softphone** for Windows, macOS, or Linux from - (or, to skip installing, use the browser version -at ). Then create an account with the credentials -from the front page: - -| Field | Value | -|-------|-------| -| Username / Auth user | `6001` | -| Password | `Lab-6001-secret` | -| Domain / SIP server | `127.0.0.1` (same computer) — or your machine's LAN IP | -| Port | `5060`, transport **UDP** | - -Save and let it register. Now re-check from the Asterisk CLI: - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoint 6001' -``` - -### ✅ Checkpoint - -`pjsip show endpoint 6001` reports the endpoint **`Not in use`** (instead of `Unavailable`) and -lists a **Contact** with a `sip:` URI from your phone. Your softphone shows *Registered* / -*Online*. The lab is alive. - -### Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| Softphone never registers | Wrong domain/port, or firewall | Use `127.0.0.1:5060` UDP if the phone is on the same computer; allow Docker through the firewall when Windows/macOS prompts. | -| `endpoint ... Unavailable` still | Phone not actually registered | Re-check username `6001` and password `Lab-6001-secret` exactly; watch `pjsip set logger on` for a `401`. | -| `port is already allocated` on `up` | Something else uses 5060/8088/8089 | Stop the other SIP app, or edit the host-side ports in `lab/docker-compose.yml`. | - -### Clean up - -Leave the lab running for the next labs. To stop it later: -`docker compose -f lab/docker-compose.yml down`. - ---- - -## Lab 1 — Your first calls - -> **You will:** call between two softphones and run the classic echo test. -> **Time:** ~15 min **Prerequisites:** Lab 0. Register **both** `6001` and `6002` (use a -> second softphone, a second device, or a second profile). -> **Book chapter:** *Building Your First PBX*. - -### Step 1 — See the dialplan you already have - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'dialplan show internal' -``` - -**You should see** the `internal` context with extensions `600`, `6001`, `6002`, `9000`, -`1000`: - -``` - '600' => 1. Answer() - 2. Playback(demo-echotest) - 3. Echo() - 4. Hangup() - '6001' => 1. Dial(PJSIP/6001,20) - '6002' => 1. Dial(PJSIP/6002,20) --= 5 extensions (11 priorities) in 1 context. =- -``` - -### Step 2 — Call phone to phone - -From the softphone registered as `6001`, dial **`6002`**. The other phone rings; answer it and -talk. Then hang up and call the other way. - -While a call is up, watch it live: - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'core show channels' -``` - -You will see two `PJSIP/…` channels bridged together. - -### Step 3 — Run the echo test - -From either phone, dial **`600`**. Asterisk answers, plays a short prompt, then echoes your -microphone back to your ear with a tiny delay. This is the fastest way to prove two-way audio -(RTP) is flowing. - -### ✅ Checkpoint - -You can hold a two-way conversation between `6001` and `6002`, and dialling `600` plays your -own voice back. If `600` is silent in one direction, your RTP/audio path has a NAT or firewall -problem — see the table. - -### Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| Phone rings but no audio | RTP blocked | Make sure the lab's `10000-10100/udp` ports aren't blocked by your firewall; keep both phones on the same machine for the simplest path. | -| "All circuits are busy" / 404 | Dialled an unknown extension | Only `600`, `6001`, `6002`, `9000`, `1000` exist until you add more. | -| Calls drop after 20 s on no-answer | `Dial(...,20)` timeout | Expected — answer within 20 seconds. | - -### Clean up - -Nothing to undo — you only placed calls. - ---- - -## Lab 2 — A dial plan with an auto-attendant (IVR) - -> **You will:** build a small voice menu: "Press 1 for Alice, 2 for Bob, 0 for the echo test." -> **Time:** ~20 min **Prerequisites:** Lab 1. -> **Book chapter:** *Building an Interactive Dial Plan*. - -### Step 1 — Add an IVR context - -Open `lab/asterisk/etc/extensions.conf` in your editor and add this new context at the bottom: - -```ini -[ivr] -exten => start,1,Answer() - same => n,Wait(1) - same => n(menu),Background(demo-instruct) ; play the menu, listen for a digit - same => n,WaitExten(5) ; wait 5s for the caller to press a key - -exten => 1,1,Dial(PJSIP/6001,20) -exten => 2,1,Dial(PJSIP/6002,20) -exten => 0,1,Goto(internal,600,1) ; echo test -exten => i,1,Playback(pbx-invalid) ; invalid key - same => n,Goto(ivr,start,menu) -exten => t,1,Playback(vm-goodbye) ; timed out - same => n,Hangup() -``` - -### Step 2 — Give your phones a way to reach the menu - -Still in `extensions.conf`, add one line to the existing `[internal]` context so dialling -**`500`** enters the IVR: - -```ini -exten => 500,1,Goto(ivr,start,1) -``` - -### Step 3 — Reload and verify - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'dialplan reload' -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'dialplan show ivr' -``` - -**You should see** the `ivr` context with `start`, `1`, `2`, `0`, `i`, `t`. - -### Step 4 — Try it - -From `6001`, dial **`500`**. Listen to the prompt, then press **`2`** — phone `6002` rings. -Hang up, dial `500` again, and press **`0`** for the echo test. Press a wrong key (e.g. `9`) to -hear the invalid-key handling. - -### ✅ Checkpoint - -Dialling `500` answers with a menu, and `1`/`2`/`0` route the call as configured; a bad key -re-plays the menu. - -### Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| `dialplan reload` shows no `ivr` | Typo / unbalanced context | Re-check the `[ivr]` header and indentation; run the reload again and read for a parse error. | -| Key presses ignored | DTMF mode mismatch | The lab uses RFC 4733 DTMF by default; set the same in your softphone (often "RFC2833"). | -| "Invalid" every time | Prompt still playing | `Background` lets you dial *during* the prompt; wait a beat or press the key again. | - -### Clean up - -To return to the base dial plan, delete the `[ivr]` context and the `exten => 500` line you -added, then `dialplan reload`. (Or keep them — later labs don't conflict.) - ---- - -## Lab 3 — Voicemail - -> **You will:** send unanswered calls to voicemail and retrieve messages with `*97`. -> **Time:** ~20 min **Prerequisites:** Lab 1. -> **Book chapter:** *Voicemail*. - -### Step 1 — Create the mailboxes - -Create a **new file** `lab/asterisk/etc/voicemail.conf` with two mailboxes (PIN `1234`): - -```ini -[general] -format=wav49|gsm|wav -maxmsg=100 - -[default] -6001 => 1234,Alice Lab,alice@example.com -6002 => 1234,Bob Lab,bob@example.com -``` - -Reload and confirm: - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'voicemail reload' -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'voicemail show users' -``` - -**You should see:** - -``` -Context Mbox User Zone NewMsg -default 6001 Alice Lab 0 -default 6002 Bob Lab 0 -2 voicemail users configured. -``` - -### Step 2 — Send unanswered calls to voicemail - -Edit `lab/asterisk/etc/extensions.conf`. Replace the two existing phone lines in `[internal]`: - -```ini -exten => 6001,1,Dial(PJSIP/6001,20) -exten => 6002,1,Dial(PJSIP/6002,20) -``` - -with versions that fall through to voicemail when the call isn't answered in 20 seconds: - -```ini -exten => 6001,1,Dial(PJSIP/6001,20) - same => n,VoiceMail(6001@default,u) - same => n,Hangup() -exten => 6002,1,Dial(PJSIP/6002,20) - same => n,VoiceMail(6002@default,u) - same => n,Hangup() -``` - -Add an extension to **check** your own messages: - -```ini -exten => *97,1,VoiceMailMain(${CALLERID(num)}@default) -``` - -Reload: `docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'dialplan reload'`. - -### Step 3 — Leave and retrieve a message - -1. From `6001`, call `6002` and **don't answer**. After 20 s you hear the "please leave a - message" prompt — record one and hang up. -2. From `6002`, dial **`*97`**, enter PIN **`1234`**, and follow the prompts to hear the - message. - -### ✅ Checkpoint - -`voicemail show users` shows `NewMsg` `1` for `6002` after you leave a message, and `*97` plays -it back. - -### Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| No "leave a message" prompt | Dialplan not reloaded, or call answered | Reload, and make sure the called phone truly rings out (don't pick up). | -| `*97` asks for a mailbox number | Caller ID not set to `6001`/`6002` | Enter the mailbox number manually, then PIN `1234`. | -| "Sorry, I can't let you do that" | Wrong PIN | The PIN is `1234`. | - -### Clean up - -Delete `voicemail.conf`, revert the `[internal]` phone lines, remove `*97`, then -`dialplan reload` and `voicemail reload`. +Work through them in order. Each one starts from the verified end-state of the last, and +ends with a checkpoint you can see or hear. --- -## Lab 4 — A call queue - -> **You will:** put callers in a queue that rings both agents. -> **Time:** ~15 min **Prerequisites:** Lab 1. -> **Book chapter:** *Queues and Call Centers*. - -### Step 1 — Define the queue - -Create a **new file** `lab/asterisk/etc/queues.conf`: - -```ini -[general] - -[support] -strategy=ringall -timeout=15 -member => PJSIP/6001 -member => PJSIP/6002 -``` - -Reload and check: - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'module reload app_queue.so' -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'queue show support' -``` - -**You should see:** - -``` -support has 0 calls (max unlimited) in 'ringall' strategy ... - Members: - PJSIP/6002 (ringinuse enabled) (Unavailable) has taken no calls yet ... - PJSIP/6001 (ringinuse enabled) (Unavailable) has taken no calls yet ... - No Callers -``` - -(Members read `Unavailable` until their softphones are registered.) - -### Step 2 — Route a number into the queue - -Add to `[internal]` in `extensions.conf`, then `dialplan reload`: - -```ini -exten => 700,1,Answer() - same => n,Queue(support) - same => n,Hangup() -``` - -### Step 3 — Test - -Register both `6001` and `6002`. From a third identity (or the WebRTC phone from Lab 6, or by -temporarily un-registering one agent), dial **`700`**. Both agents' phones ring; whoever -answers takes the call. Watch it: - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'queue show support' -``` - -### ✅ Checkpoint - -Dialling `700` rings the registered agents, and `queue show support` shows the caller in queue -then the answering agent's call count increment. - -### Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| Members always `Unavailable` | Agents not registered | Register `6001`/`6002` first; re-run `queue show support`. | -| Caller hears silence | No agents available | Register at least one agent; `ringall` needs a reachable member. | - -### Clean up - -Delete `queues.conf` and the `exten => 700` block; `dialplan reload` and -`module reload app_queue.so`. +## The labs + +| # | Lab | Covers | Time | +|---|---|---|---| +| 0 | [Build Your Lab Machine](lab0-build-machine.md) | VirtualBox or a cloud server, Ubuntu 24.04, networking | 20–50 min | +| 1 | [Install Asterisk 22 From Source](lab1-install.md) | configure, menuselect, make, systemd | 25 min | +| 2 I | [Create SIP Extensions](lab2-part1-extensions.md) | transports, endpoints, auth, AORs | 30 min | +| 2 II | [Register Softphones, First Call](lab2-part2-softphones.md) | REGISTER, digest auth, first dialplan | 35 min | +| 3 | [Connect a SIP Trunk](lab3-trunk.md) | outbound registration, identify, toll-fraud contexts | 30 min | +| 4 | [Build a Real Dialplan](lab4-dialplan.md) | patterns, DIALSTATUS, IVR, context security, time routing | 45 min | +| 5 | [Voicemail, Transfers and a Call Queue](lab5-features-queues.md) | voicemail, transfer, park, pickup, MOH, queues | 50 min | +| 6 | [See What SIP Actually Sends](lab6-sip-in-depth.md) | sngrep, SDP, codecs, NAT, WebRTC, TLS/SRTP | 55 min | +| 7 | [Make Asterisk Talk to Your Own Code](lab7-programmability.md) | CDR to MariaDB, AMI, AGI, ARI/Stasis | 55 min | +| 8 | [Lock It Down and Keep It Running](lab8-security-operations.md) | security log, fail2ban, iptables, systemd, backup, monitoring | 60 min | + +Labs 6, 7 and 8 each deliberately cover more than one chapter. Reading about codec +negotiation does not need a checkpoint; *capturing a call and reading its SDP* does — and +that is one lab, not ten. --- -## Lab 5 — Connect a real SIP trunk - -> **You will:** register your lab PBX to a real ITSP (`sip.flagonc.com`) and route calls over -> it. -> **Time:** ~25 min **Prerequisites:** Lab 1. Internet access from the container. -> **Book chapter:** *SIP Trunking and DID*. -> -> *Lab sponsor:* this trunk lab is served by the shared provider on `sip.flagonc.com`. - -### Step 1 — Add the trunk to PJSIP - -Edit `lab/asterisk/etc/pjsip.conf` and append a trunk. **Use the account assigned to you** -(`1010`–`1050`); this example uses `1020`. Note the provider port is **`5600`**, and the -`from_user` line is what lets the provider recognize your outbound calls: - -```ini -; ---- SIP trunk: sip.flagonc.com ---- -[flagonc] -type=registration -outbound_auth=flagonc-auth -server_uri=sip:1020@sip.flagonc.com:5600 -client_uri=sip:1020@sip.flagonc.com -contact_user=9999 - -[flagonc-auth] -type=auth -auth_type=userpass -username=1020 -password=supersecret - -[flagonc] -type=endpoint -context=from-pstn -transport=transport-udp -disallow=all -allow=ulaw -direct_media=no -outbound_auth=flagonc-auth -aors=flagonc -from_user=1020 -from_domain=sip.flagonc.com - -[flagonc] -type=aor -contact=sip:sip.flagonc.com:5600 - -[flagonc] -type=identify -endpoint=flagonc -match=sip.flagonc.com -``` +## Lab credentials and quick reference *(keep this page open)* -### Step 2 — Reload and confirm registration +Everything you need is here. You will not have to invent a username or password. -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'module reload res_pjsip.so' -# give it a few seconds, then: -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show registrations' -``` +### Your lab machine -**You should see** `Status` **`Registered`**: +| | | +|---|---| +| Login | `lab` / `lab` | +| Address | run `lab ip` on the VM — it is assigned by your router | +| Asterisk CLI | `sudo asterisk -rvvv` (leave with `Ctrl-C`) | +| One-off command | `asterisk -rx 'core show version'` | +| Configuration | `/etc/asterisk/` | +| Log | `/var/log/asterisk/messages.log` | +| Restore a working config | `sudo lab reset` | -``` - -========================================================================================== - flagonc/sip:1020@sip.flagonc.com:5600 flagonc-auth Registered (exp. 3554s) -``` - -If it says **`Rejected`**, the account/password was refused (see Troubleshooting). If it stays -**`Unregistered`** for more than ~15 s, the provider didn't answer — check connectivity. - -### Step 3 — Receive an inbound call (free, no charges) - -Add a landing place for inbound calls. In `extensions.conf`, add a `from-pstn` context: - -```ini -[from-pstn] -exten => 9999,1,NoOp(Inbound from trunk, caller ${CALLERID(num)}) - same => n,Answer() - same => n,Playback(demo-congrats) - same => n,Goto(ivr,start,1) ; hand off to your IVR from Lab 2 (optional) -exten => _.,1,Goto(9999,1) ; catch any inbound number -``` - -Reload (`dialplan reload`). Now trigger an inbound call. Two ways: - -- **Self-test (no second person):** once the outbound rule from Step 4 is in place, dial **your - own trunk number** — e.g. from `6001` dial `1050` (your account). The call goes out to the - provider, which routes it straight back to your registered phone as a genuine *inbound* call, - landing in `from-pstn`. -- **From outside:** have a classmate dial your account number, or use the provider's web - click-to-call. - -Watch the inbound leg arrive — you'll see a `PJSIP/flagonc-…` channel in **`from-pstn`** answer -and play the prompt: - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'core show channels' -``` - -``` -PJSIP/flagonc-00000005 9999@from-pstn:3 Up Playback(demo-congrats) -``` - -### Step 4 — Place an outbound call (free, over the trunk) - -The provider hosts a **free echo test on `*98`** and lets accounts call each other, so you can -test outbound without any PSTN charges. Add an outbound rule to `[internal]`: - -```ini -exten => 800,1,Dial(PJSIP/*98@flagonc,30) ; provider echo test (free) -exten => _10XX,1,Dial(PJSIP/${EXTEN}@flagonc,30) ; ring another lab account (e.g. 1011) -exten => _20XX,1,Dial(PJSIP/${EXTEN}@flagonc,30) ; ring a 20xx lab account -``` - -`dialplan reload`, then from `6001` dial **`800`** — the call goes out over the trunk to the -provider's echo test and you hear yourself. Or dial another live account (e.g. `1011`) to ring a -classmate. Watch it go out: - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'core show channels' -``` - -You'll see a `PJSIP/flagonc-…` channel reach **`Up`**. - -### ✅ Checkpoint - -`pjsip show registrations` reads **`Registered`**; dialling `800` reaches the provider's echo -test over the trunk (a `PJSIP/flagonc-…` channel goes `Up`); and dialling your own number (the -self-test above) comes back as an **inbound** `PJSIP/flagonc-…` channel in `from-pstn`. - -### Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| `Rejected` | Wrong password, or an account outside `1010`–`1050` | Confirm port `5600`, password `supersecret`, and an account in `1010`–`1050`. A `403 Forbidden` in `pjsip set logger on` means the credentials were refused (e.g. account `1001` doesn't exist). | -| Outbound `403 Forbidden` | Provider can't identify the caller | Make sure `from_user=` is on the `[flagonc]` endpoint — without it the provider rejects your INVITE. | -| `Unregistered`, never changes | No route to `sip.flagonc.com:5600` | From the container: `getent hosts sip.flagonc.com` should resolve; check your network/VPN/firewall allows outbound UDP. | -| Registered but inbound silent | One-way audio / NAT | Keep `direct_media=no` (already set); inbound media returns via Asterisk. | +Wherever a lab says **``**, use the address `lab ip` printed. -### Clean up +### Softphone accounts -Remove the `[flagonc*]` blocks from `pjsip.conf` and the `from-pstn`/`_9.` lines from -`extensions.conf`, then `module reload res_pjsip.so` and `dialplan reload`. - ---- - -## Lab 6 — A WebRTC phone in the browser - -> **You will:** register and call from a web browser with no softphone installed. -> **Time:** ~25 min **Prerequisites:** Lab 0. -> **Book chapter:** *WebRTC*. - -### Step 1 — Generate the WebRTC certificate - -WebRTC requires TLS. Generate the lab's self-signed cert and restart Asterisk: - -```bash -bash lab/make-certs.sh -docker compose -f lab/docker-compose.yml restart asterisk -``` +Install a softphone on **your own computer**, not inside the VM: -(On Windows, run `bash lab/make-certs.sh` from **Git Bash** or **WSL**.) +| Platform | Softphone | +|---|---| +| Windows | **MicroSIP** — | +| macOS / Linux | **Linphone** — | +| Terminal | **baresip** — `apt install baresip` | -### Step 2 — Serve the browser client - -The lab ships a minimal WebRTC page at `lab/webrtc/index.html`. Serve it locally: - -```bash -cd lab/webrtc -python3 -m http.server 8000 -``` - -Open **`http://localhost:8000`** in Chrome or Edge. - -### Step 3 — Trust the certificate - -In a new tab, visit **`https://localhost:8089/ws`** and accept/proceed past the -self-signed-certificate warning once. This lets the browser open the secure WebSocket to -Asterisk. +| Account | Password | Who | +|---|---|---| +| `6001` | `Lab-6001-secret` | Alice | +| `6002` | `Lab-6002-secret` | Bob | +| `webrtc-1000` | `Lab-webrtc-secret` | Browser phone (Lab 6) | -### Step 4 — Register and call +- **SIP server / domain:** ``, **port 5060**, transport **UDP** +- **Codecs:** μ-law (PCMU) and A-law (PCMA) -In the WebRTC page, connect as **`webrtc-1000`** / **`Lab-webrtc-secret`**. Then: +### Numbers you will build -- Dial **`600`** — the echo test; you should hear yourself in the browser. -- From the SipPulse Softphone registered as `6001`, dial **`1000`** — the browser rings. +| Number | What it does | Built in | +|---|---|---| +| `6001` / `6002` | The desk phones | Lab 2 | +| `600` | Echo test — proves two-way audio | Lab 2 | +| `800` | Echo test *at the gateway*, over the trunk | Lab 3 | +| `6000` | Auto-attendant (IVR) | Lab 4 | +| `*97` | Collect your voicemail (PIN `1234`) | Lab 5 | +| `700` / `701`–`720` | Park a call / retrieve it | Lab 5 | +| `**` | Pick up a colleague's ringing phone | Lab 5 | +| `6500` | The support queue | Lab 5 | -Confirm the browser is registered: +### Services you switch on later -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoint webrtc-1000' -``` +You create these yourself, in the lab named. They are listed here so you never have to +hunt back through a lab for a password. -### ✅ Checkpoint +| Service | Lab | Login | +|---|---|---| +| Voicemail | 5 | mailbox `6001` / `6002`, PIN `1234`, dial `*97` | +| ARI (REST API) | 7 | `labuser` / `Lab-ari-secret` on port `8088` | +| AMI (manager socket) | 7 | `labami` / `Lab-ami-secret` on `127.0.0.1:5038` | +| CDR database | 7 | MariaDB `asterisk` / `Lab-cdr-secret`, database `asterisk` | -The browser registers as `webrtc-1000`, the echo test plays your voice back, and `6001` can -ring the browser by dialling `1000`. +### The simulated PSTN gateway (Lab 3) -### Troubleshooting +A real Asterisk server standing in for the public network. Everyone shares it. -| Symptom | Cause | Fix | -|---------|-------|-----| -| Browser won't connect to WSS | Cert not trusted | Visit `https://localhost:8089/ws` and accept the warning first. | -| Mic blocked | Browser permission | Allow microphone access for `localhost`; WebRTC needs a secure context. | -| `8089` refused | Cert not generated / Asterisk not restarted | Re-run `lab/make-certs.sh`, then restart the container. | +| | | +|---|---| +| Host | `sip.flagonc.com` | +| Port | **`5600`** — not 5060 | +| Account | the number you were assigned, in `1010`–`1050` | +| Password | `supersecret` | +| Free echo test | `*98` | -### Clean up - -Stop the `python3 -m http.server` (Ctrl-C). The cert can stay; it's gitignored. +> Use **your assigned account**. Two students on one number fight over the registration +> and both lose it. --- -## Lab 7 — Secure your SIP with TLS and SRTP - -> **You will:** offer encrypted signaling (TLS) and encrypted media (SRTP) to a phone. -> **Time:** ~20 min **Prerequisites:** Lab 6 (certificate already generated). -> **Book chapter:** *Securing Asterisk*. - -### Step 1 — Add a TLS transport - -The lab's WebRTC transport already uses the cert from Lab 6. Add a SIP-over-TLS transport for -softphones. Append to `lab/asterisk/etc/pjsip.conf`: - -```ini -[transport-tls] -type=transport -protocol=tls -bind=0.0.0.0:5061 -cert_file=/etc/asterisk/keys/asterisk.crt -priv_key_file=/etc/asterisk/keys/asterisk.key -method=tlsv1_2 -``` - -You'll also need to publish port `5061`. Add `- "5061:5061/tcp"` under `ports:` in -`lab/docker-compose.yml`, then recreate the container: +## The two commands you will use constantly ```bash -docker compose -f lab/docker-compose.yml up -d -``` - -### Step 2 — Require encrypted media on an endpoint +# Run one Asterisk command and come straight back to the shell: +sudo asterisk -rx 'pjsip show endpoints' -Make `6001` use TLS + SRTP by adding `media_encryption` to it. The simplest way in the lab is to -append an override; in `pjsip.conf` set on the `6001` endpoint: - -```ini -media_encryption=sdes -``` - -Reload: `docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'module reload res_pjsip.so'`. - -### Step 3 — Point the softphone at TLS - -In the SipPulse Softphone account for `6001`, change the transport to **TLS**, port **`5061`**, -and accept the self-signed certificate. Re-register. - -### Step 4 — Verify encryption - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoint 6001' +# Open the live console — this is where calls narrate themselves: +sudo asterisk -rvvv ``` -Place a call to `600` and confirm in `pjsip set logger on` that signaling is over TLS and the -SDP offers `RTP/SAVP` (SRTP). - -### ✅ Checkpoint - -`6001` registers over **TLS/5061**, and a call to the echo test negotiates **SRTP** (`RTP/SAVP` -in the SDP). Audio still works end to end. - -### Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| TLS handshake fails | Cert not trusted by phone | Accept/import the self-signed cert in the softphone; for the lab, disable strict cert verification. | -| Registers on UDP not TLS | Phone still on 5060/UDP | Set transport TLS and port `5061` in the phone. | -| No audio with SRTP | One side not offering SRTP | Confirm `media_encryption=sdes` on the endpoint and SRTP enabled in the phone. | - -### Clean up - -Remove `[transport-tls]`, the `media_encryption` line, and the `5061` port mapping; recreate -the container and reload. +Throughout this manual, **"run in the Asterisk CLI"** means either of those. **"Edit a +file"** always means a file under `/etc/asterisk/` on the lab VM — save it, then reload +as the lab tells you. --- -## Lab 8 — Control Asterisk with ARI - -> **You will:** call the Asterisk REST Interface (ARI) and originate a call from a script. -> **Time:** ~20 min **Prerequisites:** Lab 1. -> **Book chapter:** *The Asterisk REST Interface (ARI)*. - -### Step 1 — Enable ARI - -Create a **new file** `lab/asterisk/etc/ari.conf`: - -```ini -[general] -enabled=yes - -[labuser] -type=user -password=Lab-ari-secret -``` - -Reload ARI: - -```bash -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'module reload res_ari.so' -``` - -(The lab already enables the HTTP server on `8088` and publishes it to your computer.) - -### Step 2 — Query ARI from your computer - -ARI is plain HTTP + JSON. From your normal shell: - -```bash -curl -s -u labuser:Lab-ari-secret http://localhost:8088/ari/asterisk/info -``` - -**You should see** JSON describing the running system — note `"version":"22.10.0"`: - -```json -{"build":{"os":"Linux", ... ,"date":"2026-06-19 00:12:19 UTC","user":"root"}, - "system":{"version":"22.10.0","entity_id":"..."}, - "config":{"name":"","default_language":"en", ...}, - "status":{"startup_time":"...","last_reload_time":"..."}} -``` - -> On Windows, `curl` ships with Windows 10/11; in PowerShell use `curl.exe` (with the `.exe`) so -> you get real curl and not the PowerShell alias. - -### Step 3 — List your endpoints over ARI - -```bash -curl -s -u labuser:Lab-ari-secret http://localhost:8088/ari/endpoints -``` - -You'll get a JSON array of your PJSIP endpoints (`6001`, `6002`, …) with their states. - -### Step 4 — Originate a call from the API - -Make Asterisk ring `6001` and drop it into the echo test — no dialplan trigger, driven entirely -by the REST call: +## If you get stuck ```bash -curl -s -u labuser:Lab-ari-secret -X POST \ - "http://localhost:8088/ari/channels?endpoint=PJSIP/6001&extension=600&context=internal&priority=1&callerId=ARI" +lab status # is the network up, is Asterisk running, who is registered? +lab ip # this machine's address +lab reset # put /etc/asterisk back to a known-good state +lab logs # follow the Asterisk log +lab rescue # reinstall Asterisk unattended, if your Lab 1 build failed ``` -Your `6001` softphone rings; answer it and you're in the echo test. - -### ✅ Checkpoint - -`/ari/asterisk/info` returns `22.10.0`, `/ari/endpoints` lists your phones, and the originate -POST makes `6001` ring. - -### Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| `401 Unauthorized` | Wrong ARI credentials | Use `labuser` / `Lab-ari-secret` exactly; confirm `ari.conf` reloaded. | -| `curl: connection refused` | 8088 not reachable | Ensure the lab was recreated after the compose change (`up -d`); ARI is on `localhost:8088`. | -| Originate returns 4xx | Endpoint offline | Register `6001` first; check the JSON error message. | - -### Clean up - -Delete `ari.conf` and `module reload res_ari.so`. +`lab reset` saves your current configuration before replacing it, so it is never +destructive. Being stuck is never the end of the course. --- -## Appendix A — Everyday lab commands +## The machine you are building on -```bash -# Start / stop / rebuild -docker compose -f lab/docker-compose.yml up -d # start (or apply compose changes) -docker compose -f lab/docker-compose.yml down # stop and remove -docker compose -f lab/docker-compose.yml restart asterisk - -# Live console and one-shot commands -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rvvv -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'pjsip show endpoints' -docker compose -f lab/docker-compose.yml exec asterisk asterisk -rx 'core show channels' - -# Reloads (narrowest that works) -... -rx 'dialplan reload' # extensions.conf -... -rx 'module reload res_pjsip.so' # pjsip.conf -... -rx 'voicemail reload' # voicemail.conf -... -rx 'module reload app_queue.so' # queues.conf -... -rx 'module reload res_ari.so' # ari.conf - -# See SIP on the wire -... -rx 'pjsip set logger on' # ... 'pjsip set logger off' - -# Tail the logs -docker compose -f lab/docker-compose.yml logs -f asterisk -``` - -## Appendix B — Editing config files safely - -- All config lives under **`lab/asterisk/etc/`** on your computer. The folder is mounted into - the container, so your edits appear instantly — you only need to *reload*. -- On **Windows**, edit with **VS Code** (or any editor that keeps **LF** line endings). Notepad - may insert Windows line endings that confuse Asterisk. -- After every edit, run the matching reload from Appendix A and read its output for parse - errors. -- To get back to a clean slate at any time: - `git checkout -- lab/asterisk/etc/` then `docker compose -f lab/docker-compose.yml restart asterisk`. - -## Appendix C — Resetting everything - -```bash -docker compose -f lab/docker-compose.yml down -git checkout -- lab/asterisk/etc/ # discard your config edits -docker compose -f lab/docker-compose.yml up -d --build -``` - ---- +One Ubuntu 24.04 LTS server — in VirtualBox on your own computer, or rented from a cloud +provider if you are on an Apple Silicon Mac — with Asterisk 22 installed from source and +managed by systemd. Not a container and not a simulator: the commands you type here are +the commands you would type on a customer's server. -*Asterisk Guide — Hands-On Lab Manual. Asterisk 22.10.0, PJSIP-only, hardware-free. Every -command in this manual was verified against the lab. Licensed CC BY-NC-SA 4.0.* +That matters most in the last two labs. `systemctl`, `iptables` and `fail2ban` are a real +part of running a PBX, and they only mean something on a real machine. diff --git a/labs/lab0-build-machine.md b/labs/lab0-build-machine.md new file mode 100644 index 0000000..9fde186 --- /dev/null +++ b/labs/lab0-build-machine.md @@ -0,0 +1,516 @@ +# Lab 0: Build Your Lab Machine + +**Time:** 20 minutes (Path A) · about an hour (Path B) · 25 minutes (Path C) +**You need:** 4 GB of free RAM and 20 GB of free disk on a Windows, Linux or Intel Mac +computer — or, on an Apple Silicon Mac, a cloud account and roughly US$25 for the month +you spend on the course. +**Prerequisites:** none — this is the first thing you do. + +The last few lessons told you Asterisk runs on Ubuntu 24.04 LTS Server. They did not +tell you where that Ubuntu is going to live. That is this lab. + +You are going to build one Linux server and keep it for the rest of the course. You +install Asterisk on it in Lab 1, configure phones on it in Lab 2, and you are still using +the same machine in the security and operations labs at the end. + +Nothing here touches your own operating system. + +--- + +## Step 1 — Choose your path + +Three ways to get the lab machine. **They end at exactly the same place** — an Ubuntu +24.04 server with the Asterisk source waiting to be built. + +| | **Path A — Ready-made VM** | **Path B — Build the VM yourself** | **Path C — A server in the cloud** | +|---|---|---|---| +| Runs on | Windows, Linux, Intel Mac | Windows, Linux, Intel Mac | **any computer, including Apple Silicon Macs** | +| Time | ~15 minutes | ~50 minutes | ~25 minutes | +| Cost | free | free | ~US$25/month, billed hourly | +| Download | 776 MB appliance | 3.2 GB Ubuntu ISO | nothing | +| You do | Import and start it | Install Ubuntu, then run one script | Create a server, lock it down, run one script | +| Good if | You want to get to Asterisk today | You want to have installed a Linux server yourself | You are on a Mac, or your computer has no RAM to spare | + +Paths A and B use VirtualBox on your own computer. Path B is the one the last lessons +described — you install the operating system, exactly as you would on a real server. + +**If you have never installed Linux, take Path B once.** You will meet a real Ubuntu +installer, partitioning, and a first boot, and none of that will be mysterious the next +time. If you are short on time, Path A loses you nothing that later labs depend on. + +> ### On a Mac? It depends which Mac. +> +> Check first: **→ About This Mac**. If it says **Chip: Apple M1/M2/M3/M4**, you have +> an Apple Silicon Mac. If it says **Processor: Intel**, you have an Intel Mac. +> +> **Intel Mac — use Path A or B like everybody else.** VirtualBox has a macOS/Intel +> build on the same download page, and every instruction below applies unchanged. +> +> **Apple Silicon Mac — take Path C.** VirtualBox's macOS/Arm64 build is an unsupported +> developer preview that runs arm64 guests only, and the ready-made appliance is an +> x86-64 image, so there is nothing to import and nothing to fall back on. **This course +> does not support running the lab on Apple Silicon.** Rent a Linux server for the +> duration instead — same Ubuntu, a few dollars, destroy it when you are done. Path C is +> written for exactly this. +> +> Parallels Desktop, VMware Fusion and UTM *will* run an **arm64** Ubuntu 24.04 on +> Apple Silicon, and Asterisk does build from source on arm64. That route is not tested +> for this course and no lab assumes it: you would be doing Path B with the arm64 +> installer image and sorting out any architecture differences yourself. Nothing stops +> you — but if it breaks, the course cannot help you. + +Jump to **Path A**, **Path B** or **Path C** below. Paths A and B then continue at +**Step 3**; Path C goes straight to **Step 4**. + +--- + +## Step 2 — Install VirtualBox *(Paths A and B only — Path C, skip to Path C)* + +VirtualBox is the program that runs virtual machines. Download it for your operating +system from and install with the defaults. +Take **Windows hosts**, **Linux distributions**, or **macOS / Intel hosts** — *not* the +macOS / Arm64 developer preview, which cannot run this machine. Your network will drop +for a second during installation — that is normal, it is inserting a virtual network +adapter. + +Check it worked — **PowerShell** (Windows) or **Terminal** (macOS/Linux): + +``` +VBoxManage --version +``` + +**You should see** **7.2 or higher**, for example `7.2.14r170000`. + +> **Do not skip this.** Older VirtualBox releases have emulation bugs that crash the +> *guest kernel*, not Asterisk — and they look like the lab is broken. Building this +> course on 7.1.6 produced three separate kernel-level failures: a panic in the emulated +> Intel network card, a fault in an AVX routine that hung boot, and an RCU stall that +> froze the machine. All three are hypervisor bugs. The image works around the first two, +> but running a current VirtualBox is the real fix. + +> Windows, "command not found"? Use the full path: +> `& "C:\Program Files\Oracle\VirtualBox\VBoxManage.exe" --version` + +--- + +# Path A — Download the ready machine + +### A1. Download + +Download **`asterisk-lab-base-1.0.ova`** — **776 MB**: + + + +Or from a terminal, which is easier to resume if the connection drops: + +``` +curl -L -O -C - https://pub-d6afaeeb01b74b1eb49d4564ab14ee61.r2.dev/asterisk-lab-base-1.0.ova +``` + +It is Ubuntu 24.04 LTS Server with the Asterisk build dependencies already installed and +the Asterisk 22.10.0 source already unpacked — but **Asterisk itself is not installed**. +Installing it is Lab 1, and you do that yourself. + +Check the download arrived intact: + +``` +certutil -hashfile asterisk-lab-base-1.0.ova SHA256 # Windows +shasum -a 256 asterisk-lab-base-1.0.ova # macOS / Linux +``` + +**You should see** exactly this: + +``` +4a3fe749cc4edad5eba7c8ce6ede07e2d9d1c188f3a871da5a0f19c692aef25c +``` + +If it differs by a single character, the download is damaged — delete it and fetch it +again. A truncated appliance imports without complaint and then fails to boot, which is +a miserable way to spend an evening. + +### A2. Import + +Double-click the `.ova`, or in VirtualBox choose **File → Import Appliance** and select +it. Accept the defaults and click **Import**. A minute or two. + +> **Do not start it yet.** The appliance ships with its network adapter set to NAT, +> because a *bridged* adapter has to name a network card on **your** computer and there is +> no way to know that in advance. Step 3 sets it. If you start it first, it boots with no +> reachable address and `lab ip` will show `10.0.2.15` — the NAT placeholder, which no +> softphone can call. + +Now go to **Step 3**. + +--- + +# Path B — Build it yourself + +### B1. Download the Ubuntu installer + +Download **`ubuntu-24.04.x-live-server-amd64.iso`** (about 3.2 GB) from + — take the newest point release listed. + +Take the **live-server** image, not Desktop. Asterisk should not share a machine with a +graphical desktop — the lessons made that point, and this is where you act on it. + +### B2. Create the virtual machine + +In VirtualBox click **New**, then: + +| Setting | Value | +|---|---| +| Name | `asterisk-lab` | +| Type / Version | Linux / Ubuntu (64-bit) | +| ISO Image | the ISO you just downloaded | +| Skip Unattended Installation | **tick this box** | +| Memory | **4096 MB** | +| Processors | **2** (more if you have them — Lab 1 compiles faster) | +| Disk | **20 GB** | + +Ticking *Skip Unattended Installation* matters: you want to see the installer, which is +the entire point of this path. + +Click **Finish**, then **Start**. + +### B3. Install Ubuntu + +The installer boots. Work through it — the defaults are right nearly everywhere. + +| Screen | What to choose | +|---|---| +| Language / keyboard | Yours | +| Type of install | **Ubuntu Server** (not minimized) | +| Network | Leave it — DHCP. Note the address it shows; you will meet it again. | +| Proxy / mirror | Leave blank / default | +| Storage | **Use an entire disk**, then **Done** and **Continue** to confirm | +| Profile | Your name; server name **`asterisk-lab`**; username **`lab`**; password **`lab`** | +| Ubuntu Pro | **Skip for now** | +| SSH | **Tick "Install OpenSSH server"** — you will want this | +| Featured snaps | Select none | + +> Use `lab` / `lab` exactly. Every later lab refers to that username. This machine is a +> disposable lab on your own network, not a server. + +Installation takes 10–20 minutes. When it finishes, choose **Reboot Now**. If it hangs +asking you to remove the installation medium, just press **Enter**. + +Log in as `lab`. + +### B4. Update, then run the lab setup + +```bash +sudo apt-get update && sudo apt-get -y upgrade +``` + +Then fetch the lab setup and run it: + +```bash +sudo apt-get install -y git +git clone https://github.com/flaviogoncalves/asterisk-guide.git +sudo ./asterisk-guide/lab-vm/provision.sh base +``` + +This takes about 10 minutes. It installs the build dependencies from the lesson's +package list, installs the tools later labs need (`sngrep`, `sipp`, `tcpdump`, +`fail2ban`), downloads and unpacks the Asterisk 22.10.0 source, and installs the `lab` +helper command. + +**It does not install Asterisk.** That is Lab 1, and it is yours to do. + +**You should see** it finish with: + +``` +==> Stage 'base' complete — Asterisk source staged in /usr/src/asterisk-22.10.0, not built +``` + +> Curious what it did? Read it — `asterisk-guide/lab-vm/provision.sh` is commented +> throughout and is deliberately the same sequence the lessons describe. + +Now go to **Step 3**. + +--- + +# Path C — Rent a server in the cloud + +This is the Mac path, and it is a good path on any computer that cannot spare 4 GB of +RAM. You get the same Ubuntu 24.04 server everybody else has — it simply lives in a data +centre instead of in a window, and you reach it over SSH. + +The instructions below use **DigitalOcean** because it is the simplest. Vultr, Hetzner, +Linode and AWS Lightsail all work the same way; only the buttons differ. + +> **The one real difference from Paths A and B.** Your lab has a public address on the +> internet. A PBX on a public address is scanned by attackers within minutes of coming +> up — that is not a scare story, it is what the security lab has you read in your own +> logs. Step **C2** is not optional, and it comes *before* you install anything. + +### C1. Create the server + +Sign in to , then **Create → Droplets**: + +| Setting | Value | +|---|---| +| Region | the one nearest you — this is your audio latency | +| Image | **Ubuntu 24.04 (LTS) x64** | +| Droplet type | **Basic**, Regular (SSD) | +| Size | **4 GB RAM / 2 vCPUs** — 2 GB works but Lab 1's compile is slow; 1 GB is not enough | +| Authentication | **SSH key** if you have one, otherwise a password you choose | +| Hostname | `asterisk-lab` | + +Click **Create Droplet**. Half a minute later the control panel shows its **public IPv4 +address**. Write that down — everywhere the course says ``, this is it. + +> **Billing.** Droplets are billed by the hour for as long as they exist — *including +> while powered off*. At 4 GB / 2 vCPUs that is roughly US$24 a month at the time of +> writing; check the current price on the size selector. When you finish the course, +> **destroy** the droplet (not just power it off). Costs nothing to recreate later. + +### C2. Lock it down — before you install anything + +First, find the two addresses your firewall must allow. + +**Your own computer's public address**, from a terminal on *your* computer: + +``` +curl -4 ifconfig.me +``` + +**The lab's simulated PSTN gateway**, which Lab 3 registers to and which sends calls +back to you: + +``` +getent hosts sip.flagonc.com +``` + +**You should see** `74.50.97.11`. Use whatever it prints today. + +Now in DigitalOcean go to **Networking → Firewalls → Create Firewall**, name it +`asterisk-lab`, and set the **inbound** rules to exactly this — nothing else: + +| Type | Protocol | Port range | Sources | +|---|---|---|---| +| SSH | TCP | `22` | your public IP | +| Custom | UDP | `5060` | your public IP, `74.50.97.11` | +| Custom | UDP | `10000-10200` | your public IP, `74.50.97.11` | +| Custom | TCP | `8088-8089` | your public IP | + +Leave the **outbound** rules at their defaults (all traffic allowed) — Asterisk has to +reach the gateway and the package archives. + +Under **Apply to Droplets**, choose `asterisk-lab`. Create. + +That is SIP signalling, the RTP media range from `lab.env`, and the HTTP/WebSocket ports +Labs 6 and 7 use — each one reachable only from you, and 5060 also from the gateway so +inbound trunk calls arrive. + +> **Home addresses change.** If your ISP hands you a new one, every single thing stops +> working at once — SSH included. Re-run `curl -4 ifconfig.me` and update the firewall's +> sources. If you are locked out, DigitalOcean's **Droplet → Access → Launch Recovery +> Console** gets you in over the web regardless of the firewall. That console is your +> equivalent of the VirtualBox window, and Lab 8 will remind you of it. + +### C3. Create the `lab` user + +Connect as root, using the address from C1: + +``` +ssh root@ +``` + +Every lab from here on assumes the user `lab`, so create it: + +```bash +adduser lab # password: lab +usermod -aG sudo lab +``` + +If you used an SSH key in C1, copy it across so you can log in as `lab` directly: + +```bash +rsync --archive --chown=lab:lab ~/.ssh /home/lab +``` + +Then become that user — or log out and back in as `ssh lab@`: + +```bash +su - lab +``` + +### C4. Run the lab setup + +```bash +sudo apt-get update && sudo apt-get -y upgrade +sudo apt-get install -y git +git clone https://github.com/flaviogoncalves/asterisk-guide.git +sudo ./asterisk-guide/lab-vm/provision.sh base +``` + +Same script, same ten minutes, same result as Path B — build dependencies, the lab +tooling (`sngrep`, `sipp`, `tcpdump`, `fail2ban`), the Asterisk 22.10.0 source unpacked, +and the `lab` command. + +**You should see** it finish with: + +``` +==> Stage 'base' complete — Asterisk source staged in /usr/src/asterisk-22.10.0, not built +``` + +**Skip Step 3** — there is no virtual network adapter to change. Your server already has +its own public address, and no NAT sits between it and your softphone, which is exactly +the arrangement Step 3 builds for the other two paths. Go straight to **Step 4**. + +--- + +# Step 3 — Put the machine on your network *(Paths A and B)* + +**This step is why the appliance ships on NAT — do it before the first boot.** + +If you already started the machine, shut it down first (`sudo poweroff`, or the VirtualBox +window's close button → *Power off*). Then select **asterisk-lab** → **Settings** → +**Network** → **Adapter 1**: + +| Setting | Value | +|---|---| +| Attached to | **Bridged Adapter** | +| Name | the network card your computer is actually using — the wifi or ethernet adapter | + +**Bridged** puts the VM on your network as though it were a separate physical computer +plugged into the same switch. It gets its own address from your router, exactly as a +real PBX would. Your softphone reaches it there — and so can a real desk phone on the +same network, if you have one. + +> **Not NAT.** VirtualBox defaults to NAT, which hides the VM behind your computer and +> makes incoming SIP unreachable. When a phone will not register in Lab 2, this setting +> is the first thing to check. + +Click **OK** and **Start** the machine. + +--- + +# Step 4 — Confirm the machine is what you think it is + +Log in as `lab` (password `lab` on Paths A and B; whatever you set on Path C). Four +questions worth answering before you build anything on it. + +> **Paths A and B:** the VM window captures your mouse when you click in it. Release it +> with the **Host key** — **Right Ctrl** on Windows/Linux, **Left ⌘** on an Intel Mac. +> It is shown in the bottom-right of the window. + +**Which Ubuntu is this?** + +```bash +lsb_release -d +``` + +**You should see** `Description: Ubuntu 24.04.x LTS` — the distribution the lessons +specified. + +**What is my IP address?** This is the most important thing to come out of this lab. +Write it down; every later lab needs it. + +```bash +lab ip +``` + +**You should see** an address, and the line you will need in Lab 2: + +``` +192.168.1.47 + +Point your softphone at: 192.168.1.47:5060 (UDP) +``` + +Yours will differ — on Paths A and B it is whatever your router handed out; on Path C it +is your server's public address. Wherever the course says **``**, this is +the number. + +> Paths A and B: nothing, or an address starting `10.0.2.`? Adapter 1 is still on NAT. +> Shut down, fix it as in Step 3, start again. +> +> Path C: it must match the public IPv4 address in your provider's control panel. If it +> prints a `10.` address instead, use the panel's public address for the rest of the +> course. + +**Can it reach the internet?** + +```bash +ping -c 3 downloads.asterisk.org +``` + +**You should see** replies. + +**Is Asterisk installed?** It should not be — that is the point. + +```bash +asterisk -V +``` + +**You should see** `asterisk: command not found`. Correct. There is no PBX here yet. + +Now look at what is waiting: + +```bash +ls /usr/src/asterisk-22.10.0 +``` + +**You should see** the unpacked source — `configure`, `Makefile`, `channels/`, `apps/`. +The same tarball the lessons told you to `wget`, already downloaded so a slow network +cannot end your lab twenty minutes in. In Lab 1 you build it. + +--- + +## ✅ Checkpoint + +Whichever path you took, you are finished when all four are true: + +1. You are logged in as `lab` +2. `lsb_release -d` reports **Ubuntu 24.04** +3. `lab ip` prints an address you can reach from your own computer, and you have written + it down +4. `asterisk -V` says **command not found**, and `/usr/src/asterisk-22.10.0` exists + +Point 4 is not a failure. You have built a server. You have not built a PBX. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| "VT-x is disabled in the BIOS", VM will not start | Hardware virtualization is off | Reboot into BIOS/UEFI and enable **Intel VT-x** or **AMD-V**. On Windows also run `bcdedit /set hypervisorlaunchtype off` as administrator and reboot — Hyper-V and VirtualBox compete for the same hardware. | +| VM is very slow | Too little RAM or one CPU | **Settings → System**: 4096 MB, 2+ processors. | +| VM freezes, panics, or hangs at boot with kernel messages | Almost always an old VirtualBox, not Asterisk | Check `VBoxManage --version`. Anything below **7.2** is worth upgrading before you debug anything else — these show up as `Kernel panic`, `BUG: unable to handle`, or `rcu_preempt self-detected stall` on the console. | +| `lab ip` prints nothing, or `10.0.2.15` (Paths A/B) | Adapter 1 still on NAT | **Settings → Network → Adapter 1** → *Bridged Adapter*, pick the card you are really using. Restart. | +| `lab ip` gives an address your computer cannot ping (Paths A/B) | Bridged to the wrong physical card — e.g. ethernet while you are on wifi | Same setting, change **Name**. | +| `lab: command not found` (Paths B/C) | `provision.sh base` did not finish | Re-run it and read the last lines for the error. | +| Path B: installer hangs on "remove installation medium" | Normal | Press **Enter**. If it persists, **Devices → Optical Drives → Remove disk**, then reset the VM. | +| Import fails partway (Path A) | Corrupt download | Re-download and compare the SHA-256 against the one in step A1. | +| Path C: SSH suddenly refuses to connect | Your home IP changed and the firewall no longer allows it | `curl -4 ifconfig.me` on your computer, update the firewall sources. Locked out entirely: **Droplet → Access → Launch Recovery Console**. | +| Path C: `lab ip` shows a `10.` address | It picked the provider's private/anchor interface | Use the public IPv4 address shown in the control panel wherever the labs say ``. | +| VirtualBox installs on an Apple Silicon Mac but the appliance will not import, or the VM will not start | The macOS/Arm64 build is an unsupported preview and runs arm64 guests only; the appliance is x86-64 | Not fixable — take **Path C**. Skip Step 2 entirely; Path C needs nothing on your Mac but the SSH client macOS already has. | + +--- + +## Working comfortably (optional, recommended) + +*Paths A and B:* the VirtualBox console window has no scrollback and no copy-paste. From +Lab 1 on you will be reading long build output, so connect over SSH from your own +terminal instead: + +``` +ssh lab@ +``` + +Password `lab`. You get scrollback, copy-paste, and a resizable window. The VM must be +running, but you can then minimise it and forget about it. + +*Path C:* you are already doing this — that is all a cloud server ever gives you. + +--- + +**Next:** Lab 1 — you install Asterisk 22 on this machine, by hand, following the +sequence from the last two lessons. + +To finish a session: `sudo poweroff` on Paths A and B, then **Start** the machine in +VirtualBox next time. On Path C leave it running — and when you reach the end of the +course, **destroy** the droplet so it stops billing. diff --git a/labs/lab1-install.md b/labs/lab1-install.md new file mode 100644 index 0000000..935944a --- /dev/null +++ b/labs/lab1-install.md @@ -0,0 +1,317 @@ +# Lab 1: Install Asterisk 22 From Source + +**Time:** ~25 minutes, of which 5–15 is the machine compiling while you read. +**Prerequisites:** Lab 0 — you have the lab VM running and can log in as `lab`. + +The last two lessons walked through the installation sequence: configure the build, +select modules, compile, install, install the sample configuration. Now you do it. + +This is a real source installation on a real Linux server. When you finish you will +have a working Asterisk 22.10.0 with no configuration at all — no phones, no dialplan, +nothing. That is the correct place to end. Lab 2 is where you start building a PBX. + +> **Work over SSH if you can.** `ssh lab@` from your own terminal gives you +> scrollback and copy-paste, which matters in this lab because the build prints +> thousands of lines. Use the address `lab ip` gave you in Lab 0. The console window +> works too. + +--- + +## Step 1 — Go to the source + +```bash +cd /usr/src/asterisk-22.10.0 +ls +``` + +**You should see** the source tree — `configure`, `Makefile`, `apps/`, `channels/`, +`res/`, and more. + +This is the tarball the lessons told you to `wget` from +`downloads.asterisk.org`, already downloaded and unpacked for you. The build +dependencies from the lesson's `apt-get install` list are already resolved too. Both +were done in advance for one reason: a failed download or a missing library forty +minutes into a compile teaches you nothing, and it is where most people give up. + +Everything from here is yours. + +--- + +## Step 2 — Configure the build + +```bash +sudo ./configure --with-jansson-bundled --with-pjproject-bundled --with-srtp +``` + +This takes a minute or two and prints a long stream of `checking for...` lines. It is +inspecting your system — which compiler, which libraries, which headers — and writing +a build configuration that matches. + +**You should see** it finish with the Asterisk ASCII-art banner and: + +``` +configure: Package configured for: +configure: OS type : linux-gnu +configure: Host CPU : x86_64 +``` + +**What the three options mean:** + +| Option | Why | +|---|---| +| `--with-jansson-bundled` | Build the bundled JSON library instead of the system one. Fewer version surprises. | +| `--with-pjproject-bundled` | Build the bundled PJSIP stack. **This is the important one** — it keeps the SIP stack version-matched to Asterisk. | +| `--with-srtp` | Build against libsrtp so encrypted media is possible. You need this for the TLS/SRTP lab later. | + +> If `configure` stops with an error about a missing library, something has gone wrong +> with the image rather than with you. Run `sudo apt-get install -f` and try again, and +> tell your instructor. + +--- + +## Step 3 — Choose your modules + +```bash +sudo make menuselect +``` + +A blue text menu opens. This is where you decide what actually gets built. Asterisk +has hundreds of modules and you do not want all of them. + +Move with the **arrow keys**, change category with **left/right**, toggle an item with +**Enter** or the **spacebar**. `[*]` means selected, `[ ]` means not. + +Turn on four things: + +1. **Resource Modules** → find `res_srtp` → make sure it is `[*]` + Encrypted media. Needed for the TLS/SRTP lab. +2. **Resource Modules** → find `res_http_websocket` → make sure it is `[*]` + WebSockets. Needed for the browser phone lab. +3. **Core Sound Packages** → `CORE-SOUNDS-EN-ULAW` → `[*]` + The prompts Asterisk plays. Without these, calls connect and you hear nothing — + which you will then spend an hour blaming on RTP. +4. **Extras Sound Packages** → `EXTRA-SOUNDS-EN-ULAW` → `[*]` + Extra prompts used by voicemail and the IVR labs. + +Press **F12** or **x** to save and exit. (If `x` does nothing, use **F12**; if neither +works, press **Esc Esc** and check you are not inside a submenu.) + +> **Why choose at all?** Every module you build is code that gets compiled, installed +> and loaded. In production, fewer modules means a smaller attack surface and a faster +> start. Being deliberate about this is a habit worth forming now. + +--- + +## Step 4 — Compile + +```bash +sudo make -j$(nproc) +``` + +**This is the slow step.** On the 2-core VM from Lab 0 it takes about **5 minutes**; +on a slower machine, or one core, allow 15. `-j$(nproc)` tells `make` to use every CPU +core your VM has, which is why giving it 2 in Lab 0 mattered. + +Leave it running. Do not close the window. + +**While it compiles**, here is what is happening. `make` is walking the source tree +directory by directory — `main/` builds the core, `channels/` builds `chan_pjsip` and +friends, `apps/` builds the dialplan applications like `Dial()` and `Voicemail()`, and +`res/` builds resource modules including the whole bundled PJSIP stack. That bundled +stack is why this takes as long as it does: you are compiling a complete SIP protocol +library as well as Asterisk itself. + +**You should see** it end with: + +``` + +--------- Asterisk Build Complete ---------+ + + Asterisk has successfully been built, and + + + can be installed by running: + + + + + + make install + + +-------------------------------------------+ +``` + +> **If the build fails**, scroll back and find the *first* error — later ones are +> usually consequences. The most common cause on a small VM is running out of memory, +> which shows up as `cc1plus: out of memory` or the compiler being killed. Fix it by +> giving the VM more RAM, or by building with one job at a time: `sudo make -j1` +> (slower, but it will finish). +> +> Genuinely stuck? `sudo lab rescue` installs a known-good build so you are never +> blocked from continuing the course. Use it if you need it — but try the real build +> first, because the next time you do this it will be on someone's production server. + +--- + +## Step 5 — Install + +```bash +sudo make install +``` + +Copies the binaries, modules and sounds into place. About a minute. + +**You should see** a banner at the end pointing out that this did **not** install any +configuration files, and telling you to run `make samples` if you want them. That is +expected — it is the next step. + +```bash +sudo make samples +``` + +This writes the stock configuration files into `/etc/asterisk/`. They are almost +entirely commented-out examples, and they are genuinely useful: when you are not sure +what an option does, the sample file usually explains it. + +```bash +sudo make install-logrotate +``` + +Sets up log rotation, so `/var/log/asterisk/messages.log` cannot quietly fill the disk. Skipping +this is a classic way to take down a PBX that has been running happily for a year. + +Finally, keep a pristine copy of those stock files: + +```bash +sudo cp -a /etc/asterisk /etc/asterisk.samples +``` + +Thirty seconds now, and it is what `sudo lab reset` restores from when a later lab leaves +you with a PBX that will not start. Every lab from here edits files in `/etc/asterisk/`; +this is the copy nothing ever touches. + +--- + +## Step 6 — Run Asterisk as a service + +Asterisk is installed, but nothing is running it yet. On a real server you do not start +a PBX by hand — you let the system do it, so it comes back after a reboot or a crash. + +Create a dedicated user for it, rather than running as root: + +```bash +sudo adduser --system --group --home /var/lib/asterisk --no-create-home --gecos "Asterisk PBX" asterisk +sudo chown -R asterisk:asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /etc/asterisk +sudo usermod -aG asterisk lab +``` + +Install the service definition. It has been prepared for you — you will take it apart +line by line in the Deployment and Operations section: + +```bash +sudo cp /opt/lab/asterisk.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now asterisk +``` + +Check on it: + +```bash +systemctl status asterisk +``` + +**You should see** `Active: active (running)`. Press `q` to exit. + +> `enable` means "start at boot". `--now` means "and start it right now". Together they +> are the two things you almost always want. + +--- + +## Step 7 — Talk to it + +Asterisk is running as a background service. Connect to its console: + +```bash +sudo asterisk -rvvv +``` + +**You should see** the Asterisk banner and a prompt like: + +``` +asterisk-lab*CLI> +``` + +You are now inside Asterisk. Try: + +``` +core show version +``` + +**You should see** `Asterisk 22.10.0 built by root @ asterisk-lab ... running Linux`. + +> It says `root` because you built it with `sudo`. The name recorded is whoever ran +> `make`, not who runs the service — Asterisk itself runs as the unprivileged `asterisk` +> user you created in Step 6. + +Two more, to see the state of a PBX with no configuration: + +``` +pjsip show endpoints +``` + +**You should see** `No objects found.` — there are no phones. Correct. + +``` +core show channels +``` + +**You should see** `0 active channels`. Nothing is happening, because nothing can yet. + +Leave the console with **Ctrl-C**. That exits the console only; Asterisk keeps running. + +> The `-rvvv` matters. `-r` means "connect to the running Asterisk". The `vvv` sets +> verbosity — with it you see calls narrate themselves as they happen, which is how you +> will debug everything from here on. Without it the console is nearly silent. + +--- + +## ✅ Checkpoint + +You have finished this lab when all four are true: + +1. `systemctl status asterisk` reports `active (running)` +2. `sudo asterisk -rvvv` gives you a `*CLI>` prompt +3. `core show version` reports **22.10.0** +4. `pjsip show endpoints` reports **`No objects found.`** + +Point 4 is not a failure. You have installed a PBX; you have not configured one. That +is Lab 2. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `make` killed, or `out of memory` | VM has too little RAM for a parallel build | Shut down, VM → **Settings → System** → raise Base Memory to 4096 MB. Or rebuild with `sudo make -j1`. | +| `configure: error: ... not found` | A build dependency is missing | `sudo apt-get update && sudo apt-get install -f`. If it persists the image is at fault — use `sudo lab rescue` and report it. | +| menuselect will not exit | You are inside a submenu | Press **Esc** to back out, then **F12** to save and exit. | +| `asterisk: command not found` after install | Shell has cached the old PATH lookup | `hash -r`, or log out and back in. | +| `systemctl status` shows `failed` | Permissions on Asterisk's directories | `sudo journalctl -u asterisk -n 50` and read the last error. Then `sudo chown -R asterisk:asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk`. | +| `systemctl status` says **`active (running)`** but the CLI still says `Unable to connect to remote asterisk` | The service is up but has no control socket | Confirm the unit you copied contains `RuntimeDirectory=asterisk`: `grep RuntimeDirectory /etc/systemd/system/asterisk.service`. That line is what makes systemd create `/run/asterisk` owned by the `asterisk` user. Without it Asterisk runs perfectly and is simply unreachable — `ls -ld /run/asterisk` will show it owned by `root`. Fix the unit, then `sudo systemctl daemon-reload && sudo systemctl restart asterisk`. | +| CLI says `Unable to connect to remote asterisk`, and the service is **not** running | Asterisk stopped or failed to start | `sudo systemctl start asterisk`, then `journalctl -u asterisk -n 50`. | +| Calls later have no sound | Sound packages were not selected in menuselect | Re-run `sudo make menuselect`, enable `CORE-SOUNDS-EN-ULAW`, then `sudo make install && sudo systemctl restart asterisk`. | + +--- + +## What you built, and where it went + +| What | Where | +|---|---| +| The `asterisk` binary | `/usr/sbin/asterisk` | +| Configuration | `/etc/asterisk/` | +| Loadable modules | `/usr/lib/asterisk/modules/` | +| Sound prompts | `/var/lib/asterisk/sounds/` | +| Voicemail, recordings, call files | `/var/spool/asterisk/` | +| Logs | `/var/log/asterisk/` — `full` is the one you will read | +| The source you built from | `/usr/src/asterisk-22.10.0` | + +Worth knowing now: to *upgrade* Asterisk later you unpack a newer source tree and repeat +Steps 2 to 5. `/etc/asterisk` is not touched by `make install`, so your configuration +survives. That is one of the real arguments for building from source. + +--- + +**Next:** Lab 2 — you open `pjsip.conf`, create two extensions, and register your first +softphone. diff --git a/labs/lab2-part1-extensions.md b/labs/lab2-part1-extensions.md new file mode 100644 index 0000000..749b53a --- /dev/null +++ b/labs/lab2-part1-extensions.md @@ -0,0 +1,336 @@ +# Lab 2 Part I: Create SIP Extensions + +**Time:** ~30 minutes +**Prerequisites:** Lab 1 — Asterisk 22.10.0 is installed and running on your lab machine. + +At the end of Lab 1 you ran `pjsip show endpoints` and Asterisk answered +`No objects found.` You have a PBX that knows about no telephones at all. + +In this lab you write the configuration that gives it two: extensions `6001` and +`6002`. You will not copy a finished file — you will build it up a piece at a time and +watch Asterisk pick up each piece, because when this goes wrong in production it goes +wrong one object at a time. + +Nothing registers a phone in this lab. That is Part II. Here you are working purely on +the server side. + +--- + +## Step 1 — The four objects a telephone needs + +Old Asterisk used one `sip.conf` block per device. PJSIP splits that into small objects +that reference each other by name. It feels like more typing until the first time you +need two phones to share one set of credentials, or one phone to answer on three +addresses. + +For one working desk phone you need four: + +| Object | Answers the question | Think of it as | +|---|---|---| +| **transport** | On which IP, port and protocol does Asterisk listen? | the socket | +| **endpoint** | What is this phone allowed to do? | the phone's personality | +| **auth** | What username and password will it prove itself with? | the credentials | +| **aor** | Where is the phone right now? | the address book entry | + +A transport is shared by every phone. The other three are per phone. + +> **The naming rule that catches everyone:** an endpoint finds its auth and its aor +> **by section name**. `auth=6001` means "the section called `[6001]` that has +> `type=auth`". Three different objects can — and here, do — all share the name `6001`, +> and Asterisk tells them apart by their `type=`. Get a name wrong and Asterisk does not +> error; the phone simply never works. + +--- + +## Step 2 — Start a fresh pjsip.conf + +`make samples` in Lab 1 left a large `pjsip.conf` full of commented-out examples. It is +worth reading one day, but not today. Move it aside and start clean: + +```bash +sudo mv /etc/asterisk/pjsip.conf /etc/asterisk/pjsip.conf.sample +sudo nano /etc/asterisk/pjsip.conf +``` + +> `nano` saves with **Ctrl-O**, then **Enter**, and exits with **Ctrl-X**. Use `vim` if +> you prefer it. + +--- + +## Step 3 — The transport + +Type this in — it is the first thing every PJSIP config needs: + +```ini +[transport-udp] +type=transport +protocol=udp +bind=0.0.0.0:5060 +``` + +`0.0.0.0` means "every address this machine has". Whichever path you took in Lab 0 — a +bridged adapter on your own network, or a server with its own public address — that +includes the address `lab ip` reported, which is where your softphone will send its +packets in Part II. + +Save, then tell Asterisk to re-read the file: + +```bash +sudo asterisk -rx 'module reload res_pjsip.so' +``` + +Check it took: + +```bash +sudo asterisk -rx 'pjsip show transports' +``` + +**You should see** one transport, `transport-udp`, bound to `0.0.0.0:5060`. + +> If you see nothing, the file did not parse. Run +> `sudo asterisk -rx 'module reload res_pjsip.so'` again and watch for an error, or check +> `sudo tail -20 /var/log/asterisk/messages.log`. A missing `]` is the usual cause. + +--- + +## Step 4 — Your first endpoint + +Now add `6001`. Append all three objects to `/etc/asterisk/pjsip.conf`: + +```ini +;=============================== 6001 =================================== +[6001] +type=endpoint +context=internal +disallow=all +allow=ulaw +allow=alaw +auth=6001 +aors=6001 +callerid=Alice <6001> + +[6001] +type=auth +auth_type=userpass +username=6001 +password=Lab-6001-secret + +[6001] +type=aor +max_contacts=1 +``` + +Line by line, because every one of these earns its place: + +| Line | What it does | +|---|---| +| `context=internal` | Which part of the dialplan this phone's calls enter. **This is a security control, not a label** — it decides what the phone is allowed to dial. You will build the `internal` context in Part II. | +| `disallow=all` then `allow=ulaw` | Clear the codec list, then permit only what you want. Always in that order; `allow` without `disallow=all` first leaves the defaults in place. | +| `auth=6001` / `aors=6001` | The name links to the two sections below. | +| `callerid=Alice <6001>` | What the other phone displays. | +| `max_contacts=1` | One device may register as `6001` at a time. Raise it if you want a desk phone and a mobile on one extension. | + +Reload and look: + +```bash +sudo asterisk -rx 'module reload res_pjsip.so' +sudo asterisk -rx 'pjsip show endpoints' +``` + +**You should see:** + +``` + Endpoint: 6001/6001 Unavailable 0 of inf + InAuth: 6001/6001 + Aor: 6001 1 +``` + +> **Why `6001/6001` and not just `6001`?** That column is headed +> `` — it shows the endpoint name, then the caller ID number. You set +> `callerid=Alice <6001>`, so the CID is `6001`. Drop the `callerid` line and it would +> read `6001/` with nothing after the slash. + +**`Unavailable` is correct.** It means "this endpoint is defined, but no telephone has +registered to it". You have described a phone; no phone has turned up yet. + +The two indented lines are the proof your names matched. `InAuth: 6001/6001` means the +endpoint found its auth object. `Aor: 6001` means it found its aor. If you had +mistyped `auth=6002`, those lines would be missing — and that is the fastest way to +spot the mistake. + +--- + +## Step 5 — Look at the objects individually + +Three commands worth learning now, because you will use them for the rest of your +career: + +```bash +sudo asterisk -rx 'pjsip show endpoint 6001' +``` + +Everything Asterisk believes about this phone — every codec, NAT setting and timer, +including the defaults you never wrote down. Long output; skim it. + +```bash +sudo asterisk -rx 'pjsip show aors' +``` + +**You should see** `6001` with a `Contacts` column that is empty. That column is the +whole job of an AOR: it fills in when a phone registers, and empties when it goes away. + +```bash +sudo asterisk -rx 'pjsip show auths' +``` + +**You should see** the `6001` auth object, `userpass`. + +--- + +## Step 6 — Now do it yourself: add 6002 + +This one is yours. Add extension **`6002`** for **Bob**, password **`Lab-6002-secret`**, +following exactly the pattern of `6001`. + +Four things must change from the block you just wrote, and nothing else: + +- the three section names +- `username=` +- `password=` +- `callerid=` + +Write it, save, and reload: + +```bash +sudo asterisk -rx 'module reload res_pjsip.so' +sudo asterisk -rx 'pjsip show endpoints' +``` + +**You should see both** `6001` and `6002`, each `Unavailable`, each with its own +`InAuth` and `Aor` lines, and `Objects found: 2`. + +> Only got one? The commonest causes, in order: a section name still says `6001`; a +> `type=` line is missing; or the file did not parse at all — in which case +> `pjsip show endpoints` still shows the *previous* configuration and nothing looks +> wrong. Always check `Objects found:` actually changed. + +--- + +## Step 7 — Prove the config is what you think it is + +There is a difference between "the file on disk" and "what Asterisk currently has +loaded". After a failed reload those are not the same thing, and chasing that gap has +cost people entire evenings. + +```bash +sudo asterisk -rx 'pjsip show endpoints' | grep 'Objects found:' +``` + +**You should see** `Objects found: 2`. + +> Use `Objects found:` rather than counting `Endpoint:` lines yourself. The table prints +> a legend row that also begins `Endpoint:`, so a naive `grep -c` comes out one too high. +> Asterisk's CLI tables are for reading, not for counting — when you need a number, find +> the one Asterisk already gives you. + +Now deliberately break it, so you know what a failure looks like *before* it happens by +accident. In the `[6001]` **endpoint** section, change `allow=ulaw` to a codec that does +not exist: + +```ini +allow=notacodec +``` + +Reload, and look at what Asterisk reports: + +```bash +sudo asterisk -rx 'module reload res_pjsip.so' +``` + +**You should see** `Module 'res_pjsip.so' reloaded successfully.` — which is a lie, or +at least badly incomplete. Check the endpoints: + +```bash +sudo asterisk -rx 'pjsip show endpoints' | grep 'Objects found:' +``` + +**Still `Objects found: 2`.** Nothing appears wrong anywhere. Now read the log: + +```bash +sudo tail -5 /var/log/asterisk/messages.log +``` + +**You should see:** + +``` +ERROR res_sorcery_config.c: Could not create an object of type 'endpoint' with id + '6001' from configuration file 'pjsip.conf' +NOTICE res_sorcery_config.c: Retaining existing configuration for object of type + 'endpoint' with id '6001' +``` + +**"Retaining existing configuration"** is the sentence to remember. Asterisk rejected +your edit and carried on with the version it already had in memory — rather than +dropping a working endpoint and everyone's calls with it. + +That is the right behaviour, and it has a consequence you must build a habit around: +**the file on disk and the configuration Asterisk is running are two different things.** +After a rejected reload they disagree, silently, and every subsequent edit you make will +seem to have no effect. People lose entire evenings to this. + +So: after any reload, verify the *result* — not the fact that a reload happened. + +Put `allow=ulaw` back, reload, and confirm the log is clean. + +--- + +## ✅ Checkpoint + +You have finished this lab when all four are true: + +1. `pjsip show transports` shows `transport-udp` on `0.0.0.0:5060` +2. `pjsip show endpoints` shows `6001` and `6002`, `Objects found: 2` +3. Each endpoint shows its own `InAuth:` and `Aor:` lines +4. `pjsip show aors` shows both, with **no contacts** — no phone has registered yet + +Point 4 is the correct end state. Part II is where the telephones arrive. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `pjsip show endpoints` → `No objects found.` | The file did not parse, or `pjsip.conf` is in the wrong place | `sudo tail -20 /var/log/asterisk/messages.log`. Confirm the path is exactly `/etc/asterisk/pjsip.conf`. | +| Endpoint listed, but no `InAuth:` / `Aor:` lines | Names do not match | The `auth=` and `aors=` values must equal the section names exactly. Case matters. | +| Changes appear to do nothing | The reload failed and the old config is still loaded | Check `Objects found:` really changed. Read the log. | +| `Unable to connect to remote asterisk` | Asterisk is not running | `sudo systemctl status asterisk`, then `sudo journalctl -u asterisk -n 50`. | +| You have lost track of the file | — | `sudo lab reset` restores a known-good configuration, then start again from Step 3. | + +--- + +## What you built + +``` + ┌──────────────────┐ + │ transport-udp │ 0.0.0.0:5060/UDP + └────────┬─────────┘ + │ + ┌─────────────────┴─────────────────┐ + │ │ +┌───────▼────────┐ ┌────────▼───────┐ +│ endpoint 6001 │ │ endpoint 6002 │ +│ context=internal│ │ context=internal│ +└───┬────────┬───┘ └───┬────────┬───┘ + │ │ │ │ +┌───▼───┐ ┌──▼────┐ ┌────▼──┐ ┌───▼───┐ +│auth │ │aor │ │auth │ │aor │ +│6001 │ │6001 │ │6002 │ │6002 │ +└───────┘ └───────┘ └───────┘ └───────┘ + (no contacts yet) +``` + +--- + +**Next:** Lab 2 Part II — you register real softphones against these two endpoints, watch the +SIP REGISTER exchange as it happens, and place your first call. diff --git a/labs/lab2-part2-softphones.md b/labs/lab2-part2-softphones.md new file mode 100644 index 0000000..71b36db --- /dev/null +++ b/labs/lab2-part2-softphones.md @@ -0,0 +1,307 @@ +# Lab 2 Part II: Register Softphones and Make Your First Call + +**Time:** ~35 minutes +**Prerequisites:** Lab 2 Part I — `pjsip show endpoints` lists `6001` and `6002`, both `Unavailable`. + +Two endpoints exist and no telephone has ever contacted them. Now you bring the phones. + +By the end of this lab a call will cross your PBX and you will have watched, packet by +packet, how the phone proved who it was. + +--- + +## Step 1 — What you need + +You need **two** phones, because one phone cannot demonstrate a call. Options: + +| Your computer | Softphone | Notes | +|---|---|---| +| Windows | **MicroSIP** — | Small, free, no account required. Run two copies for two extensions. | +| macOS / Linux | **Linphone** — | Free, handles multiple accounts in one app. | +| Linux, or you like the terminal | **baresip** — `sudo apt install baresip` | Command-line. Excellent for seeing exactly what SIP is doing. | + +You can also use a real desk phone — it is on the same network as the lab now, so it +will work. And your mobile: Linphone and Zoiper both have free apps, and your phone is +on the same wifi. + +> **Two extensions, two phones.** The simplest arrangement is your computer running two +> softphone instances. Second simplest is your computer plus your mobile. + +**Get your lab's address** — on the VM: + +```bash +lab ip +``` + +Everywhere below that says ``, use that number. + +--- + +## Step 2 — Watch what happens, as it happens + +Before you register anything, turn on SIP logging. This is the single most useful thing +in Asterisk and most people meet it far too late. + +On the VM, open the console: + +```bash +sudo asterisk -rvvv +``` + +At the `*CLI>` prompt: + +``` +pjsip set logger on +``` + +Leave this window open and visible. Every SIP packet in and out now prints here. + +--- + +## Step 3 — Register 6001 + +In your softphone, create an account: + +| Field | Value | +|-------|-------| +| Username / Auth user | `6001` | +| Password | `Lab-6001-secret` | +| Domain / SIP server / Registrar | `` | +| Port | `5060` | +| Transport | **UDP** | + +*baresip users:* create `~/.baresip/accounts` containing one line — +`>;auth_pass=Lab-6001-secret;transport=udp` — then run `baresip`. + +Save it, and watch the CLI window. + +**You should see** something close to this — and it is worth reading properly, because +this exchange is the whole of SIP authentication: + +``` +<--- Received SIP request (....) from udp:192.168.1.20:5060 ---> +REGISTER sip:192.168.1.47 SIP/2.0 +... +<--- Transmitting SIP response (....) to udp:192.168.1.20:5060 ---> +SIP/2.0 401 Unauthorized +... +WWW-Authenticate: Digest realm="asterisk",nonce="1a2b3c..." + +<--- Received SIP request (....) from udp:192.168.1.20:5060 ---> +REGISTER sip:192.168.1.47 SIP/2.0 +... +Authorization: Digest username="6001",realm="asterisk",nonce="1a2b3c...",response="9f8e7d..." + +<--- Transmitting SIP response (....) to udp:192.168.1.20:5060 ---> +SIP/2.0 200 OK +``` + +**What just happened, and why it matters:** + +1. The phone said "I am 6001" — with **no password**. +2. Asterisk replied **401 Unauthorized** with a `nonce`: a one-time random value. +3. The phone hashed its password together with that nonce and sent the result. +4. Asterisk did the same hash and compared. They matched, so: **200 OK**. + +The password itself never crossed the network — only a hash of it, salted with a value +that will never be used again. That is digest authentication, and it is why the first +REGISTER is *supposed* to fail. When you see a 401 in a log, your first thought should +be "normal", not "broken". + +Now confirm from the PBX's own point of view: + +``` +pjsip show endpoint 6001 +``` + +**You should see** the state change from `Unavailable` to **`Not in use`**, and a new +line: + +``` + Contact: 6001/sip:6001@192.168.1.20:5060 Avail +``` + +That contact is what the AOR is for. Asterisk now knows *where* 6001 is. Before this it +knew only that 6001 was allowed to exist. + +> `Not in use` means "registered, and idle". You will see `In use` during a call. The +> word "Available" appears against the *contact*, not the endpoint — two different +> things that both mean roughly "alive". + +--- + +## Step 4 — Register 6002 + +Same again on your second phone, with `6002` / `Lab-6002-secret`. + +``` +pjsip show endpoints +``` + +**You should see** both `Not in use`. + +``` +pjsip show aors +``` + +**You should see** each AOR with one contact now — the column that was empty at the end +of Part I. + +Turn the logger off; it has done its job and it is noisy: + +``` +pjsip set logger off +``` + +--- + +## Step 5 — Give them somewhere to call + +Both phones are registered and **neither can call anything**. A registered phone with no +dialplan is a phone that can only be called, never dial. + +Remember `context=internal` on each endpoint in Part I? That is the name of the part of +the dialplan those phones enter. It does not exist yet. Create it. + +Move the sample dialplan aside and start clean: + +```bash +sudo mv /etc/asterisk/extensions.conf /etc/asterisk/extensions.conf.sample +sudo nano /etc/asterisk/extensions.conf +``` + +Type in: + +```ini +[internal] + +; phone to phone +exten => 6001,1,Dial(PJSIP/6001,20) + same => n,Hangup() + +exten => 6002,1,Dial(PJSIP/6002,20) + same => n,Hangup() + +; echo test — proves audio works in both directions +exten => 600,1,Answer() + same => n,Playback(demo-echotest) + same => n,Echo() + same => n,Playback(demo-echodone) + same => n,Hangup() +``` + +How to read `exten => 6001,1,Dial(PJSIP/6001,20)`: + +| Part | Meaning | +|---|---| +| `exten =>` | this line defines a dialable number | +| `6001` | the digits the caller dialled | +| `1` | priority — the first step. `same => n` means "next step" | +| `Dial(PJSIP/6001,20)` | call the PJSIP endpoint named `6001`, ring for 20 seconds | + +The number dialled and the endpoint called happen to share the name `6001` here, and +that trips people up. They are unrelated: the left `6001` is *what the caller typed*, +the right one is *which phone to ring*. You could dial `7` and ring `PJSIP/6001`. + +Load it: + +```bash +sudo asterisk -rx 'dialplan reload' +sudo asterisk -rx 'dialplan show internal' +``` + +**You should see** all three extensions listed, with `600` showing four priorities. + +--- + +## Step 6 — The echo test first + +Always prove audio before you blame anything else. + +**From phone 6001, dial `600`.** + +**You should hear** a short recorded message, then your own voice back with a delay. +Talk and listen. + +That one test proves: the phone can signal to Asterisk, Asterisk can answer, media +flows out, and media flows back. Almost everything that breaks in VoIP breaks one of +those four. + +> **Silence?** The most likely cause is that the sound packages were not selected during +> `make menuselect` in Lab 1. Check with `ls /var/lib/asterisk/sounds/en/ | head`. If it +> is empty, go back to Lab 1 Step 3 and enable `CORE-SOUNDS-EN-ULAW`, then +> `sudo make install` again. + +--- + +## Step 7 — Your first real call + +**From phone 6001, dial `6002`.** + +Phone 6002 should ring. Answer it. Talk. + +Watch it in the CLI while it is up: + +```bash +sudo asterisk -rx 'core show channels' +``` + +**You should see** two channels — one per phone — and `1 active call`. A call is always +two channels in Asterisk: the leg that came in, and the leg it went out on. That model +will make sense of queues, transfers and everything else later. + +Hang up, and look at what was recorded: + +```bash +sudo tail -3 /var/log/asterisk/cdr-csv/Master.csv +``` + +**You should see** a call detail record: who called whom, when, for how long, and +`ANSWERED`. Every call your PBX handles leaves one of these. You will send them to a +database in the Programmability section. + +--- + +## ✅ Checkpoint + +You have finished this lab when all four are true: + +1. `pjsip show endpoints` shows `6001` and `6002` both **`Not in use`** +2. `pjsip show aors` shows a contact for each +3. Dialling `600` gives you **your own voice back** +4. **6001 rings 6002, you answer, and you can hear each other** — and a CDR records it + +You now have a working two-extension PBX that you configured from an empty file. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Phone never registers, nothing in the SIP log | Packets are not arriving | From your computer: `ping `. No reply → on a VM, Adapter 1 is not Bridged (Lab 0 Step 3); on a cloud server, your public address has changed and the provider firewall no longer allows it (Lab 0 Step C2). Reply but still nothing → a firewall on your computer is blocking outbound 5060/UDP, or the cloud firewall is missing the UDP 5060 rule. | +| Log shows REGISTER then repeated `401`, never `200` | Wrong password | It is case-sensitive: `Lab-6001-secret`. Also check the softphone's *auth user* is `6001` and not your name. | +| `403 Forbidden`, and it worked five minutes ago | **A stale contact is still bound.** Your endpoint has `max_contacts=1`, and the previous registration is still there — so the new one is refused. This happens whenever a softphone is force-quit or crashes instead of unregistering cleanly. | `sudo asterisk -rx 'pjsip show contacts'`. If you see a contact you no longer have a phone for, remove it: `sudo asterisk -rx 'pjsip show aors'` to confirm, then either wait for it to expire, or raise `max_contacts=2` on the AOR while you are experimenting. | +| `403 Forbidden` on a first-ever registration | Username does not match any endpoint | `pjsip show endpoints` — is it really `6001`? Check for a trailing space in the config. | +| Registers, then drops after ~30s | Registration expiry not being refreshed | Usually a NAT device between phone and VM. On one LAN there should be none — check the phone is on the same network, not a guest wifi. | +| `Not in use` on both, but dialling gives "not in service" | Dialplan not loaded, or wrong context | `dialplan show internal`. If empty, the reload failed — `sudo tail -20 /var/log/asterisk/messages.log`. Confirm each endpoint says `context=internal`. | +| Phone rings, answered, but **silence both ways** | Media not flowing | `sudo asterisk -rx 'rtp set debug on'` and place another call. No RTP at all → firewall. RTP one way → check the phone's own audio device settings. | +| Silence **one way only** | Nearly always the phone, on a flat LAN | Test the other phone against `600`. The one that fails the echo test is the broken one. | +| You have lost track | — | `sudo lab reset`, then redo from Part I Step 3. | + +--- + +## Two commands to keep + +```bash +sudo asterisk -rx 'pjsip show endpoints' # who is registered +sudo asterisk -rx 'pjsip set logger on' # what is actually being said +``` + +Between them they answer most "the phone isn't working" questions before you have +finished reading the ticket. + +--- + +**Next:** Lab 3 — you connect this PBX to the outside world with a SIP trunk, and +make a call that leaves your network. diff --git a/labs/lab3-trunk.md b/labs/lab3-trunk.md new file mode 100644 index 0000000..614a72a --- /dev/null +++ b/labs/lab3-trunk.md @@ -0,0 +1,348 @@ +# Lab 3: Connect a SIP Trunk + +**Time:** ~30 minutes +**Prerequisites:** Lab 2 Part II — `6001` and `6002` are registered and can call each other. +**You also need:** your assigned trunk account number, between `1010` and `1050`. + +Your PBX can call itself. That is not yet a telephone system. + +In this lab you connect it to a SIP trunk — a gateway that stands in for the public +telephone network — so calls can leave your network and arrive from outside it. + +--- + +## Step 1 — The idea that trips everyone up + +In Part II, phones registered **to** your PBX. Now your PBX registers **to** somebody +else. Same protocol, opposite direction, and the configuration reflects that: + +| | Phone → your PBX (Part II) | Your PBX → the gateway (now) | +|---|---|---| +| Who proves who they are | the phone, to you | you, to the gateway | +| Object that does it | `type=auth` + `auth=` | `type=auth` + **`outbound_auth=`** | +| Who initiates REGISTER | the phone | **you**, via `type=registration` | +| Where the AOR points | wherever the phone turns up | a **fixed address** — the gateway | + +`auth=` means "challenge them with this". `outbound_auth=` means "answer their challenge +with this". Using the wrong one is the single most common trunk mistake, and it fails +with a confusing `401` loop rather than a clear error. + +**Your gateway:** + +| Setting | Value | +|---|---| +| Host | `sip.flagonc.com` | +| Port | **`5600`** — not 5060 | +| Account | your assigned number in `1010`–`1050` | +| Password | `supersecret` | +| Free echo test | `*98` | + +> Everyone on this course shares this gateway, so **use the account you were assigned**. +> Two students on the same number will fight over the registration and both will see it +> flapping between `Registered` and `Unregistered`. The examples below use `1020` — +> replace every `1020` with your own number. + +--- + +## Step 2 — Define the trunk + +Append to `/etc/asterisk/pjsip.conf`: + +```ini +;=============================== PSTN gateway ============================ +[pstn] +type=endpoint +context=from-pstn +transport=transport-udp +disallow=all +allow=ulaw +allow=alaw +direct_media=no +outbound_auth=pstn +aors=pstn +from_user=1020 + +[pstn] +type=aor +contact=sip:sip.flagonc.com:5600 + +[pstn] +type=auth +auth_type=userpass +username=1020 +password=supersecret + +[pstn] +type=registration +transport=transport-udp +outbound_auth=pstn +server_uri=sip:sip.flagonc.com:5600 +client_uri=sip:1020@sip.flagonc.com:5600 +contact_user=1020 +retry_interval=60 + +[pstn] +type=identify +endpoint=pstn +match=sip.flagonc.com +``` + +Six objects, and each is doing a job: + +| Object | Job | +|---|---| +| `endpoint` | How to talk to the gateway, and — critically — **which context its calls land in**. | +| `aor` | Where the gateway is. Fixed, because a gateway does not move. | +| `auth` | Your credentials, used **outbound** only. | +| `registration` | Tells Asterisk to actively register to them, and to keep retrying. | +| `identify` | Matches inbound calls to this endpoint **by source address** instead of by the `From` header — which anyone can forge. | + +Two lines deserve special attention: + +- **`from_user=1020`** — what the gateway sees as the caller. Without it your outbound + INVITEs go out as `6001`, which means nothing to them, and you get `403 Forbidden`. +- **`context=from-pstn`** — calls arriving from the gateway enter `from-pstn`, **never + `internal`**. Step 5 explains why that one word is the most important security setting + in this lab. + +--- + +## Step 3 — Register, and watch it happen + +```bash +sudo asterisk -rvvv +``` + +At the CLI: + +``` +pjsip set logger on +module reload res_pjsip.so +``` + +**You should see** your PBX send a REGISTER *outward*, get challenged, and answer: + +``` +<--- Transmitting SIP request (....) to udp:74.50.97.11:5600 ---> +REGISTER sip:sip.flagonc.com:5600 SIP/2.0 +... +<--- Received SIP response (....) from udp:74.50.97.11:5600 ---> +SIP/2.0 401 Unauthorized +... +<--- Transmitting SIP request (....) to udp:74.50.97.11:5600 ---> +REGISTER sip:sip.flagonc.com:5600 SIP/2.0 +Authorization: Digest username="1020",... +... +<--- Received SIP response (....) from udp:74.50.97.11:5600 ---> +SIP/2.0 200 OK +``` + +The same 401-then-authenticate dance as Part II, with you on the other side of it. + +Confirm: + +``` +pjsip show registrations +``` + +**You should see:** + +``` + + pstn/sip:sip.flagonc.com:5600 pstn Registered +Objects found: 1 +``` + +And look at the AOR: + +``` +pjsip show aors +``` + +**You should see** the `pstn` AOR with its contact and the status **`NonQual`** — +"not qualified". That is correct here and it is worth understanding. + +`qualify` works by sending a SIP `OPTIONS` request every so often and timing the reply, +which is how a PBX notices a dead trunk before a customer does. **This gateway does not +answer OPTIONS**, so switching qualify on would leave the contact permanently reading +`Unavailable` — a trunk that registers, carries calls perfectly, and looks broken. + +So this AOR has no `qualify_frequency`, and `NonQual` is the expected state. On a +provider that does answer OPTIONS, add `qualify_frequency=60` and watch for `Avail` +instead. Check before you assume: not every carrier responds. + +``` +pjsip set logger off +``` + +--- + +## Step 4 — Call out + +Add to the `[internal]` context in `/etc/asterisk/extensions.conf`: + +```ini +; --- outbound over the PSTN gateway --- +exten => 800,1,NoOp(Outbound to the gateway echo test) + same => n,Dial(PJSIP/*98@pstn,30) + same => n,Hangup() + +; ring another student's lab, e.g. 1011 +exten => _10XX,1,NoOp(Outbound to lab account ${EXTEN}) + same => n,Dial(PJSIP/${EXTEN}@pstn,30) + same => n,Hangup() +``` + +`Dial(PJSIP/*98@pstn,30)` reads as: **call `*98`, via the endpoint named `pstn`**. The +part after `@` selects which trunk — with two providers you would have two names here, +and that is how least-cost routing starts. + +```bash +sudo asterisk -rx 'dialplan reload' +``` + +**From phone 6001, dial `800`.** + +**You should hear** the gateway's echo test — your own voice, having travelled out of +your network and back. + +While it is up: + +```bash +sudo asterisk -rx 'core show channels' +``` + +**You should see two channels**: `PJSIP/6001-...` and `PJSIP/pstn-...`. Your phone's leg, +and the trunk's leg. Asterisk is bridging them. + +> **`403 Forbidden`?** Almost always a missing or wrong `from_user`. Turn the logger on, +> place the call again, and read the `From:` header your PBX sent — it must contain your +> assigned account number. + +--- + +## Step 5 — Take a call in, safely + +Now the other direction. Calls from the gateway arrive in `from-pstn`, a context that +does not exist yet. + +**Before you write it, understand what it is for.** Your `internal` context can dial +`_10XX` — out through the trunk, to the real world. If inbound calls landed in +`internal`, anyone who could reach your PBX from outside could dial straight back out +through it, on your account. That is toll fraud, and it is not theoretical: attackers +scan for exactly this and can run up thousands in international calls overnight. + +So `from-pstn` gets the absolute minimum it needs, and no route to the outside. + +Add at the end of `/etc/asterisk/extensions.conf`: + +```ini +;=========================================================================== +; from-pstn — calls arriving from the gateway +;=========================================================================== +; This context deliberately cannot dial out. Do not add an outbound rule here, +; and never use `include => internal`. +[from-pstn] + +exten => 1020,1,NoOp(Inbound from PSTN: ${CALLERID(num)} -> ${EXTEN}) + same => n,Answer() + same => n,Playback(demo-congrats) + same => n,Dial(PJSIP/6001,20) + same => n,Hangup() + +; anything else the gateway sends us +exten => _X.,1,NoOp(Inbound to unknown number ${EXTEN}) + same => n,Answer() + same => n,Playback(demo-congrats) + same => n,Hangup() +``` + +Use **your** account number in place of `1020`. + +> **`_X.` and not `_.`** — the difference is not cosmetic. `_.` matches *one or more of +> anything*, which includes Asterisk's own special extensions: `s` (start), `i` +> (invalid), `t` (timeout) and `h` (hangup). A catch-all written `_.` swallows those, so +> the context can never handle a timeout or a hangup properly. Asterisk warns about it on +> every reload: +> +> ``` +> WARNING pbx_config.c: The use of '_.' for an extension is strongly discouraged +> and can have unexpected behavior. Please use '_X.' instead +> ``` +> +> `_X.` means "a digit, then one or more of anything" — every real phone number, and none +> of the special names. + +```bash +sudo asterisk -rx 'dialplan reload' +sudo asterisk -rx 'dialplan show from-pstn' +``` + +**You should see** both entries. + +### Test it + +**From phone 6002, dial your own trunk number** — `1020`, or whatever yours is. + +The call goes out through the gateway, the gateway routes it straight back to you, and +it arrives as a genuine inbound call. You should hear the congratulations prompt, then +phone `6001` rings. + +Watch both legs: + +```bash +sudo asterisk -rx 'core show channels' +``` + +**You should see** an inbound `PJSIP/pstn-...` channel sitting in `from-pstn`. + +--- + +## ✅ Checkpoint + +You have finished this lab when all four are true: + +1. `pjsip show registrations` reads **`Registered`** +2. `pjsip show aors` shows the `pstn` contact as **`NonQual`** — and you can say why +3. Dialling **`800`** reaches the gateway echo test and you hear yourself +4. Dialling **your own trunk number** comes back in as an inbound call in `from-pstn` + +And one you should be able to answer out loud: **why must `from-pstn` not be allowed to +dial out?** + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `Rejected` | Wrong credentials, or an account outside `1010`–`1050` | Password is `supersecret`, port is **5600** not 5060. `pjsip set logger on` and look at the failure. | +| Stuck `Unregistered` | Cannot reach the gateway | On the VM: `getent hosts sip.flagonc.com` should resolve. Then check outbound UDP is not blocked — a corporate firewall or VPN is the usual culprit. | +| Registered, then flapping | Someone else is using your account number | Use the number you were assigned. Two registrations for one account fight and both lose. | +| AOR shows `Unavailable` | You added `qualify_frequency` and the gateway ignores OPTIONS | Remove it. `Unavailable` here means "did not answer a keepalive", not "unreachable" — the trunk still carries calls. | +| Outbound `403 Forbidden` | Gateway cannot identify you | `from_user=` on the `[pstn]` endpoint must be your account number. | +| Outbound rings, then silence | No media path | `sudo asterisk -rx 'rtp set debug on'` during the call. Confirm `direct_media=no` is present — without it Asterisk tries to hand media directly between phone and gateway, which will not work from a private network. | +| Inbound never arrives | Gateway is not matching you to the endpoint | `pjsip show identifies` — the `pstn` identify must be listed. If the gateway's IP changed, `match=sip.flagonc.com` re-resolves on reload. | +| Inbound arrives but drops instantly | It landed in a context with no matching extension | `sudo asterisk -rvvv` during the call and read the context and extension it reports. Add it to `from-pstn`. | + +--- + +## What you have now + +``` + 6001 ──┐ + ├── [internal] ──► Dial(PJSIP/*98@pstn) ──┐ + 6002 ──┘ │ + ▼ + sip.flagonc.com:5600 + │ + [from-pstn] ◄── inbound ────────────────────┘ + │ + └── Answer, greet, ring 6001 + (and deliberately nothing else) +``` + +--- + +**Next:** Lab 4 — the dialplan itself. Pattern matching, call routing, and making the +`internal` context do something more interesting than one line per phone. diff --git a/labs/lab4-dialplan.md b/labs/lab4-dialplan.md new file mode 100644 index 0000000..5d2483f --- /dev/null +++ b/labs/lab4-dialplan.md @@ -0,0 +1,425 @@ +# Lab 4: Build a Real Dialplan + +**Time:** ~45 minutes +**Prerequisites:** Lab 3 — two phones registered, a trunk registered, `800` reaches the gateway echo test. + +Your dialplan is one line per telephone. That does not scale past about four +telephones, and it cannot express a single business rule. + +In this lab you replace it with pattern matching, a working auto-attendant, and a +context structure that decides who is allowed to spend your money. Everything the last +five lessons described, applied to the PBX you built. + +Keep a CLI open the whole way through — you will use it constantly: + +```bash +sudo asterisk -rvvv +``` + +--- + +## Step 1 — How Asterisk chooses an extension + +Before writing anything, understand the matching rules, because they are the source of +most dialplan surprises. + +A pattern always begins with `_`. Inside it: + +| Symbol | Matches | +|---|---| +| `X` | one digit, `0`–`9` | +| `Z` | one digit, `1`–`9` | +| `N` | one digit, `2`–`9` | +| `.` | one or more of anything | +| `!` | zero or more of anything | +| `[147]` | one digit from the set | + +**When several patterns match, the most specific wins** — not the first one written. +`6001` beats `_60XX`, which beats `_6XXX`, which beats `_.`. Order in the file is +irrelevant, which is exactly what catches people who expect it to read top to bottom. + +Try it. Replace your two per-phone lines in `[internal]` with one pattern: + +```bash +sudo nano /etc/asterisk/extensions.conf +``` + +Delete these: + +```ini +exten => 6001,1,Dial(PJSIP/6001,20) + same => n,Hangup() + +exten => 6002,1,Dial(PJSIP/6002,20) + same => n,Hangup() +``` + +And put this in their place: + +```ini +; Any four-digit number starting 60 rings the matching endpoint. +exten => _60XX,1,NoOp(Internal call to ${EXTEN} from ${CALLERID(num)}) + same => n,Dial(PJSIP/${EXTEN},20) + same => n,Hangup() +``` + +`${EXTEN}` is the digits that were actually dialled. One rule now serves `6001`, `6002`, +and every extension you ever add. + +```bash +sudo asterisk -rx 'dialplan reload' +sudo asterisk -rx 'dialplan show internal' +``` + +**You should see** `_60XX` where two separate lines used to be. + +**Test:** dial `6002` from `6001`. It still rings — but watch the CLI and you will see +your `NoOp` print the caller and the destination. `NoOp` does nothing except write to +the log, and it is the most useful debugging tool in the dialplan. + +Now ask Asterisk to explain its own decision: + +```bash +sudo asterisk -rx 'dialplan show 6002@internal' +``` + +**You should see** it resolve `6002` to the `_60XX` rule. + +--- + +## Step 2 — Unanswered calls should do something + +`Dial(...,20)` rings for twenty seconds and then falls through to the next priority. +Right now that is `Hangup()`, so the caller gets silence and a dead line. + +Asterisk sets `${DIALSTATUS}` after every `Dial`. Use it: + +```ini +exten => _60XX,1,NoOp(Internal call to ${EXTEN} from ${CALLERID(num)}) + same => n,Dial(PJSIP/${EXTEN},20) + same => n,NoOp(Dial finished with status: ${DIALSTATUS}) + same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done) + same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy:noanswer) + + same => n(busy),Playback(the-party-you-are-calling&is-curntly-busy) + same => n,Hangup() + + same => n(noanswer),Playback(the-party-you-are-calling&is-curntly-unavail) + same => n,Hangup() + + same => n(done),Hangup() +``` + +Four new things: + +- **`n(busy)`** gives a priority a *label*, so you can jump to it by name. +- **`GotoIf($[...]?a:b)`** jumps to label `a` if the expression is true, `b` if not. The + `$[ ]` is what makes it an expression rather than text. With only one label — + `?done` — it jumps if true and simply falls through to the next priority if false. +- **The `ANSWER` check comes first, and it is not optional.** `Dial()` hands control + back to the dialplan when the call ends, including when it ended *successfully*. Miss + this line and a caller whose colleague hangs up first is told "the party you are + calling is currently unavailable" — after a perfectly good ten-minute conversation. +- **`&`** in `Playback` chains sound files into one sentence. + +```bash +sudo asterisk -rx 'dialplan reload' +``` + +**Test:** dial `6002` and just let it ring out. You should hear "the party you are +calling is currently unavailable", and the CLI should print +`Dial finished with status: NOANSWER`. + +Then set phone `6002` to Do Not Disturb, or hang up on the incoming call, and dial it +again — `BUSY`, and the other prompt. + +> **Check the names before you trust them.** `ls /var/lib/asterisk/sounds/en/ | grep curntly` +> — and yes, `is-curntly-busy` and `is-curntly-unavail` really are spelled that way. +> Asterisk has shipped those since the beginning. +> +> These two prompts live in the **extra** sound package, not the core one. If you hear +> only the first half of the sentence, `EXTRA-SOUNDS-EN-ULAW` was not selected in +> `make menuselect` back in Lab 1. `Playback` skips a file it cannot find and carries on +> without an error, so a missing prompt is silence, never a message. + +--- + +## Step 3 — An auto-attendant + +A menu that answers, speaks, and waits for a digit. Add a new context at the end of the +file: + +```ini +;=========================================================================== +; ivr — the auto-attendant +;=========================================================================== +[ivr] + +exten => s,1,NoOp(IVR entered by ${CALLERID(num)}) + same => n,Answer() + same => n,Wait(1) + same => n(menu),Background(demo-congrats) + same => n,WaitExten(7) + +exten => 1,1,NoOp(Caller chose sales) + same => n,Dial(PJSIP/6001,20) + same => n,Hangup() + +exten => 2,1,NoOp(Caller chose support) + same => n,Dial(PJSIP/6002,20) + same => n,Hangup() + +exten => 9,1,Playback(demo-echotest) + same => n,Echo() + same => n,Hangup() + +; caller pressed something that is not on the menu +exten => i,1,Playback(invalid) + same => n,Goto(s,menu) + +; caller pressed nothing at all +exten => t,1,Playback(vm-goodbye) + same => n,Hangup() +``` + +The special extension names are the point of this step: + +| Extension | Fires when | +|---|---| +| `s` | the call **s**tarts — entered the context without a number | +| `i` | the caller pressed an **i**nvalid option | +| `t` | the caller **t**imed out and pressed nothing | + +And the two applications that make a menu work: + +- **`Background()`** plays a prompt but keeps listening for digits, so a caller who knows + the menu can interrupt it. `Playback()` will not — a menu built on `Playback` forces + everyone to sit through the whole recording. +- **`WaitExten(7)`** waits seven more seconds for a digit after the prompt ends. + +> Notice there is nothing after `WaitExten`. There is no point putting anything there: +> on timeout, control jumps to the `t` extension, and on an invalid key to `i`. Any +> priority written after `WaitExten` in the `s` extension is unreachable. A dialplan +> that looks like it loops back to the menu but never does is a genuinely hard bug to +> see, so it is worth knowing why this one stops here. + +Give the IVR a number from `[internal]`: + +```ini +exten => 6000,1,Goto(ivr,s,1) +``` + +```bash +sudo asterisk -rx 'dialplan reload' +``` + +**Test:** dial `6000`. You should hear the prompt. Press `1` — phone 6001 rings. Call +again, press `9` — echo test. Call again, press `5` — "invalid". Call again, press +nothing — goodbye, then hangup. + +Watch the CLI through all of it. Every choice prints its `NoOp`. + +--- + +## Step 4 — Contexts are a security boundary + +This is the part that matters more than everything above it. + +A context is not a folder for organising your dialplan. It is a statement of **what a +caller is permitted to do**. A phone in `internal` can dial `_10XX` — out through your +trunk, at your expense. A caller in `from-pstn` cannot, because you never gave that +context an outbound rule. + +`include` is how you grant permission. Build a tier of access: + +```ini +;=========================================================================== +; Access tiers. Each includes the one below it. +;=========================================================================== + +; Everyone gets these. No outbound, no cost. +[features] +exten => 600,1,Answer() + same => n,Playback(demo-echotest) + same => n,Echo() + same => n,Hangup() + +exten => 6000,1,Goto(ivr,s,1) + +exten => _60XX,1,NoOp(Internal call to ${EXTEN} from ${CALLERID(num)}) + same => n,Dial(PJSIP/${EXTEN},20) + same => n,NoOp(Dial finished with status: ${DIALSTATUS}) + same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done) + same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy:noanswer) + same => n(busy),Playback(the-party-you-are-calling&is-curntly-busy) + same => n,Hangup() + same => n(noanswer),Playback(the-party-you-are-calling&is-curntly-unavail) + same => n,Hangup() + same => n(done),Hangup() + +; Calls that leave the building and cost money. +[outbound] +include => features + +exten => 800,1,NoOp(Outbound to the gateway echo test) + same => n,Dial(PJSIP/*98@pstn,30) + same => n,Hangup() + +exten => _10XX,1,NoOp(Outbound to lab account ${EXTEN}) + same => n,Dial(PJSIP/${EXTEN}@pstn,30) + same => n,Hangup() + +; What the desk phones actually use. +[internal] +include => outbound +``` + +Move your existing extensions into these contexts so `[internal]` contains nothing but +the `include`, exactly as above. + +```bash +sudo asterisk -rx 'dialplan reload' +sudo asterisk -rx 'dialplan show internal' +``` + +**You should see** `internal` listing `Include => outbound`, and the extensions +inherited through the chain. + +Nothing changed for your phones — they still reach everything. But you can now demote a +phone to internal-only by changing **one word** in `pjsip.conf`. + +**Test that.** Edit the `6002` endpoint, change `context=internal` to `context=features`, +then: + +```bash +sudo asterisk -rx 'module reload res_pjsip.so' +``` + +From `6002`, dial `6001` — works. Now dial `800` — it fails, and the CLI prints +something like: + +``` +NOTICE: Call from '6002' to extension '800' rejected because extension not found in context 'features' +``` + +That single line is what a toll-fraud attempt looks like when your contexts are right. + +Put `6002` back to `context=internal` and reload. + +--- + +## Step 5 — Route by time of day + +Businesses are not open at 3am, and neither should your IVR be. + +```ini +[features] +; ... your existing entries ... + +exten => 6000,1,NoOp(Main number, checking office hours) + same => n,GotoIfTime(09:00-18:00,mon-fri,*,*?open,1) + same => n,Goto(closed,1) + +exten => open,1,Goto(ivr,s,1) + +exten => closed,1,Answer() + same => n,Playback(vm-goodbye) + same => n,Hangup() +``` + +Replace your earlier one-line `6000` with this. + +`GotoIfTime(times,days-of-week,days-of-month,months?destination)` — the `*` means "any". + +```bash +sudo asterisk -rx 'dialplan reload' +``` + +**Test:** dial `6000`. Whether you get the menu or the goodbye depends on when you are +reading this. To test the other branch without waiting, temporarily narrow the window +to a range that excludes right now — for example `00:00-00:01` — reload, and dial again. + +Check the machine agrees with you about the time: + +```bash +date +``` + +A PBX with the wrong timezone routes calls to the wrong place and stamps every CDR +incorrectly. If it is wrong: `sudo timedatectl set-timezone America/Sao_Paulo`. + +--- + +## Step 6 — Read the dialplan the way Asterisk does + +Three commands worth keeping: + +```bash +sudo asterisk -rx 'dialplan show internal' +``` +Everything reachable from `internal`, includes followed. + +```bash +sudo asterisk -rx 'dialplan show 800@internal' +``` +Resolves one number. The fastest answer to "why does this go there?" + +```bash +sudo asterisk -rx 'dialplan show' +``` +Every context. Long, but it is the whole map. + +--- + +## ✅ Checkpoint + +You have finished when all six are true: + +1. `_60XX` rings any `60xx` extension — one rule, not one line per phone +2. Letting a call ring out plays "not available"; a busy phone plays "busy" +3. Dialling `6000` reaches the IVR; `1` rings 6001, `9` echoes, an invalid key says so, silence times out +4. `dialplan show internal` shows the include chain `internal → outbound → features` +5. Moving `6002` to `context=features` **blocks** `800`, and the CLI logs the rejection +6. `6000` routes differently inside and outside office hours + +Number 5 is the one to be sure of. If a context change does not restrict what a phone +can dial, your contexts are decorative — and that is how PBXs get robbed. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Reload seems to do nothing | Parse error; the old dialplan is still loaded | `sudo tail -20 /var/log/asterisk/messages.log`. Check the extension count in `dialplan show` actually changed. | +| Pattern never matches | Missing the leading `_` | `_60XX` is a pattern; `60XX` is a literal extension made of the characters `6`, `0`, `X`, `X`. | +| A more general pattern wins | It is genuinely more specific than you think | `dialplan show 6002@internal` shows which rule won. Remember `.` matches one *or more*, so `_6.` also matches `6002`. | +| `GotoIf` always takes the same branch | Missing `$[ ]`, or unquoted variable | It must be `$["${DIALSTATUS}" = "BUSY"]` — with the quotes. An empty variable without quotes makes the expression malformed. | +| IVR plays but digits do nothing | Used `Playback` instead of `Background` | `Playback` does not listen. Only `Background` collects digits. | +| Digits work but land in the wrong place | The digit extensions are in the wrong context | `1`, `2`, `9`, `i`, `t` must all be in `[ivr]`, alongside `s`. | +| `include` seems ignored | Included context defined after use — this is fine — or a typo in the name | `dialplan show internal` lists includes. Names are case-sensitive. | +| Lost the file | — | `sudo lab reset`, then rebuild from Lab 2 Part II Step 5. | + +--- + +## What you built + +``` + 6001, 6002 ──► context=internal + │ + └─ include ─► outbound 800, _10XX (costs money) + │ + └─ include ─► features 600, 6000, _60XX + │ + └─ 6000 ─► [ivr] 1 / 2 / 9 / i / t + + gateway ──────► context=from-pstn (no include — cannot dial out, by design) +``` + +The shape of that diagram *is* your security policy. Everything a phone may do, it may +do because a line in `pjsip.conf` put it in a context that reaches it. + +--- + +**Next:** Lab 5 — voicemail, transfers, parking, music on hold, and queues with real +agents. diff --git a/labs/lab5-features-queues.md b/labs/lab5-features-queues.md new file mode 100644 index 0000000..ce7548a --- /dev/null +++ b/labs/lab5-features-queues.md @@ -0,0 +1,492 @@ +# Lab 5: Voicemail, Transfers and a Call Queue + +**Time:** ~50 minutes +**Prerequisites:** Lab 4 — you have the `features` / `outbound` / `internal` context chain, the `_60XX` pattern, and a working IVR on `6000`. + +Everything so far has been about getting a call from A to B. This lab is about what +happens when B does not answer, when B needs to hand the call to C, and when there are +more callers than people to take them. + +These are the features people actually buy a PBX for. + +Keep a CLI open throughout: + +```bash +sudo asterisk -rvvv +``` + +--- + +## Step 1 — Voicemail + +A call that rings out currently plays "the party you are calling is currently +unavailable" and hangs up. Give the caller somewhere to leave a message instead. + +Edit `/etc/asterisk/voicemail.conf`. Find the `[default]` context near the bottom and add +two mailboxes: + +```ini +[default] +6001 => 1234,Alice,alice@example.com +6002 => 1234,Bob,bob@example.com +``` + +The format is `mailbox => PIN,Full Name,email`. The PIN is what the user types to collect +messages — `1234` for both, because this is a lab. + +Now use it. In `/etc/asterisk/extensions.conf`, replace the `noanswer` and `busy` labels +in your `_60XX` rule so they send the caller to voicemail rather than a dead end: + +```ini +exten => _60XX,1,NoOp(Internal call to ${EXTEN} from ${CALLERID(num)}) + same => n,Dial(PJSIP/${EXTEN},20) + same => n,NoOp(Dial finished with status: ${DIALSTATUS}) + same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done) + same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy:noanswer) + + same => n(busy),VoiceMail(${EXTEN}@default,b) + same => n,Hangup() + + same => n(noanswer),VoiceMail(${EXTEN}@default,u) + same => n,Hangup() + + same => n(done),Hangup() +``` + +The letter after the mailbox chooses the greeting: **`u`** for *unavailable*, **`b`** for +*busy*. Two different messages, and the caller hears the right one — which is the whole +reason you checked `${DIALSTATUS}` in Lab 3 instead of treating every failure the same. + +Add a way to collect messages, in the `[features]` context: + +```ini +exten => *97,1,NoOp(Voicemail collection for ${CALLERID(num)}) + same => n,VoiceMailMain(${CALLERID(num)}@default) + same => n,Hangup() +``` + +`VoiceMailMain(${CALLERID(num)})` logs the caller into *their own* mailbox — it reads who +is calling rather than asking. Reload both: + +```bash +sudo asterisk -rx 'voicemail reload' +sudo asterisk -rx 'dialplan reload' +sudo asterisk -rx 'voicemail show users' +``` + +**You should see** both mailboxes listed in context `default`. + +### Test it + +1. From `6001`, dial `6002` and let it ring out. **You should hear** the unavailable + greeting and a beep. Leave a message and hang up. +2. **You should see** on `6002`'s softphone a message-waiting indicator — a light, a + badge, or an envelope icon depending on the phone. +3. From `6002`, dial `*97`, enter PIN `1234`, and listen to the message. + +```bash +sudo asterisk -rx 'voicemail show users' +``` + +**You should see** `6002` now has 1 new message. + +> **No message-waiting light?** That is MWI, and it needs the endpoint to subscribe. +> Add `mailboxes=6002@default` to the `[6002]` **endpoint** section in `pjsip.conf` (not +> the aor), then `module reload res_pjsip.so` and re-register the phone. Some softphones +> do not show MWI at all — check `voicemail show users` for the truth. + +--- + +## Step 2 — Transfers + +A receptionist who cannot transfer a call is not a receptionist. + +Transfers live in `/etc/asterisk/features.conf`, in the `[featuremap]` section. Make it +read: + +```ini +[featuremap] +blindxfer => #1 +atxfer => #2 +disconnect => *0 +``` + +**Two of those are commented out in the shipped file and one is missing entirely.** +`blindxfer` and `disconnect` are present with a leading `;` — uncomment them. **`atxfer` +is not in the file at all**; you have to add the line yourself. + +Check your work, because this one misleads people: + +```bash +grep -n 'xfer' /etc/asterisk/features.conf +``` + +Several shipped lines contain `atxfer` as part of a *longer* option name — +`atxferabort`, `atxfercallbackretries`, `atxferdropcall`. It is easy to see those, think +attended transfer is already configured, and spend twenty minutes wondering why `#2` does +nothing. + +These define **DTMF feature codes** — key sequences a party can press *during* a call to +make Asterisk do something. + +| Code | Feature | What happens | +|---|---|---| +| `#1` | Blind transfer | You press `#1`, dial a number, and hang up immediately. The call is handed over whether or not anyone answers. | +| `#2` | Attended transfer | You press `#2`, dial, **talk to the person first**, then hang up to connect them. If they refuse, you get the caller back. | +| `*0` | Disconnect | Ends the call. | + +Feature codes only work if the channel is told to listen for them. In your dialplan, the +`Dial()` needs flags: + +```ini + same => n,Dial(PJSIP/${EXTEN},20,tT) +``` + +- **`t`** — allow the **called** party to transfer +- **`T`** — allow the **calling** party to transfer + +Capital and lowercase mean different things here, and getting them the wrong way round is +a classic. Add `tT` to the `_60XX` Dial in `[features]`. + +```bash +sudo asterisk -rx 'module reload features' +sudo asterisk -rx 'dialplan reload' +sudo asterisk -rx 'features show' +``` + +**You should see** your feature codes listed under Dynamic Features / Feature Map. + +### Test it + +You need three parties, so use your two softphones plus the IVR echo test as the third +destination. + +1. `6001` calls `6002`. Answer. +2. On `6002`, press **`#2`** — the caller hears hold music. +3. Dial `600` and press `#`. You are now talking to the echo test. +4. Hang up `6002`. `6001` is now connected to the echo test. + +**You should see** in the CLI the channels being re-bridged as the transfer completes. + +> Pressing `#2` does nothing? Your softphone is probably sending DTMF as SIP INFO while +> Asterisk expects RFC 4733. Set `dtmf_mode=rfc4733` on the endpoint (it is the default), +> and check the phone's own DTMF setting matches. `pjsip set logger on` shows you which +> is being used. + +--- + +## Step 3 — Music on hold + +That hold music during the transfer came from the `default` MOH class, which +`make samples` populated. Confirm: + +```bash +sudo asterisk -rx 'moh show classes' +``` + +**You should see** class `default`, mode `files`, pointing at +`/var/lib/asterisk/moh`. + +Add a second class so you can hear the difference. In `/etc/asterisk/musiconhold.conf`: + +```ini +[queue-hold] +mode=files +directory=moh +sort=random +``` + +`sort=random` matters more than it sounds: with the default alphabetical sort, every +caller hears the same track from the beginning, and a queue full of people hears the same +music in lockstep. + +```bash +sudo asterisk -rx 'moh reload' +sudo asterisk -rx 'moh show classes' +``` + +--- + +## Step 4 — Call parking + +A transfer sends a call to a person. **Parking** puts a call somewhere and tells you the +slot number, so anyone can collect it — the PBX equivalent of "hold line 2". + +Open `/etc/asterisk/res_parking.conf` and find the `[default]` lot — **it is already +there**, and already says what you need: + +```ini +[default] +parkext => 700 +parkpos => 701-720 +context => parkedcalls +parkingtime => 45 +findslot => first +comebacktoorigin => yes +``` + +Read it rather than retyping it. **Do not add a second lot** with its own `parkext => 700`: +two lots claiming the same extension is a conflict, the new lot silently fails to load, +and `parking show ` answers `Could not find parking lot`. + +- `parkext => 700` — dial 700 (or transfer to it) to park a call +- `parkpos => 701-720` — the slots calls land in +- `parkingtime => 45` — after 45 seconds unparked, it comes back to whoever parked it + +That last one matters. Without it a forgotten call sits on hold forever and the caller +eventually gives up on your company. + +Make the parking lot reachable from `[features]`: + +```ini +include => parkedcalls +exten => 700,1,Park() +``` + +```bash +sudo asterisk -rx 'module reload res_parking.so' +sudo asterisk -rx 'dialplan reload' +sudo asterisk -rx 'parking show default' +``` + +**You should see:** + +``` +Parking Lot: default +Parking Extension : 700 +Parking Context : parkedcalls +Parking Spaces : 701-720 +Parking Time : 45 sec +Comeback to Origin : yes +``` + +> Use `parking show default`, not bare `parking show` — the latter prints only the general +> options and lists no lots at all, which reads as "parking is not configured" when it is +> working perfectly. + +### Test it + +1. `6001` calls `6002`, answer. +2. On `6002` press `#1` (blind transfer), dial `700`. +3. **You should hear** Asterisk announce a slot number — probably **701**. +4. From `6002`, dial `701`. You are reconnected to `6001`. + +```bash +sudo asterisk -rx 'parking show default' +``` + +Run that while the call is parked and **you should see** it occupying a slot. + +--- + +## Step 5 — Call pickup + +The phone on the next desk is ringing and its owner is at lunch. Pickup lets you answer +it from your own phone. + +Both endpoints need to be in the same pickup group. In `pjsip.conf`, add to **both** +`[6001]` and `[6002]` endpoint sections: + +```ini +callgroup=1 +pickupgroup=1 +``` + +- `callgroup` — which group this phone's ringing calls belong to +- `pickupgroup` — which groups this phone is allowed to snatch calls from + +They are separate on purpose: a manager can be allowed to pick up their team's calls +without the team being able to pick up the manager's. + +In `[features]`: + +```ini +exten => *8,1,NoOp(Group pickup by ${CALLERID(num)}) + same => n,PickupChan(PJSIP/${EXTEN}) + same => n,Hangup() + +exten => **,1,Pickup() + same => n,Hangup() +``` + +```bash +sudo asterisk -rx 'module reload res_pjsip.so' +sudo asterisk -rx 'dialplan reload' +``` + +### Test it + +1. From the IVR or a third phone, call `6002` and let it ring. +2. On `6001`, dial `**`. +3. **You should see** `6001` connected to the caller, and `6002` stops ringing. + +--- + +## Step 6 — A call queue with agents + +This is the call-centre part, and it is where a PBX stops being a phone system and +starts being an operations tool. + +In `/etc/asterisk/queues.conf`: + +```ini +[general] +persistentmembers = yes +monitor-type = MixMonitor + +[support] +strategy = rrmemory +timeout = 15 +retry = 5 +wrapuptime = 10 +maxlen = 0 +musicclass = queue-hold +announce-frequency = 30 +announce-holdtime = yes +joinempty = no +leavewhenempty = no +ringinuse = no + +member => PJSIP/6001,0,Alice +member => PJSIP/6002,1,Bob +``` + +The settings that decide whether your queue behaves sensibly: + +| Setting | What it does | +|---|---| +| `strategy = rrmemory` | Round-robin, but it *remembers* where it stopped, so the same agent is not hit first every time. | +| `timeout = 15` | Ring one agent for 15s before moving to the next. | +| `wrapuptime = 10` | Give an agent 10s after a call before sending them another. Without it, agents get a new call while still typing up the last one. | +| `ringinuse = no` | Do not ring an agent already on a call. Sounds obvious; is off by default. | +| `joinempty = no` | Do not let callers into a queue with no logged-in agents — they would wait for nobody. | +| `member => PJSIP/6001,0,Alice` | The `0` is a **penalty**. Lower penalty is tried first, so Bob (1) only rings when Alice (0) is unavailable. That is how you build tiered support. | + +Route a number into it, in `[features]`: + +```ini +exten => 6500,1,NoOp(Caller ${CALLERID(num)} entering the support queue) + same => n,Answer() + same => n,Queue(support,tT) + same => n,NoOp(Left queue with status ${QUEUESTATUS}) + same => n,Hangup() +``` + +```bash +sudo asterisk -rx 'module reload app_queue.so' +sudo asterisk -rx 'dialplan reload' +sudo asterisk -rx 'queue show support' +``` + +**You should see** the queue with both members, their penalties, and `No Callers`. + +### Test it + +1. Dial `6500` from the IVR path or another phone. Alice (`6001`) should ring first. +2. Do not answer. After 15 seconds Bob (`6002`) rings. +3. Answer on `6002`. + +While a caller is waiting: + +```bash +sudo asterisk -rx 'queue show support' +``` + +**You should see** the caller listed with their position and wait time, and the members +marked `In use` or `Not in use`. + +--- + +## Step 7 — Watch the queue like an operations person + +```bash +sudo asterisk -rx 'queue show support' +``` + +**You should see** at the bottom the counters that matter in a real call centre: + +``` + No Callers + Completed: 1 + Abandoned: 0 + Mean Holdtime: 12s + Service Level: 0.0% +``` + +- **Abandoned** — callers who hung up while waiting. The single most important number in + a call centre, and the one people forget to look at. +- **Mean Holdtime** — average wait before an agent answered. +- **Service Level** — percentage answered within `servicelevel` seconds. Set + `servicelevel = 20` in the queue and reload to make it meaningful. + +Agents can log in and out at runtime rather than being fixed in the config: + +```bash +sudo asterisk -rx 'queue remove member PJSIP/6002 from support' +sudo asterisk -rx 'queue show support' +sudo asterisk -rx 'queue add member PJSIP/6002 to support penalty 1' +``` + +**You should see** the member list change. This is what a "log out for lunch" button does +underneath — and in the Programmability section you will do exactly this over AMI. + +There is also a running log of every queue event: + +```bash +sudo tail -20 /var/log/asterisk/queue_log +``` + +**You should see** `ENTERQUEUE`, `CONNECT`, `COMPLETEAGENT` / `COMPLETECALLER` records. +Every call-centre report ever written is built from this file. + +--- + +## ✅ Checkpoint + +You have finished when all six are true: + +1. A call that rings out lands in voicemail, and `*97` plays it back +2. `#2` performs an attended transfer between your two phones +3. Parking a call announces a slot, and dialling that slot retrieves it +4. `**` picks up a ringing phone from the other handset +5. `queue show support` lists both agents with different penalties, and a call to `6500` + rings Alice before Bob +6. `queue_log` contains `ENTERQUEUE` and `CONNECT` for that call + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `VoiceMail()` says the mailbox does not exist | Mailbox not in the `default` context | `voicemail show users`. The dialplan says `${EXTEN}@default`, so the mailbox must be under `[default]` in voicemail.conf. | +| Voicemail records but `*97` finds nothing | Collecting from a phone whose caller ID is not the mailbox number | `VoiceMailMain(${CALLERID(num)})` uses the caller's number. Check `callerid=` on the endpoint. | +| Feature codes do nothing | Missing `tT` on `Dial()`, or DTMF mismatch | `features show` to confirm the codes, then `pjsip set logger on` during a call and look for the DTMF method the phone uses. | +| Transfer drops the call instead | Blind transfer to a number that does not exist | The target must be reachable **from the context the transferring phone is in**. Check with `dialplan show @features`. | +| Parked call never comes back | `comebacktoorigin` off, or the parker hung up | `parking show default` while parked. | +| Pickup says "there is no call to pick up" | Groups do not match, or the phone is not ringing yet | Both endpoints need `callgroup` **and** `pickupgroup`. Reload res_pjsip and re-register. | +| Queue rings nobody | Members not reachable, or `joinempty=no` with no agents | `queue show support` — members must not be `Unavailable`. An unregistered phone is an unavailable agent. | +| Both agents ring at once | `strategy = ringall` | You want `rrmemory`. Check you edited the right queue. | +| Lost the configuration | — | `sudo lab reset` restores the baseline; rebuild from Lab 2. | + +--- + +## What you built + +``` + 6500 ──► Queue(support) strategy=rrmemory, wrapuptime=10 + ├─ penalty 0 PJSIP/6001 Alice ← tried first + └─ penalty 1 PJSIP/6002 Bob ← only when Alice is busy + + _60XX ──► Dial(...,20,tT) ──► no answer ──► VoiceMail(u) + └─ busy ──► VoiceMail(b) + └─ #1 / #2 ──► transfer + └─ 700 ──► park (701-720, 45s timeout) + + *97 ──► VoiceMailMain ** ──► Pickup +``` + +--- + +**Next:** Lab 6 — the SIP and PJSIP in Depth section — you will capture these calls with `sngrep` +and read what actually went across the wire. diff --git a/labs/lab6-sip-in-depth.md b/labs/lab6-sip-in-depth.md new file mode 100644 index 0000000..9dd7451 --- /dev/null +++ b/labs/lab6-sip-in-depth.md @@ -0,0 +1,381 @@ +# Lab 6: See What SIP Actually Sends + +**Time:** ~55 minutes +**Prerequisites:** Lab 5 — voicemail, transfers and the `support` queue all work. + +Five labs of building, and you have never looked at a SIP packet. + +Everything so far you diagnosed from Asterisk's point of view — `pjsip show endpoints`, +`core show channels`. That works until the day Asterisk and the phone disagree about +what happened, and then the only source of truth is what actually crossed the wire. + +This lab is about reading that. Then you use what you learn to negotiate codecs +deliberately, put a phone in a browser, and encrypt the whole thing. + +--- + +## Step 1 — Watch a call, live + +`sngrep` was installed on your lab machine in Lab 0. It is a SIP packet capture tool with +a ladder diagram, and it is the single most useful troubleshooting tool in VoIP. + +On the VM: + +```bash +sudo sngrep +``` + +**You should see** an empty list with a header. It is now capturing every SIP packet on +the machine. + +Leave it running and, from your softphone, **dial `600`**. + +**You should see** a new row appear the instant the call starts — method `INVITE`, from +`6001`, to `600`. + +Select it with the arrow keys and press **Enter**. + +**You should see** the ladder diagram — time down the left, one column per party, every +message as an arrow: + +``` + 192.168.1.20 192.168.1.47 + │ │ + │ ──────── INVITE ──────────► │ + │ ◄─────── 100 Trying ─────── │ + │ ◄─────── 200 OK ─────────── │ + │ ──────── ACK ─────────────► │ + │ │ + │ ◄─────── BYE ────────────── │ (or ──── BYE ────►, depending who hung up) + │ ──────── 200 OK ──────────► │ +``` + +That shape — INVITE, a provisional response, 200 OK, ACK — is the SIP call setup you will +see for the rest of your career. Learn to recognise it and anything abnormal jumps out. + +Press **Enter** on the `INVITE` line to read the whole message. + +Keys worth knowing: **F2** save to pcap, **F7** filter, **Esc** back, **q** quit. + +--- + +## Step 2 — Read the SDP + +Inside the INVITE, below the headers and after a blank line, is the **SDP** — the body +that negotiates media. Scroll to it. + +**You should see** something like: + +``` +v=0 +o=- 3950481324 3950481324 IN IP4 192.168.1.20 +c=IN IP4 192.168.1.20 +m=audio 4002 RTP/AVP 0 8 101 +a=rtpmap:0 PCMU/8000 +a=rtpmap:8 PCMA/8000 +a=rtpmap:101 telephone-event/8000 +``` + +Four lines carry all the meaning: + +| Line | Meaning | +|---|---| +| `c=IN IP4 192.168.1.20` | **Send the audio here.** Not where the SIP came from — where the *media* should go. When these differ, you have NAT. | +| `m=audio 4002 RTP/AVP 0 8 101` | "I will receive audio on **port 4002**, and I support payload types 0, 8 and 101, in that order of preference." | +| `a=rtpmap:0 PCMU/8000` | Payload type 0 is μ-law. | +| `a=rtpmap:101 telephone-event/8000` | DTMF as RFC 4733 — the keypresses that made your transfers work in Lab 5. | + +Now look at Asterisk's **200 OK** and find *its* SDP. + +**You should see** a much shorter `m=` line — typically `m=audio RTP/AVP 0 101`. + +**That is codec negotiation.** The phone offered three; Asterisk replied with the one it +chose. It chose μ-law because your endpoint config said `disallow=all` then `allow=ulaw` +first. The answer is always a subset of the offer. + +--- + +## Step 3 — Change the negotiation and watch it change + +Prove the connection between config and packets. + +Edit `[6001]`'s **endpoint** section in `/etc/asterisk/pjsip.conf` and reverse the codec +order: + +```ini +disallow=all +allow=alaw +allow=ulaw +``` + +```bash +sudo asterisk -rx 'module reload res_pjsip.so' +``` + +Re-register the phone if needed, then dial `600` again with `sngrep` running. + +**You should see** the 200 OK now answer with **`m=audio RTP/AVP 8 101`** — payload +type 8, A-law. The phone offered the same three codecs; you changed which one Asterisk +picked, from the server side, without touching the phone. + +Confirm from Asterisk's side during the call: + +```bash +sudo asterisk -rx 'core show channels concise' | head -3 +sudo asterisk -rx 'pjsip show channelstats' +``` + +**You should see** the negotiated codec and live RTP counters — packets sent, received, +and lost. + +Put `allow=ulaw` back first when you are done. + +> **Why this matters commercially:** G.711 (μ-law/A-law) is 64 kbit/s per call plus +> overhead — about 87 kbit/s on the wire. Thirty concurrent calls is roughly 2.6 Mbit/s +> each way. That calculation, from the `m=` line, is how you size a customer's circuit. + +--- + +## Step 4 — See what breaks with NAT + +Your lab has no NAT: the phone and the PBX are on one flat network, which is why audio has +just worked. Most real deployments are not like that, and one-way audio is the single most +common VoIP support call. + +You can see the cause without breaking anything. In the INVITE's SDP, compare: + +- the **`c=` line** — where the phone says to send audio +- the **actual source IP** of the packet, shown at the top of the sngrep screen + +**In your lab these are the same.** Behind NAT they are not: the phone writes its *private* +address into `c=` (`192.168.x.x`), the router rewrites the packet's source to the public +address, and Asterisk dutifully sends audio to a private address that means nothing to it. +The caller hears nothing; the phone's audio still arrives. **One-way audio.** + +The fix is telling Asterisk to ignore the SDP and use the address the packets really came +from. Add to an endpoint that sits behind NAT: + +```ini +rtp_symmetric=yes +force_rport=yes +rewrite_contact=yes +``` + +| Option | What it does | +|---|---| +| `rtp_symmetric=yes` | Send RTP back where it came from, not where `c=` claims. | +| `force_rport=yes` | Reply to the real source port, not the one in the `Via` header. | +| `rewrite_contact=yes` | Replace the `Contact` with the real address, so you can call the phone later. | + +Those three lines fix the majority of one-way-audio tickets. You do not need them in this +lab — but you will know why the day you do. + +--- + +## Step 5 — A phone in the browser + +WebRTC puts a phone on a web page: no install, no configuration by the user. It needs two +things your PBX does not have yet — a **secure WebSocket** to signal over, and an endpoint +configured for it. + +### Give Asterisk a TLS certificate + +WebRTC will not run over an insecure connection, so this is not optional. + +```bash +sudo lab certs +``` + +That generates a self-signed certificate for your machine's current IP into +`/etc/asterisk/keys/`. (It also runs at every boot, so the certificate follows your IP if +DHCP changes it.) + +### Turn on the HTTP server + +Edit `/etc/asterisk/http.conf`: + +```ini +[general] +enabled=yes +bindaddr=0.0.0.0 +bindport=8088 +tlsenable=yes +tlsbindaddr=0.0.0.0:8089 +tlscertfile=/etc/asterisk/keys/asterisk.pem +tlsprivatekey=/etc/asterisk/keys/asterisk.key +``` + +```bash +sudo asterisk -rx 'module reload http' +sudo asterisk -rx 'http show status' +``` + +**You should see** the server enabled, bound on 8088, and a TLS listener on 8089. If the +TLS line is missing, the certificate is not where `http.conf` says it is. + +### Add the WebRTC endpoint + +Append to `/etc/asterisk/pjsip.conf`: + +```ini +[transport-wss] +type=transport +protocol=wss +bind=0.0.0.0 + +[webrtc-1000] +type=endpoint +context=internal +disallow=all +allow=ulaw +allow=opus +aors=webrtc-1000 +auth=webrtc-1000 +webrtc=yes +transport=transport-wss +callerid=Browser <1000> + +[webrtc-1000] +type=auth +auth_type=userpass +username=webrtc-1000 +password=Lab-webrtc-secret + +[webrtc-1000] +type=aor +max_contacts=1 +``` + +**`webrtc=yes` is doing a lot of work.** It is a shorthand that switches on everything a +browser requires: DTLS media encryption, an auto-generated certificate for it, ICE +support, AVPF, and RTCP multiplexing. Setting those by hand is six more lines and a +common source of "it almost works". + +```bash +sudo asterisk -rx 'module reload res_pjsip.so' +sudo asterisk -rx 'pjsip show endpoint webrtc-1000' +``` + +**You should see** the endpoint, `Unavailable`, on `transport-wss`. + +Give it a number, in `[features]`: + +```ini +exten => 1000,1,Dial(PJSIP/webrtc-1000,20) + same => n,Hangup() +``` + +### Connect a browser + +Serve the client page that ships with the lab: + +```bash +cd ~/asterisk-guide/lab/webrtc && python3 -m http.server 8000 +``` + +On your own computer, first visit **`https://:8089/ws`** and accept the +certificate warning — the browser will refuse the WebSocket silently otherwise, and this +is the step everyone skips. + +Then open **`http://:8000`**, log in as `webrtc-1000` / `Lab-webrtc-secret`, +and allow microphone access. + +```bash +sudo asterisk -rx 'pjsip show endpoint webrtc-1000' +``` + +**You should see** it become `Not in use` with a contact. + +**Dial `600` from the browser.** You should hear the echo test — from a web page, with +nothing installed. + +Look at that call in `sngrep`: the SDP now says **`RTP/SAVPF`**, not `RTP/AVP`. `S` for +secure, `F` for feedback. The media is DTLS-encrypted end to end. + +--- + +## Step 6 — Encrypt a normal SIP phone too + +WebRTC is encrypted because the browser insists. Your desk phones are not — everything in +Labs 2 to 5 crossed the network in clear text, and `sngrep` read it as easily as you did. + +Two separate things need encrypting, and people routinely confuse them: + +| | Protects | Mechanism | +|---|---|---| +| **TLS** | the signalling — who called whom, and the passwords | `transport-tls` | +| **SRTP** | the audio itself | `media_encryption=sdes` | + +TLS alone still sends the conversation in the clear. Do both. + +Add a TLS transport to `pjsip.conf`: + +```ini +[transport-tls] +type=transport +protocol=tls +bind=0.0.0.0:5061 +cert_file=/etc/asterisk/keys/asterisk.pem +priv_key_file=/etc/asterisk/keys/asterisk.key +method=tlsv1_2 +``` + +And require encryption on `6002`'s **endpoint** section: + +```ini +transport=transport-tls +media_encryption=sdes +``` + +```bash +sudo asterisk -rx 'module reload res_pjsip.so' +sudo asterisk -rx 'pjsip show transports' +``` + +**You should see** `transport-tls` on `0.0.0.0:5061`. + +Point the `6002` softphone at **port 5061, transport TLS**, and accept the certificate. +Register, then call `600` with `sngrep` running. + +**You should see** — and this is the point — that sngrep can no longer show you the +message contents. The signalling is encrypted. The SDP will say `RTP/SAVP`. + +```bash +sudo asterisk -rx 'pjsip show endpoint 6002' +``` + +**You should see** `media_encryption: sdes`. + +> **Self-signed certificates are for labs.** A real deployment uses a certificate from a +> CA the phones already trust, or you will spend your life clicking through warnings — +> and teaching users to click through certificate warnings is worse than no TLS at all. + +--- + +## ✅ Checkpoint + +1. `sngrep` shows a ladder diagram for a call to `600`, and you can read its SDP +2. Reversing `allow=` order changes the negotiated payload type from `0` to `8` in the 200 OK +3. You can point at the `c=` line and say what would break behind NAT, and which three options fix it +4. The browser phone registers and `600` echoes your voice through it +5. That call's SDP shows `RTP/SAVPF` +6. `6002` registers over TLS on 5061, and sngrep can no longer read the signalling + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| sngrep shows nothing | Capturing the wrong interface | `sudo sngrep -d any`. Confirm the phone is actually sending: `sudo tcpdump -ni any port 5060`. | +| Codec change has no effect | Reload rejected, old config still loaded | `sudo tail -5 /var/log/asterisk/messages.log` and look for `Retaining existing configuration`. | +| `http show status` shows no TLS listener | Certificate missing or unreadable | `sudo lab certs`, then check `ls -l /etc/asterisk/keys/` — the key must be readable by the `asterisk` user. | +| Browser never registers | Certificate not accepted | Visit `https://:8089/ws` directly first and accept the warning. Then check the browser console. | +| Browser registers but no audio | Microphone permission, or ICE | The browser must have mic access on an `https` or `localhost` page. Check `pjsip show endpoint webrtc-1000` shows `webrtc=yes` took effect. | +| TLS phone will not register | Wrong port or protocol on the phone | Must be **5061** and **TLS**, not 5060/TCP. `pjsip set logger on` shows whether anything arrives at all. | +| `media_encryption` rejected | Phone does not support SRTP | Try `media_encryption_optimistic=yes` to allow fallback while testing. | + +--- + +**Next:** Lab 7 — making Asterisk talk to other software: call records in a database, and +controlling calls from your own code. diff --git a/labs/lab7-programmability.md b/labs/lab7-programmability.md new file mode 100644 index 0000000..7da0c3e --- /dev/null +++ b/labs/lab7-programmability.md @@ -0,0 +1,471 @@ +# Lab 7: Make Asterisk Talk to Your Own Code + +**Time:** ~55 minutes +**Prerequisites:** Lab 6 — specifically **Step 5, where you switched on Asterisk's HTTP +server**. ARI is served by it, so if you skipped that part every `curl` in Step 4 will +refuse the connection. Check before you start: + +```bash +sudo asterisk -rx 'http show status' +``` + +**You should see** `Server Enabled and Bound to 0.0.0.0:8088`. If not, go back to Lab 6 +Step 5 — it is five lines of `http.conf`. + +A PBX that only does what its config file says is a phone system. A PBX other software can +query, drive and extend is infrastructure. + +This lab connects Asterisk to four things: a database for call records, a management +socket you can script, a dialplan hook that runs your program, and a REST API that lets +your application control calls directly. + +--- + +## Step 1 — Call records where you can query them + +You have been reading CDRs out of a CSV since Lab 2. That is fine for one machine and +useless for reporting. + +MariaDB is already installed. Create the database: + +```bash +sudo mariadb <<'SQL' +CREATE DATABASE IF NOT EXISTS asterisk; +CREATE USER IF NOT EXISTS 'asterisk'@'localhost' IDENTIFIED BY 'Lab-cdr-secret'; +GRANT ALL ON asterisk.* TO 'asterisk'@'localhost'; +FLUSH PRIVILEGES; +USE asterisk; +CREATE TABLE IF NOT EXISTS cdr ( + id INT AUTO_INCREMENT PRIMARY KEY, + calldate DATETIME NOT NULL DEFAULT '1900-01-01 00:00:00', + clid VARCHAR(80) NOT NULL DEFAULT '', + src VARCHAR(80) NOT NULL DEFAULT '', + dst VARCHAR(80) NOT NULL DEFAULT '', + dcontext VARCHAR(80) NOT NULL DEFAULT '', + channel VARCHAR(80) NOT NULL DEFAULT '', + dstchannel VARCHAR(80) NOT NULL DEFAULT '', + lastapp VARCHAR(80) NOT NULL DEFAULT '', + lastdata VARCHAR(80) NOT NULL DEFAULT '', + duration INT NOT NULL DEFAULT 0, + billsec INT NOT NULL DEFAULT 0, + disposition VARCHAR(45) NOT NULL DEFAULT '', + amaflags VARCHAR(45) NOT NULL DEFAULT '', + accountcode VARCHAR(20) NOT NULL DEFAULT '', + uniqueid VARCHAR(150) NOT NULL DEFAULT '', + userfield VARCHAR(255) NOT NULL DEFAULT '', + INDEX (calldate), INDEX (src), INDEX (dst) +); +SQL +echo "database ready" +``` + +> Those indexes are not decoration. A CDR table is the fastest-growing table you will +> own; a year in, an unindexed `WHERE calldate BETWEEN` is a full table scan. + +### Connect it through ODBC + +Asterisk reaches databases through **unixODBC**, which is why it can talk to MariaDB, +PostgreSQL and SQL Server with the same code. Two files. + +`/etc/odbcinst.ini` — describes the *driver*: + +```ini +[MariaDB] +Description = MariaDB ODBC driver +Driver = /usr/lib/x86_64-linux-gnu/odbc/libmaodbc.so +``` + +`/etc/odbc.ini` — describes a *connection* using that driver: + +```ini +[asterisk-cdr] +Description = Asterisk CDR +Driver = MariaDB +Server = localhost +Database = asterisk +Port = 3306 +``` + +Test it **before involving Asterisk** — this is the step that saves an hour: + +```bash +isql -v asterisk-cdr asterisk Lab-cdr-secret +``` + +**You should see** `Connected!` and an `SQL>` prompt. Type `quit`. + +If that fails, Asterisk will fail too, and its error will be less clear. Fix it here. + +### Tell Asterisk to use it + +`/etc/asterisk/res_odbc.conf`: + +```ini +[asterisk] +enabled => yes +dsn => asterisk-cdr +username => asterisk +password => Lab-cdr-secret +pre-connect => yes +``` + +`/etc/asterisk/cdr_adaptive_odbc.conf`: + +```ini +[cdr] +connection=asterisk +table=cdr +alias start => calldate +``` + +**Adaptive** ODBC means Asterisk inspects the table and writes whatever columns exist. Add +a column, it starts populating it — no code change. + +**That last line is not optional, and leaving it out fails in the worst possible way.** +Adaptive ODBC matches columns to CDR *variable names*, and the variable holding the start +time is called **`start`**, not `calldate`. Without the alias, rows still appear — the +call, the numbers, the duration, the disposition, all correct — but every `calldate` is +`1900-01-01 00:00:00`, the column default. + +Nothing errors. Nothing warns. You find out months later when someone asks for last +quarter's call volumes and every record is dated 1900. + +`calldate` is the traditional name from the old `cdr_mysql` schema, which is why almost +every CDR table you meet uses it — and why this alias is almost always needed. + +```bash +sudo asterisk -rx 'module reload res_odbc.so' +sudo asterisk -rx 'module reload cdr_adaptive_odbc.so' +sudo asterisk -rx 'odbc show all' +``` + +**You should see:** + +``` + Name: asterisk + DSN: asterisk-cdr + Number of active connections: 1 (out of 1) +``` + +**`Number of active connections: 1`** is the line that matters — it means Asterisk +opened the connection at load time, because you set `pre-connect => yes`. A `0` there +means the credentials or the DSN are wrong, and no CDR will ever be written. + +> Older documentation shows a `Logged in: yes` line here. Asterisk 22 does not print it; +> do not go looking for it. + +### Test + +Place a call — dial `600` and hang up. Then: + +```bash +sudo mariadb -e "SELECT calldate,src,dst,disposition,billsec FROM asterisk.cdr ORDER BY id DESC LIMIT 5;" +``` + +**You should see** your call — and check the **`calldate` column has a real date in it**, +not `1900-01-01`. If it does not, the `alias` line above is missing or misspelled. + +Every call from now on lands in a table you can report from. + +--- + +## Step 2 — AMI: drive Asterisk from a script + +The **Asterisk Manager Interface** is a TCP socket that emits events and accepts commands. +Every "click to dial" button and every wallboard is built on it. + +`/etc/asterisk/manager.conf`: + +```ini +[general] +enabled = yes +port = 5038 +bindaddr = 127.0.0.1 + +[labami] +secret = Lab-ami-secret +read = system,call,agent,user,cdr,dialplan +write = system,call,agent,user,originate +``` + +**`bindaddr = 127.0.0.1` is deliberate.** AMI is a full remote-control interface with a +plaintext password. Exposing it on `0.0.0.0` hands your PBX to anyone who can reach port +5038. Bind it to loopback and reach it over SSH. + +```bash +sudo asterisk -rx 'module reload manager' +sudo asterisk -rx 'manager show users' +``` + +**You should see** the `labami` user. + +### Talk to it by hand + +AMI is line-based text, so `nc` is enough to understand it: + +```bash +nc 127.0.0.1 5038 +``` + +Type this, ending with a **blank line**: + +``` +Action: Login +Username: labami +Secret: Lab-ami-secret + +``` + +**You should see** `Response: Success` — then a flood of events as things happen. + +Now originate a call. Type: + +``` +Action: Originate +Channel: PJSIP/6001 +Context: internal +Exten: 600 +Priority: 1 +CallerID: AMI Test <9999> + +``` + +**You should see** phone `6001` ring. Answer it and you are in the echo test — a call +created by a socket command, with no one dialling anything. + +Type `Action: Logoff` and a blank line to exit. + +### Script it + +```bash +cat > ~/click2dial.sh <<'EOF' +#!/usr/bin/env bash +# Usage: ./click2dial.sh +FROM="${1:?which extension should ring}" +TO="${2:?what should it dial}" +printf 'Action: Login\r\nUsername: labami\r\nSecret: Lab-ami-secret\r\n\r\n%s\r\n' \ + "Action: Originate +Channel: PJSIP/${FROM} +Context: internal +Exten: ${TO} +Priority: 1 +Async: true +" | tr '\n' '\r' | sed 's/\r/\r\n/g' | nc -q3 127.0.0.1 5038 +EOF +chmod +x ~/click2dial.sh +~/click2dial.sh 6001 600 +``` + +**You should see** `6001` ring again. That script is, in essence, the click-to-dial button +in every CRM you have ever used. + +> `Async: true` matters. Without it the connection blocks until the call finishes, and a +> web app doing that will hang. + +--- + +## Step 3 — AGI: run your program inside the dialplan + +AMI controls calls from outside. **AGI** runs your program *during* a call, as a dialplan +step: Asterisk passes the call's variables in on stdin, your program writes commands on +stdout. + +```bash +sudo tee /var/lib/asterisk/agi-bin/callerinfo.py >/dev/null <<'EOF' +#!/usr/bin/env python3 +"""Minimal AGI: read the call environment, speak a decision back.""" +import sys + +# Asterisk sends the call environment as key: value lines, ending with a blank line. +env = {} +while True: + line = sys.stdin.readline().strip() + if line == "": + break + if ':' in line: + k, v = line.split(':', 1) + env[k.strip()] = v.strip() + +def agi(cmd): + """Send one AGI command and read its result line.""" + sys.stdout.write(cmd + "\n") + sys.stdout.flush() + return sys.stdin.readline().strip() + +caller = env.get('agi_callerid', 'unknown') +exten = env.get('agi_extension', 'unknown') + +# Anything on stderr goes to the Asterisk log — this is how you debug an AGI. +sys.stderr.write(f"callerinfo: {caller} dialled {exten}\n") + +agi('VERBOSE "AGI running for caller %s" 1' % caller) +agi('SET VARIABLE AGI_RESULT "seen-%s"' % caller) +agi('STREAM FILE demo-congrats ""') +EOF +sudo chmod +x /var/lib/asterisk/agi-bin/callerinfo.py +sudo chown asterisk:asterisk /var/lib/asterisk/agi-bin/callerinfo.py +``` + +Wire it into `[features]`: + +```ini +exten => 6600,1,NoOp(AGI demo) + same => n,Answer() + same => n,AGI(callerinfo.py) + same => n,NoOp(AGI set the variable to: ${AGI_RESULT}) + same => n,Hangup() +``` + +```bash +sudo asterisk -rx 'dialplan reload' +``` + +**Dial `6600`.** You should hear the prompt, and in the CLI: + +``` +AGI running for caller ... +AGI set the variable to: seen-... +``` + +The important part is `SET VARIABLE`: your program handed a value **back into the +dialplan**, which can then route on it. That is how database lookups, blacklists and +customer routing get built. + +> AGI is synchronous — the call waits for your script. A slow database query is dead air. +> For anything that might block, use ARI instead. + +--- + +## Step 4 — ARI: control calls from an application + +AGI runs a script per call. **ARI** is different in kind: your application connects once +over WebSocket, receives events, and manipulates channels over REST. The call lives in a +**Stasis** application — Asterisk hands it over and stops applying dialplan. + +`/etc/asterisk/ari.conf`: + +```ini +[general] +enabled = yes +pretty = yes + +[labuser] +type = user +password = Lab-ari-secret +``` + +```bash +sudo asterisk -rx 'module reload res_ari.so' +sudo asterisk -rx 'ari show users' +``` + +**You should see** `labuser`. + +Query it — ARI is plain REST, so `curl` works. If these return nothing at all rather +than an error, the HTTP server is off; see the prerequisite at the top. + +```bash +curl -s -u labuser:Lab-ari-secret http://localhost:8088/ari/asterisk/info | jq '.system' +``` + +**You should see** the system name and version as JSON. + +```bash +curl -s -u labuser:Lab-ari-secret http://localhost:8088/ari/endpoints | jq '.[] | {resource, state}' +``` + +**You should see** every endpoint and whether it is online — the same information as +`pjsip show endpoints`, in a form a web app can consume. + +### Hand a call to Stasis + +Add to `[features]`: + +```ini +exten => 6700,1,NoOp(Handing this call to the ARI application) + same => n,Answer() + same => n,Stasis(lab-app) + same => n,Hangup() +``` + +```bash +sudo asterisk -rx 'dialplan reload' +``` + +Now write the application. It listens for events and plays a prompt to any call it is +given: + +The lab image already has the `websockets` library installed, so this is short: + +```bash +cp /opt/lab/examples/ari-app.py ~/ari-app.py +chmod +x ~/ari-app.py +``` + +Read it — it is about forty lines, and the shape is the whole lesson: + +- **Events arrive over a WebSocket**, one JSON object per event. +- **Actions go back over REST**, one HTTP call per thing you want done. + +Two channels, one conversation. When `StasisStart` arrives, the program has a channel ID +and decides what to do with it; here it POSTs a `play` to that channel. + +> Do not hand-roll the WebSocket. A client must mask its frames, and a naive socket that +> just reads bytes will work on a quiet system and corrupt under load. Use the library. + +Run it in one terminal: + +```bash +python3 ~/ari-app.py +``` + +**You should see** `connected to ARI, waiting for calls`. + +**Dial `6700`** from your softphone. + +**You should see** `StasisStart: 6001 -> channel ...` and hear the prompt — played not by +the dialplan, but by your program deciding to play it. + +While the call is up, from another terminal: + +```bash +curl -s -u labuser:Lab-ari-secret http://localhost:8088/ari/channels | jq '.[] | {id, name, state}' +``` + +**You should see** the live channel as JSON. + +> **The distinction worth keeping:** in the dialplan, Asterisk decides and your code +> assists. In Stasis, your code decides and Asterisk executes. Everything modern — +> voicebots, browser-based contact centres, AI agents on a call — is built on this side. + +--- + +## ✅ Checkpoint + +1. `odbc show all` reports `Number of active connections: 1`, and a call appears in the `asterisk.cdr` table +2. An AMI `Originate` makes `6001` ring +3. `~/click2dial.sh 6001 600` does the same from a script +4. Dialling `6600` runs the AGI script and sets `${AGI_RESULT}` in the dialplan +5. `curl .../ari/endpoints` returns your endpoints as JSON +6. Dialling `6700` produces `StasisStart` in your ARI application, and it plays a prompt + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `isql` fails to connect | Driver path wrong | `ls /usr/lib/x86_64-linux-gnu/odbc/` and correct `Driver =` in odbcinst.ini. | +| `odbc show all` says `Logged in: no` | Credentials, or MariaDB not running | `systemctl status mariadb`, then re-test with `isql` first. | +| CDRs still only in CSV | `cdr_adaptive_odbc` not loaded | `module show like cdr`. Check `cdr_adaptive_odbc.conf` names `connection=asterisk`. | +| CDR row exists but columns are empty | Column names do not match CDR fields | Adaptive ODBC matches on name — `src`, `dst`, `billsec` must be spelled exactly. | +| AMI login fails | Reload not done, or wrong secret | `manager show users`, then `manager show user labami`. | +| AMI `Originate` returns success but nothing rings | The channel is unregistered | `pjsip show endpoints` — you cannot originate to a phone that is not there. | +| AGI does nothing | Not executable, or wrong owner | `ls -l /var/lib/asterisk/agi-bin/` — must be executable and owned by `asterisk`. Check the log for `stderr` output. | +| ARI 401 | Wrong user, or `ari.conf` not reloaded | `ari show users`. | +| ARI connects but no events | Dialplan does not hand the call over | The app name in `Stasis(lab-app)` must match `?app=lab-app` exactly. | + +--- + +**Next:** Lab 8 — locking it down and keeping it running: iptables, fail2ban, systemd, +backups and monitoring. diff --git a/labs/lab8-security-operations.md b/labs/lab8-security-operations.md new file mode 100644 index 0000000..22820bd --- /dev/null +++ b/labs/lab8-security-operations.md @@ -0,0 +1,488 @@ +# Lab 8: Lock It Down and Keep It Running + +**Time:** ~60 minutes +**Prerequisites:** Lab 7 — CDRs land in MariaDB and ARI answers. + +Your PBX can now place calls out through a trunk, on an account that costs money. That +makes it a target. Toll fraud is not a hypothetical: attackers scan the internet +continuously for SIP ports, and a PBX found with a weak password can carry thousands of +pounds of international calls before anyone notices — usually over a weekend. + +Everything here happens on the machine, not in Asterisk's configuration: the service +manager that restarts it, the firewall in front of it, the jail that bans an attacker, +the backup you restore from. This is the part of running a PBX that has nothing to do +with telephony and everything to do with whether the phones work on Monday. + +You will attack your own PBX, watch it get broken into, then stop it. + +--- + +## Step 1 — See the attack surface + +```bash +sudo ss -lnptu | grep -E 'asterisk|:5060|:5061|:8088|:8089|:5038' +``` + +**You should see** Asterisk listening on 5060/UDP (SIP), 5061/TCP (TLS), 8088 and 8089 +(HTTP/ARI/WSS), and 5038 on **127.0.0.1** only (AMI, from Lab 7). + +Every one of those is a way in. Ask of each: *does this need to be reachable from the +whole network?* + +- **5060** — yes, phones need it +- **8088/8089** — only if you use ARI or WebRTC +- **5038** — no. It is on loopback already, which is why that was the right default. + +--- + +## Step 2 — Break into your own PBX + +Before defending it, see the attack. `sipp` is already installed. + +Watch the log in one terminal: + +```bash +sudo tail -f /var/log/asterisk/messages.log +``` + +In another, register-scan with a wrong password, the way a scanner does: + +```bash +for u in 1000 1001 6001 6002 admin; do + timeout 3 sipp -sn uac 127.0.0.1 -s "$u" -m 1 -r 5 -trace_err 2>/dev/null +done +echo "scan finished" +``` + +Now ask Asterisk what it saw: + +```bash +sudo asterisk -rx 'pjsip show endpoints' | head -5 +``` + +**The problem:** by default, this leaves almost no trace. A stock Asterisk does not log +failed authentication anywhere useful, so an attacker can try thousands of passwords in +silence. + +Turn on the security log — this is the single most valuable line in this lab. + +Edit `/etc/asterisk/logger.conf` and add to `[logfiles]`: + +```ini +security => security +``` + +```bash +sudo asterisk -rx 'logger reload' +``` + +Run the scan again, then: + +```bash +sudo cat /var/log/asterisk/security +``` + +**You should see** structured security events — `InvalidAccountID`, `ChallengeSent`, +`InvalidPassword` — each with the source address: + +``` +SecurityEvent="InvalidAccountID",EventTV=...,Severity="Error",Service="PJSIP", +RemoteAddress="IPV4/UDP/127.0.0.1/5060",AccountID="admin" +``` + +**That file is what fail2ban reads.** Without it, fail2ban has nothing to work with — and +this is the step most guides omit. + +--- + +## Step 3 — Ban the attacker automatically + +fail2ban was installed in Lab 0 and deliberately left switched off. Now configure it. + +`/etc/fail2ban/jail.d/asterisk.conf`: + +```ini +[asterisk] +enabled = true +port = 5060,5061 +protocol = udp +filter = asterisk +logpath = /var/log/asterisk/security +maxretry = 3 +findtime = 300 +bantime = 3600 +backend = auto +``` + +Read those four numbers as a sentence: **3 failures within 300 seconds gets you banned +for 3600 seconds.** + +Choosing them is a judgement call. Too strict and a user with a stale saved password +locks out their whole office, because everyone shares one public IP. Too loose and a +patient attacker walks through. `3 / 5 min / 1 hour` is a reasonable starting point for a +system with human users. + +```bash +sudo systemctl enable fail2ban +sudo systemctl restart fail2ban +sudo fail2ban-client status asterisk +``` + +**You should see:** + +``` +Status for the jail: asterisk +|- Filter +| `- File list: /var/log/asterisk/security +`- Actions + `- Currently banned: 0 +``` + +> **`restart`, not `enable --now`.** If fail2ban is already running — and on Ubuntu it +> often is — `--now` does nothing, your new jail is never read, and +> `fail2ban-client status asterisk` answers **"Sorry but the jail 'asterisk' does not +> exist"** while the service sits there reporting `active`. Check with +> `sudo fail2ban-client status`: the jail list must contain `asterisk`, not just `sshd`. +> +> The `File list:` line is the other thing to confirm. If it is missing, fail2ban is not +> reading Asterisk's security log and will never ban anyone, no matter how hard you are +> attacked. + +### Now attack it again + +```bash +for i in 1 2 3 4 5; do + timeout 3 sipp -sn uac 127.0.0.1 -s 9999 -m 1 -r 5 2>/dev/null + sleep 1 +done +sudo fail2ban-client status asterisk +``` + +**You should see** the failure count rise and — because this comes from `127.0.0.1`, which +fail2ban ignores by default — no ban. That is correct behaviour and worth understanding: +loopback is in `ignoreip`, so you cannot ban yourself by accident. + +To see a real ban, attack from your own computer instead. From **your machine**, using a +softphone configured with the right username and a deliberately **wrong password**, try to +register four or five times. Then on the VM: + +```bash +sudo fail2ban-client status asterisk +sudo iptables -L f2b-asterisk -n +``` + +**You should see** your computer's address under `Currently banned`, and a `REJECT` rule +for it in the `f2b-asterisk` chain. Your softphone can no longer reach the PBX at all. + +Unban yourself: + +```bash +sudo fail2ban-client set asterisk unbanip +``` + +> **The lesson:** fail2ban does not protect Asterisk. It reads Asterisk's log and edits +> the *firewall*. If the security log is not enabled, or the path in `logpath` is wrong, +> fail2ban will sit there reporting zero failures forever while you are being attacked. +> Always verify with a real failed login, exactly as you just did. + +--- + +## Step 4 — A firewall that fails closed + +fail2ban blocks who has already misbehaved. A firewall decides who may talk to you at all. + +```bash +sudo tee /etc/iptables/rules.v4 >/dev/null <<'EOF' +*filter +:INPUT DROP [0:0] +:FORWARD DROP [0:0] +:OUTPUT ACCEPT [0:0] + +# Established traffic and loopback always allowed. +-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT +-A INPUT -i lo -j ACCEPT + +# Diagnostics. +-A INPUT -p icmp --icmp-type echo-request -j ACCEPT + +# SSH — restrict the source on a real server. +-A INPUT -p tcp --dport 22 -j ACCEPT + +# SIP and RTP from the local network only. +-A INPUT -s 192.168.0.0/16 -p udp --dport 5060 -j ACCEPT +-A INPUT -s 192.168.0.0/16 -p tcp --dport 5061 -j ACCEPT +-A INPUT -s 192.168.0.0/16 -p udp --dport 10000:20000 -j ACCEPT + +# The PSTN gateway must reach us for inbound calls. +-A INPUT -s 74.50.97.11/32 -p udp --dport 5060 -j ACCEPT + +# ARI / WebRTC, local network only. +-A INPUT -s 192.168.0.0/16 -p tcp --dport 8088 -j ACCEPT +-A INPUT -s 192.168.0.0/16 -p tcp --dport 8089 -j ACCEPT + +COMMIT +EOF +``` + +The important line is the first: **`:INPUT DROP`**. The default is to refuse. Everything +after it is an exception you chose. A firewall whose default is ACCEPT with a list of +blocks is not a firewall — you will always forget something. + +> **Before you apply this, read it again.** `-A INPUT -p tcp --dport 22 -j ACCEPT` is the +> line that keeps your SSH session alive. Removing or mistyping it locks you out of a +> remote server permanently. On a real machine, always have console access before +> touching the firewall. + +Apply and verify: + +```bash +sudo iptables-restore < /etc/iptables/rules.v4 +sudo iptables -L INPUT -n --line-numbers | head -12 +``` + +**You should see** the policy `DROP` and your rules. Your SSH session should still be +alive — if it froze, you made a mistake, and the VM console is how you recover. + +Confirm calls still work: register a phone and dial `600`. + +Make it survive reboot: + +```bash +sudo netfilter-persistent save +sudo systemctl enable netfilter-persistent +``` + +> **Why the gateway needs its own rule:** inbound calls arrive from `74.50.97.11`, not +> from your LAN. Without that line, outbound calls work and inbound ones silently vanish +> — a genuinely confusing half-working state. + +--- + +## Step 5 — Make the service behave under systemd + +You copied a unit file in Lab 1 and never looked inside it. Now read it: + +```bash +systemctl cat asterisk +``` + +Two lines are load-bearing, and one of them cost real debugging time to discover: + +| Line | Why | +|---|---| +| `RuntimeDirectory=asterisk` | Creates `/run/asterisk` owned by the `asterisk` user at every boot. `/run` is tmpfs, so it does not survive. **Without this, Asterisk starts, reports `active (running)`, and the CLI is completely unreachable** — a healthy-looking service you cannot talk to. | +| `AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_SYS_NICE` | Lets a non-root process bind low ports and raise RTP thread priority. It is what makes running as the `asterisk` user possible at all. | + +Add restart limits so a crash loop does not hammer the machine: + +```bash +sudo systemctl edit asterisk +``` + +In the editor: + +```ini +[Service] +Restart=on-failure +RestartSec=5 +StartLimitBurst=5 +StartLimitIntervalSec=300 +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl restart asterisk +systemctl show asterisk -p Restart -p RestartUSec -p StartLimitBurst +``` + +Prove it recovers: + +```bash +sudo pkill -9 asterisk +sleep 8 +systemctl status asterisk --no-pager | head -4 +``` + +**You should see** it `active (running)` again, restarted by systemd. That is the +difference between an outage and a five-second blip at 3am. + +--- + +## Step 6 — Back it up, and prove the backup + +An untested backup is a rumour. + +```bash +sudo tee /usr/local/bin/asterisk-backup >/dev/null <<'EOF' +#!/usr/bin/env bash +# Back up everything needed to rebuild this PBX's behaviour. +set -euo pipefail +DEST="/var/backups/asterisk" +STAMP="$(date +%Y%m%d-%H%M%S)" +mkdir -p "${DEST}" + +tar czf "${DEST}/config-${STAMP}.tar.gz" /etc/asterisk 2>/dev/null + +# Voicemail messages and recordings are user data, not configuration. +tar czf "${DEST}/spool-${STAMP}.tar.gz" \ + /var/spool/asterisk/voicemail /var/spool/asterisk/monitor 2>/dev/null || true + +# CDRs live in the database, so they need a dump of their own. +mariadb-dump --single-transaction asterisk > "${DEST}/cdr-${STAMP}.sql" 2>/dev/null || true + +# Keep 14 days. +find "${DEST}" -type f -mtime +14 -delete + +echo "backup complete: ${DEST}/*-${STAMP}.*" +ls -lh "${DEST}" | tail -4 +EOF +sudo chmod +x /usr/local/bin/asterisk-backup +sudo /usr/local/bin/asterisk-backup +``` + +**You should see** three files listed. + +Note what is backed up and what is not: **configuration, user data, and the database**. +Not the Asterisk binaries — those you rebuild from source, which is why Lab 1 mattered. + +### Now restore it, for real + +The only way to know a backup works: + +```bash +# Break something on purpose. +sudo mv /etc/asterisk/pjsip.conf /tmp/pjsip.saved +sudo systemctl restart asterisk +sleep 5 +sudo asterisk -rx 'pjsip show endpoints' +``` + +**You should see** `No objects found.` — every endpoint gone. + +> **`restart`, not `module reload`.** Deleting the file and reloading proves nothing: +> Asterisk keeps the configuration it already has in memory, so all your endpoints stay +> exactly where they were and the "test" passes while the file is in the bin. Only a +> restart forces a read from disk. +> +> This is the same behaviour you met in Lab 2 Part I, and it cuts both ways — it protects +> you from a bad edit, and it will happily fool you into believing an untested backup +> works. + +Now restore just that file from the archive: + +```bash +LATEST=$(ls -t /var/backups/asterisk/config-*.tar.gz | head -1) +sudo tar xzf "$LATEST" -C / etc/asterisk/pjsip.conf +sudo systemctl restart asterisk +sleep 5 +sudo asterisk -rx 'pjsip show endpoints' +``` + +**You should see** your endpoints return. Now schedule it: + +```bash +echo '30 2 * * * root /usr/local/bin/asterisk-backup >> /var/log/asterisk-backup.log 2>&1' \ + | sudo tee /etc/cron.d/asterisk-backup +``` + +--- + +## Step 7 — Know when it breaks before your users do + +```bash +sudo tee /usr/local/bin/asterisk-health >/dev/null <<'EOF' +#!/usr/bin/env bash +# Five questions worth asking every minute. +fail=0 +say() { printf '%-28s %s\n' "$1" "$2"; } + +systemctl is-active --quiet asterisk \ + && say "service:" "running" || { say "service:" "DOWN"; fail=1; } + +asterisk -rx 'core show version' >/dev/null 2>&1 \ + && say "cli:" "responding" || { say "cli:" "UNREACHABLE"; fail=1; } + +reg=$(asterisk -rx 'pjsip show registrations' 2>/dev/null | grep -c 'Registered') +[ "$reg" -gt 0 ] && say "trunk registrations:" "$reg" \ + || { say "trunk registrations:" "NONE — no inbound or outbound calls"; fail=1; } + +peers=$(asterisk -rx 'pjsip show aors' 2>/dev/null | grep -cE '^ +Contact: [^<]') +say "registered phones:" "$peers" + +calls=$(asterisk -rx 'core show channels' 2>/dev/null | awk '/active call/{print $1}') +say "active calls:" "${calls:-0}" + +df -h /var | awk 'NR==2 {print "disk /var: " $5 " used"}' +exit $fail +EOF +sudo chmod +x /usr/local/bin/asterisk-health +sudo /usr/local/bin/asterisk-health +``` + +**You should see** all five green, and a non-zero exit if anything is wrong — which is +what makes it usable from cron, Nagios, or any monitoring system. + +The **trunk registration** check is the one people leave out and regret. A PBX whose trunk +has silently dropped looks perfectly healthy: the service runs, phones register, internal +calls work. Nobody notices until a customer says "I have been ringing you all morning." + +Finally, keep the logs from filling the disk. You ran `make install-logrotate` in Lab 1; +confirm it took: + +```bash +cat /etc/logrotate.d/asterisk +sudo logrotate -d /etc/logrotate.d/asterisk 2>&1 | head -12 +``` + +**You should see** a dry run showing which files would rotate. A PBX taken down by a full +`/var` after a year of uptime is a classic, and entirely preventable. + +--- + +## ✅ Checkpoint + +1. `/var/log/asterisk/security` records failed authentication attempts with source addresses +2. `fail2ban-client status asterisk` shows the jail active and reading that file +3. A real wrong-password attempt from your own computer gets that address banned, and a `REJECT` rule appears in `f2b-asterisk` +4. `iptables -L INPUT` shows policy **DROP**, calls still work, and rules survive `netfilter-persistent save` +5. `pkill -9 asterisk` is followed by systemd restarting it within seconds +6. A backup can be taken; deleting `pjsip.conf` **and restarting** empties the endpoints, and restoring from the archive brings them back +7. `asterisk-health` reports service, CLI, trunk, phones, calls and disk, and exits non-zero when the trunk is down + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `/var/log/asterisk/security` never appears | `security => security` not added, or logger not reloaded | Add it under `[logfiles]` in logger.conf, then `asterisk -rx 'logger reload'`. | +| fail2ban shows 0 failures during a real attack | Wrong `logpath`, or the file is empty | `sudo fail2ban-client get asterisk logpath`, then `tail -f` that exact file while attacking. | +| fail2ban will not start | Syntax error in the jail | `sudo fail2ban-client -d` prints the parsed config. | +| `the jail 'asterisk' does not exist`, but the service is `active` | fail2ban was already running and never re-read your config | `sudo systemctl restart fail2ban`, then `sudo fail2ban-client status` — the jail list must include `asterisk`. | +| You cannot ban yourself from the VM | `127.0.0.1` is in `ignoreip` | Correct and deliberate. Attack from another machine to test. | +| SSH froze after applying iptables | The SSH ACCEPT rule is missing or wrong | Get to the console the firewall cannot block — the VirtualBox window, or on a cloud server **Droplet → Access → Launch Recovery Console** — then `sudo iptables -P INPUT ACCEPT` to recover. | +| Inbound trunk calls stop after the firewall | No rule for the gateway address | Add `-A INPUT -s 74.50.97.11/32 -p udp --dport 5060 -j ACCEPT`. Confirm the current address with `getent hosts sip.flagonc.com`. | +| Rules vanish after reboot | Not saved | `sudo netfilter-persistent save` and enable the service. | +| systemd will not restart after repeated crashes | Start limit hit | `sudo systemctl reset-failed asterisk`. Then find out why it is crashing. | +| Restore did not bring endpoints back | Extracted to the wrong place | The archive stores paths relative to `/`, so extract with `-C /`. | + +--- + +## What you have, at the end + +A PBX that: + +- runs as an unprivileged user under systemd, and restarts itself when it dies +- logs authentication failures, and bans the sources automatically +- refuses everything by default at the firewall, and permits only what you listed +- is backed up nightly, with a restore you have actually performed +- tells you when its trunk drops, before a customer does + +That is the difference between something that works on your laptop and something you can +put in front of a business. + +--- + +**This is the last lab.** Your machine still runs everything you built — extensions, +trunk, IVR, queues, browser phone, database CDRs, ARI application. Keep it. It is the +fastest way to test an idea before you try it on a system that matters.