Tier 0: render what the services already decided, honestly
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -15,7 +15,7 @@ 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 add-port PORT/PROTO... | remove-port PORT/PROTO...
|
||||
panama-firewall set-zone INTERFACE ZONE
|
||||
panama-firewall set-default-zone ZONE
|
||||
"""
|
||||
@@ -401,10 +401,17 @@ def main(arguments: list[str]) -> int:
|
||||
name = require(SERVICE, arguments[1], "That is not a service name.")
|
||||
verb = "--add-service" if arguments[0] == "add-service" else "--remove-service"
|
||||
change(active_zone(), f"{verb}={name}")
|
||||
elif len(arguments) == 2 and arguments[0] in ("add-port", "remove-port"):
|
||||
spec = require(PORT_SPEC, arguments[1], "That is not a port.")
|
||||
elif len(arguments) >= 2 and arguments[0] in ("add-port", "remove-port"):
|
||||
# Several specs in one invocation, because one rule as a user sees
|
||||
# it is often two as firewalld stores it: the range Fedora opens is
|
||||
# a tcp range AND a udp range, and "close the range" that closed
|
||||
# only the tcp half would be a lie the page had already told.
|
||||
# Validated all-or-nothing first, so a bad spec at the end cannot
|
||||
# leave the firewall half-changed.
|
||||
specs = [require(PORT_SPEC, argument, "That is not a port.")
|
||||
for argument in arguments[1:]]
|
||||
verb = "--add-port" if arguments[0] == "add-port" else "--remove-port"
|
||||
change(active_zone(), f"{verb}={spec}")
|
||||
change(active_zone(), *[f"{verb}={spec}" for spec in specs])
|
||||
elif len(arguments) == 3 and arguments[0] == "set-zone":
|
||||
interface = require(INTERFACE, arguments[1], "That is not a network interface.")
|
||||
zone = require(ZONE, arguments[2], "That is not a zone.")
|
||||
@@ -416,7 +423,7 @@ def main(arguments: list[str]) -> int:
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-firewall snapshot | zone-info ZONE | add-service NAME | "
|
||||
"remove-service NAME | add-port PORT/PROTO | remove-port PORT/PROTO | "
|
||||
"remove-service NAME | add-port PORT/PROTO... | remove-port PORT/PROTO... | "
|
||||
"set-zone INTERFACE ZONE | set-default-zone ZONE")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
|
||||
@@ -143,32 +143,56 @@ def dnf_download_sizes() -> dict[str, int]:
|
||||
|
||||
|
||||
def dnf_updates() -> dict:
|
||||
"""Pending packages, or the reason there is no count.
|
||||
|
||||
A failure used to be indistinguishable from an empty list: any exit code
|
||||
outside 0/100 left `packages` empty and reported nothing, so a dnf that
|
||||
could not reach a repository produced a confident "Up to date". Not knowing
|
||||
and knowing there is nothing are different answers and are reported as
|
||||
different answers.
|
||||
"""
|
||||
if not shutil.which("dnf5"):
|
||||
return {"available": False, "count": 0, "packages": [], "securityCount": 0}
|
||||
return {"available": False, "count": 0, "packages": [], "securityCount": 0,
|
||||
"securityKnown": True, "error": ""}
|
||||
|
||||
result = run(["dnf5", "check-upgrade", "--json"], timeout=180)
|
||||
packages = []
|
||||
# dnf5 exits 100 when upgrades exist, 0 when none do. Both are success.
|
||||
if result.returncode in (0, 100):
|
||||
try:
|
||||
payload = json.loads(result.stdout or "{}")
|
||||
for entry in payload.get("upgrades", []):
|
||||
packages.append({
|
||||
"name": str(entry.get("name", "")),
|
||||
"version": str(entry.get("evr", "")),
|
||||
"repository": str(entry.get("repository", "")),
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# dnf5 exits 100 when upgrades exist, 0 when none do. Both are success;
|
||||
# anything else is dnf saying it could not answer, most often a repository
|
||||
# it could not reach.
|
||||
if result.returncode not in (0, 100):
|
||||
return {"available": True, "count": 0, "packages": [], "securityCount": 0,
|
||||
"securityKnown": False,
|
||||
"error": _refusal(result, "The package list could not be read.")}
|
||||
try:
|
||||
payload = json.loads(result.stdout or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return {"available": True, "count": 0, "packages": [], "securityCount": 0,
|
||||
"securityKnown": False,
|
||||
"error": "dnf answered with something that was not a package list."}
|
||||
|
||||
packages = []
|
||||
for entry in payload.get("upgrades", []):
|
||||
packages.append({
|
||||
"name": str(entry.get("name", "")),
|
||||
"version": str(entry.get("evr", "")),
|
||||
"repository": str(entry.get("repository", "")),
|
||||
})
|
||||
|
||||
# A separate question with a separate failure. This count only ever shrinks
|
||||
# the alarm -- the page says "nothing security-critical" when it is zero --
|
||||
# so an advisory query that failed and returned zero would be reassurance
|
||||
# nobody measured. Whether it is known is reported alongside it.
|
||||
security = 0
|
||||
security_known = True
|
||||
advisory = run(["dnf5", "check-upgrade",
|
||||
f"--advisory-severities={SECURITY_SEVERITIES}", "--json"], timeout=180)
|
||||
if advisory.returncode in (0, 100):
|
||||
try:
|
||||
security = len(json.loads(advisory.stdout or "{}").get("upgrades", []))
|
||||
except json.JSONDecodeError:
|
||||
security = 0
|
||||
security_known = False
|
||||
else:
|
||||
security_known = False
|
||||
|
||||
sizes = dnf_download_sizes() if packages else {}
|
||||
for package in packages:
|
||||
@@ -177,7 +201,7 @@ def dnf_updates() -> dict:
|
||||
|
||||
packages.sort(key=lambda item: item["name"])
|
||||
source = {"available": True, "count": len(packages), "packages": packages,
|
||||
"securityCount": security}
|
||||
"securityCount": security, "securityKnown": security_known, "error": ""}
|
||||
# Only when every pending package was priced. A partial total reads as the
|
||||
# whole download and would understate it, which is the direction that
|
||||
# surprises somebody on a metered connection.
|
||||
@@ -202,23 +226,28 @@ def human_bytes(text: str) -> int:
|
||||
|
||||
def flatpak_updates() -> dict:
|
||||
if not shutil.which("flatpak"):
|
||||
return {"available": False, "count": 0, "applications": []}
|
||||
return {"available": False, "count": 0, "applications": [], "error": ""}
|
||||
result = run(["flatpak", "remote-ls", "--updates",
|
||||
"--columns=application,version,origin,download-size"], timeout=120)
|
||||
# An unreachable remote exits non-zero and prints nothing usable. Reading
|
||||
# that as an empty list would report every application as current.
|
||||
if result.returncode != 0:
|
||||
return {"available": True, "count": 0, "applications": [],
|
||||
"error": _refusal(result, "The application list could not be read.")}
|
||||
applications = []
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.splitlines():
|
||||
parts = [part.strip() for part in line.split("\t")]
|
||||
if not parts or not parts[0]:
|
||||
continue
|
||||
application = {"id": parts[0],
|
||||
"version": parts[1] if len(parts) > 1 else "",
|
||||
"origin": parts[2] if len(parts) > 2 else ""}
|
||||
size = human_bytes(parts[3]) if len(parts) > 3 else 0
|
||||
if size:
|
||||
application["bytes"] = size
|
||||
applications.append(application)
|
||||
source = {"available": True, "count": len(applications), "applications": applications}
|
||||
for line in result.stdout.splitlines():
|
||||
parts = [part.strip() for part in line.split("\t")]
|
||||
if not parts or not parts[0]:
|
||||
continue
|
||||
application = {"id": parts[0],
|
||||
"version": parts[1] if len(parts) > 1 else "",
|
||||
"origin": parts[2] if len(parts) > 2 else ""}
|
||||
size = human_bytes(parts[3]) if len(parts) > 3 else 0
|
||||
if size:
|
||||
application["bytes"] = size
|
||||
applications.append(application)
|
||||
source = {"available": True, "count": len(applications),
|
||||
"applications": applications, "error": ""}
|
||||
if applications and all("bytes" in application for application in applications):
|
||||
source["downloadBytes"] = sum(application["bytes"] for application in applications)
|
||||
return source
|
||||
@@ -240,32 +269,54 @@ def strip_markup(text: str) -> str:
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
||||
|
||||
# fwupd reports "nothing to do" through the same Error channel it uses for
|
||||
# failures. These are not failures, and calling them one would put a red row on
|
||||
# a machine whose firmware is simply current.
|
||||
FWUPD_NOTHING_PENDING = ("no updates available", "no updatable devices",
|
||||
"no supported devices")
|
||||
|
||||
|
||||
def firmware_updates() -> dict:
|
||||
if not shutil.which("fwupdmgr"):
|
||||
return {"available": False, "count": 0, "devices": []}
|
||||
return {"available": False, "count": 0, "devices": [], "error": ""}
|
||||
result = run(["fwupdmgr", "get-updates", "--json"], timeout=120)
|
||||
devices = []
|
||||
# The exit code is not the signal here: `fwupdmgr --json` exits 0 even when
|
||||
# it failed and reports the failure inside the payload instead (verified
|
||||
# against fwupd on this machine). An answer is a payload carrying Devices or
|
||||
# Error; anything else -- including the empty output a crashed fwupdmgr
|
||||
# leaves -- is not an answer. This used to be `result.stdout or "{}"`, which
|
||||
# turned "printed nothing" into "no firmware updates".
|
||||
try:
|
||||
payload = json.loads(result.stdout or "{}")
|
||||
for device in payload.get("Devices", []):
|
||||
releases = device.get("Releases", [])
|
||||
entry = {
|
||||
"name": str(device.get("Name", "Unknown device")),
|
||||
"version": str(device.get("Version", "")),
|
||||
"target": str(releases[0].get("Version", "")) if releases else "",
|
||||
# Firmware that needs a reboot to flash is worth saying up front.
|
||||
"needsReboot": "needs-reboot" in json.dumps(device.get("Flags", [])),
|
||||
}
|
||||
# fwupd already has the vendor's release notes in hand, so they are
|
||||
# kept here rather than re-fetched: the changelog for firmware costs
|
||||
# nothing beyond the scan that found the update.
|
||||
notes = strip_markup(str(releases[0].get("Description", ""))) if releases else ""
|
||||
if notes:
|
||||
entry["changelog"] = notes[:CHANGELOG_LIMIT]
|
||||
devices.append(entry)
|
||||
payload = json.loads(result.stdout or "")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"available": True, "count": len(devices), "devices": devices}
|
||||
payload = None
|
||||
if not isinstance(payload, dict) or ("Devices" not in payload and "Error" not in payload):
|
||||
return {"available": True, "count": 0, "devices": [],
|
||||
"error": _refusal(result, "The firmware list could not be read.")}
|
||||
failure = payload.get("Error")
|
||||
if isinstance(failure, dict):
|
||||
message = str(failure.get("Message", "")).strip()
|
||||
if message and message.lower() not in FWUPD_NOTHING_PENDING:
|
||||
return {"available": True, "count": 0, "devices": [], "error": message[:200]}
|
||||
|
||||
devices = []
|
||||
for device in payload.get("Devices", []):
|
||||
releases = device.get("Releases", [])
|
||||
entry = {
|
||||
"name": str(device.get("Name", "Unknown device")),
|
||||
"version": str(device.get("Version", "")),
|
||||
"target": str(releases[0].get("Version", "")) if releases else "",
|
||||
# Firmware that needs a reboot to flash is worth saying up front.
|
||||
"needsReboot": "needs-reboot" in json.dumps(device.get("Flags", [])),
|
||||
}
|
||||
# fwupd already has the vendor's release notes in hand, so they are
|
||||
# kept here rather than re-fetched: the changelog for firmware costs
|
||||
# nothing beyond the scan that found the update.
|
||||
notes = strip_markup(str(releases[0].get("Description", ""))) if releases else ""
|
||||
if notes:
|
||||
entry["changelog"] = notes[:CHANGELOG_LIMIT]
|
||||
devices.append(entry)
|
||||
return {"available": True, "count": len(devices), "devices": devices, "error": ""}
|
||||
|
||||
|
||||
def automatic_state() -> dict:
|
||||
@@ -282,26 +333,75 @@ def automatic_state() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def probe_source(probe, empty: dict) -> dict:
|
||||
"""One source's answer, or its own failure -- never the other two's.
|
||||
|
||||
The three fail independently, which is the whole reason they are counted
|
||||
separately. A dnf that times out raises out of `run`, and left unhandled it
|
||||
would abort the entire check and blank the firmware and application lists
|
||||
along with it.
|
||||
"""
|
||||
try:
|
||||
return probe()
|
||||
except BoundaryError as error:
|
||||
return {**empty, "available": True, "count": 0, "error": str(error)}
|
||||
|
||||
|
||||
# What each source looks like with nothing in it. Shared by the failure path and
|
||||
# by a cache written before a field existed, so both produce the same shape.
|
||||
EMPTY_SOURCES = {
|
||||
"dnf": {"packages": [], "securityCount": 0, "securityKnown": False},
|
||||
"flatpak": {"applications": []},
|
||||
"firmware": {"devices": []},
|
||||
}
|
||||
|
||||
|
||||
def check() -> dict:
|
||||
previous = read_cache()
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"dnf": dnf_updates(),
|
||||
"flatpak": flatpak_updates(),
|
||||
"firmware": firmware_updates(),
|
||||
"checkedAt": int(time.time()),
|
||||
"dnf": probe_source(dnf_updates, EMPTY_SOURCES["dnf"]),
|
||||
"flatpak": probe_source(flatpak_updates, EMPTY_SOURCES["flatpak"]),
|
||||
"firmware": probe_source(firmware_updates, EMPTY_SOURCES["firmware"]),
|
||||
}
|
||||
for name, source in payload.items():
|
||||
# A source stamps its own clock only when it answered. A check that
|
||||
# failed must not make the count beside it look freshly verified --
|
||||
# that is exactly how a failed check came to read as "Up to date".
|
||||
source["checkedAt"] = (
|
||||
int(previous.get(name, {}).get("checkedAt", 0)) if source.get("error") else now
|
||||
)
|
||||
failed = any(source.get("error") for source in payload.values())
|
||||
# The overall stamp is the last time every source answered. Carried forward
|
||||
# rather than refreshed on a partial failure, so "Checked 2 minutes ago"
|
||||
# never describes a check that did not complete.
|
||||
payload["checkedAt"] = int(previous.get("checkedAt", 0)) if failed else now
|
||||
write_cache(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def cached_source(cached: dict, name: str) -> dict:
|
||||
"""A stored source, filled out to the shape this version expects.
|
||||
|
||||
A cache written before per-source errors and stamps existed carries neither
|
||||
field; defaulting them here means an old cache reads as "nothing recorded"
|
||||
instead of missing a key at the surface.
|
||||
"""
|
||||
empty = {"available": True, "count": 0, "error": "", "checkedAt": 0,
|
||||
**EMPTY_SOURCES[name]}
|
||||
source = cached.get(name)
|
||||
return {**empty, **source} if isinstance(source, dict) else empty
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
cached = read_cache()
|
||||
empty = {"available": True, "count": 0}
|
||||
return {
|
||||
"dnf": cached.get("dnf", {**empty, "packages": [], "securityCount": 0}),
|
||||
"flatpak": cached.get("flatpak", {**empty, "applications": []}),
|
||||
"firmware": cached.get("firmware", {**empty, "devices": []}),
|
||||
"dnf": cached_source(cached, "dnf"),
|
||||
"flatpak": cached_source(cached, "flatpak"),
|
||||
"firmware": cached_source(cached, "firmware"),
|
||||
# 0 means never checked, which the page says rather than showing a
|
||||
# confident "0 updates" it has no basis for.
|
||||
# confident "0 updates" it has no basis for. It only ever holds the time
|
||||
# of a check where every source answered.
|
||||
"checkedAt": int(cached.get("checkedAt", 0)),
|
||||
"kernel": kernel_state(),
|
||||
"automatic": automatic_state(),
|
||||
|
||||
Reference in New Issue
Block a user