"""Services: one network layer, one ordered chain of filters on top of it. This is the module that replaced `nfregex`, `nfproxy` and `tls`. Those were three ways of saying the same two things — how to intercept, and what to do — with the two welded together, so choosing regexes also chose NFQUEUE, and choosing TLS meant creating a second object and keeping its address in step with the first. Here a service says only where the traffic is and how to reach it. Filters are attached to it, in an order the operator chooses, and can be added, reordered, switched off and switched on without the service being recreated or a single connection being dropped. """ import asyncio import base64 import binascii import json import os import secrets import sqlite3 import sys import time import traceback from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import PlainTextResponse from pydantic import BaseModel from modules.services.firewall import ( CODE_DIR, FirewallManager, clear_code, read_code, write_code, ) from modules.services import stats from modules.services.logs import log_for from modules.services.models import (KIND, L4, MODE, PROTO, STATUS, Service, TRANSPORT, UPSTREAM) from modules.services.nftables import FiregexTables, hijack_endpoint, one_address from modules.services import transports from modules.services.transports import PROXY_ENGINE, PYWORKER, UnsupportedChain from utils import ( PortType, ip_family, ip_parse, is_ip_parse, parse_ip_or_int, refactor_name, socketio_emit, ) from utils.models import ResetRequest, StatusMessageModel from utils.sqlite import SQLite def _counters_written() -> None: """Every request sees the block counters as they are, and changes them from there. They are written once a second (`BLOCK_FLUSH` in the manager), not per block — so a read would otherwise be up to a second behind, and an edit that resets a pattern's counter would have the old pattern's last blocks written onto the new one after it. """ firewall.flush_blocks() app = APIRouter(dependencies=[Depends(_counters_written)]) db = SQLite( "db/services.db", { "services": { "service_id": "VARCHAR(100) PRIMARY KEY", "name": "VARCHAR(100) NOT NULL UNIQUE", "status": 'VARCHAR(100) NOT NULL CHECK (status IN ("active", "stop"))', # What the service speaks, which is not always what the kernel matches on: # `tls` is TCP on the wire and `quic` is UDP. The address carries the second # answer. "proto": 'VARCHAR(4) NOT NULL CHECK (proto IN ("tcp", "udp", "tls", "quic", "http"))', "transport": 'VARCHAR(20) NOT NULL CHECK (transport IN ("nfqueue", "proxy", "external"))', "fail_open": "BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1", # How many connections (or UDP flows) this service may carry at once, and # what happens to the next one. `0` is no limit, which is what every service # had before there was one — kept as a value rather than as an absence, # because "unlimited" is a choice an operator can make and should have to. "max_connections": "INTEGER NOT NULL DEFAULT 0", # 0: refuse what does not fit. 1: forward it with no filter in front of it. "over_limit_forwards": "BOOLEAN NOT NULL CHECK (over_limit_forwards IN (0, 1)) DEFAULT 0", # Seconds a connection may carry nothing in either direction before it is # closed. 0 is off. Only until the *first* byte: a session that has spoken # and gone quiet is a session, and sessions are allowed to think. "first_byte_timeout": "INTEGER NOT NULL DEFAULT 0", # The trace. A counter in the log would be gone with the next thousand lines, # and the ring the live log keeps is bounded on purpose; this survives a # restart, so "did we ever hit the wall" has an answer tomorrow morning. "over_limit_hits": "INTEGER NOT NULL DEFAULT 0", #: When it first happened and when it last did, unix seconds. Two instants #: rather than one, because "it started an hour ago and is still going" and #: "it happened once at 3am" are different situations. "over_limit_first": "INT", "over_limit_last": "INT", "tls_cert": "TEXT", "tls_key": "TEXT", # The first instant this service could refuse anything: running, with a # filter attached. Statistics start there rather than at the beginning of # retention, because the hours before a service existed are not quiet hours # — they are hours the question was not being asked, and drawing them as an # empty chart says the opposite. Never cleared once set: a service stopped # and started again has a real gap in the middle, and that is worth seeing. "filtering_since": "INT", }, # Where a service is reachable. A list, because one service routinely answers on # more than one address — a v4 and a v6 one, a public and an internal one, two # ports of the same daemon — and making the operator create one service per # address meant keeping their filter chains in step by hand. "service_addresses": { "address_id": "VARCHAR(100) PRIMARY KEY", "service_id": "VARCHAR(100) NOT NULL", "ip_int": "VARCHAR(100) NOT NULL", "port": "INT NOT NULL CHECK(port > 0 and port < 65536)", # What clients speak at this address, in the same words a service speaks # them: `tls` is TCP with TLS on it, `quic` is UDP with QUIC. For every # protocol but `http` it is the service's own and nobody is asked; `http` is # the one that means "each address says", which is what it has always meant. "edge": 'VARCHAR(4) NOT NULL CHECK (edge IN ("tcp", "udp", "tls", "quic")) ' "DEFAULT 'tcp'", # Where this address's traffic actually goes, when that is not the port it # arrived on. NULL is transparent — the address is the service — which is # what firegex did and only did until an address could be a *publication*: # a service answering in the clear on :80 reached over TLS on :443 without # moving. Only the port: the same host, because what is being published is a # service this instance is already in front of. "target_port": "INT CHECK(target_port IS NULL OR " "(target_port > 0 AND target_port < 65536))", # What the service behind *this address* speaks, which used to be assumed to # be whatever the client spoke. `same` is that assumption and the default; # `tcp` and `tls` say the service speaks HTTP/1.1 — in the clear or under its # own TLS — whatever arrived at the front, which is what puts an ordinary web # service behind a TLS or HTTP/3 edge with firegex terminating on its behalf. # # On the address rather than on the service because that is where it has an # answer: one daemon reached over TLS on one port and in the clear on another # is re-encrypted for the first and handed the plaintext for the second. "upstream": 'VARCHAR(8) NOT NULL CHECK (upstream IN ("same", "tcp", "tls")) ' "DEFAULT 'same'", # The transport the *kernel* sees, derived from the service's own: `tls` is # TCP on the wire. Kept here so `(ip, port, proto)` is a usable uniqueness # key — a TCP service and a UDP one may share an address exactly as the # kernel allows, while a TCP service and a TLS one may not, because that is # the same port twice. "proto": 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))', # Where the operator's own proxy is listening for *this* address. Only for # `external`; loopback when they do not say otherwise, which is where such a # proxy normally is. "proxy_ip": "VARCHAR(100)", "proxy_port": "INT CHECK(proxy_port IS NULL OR (proxy_port > 0 AND proxy_port < 65536))", "FOREIGN KEY (service_id)": "REFERENCES services (service_id)", }, # The chain. `position` is what makes it ordered, and the operator sets it. "filters": { "filter_id": "VARCHAR(100) PRIMARY KEY", "service_id": "VARCHAR(100) NOT NULL", "position": "INT NOT NULL", "kind": 'VARCHAR(20) NOT NULL CHECK (kind IN ("regex", "pyfilter"))', # Only meaningful for a pyfilter, and never chosen: read off the code when # it is saved, and stored so the interface can show it without executing # anything. The code stays the only thing that decides it. "proto": 'VARCHAR(10) NOT NULL CHECK (proto IN ("tcp", "http")) DEFAULT "tcp"', "name": "VARCHAR(100) NOT NULL", "active": "BOOLEAN NOT NULL CHECK (active IN (0, 1)) DEFAULT 1", "blocked": "INTEGER UNSIGNED NOT NULL DEFAULT 0", "FOREIGN KEY (service_id)": "REFERENCES services (service_id)", }, # One row per `@pyfilter` function a filter's code defines. Derived from the # code and reconciled with it every time the code is saved — the function list # is the file's to decide — but `active` and `blocked` are the operator's, and # survive an edit that leaves the function in place. Turning a function off is # how you stop consulting it without deleting the code that defines it. "pyfilters": { "filter_id": "VARCHAR(100) NOT NULL", "name": "VARCHAR(100) NOT NULL", "active": "BOOLEAN NOT NULL CHECK (active IN (0, 1)) DEFAULT 1", "blocked": "INTEGER UNSIGNED NOT NULL DEFAULT 0", # Where the function sits in its file. The library runs them in definition # order, so listing them in any other order — this was `ORDER BY name`, and # before that the run order itself was a set's — shows the operator a # sequence that is not the one deciding their traffic. "position": "INTEGER NOT NULL DEFAULT 0", "FOREIGN KEY (filter_id)": "REFERENCES filters (filter_id)", }, # The patterns inside one regex filter. They are compiled together into a # single hyperscan database, which is why they belong to a filter rather than # each being one. "regexes": { "regex_id": "VARCHAR(100) PRIMARY KEY", "filter_id": "VARCHAR(100) NOT NULL", "regex": "TEXT NOT NULL", "mode": 'VARCHAR(1) NOT NULL CHECK (mode IN ("C", "S", "B"))', "case_sensitive": "BOOLEAN NOT NULL CHECK (case_sensitive IN (0, 1)) DEFAULT 1", "active": "BOOLEAN NOT NULL CHECK (active IN (0, 1)) DEFAULT 1", "blocked": "INTEGER UNSIGNED NOT NULL DEFAULT 0", "FOREIGN KEY (filter_id)": "REFERENCES filters (filter_id)", }, # How much each filter refused, in fixed time buckets. Bounded and coalesced — # see `modules/services/stats.py`, where both properties are the point. "block_history": { "service_id": "VARCHAR(100) NOT NULL", "filter_id": "VARCHAR(100) NOT NULL", # Whatever the datapath reported: a pattern id, `/`, or the # filter itself. Kept at this resolution so that asking for a time range # answers the same question everywhere on the page — the chart, the totals # and the shares — instead of the chart honouring it and the table beside it # quietly reporting all of time. "rule_id": "VARCHAR(200) NOT NULL", "bucket": "INTEGER NOT NULL", "blocked": "INTEGER UNSIGNED NOT NULL DEFAULT 0", }, "QUERY": [ "CREATE UNIQUE INDEX IF NOT EXISTS unique_block_bucket " "ON block_history (rule_id, bucket);", "CREATE INDEX IF NOT EXISTS block_history_by_service " "ON block_history (service_id, bucket);", "CREATE UNIQUE INDEX IF NOT EXISTS unique_service_address " "ON service_addresses (ip_int, port, proto);", # Two addresses handed to the same proxy endpoint could not be told apart on # the way back: the return rule recognises the operator's proxy by address # and port, and puts the original port back. Refusing the collision here is # cheaper than an intermittently wrong source port in production. "CREATE UNIQUE INDEX IF NOT EXISTS unique_hijack_target " "ON service_addresses (proxy_ip, proxy_port) WHERE proxy_port IS NOT NULL;", "CREATE UNIQUE INDEX IF NOT EXISTS unique_filter_position ON filters (service_id, position);", "CREATE UNIQUE INDEX IF NOT EXISTS unique_pyfilter ON pyfilters (filter_id, name);", "CREATE UNIQUE INDEX IF NOT EXISTS unique_regex ON regexes (filter_id, regex, mode, case_sensitive);", ], }, ) firewall = FirewallManager(db) class AddressModel(BaseModel): address_id: str service_id: str ip_int: str port: PortType proto: str #: What clients speak here, which is what the operator chose. `proto` is what the #: kernel matches, and is this with the hat taken off. edge: str = L4.TCP #: Where the service is, when this address is a publication rather than the service. target_port: int | None = None #: What the service behind this address speaks: `same`, `tcp` or `tls`. upstream: str = UPSTREAM.SAME proxy_ip: str | None = None proxy_port: int | None = None class AddressForm(BaseModel): ip_int: str port: PortType #: What clients speak here — `tcp`, `tls`, `udp` or `quic`. Only ever set on an #: `http` service, which is the one whose addresses are not all the same; elsewhere #: the service's own protocol decides and a value here is refused rather than #: allowed to disagree with it. edge: str | None = None #: Where the service actually is, when it is not on this port. Absent is the #: transparent case: the address *is* the service. target_port: int | None = None #: What the service behind this address speaks. Absent is `same` — whatever arrived #: at the front goes back out the same way, which is what every address did before #: there was a choice. upstream: str | None = None #: `external` only: where your own proxy listens for this address. Two addresses #: cannot share one — the return rule tells them apart by it. proxy_ip: str | None = None proxy_port: PortType | None = None class ProblemModel(BaseModel): """The newest warning or error in a service's log. See `ServiceLog.last_problem`.""" #: Unix milliseconds, like every log line. at: int level: str text: str class ServiceModel(BaseModel): service_id: str name: str status: str proto: str transport: str fail_open: bool #: How many connections or UDP flows may be in flight at once; 0 means no limit. max_connections: int = 0 #: Whether what does not fit is forwarded unfiltered rather than refused. over_limit_forwards: bool = False #: Seconds a connection may say nothing at all before it is closed; 0 means never. first_byte_timeout: int = 0 #: How many times the limit turned something away, ever. Survives a restart, which #: is what makes it a trace rather than a reading. over_limit_hits: int = 0 over_limit_first: int | None = None over_limit_last: int | None = None #: Whether a certificate *and* a key are stored for it. The material itself never #: comes back — the key deliberately, the certificate for symmetry — so this is what #: lets the form know whether an empty field means "unchanged" or "never set", and #: ask for one before the service is started rather than after the engine has refused it. has_tls_material: bool = False addresses: list[AddressModel] = [] n_filters: int n_blocked: int #: The newest warning or error still in its log, so the list can say something is #: wrong without anybody having the service's page open. Cleared with the log. problem: ProblemModel | None = None class ServiceAddForm(BaseModel): name: str proto: str = L4.TCP #: Every address to protect. At least one, and they may mix address families — #: one service, one chain, however many places it answers. addresses: list[AddressForm] transport: str = TRANSPORT.PROXY fail_open: bool = True max_connections: int = 0 over_limit_forwards: bool = False first_byte_timeout: int = 0 tls_cert: str | None = None tls_key: str | None = None class ServiceSettingsForm(BaseModel): name: str | None = None proto: str | None = None transport: str | None = None fail_open: bool | None = None max_connections: int | None = None over_limit_forwards: bool | None = None first_byte_timeout: int | None = None tls_cert: str | None = None tls_key: str | None = None class ServiceAddResponse(BaseModel): status: str service_id: str | None = None class FilterModel(BaseModel): filter_id: str service_id: str position: int kind: str proto: str name: str active: bool blocked: int n_regexes: int #: How many `@pyfilter` functions the code defines, and how many are switched on. n_functions: int = 0 n_functions_active: int = 0 class FilterAddForm(BaseModel): kind: str name: str | None = None active: bool = True class FilterEditForm(BaseModel): name: str | None = None active: bool | None = None class PyFunctionModel(BaseModel): filter_id: str #: The `@pyfilter` function's own name, as written in the code. name: str active: bool blocked: int class PyFunctionEditForm(BaseModel): active: bool class ReorderForm(BaseModel): #: Every filter of the service, in the order they should run. filters: list[str] class RegexModel(BaseModel): regex_id: str filter_id: str regex: str mode: str case_sensitive: bool active: bool blocked: int class RegexAddForm(BaseModel): regex: str mode: str = MODE.BOTH case_sensitive: bool = True active: bool = True class RegexEditForm(BaseModel): active: bool | None = None #: base64, like the one on the add form: a pattern is bytes. regex: str | None = None mode: str | None = None case_sensitive: bool | None = None class CodeForm(BaseModel): code: str class StatsEntryModel(BaseModel): id: str name: str kind: str filter_id: str | None = None #: True for the one entry per filter that stands for blocks no rule still in it #: accounts for — a pattern deleted, or one whose text was edited and so gave its #: history back to the filter. `name` describes it rather than naming a rule, which #: is why the interface prints it as prose instead of as a pattern. residual: bool = False #: Refused in the selected range. blocked: int #: Refused since it was created. Only reported for filters, which are the only #: things carrying a lifetime counter of their own. all_time: int | None = None #: What share of everything this service refused is this one's doing, 0–100. #: Computed against the total rather than left to the browser, so every view of it #: agrees and a chart cannot disagree with a table beside it. share: float = 0.0 class TrafficModel(BaseModel): """How much arrived, in the units each layer can honestly report. Packets come from the kernel's own counters on the intercept rules, and are available on every network layer. Connections come from the proxy engine, which is the only layer that works in them — and is therefore the only one where the share of refused traffic can be computed without dividing two different things. """ packets: int = 0 bytes: int = 0 connections: int | None = None connections_refused: int | None = None #: Percentage of connections refused, 0–100. Null where the layer counts packets. refused_share: float | None = None class StatsSeriesModel(BaseModel): id: str name: str counts: list[int] class StatsModel(BaseModel): filters: list[StatsEntryModel] patterns: list[StatsEntryModel] #: One entry per `@pyfilter` function, across every pyfilter in the chain. functions: list[StatsEntryModel] buckets: list[int] #: How wide one bucket is. Widens with the range, so a chart stays readable. bucket_seconds: int series: list[StatsSeriesModel] #: Refused in the selected range. Every list above counts the same range. total: int #: Refused since each rule was created, which is what the cards show. Reported #: alongside so a narrow range showing nothing is not read as a contradiction. all_time: int #: The range actually served, after clamping to what is still kept. range_from: int range_to: int #: The oldest instant anything is known about, so "everything" has a meaning. kept_from: int #: When this service first became able to refuse anything, if it ever did. The #: effective floor of every range, the same way `kept_from` is. filtering_since: int | None = None traffic: TrafficModel class LogEntryModel(BaseModel): at: int level: str text: str seq: int class DebugPatternForm(BaseModel): id: str expr: str case_sensitive: bool = True class DebugForm(BaseModel): patterns: list[DebugPatternForm] #: base64, because a pattern is matched against bytes and a useful sample is not #: always valid text. sample: str class DebugMatchModel(BaseModel): id: str start: int end: int class DebugErrorModel(BaseModel): id: str error: str class DebugResponse(BaseModel): matches: list[DebugMatchModel] errors: list[DebugErrorModel] error: str | None = None #: Valid, and will run, but cannot be highlighted here. unscannable: list[DebugErrorModel] = [] truncated: bool # --- lifecycle hooks the loader calls ---------------------------------------- async def refresh_frontend(extra: list[str] | None = None): await socketio_emit(["services"] + (extra or [])) async def startup(): db.init() _prune_orphan_code() try: await firewall.init() except Exception as e: print("WARNING cannot start the services firewall:", e) # Whatever came back up is filtering from now, if it was not already marked. This is # what covers the services that existed before the mark did, and the ones restored # from a backup: both are running with a chain the moment `init()` returns, and # neither passes through a start or an edit to say so. for row in db.query("SELECT service_id FROM services;"): _note_filtering(row["service_id"]) def _prune_orphan_code() -> None: """Remove the filter files no filter refers to. The code lives beside the database rather than in it, so the two can drift apart, and an import is where they did: it replaced the database and wrote the backup's files, but left every file the backup did not have — the code of every Python filter created since, kept on disk for filters that no longer existed. Done at every start, which is also what an import ends with, so the directory holds exactly what the database uses. Only `.py` files are touched; anything else in there is not ours to judge. """ if not os.path.isdir(CODE_DIR): return used = { row["filter_id"] for row in db.query("SELECT filter_id FROM filters WHERE kind = ?;", KIND.PYFILTER) } for name in os.listdir(CODE_DIR): stem, ext = os.path.splitext(name) if ext != ".py" or stem in used: continue try: os.remove(os.path.join(CODE_DIR, name)) except OSError: traceback.print_exc() async def shutdown(): await firewall.close() # The capture interface outlives any one service but not the process: this is the # only place it goes away, so a `reset()` — which also closes — leaves it alone. firewall.release_capture() db.disconnect() async def reset(params: ResetRequest): if not params.delete: # Into the copy that is put back, or the last second of counters is written # after it was taken and then overwritten by it. firewall.flush_blocks() db.backup() await firewall.close() FiregexTables().reset() if params.delete: db.delete() db.init() # The user's Python and the certificates are not in the database, so wiping # the database alone would leave them behind to be inherited by whatever # reuses their id. for name in os.listdir(CODE_DIR) if os.path.isdir(CODE_DIR) else []: os.remove(os.path.join(CODE_DIR, name)) else: db.restore() try: await firewall.init() except Exception as e: raise HTTPException(status_code=500, detail=str(e)) def gen_id() -> str: return secrets.token_hex(8) def _service_or_404(service_id: str) -> dict: res = db.query("SELECT * FROM services WHERE service_id = ?;", service_id) if not res: raise HTTPException(status_code=404, detail="This service does not exist") return res[0] def _addresses(service_id: str) -> list[dict]: return db.query( "SELECT * FROM service_addresses WHERE service_id = ? ORDER BY ip_int, port;", service_id, ) def _address_or_404(service_id: str, address_id: str) -> dict: """An address is addressed through its service, and has to actually be in it.""" res = db.query( "SELECT * FROM service_addresses WHERE address_id = ? AND service_id = ?;", address_id, service_id, ) if not res: raise HTTPException(status_code=404, detail="This address does not exist") return res[0] def load_service(service_id: str): """The service and everywhere it is reachable, as the datapath sees it.""" from modules.services.models import Address row = _service_or_404(service_id) srv = Service.from_dict(row) srv.addresses = [Address.from_dict(a) for a in _addresses(service_id)] return srv def _filter_or_404(service_id: str, filter_id: str) -> dict: """A filter is addressed through its service, and has to actually be in it. Without the second half of that check the nesting would be decoration: any filter id would work under any service, and the chain the caller thought it was editing would not be the one that changed. """ res = db.query( "SELECT * FROM filters WHERE filter_id = ? AND service_id = ?;", filter_id, service_id ) if not res: raise HTTPException(status_code=404, detail="This filter does not exist") return res[0] def _regex_or_404(filter_id: str, regex_id: str) -> dict: res = db.query( "SELECT * FROM regexes WHERE regex_id = ? AND filter_id = ?;", regex_id, filter_id ) if not res: raise HTTPException(status_code=404, detail="This pattern does not exist") return res[0] def _note_filtering(service_id: str) -> None: """Remember the first moment this service could have refused something. Two things have to be true — it is running, and there is at least one active filter in its chain — and neither of them is where the other is changed, so this is called from both: starting a service, and every edit that goes through `_apply_chain`. Asking the database is cheaper than keeping a third copy of the answer in step. Written once and never rewritten. A service that stops filtering and starts again has a real gap in the middle of its history, and moving the mark forward would hide it; clearing it would make the chart claim the earlier hours never happened. """ manager = firewall.services.get(service_id) if manager is not None and not manager.active: # Meant to run and not running — one that could not start at boot, say — has not # begun filtering, whatever the stored status says. return row = db.query( "SELECT s.filtering_since since, s.status status, " "(SELECT COUNT(*) FROM filters f WHERE f.service_id = s.service_id AND f.active = 1) n " "FROM services s WHERE s.service_id = ?;", service_id, ) if not row or row[0]["since"] is not None: return if row[0]["status"] != STATUS.ACTIVE or int(row[0]["n"]) < 1: return # Stored aligned to its bucket, like everything else the statistics work in: the # mark names the first bucket that can hold one of this service's blocks. An # unaligned mark is a few seconds *after* the minute-aligned "now" the stats endpoint # compares it against, which silently switched the floor off for the first minute of # every service's life — exactly the minute it matters most. db.query( "UPDATE services SET filtering_since = ? WHERE service_id = ?;", stats.bucket_of(), service_id, ) async def _apply_chain(service_id: str, undo=None): """Push the chain to a running service, translating a refusal into a 400. A chain the transport cannot host is the operator's mistake, not the server's, and the message has to say which combination was impossible — an unqualified 500 would leave them guessing. `undo` puts the database back. Without it a refused edit would still have been written: the operator would be told their filter was rejected and then find it in the list, and the next start would fail for a reason they thought they had avoided. A refusal has to mean nothing changed. """ undo = _once(undo) try: await firewall.get(service_id).update_chain(undo) except UnsupportedChain as e: undo() raise HTTPException(status_code=400, detail=str(e)) except Exception as e: undo() raise HTTPException(status_code=500, detail=str(e)) _note_filtering(service_id) def _once(undo): """`undo`, or nothing, made safe to call from both the manager and the caller. A rebuild that fails has the manager put the database back before it starts the service again, and the caller then reaches its own `except` — where the undo it has always run is now the second one. """ done = [] def run(): if undo is not None and not done: done.append(True) undo() return run # --- services ---------------------------------------------------------------- SERVICE_QUERY = """ SELECT s.service_id service_id, s.name name, s.status status, s.proto proto, s.transport transport, s.fail_open fail_open, s.max_connections max_connections, s.over_limit_forwards over_limit_forwards, s.first_byte_timeout first_byte_timeout, s.over_limit_hits over_limit_hits, s.over_limit_first over_limit_first, s.over_limit_last over_limit_last, (s.tls_cert IS NOT NULL AND TRIM(s.tls_cert) != '' AND s.tls_key IS NOT NULL AND TRIM(s.tls_key) != '') has_tls_material, COUNT(f.filter_id) n_filters, COALESCE(SUM(f.blocked), 0) n_blocked FROM services s LEFT JOIN filters f ON s.service_id = f.service_id """ def _address_row(row: dict) -> dict: """One address, as it is stored. It used to grow two loopback ports here, because a TLS service was protected on a pair of them rather than on the address itself. Nothing is derived any more: the engine decrypts at the address the world dials. """ return dict(row) def _with_addresses(row: dict) -> dict: row = dict(row) row["status"] = _running_status(row) row["addresses"] = [ _address_row(a) for a in _addresses(row["service_id"]) ] row["problem"] = log_for(row["service_id"]).last_problem() return row def _running_status(row: dict) -> str: """Whether the service is running, not whether it is meant to. The stored status is what the operator asked for, and what the next boot starts: a service that could not come back up at boot kept `active` there, and the interface showed it as active while nothing was filtering it — the one failure this whole module is arranged to avoid. The reason is in its log, and on the list as its problem. """ manager = firewall.services.get(row["service_id"]) if manager is None: return row["status"] return STATUS.ACTIVE if manager.active else STATUS.STOP @app.get("", response_model=list[ServiceModel]) async def get_services(): """Every service, with how many filters it runs and how much they have blocked.""" return [_with_addresses(row) for row in db.query(SERVICE_QUERY + " GROUP BY s.service_id;")] class ApiMemberModel(BaseModel): name: str doc: str = "" #: True for the few things a filter may assign to — the payload, and nothing else. writable: bool = False signature: str | None = None class ApiModelModel(BaseModel): name: str doc: str = "" members: list[ApiMemberModel] = [] #: Which application protocols provide it; a file asking for one of these is one of #: those. `tcp` and `http` both means it runs on anything. protocols: list[str] = [] class ApiEntryModel(BaseModel): name: str doc: str = "" value: int | None = None values: list[str] = [] class PyFilterApiModel(BaseModel): models: list[ApiModelModel] verdicts: list[ApiEntryModel] settings: list[ApiEntryModel] # Declared before `/{service_id}`, or that route matches this path and looks for a # service called "pyfilter-api". @app.get("/pyfilter-api", response_model=PyFilterApiModel) async def get_pyfilter_api(): """What the filter library offers, for the editor to suggest. Introspected from the library rather than written down: hints that disagree with the thing they describe are worse than none, and a second description would disagree the first time a model gained a property. """ from modules.services.pyapi import describe return describe() @app.get("/{service_id}", response_model=ServiceModel) async def get_service(service_id: str): res = db.query(SERVICE_QUERY + " WHERE s.service_id = ? GROUP BY s.service_id;", service_id) if not res: raise HTTPException(status_code=404, detail="This service does not exist") return _with_addresses(res[0]) def _insert_address(service_id: str, proto: str, transport: str, form: AddressForm) -> str: """Add one address, carrying the transport the kernel will match on. Not the service's own protocol: `tls` is TCP on the wire, and an address storing it verbatim would let a TCP service and a TLS one claim one `ip:port` between them. On an `http` service the address says what is spoken at it, because that service is reached in more than one way at once — `:80` in the clear, `:443` under TLS, a UDP port over HTTP/3. What it says is kept beside the transport the kernel matches: the two are the same answer with the hat on and off. """ try: parsed_ip = parse_ip_or_int(form.ip_int) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) edge = _address_edge(proto, form) target_port = _address_target(transport, form) upstream = _address_upstream(proto, edge, form) proxy_ip = _hijack_ip(transport, parsed_ip, form) address_id = gen_id() db.query( "INSERT INTO service_addresses (address_id, service_id, ip_int, port, proto, " "edge, target_port, upstream, proxy_ip, proxy_port) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", address_id, service_id, parsed_ip, form.port, L4.l4_of(edge), edge, target_port, upstream, proxy_ip, _hijack_port(transport, form), ) return address_id def _hijack_ip(transport: str, service_ip: str, form: AddressForm) -> str | None: """Where this address's traffic is handed off, stored rather than defaulted later. Only the hand-off layer has one; everywhere else it stays NULL, because there is no endpoint and a value would be a setting that does nothing. Resolved **here**, at the point the row is written, so that the partial unique index on `(proxy_ip, proxy_port)` can do its job. It could not before: the column was left NULL whenever the operator did not type an address, SQLite counts every NULL as distinct in a UNIQUE index, and the rules then defaulted all of them to the same loopback address — so any number of addresses could sit behind one endpoint, which is the single thing that index was added to forbid. The return rule tells them apart by address and port, so it could not. """ if str(transport) != TRANSPORT.EXTERNAL: return None if form.proxy_ip and str(form.proxy_ip).strip(): # Checked here, where the row is written: it used to be stored as typed and only # parsed when the rules were built, so a mistyped endpoint was a service that # would not start, with the reason in its log rather than in this answer. proxy = str(form.proxy_ip).strip() if not is_ip_parse(proxy) or _shown(ip_parse(proxy)) != one_address(ip_parse(proxy)): raise HTTPException( status_code=400, detail=f"Your proxy's address has to be one IP address, not {proxy!r}", ) if is_ip_parse(service_ip) and ip_family(proxy) != ip_family(service_ip): raise HTTPException( status_code=400, detail="Your proxy's address has to be in the same family as the address " "it stands in for: the rewrite changes the destination and nothing " "else, so an IPv4 packet cannot be sent to an IPv6 proxy.", ) form.proxy_ip = proxy if not form.proxy_port: return form.proxy_ip or None return hijack_endpoint(form.proxy_ip, service_ip) def _hijack_port(transport: str, form: AddressForm) -> int | None: """The port half of the same endpoint: the hand-off layer's alone. Stored for any layer, it was a value read by nothing that still sat in the partial unique index — so a proxy address that happened to carry one could refuse a real hand-off its endpoint. """ return form.proxy_port if str(transport) == TRANSPORT.EXTERNAL else None def _address_edge(proto: str, form: AddressForm) -> str: """What clients speak at one address of a service. Every protocol but `http` has one answer for the whole service, so an address of one is not asked and a value sent anyway is refused rather than quietly kept: a row whose edge disagrees with its service is a row that would be intercepted on one transport and served on another. `http` is the protocol that means "each address says", which is what it meant when the choice was TCP or UDP and what it still means now that the same address can say whether it is encrypted. The default is `tcp`, which is what an HTTP address is unless somebody says otherwise. """ asked = str(form.edge) if form.edge else None if str(proto) != L4.HTTP: if asked and asked != str(proto): raise HTTPException( status_code=400, detail=f"A {str(proto).upper()} service speaks {str(proto).upper()} at " f"every address, so this one cannot be {asked!r}. Only an HTTPS " f"service is reached in more than one way at once.", ) return str(proto) if asked is None: return L4.TCP if asked not in (L4.TCP, L4.TLS, L4.QUIC): raise HTTPException( status_code=400, detail=f"An address of an HTTPS service is reached in the clear, under TLS " f"or over QUIC — not {asked!r}.", ) return asked def _address_target(transport: str, form: AddressForm) -> int | None: """Where the service reached through one address is, when it is not on that port. Every protocol can say it, because **publishing is being reachable one more way than the service is** and nothing about that is HTTP's. One daemon on `:8080` is reached at `:80` in the clear, at `:443` under TLS and at a UDP port over HTTP/3 — and also, with nothing HTTP about it, at whatever port the round's scoreboard was told about. It was refused outside `http` while `http` was the only protocol whose addresses were not all the same, which confused the shape of one protocol with the shape of the feature. What it does need is something that **dials**, and that is the layer rather than the protocol. The proxy terminates the connection and opens the one to the service, so it is free to open it somewhere else. NFQUEUE hands the kernel a verdict on packets already on their way and opens nothing; the hand-off layer rewrites the destination to your own proxy, which then decides for itself where the service is. On both, a stored port would be read by nothing at all — refused rather than ignored, because an operator told their service is published and finding it merely intercepted is exactly the trap this file keeps arguing against. """ if not form.target_port: return None if str(transport) != TRANSPORT.PROXY: raise HTTPException( status_code=400, detail="Only the proxy layer can send an address's traffic to a port other " "than the one it arrived on: it terminates the connection and opens " "the one to the service, so it is free to open it elsewhere. " + ("NFQUEUE passes judgement on packets already on their way and opens " "nothing." if str(transport) == TRANSPORT.NFQUEUE else "This layer hands the traffic to your own proxy, which decides for " "itself where the service is."), ) if not 0 < form.target_port < 65536: raise HTTPException(status_code=400, detail="Invalid port for the service") return form.target_port def upstream_applies(proto: str, edge: str) -> bool: """Whether one address has an upstream leg of its own to decide. Only where firegex decrypts what arrives: on a cleartext service it terminates nothing, and on the cleartext address of an HTTPS service the engine carries what arrived as it arrived — so an answer stored there is read by nothing. It was accepted there, and the form offered it: "send it to the service over TLS" on a port clients reach in the clear was saved, shown back as a tag, and ignored by the engine, whose cleartext path never asks. """ if str(proto) not in (L4.TLS, L4.QUIC, L4.HTTP): return False return str(edge) != L4.TCP def _address_upstream(proto: str, edge: str, form: AddressForm) -> str: """What the service behind one address speaks. Only an address whose traffic is decrypted has an upstream leg of its own to decide (`upstream_applies`). Anywhere else a stored answer would be a setting that does nothing, and it is refused rather than ignored for that reason. """ asked = str(form.upstream) if form.upstream else UPSTREAM.SAME if asked == UPSTREAM.SAME: return UPSTREAM.SAME if asked not in UPSTREAM.ALL: raise HTTPException(status_code=400, detail=f"Unknown upstream {asked!r}") if str(proto) not in (L4.TLS, L4.QUIC, L4.HTTP): raise HTTPException( status_code=400, detail=f"A {str(proto).upper()} service is carried as it arrives: firegex " f"terminates nothing there, so there is no upstream leg to decide. " f"Only a service firegex decrypts has one.", ) if not upstream_applies(proto, edge): raise HTTPException( status_code=400, detail="This address is reached in the clear, and firegex carries what arrives " "there as it arrived — there is no decrypted leg for it to hand the " "service differently. Only an address reached over TLS or HTTP/3 has one.", ) return asked def _address_taken(e: sqlite3.IntegrityError) -> str: """Say which uniqueness rule was broken, because they mean different things. **Told apart by the columns, not by the index name.** SQLite reports a violated unique index as `UNIQUE constraint failed: ., ...` — it names the index only for one on an expression — so keying on `unique_hijack_target` matched nothing, and the sentence below has never once been shown. What an operator saw instead was "one of these addresses is already protected", which points at the service address while the thing actually colliding is their proxy's port: they go looking for a service that does not exist. The index name is still accepted, because it costs nothing and is what a future SQLite, or an index written as an expression, might report instead. """ said = str(e) if "proxy_port" in said or "unique_hijack_target" in said: return ( "another address already hands its traffic to that proxy endpoint. Give " "this one its own port: the return rule recognises your proxy by address " "and port to put the original port back, so two addresses behind the same " "endpoint could not be told apart on the way out." ) return "one of these addresses is already protected by a service" def _check_tls_material(cert: str | None, key: str | None) -> None: """Refuse TLS material the engine would refuse, at the point it is handed over. Only the PEM envelope is checked, not the key itself: the aim is to catch the two mistakes that are made by hand and cost a restart to discover — a DER or PKCS#12 file pasted in whole, and the certificate and the key swapped between the two fields. Both used to surface as a service that simply would not start, with the reason in the service log rather than in the answer to the request that caused it. Anything that carries the right header is passed through untouched: judging a certificate here by more than its envelope would be a second opinion on what the engine accepts, and the second opinion is the one that is wrong. """ if cert is not None and cert.strip(): if "-----BEGIN CERTIFICATE-----" not in cert: raise HTTPException( status_code=400, detail="That does not look like a PEM certificate: no " "'BEGIN CERTIFICATE' block in it." + (" It contains a private key — the two fields are the other way " "round." if "PRIVATE KEY-----" in cert else ""), ) if key is not None and key.strip(): if "PRIVATE KEY-----" not in key: raise HTTPException( status_code=400, detail="That does not look like a PEM private key: no 'BEGIN PRIVATE " "KEY' block in it." + (" It contains a certificate — the two fields are the other way " "round." if "-----BEGIN CERTIFICATE-----" in key else ""), ) if "ENCRYPTED PRIVATE KEY-----" in key: raise HTTPException( status_code=400, detail="That private key is passphrase-protected, and nothing here can " "be asked for the passphrase. Decrypt it first: " "openssl pkey -in key.pem -out plain.pem", ) @app.post("", response_model=ServiceAddResponse) async def add_service(form: ServiceAddForm): if form.transport not in TRANSPORT.ALL: raise HTTPException(status_code=400, detail=f"Unknown transport {form.transport!r}") if form.proto not in L4.ALL: raise HTTPException(status_code=400, detail=f"Unknown protocol {form.proto!r}") if not form.addresses: raise HTTPException( status_code=400, detail="A service needs at least one address to protect" ) if form.proto in (L4.TLS, L4.QUIC, L4.HTTP): if not (form.tls_cert and form.tls_key): raise HTTPException( status_code=400, detail=f"A service that speaks {form.proto.upper()} needs both a " f"certificate and a private key" + (". An HTTP service covers the TLS and HTTP/3 edges as well as " "the cleartext one, and neither exists without a certificate — " "a service that only answers in the clear is a `tcp` one, where " "HTTP/1.1 and HTTP/2 are already filtered." if form.proto == L4.HTTP else ""), ) _check_tls_material(form.tls_cert, form.tls_key) # Each address answers for itself what the service behind it speaks, and is refused # here rather than at the first start if it cannot. Everything else an address says # is checked in the same breath, and for a second reason: the rows are written after # the service's own, so a refusal arriving then would leave a service behind that # nobody asked for. for address in form.addresses: _address_target(form.transport, address) _address_upstream(form.proto, _address_edge(form.proto, address), address) # The transport gets a look before the row exists. A chain it could not host is # caught later, when there is one; what is caught here is what the *service* makes # impossible on its own — a protocol this layer cannot carry — and it is worth # catching now, because the alternative is a row that is created happily and then # refuses to start every time, with the reason arriving one action too late. try: transports.build_class(form.transport).check( Service.from_dict({ "service_id": "", "name": form.name, "status": STATUS.STOP, "proto": form.proto, "transport": form.transport, "fail_open": form.fail_open, }), [], ) except UnsupportedChain as e: raise HTTPException(status_code=400, detail=str(e)) if form.transport == TRANSPORT.EXTERNAL and any(not a.proxy_port for a in form.addresses): raise HTTPException( status_code=400, detail="A service handing its traffic to your own proxy needs that proxy's " "port for every address", ) if form.transport == TRANSPORT.EXTERNAL and any(not is_ip_parse(a.ip_int) for a in form.addresses): raise HTTPException( status_code=400, detail="The external transport hands traffic to your own proxy and rewrites the " "source address on return, which requires a concrete IP address rather than an interface.", ) # What the rows will hold, worked out before the first one is written: the addresses # go in after the service, so a refusal half way through left the service and the # addresses before it behind — holding the name, unknown to the running firewall. for address in form.addresses: try: _hijack_ip(form.transport, parse_ip_or_int(address.ip_int), address) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) service_id = gen_id() try: db.query( "INSERT INTO services (service_id, name, status, proto, transport, " "fail_open, max_connections, over_limit_forwards, first_byte_timeout, " "tls_cert, tls_key) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", service_id, refactor_name(form.name), STATUS.STOP, form.proto, form.transport, form.fail_open, max(0, form.max_connections), form.over_limit_forwards, max(0, form.first_byte_timeout), form.tls_cert, form.tls_key, ) except sqlite3.IntegrityError as e: # The name is the only thing an operator can collide with by accident, so it is # the answer worth giving — but it is not the only constraint on this row, and # answering "the name exists" to a CHECK that failed sends whoever reads it # looking for a service that is not there. Ask the exception which it was. if "name" not in str(e): return {"status": f"The service was refused by the database: {e}"} return {"status": "A service with this name already exists"} try: for address in form.addresses: _insert_address(service_id, form.proto, form.transport, address) except BaseException as e: # All of it or none: a service that came up on half the addresses the operator # listed is one they would believe is protecting the other half. Whatever the # reason — a refusal from a check on the way in used to leave the service and its # first addresses behind, holding the name and unknown to the running firewall. db.query("DELETE FROM service_addresses WHERE service_id = ?;", service_id) db.query("DELETE FROM services WHERE service_id = ?;", service_id) if isinstance(e, sqlite3.IntegrityError): return {"status": _address_taken(e)} raise await firewall.reload() await refresh_frontend() return {"status": "ok", "service_id": service_id} def _shown(ip_int: str) -> str: """An address as an operator typed it: the host-length prefix it is stored with is dropped, a real range keeps its own.""" addr, _, prefix = str(ip_int).partition("/") if prefix in ("32", "128"): return addr return str(ip_int) def _address_under(address: dict, proto: str) -> dict: """What one address becomes when its service changes protocol. The addresses carry the transport the kernel matches on, so that `(ip, port, proto)` can be unique, and what is spoken at them; both follow the service rather than being a second thing the operator sets. `http` is the one protocol whose addresses keep their own answer — rewriting them all to `tcp` would quietly turn an HTTP/3 address into a TCP one — except that an address reached over plain UDP is, on an HTTPS service, its HTTP/3 edge. Switching *away* from `http` flattens them, which is honest: the service now speaks one thing, and a collision that follows from it is refused. An upstream choice survives only where the address still has a decrypted leg for it to apply to (`upstream_applies`); anywhere else it would be a stored answer read by nothing. """ if str(proto) == L4.HTTP: edge = str(address["edge"]) if edge not in (L4.TCP, L4.TLS, L4.QUIC): edge = L4.QUIC if L4.l4_of(edge) == L4.UDP else L4.TCP else: edge = str(proto) upstream = str(address["upstream"] or UPSTREAM.SAME) if not upstream_applies(proto, edge): upstream = UPSTREAM.SAME return {**address, "edge": edge, "proto": L4.l4_of(edge), "upstream": upstream} async def _filters_needing_a_stream(service_id: str) -> list[str]: """The Python filters of a service that could not run on datagrams, by name. Asked of the checker, which reports the models a file asks for: the protocol stored on the row says only whether a file parses HTTP, and a file asking for a TCP stream is stored as `tcp` and would sit on a UDP service never being called. """ names = [] for row in db.query( "SELECT filter_id, name FROM filters WHERE service_id = ? AND kind = ?;", service_id, KIND.PYFILTER, ): code = read_code(row["filter_id"]) if not code.strip(): continue checked = await validate_code(code) if checked.get("ok") and needs_a_stream(checked.get("models", [])): names.append(row["name"]) return names @app.put("/{service_id}", response_model=StatusMessageModel) async def edit_service(service_id: str, form: ServiceSettingsForm): """Change a service's definition. Anything here changes what the datapath is, not what it enforces, so a running service is stopped and started around it. That is a visible interruption, which is why filters and addresses are edited through their own endpoints instead — those cost at most the connections on the address that changed. **A refused edit changes nothing**, and three things make that true. The service it would become is checked before anything is written — the layer against the protocol and the chain, the way a start would check it, whether or not the service is running; this used to happen only by starting it, so a stopped service took any combination and failed on its next start instead. It is written in one transaction, the service and its addresses together: a protocol change that collided with another service's address used to leave the service rewritten and its addresses not. And a running service whose new definition will not start is put back, and started again as it was, instead of being left stopped on the definition that failed. """ row = _service_or_404(service_id) fields = {} if form.name is not None: fields["name"] = refactor_name(form.name) if form.proto is not None: if form.proto not in L4.ALL: raise HTTPException(status_code=400, detail=f"Unknown protocol {form.proto!r}") fields["proto"] = form.proto if form.transport is not None: if form.transport not in TRANSPORT.ALL: raise HTTPException(status_code=400, detail=f"Unknown transport {form.transport!r}") fields["transport"] = form.transport if form.fail_open is not None: fields["fail_open"] = form.fail_open if form.max_connections is not None: if form.max_connections < 0: raise HTTPException(status_code=400, detail="A limit cannot be negative; 0 means none") fields["max_connections"] = form.max_connections if form.over_limit_forwards is not None: fields["over_limit_forwards"] = form.over_limit_forwards if form.first_byte_timeout is not None: if form.first_byte_timeout < 0: raise HTTPException(status_code=400, detail="A deadline cannot be negative; 0 means none") fields["first_byte_timeout"] = form.first_byte_timeout _check_tls_material(form.tls_cert, form.tls_key) # What has to hold is the state this edit *lands on*, not what it carries: turning # TLS on for a service that has never been given a certificate is a valid-looking # request whose only symptom is the engine refusing to start later, on a restart the # operator has by then attributed to something else. So the stored material counts # towards it, and a service already holding a certificate can be switched on without # pasting it again. lands_on = form.proto if form.proto is not None else row["proto"] if lands_on in (L4.TLS, L4.QUIC, L4.HTTP): cert = form.tls_cert if form.tls_cert is not None else row["tls_cert"] key = form.tls_key if form.tls_key is not None else row["tls_key"] if not (cert and cert.strip()) or not (key and key.strip()): missing = "a certificate and a private key" if not (cert and cert.strip()) and not (key and key.strip()) \ else "a certificate" if not (cert and cert.strip()) else "a private key" raise HTTPException( status_code=400, detail=f"This service terminates {str(lands_on).upper()}, so it needs " f"{missing}. Give it one here — the engine would refuse to start " f"without it, and the service would fail on its next start rather " f"than now.", ) if form.tls_cert is not None: fields["tls_cert"] = form.tls_cert if form.tls_key is not None: fields["tls_key"] = form.tls_key # Only what actually changes. The form sends every setting on every save, and a field # that already holds the value it is sent is not an edit: counted as one, opening the # dialog and saving it untouched restarted a running service and dropped every # connection it was carrying. fields = {key: value for key, value in fields.items() if row[key] != value} if not fields: return {"status": "ok"} proto = str(fields.get("proto", row["proto"])) transport = str(fields.get("transport", row["transport"])) old_addresses = _addresses(service_id) new_addresses = [_address_under(address, proto) for address in old_addresses] if transport != TRANSPORT.EXTERNAL: # The endpoint of the operator's own proxy is the hand-off layer's alone. Left # on the rows it is read by nothing and still sits in the unique index, refusing # another service's hand-off the endpoint this one no longer uses. new_addresses = [{**address, "proxy_ip": None, "proxy_port": None} for address in new_addresses] # What the addresses say has to mean something on the layer they land on. A port the # service is published on takes a layer that dials; on the others it would be read by # nothing, and the address would look published while being merely intercepted. if transport != TRANSPORT.PROXY: published = [a for a in new_addresses if a["target_port"]] if published: raise HTTPException( status_code=400, detail="Only the proxy layer can send an address's traffic to a port other " "than the one it arrived on, and " + ", ".join(f"{_shown(a['ip_int'])}:{a['port']}" for a in published) + " is published on another port. Take that off the address first.", ) # The service it would become, asked what a start would ask of the layer and the # chain. Without its addresses, the way a service is checked when it is created: what # an address still lacks on the new layer — the port of the operator's own proxy, say # — is filled in on the address afterwards, and a start says so if it is not. manager = firewall.get(service_id) candidate = Service.from_dict({**row, **fields}) try: transports.build_class(transport).check(candidate, manager.chain()) except UnsupportedChain as e: raise HTTPException(status_code=400, detail=str(e)) if proto == L4.UDP: needing = await _filters_needing_a_stream(service_id) if needing: raise HTTPException( status_code=400, detail=f"{', '.join(needing)} {'ask' if len(needing) > 1 else 'asks'} for a " f"stream — an assembled TCP stream or a parsed HTTP message — and a " f"UDP service carries datagrams, so on it the library would never " f"call {'them' if len(needing) > 1 else 'it'}. Rewrite them against " f"RawPacket, or keep the service on a stream.", ) def rewrite(values: dict, addresses: list[dict]) -> list[tuple]: queries = [( f"UPDATE services SET {', '.join(f'{k} = ?' for k in values)} WHERE service_id = ?;", *values.values(), service_id, )] for address in addresses: queries.append(( "UPDATE service_addresses SET proto = ?, edge = ?, upstream = ?, " "proxy_ip = ?, proxy_port = ? WHERE address_id = ?;", address["proto"], address["edge"], address["upstream"], address["proxy_ip"], address["proxy_port"], address["address_id"], )) return queries # All of it or none: a protocol change that collided with another service's address # used to leave the service rewritten and its addresses as they were. try: db.queries(rewrite(fields, new_addresses)) except sqlite3.IntegrityError as e: if "services.name" in str(e): raise HTTPException(status_code=400, detail="That name is already taken") raise HTTPException( status_code=400, detail=f"Another service already protects one of these addresses on {proto}", ) if set(fields) == {"name"}: # A name is the operator's, not the datapath's: nothing running reads it, so it # is not a reason to take the service down and bring it back. manager.srv.name = fields["name"] await refresh_frontend() return {"status": "ok"} was_active = manager.active try: await manager.refresh(load_service(service_id)) except Exception as e: # The new definition would not start. Put the old one back, and the service with # it, rather than leaving it stopped on the definition that failed — which is what # happened, with a 500, to a service handed a key its engine would not sign with. db.queries(rewrite({k: row[k] for k in fields}, old_addresses)) manager.srv = load_service(service_id) if was_active: try: await manager.enable() except Exception: traceback.print_exc() raise HTTPException( status_code=400 if isinstance(e, UnsupportedChain) else 500, detail=f"The service would not start with that, so nothing was changed: {e}", ) await refresh_frontend() return {"status": "ok"} # --- where a service is reachable --------------------------------------------- @app.get("/{service_id}/addresses", response_model=list[AddressModel]) async def get_addresses(service_id: str): _service_or_404(service_id) return [_address_row(a) for a in _addresses(service_id)] @app.post("/{service_id}/addresses", response_model=StatusMessageModel) async def add_address(service_id: str, form: AddressForm): """Protect one more address with this service's existing chain. Not a restart: the datapath is already running and already enforcing the chain, so this installs the rules that point the new address at it. The connections on the other addresses are untouched. The one exception is a proxy service gaining its first IPv6 address, because the listener has to be reopened in a family that can accept it — the manager says so and rebuilds. """ row = _service_or_404(service_id) if row["transport"] == TRANSPORT.EXTERNAL and not form.proxy_port: raise HTTPException( status_code=400, detail="This service hands its traffic to your own proxy, so the new " "address needs the port that proxy listens on for it", ) if row["transport"] == TRANSPORT.EXTERNAL and not is_ip_parse(form.ip_int): raise HTTPException( status_code=400, detail="The external transport hands traffic to your own proxy and rewrites the " "source address on return, which requires a concrete IP address rather than an interface.", ) # Before the row exists: what the address says has to mean something on this service. _address_upstream(row["proto"], _address_edge(row["proto"], form), form) try: address_id = _insert_address(service_id, row["proto"], row["transport"], form) except sqlite3.IntegrityError as e: raise HTTPException(status_code=400, detail=_address_taken(e)) @_once def undo(): # The row *and* the manager's copy of it: the manager re-read the list on its # way in, so leaving it there would mean the next start protecting an address # that was refused and no longer exists. db.query("DELETE FROM service_addresses WHERE address_id = ?;", address_id) firewall.get(service_id).reload_addresses() try: await firewall.get(service_id).address_added(address_id, undo) except UnsupportedChain as e: undo() raise HTTPException(status_code=400, detail=str(e)) except Exception as e: undo() raise HTTPException(status_code=500, detail=str(e)) await refresh_frontend() return {"status": "ok"} @app.put("/{service_id}/addresses/{address_id}", response_model=StatusMessageModel) async def edit_address(service_id: str, address_id: str, form: AddressForm): """Change one address: where it is, and everything it says. The rules for it are taken back and reinstalled, which is what makes the whole row rewritable rather than only its address. It used to touch `ip_int`, `port` and the proxy endpoint alone, so the edge, the port the service is on and what that service speaks were add-only — and the operator who set one of them while creating the service had no way back to it except deleting the address and adding it again. That is a worse answer than reinstalling rules the edit already reinstalls. Only this address stops being steered while that happens; the rest of the service keeps its connections. A refusal puts the row back exactly as it was. """ row = _service_or_404(service_id) was = _address_or_404(service_id, address_id) if row["transport"] == TRANSPORT.EXTERNAL and not form.proxy_port: raise HTTPException( status_code=400, detail="This service hands its traffic to your own proxy, so every address " "needs the port that proxy listens on for it", ) if row["transport"] == TRANSPORT.EXTERNAL and not is_ip_parse(form.ip_int): raise HTTPException( status_code=400, detail="The external transport hands traffic to your own proxy and rewrites the " "source address on return, which requires a concrete IP address rather than an interface.", ) # A field the caller did not mention keeps what the row already had. This is the # endpoint that *moves* an address as well as the one that changes what it says, and # a move sending only an address and a port would otherwise quietly unpublish it. # Taking one off is a value, not an absence: `0` for the port, `same` for the # upstream — which is what the interface sends when it is emptied. upstream_mentioned = form.upstream is not None if form.edge is None: form.edge = was["edge"] if form.target_port is None: form.target_port = was["target_port"] if form.upstream is None: form.upstream = was["upstream"] # Everything the row says is worked out *before* the old rules come back, so a # refusal leaves an address that is still steered exactly as it was. edge = _address_edge(row["proto"], form) if not upstream_mentioned and not upstream_applies(row["proto"], edge): # Moved to the clear without a word about the upstream: what the row had was an # answer for the decrypted leg this address no longer has, and keeping it would # refuse the very edit that made it meaningless. form.upstream = UPSTREAM.SAME target_port = _address_target(row["transport"], form) upstream = _address_upstream(row["proto"], edge, form) def write(ip_int, port, proto, edge, target_port, upstream, proxy_ip, proxy_port): db.query( "UPDATE service_addresses SET ip_int = ?, port = ?, proto = ?, edge = ?, " "target_port = ?, upstream = ?, proxy_ip = ?, proxy_port = ? " "WHERE address_id = ?;", ip_int, port, proto, edge, target_port, upstream, proxy_ip, proxy_port, address_id, ) async def restore(): """Put the address back exactly as it was, and steer it again. Best effort by design: the edit has already taken the old rules back, so the alternative to trying is an address left unprotected because the *new* one was impossible. A failure here is reported by the exception that caused it. """ write(was["ip_int"], was["port"], was["proto"], was["edge"], was["target_port"], was["upstream"], was["proxy_ip"], was["proxy_port"]) try: await firewall.get(service_id).address_added(address_id) except Exception: pass try: parsed_ip = parse_ip_or_int(form.ip_int) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) proxy_ip = _hijack_ip(row["transport"], parsed_ip, form) manager = firewall.get(service_id) await manager.address_removed(address_id) try: write(parsed_ip, form.port, L4.l4_of(edge), edge, target_port, upstream, proxy_ip, _hijack_port(row["transport"], form)) except sqlite3.IntegrityError as e: await restore() raise HTTPException(status_code=400, detail=_address_taken(e)) try: # A rebuild failing on the new address stops the service before it finds out; # the manager puts the old row back and starts it again on that. await manager.address_added(address_id, lambda: write( was["ip_int"], was["port"], was["proto"], was["edge"], was["target_port"], was["upstream"], was["proxy_ip"], was["proxy_port"])) except UnsupportedChain as e: await restore() raise HTTPException(status_code=400, detail=str(e)) except Exception as e: await restore() raise HTTPException(status_code=500, detail=str(e)) await refresh_frontend() return {"status": "ok"} @app.delete("/{service_id}/addresses/{address_id}", response_model=StatusMessageModel) async def delete_address(service_id: str, address_id: str): _service_or_404(service_id) _address_or_404(service_id, address_id) if len(_addresses(service_id)) == 1: raise HTTPException( status_code=400, detail="This is the only address this service protects. Delete the service " "itself, or add another address first.", ) await firewall.get(service_id).address_removed(address_id) db.query("DELETE FROM service_addresses WHERE address_id = ?;", address_id) await refresh_frontend() return {"status": "ok"} @app.delete("/{service_id}", response_model=StatusMessageModel) async def delete_service(service_id: str): _service_or_404(service_id) await firewall.remove(service_id) for row in db.query("SELECT filter_id FROM filters WHERE service_id = ?;", service_id): clear_code(row["filter_id"]) db.query("DELETE FROM regexes WHERE filter_id = ?;", row["filter_id"]) db.query("DELETE FROM pyfilters WHERE filter_id = ?;", row["filter_id"]) db.query("DELETE FROM filters WHERE service_id = ?;", service_id) db.query("DELETE FROM service_addresses WHERE service_id = ?;", service_id) stats.forget(db, service_id) db.query("DELETE FROM services WHERE service_id = ?;", service_id) await refresh_frontend() return {"status": "ok"} @app.post("/{service_id}/start", response_model=StatusMessageModel) async def start_service(service_id: str): _service_or_404(service_id) try: await firewall.get(service_id).next(STATUS.ACTIVE) except UnsupportedChain as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) _note_filtering(service_id) await refresh_frontend() return {"status": "ok"} @app.post("/{service_id}/stop", response_model=StatusMessageModel) async def stop_service(service_id: str): _service_or_404(service_id) await firewall.get(service_id).next(STATUS.STOP) await refresh_frontend() return {"status": "ok"} # --- the chain --------------------------------------------------------------- @app.get("/{service_id}/filters", response_model=list[FilterModel]) async def get_filters(service_id: str): """The chain, in the order it runs.""" _service_or_404(service_id) return db.query( "SELECT f.filter_id filter_id, f.service_id service_id, f.position position, " "f.kind kind, f.proto proto, f.name name, f.active active, f.blocked blocked, " "(SELECT COUNT(*) FROM regexes r WHERE r.filter_id = f.filter_id) n_regexes, " "(SELECT COUNT(*) FROM pyfilters p WHERE p.filter_id = f.filter_id) n_functions, " "(SELECT COUNT(*) FROM pyfilters p WHERE p.filter_id = f.filter_id AND p.active = 1) " "n_functions_active " "FROM filters f WHERE f.service_id = ? ORDER BY f.position ASC;", service_id, ) @app.post("/{service_id}/filters", response_model=StatusMessageModel) async def add_filter(service_id: str, form: FilterAddForm): """Attach a filter to the end of a service's chain.""" _service_or_404(service_id) if form.kind not in KIND.ALL: raise HTTPException(status_code=400, detail=f"Unknown filter kind {form.kind!r}") last = db.query( "SELECT COALESCE(MAX(position), -1) p FROM filters WHERE service_id = ?;", service_id )[0]["p"] filter_id = gen_id() db.query( "INSERT INTO filters (filter_id, service_id, position, kind, proto, name, active) " "VALUES (?, ?, ?, ?, ?, ?, ?);", filter_id, service_id, last + 1, form.kind, # A new pyfilter has no code yet, so it asks for nothing and speaks the simplest # protocol there is. Saving code is what settles it. PROTO.TCP, refactor_name(form.name or form.kind), form.active, ) if form.kind == KIND.PYFILTER: write_code(filter_id, "") def undo(): db.query("DELETE FROM filters WHERE filter_id = ?;", filter_id) clear_code(filter_id) await _apply_chain(service_id, undo) await refresh_frontend() return {"status": "ok"} @app.put("/{service_id}/filters/{filter_id}", response_model=StatusMessageModel) async def edit_filter(service_id: str, filter_id: str, form: FilterEditForm): was = _filter_or_404(service_id, filter_id) fields = {} if form.name is not None: fields["name"] = refactor_name(form.name) if form.active is not None: fields["active"] = form.active if fields: db.query( f"UPDATE filters SET {', '.join(f'{k} = ?' for k in fields)} WHERE filter_id = ?;", *fields.values(), filter_id, ) def undo(): db.query( f"UPDATE filters SET {', '.join(f'{k} = ?' for k in fields)} WHERE filter_id = ?;", *[was[k] for k in fields], filter_id, ) await _apply_chain(service_id, undo) await refresh_frontend() return {"status": "ok"} @app.delete("/{service_id}/filters/{filter_id}", response_model=StatusMessageModel) async def delete_filter(service_id: str, filter_id: str): _filter_or_404(service_id, filter_id) db.query("DELETE FROM regexes WHERE filter_id = ?;", filter_id) db.query("DELETE FROM pyfilters WHERE filter_id = ?;", filter_id) db.query("DELETE FROM filters WHERE filter_id = ?;", filter_id) # Its history goes with it, or the chart would keep drawing a line for something # that no longer exists, labelled with an id nobody can look up. stats.forget_filter(db, filter_id) clear_code(filter_id) # Positions must stay contiguous: a gap is invisible until something orders by # position and gets a different answer than the operator saw. _renumber(service_id) await _apply_chain(service_id) await refresh_frontend() return {"status": "ok"} def _renumber_to(order: list[str]): """Write an explicit order. Two passes because `position` is unique per service: writing the final numbers directly would collide with the ones not yet moved.""" for offset, filter_id in enumerate(order): db.query("UPDATE filters SET position = ? WHERE filter_id = ?;", -1000 - offset, filter_id) for position, filter_id in enumerate(order): db.query("UPDATE filters SET position = ? WHERE filter_id = ?;", position, filter_id) def _renumber(service_id: str): rows = db.query( "SELECT filter_id FROM filters WHERE service_id = ? ORDER BY position ASC;", service_id ) # Out of the way first: `position` is unique per service, so writing the final # numbers directly would collide with the ones not yet moved. for offset, row in enumerate(rows): db.query( "UPDATE filters SET position = ? WHERE filter_id = ?;", -1000 - offset, row["filter_id"], ) for position, row in enumerate(rows): db.query( "UPDATE filters SET position = ? WHERE filter_id = ?;", position, row["filter_id"] ) @app.post("/{service_id}/filters/order", response_model=StatusMessageModel) async def reorder_filters(service_id: str, form: ReorderForm): """Set the order the chain runs in. The whole list at once rather than a move: a partial reorder has to be reconciled against whatever the operator's page last saw, and two tabs doing that at once produce an order neither of them asked for. """ _service_or_404(service_id) existing = [ r["filter_id"] for r in db.query("SELECT filter_id FROM filters WHERE service_id = ?;", service_id) ] if sorted(existing) != sorted(form.filters): raise HTTPException( status_code=400, detail="The order must list exactly the filters this service has", ) previous = { r["filter_id"]: r["position"] for r in db.query( "SELECT filter_id, position FROM filters WHERE service_id = ?;", service_id ) } _renumber_to(form.filters) def undo(): _renumber_to(sorted(previous, key=lambda fid: previous[fid])) await _apply_chain(service_id, undo) await refresh_frontend() return {"status": "ok"} # --- a pyfilter's code ------------------------------------------------------- class CodeCheckError(BaseModel): type: str message: str #: 1-based, or 0 when nothing in the file could be pointed at. line: int = 0 column: int = 0 #: The offending source line, so the reason can be shown without a second lookup. text: str = "" traceback: str = "" class CodeCheckResult(BaseModel): ok: bool #: Which application protocol the code speaks, when it loads. proto: str | None = None #: The `@pyfilter` functions it defines, in the order the library found them. filters: list[str] = [] #: The models it asks for, which is what decides where it can run. models: list[str] = [] error: CodeCheckError | None = None @app.post("/{service_id}/filters/{filter_id}/check", response_model=CodeCheckResult) async def check_filter_code(service_id: str, filter_id: str, form: CodeForm): """Would this code load? Answered without saving it. Always 200, even when the answer is no: a refusal here is the result, not a failure of the request, and the editor wants a position to put a marker on rather than a string to print. """ row = _filter_or_404(service_id, filter_id) if row["kind"] != KIND.PYFILTER: raise HTTPException(status_code=400, detail="This filter is not a pyfilter") return await validate_code(form.code) @app.get("/{service_id}/filters/{filter_id}/code", response_class=PlainTextResponse) async def get_filter_code(service_id: str, filter_id: str): """The user's Python. Plain text, not JSON: it is a file, not a value.""" row = _filter_or_404(service_id, filter_id) if row["kind"] != KIND.PYFILTER: raise HTTPException(status_code=400, detail="This filter is not a pyfilter") return read_code(filter_id) @app.put("/{service_id}/filters/{filter_id}/code", response_model=StatusMessageModel) async def set_filter_code(service_id: str, filter_id: str, form: CodeForm): """Replace a pyfilter's code, and record which protocol it now speaks. The protocol is not asked for and cannot be set: the library reads it off the filters' parameter annotations, because that is already what decides when each one is called. Asking would mean two places that can disagree, and the symptom of a disagreement is a filter that silently never runs — or, as it happened here, one refused for asking for an `HttpRequest`, which is the only thing an HTTP filter does. A file whose filters want two *different* application protocols is refused, naming both: a connection is only ever one of them. """ row = _filter_or_404(service_id, filter_id) if row["kind"] != KIND.PYFILTER: raise HTTPException(status_code=400, detail="This filter is not a pyfilter") # Refused here, with the reason and the line, rather than by the datapath a moment # later with "the filter code did not load: worker exited" — which is true, useless, # and leaves the operator reading a traceback out of the service log. checked = await validate_code(form.code) if not checked.get("ok"): raise HTTPException(status_code=400, detail=describe_error(checked["error"])) service = _service_or_404(service_id) if str(service["proto"]) == L4.UDP: # Refused rather than accepted and never called: on a datagram these models # decline to be built, so the filter would sit in the chain doing nothing and # nothing would say so. wanted = needs_a_stream(checked.get("models", [])) if wanted: raise HTTPException( status_code=400, detail=f"This service speaks UDP, and {', '.join(wanted)} needs a TCP " f"stream underneath — an assembled stream or a parsed HTTP " f"message. On datagrams the library would decline to build it and " f"the filter would never be called. Ask for a RawPacket instead, " f"which carries the datagram and its metadata.", ) proto = checked.get("proto", PROTO.TCP) was = read_code(filter_id) was_functions = db.query( "SELECT name, active, blocked, position FROM pyfilters WHERE filter_id = ?;", filter_id ) write_code(filter_id, form.code) db.query("UPDATE filters SET proto = ? WHERE filter_id = ?;", proto, filter_id) _reconcile_functions(filter_id, checked.get("filters") or []) def undo(): write_code(filter_id, was) db.query("UPDATE filters SET proto = ? WHERE filter_id = ?;", row["proto"], filter_id) db.query("DELETE FROM pyfilters WHERE filter_id = ?;", filter_id) for fn in was_functions: # With its place in the file, or a refused edit would hand back the list in # an order the file does not have. db.query( "INSERT INTO pyfilters (filter_id, name, active, blocked, position) " "VALUES (?, ?, ?, ?, ?);", filter_id, fn["name"], fn["active"], fn["blocked"], fn["position"], ) await _apply_chain(service_id, undo) await refresh_frontend() return {"status": "ok"} def _reconcile_functions(filter_id: str, defined: list[str]) -> None: """Line the stored function list up with what the code now defines. The code decides which functions exist; the operator decides which of them run. So a function that has gone from the file is dropped, one that has appeared is added switched on, and one that is still there keeps whatever the operator had set — an edit elsewhere in the file must not silently switch a function back on. `defined` is what `pyworker.py --check` found, in definition order: the file is not run again here to ask. It used to be — `exec`ed a second time inside the backend's own event loop, after the checker had already answered the same question in a process of its own, which is exactly the place the checker exists to keep the operator's module body out of. """ existing = { row["name"] for row in db.query( "SELECT name FROM pyfilters WHERE filter_id = ?;", filter_id ) } for name in existing - set(defined): db.query("DELETE FROM pyfilters WHERE filter_id = ? AND name = ?;", filter_id, name) for position, name in enumerate(defined): if name in existing: # A survivor keeps its `active` and its counters, but not its old place: a # function inserted above it moved it down in the file, and the file is what # the order means. db.query( "UPDATE pyfilters SET position = ? WHERE filter_id = ? AND name = ?;", position, filter_id, name, ) else: db.query( "INSERT INTO pyfilters (filter_id, name, position) VALUES (?, ?, ?);", filter_id, name, position, ) #: How long the operator's code gets to load before the check gives up on it. CHECK_TIMEOUT = 8 async def _run_helper(argv: list[str], stdin: bytes | None, timeout: float) -> tuple[int, bytes, bytes] | None: """Run one of the helper processes without holding up everything else. The checker and the regex tester are separate processes on purpose, and they were waited for with `subprocess.run` from inside `async` endpoints — which blocks the one event loop the whole backend runs on. The editor asks for a check 700 ms after every pause in typing, and a check is a Python start and an import at the very least, up to `CHECK_TIMEOUT` for slow module code: for that long no acknowledgement from a datapath was read (so a ruleset push or a service start could time out), and no `BLOCKED` line either, until the engine's pipe filled and its writes stalled the traffic itself. `None` when it ran out of time, and it is killed rather than left behind. """ proc = await asyncio.create_subprocess_exec( *argv, stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: stdout, stderr = await asyncio.wait_for(proc.communicate(stdin), timeout) except (asyncio.TimeoutError, TimeoutError): proc.kill() await proc.wait() return None return proc.returncode, stdout, stderr async def validate_code(code: str) -> dict: """Would this filter load, and if not, exactly where does it go wrong? Asked of `pyworker.py --check`, which is the process that will run the filter — the same build, the same compile, the same library. A stand-in that merely parsed the file would accept things the datapath refuses, which is the class of lie the regex tester exists to avoid. In a subprocess, and not only for the answer: the module body of a filter is arbitrary code, and this used to be `exec`ed inside the backend's own event loop. A `while True:` at module level would have taken the whole interface down with it. Here it costs a timeout — the check's own, not the backend's (see `_run_helper`). """ import tempfile with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f: f.write(code) probe = f.name try: ran = await _run_helper([sys.executable or "python3", PYWORKER, "--check", probe], None, CHECK_TIMEOUT) finally: try: os.remove(probe) except OSError: pass if ran is None: return { "ok": False, "error": { "type": "Timeout", "line": 0, "column": 0, "text": "", "message": f"the file did not finish loading within {CHECK_TIMEOUT}s. " f"Code at module level runs once per connection, so it has to " f"return — a loop or a blocking call there never will.", "traceback": "", }, } _, stdout, stderr = ran try: return json.loads(stdout.decode()) except (ValueError, UnicodeDecodeError): # The checker itself failed, which is ours to explain rather than theirs. detail = stderr.decode(errors="replace").strip() or "the check produced no answer" return {"ok": False, "error": {"type": "CheckFailed", "message": detail, "line": 0, "column": 0, "text": "", "traceback": ""}} #: The one model that does not need a connection under it. #: #: Every other model reaches for something only TCP has — an assembled stream, a parsed #: HTTP message — and answers `NotReadyToRun` on a datagram, which means the filter is #: never called. Stated here rather than inferred because the library expresses it by #: declining at runtime, and a filter that silently never fires is the failure this #: whole module is arranged to prevent. DATAGRAM_SAFE_MODELS = {"RawPacket"} def needs_a_stream(models: list[str]) -> list[str]: return sorted(set(models) - DATAGRAM_SAFE_MODELS) def describe_error(error: dict) -> str: """The one-line form, for a caller that has nowhere to put a marker.""" where = f" (line {error['line']})" if error.get("line") else "" return f"{error.get('type', 'Error')}{where}: {error.get('message', '')}" # --- the functions inside one pyfilter --------------------------------------- @app.get("/{service_id}/filters/{filter_id}/functions", response_model=list[PyFunctionModel]) async def get_functions(service_id: str, filter_id: str): """Every `@pyfilter` the code defines, and whether it is switched on.""" row = _filter_or_404(service_id, filter_id) if row["kind"] != KIND.PYFILTER: raise HTTPException(status_code=400, detail="This filter is not a pyfilter") return db.query( "SELECT filter_id, name, active, blocked FROM pyfilters " "WHERE filter_id = ? ORDER BY position ASC, name ASC;", filter_id, ) @app.put( "/{service_id}/filters/{filter_id}/functions/{name}", response_model=StatusMessageModel ) async def edit_function(service_id: str, filter_id: str, name: str, form: PyFunctionEditForm): """Switch one function of a pyfilter on or off. The code is untouched: only the list of names handed to the library changes, which is exactly what decides whether a function is ever called. Deleting the code to stop consulting it, and pasting it back to resume, is the thing this exists to avoid. """ row = _filter_or_404(service_id, filter_id) if row["kind"] != KIND.PYFILTER: raise HTTPException(status_code=400, detail="This filter is not a pyfilter") found = db.query( "SELECT active FROM pyfilters WHERE filter_id = ? AND name = ?;", filter_id, name ) if not found: raise HTTPException(status_code=404, detail="This filter defines no such function") db.query( "UPDATE pyfilters SET active = ? WHERE filter_id = ? AND name = ?;", form.active, filter_id, name, ) await _apply_chain(service_id, lambda: db.query( "UPDATE pyfilters SET active = ? WHERE filter_id = ? AND name = ?;", found[0]["active"], filter_id, name)) await refresh_frontend() return {"status": "ok"} # --- a regex filter's patterns ----------------------------------------------- @app.get("/{service_id}/filters/{filter_id}/regexes", response_model=list[RegexModel]) async def get_regexes(service_id: str, filter_id: str): _filter_or_404(service_id, filter_id) return db.query("SELECT * FROM regexes WHERE filter_id = ?;", filter_id) def _decoded_pattern(encoded: str) -> str: """The pattern a request carries, or a 400 saying why it is not one. Two things the obvious `b64decode(...)` does not do, and both of them matter because what gets through here is compiled into a live service's chain. **Strict decoding.** Python's decoder ignores characters outside the alphabet unless it is asked not to, so `"!!!"` is not an error — it is `b""`. A malformed field therefore arrived as an empty pattern rather than as a refusal. **And an empty pattern is refused.** hyperscan compiles it happily (the flags allow a pattern that matches an empty buffer, which real patterns like `a*` need) and then it matches every buffer it is shown — so a service whose chain holds one refuses every connection it carries. There is no legitimate way to ask for that, an empty field in the form is the only way to arrive at it, and the symptom is a CTF service that has silently stopped answering. It is worth one sentence at the door. """ try: # Whitespace out first, then strict: a base64 field that arrived wrapped across # lines is still a perfectly good one, and `validate=True` would refuse it for a # newline. What is being caught here is a field that is not base64 at all. raw = base64.b64decode("".join(str(encoded).split()), validate=True) except (binascii.Error, ValueError): raise HTTPException(status_code=400, detail="The pattern must be base64-encoded") if not raw: raise HTTPException( status_code=400, detail="A pattern cannot be empty: an empty one matches every byte of every " "connection, so the service would refuse all of its traffic.", ) return raw.decode(errors="replace") @app.post("/{service_id}/filters/{filter_id}/regexes", response_model=StatusMessageModel) async def add_regex(service_id: str, filter_id: str, form: RegexAddForm): row = _filter_or_404(service_id, filter_id) if row["kind"] != KIND.REGEX: raise HTTPException(status_code=400, detail="This filter does not hold patterns") if form.mode not in MODE.ALL: raise HTTPException(status_code=400, detail=f"Unknown mode {form.mode!r}") pattern = _decoded_pattern(form.regex) # Checked by the engine that will run it, not by a stand-in. A pattern accepted # here and refused at start time would leave the operator with a service that # will not come up and no idea which rule is at fault. ok, why = await check_pattern(pattern, form.case_sensitive) if not ok: raise HTTPException(status_code=400, detail=f"Invalid pattern: {why}") try: regex_id = gen_id() db.query( "INSERT INTO regexes (regex_id, filter_id, regex, mode, case_sensitive, active) " "VALUES (?, ?, ?, ?, ?, ?);", regex_id, filter_id, form.regex, form.mode, form.case_sensitive, form.active, ) except sqlite3.IntegrityError: raise HTTPException(status_code=400, detail="This pattern is already in this filter") await _apply_chain(service_id, lambda: db.query( "DELETE FROM regexes WHERE regex_id = ?;", regex_id)) await refresh_frontend() return {"status": "ok"} @app.put("/{service_id}/filters/{filter_id}/regexes/{regex_id}", response_model=StatusMessageModel) async def edit_regex(service_id: str, filter_id: str, regex_id: str, form: RegexEditForm): """Change a pattern in place, without dropping the connections it is matching. The same rule keeps its row, so it keeps its place and its counters — except when the *pattern itself* changes. Then its history stops being its own: a count is about what a rule matched, and carrying yesterday's blocks over to a matcher that now matches something else would credit them to something that never made them. It is handed to the filter rather than deleted, so the timeline keeps its shape — those connections were refused, and correcting a typo afterwards does not unrefuse them. """ _filter_or_404(service_id, filter_id) was = _regex_or_404(filter_id, regex_id) fields: dict = {} if form.active is not None: fields["active"] = form.active if form.mode is not None: if form.mode not in MODE.ALL: raise HTTPException(status_code=400, detail=f"Unknown mode {form.mode!r}") fields["mode"] = form.mode if form.case_sensitive is not None: fields["case_sensitive"] = form.case_sensitive if form.regex is not None: fields["regex"] = form.regex if not fields: return {"status": "ok"} # Whatever the row will hold once this is applied, checked as a whole rather than # field by field: what the engine has to accept is the resulting pattern. pattern = _decoded_pattern(fields.get("regex", was["regex"])) ok, why = await check_pattern(pattern, fields.get("case_sensitive", was["case_sensitive"])) if not ok: raise HTTPException(status_code=400, detail=f"Invalid pattern: {why}") changed_matcher = "regex" in fields and fields["regex"] != was["regex"] if changed_matcher: fields["blocked"] = 0 try: db.query( f"UPDATE regexes SET {', '.join(f'{k} = ?' for k in fields)} WHERE regex_id = ?;", *fields.values(), regex_id, ) except sqlite3.IntegrityError: raise HTTPException( status_code=400, detail="This filter already holds that exact pattern" ) def undo(): db.query( f"UPDATE regexes SET {', '.join(f'{k} = ?' for k in fields)} WHERE regex_id = ?;", *[was[k] for k in fields], regex_id, ) await _apply_chain(service_id, undo) # After the chain has accepted it, and not before: a refusal has to leave the rule # exactly as it was, and history moved ahead of the verdict could not be put back. if changed_matcher: stats.disown_rule(db, filter_id, regex_id) await refresh_frontend() return {"status": "ok"} @app.delete("/{service_id}/filters/{filter_id}/regexes/{regex_id}", response_model=StatusMessageModel) async def delete_regex(service_id: str, filter_id: str, regex_id: str): _filter_or_404(service_id, filter_id) _regex_or_404(filter_id, regex_id) db.query("DELETE FROM regexes WHERE regex_id = ?;", regex_id) await _apply_chain(service_id) await refresh_frontend() return {"status": "ok"} # --- what the service is doing ----------------------------------------------- @app.get("/{service_id}/stats", response_model=StatsModel) async def get_stats( service_id: str, range_from: int | None = None, range_to: int | None = None, buckets: int = 60, step: int | None = None, ): """What this service's filters have refused, over a range you choose. `range_from`/`range_to` are unix seconds; leaving them out means the last hour. Everything in the answer counts the same range — the chart, the per-filter, per-pattern and per-function totals, and the shares — because a page where the chart honours the range and the table beside it does not is a page that contradicts itself. The bucket widens with the range rather than the count growing: twelve hours at one-minute resolution is seven hundred bars in a few hundred pixels. `step` overrides that with a width in seconds, for an operator who wants two ranges to be comparable by eye; it is rounded to whole stored buckets and widened if it would draw more bars than a chart can carry, and `bucket_seconds` reports what was actually used. """ service = _service_or_404(service_id) buckets = max(5, min(int(buckets), 400)) # The real instant, not the bucket it falls in: the window is aligned to buckets # inside `stats`, and truncating here would report the current, partial minute as # ending at the moment it began — which on a service a few seconds old prints as # "23:35 → 23:35". now = int(time.time()) kept_from = stats.retention_start() began = service.get("filtering_since") end = min(int(range_to) if range_to else now, now) start = int(range_from) if range_from else end - 3600 if end < start: start, end = end, start # asked for the wrong way round # Clamped rather than refused: asking for a week of a two-day history is a reasonable # thing to do, and so is asking for a day of a service that has been up for ten # minutes. The answer is what there is, with the range actually served reported back # so the interface can say what it drew. start = max(start, kept_from) # The second floor, and the one that bites every day: without it a new service is a # chart of twenty-three empty hours with a smudge at the right-hand edge, which reads # as "nothing is happening" when what it means is "this only started existing a # moment ago". Only when the window reaches into the service's life at all — a window # that ended before it began is answered as asked, and is empty because it is, rather # than being dragged forward onto minutes nobody asked about. if began is not None and end >= began: start = max(start, began) window = stats.window(db, service_id, start, end, buckets, step if step else None) counted = stats.totals(db, service_id, start, end) by_filter = stats.totals_by_filter(db, service_id, start, end) total = sum(counted.values()) def with_share(rows: list[dict]) -> list[dict]: for row in rows: row["share"] = round(100 * int(row["blocked"]) / total, 1) if total else 0.0 return rows filters = db.query( "SELECT filter_id id, name, kind, blocked all_time FROM filters " "WHERE service_id = ? ORDER BY position ASC;", service_id, ) for row in filters: # Summed over the rules inside it, not looked up by its own id: a regex block is # reported by its pattern and a Python one by its function, so a filter's own # token appears only when neither named itself. row["blocked"] = by_filter.get(row["id"], 0) patterns = db.query( "SELECT r.regex_id id, r.regex name, f.kind kind, r.filter_id filter_id " "FROM regexes r JOIN filters f ON r.filter_id = f.filter_id WHERE f.service_id = ?;", service_id, ) for row in patterns: row["blocked"] = counted.get(row["id"], 0) # The pattern is stored base64 because it is bytes; a chart legend wants text. try: row["name"] = base64.b64decode(row["name"]).decode(errors="replace") except (binascii.Error, ValueError): pass functions = db.query( "SELECT p.name name, 'pyfilter' kind, p.filter_id filter_id FROM pyfilters p " "JOIN filters f ON p.filter_id = f.filter_id WHERE f.service_id = ?;", service_id, ) for row in functions: # A function's name is unique inside its file, not across the chain, so the # token the datapath reports is what identifies it here too. row["id"] = f"{row['filter_id']}/{row['name']}" row["blocked"] = counted.get(row["id"], 0) # What a filter was credited with that no rule still in it accounts for: a pattern # that was deleted, or one whose text was edited and so handed its history to the # filter. Those blocks are in the chart and in the total because they happened, so # the breakdown has to name them — otherwise it quietly adds up to less than the # chart above it, and the operator is left looking for the difference. accounted: dict[str, int] = {} for row in patterns + functions: accounted[row["filter_id"]] = accounted.get(row["filter_id"], 0) + int(row["blocked"]) for row in filters: left = int(row["blocked"]) - accounted.get(row["id"], 0) if left <= 0: continue is_py = row["kind"] == KIND.PYFILTER (functions if is_py else patterns).append({ "id": f"{row['id']}/", "name": "functions since removed" if is_py else "patterns since edited or removed", "kind": row["kind"], "filter_id": row["id"], "blocked": left, "all_time": None, "residual": True, }) named = {row["id"]: row["name"] for row in filters} manager = None try: manager = firewall.get(service_id) except Exception: pass traffic = manager.traffic() if manager else {} seen, refused = traffic.get("connections"), traffic.get("connections_refused") return { "filters": with_share(filters), "patterns": with_share(sorted(patterns, key=lambda r: -r["blocked"])), "functions": with_share(sorted(functions, key=lambda r: -r["blocked"])), "buckets": window["edges"], "bucket_seconds": window["width"], # Only the filters that actually refused something in the range: an empty line # per filter would make a busy chain unreadable to say nothing. "series": [ {"id": fid, "name": named.get(fid, fid), "counts": counts} for fid, counts in window["series"].items() ], "total": total, "all_time": sum(int(row["all_time"]) for row in filters), "range_from": start, "range_to": end, "kept_from": kept_from, "filtering_since": began, "traffic": { **traffic, # Only where both numbers are connections. Dividing refused connections by # a packet count would produce a number that looks like a percentage and # means nothing. "refused_share": ( round(100 * refused / seen, 2) if seen and refused is not None else None ), }, } @app.get("/{service_id}/logs", response_model=list[LogEntryModel]) async def get_logs(service_id: str): """The tail of what this service has been doing. Only the tail: new lines arrive over the `log` socket event as they happen, so this exists to fill the panel when it opens rather than to be polled. It is bounded, so a service that has blocked a million connections still answers instantly. """ _service_or_404(service_id) return log_for(service_id).entries() @app.delete("/{service_id}/logs", response_model=StatusMessageModel) async def clear_logs(service_id: str): _service_or_404(service_id) log_for(service_id).clear() # The list shows the newest problem in the log, and clearing is how it is dismissed. await refresh_frontend() return {"status": "ok"} # --- the debugger ------------------------------------------------------------ async def _ask_engine(request: dict) -> dict: """Put the question to the binary that would enforce the answer.""" try: ran = await _run_helper([PROXY_ENGINE, "--debug-regex"], json.dumps(request).encode(), 10) except FileNotFoundError: raise HTTPException(status_code=500, detail="The matching engine is not installed") if ran is None: raise HTTPException(status_code=400, detail="The pattern took too long to evaluate") returncode, stdout, stderr = ran if returncode != 0: detail = stderr.decode(errors="replace").strip() or "the engine refused the request" raise HTTPException(status_code=400, detail=detail) return json.loads(stdout.decode()) async def check_pattern(pattern: str, case_sensitive: bool = True) -> tuple[bool, str]: """Would the engine accept this pattern? Asked of the engine that will run it rather than of a stand-in, and compiled for stream matching, which is the only mode anything runs in now: a pattern blocks, and blocking follows the stream across chunk boundaries. This used to take the action too, because rewriting compiled in block mode and the two do not accept quite the same patterns. `case_sensitive` is coerced rather than trusted: a caller checking a pattern it just read back from the database hands over SQLite's 1, and the engine's request format is strict about the difference — which came out as "invalid type: integer 1" against a pattern that was perfectly fine. """ try: res = await _ask_engine( { "patterns": [ { "id": "x", "expr": pattern, "case_sensitive": bool(case_sensitive), } ], "sample": "", } ) except HTTPException as e: return False, str(e.detail) if res.get("error"): return False, res["error"] if res.get("errors"): return False, res["errors"][0]["error"] return True, "ok" @app.post("/debug-regex", response_model=DebugResponse) async def debug_regexes(form: DebugForm): """Try patterns against a sample, with the engine that will run them. Deliberately not a `re`-based approximation. hyperscan takes a subset of PCRE and rejects things Python accepts, so anything else would bless patterns that cannot be saved and disagree about what matches — a tester the operator would learn not to trust, which is worse than none. """ res = await _ask_engine( { "patterns": [ { "id": p.id, "expr": p.expr, "case_sensitive": p.case_sensitive, } for p in form.patterns ], "sample": form.sample, } ) return { "matches": [ {"id": m["id"], "start": m["from"], "end": m["to"]} for m in res.get("matches", []) ], "errors": res.get("errors", []), "error": res.get("error"), "unscannable": res.get("unscannable", []), "truncated": res.get("truncated", False), }