Improve VPN status reporting and recovery
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Filesystem and closed-input regressions; no VPN, Keychain, or GUI."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME = Path(os.environ.get("LEMANA_TEST_RUNTIME", ROOT / "bin/vpn-lemanapro.sh"))
|
||||
SOURCE = RUNTIME.read_text()
|
||||
|
||||
|
||||
def function(name):
|
||||
start = SOURCE.index(name + "() {")
|
||||
return SOURCE[start:SOURCE.index("\n}\n", start) + 3]
|
||||
|
||||
|
||||
def shell(code, env, stdin="", timeout=5):
|
||||
return subprocess.run(["/bin/bash", "-c", code], input=stdin, text=True,
|
||||
capture_output=True, env=env, timeout=timeout)
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="lemana-logging-") as tmp:
|
||||
home = Path(tmp)
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("LEMANA_VPN_", "OPENCONNECT_LITE_"))}
|
||||
env.update(HOME=tmp, LEMANA_VPN_CONFIG_DIR=str(home / "config"),
|
||||
LEMANA_VPN_STATUS_DIR=str(home / "state"))
|
||||
|
||||
# EOF must differ from an intentional empty Enter, even in a conditional caller.
|
||||
prompts = "\n".join(function(n) for n in (
|
||||
"_emit", "_prompt_read", "_ensure_username", "_configure_login", "_configure_keychain", "_bw_unlock"))
|
||||
setup = '''
|
||||
set -eu
|
||||
JSON_MODE=true
|
||||
KC_USERNAME=''
|
||||
CACHE_BW_SESSION=0
|
||||
USE_TOUCHID=0
|
||||
LOCAL_OTP=0
|
||||
USE_BITWARDEN=1
|
||||
_can_prompt() { return 0; }
|
||||
_tty_available() { return 1; }
|
||||
_keychain_has() { return 0; }
|
||||
_persist_username() { echo unexpected-write; }
|
||||
_store_keychain() { echo unexpected-write; }
|
||||
_bw_cache_session() { :; }
|
||||
bw() { case "$1" in status) echo '{"status":"locked"}';; unlock) echo fake-session;; esac; }
|
||||
'''
|
||||
for call in ("_ensure_username", "_configure_login", "_configure_keychain", "_bw_unlock"):
|
||||
result = shell(prompts + setup + f"\nif {call}; then exit 99; else exit 7; fi", env)
|
||||
assert result.returncode == 7, (call, result.stdout, result.stderr)
|
||||
assert "unexpected-write" not in result.stdout
|
||||
assert len(result.stderr) < 500, (call, result.stderr)
|
||||
result = shell(prompts + setup + '\nif _configure_keychain; then exit 99; else exit 7; fi', env, "fake-password\n")
|
||||
assert result.returncode == 7 and "unexpected-write" not in result.stdout, result.stderr
|
||||
result = shell(prompts + setup + '\nvalue_out=x; _prompt_read value_out ""; test -z "$value_out"', env, "\n")
|
||||
assert result.returncode == 0, result.stderr
|
||||
helper = home / "fingerprint"
|
||||
helper.write_text('#!/bin/sh\n[ "$1" != set ] || echo unexpected-write >> "$HOME/secret-write"\n')
|
||||
helper.chmod(0o755)
|
||||
result = shell(prompts + setup + f'\nUSE_TOUCHID=1; KC_FP="{helper}"; BW_KC_SERVICE=fake; BW_KC_ACCOUNT_MASTER=fake\n'
|
||||
'if _bw_unlock; then exit 99; else exit 7; fi', env, "fake-password\n")
|
||||
assert result.returncode == 7, result.stderr
|
||||
assert not (home / "secret-write").exists(), "EOF consent must not save a password"
|
||||
print("closed input: username/password/2FA/Bitwarden/save consent exit without persistence; empty Enter unchanged")
|
||||
|
||||
log = home / "nested" / "client.log"
|
||||
log.parent.mkdir()
|
||||
log.write_bytes(b"legacy" * 10000)
|
||||
log.chmod(0o644)
|
||||
log_functions = "\n".join(function(n) for n in (
|
||||
"_append_connection_log", "_log_connection_line", "_prepare_connection_log", "_parse_expiry", "_valid_ipv4", "_publish_connection", "_filter_output"))
|
||||
log_setup = f'\nCONNECT_LOG_FILE="{log}"; LOG_MAX_BYTES=256; _connection_log_failed=false; OC_BIN=fake\n'
|
||||
result = shell(log_functions + log_setup + '''
|
||||
_prepare_connection_log
|
||||
for ((i=0; i<80; i++)); do _log_connection_line "record-$i: Привет"; done
|
||||
_log_connection_line "$(printf '%02000d' 1)"
|
||||
_log_connection_line newest
|
||||
''', env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for p in (log, Path(str(log) + ".1")):
|
||||
assert p.stat().st_size <= 256, (p, p.stat().st_size)
|
||||
assert p.stat().st_mode & 0o777 == 0o600, p
|
||||
assert log.read_bytes().endswith(b"newest\n")
|
||||
assert len(list(log.parent.iterdir())) == 2
|
||||
# A new process with a lower setting also bounds the previous archive.
|
||||
result = shell(log_functions + log_setup + 'LOG_MAX_BYTES=64; _log_connection_line reduced', env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert all(p.stat().st_size <= 64 for p in log.parent.iterdir())
|
||||
# Logging failure cannot stop the output consumer, and warns only once.
|
||||
result = shell(log_functions + log_setup + f'CONNECT_LOG_FILE="{log}/invalid"\n' + '''
|
||||
DEBUG=true; JSON_MODE=false
|
||||
printf 'first\nsecond\n' | _filter_output
|
||||
''', env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == "first\nsecond\n", result.stdout
|
||||
assert result.stderr.count("Cannot write VPN log") == 1, result.stderr
|
||||
result = shell(function("_emit") + log_functions + log_setup + f'CONNECT_LOG_FILE="{log}/invalid"\n' + '''
|
||||
set -euo pipefail
|
||||
DEBUG=false; JSON_MODE=true
|
||||
scutil() { :; }
|
||||
_write_status() { :; }
|
||||
_stop_connect_progress() { :; }
|
||||
printf 'Configured as 10.2.3.4\nSession authentication will expire at invalid-date\n' | _filter_output
|
||||
''', env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
connected = json.loads(result.stdout)
|
||||
assert connected.pop("started_at")
|
||||
assert connected == {"event": "connected", "ip": "10.2.3.4", "expires": "", "remaining_sec": 0, "attempt_id": ""}
|
||||
print("CLI log: legacy + repeated + oversized writes <= 256 bytes per file; newest kept; I/O failure preserves output")
|
||||
|
||||
config = home / "config"
|
||||
config.mkdir()
|
||||
config.joinpath("env").write_text('LEMANA_VPN_LOG_MAX_BYTES="384"\nLEMANA_VPN_BW_ITEM=\'Corporate "VPN" & / office\'\n')
|
||||
status = subprocess.run(["bash", str(RUNTIME), "--status", "--json"], env=env,
|
||||
text=True, capture_output=True, check=True)
|
||||
modules = json.loads(status.stdout)["modules"]
|
||||
assert modules["logging"]["max_bytes"] == 384
|
||||
assert modules["bitwarden"]["item"] == 'Corporate "VPN" & / office'
|
||||
home.joinpath("status.json").write_text(status.stdout)
|
||||
for limit in ("", "0", "-1", "abc", "1000000000"):
|
||||
result = subprocess.run(["bash", str(RUNTIME), "--status"], env=dict(env, LEMANA_VPN_LOG_MAX_BYTES=limit),
|
||||
text=True, capture_output=True)
|
||||
assert result.returncode == 2, (limit, result.stdout, result.stderr)
|
||||
result = subprocess.run(["bash", str(RUNTIME), "--statuz", "--status"], env=env,
|
||||
text=True, capture_output=True)
|
||||
assert result.returncode == 2 and "Unknown argument" in result.stderr
|
||||
assert not (home / "state").exists()
|
||||
print("CLI settings: saved limit and quoted item round-trip; malformed limits and unknown flags rejected")
|
||||
|
||||
# Compile the actual Foundation logger and actual status types, without starting the app.
|
||||
manager_source = (ROOT / "app/Sources/LemanaVPN/VPNManager.swift").read_text()
|
||||
types = manager_source.split("enum VPNState:")[0]
|
||||
types += manager_source[manager_source.index("enum VPNLaunchMode:"):manager_source.index("struct VPNLineBuffer")]
|
||||
home.joinpath("Status.swift").write_text(types)
|
||||
home.joinpath("main.swift").write_text(r'''
|
||||
import Foundation
|
||||
let dir = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let response = try JSONDecoder().decode(VPNStatusResponse.self, from: Data(contentsOf: dir.appendingPathComponent("status.json")))
|
||||
let url = dir.appendingPathComponent("swift/app.log")
|
||||
var log = BoundedLog(url: url)
|
||||
precondition(log.maxBytes == 5242880)
|
||||
log.maxBytes = response.modules!.logging!.max_bytes
|
||||
precondition(log.maxBytes == 384)
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try Data(repeating: 65, count: 100_000).write(to: url)
|
||||
for i in 0..<80 { try log.append("record-\(i): Привет\n") }
|
||||
try log.append(String(repeating: "Ж", count: 2000) + "\n")
|
||||
try log.append("newest\n")
|
||||
for path in [url, url.appendingPathExtension("1")] {
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: path.path)
|
||||
precondition((attrs[.size] as! NSNumber).intValue <= 384)
|
||||
precondition((attrs[.posixPermissions] as! NSNumber).intValue == 0o600)
|
||||
}
|
||||
let latest = try String(contentsOf: url, encoding: .utf8)
|
||||
precondition(latest.hasSuffix("newest\n"))
|
||||
log.maxBytes = 64
|
||||
try log.append("reduced\n")
|
||||
for path in [url, url.appendingPathExtension("1")] {
|
||||
let bytes = try Data(contentsOf: path)
|
||||
precondition(bytes.count <= 64)
|
||||
}
|
||||
do {
|
||||
try BoundedLog(url: url.appendingPathComponent("invalid")).append("failed\n")
|
||||
fatalError("Expected I/O failure")
|
||||
} catch {}
|
||||
print("Swift log: decoded CLI limit; legacy/repeated/oversized writes bounded; newest retained; I/O errors throwable")
|
||||
''')
|
||||
binary = home / "log-check"
|
||||
subprocess.run(["swiftc", "-module-cache-path", str(ROOT / "app/.build/log-test-module-cache"),
|
||||
str(ROOT / "app/Sources/LemanaVPN/BoundedLog.swift"),
|
||||
str(home / "Status.swift"), str(home / "main.swift"), "-o", str(binary)], check=True)
|
||||
subprocess.run([str(binary), tmp], check=True)
|
||||
|
||||
print("logging checks ok")
|
||||
Reference in New Issue
Block a user