Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 16:31:52 -04:00
parent aba2d16ffa
commit b30bf40407
29 changed files with 4452 additions and 241 deletions
+614
View File
@@ -0,0 +1,614 @@
#!/usr/bin/env bash
# Everything Connections learned to do, against a NetworkManager that is not
# real.
#
# This helper is the first one in Panama that handles a secret the user types.
# Three of its verbs sit next to a password: joining an enterprise network takes
# one, starting a hotspot generates one, and reading a connection's details sits
# on top of a store full of them. Each of those is a different way for a
# credential to escape:
#
# * on argv, where /proc publishes it to every process on this machine for as
# long as the command runs -- the same failure the Sharing page already
# refuses to have (sharing-contract, "the remote desktop password never
# passes through Panama");
# * in a log line, which outlives the command;
# * in the details JSON, which the page renders and which is the one place a
# stored PSK would look like it belonged.
#
# So the pins here are mostly about what must NOT appear, and they are checked
# from the data rather than from the source: the stub NetworkManager will hand
# over a passphrase to anything that asks for one, and the contract fails if any
# of them reaches the JSON, the argv log, or a file the helper wrote.
#
# The rest is the shape of the commands. `import-vpn` picking its plugin from
# the file extension, and the connection-name charset, are both places where a
# wrong guess produces a nonsense nmcli invocation rather than an error, so both
# are exercised rather than read.
#
# SAFETY. This runs the real helper, so it must be impossible for it to reach
# the real NetworkManager. Four things make that true, and the contract verifies
# the first two before running anything:
#
# 1. every system binary the helper names is resolved through PATH (asserted
# statically -- an absolute /usr/bin/nmcli would walk straight past the
# stubs), and PATH's first entry is the stub directory;
# 2. nmcli, gsettings, rfkill and every D-Bus client are stubbed, each
# recording its arguments and its stdin instead of doing anything;
# 3. `import gi` resolves to a stand-in on PYTHONPATH whose require_version
# always raises, so join-enterprise cannot take its libnm/D-Bus branch and
# falls back to nmcli, where the recording can see what it did;
# 4. both D-Bus bus addresses point at sockets that do not exist, so anything
# that got past (3) still could not connect.
#
# That means the native libnm branch is not exercised here. It is covered
# statically instead, by the check that no command list anywhere in the helper
# carries the password -- the failure that branch could have.
#
# Set PANAMA_NETWORK_STATIC_ONLY=1 to run only the source-reading half, which
# touches nothing at all.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-network"
service="$repo_dir/config/dot/quickshell/services/NetworkTools.qml"
fail() {
printf 'network tools contract: %s\n' "$1" >&2
exit 1
}
for path in "$helper" "$service"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-network is not executable'
# Sentinels. Every one of these is a value the fake NetworkManager will hand
# over, and none of them may come back out.
readonly WIFI_PSK='psk-must-never-leave-9c1f'
readonly VPN_SECRET='vpn-secret-must-never-leave-7b20'
readonly ENTERPRISE_PW='enterprise-pw-must-never-leave-4e88'
readonly HOTSPOT_PW='hotspot-pw-must-never-leave-3a55'
# ── Static: the helper cannot walk past the stubs ────────────────────────────
#
# Checked first because the dynamic half's safety rests on it. A helper that
# spelled its NetworkManager client `/usr/bin/nmcli` would ignore the stub
# directory entirely and reconfigure the machine running the test.
absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")"
[[ -z "$absolute" ]] \
|| fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute"
# ── Static: reading a connection cannot read a secret ────────────────────────
#
# By name, in one table, refused in the single function every property read goes
# through -- so a field added later cannot quietly become a leak, which a
# hand-written allowlist of safe fields would eventually permit.
grep -q 'SECRET_PROPERTIES' "$helper" \
|| fail 'the secret-holding properties are not named anywhere, so nothing can refuse them'
grep -q 'SECRET_SHAPE' "$helper" \
|| fail 'only a fixed list guards the secrets; a property NetworkManager adds later would leak until somebody noticed'
python3 - "$helper" <<'PY' || fail 'the property reader does not drop the secret properties'
import ast
import sys
source = open(sys.argv[1], encoding="utf-8").read()
tree = ast.parse(source)
secrets = set()
for node in tree.body:
target = ""
if isinstance(node, ast.AnnAssign):
target = getattr(node.target, "id", "")
elif isinstance(node, ast.Assign):
target = getattr(node.targets[0], "id", "")
if target != "SECRET_PROPERTIES":
continue
value = node.value
# frozenset({...}) / set({...}) is a call wrapped around the literal.
if isinstance(value, ast.Call) and value.args:
value = value.args[0]
try:
secrets = set(ast.literal_eval(value))
except (ValueError, TypeError):
secrets = set()
# The properties that actually hold a passphrase must be in it, or the table is
# decorative.
required = {"802-11-wireless-security.psk", "802-1x.password"}
if not required <= secrets:
print(f"missing from the refusal table: {sorted(required - secrets)}", file=sys.stderr)
raise SystemExit(1)
# And the filter has to be applied where NetworkManager is read, not at each
# use: one place that can be certain, rather than every caller remembering.
reader = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "listing"), None)
if reader is None:
print("no single function parses nmcli output", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0 if "is_secret" in ast.dump(reader) else 1)
PY
grep -q -- '--show-secrets' "$helper" \
&& fail 'the helper asks NetworkManager to print secrets; the details view has no use for them'
# ── Static: the enterprise password is read, not passed ──────────────────────
#
# The mechanism is a choice (libnm's GObject bindings when they are installed, a
# scripted `nmcli connection edit` otherwise), but neither may build a command
# that carries the password. This is the only check that reaches the libnm
# branch, because the dynamic half deliberately disables it.
grep -q 'sys.stdin' "$helper" \
|| fail 'the enterprise password is never read from stdin'
python3 - "$helper" <<'PY' || fail 'a command list in the helper carries the enterprise password'
import ast
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
# Names, not values: the value only exists at runtime. Any list literal that
# holds a system command and a password-shaped identifier is argv exposure.
SECRETISH = re.compile(r"(password|passwd|secret|psk|passphrase)", re.I)
COMMANDS = {"nmcli", "gsettings", "rfkill", "gdbus", "busctl", "dbus-send"}
bad = []
for node in ast.walk(ast.parse(source)):
if not isinstance(node, (ast.List, ast.Tuple)):
continue
literals = {element.value for element in node.elts
if isinstance(element, ast.Constant) and isinstance(element.value, str)}
if not literals & COMMANDS:
continue
for element in node.elts:
if isinstance(element, ast.Name) and SECRETISH.search(element.id):
bad.append(f"line {node.lineno}: {element.id}")
elif isinstance(element, ast.Attribute) and SECRETISH.search(element.attr):
bad.append(f"line {node.lineno}: .{element.attr}")
elif isinstance(element, ast.JoinedStr):
for part in ast.walk(element):
if isinstance(part, ast.Name) and SECRETISH.search(part.id):
bad.append(f"line {node.lineno}: f-string {part.id}")
if bad:
print("; ".join(bad), file=sys.stderr)
raise SystemExit(1)
PY
# ── Static: the service hands the password down the same way ─────────────────
grep -q 'PANAMA_NETWORK_HELPER' "$service" \
|| fail 'the service has no helper-path seam, so nothing can point it at a stub'
grep -q 'function joinEnterprise' "$service" \
|| fail 'the service cannot join an enterprise network'
python3 - "$service" <<'PY' || fail 'the service puts the enterprise password on the command line'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
start = text.find("function joinEnterprise")
if start < 0:
raise SystemExit(1)
depth = 0
end = start
for index in range(text.find("{", start), len(text)):
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
if depth == 0:
end = index
break
body = text[start:end + 1]
# Every argument list the function builds, checked for a password-shaped name.
lists = re.findall(r"\[[^\[\]]*\]", body)
if not lists:
print("joinEnterprise builds no argument list at all", file=sys.stderr)
raise SystemExit(1)
for argv in lists:
if re.search(r"(password|secret|passphrase|psk)", argv, re.I):
print(argv.strip()[:200], file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
grep -qE '\.write\(|stdinEnabled' "$service" \
|| fail 'the service never writes to the helper process stdin, so the password has no way in'
if [[ "${PANAMA_NETWORK_STATIC_ONLY:-0}" == "1" ]]; then
printf 'network tools contract: PASS (static)\n'
exit 0
fi
command -v jq >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no jq)\n'; exit 0; }
command -v python3 >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no python3)\n'; exit 0; }
# ── The fake machine ─────────────────────────────────────────────────────────
work="$(mktemp -d /tmp/panama-network-contract.XXXXXX)"
stub_dir="$work/bin"
state_dir="$work/state"
home_dir="$work/home"
config_home="$work/config"
state_home="$work/xdg-state"
run_dir="$work/run"
pystub="$work/pystub"
mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$config_home" "$state_home" "$run_dir" "$pystub/gi"
: >"$state_dir/argv"
: >"$state_dir/stdin"
printf '11111111-0000-0000-0000-000000000001\n22222222-0000-0000-0000-000000000002\n' \
>"$state_dir/uuids"
trap 'rm -rf "$work"' EXIT
# The stand-in for PyGObject. libnm's own branch hands the password to
# NetworkManager over D-Bus, which is correct and untestable from here -- it
# would configure the real machine. Forcing its absence puts the fallback under
# test instead, and that is the branch where a password could become an
# argument.
cat >"$pystub/gi/__init__.py" <<'GISTUB'
"""Stand-in for PyGObject, so panama-network takes its nmcli fallback."""
def require_version(namespace, version):
raise ValueError(f"Namespace {namespace} not available")
GISTUB
# nmcli, recorded rather than performed. `-g FIELD` is answered from a table
# that INCLUDES the secret properties: a helper that asked for a passphrase
# would be given one, which is what makes "no secret reached the JSON" a real
# result rather than a tautology.
cat >"$stub_dir/nmcli" <<STUB
#!/usr/bin/env bash
state="$state_dir"
printf 'nmcli %s\n' "\$*" >>"\$state/argv"
joined="\$*"
# The detail listing INCLUDES the secret properties. A helper that handed its
# output straight to the page would leak them, which is what makes "no secret
# reached the JSON" a result rather than a tautology.
detail() {
printf 'connection.id:Home Wi-Fi\n'
printf 'connection.uuid:22222222-0000-0000-0000-000000000002\n'
printf 'connection.type:802-11-wireless\n'
printf 'connection.autoconnect:yes\n'
printf '802-11-wireless.cloned-mac-address:random\n'
printf '802-11-wireless.ssid:panama-hotspot\n'
printf '802-11-wireless.band:bg\n'
printf 'GENERAL.STATE:activated\n'
printf 'GENERAL.DEVICES:wlp4s0\n'
printf 'GENERAL.HWADDR:AA:BB:CC:DD:EE:FF\n'
printf 'IP4.ADDRESS[1]:192.168.7.42/24\n'
printf 'IP6.ADDRESS[1]:fd00::42/64\n'
printf 'IP4.GATEWAY:192.168.7.1\n'
printf 'IP4.DNS[1]:192.168.7.1\n'
printf 'IP4.DNS[2]:1.1.1.1\n'
printf '802-11-wireless-security.psk:$WIFI_PSK\n'
printf '802-1x.password:$ENTERPRISE_PW\n'
printf 'vpn.secrets.password:$VPN_SECRET\n'
}
case "\$joined" in
*"connection edit"*)
# The only invocation that is fed anything, and it is drained under a
# timeout: every other one inherits whatever stdin the test runner had,
# and reading that would hang the suite.
timeout 5 cat >>"\$state/stdin" 2>/dev/null || true
exit 0 ;;
*"connection import"*)
printf '33333333-4444-5555-6666-777777777777\n' >>"\$state/uuids"
printf "Connection 'imported-profile' (33333333-4444-5555-6666-777777777777) successfully added.\n"
exit 0 ;;
*"device wifi show-password"*)
printf 'SSID: panama-hotspot\n'
printf 'Security: WPA2\n'
printf 'Password: $HOTSPOT_PW\n'
exit 0 ;;
*"device wifi hotspot"*)
printf "Device 'wlp4s0' successfully activated.\n"
exit 0 ;;
*"-f UUID connection show"*)
cat "\$state/uuids"
exit 0 ;;
*"-f DEVICE,TYPE device"*)
printf 'wlp4s0:wifi\nenp5s0:ethernet\nlo:loopback\n'
exit 0 ;;
*"device show"*)
printf 'GENERAL.HWADDR:AA:BB:CC:DD:EE:FF\n'
exit 0 ;;
*"connection show "*)
detail
exit 0 ;;
esac
exit 0
STUB
cat >"$stub_dir/gsettings" <<STUB
#!/usr/bin/env bash
printf 'gsettings %s\n' "\$*" >>"$state_dir/argv"
case "\$*" in
"get org.gnome.system.proxy mode") printf "'none'\n" ;;
*" port") printf '0\n' ;;
get*) printf "''\n" ;;
esac
exit 0
STUB
cat >"$stub_dir/rfkill" <<STUB
#!/usr/bin/env bash
printf 'rfkill %s\n' "\$*" >>"$state_dir/argv"
case "\$*" in
*-J*|*--json*)
printf '{"rfkilldevices":[{"id":0,"type":"wlan","device":"phy0","soft":"unblocked","hard":"unblocked"},{"id":1,"type":"bluetooth","device":"hci0","soft":"unblocked","hard":"unblocked"}]}\n' ;;
list*|"")
printf '0: phy0: Wireless LAN\n\tSoft blocked: no\n\tHard blocked: no\n'
printf '1: hci0: Bluetooth\n\tSoft blocked: no\n\tHard blocked: no\n' ;;
esac
exit 0
STUB
# Any other route out of this process is closed rather than left open.
for blocked in gdbus busctl dbus-send nm-connection-editor nmtui pkexec; do
cat >"$stub_dir/$blocked" <<STUB
#!/usr/bin/env bash
printf '$blocked %s\n' "\$*" >>"$state_dir/argv"
printf 'network tools contract: the helper reached for $blocked\n' >&2
exit 1
STUB
done
chmod +x "$stub_dir"/*
runh() {
env -i \
PATH="$stub_dir:/usr/bin:/bin" \
PYTHONPATH="$pystub" \
HOME="$home_dir" \
XDG_CONFIG_HOME="$config_home" \
XDG_STATE_HOME="$state_home" \
XDG_RUNTIME_DIR="$run_dir" \
DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \
DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \
LANG=C LC_ALL=C \
"$helper" "$@"
}
# The safety claim, verified rather than assumed.
for binary in nmcli gsettings rfkill; do
resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary")"
[[ "$resolved" == "$stub_dir/$binary" ]] \
|| fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one"
done
error_of() { runh "$@" 2>/dev/null | jq -r '.error // ""'; }
log() { cat "$state_dir/argv"; }
leak_in_scratch() {
grep -rlF "$1" "$home_dir" "$config_home" "$state_home" "$run_dir" 2>/dev/null | head -1
}
# ── details: the whole point is what is missing ──────────────────────────────
: >"$state_dir/argv"
details="$(runh details 'Home Wi-Fi' 2>/dev/null)" \
|| fail 'details failed against the stub NetworkManager'
jq -e 'has("ip4") and has("gateway") and has("dns") and has("mac") and has("macRandomized")' \
<<<"$details" >/dev/null || fail "details is missing part of its shape: $details"
jq -e '.dns | type == "array"' <<<"$details" >/dev/null \
|| fail 'dns is not a list, so a second nameserver has nowhere to go'
jq -e '(.ip4 | length) > 0 and (.gateway | length) > 0 and (.mac | length) > 0' <<<"$details" >/dev/null \
|| fail "details parsed nothing out of the connection listing: $details"
jq -e '.macRandomized == true' <<<"$details" >/dev/null \
|| fail 'a connection whose cloned MAC is "random" is not reported as randomized'
for secret in "$WIFI_PSK" "$ENTERPRISE_PW" "$VPN_SECRET"; do
grep -Fq "$secret" <<<"$details" \
&& fail 'a stored secret reached the details JSON, which the page renders'
done
# The absence above only means something if the secrets were on offer. They
# were: the very listing that produced the address, the gateway and the MAC
# carries all three, asked of the fake NetworkManager directly. So that block
# says the filter ran, not that there was nothing to filter.
served="$(PATH="$stub_dir:$PATH" nmcli -t -e no connection show 'Home Wi-Fi')"
for secret in "$WIFI_PSK" "$ENTERPRISE_PW" "$VPN_SECRET"; do
grep -Fq "$secret" <<<"$served" \
|| fail 'the fake NetworkManager offered no secret, so the details filter was never actually tested'
done
# Not just the values: a key shaped like a credential is where a future field
# would land without anybody noticing.
offenders="$(jq -r '[paths | map(tostring) | join(".")]
| map(select(test("(password|secret|psk|passphrase)$";"i"))) | join(", ")' <<<"$details")"
[[ -z "$offenders" ]] || fail "details carries credential-shaped fields: $offenders"
# ── forget and the two modifiers ─────────────────────────────────────────────
: >"$state_dir/argv"
runh forget 'Home Wi-Fi' >/dev/null 2>&1
grep -Fq 'connection delete Home Wi-Fi' "$state_dir/argv" \
|| fail "forget did not delete the connection profile: $(log)"
: >"$state_dir/argv"
runh set-autoconnect 'Home Wi-Fi' true >/dev/null 2>&1
grep -Fq 'connection modify' "$state_dir/argv" \
|| fail "set-autoconnect did not modify the profile: $(log)"
grep -Eq 'connection\.autoconnect +(yes|true)' "$state_dir/argv" \
|| fail "set-autoconnect did not set connection.autoconnect: $(log)"
: >"$state_dir/argv"
runh set-autoconnect 'Home Wi-Fi' false >/dev/null 2>&1
grep -Eq 'connection\.autoconnect +(no|false)' "$state_dir/argv" \
|| fail "turning autoconnect off did not reach nmcli as a negative: $(log)"
: >"$state_dir/argv"
mac_on="$(runh set-mac-random 'Home Wi-Fi' true 2>/dev/null)"
grep -Eq 'cloned-mac-address +random' "$state_dir/argv" \
|| fail "randomizing the MAC did not set the cloned address: $(log)"
# The setting does nothing until the connection comes back up, and a switch
# that appears to have taken effect when it has not is the whole bug.
grep -qi 'reconnect' <<<"$mac_on" \
|| fail "set-mac-random does not say a reconnect is needed: $mac_on"
: >"$state_dir/argv"
runh set-mac-random 'Home Wi-Fi' false >/dev/null 2>&1
grep -Eq 'cloned-mac-address +permanent' "$state_dir/argv" \
|| fail "turning randomization off did not restore the permanent address: $(log)"
# ── import-vpn picks its plugin from the extension ───────────────────────────
printf '[Interface]\n' >"$work/tunnel.conf"
printf 'client\n' >"$work/tunnel.ovpn"
printf 'not a tunnel\n' >"$work/tunnel.txt"
: >"$state_dir/argv"
imported="$(runh import-vpn "$work/tunnel.conf" 2>/dev/null)"
grep -Fq 'connection import type wireguard' "$state_dir/argv" \
|| fail "a .conf file was not imported as WireGuard: $(log)"
jq -e '((.name // "") | length > 0) and ((.uuid // "") | length > 0)' <<<"$imported" >/dev/null \
|| fail "import-vpn does not name the connection it made: $imported"
jq -e '.kind == "wireguard"' <<<"$imported" >/dev/null \
|| fail "a .conf import is not reported as WireGuard: $imported"
: >"$state_dir/argv"
ovpn="$(runh import-vpn "$work/tunnel.ovpn" 2>/dev/null)"
grep -Fq 'connection import type openvpn' "$state_dir/argv" \
|| fail "a .ovpn file was not imported as OpenVPN: $(log)"
jq -e '.kind == "openvpn"' <<<"$ovpn" >/dev/null \
|| fail "a .ovpn import is not reported as OpenVPN: $ovpn"
: >"$state_dir/argv"
[[ -n "$(error_of import-vpn "$work/tunnel.txt")" ]] \
|| fail 'a file that is neither .conf nor .ovpn was accepted for import'
grep -Fq 'connection import' "$state_dir/argv" \
&& fail 'an unimportable file still reached nmcli'
[[ -n "$(error_of import-vpn "$work/does-not-exist.conf")" ]] \
|| fail 'a path that does not exist was accepted for import'
# ── hotspot: the password comes back once and is written nowhere ─────────────
: >"$state_dir/argv"
hotspot="$(runh hotspot start panama-hotspot 2>/dev/null)"
grep -Fq 'device wifi hotspot' "$state_dir/argv" \
|| fail "starting a hotspot did not reach nmcli: $(log)"
grep -Fq 'device wifi show-password' "$state_dir/argv" \
|| fail 'the generated hotspot password is never read back, so the UI cannot show it'
grep -Fq "$HOTSPOT_PW" <<<"$hotspot" \
|| fail "the hotspot password is not returned to the caller: $hotspot"
# It may pass through the return value exactly once. It may not be an argument,
# and it may not be left behind on disk.
grep -Fq "$HOTSPOT_PW" "$state_dir/argv" \
&& fail 'the hotspot password was passed to a command, where /proc publishes it'
leaked="$(leak_in_scratch "$HOTSPOT_PW")"
[[ -z "$leaked" ]] || fail "the hotspot password was written to $leaked"
runh hotspot status >/dev/null 2>&1 || fail 'hotspot status failed'
: >"$state_dir/argv"
runh hotspot stop >/dev/null 2>&1
[[ -s "$state_dir/argv" ]] || fail 'stopping the hotspot did nothing at all'
# ── join-enterprise: the password arrives on stdin and never on argv ─────────
#
# The pin the whole file is built around. libnm is unavailable here by
# construction, so this is the nmcli fallback: the editor takes `set
# 802-1x.password …` as a line of input, which keeps the secret out of ps.
: >"$state_dir/argv"
: >"$state_dir/stdin"
enterprise_out="$(printf '%s\n' "$ENTERPRISE_PW" \
| runh join-enterprise 'Campus Secure' peap-mschapv2 '[email protected]' 2>"$work/enterprise.err")"
grep -Fq "$ENTERPRISE_PW" "$state_dir/argv" \
&& fail 'the enterprise password was passed as a command argument'
grep -Fq "$ENTERPRISE_PW" <<<"$enterprise_out" \
&& fail 'the enterprise password is echoed back in the helper output'
grep -Fq "$ENTERPRISE_PW" "$work/enterprise.err" \
&& fail 'the enterprise password was written to stderr'
leaked="$(leak_in_scratch "$ENTERPRISE_PW")"
[[ -z "$leaked" ]] || fail "the enterprise password was written to $leaked"
# And it did reach NetworkManager, or the verb is a no-op wearing a JSON hat.
grep -Fq 'connection edit' "$state_dir/argv" \
|| fail "join-enterprise never opened the connection editor: $(log)"
grep -Fq "$ENTERPRISE_PW" "$state_dir/stdin" \
|| fail 'the enterprise password never reached nmcli at all, on stdin or otherwise'
# The EAP method is a closed set, not free text handed to nmcli. Nothing is
# piped in on purpose: the arguments are checked before stdin is read, so a
# request that was always going to be refused must not sit waiting for a
# password first.
: >"$state_dir/argv"
bad_method="$(runh join-enterprise 'Campus Secure' ldap-md5 'gib' </dev/null 2>/dev/null \
| jq -r '.error // ""')"
[[ -n "$bad_method" ]] || fail 'an unknown EAP method was accepted'
grep -Fq 'connection edit' "$state_dir/argv" \
&& fail 'an unknown EAP method still reached nmcli'
[[ -n "$(runh join-enterprise 'Campus Secure' peap-mschapv2 'gib' </dev/null 2>/dev/null \
| jq -r '.error // ""')" ]] \
|| fail 'an enterprise join with no password was accepted'
# ── proxy and airplane are the settings they claim to be ─────────────────────
: >"$state_dir/argv"
proxy="$(runh proxy get 2>/dev/null)"
grep -Fq 'org.gnome.system.proxy' "$state_dir/argv" \
|| fail "proxy get does not read the GNOME proxy settings: $(log)"
jq -e 'has("mode")' <<<"$proxy" >/dev/null || fail "proxy get reports no mode: $proxy"
: >"$state_dir/argv"
runh proxy set manual 192.168.7.9 3128 >/dev/null 2>&1
grep -Fq 'set org.gnome.system.proxy mode' "$state_dir/argv" \
|| fail "setting a manual proxy did not set the mode: $(log)"
# All three or none: a proxy applied to http alone silently leaks the rest.
for scheme in http https socks; do
grep -Fq "org.gnome.system.proxy.$scheme host" "$state_dir/argv" \
|| fail "the manual proxy was not applied to $scheme, so that traffic ignores it"
done
[[ -n "$(error_of proxy set sideways)" ]] || fail 'an unknown proxy mode was accepted'
[[ -n "$(error_of proxy set manual 'host; reboot' 3128)" ]] \
|| fail 'a proxy host with a shell metacharacter was accepted'
[[ -n "$(error_of proxy set manual 192.168.7.9 99999)" ]] \
|| fail 'an impossible proxy port was accepted'
[[ -n "$(error_of proxy set auto 'javascript:alert(1)')" ]] \
|| fail 'a PAC URL that is not a URL was accepted'
: >"$state_dir/argv"
airplane="$(runh airplane status 2>/dev/null)"
grep -Fq 'rfkill' "$state_dir/argv" || fail "airplane status does not read rfkill: $(log)"
jq -e 'has("on")' <<<"$airplane" >/dev/null || fail "airplane status reports no state: $airplane"
: >"$state_dir/argv"
runh airplane set true >/dev/null 2>&1
grep -Eq 'rfkill +block' "$state_dir/argv" \
|| fail "turning airplane mode on did not block the radios: $(log)"
: >"$state_dir/argv"
runh airplane set false >/dev/null 2>&1
grep -Eq 'rfkill +unblock' "$state_dir/argv" \
|| fail "turning airplane mode off did not unblock the radios: $(log)"
[[ -n "$(error_of airplane set maybe)" ]] || fail 'a non-boolean was accepted for airplane mode'
# ── Names are validated here, not by nmcli ───────────────────────────────────
#
# The reason matters: without validation these still fail, because nmcli refuses
# them too -- so a check that only asks "did something error" passes with the
# validation deleted. Each must also leave the recording untouched, which is
# what says the refusal happened before anything was run.
long_name="$(printf 'a%.0s' {1..300})"
for bad in 'Home; rm -rf /' '-x-not-a-name' '' "$long_name" 'new
line'; do
: >"$state_dir/argv"
[[ -n "$(error_of details "$bad")" ]] \
|| fail "a bad connection name was accepted: ${bad@Q}"
[[ ! -s "$state_dir/argv" ]] \
|| fail "a bad connection name reached nmcli before being refused: ${bad@Q}"
[[ -n "$(error_of forget "$bad")" ]] \
|| fail "forget accepted a bad connection name: ${bad@Q}"
done
for bad in 'ssid; reboot' '-x-not-a-name' '' "$(printf 'a%.0s' {1..33})"; do
: >"$state_dir/argv"
[[ -n "$(error_of hotspot start "$bad")" ]] \
|| fail "a bad hotspot SSID was accepted: ${bad@Q}"
[[ ! -s "$state_dir/argv" ]] \
|| fail "a bad hotspot SSID reached nmcli before being refused: ${bad@Q}"
done
[[ -n "$(error_of set-autoconnect 'Home Wi-Fi' perhaps)" ]] \
|| fail 'a non-boolean was accepted for autoconnect'
[[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted'
# ── Nothing anywhere left a secret behind ───────────────────────────────────
for secret in "$WIFI_PSK" "$VPN_SECRET" "$ENTERPRISE_PW" "$HOTSPOT_PW"; do
leaked="$(leak_in_scratch "$secret")"
[[ -z "$leaked" ]] || fail "a secret was left behind in $leaked"
done
printf 'network tools contract: PASS (details, forget, autoconnect, MAC, import, hotspot, enterprise, proxy, airplane)\n'