Fix captive portal detection and restore MOTD

This commit is contained in:
v3d 2026-06-29 17:29:00 +02:00
parent 3b7a16564b
commit 34b4c4b036
9 changed files with 153 additions and 59 deletions

View file

@ -42,3 +42,7 @@ Use `pikey` to save upstream Wi-Fi credentials, then `onion` and reboot to host
through Tor. Use `hotspot` and reboot to run the local captive gallery at through Tor. Use `hotspot` and reboot to run the local captive gallery at
`http://10.1.1.1/`. HTTPS interception is intentionally not attempted because a `http://10.1.1.1/`. HTTPS interception is intentionally not attempted because a
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
`pivilion-diagnose` on the Pi to verify DHCP/DNS, Apache rewrite handling,
nftables, and the common Android, Apple, and Microsoft HTTP probes.

View file

@ -0,0 +1,12 @@
# Show the Pivilion status banner for the configured owner on interactive Bash
# login shells. Other users and non-interactive sessions remain unchanged.
[ -n "${BASH_VERSION:-}" ] || return 0
case $- in *i*) ;; *) return 0 ;; esac
[ -r /usr/local/lib/pivilion/common.sh ] || return 0
[ "${PIVILION_MOTD_SHOWN:-0}" != 1 ] || return 0
. /usr/local/lib/pivilion/common.sh
pivilion_load_config >/dev/null 2>&1 || return 0
[ "$(id -un)" = "$PIVILION_USER" ] || return 0
pivilion_print_motd
export PIVILION_MOTD_SHOWN=1

View file

@ -112,27 +112,13 @@ if ! shopt -oq posix; then
fi fi
fi fi
# info on login # Pivilion login banner. The modern installer deploys the same hook through
# /etc/profile.d; this fallback keeps the legacy filesystem overlay usable.
let upSeconds="$(/usr/bin/cut -d. -f1 /proc/uptime)" if [ "${PIVILION_MOTD_SHOWN:-0}" != 1 ] && [ -r /usr/local/lib/pivilion/common.sh ]; then
let secs=$((${upSeconds}%60)) . /usr/local/lib/pivilion/common.sh
let mins=$((${upSeconds}/60%60)) if pivilion_load_config >/dev/null 2>&1 && [ "$(id -un)" = "$PIVILION_USER" ]; then
let hours=$((${upSeconds}/3600%24)) pivilion_print_motd
let days=$((${upSeconds}/86400)) export PIVILION_MOTD_SHOWN=1
UPTIME=`printf "%d days, %02dh%02dm%02ds" "$days" "$hours" "$mins" "$secs"` fi
fi
# get the load averages
read one five fifteen rest < /proc/loadavg
echo "
@@@@@@@@@@@@@@@@@@@ `date +"%A, %e %B %Y, %r"`
@@@@@@, &@& *@@@@@@ `uname -srnmo`
@@@@@@@. .@@@@@@@ Uptime.............: ${UPTIME}
@@@@ .@@@. @@@@ Memory.............: `cat /proc/meminfo | grep MemFree | awk {'print $2'}`kB (Free) / `cat /proc/meminfo | grep MemTotal | awk {'print $2'}`kB (Total)
@ &@@@@.@@@@# @ Load Averages......: ${one}, ${five}, ${fifteen} (1, 5, 15 min)
@ @ . #@@@# . @ @ Running Processes..: `ps ax | wc -l | tr -d " "`
@ @@@, # ,@@@ @ Manual / more info.: pivilion.net
@@, @ ,@@ Onion Address......: `cat /home/pi/pivilion/torname`
@@@@@#@@@@@@@#@@@@@ Run "pivilion" for inital setup! / Running in `cat /home/pi/pivilion/mode` mode
"
export LC_ALL=C export LC_ALL=C

View file

@ -1,17 +1,11 @@
RewriteEngine on RewriteEngine on
RewriteCond %{REQUEST_URI} !(\/index\.php)$
RewriteCond %{REQUEST_URI} !(\/pi-logo_128\.png)$ # Captive-network probes use otherwise unknown paths such as /generate_204,
RewriteCond %{REQUEST_URI} !(\/content\/.*)$ # /hotspot-detect.html, and /connecttest.txt. Redirect those temporarily so
RewriteCond %{REQUEST_URI} !(\/index\.php)$ # clients open their login assistant, but let the gallery serve its own PHP and
RewriteCond %{REQUEST_URI} !(\/pi-logo_128\.png)$ # asset paths without a loop.
RewriteCond %{REQUEST_URI} !(\/content\/.*)$ RewriteCond %{REQUEST_URI} !\.php$ [NC]
RewriteCond %{REQUEST_URI} !(\/css\/.*)$ RewriteCond %{REQUEST_URI} !\.head(?:\.|$) [NC]
RewriteCond %{REQUEST_URI} !(\/doc\/.*)$ RewriteCond %{REQUEST_URI} !^/(?:content|css|doc|docker|img|images|js|modules|tests|upload)(?:/|$) [NC]
RewriteCond %{REQUEST_URI} !(\/docker\/.*)$ RewriteCond %{REQUEST_URI} !^/pi-logo_128\.png$ [NC]
RewriteCond %{REQUEST_URI} !(\/img\/.*)$ RewriteRule ^ http://10.1.1.1/index.php [L,R=302,NE]
RewriteCond %{REQUEST_URI} !(\/js\/.*)$
RewriteCond %{REQUEST_URI} !(\/modules\/.*)$
RewriteCond %{REQUEST_URI} !(\/tests\/.*)$
RewriteCond %{REQUEST_URI} !(\/*.php)$
RewriteCond %{REQUEST_URI} !(\/*.head.*)$
RewriteRule ^(.*)$ http://10.1.1.1/index.php [L,R=301]

View file

@ -1,15 +1,16 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
SOURCE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
source "$SOURCE_DIR/usr/local/lib/pivilion/common.sh"
pivilion_load_config
pivilion_print_motd "Pivilion installer"
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "Run this installer with sudo: sudo ./install.sh" >&2 echo "Run this installer with sudo: sudo ./install.sh" >&2
exit 1 exit 1
fi fi
SOURCE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
source "$SOURCE_DIR/usr/local/lib/pivilion/common.sh"
pivilion_load_config
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
is_networkmanager_system() { is_networkmanager_system() {
@ -20,7 +21,7 @@ is_networkmanager_system() {
} }
install_packages() { install_packages() {
local packages=(apache2 libapache2-mod-php php php-cli tor dnsmasq nftables iproute2 iw rfkill wget unzip wpasupplicant) local packages=(apache2 libapache2-mod-php php php-cli tor dnsmasq dnsutils nftables iproute2 iw rfkill curl wget unzip wpasupplicant)
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
@ -37,13 +38,14 @@ install_packages() {
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 install -d -m 0755 /usr/local/lib/pivilion /usr/local/bin /etc/systemd/system /etc/tor/torrc.d /etc/profile.d
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" /usr/local/lib/pivilion/mode.sh
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/pivilion-mode.service" /etc/systemd/system/pivilion-mode.service
install -m 0644 "$SOURCE_DIR/etc/systemd/system/pivilion-dnsmasq.service" /etc/systemd/system/pivilion-dnsmasq.service 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
cat > /etc/pivilion.defaults <<EOF cat > /etc/pivilion.defaults <<EOF
PIVILION_USER=$PIVILION_USER PIVILION_USER=$PIVILION_USER
@ -122,7 +124,7 @@ validate_install() {
dnsmasq --test dnsmasq --test
bash -n /usr/local/lib/pivilion/common.sh /usr/local/lib/pivilion/mode.sh /usr/local/bin/pivilion \ bash -n /usr/local/lib/pivilion/common.sh /usr/local/lib/pivilion/mode.sh /usr/local/bin/pivilion \
/usr/local/bin/hotspot /usr/local/bin/onion /usr/local/bin/pikey /usr/local/bin/generator \ /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 /usr/local/bin/static /usr/local/bin/hotglue /usr/local/bin/htaccess /usr/local/bin/pivilion-diagnose
} }
install_packages install_packages

View file

@ -3,21 +3,11 @@ set -euo pipefail
source /usr/local/lib/pivilion/common.sh source /usr/local/lib/pivilion/common.sh
pivilion_load_config pivilion_load_config
mode=$(pivilion_current_mode) pivilion_print_motd
torname="not generated yet"
[ ! -s "$PIVILION_DIR/torname" ] || torname=$(tr -d '\r\n' < "$PIVILION_DIR/torname")
uptime_text=$(uptime -p 2>/dev/null || true)
cat <<EOF cat <<EOF
Welcome to Pivilion! Web root: $PIVILION_WEBROOT
Host...............: $(uname -n)
System.............: $(uname -srmo)
Uptime.............: ${uptime_text:-unknown}
Mode after reboot..: $mode
Onion address......: $torname
Web root...........: $PIVILION_WEBROOT
Commands: Commands:
pivilion show this status pivilion show this status
@ -28,4 +18,5 @@ 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
EOF EOF

View file

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

View file

@ -111,3 +111,30 @@ pivilion_current_mode() {
pivilion_require_root() { 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_print_motd() {
local heading=${1:-Welcome to Pivilion!}
local uptime_text memory_free memory_total load_one load_five load_fifteen processes torname mode
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)
torname=not-generated-yet
[ ! -s "$PIVILION_DIR/torname" ] || torname=$(tr -d '\r\n' < "$PIVILION_DIR/torname")
mode=$(pivilion_current_mode)
printf '\n%s\n' "$heading"
cat <<EOF
@@@@@@@@@@@@@@@@@@@ $(date '+%A, %e %B %Y, %r')
@@@@@@, &@& *@@@@@@ $(uname -srnmo)
@@@@@@@. .@@@@@@@ Uptime.............: $uptime_text
@@@@ .@@@. @@@@ Memory.............: ${memory_free:-unknown}kB (Available) / ${memory_total:-unknown}kB (Total)
@ &@@@@.@@@@# @ Load Averages......: $load_one, $load_five, $load_fifteen (1, 5, 15 min)
@ @ . #@@@# . @ @ Running Processes..: $processes
@ @@@, # ,@@@ @ Manual / more info.: pivilion.net
@@, @ ,@@ Onion Address......: $torname
@@@@@#@@@@@@@#@@@@@ Running in $mode mode
EOF
}

View file

@ -11,6 +11,77 @@ IFACE=$(pivilion_wifi_interface)
RUN_DIR=/run/pivilion RUN_DIR=/run/pivilion
DNSMASQ_CONF=/etc/dnsmasq.d/pivilion-runtime.conf DNSMASQ_CONF=/etc/dnsmasq.d/pivilion-runtime.conf
NM_HOTSPOT=pivilion-hotspot NM_HOTSPOT=pivilion-hotspot
CAPTIVE_TEMPLATE=$PIVILION_DIR/config/html/.htaccess
CAPTIVE_LIVE=$PIVILION_WEBROOT/gen/.htaccess
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"
}
diagnostic_result() {
local label=$1
shift
if "$@"; then
printf ' [ok] %s\n' "$label"
return 0
fi
printf ' [fail] %s\n' "$label" >&2
return 1
}
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() { stop_hotspot() {
systemctl stop pivilion-dnsmasq.service 2>/dev/null || true systemctl stop pivilion-dnsmasq.service 2>/dev/null || true
@ -56,6 +127,7 @@ EOF
start_hotspot() { start_hotspot() {
stop_hotspot stop_hotspot
install_captive_redirect
rfkill unblock wifi 2>/dev/null || true rfkill unblock wifi 2>/dev/null || true
systemctl start apache2 systemctl start apache2
if pivilion_uses_networkmanager; then if pivilion_uses_networkmanager; then
@ -87,6 +159,7 @@ EOF
dnsmasq --test --conf-file="$DNSMASQ_CONF" dnsmasq --test --conf-file="$DNSMASQ_CONF"
systemctl restart pivilion-dnsmasq.service systemctl restart pivilion-dnsmasq.service
start_firewall start_firewall
hotspot_diagnostics
echo "Pivilion hotspot '$PIVILION_HOTSPOT_SSID' is active on $IFACE (10.1.1.1)." echo "Pivilion hotspot '$PIVILION_HOTSPOT_SSID' is active on $IFACE (10.1.1.1)."
} }
@ -120,7 +193,7 @@ start_onion() {
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 "$PIVILION_WEBROOT/gen/.htaccess" rm -f "$CAPTIVE_LIVE"
systemctl start apache2 systemctl start apache2
if systemctl cat tor@default.service >/dev/null 2>&1; then if systemctl cat tor@default.service >/dev/null 2>&1; then
systemctl restart tor@default.service systemctl restart tor@default.service
@ -134,5 +207,6 @@ start_onion() {
case "$MODE" in case "$MODE" in
hotspot) start_hotspot ;; hotspot) start_hotspot ;;
onion) start_onion ;; onion) start_onion ;;
diagnose) hotspot_diagnostics ;;
*) echo "Unknown Pivilion mode: $MODE" >&2; exit 2 ;; *) echo "Unknown Pivilion mode: $MODE" >&2; exit 2 ;;
esac esac