Improve VPN status reporting and recovery
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise installer rendering in temporary directories; never run installer main."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INSTALLER = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "install.sh"
|
||||
SOURCE = INSTALLER.read_text().rsplit('\nmain "$@"', 1)[0]
|
||||
KEYS = ("LEMANA_VPN_MODE", "LEMANA_VPN_USERNAME", "LEMANA_VPN_BW_ITEM", "LEMANA_VPN_USE_TOUCHID")
|
||||
|
||||
|
||||
def setup(path):
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if not key.startswith(("LEMANA_VPN_", "OPENCONNECT_LITE_"))}
|
||||
env.update(HOME=str(path), LEMANA_VPN_CONFIG_DIR=str(path / "settings"),
|
||||
OPENCONNECT_LITE_CONFIG_DIR=str(path / "openconnect"),
|
||||
TEST_STAGE=str(path / "stage"))
|
||||
for directory in ("settings", "openconnect", "stage"):
|
||||
(path / directory).mkdir()
|
||||
return env
|
||||
|
||||
|
||||
def render(env, *args):
|
||||
command = SOURCE + '''
|
||||
# Only configuration writes are exercised, with repository-local templates.
|
||||
download_file() { cp "$TEST_ROOT/$1" "$2"; }
|
||||
preserve_existing_username
|
||||
install_config "$TEST_STAGE"
|
||||
set -a
|
||||
. "$CONFIG_DIR/env"
|
||||
python3 - <<'PYRESULT'
|
||||
import json, os
|
||||
print("RESULT=" + json.dumps({key: os.environ[key] for key in (
|
||||
"LEMANA_VPN_MODE", "LEMANA_VPN_USERNAME", "LEMANA_VPN_BW_ITEM", "LEMANA_VPN_USE_TOUCHID")}))
|
||||
PYRESULT
|
||||
'''
|
||||
result = subprocess.run(["/bin/sh", "-c", command, str(ROOT / "install.sh"), *args],
|
||||
env=dict(env, TEST_ROOT=str(ROOT)), capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
return json.loads(next(line[7:] for line in result.stdout.splitlines() if line.startswith("RESULT=")))
|
||||
|
||||
|
||||
def mode_from_environment(path, env):
|
||||
settings = render(dict(env, LEMANA_VPN_MODE="bitwarden-auto"))
|
||||
assert settings["LEMANA_VPN_MODE"] == "bitwarden-auto", settings
|
||||
|
||||
|
||||
def literal_settings(path, env):
|
||||
sentinel = path / "must-not-exist"
|
||||
special = 'Sales/VPN & "quotes" \\ $HOME `touch ' + str(sentinel) + '` $(touch ' + str(sentinel) + ') {{USERNAME}}'
|
||||
settings = render(env, "--mode", "bitwarden-auto", "--username", special, "--bw-item", special)
|
||||
assert settings["LEMANA_VPN_USERNAME"] == special, settings
|
||||
assert settings["LEMANA_VPN_BW_ITEM"] == special, settings
|
||||
assert not sentinel.exists(), "configuration executed a command substitution"
|
||||
text = (path / "openconnect/config.toml").read_text()
|
||||
username = next(line.split("=", 1)[1].strip() for line in text.splitlines() if line.startswith("username ="))
|
||||
assert json.loads(username) == special, text
|
||||
again = render(env)
|
||||
assert again == settings, (settings, again)
|
||||
assert not sentinel.exists()
|
||||
|
||||
|
||||
def update_preserves_settings(path, env):
|
||||
config = path / "settings/env"
|
||||
custom = '# User runtime options\nLEMANA_VPN_LOG_MAX_BYTES="12345"\nLEMANA_VPN_CONNECT_WAIT_SECONDS="37"\n'
|
||||
config.write_text('LEMANA_VPN_MODE="bitwarden-auto"\nLEMANA_VPN_USERNAME="saved-user"\nLEMANA_VPN_BW_ITEM="Saved/VPN & more" # custom item\nLEMANA_VPN_USE_TOUCHID="0"\n' + custom)
|
||||
original_toml = ('# User SSO profile\n[default_profile]\naddress = "custom.example"\n'
|
||||
'username = "leave-profile-field"\n\n[credentials] # saved login\nusername = "old-user"\n'
|
||||
'\n[[auto_fill_rules."https://custom.example/*"]]\nselector = "#custom"\nfill = "username"\n')
|
||||
(path / "openconnect/config.toml").write_text(original_toml)
|
||||
settings = render(env)
|
||||
assert settings == dict(zip(KEYS, ("bitwarden-auto", "saved-user", "Saved/VPN & more", "0"))), settings
|
||||
assert custom.rstrip() in config.read_text()
|
||||
assert (path / "openconnect/config.toml").read_text() == original_toml.replace('username = "old-user"', 'username = "saved-user"')
|
||||
first = config.read_text()
|
||||
assert render(env) == settings
|
||||
assert config.read_text() == first, "a second update changed unowned settings"
|
||||
assert config.stat().st_mode & 0o777 == 0o600
|
||||
assert (path / "openconnect/config.toml").stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def local_auto_migration(path, env):
|
||||
config = path / "settings/env"
|
||||
legacy = 'LEMANA_VPN_MODE="manual-keychain"\nLEMANA_VPN_LOCAL_OTP="1"\nLEMANA_VPN_USERNAME="saved"\n'
|
||||
config.write_text(legacy)
|
||||
assert render(env)["LEMANA_VPN_MODE"] == "keychain-auto"
|
||||
assert 'LEMANA_VPN_AUTO_SOURCE="keychain"' in config.read_text()
|
||||
assert render(env)["LEMANA_VPN_MODE"] == "keychain-auto"
|
||||
assert render(env, "--mode", "manual-keychain")["LEMANA_VPN_MODE"] == "manual-keychain"
|
||||
assert 'LEMANA_VPN_LOCAL_OTP="0"' in config.read_text()
|
||||
config.write_text(legacy)
|
||||
assert render(dict(env, LEMANA_VPN_MODE="manual-keychain"))["LEMANA_VPN_MODE"] == "manual-keychain"
|
||||
|
||||
|
||||
def precedence(path, env):
|
||||
(path / "settings/env").write_text('LEMANA_VPN_MODE="manual-keychain"\nLEMANA_VPN_USERNAME="saved"\nLEMANA_VPN_BW_ITEM="saved"\nLEMANA_VPN_USE_TOUCHID="0"\n')
|
||||
invocation = dict(env, LEMANA_VPN_MODE="bitwarden-auto", LEMANA_VPN_USERNAME="environment",
|
||||
LEMANA_VPN_BW_ITEM="environment", LEMANA_VPN_USE_TOUCHID="1")
|
||||
settings = render(invocation)
|
||||
assert settings == dict(zip(KEYS, ("bitwarden-auto", "environment", "environment", "1"))), settings
|
||||
settings = render(invocation, "--mode", "manual-keychain", "--username", "flag", "--bw-item", "flag", "--without-touchid")
|
||||
assert settings == dict(zip(KEYS, ("manual-keychain", "flag", "flag", "0"))), settings
|
||||
|
||||
|
||||
def legacy_environment_precedence(path, env):
|
||||
for saved_auto in (False, True):
|
||||
saved = ("bitwarden-auto", "auto", "bitwarden", "1") if saved_auto else ("manual-keychain", "saved-login", "keychain", "0")
|
||||
overrides = (("CREDENTIAL_SOURCE", "keychain"), ("AUTH_PROFILE", "manual"), ("USE_BITWARDEN", "0")) if saved_auto else (("CREDENTIAL_SOURCE", "bitwarden"), ("AUTH_PROFILE", "auto"), ("USE_BITWARDEN", "1"))
|
||||
for key, value in overrides:
|
||||
(path / "settings/env").write_text("".join("LEMANA_VPN_" + name + "=" + json.dumps(setting) + "\n" for name, setting in zip(("MODE", "AUTH_PROFILE", "CREDENTIAL_SOURCE", "USE_BITWARDEN"), saved)))
|
||||
settings = render(dict(env, **{"LEMANA_VPN_" + key: value}))
|
||||
expected = "manual-keychain" if saved_auto else "bitwarden-auto"
|
||||
assert settings["LEMANA_VPN_MODE"] == expected, (key, value, settings)
|
||||
|
||||
|
||||
def safe_saved_read(path, env):
|
||||
sentinel = path / "must-not-exist"
|
||||
(path / "settings/env").write_text('LEMANA_VPN_MODE="manual-keychain"\nLEMANA_VPN_USERNAME="$(touch ' + str(sentinel) + ')"\n')
|
||||
settings = render(env)
|
||||
assert not sentinel.exists(), "reading saved settings executed shell code"
|
||||
assert settings["LEMANA_VPN_USERNAME"] == '$(touch ' + str(sentinel) + ')'
|
||||
|
||||
|
||||
def missing_credentials_section(path, env):
|
||||
(path / "openconnect/config.toml").write_text('[default_profile]\nusername = "profile-user"\n')
|
||||
render(env, "--username", "new-user")
|
||||
text = (path / "openconnect/config.toml").read_text()
|
||||
assert text == '[default_profile]\nusername = "profile-user"\n\n[credentials]\nusername = "new-user"\n', text
|
||||
|
||||
|
||||
def empty_saved_username(path, env):
|
||||
(path / "settings/env").write_text('LEMANA_VPN_USERNAME=""\n')
|
||||
(path / "openconnect/config.toml").write_text('[credentials]\nusername = "toml-user"\n')
|
||||
assert render(env)["LEMANA_VPN_USERNAME"] == "toml-user"
|
||||
assert render(dict(env, LEMANA_VPN_USERNAME=""))["LEMANA_VPN_USERNAME"] == ""
|
||||
|
||||
|
||||
def toml_scalar_forms(path, env):
|
||||
for header, key in (("[ credentials ]", "username"), ('["credentials"]', '"username"'), ("[ 'credentials' ]", "'username'")):
|
||||
(path / "settings/env").write_text('LEMANA_VPN_USERNAME=""\n')
|
||||
original = header + ' # custom login\n' + key + ' = "toml-user"\n[default_profile]\naddress = "custom.example"\n'
|
||||
(path / "openconnect/config.toml").write_text(original)
|
||||
assert render(env)["LEMANA_VPN_USERNAME"] == "toml-user"
|
||||
parsed = tomllib.loads((path / "openconnect/config.toml").read_text())
|
||||
assert parsed == {"credentials": {"username": "toml-user"}, "default_profile": {"address": "custom.example"}}, parsed
|
||||
render(env, "--username", "new-user")
|
||||
parsed = tomllib.loads((path / "openconnect/config.toml").read_text())
|
||||
assert parsed["credentials"]["username"] == "new-user", parsed
|
||||
(path / "openconnect/config.toml").write_text('[ "credentials" ]')
|
||||
render(env, "--username", "end-of-file")
|
||||
assert tomllib.loads((path / "openconnect/config.toml").read_text()) == {"credentials": {"username": "end-of-file"}}
|
||||
|
||||
|
||||
def fresh_help_without_python(path, env):
|
||||
# Keep only the help command available; Python must not be needed yet.
|
||||
(path / "stage/cat").symlink_to("/bin/cat")
|
||||
result = subprocess.run(["/bin/sh", "-c", SOURCE, str(ROOT / "install.sh"), "--help"],
|
||||
env=dict(env, PATH=str(path / "stage")), text=True, capture_output=True, timeout=10)
|
||||
assert result.returncode == 0 and "Usage:" in result.stdout, result.stderr
|
||||
|
||||
|
||||
failures = []
|
||||
for check in (mode_from_environment, literal_settings, update_preserves_settings, local_auto_migration, precedence,
|
||||
legacy_environment_precedence, safe_saved_read, missing_credentials_section,
|
||||
empty_saved_username, toml_scalar_forms, fresh_help_without_python):
|
||||
with tempfile.TemporaryDirectory(prefix="lemana-config-") as temporary:
|
||||
path = Path(temporary)
|
||||
try:
|
||||
check(path, setup(path))
|
||||
print("PASS", check.__name__)
|
||||
except (AssertionError, subprocess.TimeoutExpired) as error:
|
||||
failures.append(check.__name__)
|
||||
print("FAIL", check.__name__, str(error))
|
||||
if failures:
|
||||
raise SystemExit(1)
|
||||
print("configuration checks passed")
|
||||
Reference in New Issue
Block a user