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

@ -4,7 +4,6 @@ source /usr/local/lib/pivilion/common.sh
[ "$(id -u)" -eq 0 ] || exec sudo "$0" "$@"
pivilion_load_config
pivilion_set_mode onion
rm -f "$PIVILION_WEBROOT/gen/.htaccess"
echo "Pivilion will start in onion mode after the next reboot."
if [ -s "$PIVILION_DIR/torname" ]; then
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
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
failure=$(pivilion_state_value failure none)
cat <<EOF
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:
pivilion show this status
@ -18,5 +29,6 @@ Commands:
static convert the current gallery to static files
hotglue install or restore Hotglue
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

View file

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

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.
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() {
local value=$1
@ -12,8 +24,9 @@ _pivilion_trim() {
}
_pivilion_config_value() {
local key=$1 file line name value
for file in /boot/firmware/pivilion.conf /boot/pivilion.conf /etc/pivilion.conf /etc/pivilion.defaults; do
local key=$1 file line name value config_paths
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
while IFS= read -r line || [ -n "$line" ]; do
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; }
}
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() {
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')
memory_free=$(awk '/^MemAvailable:/ {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
processes=$(ps ax --no-headers 2>/dev/null | wc -l)
if ! read -r load_one load_five load_fifteen _ < /proc/loadavg 2>/dev/null; then
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
[ ! -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"
cat <<EOF
@ -135,6 +189,8 @@ pivilion_print_motd() {
@ @ . #@@@# . @ @ Running Processes..: $processes
@ @@@, # ,@@@ @ Manual / more info.: pivilion.net
@@, @ ,@@ Onion Address......: $torname
@@@@@#@@@@@@@#@@@@@ Running in $mode mode
@@@@@#@@@@@@@#@@@@@ Desired / active....: $desired / $active
Backend / health...: $backend / $health
IP addresses.......: $addresses
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
set -euo pipefail
set -Eeuo pipefail
source /usr/local/lib/pivilion/common.sh
pivilion_require_root
pivilion_load_config
pivilion_load_config || exit "$PIVILION_E_CONFIG"
MODE=${1:-$(pivilion_current_mode)}
case "$MODE" in hotspot|onion) ;; *) echo "Unknown Pivilion mode: $MODE" >&2; exit 2 ;; esac
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
DNSMASQ_CONF=/etc/dnsmasq.d/pivilion-runtime.conf
HOSTAPD_CONF=$RUN_DIR/hostapd.conf
NFT_CONF=$RUN_DIR/pivilion.nft
NM_HOTSPOT=pivilion-hotspot
CAPTIVE_TEMPLATE=$PIVILION_DIR/config/html/.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() {
local tmp
[ -r "$CAPTIVE_TEMPLATE" ] || {
echo "Captive redirect template is missing: $CAPTIVE_TEMPLATE" >&2
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"
[ -r "$CAPTIVE_TEMPLATE" ] || { echo "Captive redirect template is missing: $CAPTIVE_TEMPLATE" >&2; return 1; }
[ -d "$PIVILION_WEBROOT/gen" ] || { echo "Gallery webroot is missing: $PIVILION_WEBROOT/gen" >&2; return 1; }
atomic_install "$CAPTIVE_TEMPLATE" "$CAPTIVE_LIVE" root www-data 0644
}
diagnostic_result() {
local label=$1
shift
if "$@"; then
printf ' [ok] %s\n' "$label"
return 0
fi
printf ' [fail] %s\n' "$label" >&2
return 1
validate_ap_support() {
local phy
command -v iw >/dev/null 2>&1 || return 1
phy=$(iw dev "$IFACE" info 2>/dev/null | awk '$1 == "wiphy" {print "phy" $2; exit}')
[ -n "$phy" ] && iw phy "$phy" info 2>/dev/null | grep -Eq '^[[:space:]]*\* AP$'
}
probe_redirect() {
local host=$1 path=$2 headers
headers=$(curl --silent --show-error --max-time 5 --dump-header - --output /dev/null \
--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"
cleanup_hotspot() {
systemctl stop pivilion-dnsmasq.service pivilion-hostapd.service 2>/dev/null || true
rm -f "$DNSMASQ_CONF" "$HOSTAPD_CONF"
nft delete table inet pivilion 2>/dev/null || true
if pivilion_uses_networkmanager; then
nmcli connection down "$NM_HOTSPOT" 2>/dev/null || true
nmcli connection delete "$NM_HOTSPOT" 2>/dev/null || true
else
systemctl stop hostapd 2>/dev/null || true
ip address flush dev "$IFACE" 2>/dev/null || true
if command -v nmcli >/dev/null 2>&1; then
nmcli connection down "$NM_HOTSPOT" >/dev/null 2>&1 || true
nmcli connection delete "$NM_HOTSPOT" >/dev/null 2>&1 || true
fi
if ip -4 address show dev "$IFACE" | grep -q '10\.1\.1\.1/24'; then
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
}
write_dnsmasq_config() {
install -d -m 0755 /etc/dnsmasq.d
cat > "$DNSMASQ_CONF" <<EOF
check_dns_conflict() {
local listeners
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
bind-dynamic
listen-address=10.1.1.1
@ -107,11 +100,25 @@ dhcp-option=option:router,10.1.1.1
dhcp-authoritative
address=/#/10.1.1.1
EOF
}
dnsmasq --test --conf-file="$dns_tmp"
atomic_install "$dns_tmp" "$DNSMASQ_CONF"
rm -f "$dns_tmp"
start_firewall() {
install -d -m 0755 "$RUN_DIR"
cat > "$RUN_DIR/pivilion.nft" <<EOF
hostapd_tmp=$(mktemp "$RUN_DIR/hostapd.XXXXXX")
cat > "$hostapd_tmp" <<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 {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
@ -121,46 +128,24 @@ table inet pivilion {
}
}
EOF
nft delete table inet pivilion 2>/dev/null || true
nft -f "$RUN_DIR/pivilion.nft"
nft -c -f "$nft_tmp"
atomic_install "$nft_tmp" "$NFT_CONF"
rm -f "$nft_tmp"
}
start_hotspot() {
stop_hotspot
install_captive_redirect
rfkill unblock wifi 2>/dev/null || true
systemctl start apache2
if pivilion_uses_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
nmcli connection up "$NM_HOTSPOT"
else
systemctl stop "wpa_supplicant@$IFACE.service" 2>/dev/null || true
command -v dhcpcd >/dev/null 2>&1 && dhcpcd -k "$IFACE" 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
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)."
apply_firewall() {
nft delete table inet pivilion 2>/dev/null || true
nft -f "$NFT_CONF"
}
wait_for_default_route() {
local i
for i in $(seq 1 30); do ip route show default | grep -q '^default' && return 0; sleep 1; done
return 1
}
restart_tor() {
if systemctl cat tor@default.service >/dev/null 2>&1; then systemctl restart tor@default.service; else systemctl restart tor.service; fi
}
update_torname() {
@ -168,45 +153,81 @@ update_torname() {
for i in $(seq 1 30); do
if [ -s "$source" ]; then
hostname=$(tr -d '\r\n' < "$source")
if [[ ! "$hostname" =~ ^[a-z2-7]{56}\.onion$ ]]; then
echo "Tor produced an invalid v3 onion hostname." >&2
return 1
fi
[[ "$hostname" =~ ^[a-z2-7]{56}\.onion$ ]] || return 1
tmp=$(mktemp "$PIVILION_DIR/.torname.XXXXXX")
printf '%s\n' "$hostname" > "$tmp"
chown "$PIVILION_USER:$PIVILION_GROUP" "$tmp"
chmod 0644 "$tmp"
mv -f "$tmp" "$PIVILION_DIR/torname"
chown "$PIVILION_USER:$PIVILION_GROUP" "$tmp"; chmod 0644 "$tmp"; mv -f "$tmp" "$PIVILION_DIR/torname"
return 0
fi
sleep 1
done
echo "Tor started, but no onion hostname was generated within 30 seconds." >&2
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() {
stop_hotspot
if pivilion_uses_networkmanager; then
nmcli connection up pivilion-onion 2>/dev/null || echo "No saved Pivilion Wi-Fi connection; run pikey." >&2
local failure=""
pivilion_write_state none "$BACKEND" activating none
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
systemctl restart dhcpcd 2>/dev/null || true
systemctl restart "wpa_supplicant@$IFACE.service" 2>/dev/null || systemctl restart wpa_supplicant 2>/dev/null || true
fi
rm -f "$CAPTIVE_LIVE"
wait_for_default_route || failure="${failure:+$failure; }No upstream default route after 30 seconds"
systemctl start apache2
if systemctl cat tor@default.service >/dev/null 2>&1; then
systemctl restart tor@default.service
restart_tor
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
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
update_torname || true
echo "Pivilion onion mode is active."
echo "Pivilion onion mode activation completed."
}
case "$MODE" in
hotspot) start_hotspot ;;
onion) start_onion ;;
diagnose) hotspot_diagnostics ;;
*) echo "Unknown Pivilion mode: $MODE" >&2; exit 2 ;;
esac
case "$MODE" in hotspot) start_hotspot ;; onion) start_onion ;; esac