384 lines
18 KiB
Bash
Executable File
384 lines
18 KiB
Bash
Executable File
#!/bin/sh
|
|
# 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
|
|
|
|
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
|
|
|
|
# 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
|