#!/usr/bin/env python3 """DNS command boundary with fake sudo/helper; never touches system DNS.""" import json import os from pathlib import Path import subprocess import tempfile ROOT = Path(__file__).resolve().parents[1] CLI = ROOT / "bin/vpn-lemanapro.sh" with tempfile.TemporaryDirectory(prefix="lemana-dns-cli-") as temporary: home = Path(temporary) fake = home / "bin" fake.mkdir() helper = home / "DNS helper's script" for name in ("security", "openconnect", "bw", "networksetup"): (fake / name).write_text('#!/bin/sh\necho forbidden >> "$HOME/forbidden"\nexit 1\n') # Status may check Keychain presence, but repair must not even read credentials. (fake / "security").write_text('#!/bin/sh\necho keychain >> "$HOME/keychain-read"\nexit 1\n') (fake / "sudo").write_text('''#!/bin/sh [ "$1" = -n ] || exit 90 shift printf '%s\n' "$@" > "$HOME/sudo-args" [ "${TEST_SUDO_FAIL:-0}" = 0 ] || exit 1 exec "$@" ''') for path in fake.iterdir(): path.chmod(0o755) env = {k: v for k, v in os.environ.items() if not k.startswith(("LEMANA_VPN_", "OPENCONNECT_LITE_"))} env.update(HOME=temporary, PATH=str(fake) + ":" + os.environ["PATH"], LEMANA_VPN_CONFIG_DIR=str(home / "config"), LEMANA_VPN_STATUS_DIR=str(home / "status"), LEMANA_VPN_OC_VENV=str(home / "venv"), LEMANA_VPN_OC_BIN=str(fake / "openconnect"), LEMANA_VPN_DNS_CLEANUP=str(helper)) def repair(**extra): result = subprocess.run(["bash", str(CLI), "--repair-dns", "--json"], env=dict(env, **extra), text=True, capture_output=True, timeout=10) payload = json.loads(result.stdout) assert payload["event"] == "dns_recovery" assert not (home / "forbidden").exists() assert not (home / "keychain-read").exists() assert not (home / "status").exists() return result.returncode, payload["result"] assert repair() == (1, "error") helper.write_text('#!/bin/sh\necho old-helper-ran >> "$HOME/forbidden"\n') helper.chmod(0o755) assert repair() == (1, "error") assert not (home / "sudo-args").exists(), "Legacy helper must not be executed" helper.write_text('''#!/bin/sh # LEMANA_DNS_PROTOCOL=2 [ "$#" = 1 ] && [ "$1" = --recover ] || exit 91 printf '%s\n' "${TEST_DNS_RESULT:-unchanged}" exit "${TEST_DNS_EXIT:-0}" ''') for value in ("restored", "unchanged", "no_snapshot", "active"): assert repair(TEST_DNS_RESULT=value) == (0, value) assert (home / "sudo-args").read_text().splitlines() == [str(helper), "--recover"] for extra in ({"TEST_DNS_RESULT": "not-json\nwrong"}, {"TEST_DNS_EXIT": "1"}, {"TEST_SUDO_FAIL": "1"}): assert repair(**extra) == (1, "error") source = CLI.read_text() def function(name): start = source.index(name + "() {") return source[start:source.index("\n}\n", start) + 3] cleanup = "\n".join(function(name) for name in ("_emit", "_dns_recovery_supported", "_dns_cleanup")) for client_exit, recovery_exit in ((0, 0), (7, 0), (7, 1)): ended = subprocess.run(["bash", "-c", cleanup + '\nJSON_MODE=true\n_dns_cleanup "$TEST_CLIENT_EXIT"'], env=dict(env, DNS_CLEANUP=str(helper), TEST_CLIENT_EXIT=str(client_exit), TEST_DNS_EXIT=str(recovery_exit)), text=True, capture_output=True, timeout=10) assert ended.returncode == client_exit, ended.stderr events = [json.loads(line) for line in ended.stdout.splitlines()] assert events[0]["event"] == "dns_cleanup" assert events[-1] == {"event": "dns_recovery", "result": "error" if recovery_exit else "unchanged"} helper.write_text('''#!/bin/sh # LEMANA_DNS_PROTOCOL=2 count=0 [ ! -f "$HOME/recovery-count" ] || read -r count < "$HOME/recovery-count" count=$((count + 1)) printf '%s\\n' "$count" > "$HOME/recovery-count" if [ "$count" -le "$TEST_ACTIVE_COUNT" ]; then printf 'active\\n' else touch "$HOME/dns-restored" printf 'restored\\n' fi ''') (fake / "sleep").write_text('#!/bin/sh\nexit 0\n') (fake / "sleep").chmod(0o755) for active_count in (3, 100): (home / "recovery-count").unlink(missing_ok=True) (home / "dns-restored").unlink(missing_ok=True) ended = subprocess.run(["bash", "-c", cleanup + '\nJSON_MODE=true\n_dns_cleanup 130'], env=dict(env, DNS_CLEANUP=str(helper), TEST_ACTIVE_COUNT=str(active_count)), text=True, capture_output=True, timeout=10) assert ended.returncode == 130, ended.stderr events = [json.loads(line) for line in ended.stdout.splitlines()] assert events[-1] == {"event": "dns_recovery", "result": "restored" if active_count == 3 else "active"} assert (home / "dns-restored").exists() == (active_count == 3) assert int((home / "recovery-count").read_text()) == (4 if active_count == 3 else 30) print("PASS disconnect recovery: waits for exiting VPN children, preserves exit code, bounds active-session retries") status = subprocess.run(["bash", str(CLI), "--status", "--json"], env=env, text=True, capture_output=True, check=True, timeout=10) assert json.loads(status.stdout)["modules"]["dns_cleanup"]["recovery_supported"] assert not (home / "forbidden").exists() # Exercise the actual OpenConnect shell command with a path containing quotes. hook_line = next(line for line in CLI.read_text().splitlines() if line.startswith("dns_hook=")) helper.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$HOME/hook-args"\n') subprocess.run(["bash", "-c", hook_line + '\n/bin/sh -c "$dns_hook"'], env=dict(env, DNS_CLEANUP=str(helper), SESSION_ID='11111111-2222-3333-4444-555555555555'), check=True, timeout=10) assert (home / "hook-args").read_text().splitlines() == ["--vpnc", "11111111-2222-3333-4444-555555555555"] print("PASS DNS CLI: noninteractive recovery, fixed JSON, no connection/credentials, legacy capability guard") (home / 'keychain-read').unlink(missing_ok=True) (home / 'sudo-args').unlink(missing_ok=True) token = '11111111-2222-3333-4444-555555555555' def stop(**extra): result = subprocess.run(['bash', str(CLI), '--stop-session=' + token, '--json'], env=dict(env, **extra), text=True, capture_output=True, timeout=10) assert not (home/'keychain-read').exists() and not (home/'forbidden').exists() assert not (home/'status').exists() return result.returncode, json.loads(result.stdout) assert stop() == (1, {'event': 'session_stop', 'result': 'error'}) assert not (home/'sudo-args').exists(), 'An old helper must never receive --stop' helper.write_text('''#!/bin/sh # LEMANA_SESSION_PROTOCOL=1 [ "$#" = 2 ] && [ "$1" = --stop ] || exit 91 printf '%s\\n' "${TEST_STOP_RESULT:-stopping}" exit "${TEST_STOP_EXIT:-0}" ''') assert stop() == (0, {'event': 'session_stop', 'result': 'stopping'}) assert (home/'sudo-args').read_text().splitlines() == [str(helper), '--stop', token] for extra in ({'TEST_STOP_RESULT': 'wrong'}, {'TEST_STOP_EXIT': '7'}, {'TEST_SUDO_FAIL': '1'}): assert stop(**extra) == (1, {'event': 'session_stop', 'result': 'error'}) for invalid in ('', '../../etc/passwd', token + ';echo bad'): result = subprocess.run(['bash', str(CLI), '--stop-session=' + invalid, '--json'], env=env, text=True, capture_output=True, timeout=10) assert result.returncode == 2 print('PASS stop CLI: exact session forwarded, no credentials/connect, unsupported helper and failures rejected') attempt = '22222222-2222-3333-4444-555555555555' restart_args = ['--restart-session='+token, '--restart-attempt='+attempt] def restart(args=restart_args, **extra): result = subprocess.run(['bash', str(CLI), *args], env=dict(env, **extra), text=True, capture_output=True, timeout=10) assert not (home/'keychain-read').exists() and not (home/'forbidden').exists() assert not (home/'status').exists() return result (home/'sudo-args').unlink() assert restart().returncode != 0 and not (home/'sudo-args').exists() helper.write_text('''#!/bin/sh # LEMANA_SESSION_PROTOCOL=1 # LEMANA_RECONNECT_PROTOCOL=1 [ "$#" = 3 ] && [ "$1" = --restart ] || exit 91 printf '%s\\n' "${TEST_RESTART_RESULT:-stopping}" ''') for value in ('stopping','stale','cancelled'): result = restart(TEST_RESTART_RESULT=value) assert result.returncode == 0 and result.stdout.strip() == value assert (home/'sudo-args').read_text().splitlines() == [str(helper), '--restart', token, attempt] assert restart(TEST_RESTART_RESULT='unexpected').returncode != 0 assert restart(TEST_SUDO_FAIL='1').returncode != 0 for args in ([restart_args[0]], [restart_args[1]], [restart_args[0], '--restart-attempt=invalid']): assert restart(args).returncode == 2 print('PASS restart CLI: exact session/attempt pair, capability gate, missing/invalid pair and helper failure rejected')