Shortcuts you invent, rules you write, gestures you own - all still just data

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 01:51:59 -04:00
parent f9e5d3f470
commit 06c53d6c21
48 changed files with 4749 additions and 140 deletions
+337 -3
View File
@@ -13,11 +13,16 @@ native service stays native.
panama-network details CONNECTION
panama-network forget CONNECTION
panama-network saved
panama-network set-autoconnect CONNECTION true|false
panama-network set-mac-random CONNECTION true|false
panama-network set-metered CONNECTION yes|no|auto
panama-network set-ip CONNECTION 4|6 auto
panama-network set-ip CONNECTION 4|6 manual ADDR/PREFIX GATEWAY DNS[,DNS...]
panama-network import-vpn FILE
panama-network hotspot start SSID | hotspot stop | hotspot status
panama-network join-enterprise SSID PROFILE IDENTITY [CA_CERT] (password on stdin)
panama-network join-hidden SSID PROFILE wpa-psk|sae|none (password on stdin)
panama-network proxy get
panama-network proxy set none
panama-network proxy set manual [HOST PORT]
@@ -87,6 +92,48 @@ SSID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9 _.:+()@'&#!-]{0,31}$")
HOSTNAME = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]{0,253}[A-Za-z0-9])?$")
PAC_URL = re.compile(r"^(https?|file)://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]{1,500}$")
# Static addressing. Written out per octet rather than as \d{1,3}, because
# 999.999.999.999 is four groups of three digits and is not an address -- and a
# static address that NetworkManager refuses is a connection that comes up with
# no address at all, which is a worse failure than being told to retype it.
IPV4 = re.compile(r"(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}")
# IPv6 in every form a person types one: full, compressed at either end, and
# "::" alone. One alternative per position the elision can take, which is long
# but is the only shape that accepts fd00::42 and refuses fd00:::42.
IPV6 = re.compile(
r"([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}"
r"|([0-9A-Fa-f]{1,4}:){1,7}:"
r"|([0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}"
r"|([0-9A-Fa-f]{1,4}:){1,5}(:[0-9A-Fa-f]{1,4}){1,2}"
r"|([0-9A-Fa-f]{1,4}:){1,4}(:[0-9A-Fa-f]{1,4}){1,3}"
r"|([0-9A-Fa-f]{1,4}:){1,3}(:[0-9A-Fa-f]{1,4}){1,4}"
r"|([0-9A-Fa-f]{1,4}:){1,2}(:[0-9A-Fa-f]{1,4}){1,5}"
r"|[0-9A-Fa-f]{1,4}:(:[0-9A-Fa-f]{1,4}){1,6}"
r"|:((:[0-9A-Fa-f]{1,4}){1,7}|:)"
)
# The prefix lengths, which are different numbers on the two stacks: /24 means
# something on both, /64 only on one.
IPV4_PREFIX = re.compile(r"3[0-2]|[12]?[0-9]")
IPV6_PREFIX = re.compile(r"12[0-8]|1[01][0-9]|[1-9]?[0-9]")
# How many nameservers a person may list. Not a NetworkManager limit -- a
# resolver stops trying long before this, and a field with twenty addresses in
# it is a typo rather than a configuration.
MAX_DNS = 6
# What a connection's metered flag is called, in each direction. NetworkManager
# spells "decide for yourself" as unknown; a settings page says "automatic",
# and the two must never be confused with "no", which is a claim.
METERED_NAMES = {"yes": "yes", "no": "no", "unknown": "auto", "": "auto"}
METERED_VALUES = {"yes": "yes", "no": "no", "auto": "unknown"}
# Key management for a hidden network, by the name the page offers. A closed
# set: an arbitrary key-mgmt string produces a profile that never associates,
# with nothing to say why.
WIFI_SECURITY = {"wpa-psk": "wpa-psk", "sae": "sae", "none": ""}
# The EAP profiles offered. A closed set, because "type your own EAP string"
# produces a profile that fails to authenticate with no way to tell why.
EAP_PROFILES = {
@@ -199,6 +246,69 @@ def require_connection(value: str) -> str:
return require(NAME, value, "That is not a connection name.")
def require_family(value: str) -> str:
if value not in ("4", "6"):
raise BoundaryError("An address is either IPv4 or IPv6.")
return value
def address_pattern(family: str) -> re.Pattern:
return IPV4 if family == "4" else IPV6
def require_cidr(family: str, value: str) -> str:
"""An address with its prefix, which NetworkManager will not take without.
Split from the right, because an IPv6 address is mostly colons and one
slash: rpartition finds the prefix wherever the address ends.
"""
address, separator, prefix = (value or "").rpartition("/")
if not separator:
raise BoundaryError("A static address needs a prefix, like 192.168.1.50/24."
if family == "4"
else "A static address needs a prefix, like fd00::42/64.")
require(address_pattern(family), address,
"That is not an IPv4 address." if family == "4" else "That is not an IPv6 address.")
require(IPV4_PREFIX if family == "4" else IPV6_PREFIX, prefix,
"An IPv4 prefix is a number from 0 to 32." if family == "4"
else "An IPv6 prefix is a number from 0 to 128.")
return f"{address}/{prefix}"
def require_gateway(family: str, value: str) -> str:
"""A gateway, or nothing. Empty is a real answer: a network segment with no
router is not a broken configuration, it is a network with no way out."""
text = (value or "").strip()
if text == "":
return ""
return require(address_pattern(family), text, "That is not a gateway address.")
def require_dns(family: str, value: str) -> list[str]:
"""The nameserver list, comma-separated as it is typed and as nmcli takes it.
Validated per stack rather than per address: NetworkManager stores ipv6.dns
as IPv6 addresses, and an IPv4 nameserver typed into the IPv6 box is a
profile it refuses to save with an error nobody can act on.
"""
servers = [part.strip() for part in (value or "").split(",") if part.strip()]
if len(servers) > MAX_DNS:
raise BoundaryError(f"That is more than {MAX_DNS} nameservers.")
for server in servers:
require(address_pattern(family), server, f"{server} is not a nameserver address.")
return servers
def plain(value: str) -> str:
"""nmcli's way of saying a property is unset, in either of its spellings."""
text = (value or "").strip()
return "" if text in ("--", "(none)") else text
def comma_list(value: str) -> list[str]:
return [part.strip() for part in plain(value).split(",") if part.strip()]
# ---------------------------------------------------------------- reading
@@ -284,6 +394,19 @@ def connection_state(name: str, note: str = "") -> dict:
"mac": "",
"macRandomized": False,
"autoconnect": False,
"metered": "auto",
# What the PROFILE says, which is not what the four fields above say.
# Those are the addresses on the wire; these are the addresses the
# profile asks for -- and the editor has to show the second, or a static
# address that has not been applied yet looks like it was never typed.
"ip4Method": "",
"ip4Addresses": [],
"ip4Gateway": "",
"ip4Dns": [],
"ip6Method": "",
"ip6Addresses": [],
"ip6Gateway": "",
"ip6Dns": [],
"note": note,
"error": "",
}
@@ -296,6 +419,13 @@ def connection_state(name: str, note: str = "") -> dict:
state["type"] = found.get("connection.type", "")
state["autoconnect"] = found.get("connection.autoconnect", "") in ("yes", "true")
state["macRandomized"] = first(found, *CLONED_MAC_KEYS).lower() == "random"
state["metered"] = METERED_NAMES.get(plain(found.get("connection.metered", "")), "auto")
for family, stack in (("4", "ipv4"), ("6", "ipv6")):
state[f"ip{family}Method"] = plain(found.get(f"{stack}.method", ""))
state[f"ip{family}Addresses"] = comma_list(found.get(f"{stack}.addresses", ""))
state[f"ip{family}Gateway"] = plain(found.get(f"{stack}.gateway", ""))
state[f"ip{family}Dns"] = comma_list(found.get(f"{stack}.dns", ""))
state["active"] = found.get("GENERAL.STATE", "") == "activated"
state["ip4"] = (indexed(found, "IP4.ADDRESS") or [""])[0]
@@ -348,6 +478,143 @@ def set_mac_random(name: str, enabled: bool) -> dict:
return connection_state(name, "Reconnect for this to take effect.")
def set_metered(name: str, mode: str) -> dict:
"""Whether this connection costs money by the byte.
Three states, not two. "Automatic" is NetworkManager deciding from what the
network told it, and it is not the same claim as "no" -- a page that folded
the two together would report a guess as a fact.
"""
require_connection(name)
value = METERED_VALUES.get(mode)
if value is None:
raise BoundaryError("A connection is metered, not metered, or left to NetworkManager.")
nmcli("connection", "modify", name, "connection.metered", value, timeout=60)
return connection_state(name)
def set_ip(name: str, family: str, method: str, address: str = "",
gateway: str = "", dns_text: str = "") -> dict:
"""One stack's addressing, written in a single nmcli call.
Manual and automatic are one setting rather than two: switching back to
automatic has to clear the addresses manual mode left behind, or
NetworkManager keeps them and the connection comes up holding both. So every
property this verb can write is written on every call, with the empty string
where one should go back to unset.
"""
require_connection(name)
require_family(family)
stack = "ipv4" if family == "4" else "ipv6"
# Every argument is checked before NetworkManager is asked anything at all.
# Reading the connection first would mean a typo in an address costs an
# nmcli invocation before it is refused -- and, worse, would make "did
# something error" pass with the validation deleted.
if method == "auto":
settings = [f"{stack}.method", "auto",
f"{stack}.addresses", "",
f"{stack}.gateway", "",
f"{stack}.dns", "",
f"{stack}.ignore-auto-dns", "no"]
elif method == "manual":
servers = require_dns(family, dns_text)
settings = [f"{stack}.method", "manual",
f"{stack}.addresses", require_cidr(family, address),
f"{stack}.gateway", require_gateway(family, gateway),
f"{stack}.dns", ",".join(servers),
# Without this NetworkManager appends the ones DHCP handed
# out to the ones just typed, which is not what a person
# choosing "manual" is asking for.
f"{stack}.ignore-auto-dns", "yes" if servers else "no"]
else:
raise BoundaryError("An address is either automatic or manual.")
before = connection_state(name)
if not before["exists"]:
raise BoundaryError("That connection no longer exists.")
nmcli("connection", "modify", name, *settings, timeout=60)
# Brought back up only if it was up. Activating a connection that was down
# is a different action, and doing it here would join a network on the
# strength of somebody editing its addresses.
if not before["active"]:
return connection_state(name, "Applies the next time this connection comes up.")
activation = run(["nmcli", "-w", "45", "connection", "up", name], timeout=60)
if activation.returncode != 0:
raise BoundaryError(refusal(
activation, "The addresses were saved, but the connection would not come back up."))
return connection_state(name, "Applied.")
# ---------------------------------------------------------------- saved profiles
# NAME is read last and split with a limit, because a network legitimately
# called "Cafe: Guest" would otherwise be cut in half by the field separator.
# Every field before it is a UUID, a keyword or a number, none of which can
# contain a colon.
SAVED_FIELDS = "UUID,TYPE,AUTOCONNECT,ACTIVE,TIMESTAMP,NAME"
def visible_ssids() -> set[str]:
"""What the last scan saw, for the in-range flag.
`--rescan no`, deliberately: listing the profiles this machine remembers
must not make the radio go looking, or opening a settings page would cost
airtime and drop throughput on the connection being looked at.
"""
result = run(["nmcli", "-t", "-e", "no", "-f", "SSID",
"device", "wifi", "list", "--rescan", "no"])
if result.returncode != 0:
return set()
return {line.strip() for line in result.stdout.splitlines()
if line.strip() and line.strip() != "--"}
def saved_connections() -> dict:
"""Every profile NetworkManager holds, including the ones nowhere near here.
The scan is what makes this more than `nmcli connection show`: a saved
network is otherwise invisible until you are standing next to it, which is
exactly when you are least able to go and tidy it up.
"""
tool("nmcli", "NetworkManager is not available.")
result = run(["nmcli", "-t", "-e", "no", "-f", SAVED_FIELDS, "connection", "show"])
if result.returncode != 0:
raise BoundaryError(refusal(result, "NetworkManager would not list the saved networks."))
in_range = visible_ssids()
entries = []
for line in result.stdout.splitlines():
parts = line.split(":", 5)
if len(parts) != 6:
continue
uuid, kind, autoconnect, active, timestamp, name = (part.strip() for part in parts)
if not name:
continue
wireless = "wireless" in kind or kind == "wifi"
entries.append({
"name": name,
"uuid": uuid,
"type": kind,
"wifi": wireless,
"autoconnect": autoconnect in ("yes", "true"),
"active": active in ("yes", "true"),
"lastUsed": int(timestamp) if timestamp.isdigit() else 0,
# Only a Wi-Fi profile can be out of range. A wired profile is not
# somewhere else; it is a cable, and saying "out of range" about one
# would be inventing a fact. None means the question does not apply.
"inRange": (name in in_range) if wireless else None,
})
entries.sort(key=lambda entry: (not entry["active"], not entry["autoconnect"],
entry["name"].lower()))
return {"connections": entries, "error": ""}
# ---------------------------------------------------------------- VPN import
@@ -631,6 +898,53 @@ def join_enterprise_nmcli(ssid: str, profile_name: str, identity: str,
raise BoundaryError(refusal(activation, "That network refused the sign-in."))
# ---------------------------------------------------------------- hidden Wi-Fi
def join_hidden(ssid: str, profile_name: str, security: str) -> dict:
"""A network that does not broadcast its name.
The only thing that makes this different from an ordinary join is
`802-11-wireless.hidden yes`: without it NetworkManager waits to be told the
network exists and never probes for it, so the profile saves and never
connects.
Validated before stdin is read, for the same reason join-enterprise is: a
request that was always going to be refused must not sit waiting for a
password first. The passphrase goes down the editor's stdin, never argv.
"""
require(SSID, ssid, "That is not a network name.")
require(NAME, profile_name, "That is not a connection name.")
if security not in WIFI_SECURITY:
raise BoundaryError("A hidden network is WPA2, WPA3, or open.")
secured = security != "none"
password = read_password() if secured else ""
if secured and not password:
raise BoundaryError("That network needs a password.")
script = [
f"set connection.id {profile_name}",
f"set 802-11-wireless.ssid {ssid}",
"set 802-11-wireless.hidden yes",
]
if secured:
script.append(f"set 802-11-wireless-security.key-mgmt {WIFI_SECURITY[security]}")
script.append(f"set 802-11-wireless-security.psk {password}")
script += ["save", "quit", ""]
nmcli("connection", "edit", "type", "wifi", "con-name", profile_name,
timeout=60, stdin_text="\n".join(script))
activation = run(["nmcli", "-w", "45", "connection", "up", profile_name], timeout=60)
if activation.returncode != 0:
raise BoundaryError(refusal(activation, "That network did not answer."))
state = connection_state(profile_name, "Joined.")
state["joined"] = state["exists"]
return state
# ---------------------------------------------------------------- proxy
@@ -791,7 +1105,11 @@ FALLBACKS = {
"connection": {"connection": "", "exists": False, "uuid": "", "type": "",
"interface": "", "active": False, "ip4": "", "ip6": "",
"gateway": "", "dns": [], "mac": "", "macRandomized": False,
"autoconnect": False, "note": ""},
"autoconnect": False, "metered": "auto",
"ip4Method": "", "ip4Addresses": [], "ip4Gateway": "", "ip4Dns": [],
"ip6Method": "", "ip6Addresses": [], "ip6Gateway": "", "ip6Dns": [],
"note": ""},
"saved": {"connections": []},
"import": {"name": "", "uuid": "", "kind": ""},
"hotspot": {"active": False, "ssid": "", "password": "",
"connection": HOTSPOT_CONNECTION, "band": "", "interface": ""},
@@ -800,10 +1118,14 @@ FALLBACKS = {
"hardBlocked": False, "radios": 0},
}
USAGE = ("Usage: panama-network details CONNECTION | forget CONNECTION | "
USAGE = ("Usage: panama-network details CONNECTION | forget CONNECTION | saved | "
"set-autoconnect CONNECTION true|false | set-mac-random CONNECTION true|false | "
"set-metered CONNECTION yes|no|auto | "
"set-ip CONNECTION 4|6 auto | "
"set-ip CONNECTION 4|6 manual ADDR/PREFIX GATEWAY DNS[,DNS...] | "
"import-vpn FILE | hotspot start SSID|stop|status | "
"join-enterprise SSID PROFILE IDENTITY [CA_CERT] | "
"join-hidden SSID PROFILE wpa-psk|sae|none | "
"proxy get | proxy set none|manual [HOST PORT]|auto [PAC_URL] | "
"airplane status | airplane set true|false")
@@ -816,6 +1138,14 @@ def dispatch(arguments: list[str]) -> tuple[str, dict]:
return "connection", connection_state(require_connection(rest[0]))
if verb == "forget" and len(rest) == 1:
return "connection", forget(rest[0])
if verb == "saved" and not rest:
return "saved", saved_connections()
if verb == "set-metered" and len(rest) == 2:
return "connection", set_metered(rest[0], rest[1])
if verb == "set-ip" and len(rest) == 3 and rest[2] == "auto":
return "connection", set_ip(rest[0], rest[1], "auto")
if verb == "set-ip" and len(rest) == 6 and rest[2] == "manual":
return "connection", set_ip(rest[0], rest[1], "manual", rest[3], rest[4], rest[5])
if verb == "set-autoconnect" and len(rest) == 2:
return "connection", set_autoconnect(rest[0], require_bool(rest[1]))
if verb == "set-mac-random" and len(rest) == 2:
@@ -831,6 +1161,8 @@ def dispatch(arguments: list[str]) -> tuple[str, dict]:
if verb == "join-enterprise" and len(rest) in (3, 4):
return "connection", join_enterprise(
rest[0], rest[1], rest[2], rest[3] if len(rest) == 4 else "")
if verb == "join-hidden" and len(rest) == 3:
return "connection", join_hidden(rest[0], rest[1], rest[2])
if verb == "proxy" and rest == ["get"]:
return "proxy", proxy_get()
if verb == "proxy" and len(rest) >= 2 and rest[0] == "set":
@@ -847,7 +1179,9 @@ def shape_for(arguments: list[str]) -> str:
verb = arguments[0] if arguments else ""
return {"details": "connection", "forget": "connection",
"set-autoconnect": "connection", "set-mac-random": "connection",
"join-enterprise": "connection", "import-vpn": "import",
"set-metered": "connection", "set-ip": "connection",
"join-enterprise": "connection", "join-hidden": "connection",
"saved": "saved", "import-vpn": "import",
"hotspot": "hotspot", "proxy": "proxy",
"airplane": "airplane"}.get(verb, "connection")