New service for one narrow job — getting SMS verification codes sent to a
VoIP number onto a phone with no SIM. Deliberately not a texting app: no
outbound path (Anveo Direct has none; that needs an Anveo Retail account, and
a free texting app covers sending), and messages arrive as push notifications
rather than being routed into Asterisk as SIP MESSAGE, since a code you read
and type is better served by a notification than a softphone chat thread.
Two modes, both driven entirely from the provider's "forward SMS to URL" box:
- direct — the provider calls ntfy itself; nothing installed here. ntfy
accepts GET publishing at /{topic}/(publish|send|trigger) with message and
title as query params, and auth via ?auth= holding base64url (unpadded) of
the literal "Bearer <token>" — confirmed against ntfy's server.go and
server_auth.go rather than its docs.
- relay — a stdlib systemd service, Caddy-fronted on its own domain with no
Authelia (the provider can't log in; a random 32-char token in the path is
the secret). Buys two things direct mode can't have: an unescaped "&" in a
message body survives intact, because the relay takes everything after the
last message= verbatim instead of parse_qs — which is why the generated URL
always puts the message placeholder last — and no ntfy credentials sit in a
third party's web portal.
Verification codes are bearer credentials, so: a 24-char random topic name
(the repo's ntfy defaults to auth-default-access: read-write, making the topic
name the read credential), constant-time token compare, a 60/min rate limit,
and the relay logs sender/recipient/length but never the message body.
The Anveo guide gains a section covering the two things that actually decide
whether codes arrive: short-code support (Anveo has it, unusually — VoIP.ms
does not except for Google) and Anveo's carrier-sourced *mobile* DIDs, which
are classified as mobile in the lookups that reject VoIP numbers at signup.
Also documents MMS and group texts being out of reach, and why the native
Messages app never sees any of this.
Verified against a stub ntfy: plain OTP, encoded "&", unencoded "&", "+" as
space, wrong token (404), missing message (400) and the rate limit (57x204
then 429) all behave; both installer modes were run end to end in a sandbox
and their generated URLs, settings files and READMEs checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
395 lines
18 KiB
Bash
Executable File
395 lines
18 KiB
Bash
Executable File
#!/bin/bash
|
|
# setup.sh — modular post-install dispatcher.
|
|
#
|
|
# One source of truth, multiple ways to run it:
|
|
# sudo ./setup.sh guided install: required packages, then a
|
|
# category menu you loop through
|
|
# sudo ./setup.sh <service> ... install one or more services directly
|
|
# ./setup.sh --list list available services (grouped)
|
|
# ./setup.sh --version print version
|
|
#
|
|
# Flags:
|
|
# --dry-run preview actions without making changes
|
|
# --unattended use defaults, no prompts (pair with explicit service names)
|
|
#
|
|
# Every service lives in services/<name>.sh, registers itself with
|
|
# register_service, and defines install_<name>. Adding a service = adding one
|
|
# file; it appears in the menu automatically. Nothing is generated.
|
|
set -uo pipefail
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
# whiptail requires a valid TERM; when piped through bash (curl | bash) TERM
|
|
# may be unset, causing raw-mode to fail and arrow keys to leak to the shell.
|
|
export TERM="${TERM:-xterm-256color}"
|
|
|
|
# Category display order (groups not listed here are appended alphabetically).
|
|
CATEGORY_ORDER=(base homelab utilities media cameras gaming extras backup)
|
|
# Service ordering hint within a category (lower = earlier). Default 50.
|
|
declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 )
|
|
# Retired service names that now resolve to another service. Keeps a name
|
|
# that used to work on the command line (and in docs/muscle memory) working
|
|
# after a merge, without giving it a second menu entry of its own.
|
|
declare -A SERVICE_ALIAS=( [asterisk-digital-ocean]=asterisk )
|
|
|
|
# ── Parse flags / collect service names ──────────────────────────────────────
|
|
DRY_RUN=false; UNATTENDED=false; DO_LIST=false
|
|
REQUESTED=()
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--dry-run) DRY_RUN=true ;;
|
|
--unattended) UNATTENDED=true ;;
|
|
--list|-l) DO_LIST=true ;;
|
|
--version|-V) cat "$HERE/VERSION" 2>/dev/null || echo "unknown"; exit 0 ;;
|
|
-h|--help) sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
-*) echo "Unknown flag: $arg" >&2; exit 1 ;;
|
|
*) REQUESTED+=("$arg") ;;
|
|
esac
|
|
done
|
|
export DRY_RUN UNATTENDED
|
|
|
|
# ── Load helpers + all service modules (they self-register) ──────────────────
|
|
# shellcheck source=lib/common.sh
|
|
source "$HERE/lib/common.sh"
|
|
shopt -s nullglob
|
|
for _mod in "$HERE"/services/*.sh; do source "$_mod"; done
|
|
shopt -u nullglob
|
|
|
|
# ── Helpers over the registry ────────────────────────────────────────────────
|
|
# Groups present, in CATEGORY_ORDER first, then any extras alphabetically.
|
|
groups_present() {
|
|
local g present=() seen=" "
|
|
for name in "${SERVICE_ORDER[@]}"; do
|
|
g="${SERVICE_GROUP[$name]}"
|
|
case "$seen" in *" $g "*) : ;; *) present+=("$g"); seen="$seen$g " ;; esac
|
|
done
|
|
local out=()
|
|
for g in "${CATEGORY_ORDER[@]}"; do
|
|
printf '%s\n' "${present[@]}" | grep -qx "$g" && out+=("$g")
|
|
done
|
|
for g in "${present[@]}"; do
|
|
printf '%s\n' "${CATEGORY_ORDER[@]}" | grep -qx "$g" || out+=("$g")
|
|
done
|
|
printf '%s\n' "${out[@]}"
|
|
}
|
|
|
|
# Services in a group, ordered by SERVICE_PRIORITY then name.
|
|
services_in_group() {
|
|
local group="$1" name
|
|
for name in "${SERVICE_ORDER[@]}"; do
|
|
[ "${SERVICE_GROUP[$name]}" = "$group" ] && echo "${SERVICE_PRIORITY[$name]:-50} $name"
|
|
done | sort -n -k1 | awk '{print $2}'
|
|
}
|
|
|
|
# Best-effort "is it already installed?" for the [installed] marker.
|
|
is_installed() {
|
|
case "$1" in
|
|
base) command -v ncdu >/dev/null 2>&1 ;;
|
|
glow) command -v glow >/dev/null 2>&1 ;;
|
|
crowdsec) command -v cscli >/dev/null 2>&1 ;;
|
|
security-dashboard) [ -f /opt/security-dashboard/app.py ] ;;
|
|
kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;;
|
|
silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;;
|
|
sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;;
|
|
sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;;
|
|
sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;;
|
|
# Either directory counts: boxes set up before the droplet edition was
|
|
# merged back into `asterisk` still run out of ~/docker/asterisk-digital-ocean.
|
|
asterisk) [ -e "$DOCKER_DIR/asterisk" ] || [ -e "$DOCKER_DIR/asterisk-digital-ocean" ] ;;
|
|
pstn-trunk) [ -f "$DOCKER_DIR/asterisk-digital-ocean/config/asterisk/pstn-trunk-pjsip.conf" ] || [ -f "$DOCKER_DIR/asterisk/config/asterisk/pstn-trunk-pjsip.conf" ] ;;
|
|
sms-inbound) [ -f /opt/sms-inbound/settings.env ] ;;
|
|
ssh-config) false ;; # repeatable management tool, never shows [installed]
|
|
*) [ -e "$DOCKER_DIR/$1" ] ;;
|
|
esac
|
|
}
|
|
|
|
run_service() {
|
|
local name="$1"
|
|
if [ -n "${SERVICE_ALIAS[$name]:-}" ]; then
|
|
log_info "'$name' is now part of '${SERVICE_ALIAS[$name]}' — running that instead."
|
|
name="${SERVICE_ALIAS[$name]}"
|
|
fi
|
|
if [ -z "${SERVICE_GROUP[$name]:-}" ]; then log_error "Unknown service: $name (try --list)"; return 1; fi
|
|
declare -F "install_${name}" >/dev/null || { log_error "Service '$name' has no install_${name}"; return 1; }
|
|
log_info "=== ${name} (${SERVICE_DESC[$name]}) ==="
|
|
"install_${name}"
|
|
}
|
|
|
|
list_services() {
|
|
local g name
|
|
while IFS= read -r g; do
|
|
echo ""; echo "── ${g^^} ──"
|
|
while IFS= read -r name; do
|
|
printf " %-16s %s\n" "$name" "${SERVICE_DESC[$name]}"
|
|
done < <(services_in_group "$g")
|
|
done < <(groups_present)
|
|
echo ""
|
|
}
|
|
|
|
# ── Site defaults wizard ──────────────────────────────────────────────────────
|
|
# Prompts for timezone, base domain, and Caddy network name; saves to .config.
|
|
# Run directly: sudo ./setup.sh configure
|
|
# Asks only "where does Caddy run?" and saves it. Always runs unconditionally
|
|
# (not gated behind a y/n) since every service's Caddy prompt depends on this.
|
|
ask_caddy_location() {
|
|
echo ""
|
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
echo "║ Where does Caddy run? ║"
|
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
# Resolve current Caddy mode for display — handle legacy CADDY_REMOTE_HOST
|
|
local _cur_mode="${CADDY_MODE:-}"
|
|
[ -z "$_cur_mode" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _cur_mode="remote"
|
|
[ -z "$_cur_mode" ] && _cur_mode="local"
|
|
|
|
echo " [1] This machine — Caddy installed here (default)"
|
|
echo " [2] Remote machine — different server, VPN node, or Netbird peer"
|
|
echo " (service installers save snippet files to ~/docker/caddy-snippets/)"
|
|
echo " [3] None / skip — configure Caddy later"
|
|
echo ""
|
|
local _caddy_default="1"
|
|
case "$_cur_mode" in remote) _caddy_default="2" ;; none) _caddy_default="3" ;; esac
|
|
local _caddy_choice=""
|
|
prompt_text " Caddy location [${_caddy_default}]:" "$_caddy_default" _caddy_choice
|
|
case "${_caddy_choice:-$_caddy_default}" in
|
|
2) CADDY_MODE="remote" ;;
|
|
3) CADDY_MODE="none" ;;
|
|
*) CADDY_MODE="local" ;;
|
|
esac
|
|
CADDY_REMOTE_HOST="" # clear legacy value; CADDY_MODE is authoritative now
|
|
echo ""
|
|
|
|
export CADDY_MODE CADDY_REMOTE_HOST
|
|
mkdir -p "$DOCKER_DIR"
|
|
save_site_config
|
|
log_success "Caddy mode saved: $CADDY_MODE"
|
|
}
|
|
|
|
# ── Site defaults wizard ──────────────────────────────────────────────────────
|
|
# Prompts for timezone, base domain, and Caddy network name; saves to .config.
|
|
# Run directly: sudo ./setup.sh configure
|
|
run_site_configure() {
|
|
local _sys_tz; _sys_tz=$(cat /etc/timezone 2>/dev/null || echo "UTC")
|
|
echo ""
|
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
echo "║ Site defaults · pre-filled into every service prompt ║"
|
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
echo " These become the default answer each time a service asks for"
|
|
echo " timezone, domain, etc. Press Enter to keep the shown value."
|
|
echo ""
|
|
local _cur_tz="${SITE_TZ:-$_sys_tz}"
|
|
local _cur_dom="${SITE_DOMAIN:-}"
|
|
local _cur_net="${SITE_CADDY_NET:-caddy_net}"
|
|
|
|
prompt_text " Timezone [${_cur_tz}]:" "$_cur_tz" SITE_TZ
|
|
prompt_text " Base domain (e.g., example.com) [${_cur_dom:-<not set>}]:" "$_cur_dom" SITE_DOMAIN
|
|
|
|
# Caddy Docker network only matters when Caddy runs locally — it's the
|
|
# shared bridge network services join to reach a local Caddy container
|
|
# by name. Remote/none Caddy proxies via localhost:PORT instead.
|
|
if [ "$CADDY_MODE" = "local" ]; then
|
|
prompt_text " Caddy Docker network [${_cur_net}]:" "$_cur_net" SITE_CADDY_NET
|
|
fi
|
|
|
|
export SITE_TZ SITE_DOMAIN SITE_CADDY_NET CADDY_MODE CADDY_REMOTE_HOST
|
|
mkdir -p "$DOCKER_DIR"
|
|
save_site_config
|
|
log_success "Saved to $DOCKER_DIR/.config"
|
|
echo ""
|
|
}
|
|
|
|
# ── --list ───────────────────────────────────────────────────────────────────
|
|
if [ "$DO_LIST" = true ]; then list_services; exit 0; fi
|
|
|
|
# ── configure: show/update site-wide defaults ────────────────────────────────
|
|
if [ "${REQUESTED[*]:-}" = "configure" ]; then
|
|
require_root
|
|
run_site_configure
|
|
exit 0
|
|
fi
|
|
|
|
# ── Direct install: ./setup.sh caddy homeassistant ──────────────────────────
|
|
if [ "${#REQUESTED[@]}" -gt 0 ]; then
|
|
require_root
|
|
rc=0; for name in "${REQUESTED[@]}"; do run_service "$name" || rc=1; done
|
|
exit "$rc"
|
|
fi
|
|
|
|
# ── Guided interactive flow ──────────────────────────────────────────────────
|
|
require_root
|
|
|
|
_VER="$(cat "$HERE/VERSION" 2>/dev/null || echo '?')"
|
|
_OS_LINE="${OS_DISTRO^} ${OS_VERSION} (${OS_CODENAME})"
|
|
|
|
if is_installed base; then
|
|
# ── Re-run: base already present — skip required step ────────────────────
|
|
echo ""
|
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
echo "║ Ubuntu Post-Install · v${_VER} · ${_OS_LINE}"
|
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
echo " Base packages already installed — skipping required setup."
|
|
echo " Use 'sudo ./setup.sh base' to force a reinstall."
|
|
echo ""
|
|
else
|
|
# ── First run: show required banner, confirm, install ────────────────────
|
|
echo ""
|
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
echo "║ Ubuntu Post-Install · v${_VER} · ${_OS_LINE}"
|
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
if [ "$OS_DISTRO" != "ubuntu" ]; then
|
|
log_warning "Detected OS: ${_OS_LINE} — this script targets Ubuntu. Proceed with caution."
|
|
echo ""
|
|
elif ! ubuntu_version_ge "24.04"; then
|
|
log_warning "Ubuntu ${OS_VERSION} detected — tested on 24.04+. Some packages may differ."
|
|
echo ""
|
|
fi
|
|
echo "REQUIRED (installed/verified first):"
|
|
echo " • Essential CLI packages: net-tools, git, curl, wget, htop, tree,"
|
|
echo " ncdu, zip/unzip, jq, rsync, and glow (markdown reader)"
|
|
echo " • Docker presence check (needed by all containerized services)"
|
|
echo ""
|
|
echo "Then you'll get a category menu to pick optional services."
|
|
echo ""
|
|
PROCEED=""
|
|
prompt_yn "Proceed with the required setup? (y/n):" "y" PROCEED
|
|
if [ "$PROCEED" != "y" ] && [ "$PROCEED" != "Y" ]; then
|
|
echo "Cancelled. Nothing was changed."
|
|
exit 0
|
|
fi
|
|
|
|
run_service base
|
|
fi
|
|
|
|
# Always ensure Docker is present — base may have been installed before Docker
|
|
# was added to it, or a previous install may have failed.
|
|
if ! command -v docker &>/dev/null && ! [ -x /usr/bin/docker ]; then
|
|
log_info "Docker not found — installing now..."
|
|
require_docker
|
|
fi
|
|
|
|
# 3) Ask where Caddy runs (unconditional — every service's Caddy prompt
|
|
# depends on this), then only offer the timezone/domain/network wizard
|
|
# if Caddy is local to this box.
|
|
if ! grep -q '^CADDY_MODE=' "$DOCKER_DIR/.config" 2>/dev/null; then
|
|
ask_caddy_location
|
|
load_site_config # reload so subsequent prompts see CADDY_MODE
|
|
|
|
if [ "$CADDY_MODE" = "local" ]; then
|
|
echo " Setting timezone/domain now pre-fills them for every service —"
|
|
echo " you type them once, not every time."
|
|
OFFER_CONFIG=""
|
|
prompt_yn "Configure site defaults (timezone, domain, network) now? (y/n):" "y" OFFER_CONFIG
|
|
if [ "$OFFER_CONFIG" = "y" ] || [ "$OFFER_CONFIG" = "Y" ]; then
|
|
run_site_configure
|
|
load_site_config # reload so subsequent service prompts see the new values
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# 4) Offer Caddy first (most services proxy through it) — only when Caddy is
|
|
# (or will be) local to this box. Remote/none mode means Caddy lives
|
|
# elsewhere, so installing it here would be wrong.
|
|
if [ -n "${SERVICE_GROUP[caddy]:-}" ] && ! is_installed caddy \
|
|
&& [ "${CADDY_MODE:-local}" = "local" ]; then
|
|
echo ""
|
|
OFFER_CADDY=""
|
|
prompt_yn "Install Caddy now? It's the reverse proxy most services use. (y/n):" "y" OFFER_CADDY
|
|
[ "$OFFER_CADDY" = "y" ] || [ "$OFFER_CADDY" = "Y" ] && run_service caddy
|
|
fi
|
|
|
|
# 5) Installed-service summary — show status before every menu session.
|
|
echo ""
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo " INSTALLED SERVICES"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
_any_installed=false
|
|
while IFS= read -r _g; do
|
|
_group_header_printed=false
|
|
while IFS= read -r _svc; do
|
|
if is_installed "$_svc"; then
|
|
if [ "$_group_header_printed" = false ]; then
|
|
printf "\n %-12s\n" "${_g^^}"
|
|
_group_header_printed=true
|
|
fi
|
|
printf " ✓ %-20s %s\n" "$_svc" "${SERVICE_DESC[$_svc]}"
|
|
_any_installed=true
|
|
fi
|
|
done < <(services_in_group "$_g")
|
|
done < <(groups_present)
|
|
[ "$_any_installed" = false ] && echo " (none yet)"
|
|
echo ""
|
|
|
|
# 6) Category menu loop: pick a category → checklist → install → back to menu.
|
|
have_whiptail=false
|
|
command -v whiptail >/dev/null 2>&1 && have_whiptail=true
|
|
# Ensure the terminal is in a clean state before handing control to whiptail.
|
|
stty sane </dev/tty 2>/dev/null || true
|
|
|
|
while true; do
|
|
mapfile -t CATS < <(groups_present)
|
|
|
|
if [ "$have_whiptail" = true ]; then
|
|
cat_items=()
|
|
for g in "${CATS[@]}"; do
|
|
n=$(services_in_group "$g" | wc -l)
|
|
cat_items+=("$g" "$n service(s)")
|
|
done
|
|
cat_items+=("DONE" "Finish and exit")
|
|
CHOSEN_CAT=$(whiptail --title "Service Categories" --menu \
|
|
"Pick a category (services you install come back here):" 22 70 14 \
|
|
"${cat_items[@]}" 3>&1 1>&2 2>&3 </dev/tty) || break
|
|
else
|
|
echo ""; echo "Categories:"; i=1
|
|
for g in "${CATS[@]}"; do echo " $i) $g"; i=$((i+1)); done
|
|
echo " d) Done"
|
|
read -rp "Pick a category [d]: " pick
|
|
[ "$pick" = "d" ] || [ -z "$pick" ] && break
|
|
CHOSEN_CAT="${CATS[$((pick-1))]:-}"
|
|
[ -z "$CHOSEN_CAT" ] && { echo "Invalid."; continue; }
|
|
fi
|
|
[ "$CHOSEN_CAT" = "DONE" ] && break
|
|
|
|
mapfile -t SVCS < <(services_in_group "$CHOSEN_CAT")
|
|
SELECTED=()
|
|
if [ "$have_whiptail" = true ]; then
|
|
svc_items=()
|
|
for name in "${SVCS[@]}"; do
|
|
tag="${SERVICE_DESC[$name]}"
|
|
is_installed "$name" && tag="$tag [installed]"
|
|
svc_items+=("$name" "$tag" "OFF")
|
|
done
|
|
CHOICE=$(whiptail --title "${CHOSEN_CAT^^}" --checklist \
|
|
"Space to select, Enter to install. Already-installed are marked:" 22 78 14 \
|
|
"${svc_items[@]}" 3>&1 1>&2 2>&3 </dev/tty) || continue
|
|
eval "SELECTED=($CHOICE)"
|
|
else
|
|
echo ""; echo "${CHOSEN_CAT^^}:"
|
|
for name in "${SVCS[@]}"; do
|
|
m=""; is_installed "$name" && m=" [installed]"
|
|
printf " %-16s %s%s\n" "$name" "${SERVICE_DESC[$name]}" "$m"
|
|
done
|
|
read -rp "Enter service names to install (space-separated, blank to go back): " -a SELECTED
|
|
fi
|
|
|
|
for name in "${SELECTED[@]}"; do run_service "$name"; done
|
|
done
|
|
|
|
echo ""
|
|
log_success "Done. Re-run 'sudo ./setup.sh' any time to add more."
|
|
|
|
# If Docker was installed this session (or already was), the invoking user
|
|
# was added to the docker group — but group membership only takes effect in
|
|
# a new login shell, not the one that ran sudo. Drop into a fresh login
|
|
# shell as that user so 'docker' works immediately without reconnecting SSH.
|
|
if [ -n "${SUDO_USER:-}" ] && [ "$UNATTENDED" != true ] \
|
|
&& getent group docker >/dev/null 2>&1 \
|
|
&& id -nG "$SUDO_USER" 2>/dev/null | grep -qw docker \
|
|
&& [ -t 0 ]; then
|
|
echo ""
|
|
log_info "Refreshing shell as $SUDO_USER so the docker group takes effect..."
|
|
exec su - "$SUDO_USER"
|
|
fi
|