Harden installation and runtime diagnostics

This commit is contained in:
v3d 2026-06-29 21:43:47 +02:00
parent 34b4c4b036
commit ed0b168ab6
14 changed files with 748 additions and 213 deletions

View file

@ -32,6 +32,16 @@ sudo ./install.sh
sudo reboot sudo reboot
``` ```
The installer performs preflight checks, backs up managed configuration under
`/var/backups/pivilion`, validates the deployed services, and restores the
previous configuration if deployment fails. Additional maintenance modes are:
```sh
./install.sh --dry-run # read-only preflight and proposed actions
sudo ./install.sh --check # validate an existing installation
sudo ./install.sh --repair # repeat deployment without replacing gallery content
```
The installer defaults to the user that invoked `sudo`, not to a hard-coded The installer defaults to the user that invoked `sudo`, not to a hard-coded
`pi` account. To customize paths, startup mode, hotspot SSID, or Wi-Fi `pi` account. To customize paths, startup mode, hotspot SSID, or Wi-Fi
interface, copy `pivilion.conf.example` to `/boot/firmware/pivilion.conf` (or interface, copy `pivilion.conf.example` to `/boot/firmware/pivilion.conf` (or
@ -44,5 +54,14 @@ through Tor. Use `hotspot` and reboot to run the local captive gallery at
local captive portal cannot present valid certificates for arbitrary sites. local captive portal cannot present valid certificates for arbitrary sites.
If a phone joins but does not open its captive-login window, run If a phone joins but does not open its captive-login window, run
`pivilion-diagnose` on the Pi to verify DHCP/DNS, Apache rewrite handling, `pivilion diagnose` on the Pi to verify DHCP/DNS, Apache rewrite handling,
nftables, and the common Android, Apple, and Microsoft HTTP probes. nftables, and the common Android, Apple, and Microsoft HTTP probes.
`pivilion status` distinguishes the requested next-boot mode from the last
verified active mode and reports degraded activation. `pivilion diagnose
--json` provides the same health checks for monitoring. The legacy
`pivilion-diagnose` command remains an alias.
Diagnostic exit codes are stable: `10` invalid configuration, `11` missing or
unsupported hardware, `12` service/port conflict, `13` core service failure,
`14` captive portal failure, `15` Tor/onion failure, and `20` installer failure.

View file

@ -0,0 +1,13 @@
[Unit]
Description=Display the Pivilion boot greeting and system status
After=local-fs.target
Before=getty@tty1.service
[Service]
Type=oneshot
ExecStart=/usr/local/lib/pivilion/boot-greeter.sh
StandardOutput=journal+console
StandardError=journal+console
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,11 @@
[Unit]
Description=Pivilion legacy Wi-Fi access point
After=network-pre.target
[Service]
Type=simple
ExecStart=/usr/sbin/hostapd /run/pivilion/hostapd.conf
Restart=on-failure
[Install]
WantedBy=multi-user.target

View file

@ -7,7 +7,7 @@ Wants=network-pre.target
Type=oneshot Type=oneshot
ExecStart=/usr/local/lib/pivilion/mode.sh ExecStart=/usr/local/lib/pivilion/mode.sh
RemainAfterExit=yes RemainAfterExit=yes
TimeoutStartSec=90 TimeoutStartSec=120
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -1,53 +1,183 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -Eeuo pipefail
INSTALL_VERSION=2
ACTION=install
case "${1:-}" in
'') ;;
--check) ACTION=check ;;
--dry-run) ACTION=dry-run ;;
--repair) ACTION=repair ;;
-h|--help) echo "Usage: sudo ./install.sh [--check|--dry-run|--repair]"; exit 0 ;;
*) echo "Usage: sudo ./install.sh [--check|--dry-run|--repair]" >&2; exit 2 ;;
esac
[ "$#" -le 1 ] || { echo "Only one installer action may be specified." >&2; exit 2; }
SOURCE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) SOURCE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
source "$SOURCE_DIR/usr/local/lib/pivilion/common.sh" source "$SOURCE_DIR/usr/local/lib/pivilion/common.sh"
pivilion_load_config pivilion_load_config || exit "$PIVILION_E_CONFIG"
pivilion_print_motd "Pivilion installer"
if [ "$(id -u)" -ne 0 ]; then cat <<'PIVILION_LOGO'
echo "Run this installer with sudo: sudo ./install.sh" >&2
exit 1 Welcome to Pivilion!
@@@@@@@@@@@@@@@@@@@
@@@@@@, &@& *@@@@@@
@@@@@@@. .@@@@@@@
@@@@ .@@@. @@@@
@ &@@@@.@@@@# @
@ @ . #@@@# . @ @
@ @@@, # ,@@@ @
@@, @ ,@@
@@@@@#@@@@@@@#@@@@@
PIVILION_LOGO
if [ "$ACTION" != dry-run ] && [ "$(id -u)" -ne 0 ]; then
echo "Run this installer with sudo: sudo ./install.sh ${1:-}" >&2
exit "$PIVILION_E_INSTALL"
fi fi
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
BACKUP_DIR=""
TRANSACTION_ACTIVE=0
declare -a MANAGED_PATHS=(
/etc/pivilion.defaults /etc/pivilion.php /etc/profile.d/pivilion-motd.sh
/etc/apache2/ports.conf /etc/apache2/conf-available/pivilion.conf
/etc/apache2/sites-available/000-default.conf /etc/apache2/sites-available/000-upload.conf
/etc/apache2/conf-enabled/pivilion.conf /etc/apache2/sites-enabled/000-default.conf
/etc/tor/torrc /etc/tor/torrc.d/pivilion.conf
/etc/systemd/system/pivilion-mode.service /etc/systemd/system/pivilion-dnsmasq.service
/etc/systemd/system/pivilion-hostapd.service /etc/systemd/system/pivilion-boot-greeter.service
/usr/local/lib/pivilion /usr/local/bin/pivilion /usr/local/bin/pivilion-diagnose
/usr/local/bin/hotspot /usr/local/bin/onion /usr/local/bin/pikey /usr/local/bin/generator
/usr/local/bin/static /usr/local/bin/hotglue /usr/local/bin/htaccess
/var/lib/pivilion/install-state
/etc/php/7.4/apache2/conf.d/99-pivilion.ini
/etc/php/8.2/apache2/conf.d/99-pivilion.ini
/etc/php/8.4/apache2/conf.d/99-pivilion.ini
)
log() { printf '==> %s\n' "$*"; }
warn() { printf 'WARNING: %s\n' "$*" >&2; }
preflight() {
local codename free_kb iface package
log "Running preflight checks"
[ -r /etc/os-release ] || { echo "Cannot identify the operating system." >&2; return "$PIVILION_E_INSTALL"; }
. /etc/os-release
codename=${VERSION_CODENAME:-unknown}
case "$codename" in bullseye|bookworm|trixie) ;; *) echo "Unsupported OS codename: $codename" >&2; return "$PIVILION_E_INSTALL" ;; esac
getent passwd "$PIVILION_USER" >/dev/null || return "$PIVILION_E_CONFIG"
[ -d "$PIVILION_HOME" ] || { echo "Configured home does not exist: $PIVILION_HOME" >&2; return "$PIVILION_E_CONFIG"; }
free_kb=$(df -Pk / | awk 'NR == 2 {print $4}')
[ "${free_kb:-0}" -ge 524288 ] || { echo "At least 512 MiB free space is required." >&2; return "$PIVILION_E_INSTALL"; }
iface=$(pivilion_wifi_interface)
[ -n "$iface" ] || { echo "No Wi-Fi interface found." >&2; return "$PIVILION_E_HARDWARE"; }
[ -d "/sys/class/net/$iface" ] || { echo "Configured Wi-Fi interface does not exist: $iface" >&2; return "$PIVILION_E_HARDWARE"; }
if command -v iw >/dev/null 2>&1; then
local phy
phy=$(iw dev "$iface" info 2>/dev/null | awk '$1 == "wiphy" {print "phy" $2; exit}')
[ -z "$phy" ] || iw phy "$phy" info 2>/dev/null | grep -Eq '^[[:space:]]*\* AP$' || warn "$iface does not advertise AP mode; hotspot activation will fail safely."
fi
if ss -H -ltn 'sport = :80' 2>/dev/null | grep -q .; then warn "Port 80 is currently occupied; this is expected only when Apache is already installed."; fi
if ss -H -lntu 'sport = :53' 2>/dev/null | grep -Eq '(0\.0\.0\.0|\*):53'; then warn "A wildcard DNS listener may conflict with captive mode."; fi
if command -v apt-cache >/dev/null 2>&1; then
for package in apache2 php tor dnsmasq hostapd nftables network-manager; do
apt-cache show "$package" >/dev/null 2>&1 || warn "Package metadata is unavailable for $package; apt-get update may be required."
done
fi
log "Preflight passed for $codename using interface $iface"
}
is_networkmanager_system() { is_networkmanager_system() {
command -v nmcli >/dev/null 2>&1 && { command -v nmcli >/dev/null 2>&1 && { systemctl is-enabled --quiet NetworkManager 2>/dev/null || systemctl is-active --quiet NetworkManager 2>/dev/null; }
systemctl is-enabled --quiet NetworkManager 2>/dev/null ||
systemctl is-active --quiet NetworkManager 2>/dev/null
}
} }
install_packages() { install_packages() {
local packages=(apache2 libapache2-mod-php php php-cli tor dnsmasq dnsutils nftables iproute2 iw rfkill curl wget unzip wpasupplicant) local packages=(apache2 libapache2-mod-php php php-cli tor dnsmasq dnsutils hostapd nftables iproute2 iw rfkill curl wget unzip wpasupplicant)
log "Installing required packages"
apt-get update apt-get update
apt-get install -y "${packages[@]}" apt-get install -y "${packages[@]}"
if is_networkmanager_system; then if is_networkmanager_system; then
echo "Using the installed NetworkManager backend." log "Using NetworkManager"
elif command -v dhcpcd >/dev/null 2>&1 || dpkg-query -W dhcpcd5 >/dev/null 2>&1; then elif command -v dhcpcd >/dev/null 2>&1 || dpkg-query -W dhcpcd5 >/dev/null 2>&1; then
apt-get install -y hostapd dhcpcd5 apt-get install -y dhcpcd5
echo "Using the installed dhcpcd/hostapd backend." log "Using dhcpcd with dedicated Pivilion hostapd"
else else
apt-get install -y network-manager apt-get install -y network-manager
systemctl enable NetworkManager systemctl enable NetworkManager
echo "No active networking backend was found; installed NetworkManager." log "Installed NetworkManager because no supported backend was active"
fi fi
} }
backup_managed_files() {
local path relative
install -d -m 0700 /var/backups/pivilion
BACKUP_DIR=$(mktemp -d /var/backups/pivilion/install-$(date +%Y%m%d-%H%M%S).XXXXXX)
install -d -m 0700 "$BACKUP_DIR/files"
: > "$BACKUP_DIR/present"; : > "$BACKUP_DIR/missing"; : > "$BACKUP_DIR/enabled-services"; : > "$BACKUP_DIR/active-services"
for service in apache2 pivilion-mode.service pivilion-boot-greeter.service pivilion-dnsmasq.service pivilion-hostapd.service tor.service tor@default.service; do
systemctl is-enabled --quiet "$service" 2>/dev/null && printf '%s\n' "$service" >> "$BACKUP_DIR/enabled-services" || true
systemctl is-active --quiet "$service" 2>/dev/null && printf '%s\n' "$service" >> "$BACKUP_DIR/active-services" || true
done
for path in "${MANAGED_PATHS[@]}"; do
relative=${path#/}
if [ -e "$path" ] || [ -L "$path" ]; then
printf '%s\n' "$path" >> "$BACKUP_DIR/present"
install -d -m 0700 "$BACKUP_DIR/files/$(dirname "$relative")"
cp -a "$path" "$BACKUP_DIR/files/$relative"
else
printf '%s\n' "$path" >> "$BACKUP_DIR/missing"
fi
done
TRANSACTION_ACTIVE=1
log "Configuration backup created at $BACKUP_DIR"
}
rollback() {
local rc=$? path relative service
trap - ERR INT TERM
[ "$TRANSACTION_ACTIVE" -eq 1 ] || exit "$rc"
warn "Installation failed; restoring managed configuration from $BACKUP_DIR"
while IFS= read -r path; do [ -n "$path" ] && rm -rf -- "$path"; done < "$BACKUP_DIR/missing"
while IFS= read -r path; do
[ -n "$path" ] || continue
relative=${path#/}; rm -rf -- "$path"; install -d -m 0755 "$(dirname "$path")"; cp -a "$BACKUP_DIR/files/$relative" "$path"
done < "$BACKUP_DIR/present"
systemctl daemon-reload 2>/dev/null || true
systemctl disable apache2 pivilion-mode.service pivilion-boot-greeter.service tor.service tor@default.service 2>/dev/null || true
while IFS= read -r path; do [ -n "$path" ] && systemctl enable "$path" 2>/dev/null || true; done < "$BACKUP_DIR/enabled-services"
for service in apache2 pivilion-mode.service pivilion-boot-greeter.service pivilion-dnsmasq.service pivilion-hostapd.service tor.service tor@default.service; do
grep -Fqx "$service" "$BACKUP_DIR/active-services" || systemctl stop "$service" 2>/dev/null || true
done
while IFS= read -r service; do [ -n "$service" ] && systemctl start "$service" 2>/dev/null || true; done < "$BACKUP_DIR/active-services"
apache2ctl configtest >/dev/null 2>&1 && systemctl reload apache2 2>/dev/null || true
warn "Rollback complete. Package installations were retained."
exit "$rc"
}
trap rollback ERR INT TERM
atomic_from_stdin() {
local destination=$1 mode=${2:-0644} tmp
install -d -m 0755 "$(dirname "$destination")"
tmp=$(mktemp "$(dirname "$destination")/.pivilion.XXXXXX")
cat > "$tmp"; chmod "$mode" "$tmp"; mv -f "$tmp" "$destination"
}
deploy_files() { deploy_files() {
local php_version local php_version
install -d -m 0755 /usr/local/lib/pivilion /usr/local/bin /etc/systemd/system /etc/tor/torrc.d /etc/profile.d log "Deploying Pivilion files"
install -d -m 0755 /usr/local/lib/pivilion /usr/local/bin /etc/systemd/system /etc/tor/torrc.d /etc/profile.d /var/lib/pivilion
install -m 0644 "$SOURCE_DIR/usr/local/lib/pivilion/common.sh" /usr/local/lib/pivilion/common.sh install -m 0644 "$SOURCE_DIR/usr/local/lib/pivilion/common.sh" /usr/local/lib/pivilion/common.sh
install -m 0755 "$SOURCE_DIR/usr/local/lib/pivilion/mode.sh" /usr/local/lib/pivilion/mode.sh install -m 0755 "$SOURCE_DIR/usr/local/lib/pivilion/mode.sh" "$SOURCE_DIR/usr/local/lib/pivilion/diagnose.sh" \
"$SOURCE_DIR/usr/local/lib/pivilion/boot-greeter.sh" /usr/local/lib/pivilion/
install -m 0755 "$SOURCE_DIR"/usr/local/bin/* /usr/local/bin/ install -m 0755 "$SOURCE_DIR"/usr/local/bin/* /usr/local/bin/
install -m 0644 "$SOURCE_DIR/etc/systemd/system/pivilion-mode.service" /etc/systemd/system/pivilion-mode.service install -m 0644 "$SOURCE_DIR"/etc/systemd/system/*.service /etc/systemd/system/
install -m 0644 "$SOURCE_DIR/etc/systemd/system/pivilion-dnsmasq.service" /etc/systemd/system/pivilion-dnsmasq.service
install -m 0644 "$SOURCE_DIR/etc/tor/torrc.d/pivilion.conf" /etc/tor/torrc.d/pivilion.conf install -m 0644 "$SOURCE_DIR/etc/tor/torrc.d/pivilion.conf" /etc/tor/torrc.d/pivilion.conf
install -m 0644 "$SOURCE_DIR/etc/profile.d/pivilion-motd.sh" /etc/profile.d/pivilion-motd.sh install -m 0644 "$SOURCE_DIR/etc/profile.d/pivilion-motd.sh" /etc/profile.d/pivilion-motd.sh
cat > /etc/pivilion.defaults <<EOF atomic_from_stdin /etc/pivilion.defaults <<EOF
PIVILION_USER=$PIVILION_USER PIVILION_USER=$PIVILION_USER
PIVILION_HOME=$PIVILION_HOME PIVILION_HOME=$PIVILION_HOME
PIVILION_DIR=$PIVILION_DIR PIVILION_DIR=$PIVILION_DIR
@ -56,80 +186,104 @@ PIVILION_MODE=$PIVILION_MODE
PIVILION_HOTSPOT_SSID=$PIVILION_HOTSPOT_SSID PIVILION_HOTSPOT_SSID=$PIVILION_HOTSPOT_SSID
PIVILION_WIFI_INTERFACE=$PIVILION_WIFI_INTERFACE PIVILION_WIFI_INTERFACE=$PIVILION_WIFI_INTERFACE
EOF EOF
chmod 0644 /etc/pivilion.defaults
install -d -m 0755 -o "$PIVILION_USER" -g "$PIVILION_GROUP" "$PIVILION_DIR" "$PIVILION_DIR/config" install -d -m 0755 -o "$PIVILION_USER" -g "$PIVILION_GROUP" "$PIVILION_DIR" "$PIVILION_DIR/config"
cp -a "$SOURCE_DIR/home/pi/pivilion/config/." "$PIVILION_DIR/config/" cp -a "$SOURCE_DIR/home/pi/pivilion/config/." "$PIVILION_DIR/config/"
chown -R "$PIVILION_USER:$PIVILION_GROUP" "$PIVILION_DIR/config" chown -R "$PIVILION_USER:$PIVILION_GROUP" "$PIVILION_DIR/config"
install -d -m 0755 -o "$PIVILION_USER" -g "$PIVILION_GROUP" \ install -d -m 0755 -o "$PIVILION_USER" -g "$PIVILION_GROUP" "$PIVILION_DIR/static" "$PIVILION_DIR/hotglue" "$PIVILION_DIR/hotglue/backup"
"$PIVILION_DIR/static" "$PIVILION_DIR/hotglue" "$PIVILION_DIR/hotglue/backup"
[ -s "$PIVILION_DIR/mode" ] || pivilion_set_mode "$PIVILION_MODE" [ -s "$PIVILION_DIR/mode" ] || pivilion_set_mode "$PIVILION_MODE"
[ -e "$PIVILION_DIR/torname" ] || install -m 0644 -o "$PIVILION_USER" -g "$PIVILION_GROUP" /dev/null "$PIVILION_DIR/torname" [ -e "$PIVILION_DIR/torname" ] || install -m 0644 -o "$PIVILION_USER" -g "$PIVILION_GROUP" /dev/null "$PIVILION_DIR/torname"
install -d -m 2775 -o www-data -g www-data "$PIVILION_WEBROOT" "$PIVILION_WEBROOT/gen" install -d -m 2775 -o www-data -g www-data "$PIVILION_WEBROOT" "$PIVILION_WEBROOT/gen"
cp -a "$SOURCE_DIR/var/www/html/pivilion/scripts" "$PIVILION_WEBROOT/" cp -a "$SOURCE_DIR/var/www/html/pivilion/scripts" "$SOURCE_DIR/var/www/html/pivilion/images" "$SOURCE_DIR/var/www/html/pivilion/skeleton" "$PIVILION_WEBROOT/"
cp -a "$SOURCE_DIR/var/www/html/pivilion/images" "$PIVILION_WEBROOT/"
cp -a "$SOURCE_DIR/var/www/html/pivilion/skeleton" "$PIVILION_WEBROOT/"
install -m 0664 -o www-data -g www-data "$SOURCE_DIR/var/www/html/pivilion/gen.php" "$PIVILION_WEBROOT/gen.php" install -m 0664 -o www-data -g www-data "$SOURCE_DIR/var/www/html/pivilion/gen.php" "$PIVILION_WEBROOT/gen.php"
install -m 0644 -o www-data -g www-data "$SOURCE_DIR/var/www/html/pivilion/index.html" "$PIVILION_WEBROOT/index.html" install -m 0644 -o www-data -g www-data "$SOURCE_DIR/var/www/html/pivilion/index.html" "$SOURCE_DIR/var/www/html/pivilion/pi-logo_128.png" "$PIVILION_WEBROOT/"
install -m 0644 -o www-data -g www-data "$SOURCE_DIR/var/www/html/pivilion/pi-logo_128.png" "$PIVILION_WEBROOT/pi-logo_128.png" if [ ! -e "$PIVILION_WEBROOT/gen/index.php" ]; then cp -a "$SOURCE_DIR/var/www/html/pivilion/gen/." "$PIVILION_WEBROOT/gen/"; fi
if [ ! -e "$PIVILION_WEBROOT/gen/index.php" ]; then chown -R www-data:www-data "$PIVILION_WEBROOT/scripts" "$PIVILION_WEBROOT/images" "$PIVILION_WEBROOT/skeleton" "$PIVILION_WEBROOT/gen"
cp -a "$SOURCE_DIR/var/www/html/pivilion/gen/." "$PIVILION_WEBROOT/gen/"
fi
chown -R www-data:www-data "$PIVILION_WEBROOT/scripts" "$PIVILION_WEBROOT/images" "$PIVILION_WEBROOT/skeleton"
chown -R www-data:www-data "$PIVILION_WEBROOT/gen"
find "$PIVILION_WEBROOT" -type d -exec chmod 2775 {} +
find "$PIVILION_WEBROOT" -type f -exec chmod 0664 {} +
cat > /etc/pivilion.php <<EOF atomic_from_stdin /etc/pivilion.php <<EOF
<?php <?php
define('PIVILION_DIR', ${PIVILION_DIR@Q}); define('PIVILION_DIR', ${PIVILION_DIR@Q});
define('PIVILION_WEBROOT', ${PIVILION_WEBROOT@Q}); define('PIVILION_WEBROOT', ${PIVILION_WEBROOT@Q});
EOF EOF
chmod 0644 /etc/pivilion.php
php_version=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;') php_version=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
install -d -m 0755 "/etc/php/$php_version/apache2/conf.d" install -d -m 0755 "/etc/php/$php_version/apache2/conf.d"
install -m 0644 "$SOURCE_DIR/etc/php/pivilion.ini" "/etc/php/$php_version/apache2/conf.d/99-pivilion.ini" install -m 0644 "$SOURCE_DIR/etc/php/pivilion.ini" "/etc/php/$php_version/apache2/conf.d/99-pivilion.ini"
} }
configure_services() { configure_services() {
sed "s|/var/www/html/pivilion|$PIVILION_WEBROOT|g" "$SOURCE_DIR/etc/apache2/sites-available/000-default.conf" > /etc/apache2/sites-available/000-default.conf local tmp
sed "s|/var/www/html/pivilion|$PIVILION_WEBROOT|g" "$SOURCE_DIR/etc/apache2/sites-available/000-upload.conf" > /etc/apache2/sites-available/000-upload.conf log "Configuring services"
tmp=$(mktemp); sed "s|/var/www/html/pivilion|$PIVILION_WEBROOT|g" "$SOURCE_DIR/etc/apache2/sites-available/000-default.conf" > "$tmp"; install -m 0644 "$tmp" /etc/apache2/sites-available/000-default.conf; rm -f "$tmp"
tmp=$(mktemp); sed "s|/var/www/html/pivilion|$PIVILION_WEBROOT|g" "$SOURCE_DIR/etc/apache2/sites-available/000-upload.conf" > "$tmp"; install -m 0644 "$tmp" /etc/apache2/sites-available/000-upload.conf; rm -f "$tmp"
install -m 0644 "$SOURCE_DIR/etc/apache2/ports.conf" /etc/apache2/ports.conf install -m 0644 "$SOURCE_DIR/etc/apache2/ports.conf" /etc/apache2/ports.conf
cat > /etc/apache2/conf-available/pivilion.conf <<EOF atomic_from_stdin /etc/apache2/conf-available/pivilion.conf <<EOF
<Directory "$PIVILION_WEBROOT"> <Directory "$PIVILION_WEBROOT">
Options Indexes FollowSymLinks Options Indexes FollowSymLinks
AllowOverride All AllowOverride All
Require all granted Require all granted
</Directory> </Directory>
EOF EOF
a2enmod rewrite a2enmod rewrite; a2enconf pivilion; a2ensite 000-default.conf
a2enconf pivilion
a2ensite 000-default.conf
if ! grep -Fqx '%include /etc/tor/torrc.d/*.conf' /etc/tor/torrc; then if ! grep -Fqx '%include /etc/tor/torrc.d/*.conf' /etc/tor/torrc; then
printf '\n%%include /etc/tor/torrc.d/*.conf\n' >> /etc/tor/torrc tmp=$(mktemp); cp /etc/tor/torrc "$tmp"; printf '\n%%include /etc/tor/torrc.d/*.conf\n' >> "$tmp"; install -m 0644 "$tmp" /etc/tor/torrc; rm -f "$tmp"
fi fi
systemctl daemon-reload systemctl daemon-reload
systemctl enable apache2 pivilion-mode.service systemctl enable apache2 pivilion-mode.service pivilion-boot-greeter.service
systemctl enable tor@default.service 2>/dev/null || systemctl enable tor.service systemctl enable tor@default.service 2>/dev/null || systemctl enable tor.service
systemctl disable --now dnsmasq 2>/dev/null || true systemctl disable pivilion-dnsmasq.service pivilion-hostapd.service 2>/dev/null || true
systemctl disable pivilion-dnsmasq.service 2>/dev/null || true
} }
validate_install() { validate_install() {
apache2ctl configtest local failed=0
tor --verify-config -f /etc/tor/torrc log "Validating installation"
dnsmasq --test bash -n /usr/local/lib/pivilion/*.sh /usr/local/bin/pivilion /usr/local/bin/pivilion-diagnose /usr/local/bin/hotspot /usr/local/bin/onion /usr/local/bin/pikey /usr/local/bin/generator /usr/local/bin/static /usr/local/bin/hotglue /usr/local/bin/htaccess || failed=1
bash -n /usr/local/lib/pivilion/common.sh /usr/local/lib/pivilion/mode.sh /usr/local/bin/pivilion \ apache2ctl configtest || failed=1
/usr/local/bin/hotspot /usr/local/bin/onion /usr/local/bin/pikey /usr/local/bin/generator \ tor --verify-config -f /etc/tor/torrc || failed=1
/usr/local/bin/static /usr/local/bin/hotglue /usr/local/bin/htaccess /usr/local/bin/pivilion-diagnose dnsmasq --test || failed=1
php -l "$PIVILION_WEBROOT/gen.php" || failed=1
systemd-analyze verify /etc/systemd/system/pivilion-*.service || failed=1
if command -v shellcheck >/dev/null 2>&1; then shellcheck /usr/local/lib/pivilion/*.sh /usr/local/bin/pivilion* /usr/local/bin/hotspot /usr/local/bin/onion /usr/local/bin/pikey || failed=1; fi
[ "$failed" -eq 0 ] || return "$PIVILION_E_INSTALL"
} }
write_install_state() {
atomic_from_stdin /var/lib/pivilion/install-state <<EOF
version=$INSTALL_VERSION
installed_at=$(date --iso-8601=seconds)
user=$PIVILION_USER
webroot=$PIVILION_WEBROOT
backup=$BACKUP_DIR
EOF
}
check_install() {
local failed=0 file
for file in /etc/pivilion.defaults /var/lib/pivilion/install-state /usr/local/lib/pivilion/common.sh /usr/local/lib/pivilion/mode.sh /usr/local/lib/pivilion/diagnose.sh /etc/systemd/system/pivilion-mode.service; do
if [ ! -e "$file" ]; then echo "Missing: $file" >&2; failed=1; fi
done
if [ "$failed" -eq 0 ]; then validate_install || failed=1; fi
if [ "$failed" -eq 0 ]; then log "Installed files and service configuration are valid"; return 0; fi
echo "Installation check failed. Run sudo ./install.sh --repair" >&2
return "$PIVILION_E_INSTALL"
}
preflight
case "$ACTION" in
dry-run)
log "Dry run complete; installation would install packages, back up managed configuration, deploy files, validate, and enable services."
exit 0 ;;
check) check_install; exit $? ;;
esac
backup_managed_files
install_packages install_packages
deploy_files deploy_files
configure_services configure_services
validate_install validate_install
write_install_state
systemctl is-active --quiet apache2 2>/dev/null && systemctl reload apache2 || true
TRANSACTION_ACTIVE=0
trap - ERR INT TERM
echo "Pivilion installed for $PIVILION_USER. Reboot to enter $(pivilion_current_mode) mode." log "Pivilion $ACTION completed for $PIVILION_USER. Reboot to enter $(pivilion_current_mode) mode."

67
tests/run.sh Executable file
View file

@ -0,0 +1,67 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
failures=0
run() {
local label=$1
shift
if "$@"; then printf '[pass] %s\n' "$label"; else printf '[fail] %s\n' "$label" >&2; failures=$((failures + 1)); fi
}
shell_syntax() {
bash -n "$ROOT/install.sh" "$ROOT"/usr/local/lib/pivilion/*.sh "$ROOT"/usr/local/bin/* "$ROOT/etc/profile.d/pivilion-motd.sh"
}
php_syntax() {
command -v php >/dev/null 2>&1 || return 0
while IFS= read -r file; do php -l "$file" >/dev/null || return 1; done < <(find "$ROOT/var" "$ROOT/home" -name '*.php' -type f)
}
systemd_syntax() {
local output
command -v systemd-analyze >/dev/null 2>&1 || return 0
output=$(systemd-analyze verify "$ROOT"/etc/systemd/system/*.service 2>&1) || true
output=$(printf '%s\n' "$output" | grep -Ev 'SO_PASS(RIGHTS|CRED)|handoff timestamp|user lookup socket' || true)
[ -z "$output" ]
}
captive_rules() {
grep -q 'R=302' "$ROOT/home/pi/pivilion/config/html/.htaccess" &&
! grep -q 'R=301' "$ROOT/home/pi/pivilion/config/html/.htaccess" &&
! grep -Eq 'dport[[:space:]]+443|redirect to :443' "$ROOT/usr/local/lib/pivilion/mode.sh"
}
installer_safety() {
grep -q -- '--dry-run' "$ROOT/install.sh" &&
grep -q -- '--repair' "$ROOT/install.sh" &&
grep -q 'backup_managed_files' "$ROOT/install.sh" &&
grep -q 'rollback()' "$ROOT/install.sh" &&
! grep -Eq 'disable( --now)? dnsmasq' "$ROOT/install.sh" &&
! grep -q 'rm -f.*CAPTIVE\|rm -f.*\.htaccess' "$ROOT/usr/local/bin/onion"
}
state_contract() {
grep -q "printf 'desired_mode" "$ROOT/usr/local/lib/pivilion/common.sh" &&
grep -q "printf 'active_mode" "$ROOT/usr/local/lib/pivilion/common.sh" &&
grep -q "printf 'health" "$ROOT/usr/local/lib/pivilion/common.sh" &&
grep -q 'degraded' "$ROOT/usr/local/lib/pivilion/mode.sh"
}
shellcheck_scripts() {
command -v shellcheck >/dev/null 2>&1 || return 0
shellcheck "$ROOT/install.sh" "$ROOT"/usr/local/lib/pivilion/*.sh "$ROOT"/usr/local/bin/pivilion* "$ROOT/usr/local/bin/hotspot" "$ROOT/usr/local/bin/onion" "$ROOT/usr/local/bin/pikey"
}
run "configuration and state fixtures" "$ROOT/tests/test-common.sh"
run "shell syntax" shell_syntax
run "PHP syntax" php_syntax
run "systemd unit syntax" systemd_syntax
run "temporary HTTP-only captive rules" captive_rules
run "transactional installer safety contract" installer_safety
run "truthful runtime state contract" state_contract
run "ShellCheck (when installed)" shellcheck_scripts
[ "$failures" -eq 0 ] || { echo "$failures test group(s) failed." >&2; exit 1; }
echo "All available tests passed."

62
tests/test-common.sh Executable file
View file

@ -0,0 +1,62 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
USER_NAME=$(id -un)
cat > "$TMP/primary.conf" <<EOF
PIVILION_USER=$USER_NAME
PIVILION_MODE=hotspot
EOF
cat > "$TMP/fallback.conf" <<EOF
PIVILION_USER=$USER_NAME
PIVILION_HOME=$HOME
PIVILION_DIR=$TMP/pivilion
PIVILION_WEBROOT=/var/www/html/pivilion
PIVILION_MODE=onion
PIVILION_HOTSPOT_SSID=fixture-portal
EOF
export PIVILION_CONFIG_PATHS="$TMP/primary.conf $TMP/fallback.conf"
export PIVILION_STATE_DIR="$TMP/run"
export PIVILION_STATE_FILE="$TMP/run/status.tsv"
source "$ROOT/usr/local/lib/pivilion/common.sh"
pivilion_load_config
[ "$PIVILION_MODE" = hotspot ]
[ "$PIVILION_HOTSPOT_SSID" = fixture-portal ]
[ "$PIVILION_DIR" = "$TMP/pivilion" ]
install -d "$PIVILION_DIR" "$PIVILION_STATE_DIR"
printf 'onion\n' > "$PIVILION_DIR/mode"
[ "$(pivilion_current_mode)" = onion ]
cat > "$PIVILION_STATE_FILE" <<EOF
desired_mode onion
active_mode hotspot
backend networkmanager
health degraded
failure probe failed
EOF
[ "$(pivilion_state_value active_mode)" = hotspot ]
[ "$(pivilion_state_value health)" = degraded ]
[ "$(pivilion_state_value missing fallback)" = fallback ]
mkdir "$TMP/bin"
cat > "$TMP/bin/nmcli" <<'EOF'
#!/bin/sh
exit 0
EOF
cat > "$TMP/bin/systemctl" <<'EOF'
#!/bin/sh
[ "${MOCK_NM_ACTIVE:-0}" = 1 ]
EOF
chmod +x "$TMP/bin/nmcli" "$TMP/bin/systemctl"
PATH="$TMP/bin:$PATH" MOCK_NM_ACTIVE=1 pivilion_uses_networkmanager
if PATH="$TMP/bin:$PATH" MOCK_NM_ACTIVE=0 pivilion_uses_networkmanager; then exit 1; fi
sed 's/PIVILION_MODE=hotspot/PIVILION_MODE=invalid/' "$TMP/primary.conf" > "$TMP/invalid.conf"
PIVILION_CONFIG_PATHS="$TMP/invalid.conf $TMP/fallback.conf"
export PIVILION_CONFIG_PATHS
if pivilion_load_config >/dev/null 2>&1; then exit 1; fi

View file

@ -4,7 +4,6 @@ source /usr/local/lib/pivilion/common.sh
[ "$(id -u)" -eq 0 ] || exec sudo "$0" "$@" [ "$(id -u)" -eq 0 ] || exec sudo "$0" "$@"
pivilion_load_config pivilion_load_config
pivilion_set_mode onion pivilion_set_mode onion
rm -f "$PIVILION_WEBROOT/gen/.htaccess"
echo "Pivilion will start in onion mode after the next reboot." echo "Pivilion will start in onion mode after the next reboot."
if [ -s "$PIVILION_DIR/torname" ]; then if [ -s "$PIVILION_DIR/torname" ]; then
printf 'Your onion domain is %s\n' "$(tr -d '\r\n' < "$PIVILION_DIR/torname")" printf 'Your onion domain is %s\n' "$(tr -d '\r\n' < "$PIVILION_DIR/torname")"

View file

@ -3,11 +3,22 @@ set -euo pipefail
source /usr/local/lib/pivilion/common.sh source /usr/local/lib/pivilion/common.sh
pivilion_load_config pivilion_load_config
case "${1:-status}" in
status) [ "$#" -le 1 ] || { echo "Usage: pivilion [status|diagnose [--json]]" >&2; exit 2; } ;;
diagnose) shift; exec /usr/local/lib/pivilion/diagnose.sh "$@" ;;
*) echo "Usage: pivilion [status|diagnose [--json]]" >&2; exit 2 ;;
esac
pivilion_print_motd pivilion_print_motd
failure=$(pivilion_state_value failure none)
cat <<EOF cat <<EOF
Web root: $PIVILION_WEBROOT Web root: $PIVILION_WEBROOT
Apache: $(pivilion_service_state apache2)
Tor: $(if systemctl cat tor@default.service >/dev/null 2>&1; then pivilion_service_state tor@default.service; else pivilion_service_state tor.service; fi)
Last activation failure: $failure
Commands: Commands:
pivilion show this status pivilion show this status
@ -18,5 +29,6 @@ Commands:
static convert the current gallery to static files static convert the current gallery to static files
hotglue install or restore Hotglue hotglue install or restore Hotglue
htaccess reset or remove captive redirect rules htaccess reset or remove captive redirect rules
pivilion-diagnose check captive DNS, HTTP redirects, and services pivilion diagnose [--json] check networking, redirects, and services
pivilion-diagnose compatibility alias for diagnostics
EOF EOF

View file

@ -1,4 +1,3 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
[ "$(id -u)" -eq 0 ] || exec sudo "$0" "$@" exec /usr/local/lib/pivilion/diagnose.sh "$@"
exec /usr/local/lib/pivilion/mode.sh diagnose

View file

@ -0,0 +1,6 @@
#!/bin/bash
set -euo pipefail
source /usr/local/lib/pivilion/common.sh
pivilion_load_config
pivilion_print_motd "Welcome to Pivilion!"

View file

@ -3,6 +3,18 @@
# Shared configuration and small helpers for the Pivilion commands. # Shared configuration and small helpers for the Pivilion commands.
PIVILION_CONFIG="" PIVILION_CONFIG=""
PIVILION_STATE_DIR=${PIVILION_STATE_DIR:-/run/pivilion}
PIVILION_STATE_FILE=${PIVILION_STATE_FILE:-$PIVILION_STATE_DIR/status.tsv}
# Stable command exit codes. Keep these public so scripts and monitoring can
# distinguish configuration, hardware, service, captive portal, and Tor faults.
PIVILION_E_CONFIG=10
PIVILION_E_HARDWARE=11
PIVILION_E_CONFLICT=12
PIVILION_E_SERVICE=13
PIVILION_E_CAPTIVE=14
PIVILION_E_TOR=15
PIVILION_E_INSTALL=20
_pivilion_trim() { _pivilion_trim() {
local value=$1 local value=$1
@ -12,8 +24,9 @@ _pivilion_trim() {
} }
_pivilion_config_value() { _pivilion_config_value() {
local key=$1 file line name value local key=$1 file line name value config_paths
for file in /boot/firmware/pivilion.conf /boot/pivilion.conf /etc/pivilion.conf /etc/pivilion.defaults; do config_paths=${PIVILION_CONFIG_PATHS:-/boot/firmware/pivilion.conf /boot/pivilion.conf /etc/pivilion.conf /etc/pivilion.defaults}
for file in $config_paths; do
[ -r "$file" ] || continue [ -r "$file" ] || continue
while IFS= read -r line || [ -n "$line" ]; do while IFS= read -r line || [ -n "$line" ]; do
line=$(_pivilion_trim "$line") line=$(_pivilion_trim "$line")
@ -112,18 +125,59 @@ pivilion_require_root() {
[ "$(id -u)" -eq 0 ] || { echo "This operation must run as root (use sudo)." >&2; return 1; } [ "$(id -u)" -eq 0 ] || { echo "This operation must run as root (use sudo)." >&2; return 1; }
} }
pivilion_state_value() {
local key=$1 fallback=${2:-unknown} value
value=$(awk -F '\t' -v key="$key" '$1 == key {sub(/^[^\t]*\t/, ""); print; exit}' "$PIVILION_STATE_FILE" 2>/dev/null || true)
printf '%s\n' "${value:-$fallback}"
}
pivilion_write_state() {
local active=$1 backend=$2 health=$3 failure=${4:-} tmp desired addresses
pivilion_require_root
install -d -m 0755 "$PIVILION_STATE_DIR"
desired=$(pivilion_current_mode)
addresses=$(hostname -I 2>/dev/null | xargs || true)
failure=${failure//$'\n'/ }
failure=${failure//$'\t'/ }
tmp=$(mktemp "$PIVILION_STATE_DIR/status.XXXXXX")
{
printf 'desired_mode\t%s\n' "$desired"
printf 'active_mode\t%s\n' "$active"
printf 'backend\t%s\n' "$backend"
printf 'health\t%s\n' "$health"
printf 'activated_at\t%s\n' "$(date --iso-8601=seconds)"
printf 'addresses\t%s\n' "${addresses:-none}"
printf 'failure\t%s\n' "${failure:-none}"
} > "$tmp"
chmod 0644 "$tmp"
mv -f "$tmp" "$PIVILION_STATE_FILE"
}
pivilion_service_state() {
local state
state=$(systemctl is-active "$1" 2>/dev/null || true)
printf '%s\n' "${state:-unknown}"
}
pivilion_print_motd() { pivilion_print_motd() {
local heading=${1:-Welcome to Pivilion!} local heading=${1:-Welcome to Pivilion!}
local uptime_text memory_free memory_total load_one load_five load_fifteen processes torname mode local uptime_text memory_free memory_total load_one load_five load_fifteen processes torname desired active backend health addresses
uptime_text=$(uptime -p 2>/dev/null || printf 'unknown') uptime_text=$(uptime -p 2>/dev/null || printf 'unknown')
memory_free=$(awk '/^MemAvailable:/ {print $2; exit}' /proc/meminfo 2>/dev/null) memory_free=$(awk '/^MemAvailable:/ {print $2; exit}' /proc/meminfo 2>/dev/null)
memory_total=$(awk '/^MemTotal:/ {print $2; exit}' /proc/meminfo 2>/dev/null) memory_total=$(awk '/^MemTotal:/ {print $2; exit}' /proc/meminfo 2>/dev/null)
read -r load_one load_five load_fifteen _ < /proc/loadavg if ! read -r load_one load_five load_fifteen _ < /proc/loadavg 2>/dev/null; then
processes=$(ps ax --no-headers 2>/dev/null | wc -l) load_one=unknown; load_five=unknown; load_fifteen=unknown
fi
processes=$({ ps ax --no-headers 2>/dev/null || true; } | wc -l)
[ "$processes" -gt 0 ] 2>/dev/null || processes=unknown
torname=not-generated-yet torname=not-generated-yet
[ ! -s "$PIVILION_DIR/torname" ] || torname=$(tr -d '\r\n' < "$PIVILION_DIR/torname") [ ! -s "$PIVILION_DIR/torname" ] || torname=$(tr -d '\r\n' < "$PIVILION_DIR/torname")
mode=$(pivilion_current_mode) desired=$(pivilion_current_mode)
active=$(pivilion_state_value active_mode not-verified)
backend=$(pivilion_state_value backend unknown)
health=$(pivilion_state_value health unknown)
addresses=$(pivilion_state_value addresses none)
printf '\n%s\n' "$heading" printf '\n%s\n' "$heading"
cat <<EOF cat <<EOF
@ -135,6 +189,8 @@ pivilion_print_motd() {
@ @ . #@@@# . @ @ Running Processes..: $processes @ @ . #@@@# . @ @ Running Processes..: $processes
@ @@@, # ,@@@ @ Manual / more info.: pivilion.net @ @@@, # ,@@@ @ Manual / more info.: pivilion.net
@@, @ ,@@ Onion Address......: $torname @@, @ ,@@ Onion Address......: $torname
@@@@@#@@@@@@@#@@@@@ Running in $mode mode @@@@@#@@@@@@@#@@@@@ Desired / active....: $desired / $active
Backend / health...: $backend / $health
IP addresses.......: $addresses
EOF EOF
} }

View file

@ -0,0 +1,116 @@
#!/bin/bash
set -uo pipefail
source /usr/local/lib/pivilion/common.sh
[ "$(id -u)" -eq 0 ] || exec sudo /usr/local/lib/pivilion/diagnose.sh "$@"
OUTPUT=text
QUIET=0
MODE=""
while [ "$#" -gt 0 ]; do
case "$1" in
--json) OUTPUT=json ;;
--quiet) QUIET=1 ;;
--mode) shift; MODE=${1:-} ;;
*) echo "Usage: pivilion diagnose [--json] [--mode hotspot|onion]" >&2; exit 2 ;;
esac
shift
done
pivilion_load_config || exit "$PIVILION_E_CONFIG"
DESIRED=$(pivilion_current_mode)
ACTIVE=$(pivilion_state_value active_mode not-verified)
BACKEND=$(pivilion_state_value backend unknown)
RECORDED_HEALTH=$(pivilion_state_value health unknown)
ADDRESSES=$(pivilion_state_value addresses none)
FAILURE_REASON=$(pivilion_state_value failure none)
MODE=${MODE:-$ACTIVE}
case "$MODE" in not-verified|none|unknown) MODE=$DESIRED ;; esac
case "$MODE" in hotspot|onion) ;; *) MODE=$(pivilion_current_mode) ;; esac
declare -a CHECK_NAMES=() CHECK_VALUES=() CHECK_DETAILS=()
FAILURES=0
SERVICE_FAILURE=0
MODE_FAILURE=0
HARDWARE_FAILURE=0
record_check() {
local name=$1 value=$2 detail=${3:-}
CHECK_NAMES+=("$name")
CHECK_VALUES+=("$value")
CHECK_DETAILS+=("$detail")
if [ "$value" != pass ]; then FAILURES=$((FAILURES + 1)); fi
}
service_active() { systemctl is-active --quiet "$1"; }
apache_rewrite() { apache2ctl -M 2>/dev/null | grep -q rewrite_module; }
live_redirect() { cmp -s "$PIVILION_DIR/config/html/.htaccess" "$PIVILION_WEBROOT/gen/.htaccess"; }
dns_answer() { [ "$(dig +short A @10.1.1.1 "$1" 2>/dev/null | tail -n1)" = 10.1.1.1 ]; }
nft_active() { nft list table inet pivilion >/dev/null 2>&1; }
probe_redirect() {
local host=$1 path=$2 headers
headers=$(curl -sS --max-time 5 -D - -o /dev/null -H "Host: $host" "http://10.1.1.1$path") || return 1
grep -Eq '^HTTP/[0-9.]+ 302([[:space:]]|$)' <<<"$headers" &&
grep -Eiq '^Location:[[:space:]]*http://10\.1\.1\.1/index\.php\r?$' <<<"$headers"
}
gallery_works() { curl -fsS --max-time 5 -o /dev/null http://10.1.1.1/index.php; }
default_route() { ip route show default | grep -q '^default'; }
valid_torname() {
local hostname
hostname=$(tr -d '\r\n' < "$PIVILION_DIR/torname" 2>/dev/null || true)
[[ "$hostname" =~ ^[a-z2-7]{56}\.onion$ ]]
}
tor_active() {
if systemctl cat tor@default.service >/dev/null 2>&1; then service_active tor@default.service; else service_active tor.service; fi
}
IFACE=$(pivilion_wifi_interface)
if [ -n "$IFACE" ] && [ -d "/sys/class/net/$IFACE" ]; then record_check wifi_interface pass "$IFACE"; else record_check wifi_interface fail missing; HARDWARE_FAILURE=1; fi
if service_active apache2; then record_check apache pass active; else record_check apache fail inactive; SERVICE_FAILURE=1; fi
if apache_rewrite; then record_check apache_rewrite pass enabled; else record_check apache_rewrite fail disabled; SERVICE_FAILURE=1; fi
if [ "$MODE" = hotspot ]; then
if live_redirect; then record_check captive_redirect pass installed; else record_check captive_redirect fail missing_or_stale; MODE_FAILURE=1; fi
if service_active pivilion-dnsmasq.service; then record_check dnsmasq pass active; else record_check dnsmasq fail inactive; SERVICE_FAILURE=1; fi
if nft_active; then record_check nftables pass active; else record_check nftables fail missing; MODE_FAILURE=1; fi
if dns_answer connectivitycheck.gstatic.com; then record_check captive_dns pass 10.1.1.1; else record_check captive_dns fail wrong_answer; MODE_FAILURE=1; fi
if probe_redirect connectivitycheck.gstatic.com /generate_204; then record_check android_probe pass 302; else record_check android_probe fail no_redirect; MODE_FAILURE=1; fi
if probe_redirect captive.apple.com /hotspot-detect.html; then record_check apple_probe pass 302; else record_check apple_probe fail no_redirect; MODE_FAILURE=1; fi
if probe_redirect www.msftconnecttest.com /connecttest.txt; then record_check microsoft_probe pass 302; else record_check microsoft_probe fail no_redirect; MODE_FAILURE=1; fi
if gallery_works; then record_check gallery pass reachable; else record_check gallery fail unreachable; MODE_FAILURE=1; fi
else
if default_route; then record_check upstream_route pass present; else record_check upstream_route fail missing; MODE_FAILURE=1; fi
if tor_active; then record_check tor pass active; else record_check tor fail inactive; MODE_FAILURE=1; fi
if valid_torname; then record_check onion_hostname pass valid_v3; else record_check onion_hostname fail missing_or_invalid; MODE_FAILURE=1; fi
fi
json_escape() {
local value=$1
value=${value//\\/\\\\}; value=${value//\"/\\\"}; value=${value//$'\n'/\\n}; value=${value//$'\r'/\\r}; value=${value//$'\t'/\\t}
printf '%s' "$value"
}
if [ "$OUTPUT" = json ]; then
printf '{"desired_mode":"%s","active_mode":"%s","diagnosed_mode":"%s","backend":"%s","recorded_health":"%s","addresses":"%s","failure":"%s","healthy":%s,"checks":{' \
"$(json_escape "$DESIRED")" "$(json_escape "$ACTIVE")" "$(json_escape "$MODE")" "$(json_escape "$BACKEND")" \
"$(json_escape "$RECORDED_HEALTH")" "$(json_escape "$ADDRESSES")" "$(json_escape "$FAILURE_REASON")" \
"$([ "$FAILURES" -eq 0 ] && printf true || printf false)"
for ((i=0; i<${#CHECK_NAMES[@]}; i++)); do
[ "$i" -eq 0 ] || printf ','
printf '"%s":{"status":"%s","detail":"%s"}' "$(json_escape "${CHECK_NAMES[$i]}")" "${CHECK_VALUES[$i]}" "$(json_escape "${CHECK_DETAILS[$i]}")"
done
printf '}}\n'
elif [ "$QUIET" -eq 0 ]; then
printf 'Pivilion diagnostics (desired=%s, active=%s, checking=%s, backend=%s, recorded-health=%s):\n' \
"$DESIRED" "$ACTIVE" "$MODE" "$BACKEND" "$RECORDED_HEALTH"
for ((i=0; i<${#CHECK_NAMES[@]}; i++)); do
printf ' [%s] %-20s %s\n' "${CHECK_VALUES[$i]}" "${CHECK_NAMES[$i]}" "${CHECK_DETAILS[$i]}"
done
fi
[ "$FAILURES" -eq 0 ] && exit 0
[ "$HARDWARE_FAILURE" -eq 0 ] || exit "$PIVILION_E_HARDWARE"
[ "$SERVICE_FAILURE" -eq 0 ] || exit "$PIVILION_E_SERVICE"
[ "$MODE" != hotspot ] || exit "$PIVILION_E_CAPTIVE"
exit "$PIVILION_E_TOR"

View file

@ -1,104 +1,97 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -Eeuo pipefail
source /usr/local/lib/pivilion/common.sh source /usr/local/lib/pivilion/common.sh
pivilion_require_root pivilion_require_root
pivilion_load_config pivilion_load_config || exit "$PIVILION_E_CONFIG"
MODE=${1:-$(pivilion_current_mode)} MODE=${1:-$(pivilion_current_mode)}
case "$MODE" in hotspot|onion) ;; *) echo "Unknown Pivilion mode: $MODE" >&2; exit 2 ;; esac
IFACE=$(pivilion_wifi_interface) IFACE=$(pivilion_wifi_interface)
[ -n "$IFACE" ] || { echo "No Wi-Fi interface found; set PIVILION_WIFI_INTERFACE" >&2; exit 1; } [ -n "$IFACE" ] && [ -d "/sys/class/net/$IFACE" ] || {
pivilion_write_state none none failed "Wi-Fi interface not found"
echo "No Wi-Fi interface found; set PIVILION_WIFI_INTERFACE" >&2
exit "$PIVILION_E_HARDWARE"
}
RUN_DIR=/run/pivilion RUN_DIR=/run/pivilion
DNSMASQ_CONF=/etc/dnsmasq.d/pivilion-runtime.conf DNSMASQ_CONF=/etc/dnsmasq.d/pivilion-runtime.conf
HOSTAPD_CONF=$RUN_DIR/hostapd.conf
NFT_CONF=$RUN_DIR/pivilion.nft
NM_HOTSPOT=pivilion-hotspot NM_HOTSPOT=pivilion-hotspot
CAPTIVE_TEMPLATE=$PIVILION_DIR/config/html/.htaccess CAPTIVE_TEMPLATE=$PIVILION_DIR/config/html/.htaccess
CAPTIVE_LIVE=$PIVILION_WEBROOT/gen/.htaccess CAPTIVE_LIVE=$PIVILION_WEBROOT/gen/.htaccess
BACKEND=legacy
pivilion_uses_networkmanager && BACKEND=networkmanager
activation_error() {
local rc=$? line=${BASH_LINENO[0]:-unknown}
trap - ERR
pivilion_write_state none "$BACKEND" failed "Activation failed at line $line (exit $rc)" || true
logger -t pivilion-mode "Failed to activate $MODE mode at line $line (exit $rc)" 2>/dev/null || true
exit "$rc"
}
trap activation_error ERR
atomic_install() {
local source=$1 destination=$2 owner=${3:-root} group=${4:-root} mode=${5:-0644} tmp
install -d -m 0755 "$(dirname "$destination")"
tmp=$(mktemp "$(dirname "$destination")/.pivilion.XXXXXX")
if ! install -m "$mode" -o "$owner" -g "$group" "$source" "$tmp"; then rm -f "$tmp"; return 1; fi
mv -f "$tmp" "$destination"
}
install_captive_redirect() { install_captive_redirect() {
local tmp [ -r "$CAPTIVE_TEMPLATE" ] || { echo "Captive redirect template is missing: $CAPTIVE_TEMPLATE" >&2; return 1; }
[ -r "$CAPTIVE_TEMPLATE" ] || { [ -d "$PIVILION_WEBROOT/gen" ] || { echo "Gallery webroot is missing: $PIVILION_WEBROOT/gen" >&2; return 1; }
echo "Captive redirect template is missing: $CAPTIVE_TEMPLATE" >&2 atomic_install "$CAPTIVE_TEMPLATE" "$CAPTIVE_LIVE" root www-data 0644
return 1
}
[ -d "$PIVILION_WEBROOT/gen" ] || {
echo "Gallery webroot is missing: $PIVILION_WEBROOT/gen" >&2
return 1
}
tmp=$(mktemp "$PIVILION_WEBROOT/gen/.htaccess.XXXXXX")
if ! install -m 0644 -o root -g www-data "$CAPTIVE_TEMPLATE" "$tmp"; then
rm -f "$tmp"
return 1
fi
mv -f "$tmp" "$CAPTIVE_LIVE"
} }
diagnostic_result() { validate_ap_support() {
local label=$1 local phy
shift command -v iw >/dev/null 2>&1 || return 1
if "$@"; then phy=$(iw dev "$IFACE" info 2>/dev/null | awk '$1 == "wiphy" {print "phy" $2; exit}')
printf ' [ok] %s\n' "$label" [ -n "$phy" ] && iw phy "$phy" info 2>/dev/null | grep -Eq '^[[:space:]]*\* AP$'
return 0
fi
printf ' [fail] %s\n' "$label" >&2
return 1
} }
probe_redirect() { cleanup_hotspot() {
local host=$1 path=$2 headers systemctl stop pivilion-dnsmasq.service pivilion-hostapd.service 2>/dev/null || true
headers=$(curl --silent --show-error --max-time 5 --dump-header - --output /dev/null \ rm -f "$DNSMASQ_CONF" "$HOSTAPD_CONF"
--header "Host: $host" "http://10.1.1.1$path") || return 1
grep -Eq '^HTTP/[0-9.]+ 302([[:space:]]|$)' <<<"$headers" &&
grep -Eiq '^Location:[[:space:]]*http://10\.1\.1\.1/index\.php\r?$' <<<"$headers"
}
dns_points_to_portal() {
local host=$1 answer
answer=$(dig +short A @10.1.1.1 "$host" 2>/dev/null | tail -n1)
[ "$answer" = 10.1.1.1 ]
}
apache_rewrite_enabled() {
apache2ctl -M 2>/dev/null | grep -q 'rewrite_module'
}
nft_portal_table_active() {
nft list table inet pivilion >/dev/null 2>&1
}
hotspot_diagnostics() {
local failures=0
echo "Pivilion hotspot diagnostics:"
diagnostic_result "Apache is active" systemctl is-active --quiet apache2 || failures=$((failures + 1))
diagnostic_result "Apache rewrite module is enabled" apache_rewrite_enabled || failures=$((failures + 1))
diagnostic_result "live captive redirect matches its template" cmp -s "$CAPTIVE_TEMPLATE" "$CAPTIVE_LIVE" || failures=$((failures + 1))
diagnostic_result "Pivilion dnsmasq is active" systemctl is-active --quiet pivilion-dnsmasq.service || failures=$((failures + 1))
diagnostic_result "captive DNS resolves Android probe" dns_points_to_portal connectivitycheck.gstatic.com || failures=$((failures + 1))
diagnostic_result "Pivilion nftables table is active" nft_portal_table_active || failures=$((failures + 1))
diagnostic_result "Android probe receives portal redirect" probe_redirect connectivitycheck.gstatic.com /generate_204 || failures=$((failures + 1))
diagnostic_result "Apple probe receives portal redirect" probe_redirect captive.apple.com /hotspot-detect.html || failures=$((failures + 1))
diagnostic_result "Microsoft probe receives portal redirect" probe_redirect www.msftconnecttest.com /connecttest.txt || failures=$((failures + 1))
[ "$failures" -eq 0 ] || {
echo "$failures hotspot diagnostic check(s) failed." >&2
return 1
}
}
stop_hotspot() {
systemctl stop pivilion-dnsmasq.service 2>/dev/null || true
rm -f "$DNSMASQ_CONF"
nft delete table inet pivilion 2>/dev/null || true nft delete table inet pivilion 2>/dev/null || true
if pivilion_uses_networkmanager; then if command -v nmcli >/dev/null 2>&1; then
nmcli connection down "$NM_HOTSPOT" 2>/dev/null || true nmcli connection down "$NM_HOTSPOT" >/dev/null 2>&1 || true
nmcli connection delete "$NM_HOTSPOT" 2>/dev/null || true nmcli connection delete "$NM_HOTSPOT" >/dev/null 2>&1 || true
else fi
systemctl stop hostapd 2>/dev/null || true if ip -4 address show dev "$IFACE" | grep -q '10\.1\.1\.1/24'; then
ip address flush dev "$IFACE" 2>/dev/null || true ip address del 10.1.1.1/24 dev "$IFACE" 2>/dev/null || true
fi
if systemctl is-active --quiet hostapd.service 2>/dev/null && grep -Fqx "ssid=$PIVILION_HOTSPOT_SSID" /etc/hostapd/hostapd.conf 2>/dev/null; then
systemctl stop hostapd.service
fi fi
} }
write_dnsmasq_config() { check_dns_conflict() {
install -d -m 0755 /etc/dnsmasq.d local listeners
cat > "$DNSMASQ_CONF" <<EOF listeners=$(ss -H -lntu 'sport = :53' 2>/dev/null || true)
[ -z "$listeners" ] && return 0
if grep -Eq '(0\.0\.0\.0|\*|10\.1\.1\.1):53([[:space:]]|$)' <<<"$listeners"; then
echo "Port 53 is already occupied by a non-Pivilion service." >&2
return 1
fi
}
check_hostapd_conflict() {
if systemctl is-active --quiet hostapd.service 2>/dev/null; then
echo "The system hostapd service already owns a wireless interface." >&2
return 1
fi
}
write_hotspot_configs() {
local dns_tmp hostapd_tmp nft_tmp
install -d -m 0755 "$RUN_DIR" /etc/dnsmasq.d
dns_tmp=$(mktemp "$RUN_DIR/dnsmasq.XXXXXX")
cat > "$dns_tmp" <<EOF
interface=$IFACE interface=$IFACE
bind-dynamic bind-dynamic
listen-address=10.1.1.1 listen-address=10.1.1.1
@ -107,11 +100,25 @@ dhcp-option=option:router,10.1.1.1
dhcp-authoritative dhcp-authoritative
address=/#/10.1.1.1 address=/#/10.1.1.1
EOF EOF
} dnsmasq --test --conf-file="$dns_tmp"
atomic_install "$dns_tmp" "$DNSMASQ_CONF"
rm -f "$dns_tmp"
start_firewall() { hostapd_tmp=$(mktemp "$RUN_DIR/hostapd.XXXXXX")
install -d -m 0755 "$RUN_DIR" cat > "$hostapd_tmp" <<EOF
cat > "$RUN_DIR/pivilion.nft" <<EOF interface=$IFACE
driver=nl80211
ssid=$PIVILION_HOTSPOT_SSID
hw_mode=g
channel=6
auth_algs=1
wmm_enabled=0
EOF
atomic_install "$hostapd_tmp" "$HOSTAPD_CONF"
rm -f "$hostapd_tmp"
nft_tmp=$(mktemp "$RUN_DIR/nft.XXXXXX")
cat > "$nft_tmp" <<EOF
table inet pivilion { table inet pivilion {
chain prerouting { chain prerouting {
type nat hook prerouting priority dstnat; policy accept; type nat hook prerouting priority dstnat; policy accept;
@ -121,46 +128,24 @@ table inet pivilion {
} }
} }
EOF EOF
nft delete table inet pivilion 2>/dev/null || true nft -c -f "$nft_tmp"
nft -f "$RUN_DIR/pivilion.nft" atomic_install "$nft_tmp" "$NFT_CONF"
rm -f "$nft_tmp"
} }
start_hotspot() { apply_firewall() {
stop_hotspot nft delete table inet pivilion 2>/dev/null || true
install_captive_redirect nft -f "$NFT_CONF"
rfkill unblock wifi 2>/dev/null || true }
systemctl start apache2
if pivilion_uses_networkmanager; then wait_for_default_route() {
nmcli connection add type wifi ifname "$IFACE" con-name "$NM_HOTSPOT" ssid "$PIVILION_HOTSPOT_SSID" local i
nmcli connection modify "$NM_HOTSPOT" 802-11-wireless.mode ap 802-11-wireless.band bg \ for i in $(seq 1 30); do ip route show default | grep -q '^default' && return 0; sleep 1; done
ipv4.method manual ipv4.addresses 10.1.1.1/24 ipv4.never-default yes ipv6.method disabled return 1
nmcli connection up "$NM_HOTSPOT" }
else
systemctl stop "wpa_supplicant@$IFACE.service" 2>/dev/null || true restart_tor() {
command -v dhcpcd >/dev/null 2>&1 && dhcpcd -k "$IFACE" 2>/dev/null || true if systemctl cat tor@default.service >/dev/null 2>&1; then systemctl restart tor@default.service; else systemctl restart tor.service; fi
ip link set "$IFACE" down
ip address flush dev "$IFACE"
ip address add 10.1.1.1/24 dev "$IFACE"
ip link set "$IFACE" up
install -d -m 0755 /etc/hostapd
cat > /etc/hostapd/hostapd.conf <<EOF
interface=$IFACE
driver=nl80211
ssid=$PIVILION_HOTSPOT_SSID
hw_mode=g
channel=6
auth_algs=1
wmm_enabled=0
EOF
systemctl unmask hostapd 2>/dev/null || true
systemctl restart hostapd
fi
write_dnsmasq_config
dnsmasq --test --conf-file="$DNSMASQ_CONF"
systemctl restart pivilion-dnsmasq.service
start_firewall
hotspot_diagnostics
echo "Pivilion hotspot '$PIVILION_HOTSPOT_SSID' is active on $IFACE (10.1.1.1)."
} }
update_torname() { update_torname() {
@ -168,45 +153,81 @@ update_torname() {
for i in $(seq 1 30); do for i in $(seq 1 30); do
if [ -s "$source" ]; then if [ -s "$source" ]; then
hostname=$(tr -d '\r\n' < "$source") hostname=$(tr -d '\r\n' < "$source")
if [[ ! "$hostname" =~ ^[a-z2-7]{56}\.onion$ ]]; then [[ "$hostname" =~ ^[a-z2-7]{56}\.onion$ ]] || return 1
echo "Tor produced an invalid v3 onion hostname." >&2
return 1
fi
tmp=$(mktemp "$PIVILION_DIR/.torname.XXXXXX") tmp=$(mktemp "$PIVILION_DIR/.torname.XXXXXX")
printf '%s\n' "$hostname" > "$tmp" printf '%s\n' "$hostname" > "$tmp"
chown "$PIVILION_USER:$PIVILION_GROUP" "$tmp" chown "$PIVILION_USER:$PIVILION_GROUP" "$tmp"; chmod 0644 "$tmp"; mv -f "$tmp" "$PIVILION_DIR/torname"
chmod 0644 "$tmp"
mv -f "$tmp" "$PIVILION_DIR/torname"
return 0 return 0
fi fi
sleep 1 sleep 1
done done
echo "Tor started, but no onion hostname was generated within 30 seconds." >&2
return 1 return 1
} }
start_hotspot() {
pivilion_write_state none "$BACKEND" activating none
validate_ap_support || {
pivilion_write_state none "$BACKEND" failed "$IFACE does not advertise Wi-Fi AP support"
echo "$IFACE does not advertise Wi-Fi AP support." >&2
exit "$PIVILION_E_HARDWARE"
}
cleanup_hotspot
if ! check_hostapd_conflict; then
pivilion_write_state none "$BACKEND" failed "System hostapd conflict"
exit "$PIVILION_E_CONFLICT"
fi
if ! check_dns_conflict; then
pivilion_write_state none "$BACKEND" failed "Port 53 conflict"
exit "$PIVILION_E_CONFLICT"
fi
install_captive_redirect
write_hotspot_configs
rfkill unblock wifi 2>/dev/null || true
systemctl start apache2
if [ "$BACKEND" = networkmanager ]; then
nmcli connection add type wifi ifname "$IFACE" con-name "$NM_HOTSPOT" ssid "$PIVILION_HOTSPOT_SSID"
nmcli connection modify "$NM_HOTSPOT" 802-11-wireless.mode ap 802-11-wireless.band bg \
ipv4.method manual ipv4.addresses 10.1.1.1/24 ipv4.never-default yes ipv6.method disabled connection.autoconnect no
nmcli connection up "$NM_HOTSPOT"
else
command -v dhcpcd >/dev/null 2>&1 && dhcpcd -k "$IFACE" 2>/dev/null || true
systemctl stop "wpa_supplicant@$IFACE.service" 2>/dev/null || true
ip link set "$IFACE" down; ip address flush dev "$IFACE"; ip address add 10.1.1.1/24 dev "$IFACE"; ip link set "$IFACE" up
systemctl restart pivilion-hostapd.service
fi
systemctl restart pivilion-dnsmasq.service
apply_firewall
if /usr/local/lib/pivilion/diagnose.sh --mode hotspot --quiet; then
pivilion_write_state hotspot "$BACKEND" healthy none
else
pivilion_write_state hotspot "$BACKEND" degraded "Post-start captive portal diagnostics failed"
logger -t pivilion-mode "Hotspot active but degraded; run pivilion diagnose" || true
fi
echo "Pivilion hotspot '$PIVILION_HOTSPOT_SSID' is active on $IFACE."
}
start_onion() { start_onion() {
stop_hotspot local failure=""
if pivilion_uses_networkmanager; then pivilion_write_state none "$BACKEND" activating none
nmcli connection up pivilion-onion 2>/dev/null || echo "No saved Pivilion Wi-Fi connection; run pikey." >&2 cleanup_hotspot
rm -f "$CAPTIVE_LIVE"
if [ "$BACKEND" = networkmanager ]; then
nmcli connection up pivilion-onion >/dev/null 2>&1 || failure="No saved Pivilion Wi-Fi connection; run pikey"
else else
systemctl restart dhcpcd 2>/dev/null || true systemctl restart dhcpcd 2>/dev/null || true
systemctl restart "wpa_supplicant@$IFACE.service" 2>/dev/null || systemctl restart wpa_supplicant 2>/dev/null || true systemctl restart "wpa_supplicant@$IFACE.service" 2>/dev/null || systemctl restart wpa_supplicant 2>/dev/null || true
fi fi
rm -f "$CAPTIVE_LIVE" wait_for_default_route || failure="${failure:+$failure; }No upstream default route after 30 seconds"
systemctl start apache2 systemctl start apache2
if systemctl cat tor@default.service >/dev/null 2>&1; then restart_tor
systemctl restart tor@default.service update_torname || failure="${failure:+$failure; }Tor did not produce a valid hostname; retained previous value"
if [ -z "$failure" ] && /usr/local/lib/pivilion/diagnose.sh --mode onion --quiet; then
pivilion_write_state onion "$BACKEND" healthy none
else else
systemctl restart tor.service pivilion_write_state onion "$BACKEND" degraded "${failure:-Onion diagnostics failed}"
logger -t pivilion-mode "Onion mode active but degraded: ${failure:-diagnostics failed}" || true
fi fi
update_torname || true echo "Pivilion onion mode activation completed."
echo "Pivilion onion mode is active."
} }
case "$MODE" in case "$MODE" in hotspot) start_hotspot ;; onion) start_onion ;; esac
hotspot) start_hotspot ;;
onion) start_onion ;;
diagnose) hotspot_diagnostics ;;
*) echo "Unknown Pivilion mode: $MODE" >&2; exit 2 ;;
esac