From 1482e5e3ceee25c152ba7428972c2676b3e245d3 Mon Sep 17 00:00:00 2001 From: Ivo Date: Sun, 23 Aug 2026 03:04:53 +0000 Subject: [PATCH] tests: add sigint-group regression test for teardown race (bug #1132360) --- tests/Makefile.am | 5 +- tests/scripts/vpnc-script-restore-tracking | 98 +++++++++ tests/sigint-group | 237 +++++++++++++++++++++ 3 files changed, 338 insertions(+), 2 deletions(-) create mode 100755 tests/scripts/vpnc-script-restore-tracking create mode 100755 tests/sigint-group diff --git a/tests/Makefile.am b/tests/Makefile.am index 4314a173..1610ee66 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -68,6 +68,7 @@ EXTRA_DIST = certs/ca.pem certs/ca-key.pem certs/user-cert.pem $(USER_KEYS) $(US configs/user-cert.prm configs/server-cert.prm \ softhsm2.conf.in softhsm ns.sh configs/test-dtls-psk.config \ scripts/vpnc-script scripts/vpnc-script-detect-disconnect \ + scripts/vpnc-script-restore-tracking \ suppressions.lsan fake-fortinet-server.py fake-f5-server.py fake-juniper-server.py \ fake-juniper-sso-server.py fake-tncc.py fake-gp-server.py fake-cisco-server.py \ fake-pulse-server.py @@ -75,9 +76,9 @@ EXTRA_DIST = certs/ca.pem certs/ca-key.pem certs/user-cert.pem $(USER_KEYS) $(US dist_check_SCRIPTS = autocompletion symbols TESTS = autocompletion symbols -dist_check_SCRIPTS += dtls-psk sigterm pulse-ping +dist_check_SCRIPTS += dtls-psk sigterm sigint-group pulse-ping if HAVE_NETNS -TESTS += dtls-psk sigterm pulse-ping +TESTS += dtls-psk sigterm sigint-group pulse-ping endif dist_check_SCRIPTS += ppp-over-tls ppp-over-tls-sync diff --git a/tests/scripts/vpnc-script-restore-tracking b/tests/scripts/vpnc-script-restore-tracking new file mode 100755 index 00000000..5cac42a7 --- /dev/null +++ b/tests/scripts/vpnc-script-restore-tracking @@ -0,0 +1,98 @@ +#!/bin/sh +# +# Copyright (C) 2026 Ivo +# +# This file is part of openconnect. +# +# openconnect is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# Fake vpnc-script for the sigint-group test. Do not use for real VPNs. +# +# On connect it emulates the essential vpnc-script setup (interface up, +# address, DNS) and takes a backup of /etc/resolv.conf. On disconnect it +# performs the restore in discrete, marker-recorded steps with a sleep in +# the middle that creates a deterministic window in which a group-delivered +# SIGINT (the user's second Ctrl-C) can hit the script mid-restore. +# +# The script is strict (set -e): any failed step aborts with non-zero +# status so openconnect reports the script error and the test fails +# loudly instead of passing on a half-done restore. +# +# If /etc/resolv.conf is not writable (read-only /etc on some systems), +# the DNS part is skipped explicitly: the script records resolv-skipped.tmp +# and the test reports it instead of silently dropping the assertion. +# +# Markers and state files are written to the test's working directory +# (the same convention as vpnc-script-detect-disconnect): +# marker-start script entered the disconnect path +# marker-route-del routes removed from $TUNDEV (or gone with it) +# marker-final full restore completed (resolv.conf back in place) +# saved-route.tmp routes captured from $TUNDEV at disconnect time +# resolv.bak.tmp backup of /etc/resolv.conf taken at connect +# resolv-skipped.tmp set when /etc/resolv.conf is not writable + +set -e + +if [ "$reason" = "connect" ]; then + rm -f marker-start marker-route-del marker-final saved-route.tmp saved-routes.raw.tmp resolv.bak.tmp resolv-skipped.tmp + ip link set dev "$TUNDEV" up mtu "$INTERNAL_IP4_MTU" + ip addr add "$INTERNAL_IP4_ADDRESS/32" peer "$INTERNAL_IP4_ADDRESS" dev "$TUNDEV" + if [ -n "$INTERNAL_IP6_NETMASK" ]; then + ip -6 addr add $INTERNAL_IP6_NETMASK dev $TUNDEV + fi + if [ -w /etc/resolv.conf ]; then + # Back up resolv.conf before touching it; the test restores it + # from this file on disconnect (and in its own cleanup trap as + # a backstop). + cp /etc/resolv.conf resolv.bak.tmp + echo "nameserver $INTERNAL_IP4_ADDRESS" > /etc/resolv.conf + else + touch resolv-skipped.tmp + fi +elif [ "$reason" = "disconnect" ]; then + touch marker-start + # Capture the routes now, while the client is still alive and the tun + # device exists. The client's waitpid does not restart on EINTR, so it + # can exit while this script is still sleeping below, and the tun + # device goes away with it; capturing afterwards would fail and abort + # the script (set -e), leaving the restore half-done. + ip route show dev "$TUNDEV" > saved-routes.raw.tmp + ip -6 route show dev "$TUNDEV" >> saved-routes.raw.tmp + awk '{print $1}' saved-routes.raw.tmp > saved-route.tmp + rm -f saved-routes.raw.tmp + # Give the test a deterministic window to deliver the second Ctrl-C + # (SIGINT to the whole process group) while we are mid-restore. + sleep 3 + # If the device is already gone (client exited first), its routes are + # gone with it; nothing to delete, and the marker stays honest. + if ip link show dev "$TUNDEV" >/dev/null 2>&1; then + # Not in a pipeline: a failed `ip` must abort the script (set -e), + # not be masked by awk's exit status. + while read route; do + if [ -n "$route" ]; then + case "$route" in + *:*) ip -6 route del "$route" dev "$TUNDEV" ;; + *) ip route del "$route" dev "$TUNDEV" ;; + esac + fi + done < saved-route.tmp + fi + touch marker-route-del + if [ -f resolv.bak.tmp ]; then + cp resolv.bak.tmp /etc/resolv.conf + fi + touch marker-final +fi +exit 0 diff --git a/tests/sigint-group b/tests/sigint-group new file mode 100755 index 00000000..5c165ceb --- /dev/null +++ b/tests/sigint-group @@ -0,0 +1,237 @@ +#!/bin/bash +# +# Copyright (C) 2026 Ivo +# +# This file is part of openconnect. +# +# openconnect is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# Regression test for the teardown race fixed by commit c0ed1606 +# ("Run vpnc-script in its own process group", released in v9.20/v9.21; +# Debian bug #1132360). +# +# When openconnect is interrupted with Ctrl-C, SIGINT is delivered to the +# whole foreground process group, which before the fix included the +# vpnc-script child forked to restore routing and DNS on disconnect. A +# second Ctrl-C while the script was mid-restore killed it, leaving the +# VPN route and DNS server behind. +# +# This test reproduces that race deterministically: it connects a real +# client to ocserv with a restore-tracking vpnc-script, starts teardown +# with SIGINT to the client, then delivers SIGINT to the whole process +# group while the script is mid-restore, and asserts the restore +# completed. +# +# On unfixed code the script child dies with the group and the assertions +# fail; on fixed code (setpgid in script_config_tun) the child survives in +# its own process group and the restore completes. +# +# Two details matter on fixed code. The client's waitpid for the script +# does not restart on EINTR (no SA_RESTART on the SIGINT handler), so the +# group-delivered SIGINT makes the client exit while the script is still +# sleeping, and the tun device goes away with the client. The test +# therefore polls for marker-final with a bounded timeout instead of +# asserting immediately after client exit, and the fake script captures +# the routes up front, before the device can disappear. + +OCCTL="${OCCTL:-occtl}" +SERV="${OCSERV:-ocserv}" +srcdir=${srcdir:-.} +PORT=4569 +PIDFILE=ocserv-pid.$$.tmp +CLIPID=oc-pid.$$.tmp +PATH=${PATH}:/usr/sbin +IP=$(which ip) +OUTFILE=traffic.$$.tmp +OPENCONNECT="${OPENCONNECT:-/usr/sbin/openconnect}" + +. `dirname $0`/common.sh + +if test -z "${IP}";then + echo "no IP tool is present" + exit 77 +fi + +if test "$(id -u)" != "0";then + echo "This test must be run as root" + exit 77 +fi + +if ! command -v setsid >/dev/null 2>&1; then + echo "setsid (util-linux) is required for this test" + exit 77 +fi + +echo "Testing SIGINT group teardown (bug #1132360)..." + +function finish { + set +e + echo " * Cleaning up..." + test -n "${PID}" && kill ${PID} >/dev/null 2>&1 + test -n "${PIDFILE}" && rm -f ${PIDFILE} >/dev/null 2>&1 + test -f "${CLIPID}" && kill $(cat ${CLIPID}) >/dev/null 2>&1 + test -f "${CLIPID}" && rm -f ${CLIPID} >/dev/null 2>&1 + test -n "${CONFIG}" && rm -f ${CONFIG} >/dev/null 2>&1 + rm -f ${OUTFILE} >/dev/null 2>&1 + # Backstop: never leave a test-modified resolv.conf behind. + if test -f resolv.bak.tmp; then + cp resolv.bak.tmp /etc/resolv.conf + rm -f resolv.bak.tmp + fi + rm -f marker-start marker-route-del marker-final saved-route.tmp saved-routes.raw.tmp resolv-skipped.tmp +} +trap finish EXIT + +# server address +ADDRESS=10.202.2.1 +CLI_ADDRESS=10.202.1.1 +VPNNET=192.168.3.0/24 +VPNADDR=192.168.3.1 +VPNNET6=fd91:6d87:8341:dc6a::/112 +VPNADDR6=fd91:6d87:8341:dc6a::1 +USERNAME=test +TUNDEV=oc-$$-tun0 + +. `dirname $0`/ns.sh + +# Run servers +update_config test-dtls-psk.config +if test "$VERBOSE" = 1;then +DEBUG="-d 3" +fi + +${CMDNS2} ${SERV} -p ${PIDFILE} -f -c ${CONFIG} ${DEBUG} & PID=$! + +sleep 4 + +# Run clients +echo " * Getting cookie from ${ADDRESS}:${PORT}..." +( echo "test" | ${CMDNS1} ${OPENCONNECT} ${ADDRESS}:${PORT} -u ${USERNAME} --servercert=pin-sha256:xp3scfzy3rO --cookieonly ) +if test $? != 0;then + echo "Could not get cookie from server" + exit 1 +fi + +echo " * Connecting to ${ADDRESS}:${PORT}..." +# setsid puts the client in its own session/process group, so the +# group-delivered "second Ctrl-C" below reaches the client and its +# vpnc-script child but never this test script. +( echo "test" | ${CMDNS1} setsid ${OPENCONNECT} -Q16 --interface ${TUNDEV} --dtls-ciphers=PSK-NEGOTIATE ${ADDRESS}:${PORT} -u ${USERNAME} --servercert=pin-sha256:xp3scfzy3rO -s ${srcdir}/scripts/vpnc-script-restore-tracking --pid-file=${CLIPID} --passwd-on-stdin -b ) +if test $? != 0;then + echo "Could not connect to server" + exit 1 +fi + +set -e + +echo " * wait for ${TUNDEV}" + +TIMEOUT=10 +while ! ${CMDNS1} ip link show dev ${TUNDEV} 2>/dev/null | grep -q UP; do + TIMEOUT=$(($TIMEOUT - 1)) + if [ $TIMEOUT -eq 0 ]; then + echo "Timed out waiting for ${TUNDEV}" + exit 1 + fi + sleep 1 +done + +echo " * add routes" + +${CMDNS1} ip route add ${VPNADDR} dev ${TUNDEV} +${CMDNS1} ip -6 route add ${VPNADDR6} dev ${TUNDEV} + +echo " * ping remote address" + +${CMDNS1} ping -c 3 ${VPNADDR} + +test -f "${CLIPID}" || { echo "No client pid file"; exit 1; } +CLIENT_PID=$(cat ${CLIPID}) + +echo " * First Ctrl-C: SIGINT to the client, teardown starts" +kill -INT ${CLIENT_PID} + +echo " * Wait for the disconnect script to start..." +TIMEOUT=50 +while [ ! -f marker-start ] && [ ${TIMEOUT} -gt 0 ]; do + sleep 0.1 + TIMEOUT=$((${TIMEOUT} - 1)) +done +if [ ! -f marker-start ]; then + echo "Disconnect script never started" + exit 1 +fi + +echo " * Script is mid-restore; second Ctrl-C: SIGINT to the whole group" +sleep 1 +PGID=$(ps -o pgid= -p ${CLIENT_PID} | tr -d ' ') +if [ -z "${PGID}" ]; then + echo "FAIL: could not determine the client's process group" + exit 1 +fi +echo " client pid ${CLIENT_PID}, pgid ${PGID}" +kill -INT -- -${PGID} + +echo " * Wait for the client to exit..." +TIMEOUT=100 +while kill -0 ${CLIENT_PID} 2>/dev/null && [ ${TIMEOUT} -gt 0 ]; do + sleep 0.1 + TIMEOUT=$((${TIMEOUT} - 1)) +done + +echo " * Wait for the restore script to finish..." +# The client can exit (EINTR'd waitpid) while the script is still +# sleeping; poll for the completion marker with a bounded timeout. +TIMEOUT=150 +while [ ! -f marker-final ] && [ ${TIMEOUT} -gt 0 ]; do + sleep 0.1 + TIMEOUT=$((${TIMEOUT} - 1)) +done + +fail=0 + +if [ ! -f marker-final ]; then + echo "FAIL: restore did not complete (marker-final missing)" + fail=1 +fi +if [ ! -f marker-route-del ]; then + echo "FAIL: routes were not removed (marker-route-del missing)" + fail=1 +fi +if ${CMDNS1} ip route show | grep -q "${VPNADDR} dev ${TUNDEV}"; then + echo "FAIL: route ${VPNADDR} still present after disconnect" + fail=1 +fi +if ${CMDNS1} ip -6 route show | grep -q "${VPNADDR6} dev ${TUNDEV}"; then + echo "FAIL: route ${VPNADDR6} still present after disconnect" + fail=1 +fi +if [ -f resolv.bak.tmp ]; then + if ! cmp -s resolv.bak.tmp /etc/resolv.conf; then + echo "FAIL: /etc/resolv.conf was not restored" + fail=1 + fi +elif [ -f resolv-skipped.tmp ]; then + echo "NOTE: /etc/resolv.conf not writable, DNS assertion skipped" +else + echo "FAIL: resolv.conf was never backed up at connect" + fail=1 +fi + +if [ ${fail} -eq 0 ]; then + echo "PASS: teardown survived the second Ctrl-C and restored state" + exit 0 +fi +exit 1 -- 2.39.5