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:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user