firegex/backend/application/snapshots.py
av0idd4rk 863422f202 Add authenticated MCP server and agent guide
Expose 28 tools with shared REST operations, role-scoped bearer authentication, revisions, durable retries, snapshots, jobs and timed rollback. Add Docker verification harnesses and setup/workflow documentation in docs/mcp.md and inst.md. Validated with 330 unit tests, two consecutive 25-case MCP/import runs, and restart recovery; the full upstream regression run remains incomplete.
2026-10-23 09:46:44 +03:00

134 lines
5.9 KiB
Python

"""Configuration snapshots and compensation. No authentication state is copied."""
import copy
from application import services as svc, firewall as fw
from modules.services.models import STATUS
from modules.firewall.models import FirewallSettings
METRICS = {"blocked", "filtering_since", "over_limit_hits", "over_limit_first", "over_limit_last"}
def configuration(value):
if isinstance(value, dict):
return {k: configuration(v) for k, v in value.items() if k not in METRICS}
if isinstance(value, list):
return [configuration(v) for v in value]
if isinstance(value, bool):
return int(value)
return value
def public(value):
if isinstance(value, dict):
return {k: ("[redacted]" if v else None) if k in {"tls_key", "tls_cert", "password", "access_token"}
else public(v) for k, v in value.items()}
if isinstance(value, list):
return [public(v) for v in value]
return value
def service_snapshot(service_id):
svc.firewall.flush_blocks()
rows = svc.db.query("SELECT * FROM services WHERE service_id=?", service_id)
result = {"scope": "service", "service_id": service_id, "service": rows[0] if rows else None,
"addresses": [], "filters": [], "regexes": [], "functions": [], "code": {}}
if not rows:
return result
result["addresses"] = svc.db.query("SELECT * FROM service_addresses WHERE service_id=? ORDER BY address_id", service_id)
result["filters"] = svc.db.query("SELECT * FROM filters WHERE service_id=? ORDER BY position", service_id)
for row in result["filters"]:
fid = row["filter_id"]
result["regexes"] += svc.db.query("SELECT * FROM regexes WHERE filter_id=? ORDER BY regex_id", fid)
result["functions"] += svc.db.query("SELECT * FROM pyfilters WHERE filter_id=? ORDER BY position", fid)
if row["kind"] == "pyfilter":
result["code"][fid] = svc.read_code(fid)
return result
def firewall_snapshot():
policy = fw.firewall.policy
return {"scope": "firewall", "rules": svc_copy_rules(), "policy": getattr(policy, "value", policy),
"settings": fw.firewall.settings.model_dump(mode="json"), "enabled": fw.firewall.enabled}
def svc_copy_rules():
return [{k: v for k, v in r.items() if k != "rule_id"}
for r in fw.db.query("SELECT * FROM rules ORDER BY rule_id")]
def snapshot(scope, service_id=None):
if scope == "service":
return service_snapshot(service_id)
if scope == "firewall":
return firewall_snapshot()
return {"scope": "instance", "firewall": firewall_snapshot(), "services": [
service_snapshot(r["service_id"]) for r in svc.db.query("SELECT service_id FROM services ORDER BY service_id")]}
def insert_row(table, row):
# Tables and columns only come from our own snapshots/prepared candidates.
columns = list(row)
return (f'INSERT INTO "{table}" ({", ".join(chr(34)+k+chr(34) for k in columns)}) '
f'VALUES ({", ".join("?" for _ in columns)})', *[row[k] for k in columns])
def write_service_data(value):
"""Replace this service's rows, preserving live counters for identities that survive."""
value = copy.deepcopy(value)
sid = value["service_id"]
current = service_snapshot(sid)
tables = {"addresses": "service_addresses", "filters": "filters", "regexes": "regexes", "functions": "pyfilters"}
for key in ("filters", "regexes", "functions"):
identity = lambda r: (r.get("filter_id"), r.get("regex_id"), r.get("name") if key == "functions" else None)
old = {identity(r): r for r in current[key]}
for row in value[key]:
if identity(row) in old:
for metric in METRICS & row.keys():
row[metric] = old[identity(row)].get(metric, row[metric])
if current["service"] and value["service"]:
for metric in METRICS & value["service"].keys():
value["service"][metric] = current["service"].get(metric)
queries = [
("DELETE FROM regexes WHERE filter_id IN (SELECT filter_id FROM filters WHERE service_id=?)", sid),
("DELETE FROM pyfilters WHERE filter_id IN (SELECT filter_id FROM filters WHERE service_id=?)", sid),
("DELETE FROM filters WHERE service_id=?", sid),
("DELETE FROM service_addresses WHERE service_id=?", sid),
("DELETE FROM services WHERE service_id=?", sid),
]
if value["service"]:
queries.append(insert_row("services", value["service"]))
for key, table in tables.items():
queries.extend(insert_row(table, row) for row in value[key])
svc.db.queries(queries)
for fid in current["code"].keys() - value["code"].keys():
svc.clear_code(fid)
for fid, source in value["code"].items():
svc.write_code(fid, source)
async def restore(value):
if value["scope"] == "instance":
wanted = {s["service_id"] for s in value["services"]}
for row in svc.db.query("SELECT service_id FROM services"):
if row["service_id"] not in wanted:
await svc.delete_service(row["service_id"])
for service in value["services"]:
await restore(service)
await restore(value["firewall"])
elif value["scope"] == "firewall":
fw._write_rules(value["rules"])
fw.firewall.policy = value["policy"]
fw.firewall.settings = FirewallSettings(**value["settings"])
fw.firewall.enabled = value["enabled"]
await fw.firewall.reload()
await fw.refresh_frontend()
else:
sid = value["service_id"]
await svc.firewall.remove(sid, persist=False)
write_service_data(value)
if value["service"]:
await svc.firewall.reload()
manager = svc.firewall.get(sid)
if value["service"]["status"] == STATUS.ACTIVE and not manager.active:
raise RuntimeError("Restored configuration could not start its datapath")
await svc.refresh_frontend()