Improve VPN status reporting and recovery

This commit is contained in:
2026-10-13 06:53:52 +03:00
parent fdb15ae462
commit 7a716b4c9c
188 changed files with 6953 additions and 1911 deletions
+379 -19
View File
@@ -1,23 +1,383 @@
#!/bin/sh
set -eu
# LEMANA_DNS_PROTOCOL=2
# LEMANA_SESSION_PROTOCOL=1
# LEMANA_RECONNECT_PROTOCOL=1
set -efu
PATH=/usr/bin:/bin:/usr/sbin:/sbin
LC_ALL=C
export PATH LC_ALL
umask 077
SERVICES="${LEMANA_VPN_DNS_SERVICES:-Wi-Fi
USB 10/100/1000 LAN
Ethernet}"
fail() { printf '%s\n' "$1" >&2; exit 1; }
case "${1:---recover}:$#" in --recover:0|--recover:1|--vpnc:1|--vpnc:2|--vpnc:3|--begin:3|--stop:2|--restart:3|--recover-session:2) ;; *) fail 'Unsupported helper arguments.' ;; esac
[ "$(/usr/bin/id -u)" = 0 ] || fail 'DNS helper requires root.'
STATE=/var/db/lemana-vpn-dns
[ ! -L "$STATE" ] || fail 'Unsafe DNS state directory.'
/bin/mkdir -p "$STATE"
[ "$(/usr/bin/stat -f '%u:%Lp' "$STATE")" = '0:700' ] || fail 'Unsafe DNS state permissions.'
[ ! -L "$STATE/lock" ] || fail 'Unsafe DNS lock.'
# A kernel lock releases after a crash; -k keeps all callers on the same inode.
exec /usr/bin/lockf -k -t 15 "$STATE/lock" /bin/sh -s -- "${1:---recover}" "${2:-}" "${3:-}" <<'LOCKED'
set -efu
PATH=/usr/bin:/bin:/usr/sbin:/sbin
LC_ALL=C
export PATH LC_ALL
umask 077
STATE=/var/db/lemana-vpn-dns
JOURNAL="$STATE/journal"
NEXT="$STATE/next"
SESSION="$STATE/session"
SESSION_NEXT="$STATE/session-next"
TAB="$(printf '\t')"
mode=$1
token=$2
caller=$3
exclude=' '
changed=0
fail() { printf '%s\n' "$1" >&2; exit 1; }
trap '/bin/rm -f "$NEXT" "$SESSION_NEXT"' EXIT
printf '%s\n' "$SERVICES" | while IFS= read -r service; do
[ -n "$service" ] || continue
dns="$(/usr/sbin/networksetup -getdnsservers "$service" 2>/dev/null || true)"
printf '%s\n' "$dns" | grep -q '^10\.' || continue
/usr/sbin/networksetup -setdnsservers "$service" empty >/dev/null 2>&1 || {
printf 'Failed to reset DNS for %s\n' "$service" >&2
exit 1
}
printf 'DNS reset for %s\n' "$service"
done
/usr/bin/dscacheutil -flushcache >/dev/null 2>&1 || true
/usr/bin/killall -HUP mDNSResponder >/dev/null 2>&1 || true
# Never evaluate journal content or use it as a command/path.
normalize_dns() {
/usr/bin/awk '
function valid(s, n,a,i) {
if (s ~ /:/) return s ~ /^[0-9a-fA-F:]+(%[a-zA-Z0-9_.-]+)?$/
n=split(s,a,"."); if (n!=4) return 0
for(i=1;i<=4;i++) if(a[i]!~/^[0-9]+$/ || a[i]>255) return 0
return 1
}
{ for(i=1;i<=NF;i++) { if(!valid($i)) exit 1; out=out (out?" ":"") $i } }
END { if(out!="") print out }
'
}
read_dns() {
value=$(/usr/sbin/networksetup -getdnsservers "$1" 2>/dev/null) || return 1
case "$value" in "There aren't any DNS Servers set on "*) printf 'empty\n' ;; *)
[ -n "$value" ] || return 1
printf '%s\n' "$value" | normalize_dns
esac
}
valid_dns() {
[ "$1" = empty ] || [ "$(printf '%s\n' "$1" | normalize_dns)" = "$1" ]
}
location() {
/usr/sbin/networksetup -getcurrentlocation 2>/dev/null
}
single_line() {
case "$1" in ''|*"$TAB"*|*'
'*) return 1 ;; esac
}
processes() {
table=$(/bin/ps -axww -o pid=,ppid=,lstart=,command= 2>/dev/null) || fail 'Cannot verify running VPN processes.'
printf '%s\n' "$table" | /usr/bin/awk 'NF<8 || $1!~/^[0-9]+$/ || $2!~/^[0-9]+$/ {exit 1} END {if(NR==0) exit 1}' || fail 'Cannot verify running VPN processes.'
}
process_field() {
printf '%s\n' "$table" | /usr/bin/awk -v pid="$1" -v field="$2" '$1==pid {if(field=="start") print $3,$4,$5,$6,$7; else print $2; exit}'
}
active() {
processes
if printf '%s\n' "$table" | /usr/bin/awk -v skip="$exclude" '
{ parent[$1]=$2; command=""; for(i=8;i<=NF;i++) command=command " " $i }
command ~ /(^|[ \/])openconnect(-lite)?([[:space:]]|$)/ {engine[$1]=1}
command ~ /(^|[ \/])vpn-lemanapro[.]sh([[:space:]]|$)/ && command !~ / --(status|help|repair-dns|patch-only|configure-login|configure-keychain|import-yandex-key)([[:space:]]|$)| --(set-auth|stop-session|restart-session)=/ {cli[$1]=1}
END {
for(pid in parent) {
if(index(skip," " pid " ")) continue
# Bash pipeline/progress children retain the parent CLI argv.
if(engine[pid] || (cli[pid] && !cli[parent[pid]])) found=1
}
exit !found
}'; then return 0
else
result_code=$?
[ "$result_code" = 1 ] || fail 'Cannot verify running VPN processes.'
return 1
fi
}
process_uid() {
/bin/ps -p "$1" -o uid= | /usr/bin/awk 'NF==1 && $1~/^[0-9]+$/ {print $1}'
}
valid_token() {
[ "${#1}" = 36 ] || return 1
case "$1" in *[!a-f0-9-]*) return 1 ;; esac
}
descendant() {
ancestor=$1; depth=0
while [ "$ancestor" -gt 1 ] && [ "$depth" -lt 64 ]; do
[ "$ancestor" != "$2" ] || return 0
ancestor=$(process_field "$ancestor" parent)
case "$ancestor" in ''|*[!0-9]*) return 1 ;; esac
depth=$((depth + 1))
done
return 1
}
load_session() {
[ ! -L "$SESSION" ] && [ -f "$SESSION" ] || fail 'Missing or unsafe VPN session record.'
[ "$(/usr/bin/stat -f '%u:%Lp' "$SESSION")" = '0:600' ] || fail 'Unsafe VPN session permissions.'
IFS="$TAB" read -r sv sb st su sc ss se ses stopped engine_attempt < "$SESSION" || fail 'Invalid VPN session record.'
case "$sv" in 1) engine_attempt=- ;; 2) [ "$engine_attempt" = - ] || valid_token "$engine_attempt" || fail 'Invalid engine attempt.' ;; *) fail 'Unsupported VPN session record.' ;; esac
valid_token "$st" && single_line "$sb" && single_line "$ss" && single_line "$ses" || fail 'Invalid VPN session identity.'
case "$su:$sc:$se:$stopped" in *[!0-9:]*|:*|*::*|*:) fail 'Invalid VPN session identity.' ;; esac
[ "$sc" -gt 1 ] && [ "$stopped" -le 1 ] || fail 'Invalid VPN session identity.'
/usr/bin/awk -F '\t' '($1==1 && NF!=9) || ($1==2 && NF!=10) {exit 1} END {if(NR!=1) exit 1}' "$SESSION" || fail 'Invalid VPN session record.'
}
save_session() {
[ ! -L "$SESSION_NEXT" ] || fail 'Unsafe VPN session temporary file.'
printf '2\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$sb" "$st" "$su" "$sc" "$ss" "$se" "$ses" "$stopped" "$engine_attempt" > "$SESSION_NEXT"
/bin/mv -f "$SESSION_NEXT" "$SESSION"
}
session_operation() {
valid_token "$token" || fail 'Invalid session token.'
case "${SUDO_UID:-}" in ''|*[!0-9]*) fail 'Missing requesting user.' ;; esac
processes
boot_now=$(/usr/sbin/sysctl -n kern.bootsessionuuid) || fail 'Cannot verify boot identity.'
if [ "$mode" = --begin ]; then
case "$caller" in ''|*[!0-9]*) fail 'Invalid session caller.' ;; esac
[ "$caller" -gt 1 ] && descendant "$$" "$caller" || fail 'Session caller is not an ancestor.'
[ "$(process_uid "$caller")" = "$SUDO_UID" ] || fail 'Session caller belongs to another user.'
if [ -e "$SESSION" ] || [ -L "$SESSION" ]; then
load_session
if [ "$sb" = "$boot_now" ] && { [ "$(process_field "$sc" start)" = "$ss" ] || [ "$(process_field "$se" start)" = "$ses" ]; }; then
fail 'A registered VPN session is still running.'
fi
fi
sb=$boot_now; st=$token; su=$SUDO_UID; sc=$caller; ss=$(process_field "$caller" start); se=0; ses=-; stopped=0; engine_attempt=-
single_line "$ss" || fail 'Cannot verify session start.'
save_session
printf 'ready\n'
return
fi
load_session
[ "$st:$su:$sb" = "$token:$SUDO_UID:$boot_now" ] || fail 'VPN session identity does not match.'
if [ "$mode" = --recover-session ]; then
[ "$(process_field "$sc" start)" = "$ss" ] && descendant "$$" "$sc" || fail 'Recovery caller is not the registered CLI.'
# Only the idle CLI is exempt; every VPN/SSO engine still blocks recovery.
exclude=" $sc "
recover
if [ "$stopped" = 1 ] && [ "$result" != active ]; then result=cancelled; fi
printf '%s\n' "$result"
return
fi
if [ "$mode" = --restart ]; then
valid_token "$caller" || fail 'Invalid expected engine attempt.'
if [ "$stopped" = 1 ]; then printf 'cancelled\n'; return; fi
# A delayed health request must not stop a replacement tunnel.
if [ "$engine_attempt" != "$caller" ]; then printf 'stale\n'; return; fi
fi
# Commit cancellation before signaling: a late pre-init hook must not start a tunnel.
if [ "$mode" = --stop ]; then stopped=1; save_session; fi
if [ "$(process_field "$se" start)" = "$ses" ]; then
[ "$(process_uid "$se")" = 0 ] || fail 'Recorded VPN owner changed.'
processes
if [ "$(process_field "$se" start)" = "$ses" ]; then
printf '%s\n' "$table" | /usr/bin/awk -v pid="$se" '$1==pid {n=split($8,a,"/"); if(a[n]=="openconnect") ok=1} END {exit !ok}' || fail 'Recorded VPN executable changed.'
/bin/kill -TERM "$se" || fail 'Could not stop the recorded VPN process.'
fi
elif [ "$mode" = --stop ] && [ "$(process_field "$sc" start)" = "$ss" ] && [ "$(process_uid "$sc")" = "$su" ]; then
# Before the tunnel exists, terminate only this user's authentication children.
candidates=$(printf '%s\n' "$table" | /usr/bin/awk '{print $1}')
targets=''
for candidate in $candidates; do
if descendant "$candidate" "$sc" && [ "$(process_uid "$candidate")" = "$su" ]; then
targets="$targets$candidate$TAB$(process_field "$candidate" start)
"
fi
done
printf '%s' "$targets" | while IFS="$TAB" read -r candidate started; do
processes
[ "$(process_field "$candidate" start)" = "$started" ] || continue
[ "$(process_uid "$candidate")" = "$su" ] || fail 'Session child owner changed.'
/bin/kill -TERM "$candidate" || fail 'Could not stop the authentication process.'
done
fi
# Do not wait under the DNS lock: the engine's disconnect hook needs it.
printf 'stopping\n'
}
owner() {
case "${VPNPID:-}" in ''|*[!0-9]*) fail 'Missing VPN process identity.' ;; esac
[ "$VPNPID" -gt 1 ] || fail 'Invalid VPN process identity.'
processes
printf '%s\n' "$table" | /usr/bin/awk -v pid="$VPNPID" '$1==pid {n=split($8,a,"/"); if(a[n]=="openconnect") ok=1} END {exit !ok}' || fail 'Cannot verify VPN process owner.'
current_start=$(process_field "$VPNPID" start)
current_boot=$(/usr/sbin/sysctl -n kern.bootsessionuuid 2>/dev/null) || fail 'Cannot verify boot identity.'
single_line "$current_boot" || fail 'Cannot verify boot identity.'
# Verify the supplied owner is really an ancestor of this hook.
ancestor=$$; depth=0; found=0
while [ "$ancestor" -gt 1 ] && [ "$depth" -lt 64 ]; do
[ "$ancestor" != "$VPNPID" ] || found=1
ancestor=$(process_field "$ancestor" parent)
case "$ancestor" in ''|*[!0-9]*) fail 'Cannot verify VPN process ancestry.' ;; esac
depth=$((depth + 1))
done
[ "$found" = 1 ] || fail 'VPN owner is not the hook parent.'
exclude=' '; ancestor=$VPNPID; depth=0
while [ "$ancestor" -gt 1 ] && [ "$depth" -lt 64 ]; do
exclude="$exclude$ancestor "
ancestor=$(process_field "$ancestor" parent)
case "$ancestor" in ''|*[!0-9]*) fail 'Cannot verify VPN process ancestry.' ;; esac
depth=$((depth + 1))
done
[ "$ancestor" -le 1 ] || fail 'Cannot verify VPN process ancestry.'
}
load_journal() {
[ ! -L "$JOURNAL" ] && [ -f "$JOURNAL" ] || fail 'Unsafe DNS journal.'
[ "$(/usr/bin/stat -f '%u:%Lp' "$JOURNAL")" = '0:600' ] || fail 'Unsafe DNS journal permissions.'
IFS="$TAB" read -r version boot pid start place phase expected < "$JOURNAL" || fail 'Invalid DNS journal.'
[ "$version" = 2 ] || fail 'Unsupported DNS journal.'
single_line "$boot" && single_line "$start" && single_line "$place" || fail 'Invalid DNS journal identity.'
case "$pid" in ''|*[!0-9]*) fail 'Invalid DNS journal identity.' ;; esac
case "$phase" in connected|disconnecting) ;; *) fail 'Invalid DNS journal phase.' ;; esac
old_ifs=$IFS; IFS=';'
for list in $expected; do valid_dns "$list" && [ "$list" != empty ] || fail 'Invalid DNS journal addresses.'; done
IFS=$old_ifs
[ -n "$expected" ] || fail 'Invalid DNS journal addresses.'
/usr/bin/awk -F '\t' 'NR==1 {if(NF!=7) exit 1; next} NF!=4 || $1!="service" || $2=="" || seen[$2]++ {exit 1} END {if(NR<2) exit 1}' "$JOURNAL" || fail 'Invalid DNS journal records.'
while IFS="$TAB" read -r tag service before wanted; do
[ "$tag" = service ] || continue
valid_dns "$before" && valid_dns "$wanted" || fail 'Invalid DNS journal addresses.'
done < "$JOURNAL"
}
write_header() {
printf '2\t%s\t%s\t%s\t%s\t%s\t%s\n' "$boot" "$pid" "$start" "$place" "$phase" "$expected"
}
commit() {
/bin/mv -f "$NEXT" "$JOURNAL"
/bin/sync
}
expected_dns() {
case ";$expected;" in *";$1;"*) return 0 ;; *) return 1 ;; esac
}
set_dns() {
# The parser only permits address tokens; glob expansion is disabled.
old_ifs=$IFS; IFS=' '; set -- "$1" $2; IFS=$old_ifs
/usr/sbin/networksetup -setdnsservers "$@" >/dev/null 2>&1 || fail 'Could not restore recorded DNS.'
changed=1
}
recover() {
if active; then result=active; return; fi
if [ ! -e "$JOURNAL" ] && [ ! -L "$JOURNAL" ]; then result=no_snapshot; return; fi
load_journal
current_place=$(location) || fail 'Cannot read network location.'
[ "$current_place" = "$place" ] || fail 'Saved DNS belongs to another network location.'
while IFS="$TAB" read -r tag service before wanted; do
[ "$tag" = service ] || continue
current=$(read_dns "$service") || fail 'Cannot read recorded network service DNS.'
target=$before
[ "$phase" != disconnecting ] || target=$wanted
[ "$current" != "$target" ] || continue
if expected_dns "$current" || { [ "$phase" = disconnecting ] && [ "$current" = empty ]; }; then
if active; then result=active; return; fi
[ "$(location)" = "$place" ] || fail 'Network location changed during DNS recovery.'
set_dns "$service" "$target"
[ "$(read_dns "$service")" = "$target" ] || fail 'DNS restoration could not be verified.'
fi
done < "$JOURNAL"
/bin/rm -f "$JOURNAL"
if [ "$changed" = 1 ]; then
/usr/bin/dscacheutil -flushcache >/dev/null 2>&1 || true
/usr/bin/killall -HUP mDNSResponder >/dev/null 2>&1 || true
result=restored
else result=unchanged
fi
}
capture_services() {
services=$(/usr/sbin/networksetup -listallnetworkservices 2>/dev/null) || fail 'Cannot list network services.'
printf '%s\n' "$services" | /usr/bin/sed '1d' | while IFS= read -r service; do
case "$service" in ''|\**) continue ;; esac
single_line "$service" || fail 'Unsupported network service name.'
if SERVICE_NAME="$service" /usr/bin/awk -F '\t' '$1=="service" && $2==ENVIRON["SERVICE_NAME"] {found=1} END {exit !found}' "$NEXT"; then continue; fi
before=$(read_dns "$service") || fail 'Cannot capture original DNS.'
printf 'service\t%s\t%s\t%s\n' "$service" "$before" "$before" >> "$NEXT"
done
}
stock() {
if [ -x /opt/homebrew/etc/vpnc/vpnc-script ]; then
/bin/sh /opt/homebrew/etc/vpnc/vpnc-script
elif [ -x /usr/local/etc/vpnc/vpnc-script ]; then
/bin/sh /usr/local/etc/vpnc/vpnc-script
else fail 'The installed vpnc-script is missing.'
fi
}
case "$mode" in --begin|--stop|--restart|--recover-session) session_operation; exit 0 ;; esac
if [ "$mode" = --recover ]; then
recover
printf '%s\n' "$result"
exit 0
fi
owner
if [ -n "$token" ]; then
load_session
[ "$st:$sb" = "$token:$current_boot" ] || fail 'VPN hook belongs to another session.'
if [ "${reason:-}" != disconnect ]; then
[ "$stopped" = 0 ] || fail 'VPN session was cancelled.'
[ "$(process_field "$sc" start)" = "$ss" ] && descendant "$VPNPID" "$sc" || fail 'VPN engine is not a session child.'
[ "$(process_uid "$VPNPID")" = 0 ] || fail 'VPN engine requires root.'
if [ "$se" != 0 ] && [ "$se:$ses" != "$VPNPID:$current_start" ] && [ "$(process_field "$se" start)" = "$ses" ]; then
fail 'Another engine is still running in this session.'
fi
engine_attempt=${caller:--}
[ "$engine_attempt" = - ] || valid_token "$engine_attempt" || fail 'Invalid engine attempt.'
se=$VPNPID; ses=$current_start; save_session
else
[ "$se:$ses" = "$VPNPID:$current_start" ] || fail 'VPN disconnect belongs to another engine.'
fi
fi
case "${reason:-}" in
pre-init|attempt-reconnect) stock; exit $? ;;
connect|reconnect)
incoming=$(printf '%s\n' "${INTERNAL_IP4_DNS:-}" | normalize_dns) || fail 'Invalid VPN DNS addresses.'
if active; then fail 'Another VPN connection is active.'; fi
current_place=$(location) || fail 'Cannot read network location.'
single_line "$current_place" || fail 'Invalid network location.'
if [ -e "$JOURNAL" ] || [ -L "$JOURNAL" ]; then
load_journal
if [ "$boot:$pid:$start" != "$current_boot:$VPNPID:$current_start" ] || [ "$phase" != connected ]; then
recover
[ ! -e "$JOURNAL" ] || fail 'Previous DNS recovery is incomplete.'
else
[ "$place" = "$current_place" ] || fail 'Saved DNS belongs to another network location.'
fi
fi
if [ -z "$incoming" ]; then stock; exit $?; fi
if [ -e "$JOURNAL" ]; then
expected_dns "$incoming" || expected="$expected;$incoming"
write_header > "$NEXT"
/usr/bin/sed '1d' "$JOURNAL" >> "$NEXT"
else
boot=$current_boot; pid=$VPNPID; start=$current_start; place=$current_place; phase=connected; expected=$incoming
write_header > "$NEXT"
fi
capture_services
[ "$(/usr/bin/wc -l < "$NEXT")" -gt 1 ] || fail 'No enabled network services to record.'
commit
load_journal
stock || fail 'VPN network setup failed; DNS journal retained.'
;;
disconnect)
if [ ! -e "$JOURNAL" ] && [ ! -L "$JOURNAL" ]; then stock; exit $?; fi
load_journal
[ "$boot:$pid:$start" = "$current_boot:$VPNPID:$current_start" ] || fail 'DNS journal belongs to another VPN session.'
[ "$(location)" = "$place" ] || fail 'Saved DNS belongs to another network location.'
if active; then fail 'Another VPN connection is active.'; fi
previous_phase=$phase
phase=disconnecting
write_header > "$NEXT"
while IFS="$TAB" read -r tag service before wanted; do
[ "$tag" = service ] || continue
if [ "$previous_phase" = connected ]; then
current=$(read_dns "$service") || fail 'Cannot read recorded network service DNS.'
wanted=$current
if expected_dns "$current"; then wanted=$before; fi
fi
printf 'service\t%s\t%s\t%s\n' "$service" "$before" "$wanted" >> "$NEXT"
done < "$JOURNAL"
capture_services
commit
stock || fail 'VPN network cleanup failed; DNS journal retained.'
recover
[ "$result" != active ] || fail 'DNS recovery deferred while another VPN is active.'
;;
*) fail 'Unsupported VPN hook reason.' ;;
esac
LOCKED