diff --git a/extras/dr_bringup_kopia.sh b/extras/dr_bringup_kopia.sh index 48ca048..45f781b 100644 --- a/extras/dr_bringup_kopia.sh +++ b/extras/dr_bringup_kopia.sh @@ -15,13 +15,29 @@ # sudo ./dr_bringup.sh --dry-run show what would happen, touch nothing # sudo ./dr_bringup.sh --no-start restore only, skip docker compose up -d # -# Reads backup.conf from the same directory. On the spare box this file -# won't exist yet on its own — copy it over from the primary box first (it -# holds the repository paths/passwords needed to connect): +# Reads backup.conf from the same directory (or wherever $BACKUP_CONF +# points — see below). On the spare box this file won't exist yet on its +# own — copy it over from the primary box first (it holds the repository +# paths/passwords needed to connect): # scp primary:~/docker/backup/backup.conf ~/docker/backup/backup.conf # If the destination repo is a local path shared with the primary (e.g. the # spare box IS the box the primary's REMOTE_TYPE=sftp mirror targets), -# nothing else is needed — the repo data is already there. +# nothing else is needed — the repo data is already there. Otherwise this +# also tries REMOTE_TYPE/REMOTE_ARGS (the offsite Backblaze/S3 mirror, if +# configured) as a restore source — the only one reachable from a genuinely +# new box (e.g. migrating to different hardware/a different provider). +# +# Migrating to a brand-new box that will keep running this repo's own +# backup.sh going forward: don't just scp the old box's backup.conf over +# the new box's own (that would replace its freshly-configured local repo +# with the old box's, which doesn't exist on this box). Instead: +# sudo ./setup.sh backup # sets up this box's own backups, +# # and deploys this script +# scp old-box:~/docker/backup/backup.conf ~/docker/backup/backup-source.conf +# sudo BACKUP_CONF=~/docker/backup/backup-source.conf \ +# ~/docker/backup/dr_bringup.sh --list # preview first +# sudo BACKUP_CONF=~/docker/backup/backup-source.conf \ +# ~/docker/backup/dr_bringup.sh # then actually restore set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -87,6 +103,34 @@ k_for() { read -ra _DEST_ARR <<< "$DEST_NAMES" declare -a SRC_PATH_LIST=() SRC_DEST_LIST=() SRC_SNAP_LIST=() +# DEST_NAMES entries are always LOCAL filesystem repos (services/backup.sh +# creates them with `repository create filesystem --path=...`) — meaning +# they only exist on whichever box originally ran the backup. On a genuinely +# new box (migrating to different hardware/provider, not restoring onto the +# same DR-spare the offsite mirror already targets), none of them will +# connect, and without this block there would be nothing left to restore +# from at all. REMOTE_TYPE/REMOTE_ARGS (the offsite Backblaze/S3 mirror) is +# reachable from anywhere, so try it too, as a same-shaped destination named +# "offsite" — reusing DEST_default_PASSWORD since sync-to always mirrors +# that exact same encrypted repo, so there's no separate password to ask +# for. Connect once into a fresh local config file scoped to this DR run +# (no pre-existing config for it the way the local DEST_NAMES entries have). +if [ -n "${REMOTE_TYPE:-}" ] && [ "$REMOTE_TYPE" != "none" ] && [ -n "${DEST_default_PASSWORD:-}" ]; then + OFFSITE_CFG="/etc/kopia-backup/dr-offsite.config" + mkdir -p "$(dirname "$OFFSITE_CFG")" /var/cache/kopia-backup + # shellcheck disable=SC2086 + if env KOPIA_PASSWORD="$DEST_default_PASSWORD" "$KOPIA" --config-file="$OFFSITE_CFG" repository status >/dev/null 2>&1 \ + || env KOPIA_PASSWORD="$DEST_default_PASSWORD" "$KOPIA" --config-file="$OFFSITE_CFG" \ + --cache-directory=/var/cache/kopia-backup repository connect $REMOTE_TYPE $REMOTE_ARGS >/dev/null 2>&1; then + DEST_offsite_CONFIG="$OFFSITE_CFG" + DEST_offsite_PASSWORD="$DEST_default_PASSWORD" + _DEST_ARR+=("offsite") + info "Connected to the offsite mirror ($REMOTE_TYPE) as an additional restore source ('offsite')." + else + warn "Could not connect to the offsite mirror ($REMOTE_TYPE) as a restore source — check REMOTE_TYPE/REMOTE_ARGS in $CONF." + fi +fi + for dest in "${_DEST_ARR[@]}"; do if ! k_for "$dest" repository status >/dev/null 2>&1; then warn "Cannot connect to destination '$dest' — skipping." diff --git a/services/asterisk.sh b/services/asterisk.sh index 701f83f..29cf2ba 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -582,15 +582,89 @@ case "$cmd" in mv "$HERE" "$ASIDE" echo "Extracting $ARCHIVE -> $PARENT_DIR ..." - if tar -xzf "$ARCHIVE" -C "$PARENT_DIR"; then + # Extract into a scratch staging dir first rather than straight into + # $PARENT_DIR — an archive's own top-level directory name reflects + # whichever layout produced it (see _asterisk_resolve_layout: plain + # "asterisk"/"easy-asterisk" vs droplet "asterisk-digital-ocean"/ + # "easy-asterisk-do"), which can differ from THIS box's layout (e.g. + # restoring a droplet backup onto a fresh non-droplet IONOS install). + # Landing it under the archive's own name instead of $HERE would + # leave two Asterisk directories on disk and confuse every service + # that resolves the layout by directory/container name (security- + # dashboard, pstn-trunk, CrowdSec's Asterisk acquisition, Caddy). + STAGING="$(mktemp -d)" + if tar -xzf "$ARCHIVE" -C "$STAGING"; then + EXTRACTED_DIR="$(find "$STAGING" -mindepth 1 -maxdepth 1 -type d | head -1)" + if [ -z "$EXTRACTED_DIR" ]; then + echo "Archive didn't contain a top-level directory — rolling back." + rm -rf "$STAGING" + mv "$ASIDE" "$HERE" + exit 1 + fi + + # If the archive came from the other layout, its docker- + # compose.yml still names the OLD project/container(s) — fix + # those to match THIS box's own layout before it lands at $HERE. + # $CONTAINER is this run's own correct value (baked in at + # generation time); everything else derives from it the same + # way _asterisk_resolve_layout's two known layouts do. + ARCHIVED_COMPOSE="$EXTRACTED_DIR/docker-compose.yml" + if [ -f "$ARCHIVED_COMPOSE" ]; then + ARCHIVED_CONTAINER="$(grep -m1 'container_name:' "$ARCHIVED_COMPOSE" | awk '{print $2}')" + if [ -n "$ARCHIVED_CONTAINER" ] && [ "$ARCHIVED_CONTAINER" != "$CONTAINER" ]; then + echo "Archive is from a different Asterisk layout ($ARCHIVED_CONTAINER) than" + echo "this box ($CONTAINER) — updating docker-compose.yml to match this box." + ARCHIVED_COTURN="${ARCHIVED_CONTAINER}-coturn" + NEW_COTURN="${CONTAINER}-coturn" + sed -i "s/name: ${ARCHIVED_CONTAINER#easy-}\$/name: ${CONTAINER#easy-}/; \ + s/container_name: ${ARCHIVED_CONTAINER}\$/container_name: ${CONTAINER}/; \ + s/container_name: ${ARCHIVED_COTURN}\$/container_name: ${NEW_COTURN}/" \ + "$ARCHIVED_COMPOSE" + fi + fi + + rm -rf "${HERE:?}" + mv "$EXTRACTED_DIR" "$HERE" + rm -rf "$STAGING" echo "Extracted." else echo "Extraction failed — rolling back to the pre-restore install." - rm -rf "${PARENT_DIR:?}/$BASE_NAME" + rm -rf "$STAGING" mv "$ASIDE" "$HERE" exit 1 fi + # pjsip.conf bakes external_media_address/external_signaling_address + # in as literal IPs (easy-asterisk writes them at first container + # start, not this repo) — restoring an archive from a DIFFERENT box + # verbatim leaves the OLD box's IP in place, breaking RTP media (and + # likely SIP signaling) on THIS box even though the dialplan itself + # comes back fine. Detect the mismatch and patch it automatically — + # this is the whole reason "restore" onto a new host exists, so a + # stale IP left behind would defeat the point every single time. + PJSIP_CONF="$HERE/config/asterisk/pjsip.conf" + if [ -f "$PJSIP_CONF" ]; then + OLD_EXT_IP="$(grep -m1 '^external_signaling_address=' "$PJSIP_CONF" | cut -d= -f2)" + NEW_EXT_IP="$(curl -fsS --max-time 2 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null || true)" + [ -z "$NEW_EXT_IP" ] && NEW_EXT_IP="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)" + [ -z "$NEW_EXT_IP" ] && NEW_EXT_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + + if [ -n "$OLD_EXT_IP" ] && [ -n "$NEW_EXT_IP" ] && [ "$OLD_EXT_IP" != "$NEW_EXT_IP" ]; then + echo "This archive's SIP config was for a different box's public IP" + echo "($OLD_EXT_IP) — this box's is $NEW_EXT_IP. Updating" + echo "external_media_address/external_signaling_address so RTP/SIP work here." + # Fixed-string match/replace, restricted to config + .env — + # never spool/logs/lib, which hold voicemail/recording audio + # that a text substitution would corrupt. + ESC_OLD_IP="${OLD_EXT_IP//./\\.}" + grep -rlF "$OLD_EXT_IP" "$HERE/config" "$HERE/.env" 2>/dev/null | while read -r _f; do + sed -i "s/$ESC_OLD_IP/$NEW_EXT_IP/g" "$_f" + done + echo "Updated. If this PBX uses VLANs/extra subnets, re-check them:" + echo " docker exec -it $CONTAINER easy-asterisk # Server Settings -> Configure VLAN/VPN Subnets" + fi + fi + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HERE" if [ "$WAS_RUNNING" = true ]; then @@ -989,6 +1063,41 @@ operator=no EOF } +# The easy-asterisk image ships app_voicemail.so, app_voicemail_imap.so, and +# app_voicemail_odbc.so all autoloading by default — three alternative +# storage backends for the SAME application (VoiceMail, VoiceMailMain, +# VM_INFO, etc.), which collide registering those names against each other +# on every single Asterisk start. Confirmed live: this box's own log on +# every restart shows "Already have an application 'VoiceMail'" (and every +# sibling app/function) followed by "app_voicemail.c:15897 load_module: +# Failure registering applications, functions or tests" — app_voicemail +# never actually finishes loading, on every install using this script, not +# something specific to one box or one extension. This repo only ever uses +# the plain file-based backend (voicemail.conf's [default] mailboxes, +# written above), so noload the other two — modules.conf lands in the same +# already-bind-mounted config/asterisk directory, no new volume needed. +# Regenerated on every install/update (unlike voicemail.conf) since it +# carries no per-install state of its own. +_asterisk_write_modules_conf() { + local FILE="$1" + cat > "$FILE" << 'EOF' +; services/asterisk.sh — regenerated on every install/update; edit there, +; not here directly. +; +; app_voicemail_imap/_odbc are alternative voicemail storage backends this +; repo never configures (no IMAP/ODBC settings are ever written) — loading +; them alongside the plain file-based app_voicemail.so makes all three +; collide registering the same application/function names, and +; app_voicemail.so is the one that ends up losing that race and failing to +; load at all. noload the two unused backends so the one actually +; configured (file-based, via voicemail.conf) loads cleanly. +[modules] +autoload=yes +noload => app_voicemail_imap.so +noload => app_voicemail_odbc.so +EOF +} + # Same anchor-position reasoning as _asterisk_patch_messaging_vendor_files: # Same anchor as messaging's own patch (see the comment above # _asterisk_patch_messaging_vendor_files for the live-confirmed reasoning): @@ -1705,6 +1814,30 @@ To migrate to a new host: run \`backup\` on the old one, copy the archive over, then run \`restore\` after a fresh \`sudo ./setup.sh asterisk\` install (or directly into an empty \`${EA_DIR}\`) on the new host. +\`restore\` also detects and fixes the one thing that doesn't travel between +boxes on its own: \`pjsip.conf\`'s \`external_media_address\`/ +\`external_signaling_address\` are literal IPs, baked in by easy-asterisk at +first container start — restoring an archive from a different box verbatim +would otherwise leave the OLD box's IP in place, breaking RTP media (and +likely SIP registration) even though the dialplan itself comes back fine. +\`restore\` compares the archive's IP against this host's own (same +DO-metadata → ifconfig.me → \`hostname -I\` detection this script's own +install uses) and, if they differ, rewrites every occurrence across +\`config/\` and \`.env\` — never \`spool/\`/\`logs/\`/\`lib/\`, which hold voicemail +and recording audio a text substitution would corrupt. A same-host restore +(rolling back a config mistake, no IP change) leaves everything untouched. + +\`restore\` also handles moving between the two layouts this repo supports +(see \`_asterisk_resolve_layout\` — plain \`asterisk\`/\`easy-asterisk\` vs. +DigitalOcean-droplet \`asterisk-digital-ocean\`/\`easy-asterisk-do\`). An +archive's own directory name and \`docker-compose.yml\` reflect whichever +layout produced it; restoring a droplet backup onto a fresh non-droplet +install (or vice versa) rewrites \`docker-compose.yml\`'s project/container/ +coturn-container names to match THIS box's layout and lands the data at +this box's own directory — never leaving a second, wrongly-named directory +behind that would confuse every service that resolves Asterisk's layout +(Security Dashboard, PSTN trunk, CrowdSec's Asterisk acquisition, Caddy). + ## VLANs / other subnets \`.env\` → \`HAS_VLANS\`/\`VLAN_SUBNETS\` lists extra networks (space-separated @@ -1923,9 +2056,10 @@ install_asterisk() { _asterisk_patch_voicemail_vendor_files "$EA_DIR" _asterisk_write_voicemail_dialplan "$EA_DIR/config/asterisk/voicemail-dialplan.conf" _asterisk_write_voicemail_conf "$EA_DIR/config/asterisk/voicemail.conf" + _asterisk_write_modules_conf "$EA_DIR/config/asterisk/modules.conf" _asterisk_ensure_live_voicemail_include "$EA_DIR" "$CONTAINER" ensure_docker_dir_ownership "$EA_DIR/config/asterisk" - chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf" + chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf" "$EA_DIR/config/asterisk/modules.conf" log_info "Rebuilding and restarting containers..." if docker compose up -d --build --force-recreate; then @@ -2018,9 +2152,10 @@ install_asterisk() { _asterisk_patch_voicemail_vendor_files "$EA_DIR" _asterisk_write_voicemail_dialplan "$EA_DIR/config/asterisk/voicemail-dialplan.conf" _asterisk_write_voicemail_conf "$EA_DIR/config/asterisk/voicemail.conf" + _asterisk_write_modules_conf "$EA_DIR/config/asterisk/modules.conf" _asterisk_ensure_live_voicemail_include "$EA_DIR" "$CONTAINER" ensure_docker_dir_ownership "$EA_DIR/config/asterisk" - chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf" + chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf" "$EA_DIR/config/asterisk/modules.conf" # ── Domain / networking mode ────────────────────────────────────────────── # A public cloud box is always reachable from anywhere, so there's no