#!/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