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
+67 -3
View File
@@ -13,6 +13,7 @@ publish on all interfaces.
Changes go through firewall-cmd, which is polkit-aware, so they prompt.
panama-firewall snapshot
panama-firewall zone-info ZONE
panama-firewall add-service NAME | remove-service NAME
panama-firewall add-port PORT/PROTO | remove-port PORT/PROTO
panama-firewall set-zone INTERFACE ZONE
@@ -305,6 +306,57 @@ def snapshot() -> dict:
}
# Zone targets, as a sentence rather than firewalld's vocabulary. "default" is
# the one that catches people out: it does not mean "the default zone", it means
# "reject anything no rule allowed", which is the answer someone browsing zones
# is actually looking for.
TARGETS = {
"": "anything no rule allows is rejected",
"default": "anything no rule allows is rejected",
"%%REJECT%%": "anything no rule allows is rejected, with a refusal sent back",
"REJECT": "anything no rule allows is rejected, with a refusal sent back",
"DROP": "anything no rule allows is dropped without an answer",
"ACCEPT": "anything not explicitly blocked is allowed in",
}
def zone_info(name: str) -> dict:
"""One zone, described -- read-only, for browsing before choosing.
Separate from `snapshot` because the zone browser asks about zones this
machine is not using, and a snapshot only ever describes the active ones.
Nothing here changes anything, so it needs no authorization and no confirm.
"""
require(ZONE, name, "That is not a zone.")
known = firewall("--get-zones").split()
if name not in known:
raise BoundaryError("There is no zone by that name.")
detail = zone_detail(name)
if not detail:
raise BoundaryError("That zone could not be read.")
services = detail.get("services", [])
ports = detail.get("ports", [])
parts = []
parts.append(f"{len(services)} service{'' if len(services) == 1 else 's'}")
parts.append(f"{len(ports)} port rule{'' if len(ports) == 1 else 's'}")
summary = ", ".join(parts) + "; " + TARGETS.get(
detail.get("target", ""), "custom handling for anything no rule allows")
return {
"zone": name,
"services": services,
"ports": ports,
"interfaces": detail.get("interfaces", []),
"target": detail.get("target", ""),
"richRules": detail.get("richRules", []),
"isDefault": name == firewall("--get-default-zone"),
"summary": summary,
"error": "",
}
def require(pattern: re.Pattern, value: str, message: str) -> str:
if not pattern.fullmatch(value or ""):
raise BoundaryError(message)
@@ -333,6 +385,18 @@ def main(arguments: list[str]) -> int:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
# Read-only, so it answers with its own shape rather than a snapshot --
# the browser wants one zone described, not the machine's exposure.
if len(arguments) == 2 and arguments[0] == "zone-info":
try:
answer = zone_info(arguments[1])
except BoundaryError as error:
answer = {"zone": arguments[1], "services": [], "ports": [],
"interfaces": [], "target": "", "richRules": [],
"isDefault": False, "summary": "", "error": str(error)}
print(json.dumps(answer, separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] in ("add-service", "remove-service"):
name = require(SERVICE, arguments[1], "That is not a service name.")
verb = "--add-service" if arguments[0] == "add-service" else "--remove-service"
@@ -351,9 +415,9 @@ def main(arguments: list[str]) -> int:
firewall(f"--set-default-zone={zone}", timeout=120)
else:
raise BoundaryError(
"Usage: panama-firewall snapshot | add-service NAME | remove-service NAME | "
"add-port PORT/PROTO | remove-port PORT/PROTO | set-zone INTERFACE ZONE | "
"set-default-zone ZONE")
"Usage: panama-firewall snapshot | zone-info ZONE | add-service NAME | "
"remove-service NAME | add-port PORT/PROTO | remove-port PORT/PROTO | "
"set-zone INTERFACE ZONE | set-default-zone ZONE")
except BoundaryError as error:
try:
state = snapshot()