firegex/backend/application/candidates.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

248 lines
13 KiB
Python

"""Prepare complete MCP changes before touching the running configuration."""
import base64
import copy
from application import services as svc, firewall as fw
from application.snapshots import service_snapshot, write_service_data, restore, configuration
from modules.services.models import Service, Address, Filter, Regex
from modules.services.transports import ChainLink, build_class
from modules.services.firewall import code_path
from modules.firewall.models import RuleModel
from mcp_api.auth import MCPError
DIRECTIONS = {"client_to_server": "C", "server_to_client": "S", "both": "B"}
def resolve_service(reference):
rows = svc.db.query("SELECT service_id FROM services WHERE service_id=? OR name=?", reference, reference)
if len(rows) != 1:
raise MCPError("NOT_FOUND" if not rows else "AMBIGUOUS_SERVICE", "Use a unique service name or ID")
return rows[0]["service_id"]
def resolve_filter(sid, reference):
rows = svc.db.query("SELECT filter_id FROM filters WHERE service_id=? AND (filter_id=? OR name=?)",
sid, reference, reference)
if len(rows) != 1:
raise MCPError("NOT_FOUND" if not rows else "AMBIGUOUS_FILTER", "Use a unique filter name or ID",
candidates=[r["filter_id"] for r in rows])
return rows[0]["filter_id"]
def check_chain(value):
srv = Service.from_dict(value["service"])
srv.addresses = [Address.from_dict(r) for r in value["addresses"]]
links = [ChainLink(Filter.from_dict(row),
regexes=[Regex.from_dict(r) for r in value["regexes"] if r["filter_id"] == row["filter_id"]],
code_path=code_path(row["filter_id"]),
functions=[r for r in value["functions"] if r["filter_id"] == row["filter_id"]])
for row in value["filters"]]
build_class(srv.transport).check(srv, links)
async def prepare_service(before, spec):
after = copy.deepcopy(before)
sid = before["service_id"]
changes = spec.model_dump(exclude_none=True, exclude={"addresses"})
if before["service"] is None:
if not spec.name or not spec.addresses:
raise MCPError("INVALID_ARGUMENT", "Creating a service requires name and addresses")
row = {"service_id": sid, "name": spec.name, "status": "stop", "proto": "tcp", "transport": "proxy",
"fail_open": 1, "max_connections": 0, "over_limit_forwards": 0, "first_byte_timeout": 0,
"tls_cert": None, "tls_key": None}
else:
row = dict(before["service"])
row.update(changes)
row["name"] = svc.refactor_name(row["name"])
if svc.db.query("SELECT service_id FROM services WHERE name=? AND service_id<>?", row["name"], sid):
raise MCPError("NAME_CONFLICT", "A service with that name already exists")
svc._check_tls_material(row["tls_cert"], row["tls_key"])
if row["proto"] in {"tls", "quic", "http"} and not (row["tls_cert"] and row["tls_key"]):
raise MCPError("INVALID_ARGUMENT", "This protocol needs a certificate and private key")
if spec.addresses is not None:
old = {r["address_id"]: r for r in before["addresses"]}
addresses = []
for item in spec.addresses:
form = svc.AddressForm(**item.model_dump(exclude={"address_id"}))
ip = svc.parse_ip_or_int(form.ip_int)
edge = svc._address_edge(row["proto"], form)
aid = item.address_id
if aid and aid not in old:
raise MCPError("NOT_FOUND", "address_id does not belong to this service")
if not aid:
aid = next((a["address_id"] for a in before["addresses"]
if (a["ip_int"], a["port"], a["edge"]) == (ip, form.port, edge)), svc.gen_id())
addresses.append({"address_id": aid, "service_id": sid, "ip_int": ip, "port": form.port,
"proto": svc.L4.l4_of(edge), "edge": edge,
"target_port": svc._address_target(row["transport"], form),
"upstream": svc._address_upstream(row["proto"], edge, form),
"proxy_ip": svc._hijack_ip(row["transport"], ip, form),
"proxy_port": svc._hijack_port(row["transport"], form)})
else:
addresses = [svc._address_under(a, row["proto"]) for a in before["addresses"]]
seen, proxy_seen, ids = set(), set(), set()
for address in addresses:
key = (address["ip_int"], address["port"], address["proto"])
if key in seen or address["address_id"] in ids or svc.db.query(
"SELECT address_id FROM service_addresses WHERE ip_int=? AND port=? AND proto=? AND service_id<>?", *key, sid
):
raise MCPError("ADDRESS_CONFLICT", "An address is duplicated or already belongs to another service")
seen.add(key)
ids.add(address["address_id"])
if row["transport"] != "proxy" and address["target_port"]:
raise MCPError("UNSUPPORTED_COMBINATION", "Published target ports require the proxy transport")
if row["transport"] == "external":
if not svc.is_ip_parse(address["ip_int"]) or not address["proxy_port"]:
raise MCPError("UNSUPPORTED_COMBINATION", "External transport requires a concrete IP and proxy port")
key = (address["proxy_ip"], address["proxy_port"])
if key in proxy_seen or svc.db.query(
"SELECT address_id FROM service_addresses WHERE proxy_ip=? AND proxy_port=? AND service_id<>?", *key, sid
):
raise MCPError("ADDRESS_CONFLICT", "This external proxy endpoint is already in use")
proxy_seen.add(key)
else:
address.update(proxy_ip=None, proxy_port=None)
after.update(service=row, addresses=sorted(addresses, key=lambda a: a["address_id"]))
check_chain(after)
if row["proto"] == "udp":
for code in after["code"].values():
checked = await svc.validate_code(code)
if not checked["ok"] or svc.needs_a_stream(checked.get("models", [])):
raise MCPError("UNSUPPORTED_COMBINATION", "A Python filter cannot load on this UDP service", check=checked)
return after
async def commit_service(before, after):
sid = after["service_id"]
if configuration(before) == configuration(after):
return {"service_id": sid, "changed": False}
old, new = before["service"], after["service"]
old_addresses = {a["address_id"]: a for a in before["addresses"]}
new_addresses = {a["address_id"]: a for a in after["addresses"]}
append_only = old and all(k == "name" or old.get(k) == v for k, v in new.items()) and all(
new_addresses.get(k) == v for k, v in old_addresses.items())
if append_only:
write_service_data(after)
manager = svc.firewall.get(sid)
manager.srv.name = new["name"]
for aid in new_addresses.keys() - old_addresses.keys():
await manager.address_added(aid, lambda: write_service_data(before))
await svc.refresh_frontend()
else:
await restore(after)
return {"service_id": sid, "observed_state": "running" if svc.firewall.get(sid).active else "stopped"}
async def prepare_filter(before, filter_id, spec):
after = copy.deepcopy(before)
sid = before["service_id"]
old = next((f for f in after["filters"] if f["filter_id"] == filter_id), None)
if old is None:
if spec.kind is None:
raise MCPError("INVALID_ARGUMENT", "Creating a filter requires kind")
row = {"filter_id": filter_id, "service_id": sid, "position": len(after["filters"]),
"kind": spec.kind, "name": spec.name or spec.kind, "active": 1, "blocked": 0, "proto": "tcp"}
after["filters"].append(row)
else:
row = old
if spec.kind is not None and spec.kind != row["kind"]:
raise MCPError("INVALID_ARGUMENT", "An existing filter cannot change kind")
if spec.name is not None:
row["name"] = svc.refactor_name(spec.name)
if spec.active is not None:
row["active"] = int(spec.active)
if row["kind"] == "regex":
if spec.code is not None or spec.functions is not None:
raise MCPError("INVALID_ARGUMENT", "Code/functions belong to a pyfilter")
if spec.patterns is not None:
prior = {r["regex_id"]: r for r in after["regexes"] if r["filter_id"] == filter_id}
patterns, seen = [], set()
for item in spec.patterns:
ok, why = await svc.check_pattern(item.pattern, item.case_sensitive)
if not ok:
raise MCPError("FILTER_COMPILE_ERROR", why, pattern=item.pattern)
mode = DIRECTIONS[item.direction]
encoded = base64.b64encode(item.pattern.encode()).decode()
key = (encoded, mode, item.case_sensitive)
if key in seen:
raise MCPError("INVALID_ARGUMENT", "Duplicate pattern in this filter")
seen.add(key)
rid = item.regex_id
if rid and rid not in prior:
raise MCPError("NOT_FOUND", "regex_id does not belong to this filter")
if not rid:
rid = next((r["regex_id"] for r in prior.values() if
(r["regex"], r["mode"], bool(r["case_sensitive"])) == key), svc.gen_id())
if any(r["regex_id"] == rid for r in patterns):
raise MCPError("INVALID_ARGUMENT", "Duplicate regex_id")
patterns.append({"regex_id": rid, "filter_id": filter_id, "regex": encoded, "mode": mode,
"case_sensitive": int(item.case_sensitive), "active": int(item.active),
"blocked": prior.get(rid, {}).get("blocked", 0)})
after["regexes"] = [r for r in after["regexes"] if r["filter_id"] != filter_id] + patterns
else:
if spec.patterns is not None:
raise MCPError("INVALID_ARGUMENT", "Patterns belong to a regex filter")
source = spec.code if spec.code is not None else after["code"].get(filter_id, "")
checked = await svc.validate_code(source)
if not checked["ok"]:
raise MCPError("FILTER_COMPILE_ERROR", "Python filter could not load", check=checked)
if after["service"]["proto"] == "udp" and svc.needs_a_stream(checked.get("models", [])):
raise MCPError("UNSUPPORTED_COMBINATION", "This filter requires a stream; the service carries UDP datagrams")
row["proto"] = checked.get("proto") or "tcp"
after["code"][filter_id] = source
prior = {r["name"]: r for r in after["functions"] if r["filter_id"] == filter_id}
names = checked.get("filters", [])
if spec.functions and set(spec.functions) - set(names):
raise MCPError("NOT_FOUND", "Unknown Python function names", functions=sorted(set(spec.functions) - set(names)))
after["functions"] = [r for r in after["functions"] if r["filter_id"] != filter_id] + [
{"filter_id": filter_id, "name": name, "position": i,
"active": int((spec.functions or {}).get(name, prior.get(name, {}).get("active", True))),
"blocked": prior.get(name, {}).get("blocked", 0)} for i, name in enumerate(names)]
if spec.position is not None:
if spec.position >= len(after["filters"]):
raise MCPError("INVALID_ARGUMENT", "position must be within the resulting chain")
after["filters"].remove(row)
after["filters"].insert(spec.position, row)
for i, item in enumerate(after["filters"]):
item["position"] = i
after["regexes"].sort(key=lambda r: (r["filter_id"], r["regex_id"]))
check_chain(after)
return after
async def commit_chain(before, after):
if configuration(before) == configuration(after):
return {"service_id": after["service_id"], "changed": False}
write_service_data(after)
await svc._apply_chain(after["service_id"], lambda: write_service_data(before))
# A changed matcher's old history belongs to its filter, as in the REST editor.
previous = {r["regex_id"]: r for r in before["regexes"]}
for row in after["regexes"]:
old = previous.get(row["regex_id"])
if old and old["regex"] != row["regex"]:
svc.db.query("UPDATE regexes SET blocked=0 WHERE regex_id=?", row["regex_id"])
svc.stats.disown_rule(svc.db, row["filter_id"], row["regex_id"])
removed = {f["filter_id"] for f in before["filters"]} - {f["filter_id"] for f in after["filters"]}
for fid in removed:
svc.stats.forget_filter(svc.db, fid)
await svc.refresh_frontend()
return {"service_id": after["service_id"], "chain": [r["filter_id"] for r in after["filters"]]}
def prepare_firewall(before, patch):
after = copy.deepcopy(before)
if patch.rules is not None:
rows = [fw.parse_and_check_rule(RuleModel(**r.model_dump(exclude={"id"}))).model_dump(mode="json") for r in patch.rules]
seen = set()
for row in rows:
key = tuple(row[k] for k in ("proto", "src", "dst", "port_src_from", "port_src_to",
"port_dst_from", "port_dst_to", "mode", "table"))
if key in seen:
raise MCPError("INVALID_ARGUMENT", "Duplicate firewall rule")
seen.add(key)
after["rules"] = rows
if patch.policy is not None:
after["policy"] = patch.policy
if patch.settings:
after["settings"].update(patch.settings)
return after