# ๐Ÿ›ก๏ธ Services A **service** is one thing you are protecting, described in two independent parts: ``` filters regex (hyperscan) ยท pyfilter (Python) what to do with it network layer NFQUEUE ยท proxy ยท your own proxy how to get hold of it ``` You choose each one separately. Any filter runs on any network layer that can host it, in whatever order you put them, and you can change either without recreating the other. ## Where a service is A service has a **list of addresses**, not one. The same daemon routinely answers on more than one โ€” a v4 and a v6 address, a public and an internal one, two ports of the same process โ€” and all of them deserve the same chain. Add them when you create the service or afterwards, mixing address families โ€” and interface names โ€” freely. Adding an address to a running service **drops nothing**: the datapath is already up and already enforcing the chain, so all that happens is that one more address is pointed at it. On TCP and TLS the listener is dual-stack from the start, so adding IPv4 or IPv6 addresses requires no restart. On UDP, a dedicated relay is created dynamically on the fly without interrupting existing traffic. The alternative โ€” one service per address, with the chains copied between them โ€” is how one of them silently stops being protected the first time a pattern is added to the other. ### An address, or the interface it arrives on An address entry is a concrete IP (`192.168.1.10:80`, `::1:80`, CIDR `10.0.0.0/24:8080`) or a **network interface name** (`eth0:80`, `wg0:8080`, `tun0:53`, `lo:80`). The picker lists both, each interface shown with the addresses it is carrying at that moment, and takes anything you type that is neither โ€” the address a service will answer on does not have to exist on this host yet. Which of the two to use is a question about who decides the address. An IP protects that one place. An interface protects *whatever address that link is carrying*, matched on the name, which is what you want when somebody else hands the address out โ€” a VPN the organisers dial you on, a DHCP lease, a tunnel that comes up mid-round. Nothing has to be edited when the address changes, and nothing silently stops being protected when it does. The rules are built differently for each, so the layer matters: - **On NFQUEUE**: interface matching operates natively in nftables using `meta iifname ` (in prerouting) and `meta oifname ` (in postrouting). Packets arriving at or routed through that interface on the configured port are queued to userspace, regardless of the destination IP. - **On Proxy (TCP & TLS)**: traffic entering through the interface (`meta iifname `) is redirected to the proxy listener. The engine connects upstream preserving the destination (`SO_ORIGINAL_DST`) and spoofing the client source IP (`IP_TRANSPARENT`), while policy routing diverts return packets (`meta oifname `). Local host output redirection is skipped for interfaces to prevent hijacking unrelated outbound host connections. - **On Proxy (UDP)**: because UDP relays need a concrete destination to bind upstream forwarding, Firegex automatically resolves the interface's primary assigned IP. If the interface has no IP assigned, use NFQUEUE instead. - **External proxies**: the `external` transport takes an IP only. Its return rule recognises your proxy by one address and port to put the original port back, and an interface is not one address โ€” so the interface half of the picker is not offered there at all. ## Transport protocol TCP, UDP or TLS, chosen on the service. Every layer carries TCP and UDP; TLS is on the proxy layer alone, because decrypting means terminating the connection โ€” see [TLS](#tls). **UDP on the proxy layer preserves the client's address** transparently using `IP_TRANSPARENT` source spoofing and policy routing, matching the behavior of TCP. See [UDP on the proxy layer](#udp-on-the-proxy-layer). This used to be four modules โ€” `nfregex` was NFQUEUE welded to regexes, `nfproxy` was NFQUEUE welded to Python, `tls` was a separate object you had to keep in step with the service that used it. Choosing regexes therefore also chose NFQUEUE, and switching meant starting over. Nothing about the two decisions actually requires them to be one. ## Network layers The two that inspect traffic are built on opposite trades. **The trade is not speed** โ€” the proxy layer measures faster on both bulk throughput and connection rate, for the reason in the table below โ€” it is what happens when a filter dies, and what you are allowed to do to the traffic. Pick by those. | | **Proxy** | **NFQUEUE** | |---|---|---| | Unit of work | a connection | a packet | | Transparency | full โ€” dials your service **from the client's own address** | full โ€” the real packets, on their way to your service | | Reassembly | the kernel's, on two real sockets | rebuilt in userspace with libtins | | Rewriting | patterns: no. Python: exact, any length | patterns: no. Python: unstable on TCP, exact on UDP | | If a filter dies | the engine rebuilds fail-open by hand | **the kernel keeps forwarding**, by itself | | Cost | terminating a connection, once | **a userspace round trip per packet**, per filter | | Measured ([how](../tests/bench/README.md#performance)) | **4035 MB/s** at 1 thread, **13 984** at 8 | 1820 at 1 thread, 2956 at 8 | | Short connections | the two are indistinguishable โ€” see below | | | UDP | yes, fully transparent (source IP preserved, one relay per address) | yes, fully transparent | | TLS termination | yes | no โ€” decrypting means terminating | ### Proxy The connection is terminated and reopened towards your service, so firegex owns both halves. **What that buys** - **Exact rewriting, for a Python filter.** Two independent connections means a replacement can be any length: there are no sequence numbers shared with the client to desynchronise. Patterns do not rewrite on any layer โ€” see **Regex** under *Filters* below for why that was withdrawn. This is - **Reassembly is the kernel's problem.** Both sides are ordinary sockets, so out-of-order and retransmitted segments are sorted out before a filter ever sees them โ€” rather than being rebuilt in userspace with the caveats that come with it. - **Backpressure for free.** A slow filter slows the sender through TCP flow control instead of overflowing a queue and dropping packets. - **An ordered chain inside one process.** Filters are a list walked in order, so a long chain costs one interception, not one per filter. - **It carries bulk traffic several times faster**, which reads backwards and is not: NFQUEUE copies every packet out to the kernel's queue and takes a verdict back, once per packet, while the proxy pays to terminate a connection once and then the kernel moves the bytes. Measured on the same host, same filter, same traffic: **2.2ร—** at one thread and **4.7ร— at eight**, with the ranges nowhere near touching. It also scales better with `--threads` โ€” 3.5ร— from one thread to eight, against about 1.5ร— for NFQUEUE, whose work is largely a trip through the kernel that a second core cannot take. **On short connections there is no measurable difference.** That is the axis where a terminating proxy is supposed to lose, and it does not obviously โ€” but neither does it win: measured with the layers interleaved, they overlap, and the medians land within 3% of each other. Anyone quoting a number for this axis is quoting noise, this page included until it was measured properly. Both are in [tests/bench/README.md](../tests/bench/README.md#performance), with the scripts that produce them. **What it costs** - **The kernel's fail-open backstop is gone**, and the engine has to rebuild it by hand: `catch_unwind`, a deadline per filter, a filter that misbehaves losing its say rather than the traffic being held. It works โ€” and it is code, where the other layer has a kernel guarantee. It stays invisible: it always dials your service **from the client's own address** on both TCP and UDP, so anything that logs, rate-limits or bans by IP keeps working. That is not a setting, because a service that suddenly saw one address for the whole internet would be a regression nobody would attribute to us. ### NFQUEUE Packets are lifted to userspace, inspected, and a verdict is handed back to the kernel. Nothing is terminated. **What that buys** - **The kernel's fail-open backstop is real.** If a filter panics, hangs or crashes, the kernel keeps forwarding untouched packets. - **Full transparency.** The service sees the original packets, with original headers and timestamps intact. - **No connection termination.** Suitable for protocols that cannot be proxied or where terminating the transport is prohibited. **What it costs** - **A userspace round trip per packet.** Every packet is copied across the netlink boundary, which caps bulk throughput. - **Reassembly in userspace.** Done via libtins, with memory caps and timeout heuristics. - **One process per filter.** Chaining requires multiple processes and netfilter queues. - **A Python filter's rewriting is unstable on TCP here.** Changing a payload's length desynchronises the stream, which is what `UNSTABLE_MANGLE` is named after. On **UDP** it is exact, because a datagram carries no sequence numbers to desynchronise. Patterns do not rewrite on either layer. ### UDP on the proxy layer On TCP a single listener fronts every protected address and recovers where each connection was headed with `SO_ORIGINAL_DST`, reading the conntrack entry the redirect left behind. **The kernel implements that option for TCP and SCTP only**; ask it about a UDP socket and it answers `ENOPROTOOPT`. So UDP is relayed with **one dedicated socket per protected address**, each with its upstream already known, and nothing has to be recovered per datagram. What UDP on the proxy layer provides: - **Full source IP transparency.** Outbound datagrams towards your service are sent with `IP_TRANSPARENT` using the client's own address and port, with mark-based policy routing diverting return packets to the local engine. Your service sees the real client IP. - **Zero-downtime dynamic address addition.** Adding new addresses to a running UDP service dynamically binds new relay sockets through the control channel without restarting the engine or dropping existing connections. - **Exact rewriting, at any length, for a Python filter.** A datagram is self-contained, so there are no sequence numbers for a longer or shorter payload to desynchronise. The caveat that makes rewriting unstable on the NFQUEUE layer simply does not apply. - **Per-flow filter state.** Each client address is a flow with its own filter state and its own Python module globals, released after a minute of silence โ€” UDP has no close to observe, so a timeout is the only thing that can end one. - **Both directions inspected**, and replies leave through the listener socket so conntrack rewrites them to appear from the address the client dialled. `REJECT` means something narrower here: there is no connection to close, so the datagram is simply not forwarded, and the next one from that client is judged afresh. ### Holding up under load A service can be given a **limit on how many connections it carries at once**, and told what to do with what does not fit. Both are on the proxy layer, which is the one that accepts a connection and dials the service; NFQUEUE hands the kernel a verdict on packets already in flight, and what it can run out of is queue โ€” that is what fail-open is for. Without a limit, connections that are opened and then say **nothing** are the cheapest attack there is. Each costs two descriptors โ€” one from the client, one to the service, because the upstream is dialled as soon as the connection is accepted โ€” and about five hundred of them from a single host were enough to exhaust firegex entirely. No data has to be sent, and every other service on the instance went down with the one being attacked. A limit does **not** save the service under attack. A cap cannot tell a connection that is silent because it is an attack from one that is silent because the client is slow, so an attacker who fills the limit still fills it. What it does is contain the damage to that one service: measured with four hundred silent connections against a limited service, the service beside it kept answering, which is what used to be lost. What happens past the limit is yours to choose: - **Refuse** (the default). Nothing reaches the service that was not inspected, and clients are turned away while the limit holds. - **Forward unfiltered.** The service stays reachable past the limit, and that traffic gets there with nothing having looked at it. This is the same trade as fail-open, made at a different moment, and it is off by default for the same reason: a filter that quietly stops filtering is the worse surprise. **Hitting the limit leaves a mark in two places, because they answer different questions.** The service log says it now, at warning level, so an operator watching clients fail sees the reason rather than guessing โ€” rate-limited, since being at the limit means connections are arriving faster than they leave and a line each would be the flood arriving twice. And a count is kept in the database with the first and last time it happened, which is what survives a restart and outlives the live log's bounded ring: *did we ever hit the wall* is a question asked the next morning. The service page shows it above everything else. #### A deadline on the first byte The limit contains the damage; it cannot undo it, because a cap cannot tell a connection that is silent because it is an attack from one that is silent because the client is slow. An attacker who fills the limit keeps it filled. So a service can also be told to **close a connection that has said nothing** after a number of seconds. That is the thing the limit cannot do: measured with the limit full and the attacker still holding every socket, clients got back in once the deadline passed. Two properties make it safe to turn on: - It counts **either direction**. A service that greets its client โ€” SMTP, SSH, most game protocols โ€” satisfies the deadline with its banner, exactly as a request would. A deadline that required the *client* to speak first would have hung every one of them. - It applies only until the **first** byte, and never again. A connection that has said something and then goes quiet is a session, and sessions are allowed to think. This is not an idle timeout, and it will not close a long-lived connection that is waiting. Zero means never, which is what every service did before. There is no default worth picking for you: how long a legitimate client may reasonably stay silent is a fact about your service, not about firegex. UDP flows are counted against the same number. They are the cheaper half of the same attack โ€” a flow is created per source address, a datagram's source is not verified by any handshake, and six hundred datagrams from six hundred forged sources took four hundred descriptors in three hundredths of a second and held them for a minute after the sender had gone. ### TLS TLS is one of the **transport protocols** a service can speak, beside TCP and UDP โ€” not a switch on top of TCP. That is the shape of the thing: asked what is on the wire, the answer is TLS or it is not. It is offered on the **proxy** layer only, because decrypting means terminating the connection and terminating is what that layer does. NFQUEUE inspects packets on their way past, which is the whole reason it can fail open, and a hand-off to your own proxy has nothing of firegex in the path at all โ€” so on those layers the TLS option is there but cannot be chosen, and the form says why. The same holds the other way round: choose TLS and the two layers that cannot carry it grey out. Choose it and give the service a certificate and a private key; both fields take a paste, a file picked from disk, or a file dropped onto them. The engine decrypts the connection, the filters see the plaintext, and it is re-encrypted towards your service โ€” all inside the process that is already filtering, so **a TLS service occupies no port that a plain one does not**, and clients keep dialling the address they always did. There used to be two ports, and a boolean. nginx terminated on one loopback port and re-encrypted from a second, with the filters attached to the leg in between, so protecting one address cost a pair of ports derived by hashing `ip:port` โ€” ports that were chosen rather than assigned, and could collide with something real. And because TLS was a flag rather than a protocol, three combinations were expressible that are not real: TLS on a UDP service, on NFQUEUE, and on a hand-off. Two of those cannot be said any more; the third is refused when the service is created rather than when it first fails to start. The private key is stored and never sent back to the browser; neither is the certificate, so the fields are empty when you come back to edit and leaving them that way keeps what is already there. Switching a service *to* TLS when it has never been given either is refused there and then. #### What is carried through, and what cannot be **ALPN is the service's answer, carried.** Terminating a connection means answering for your service, and answering something it did not say is how a proxy breaks a protocol it was only meant to carry โ€” a client told `h2` while the service speaks HTTP/1.1 sends frames to something that cannot read them. So the client's hello is held open, the service is asked with exactly the list the client offered, and the client is told exactly what came back. If the service picks nothing, the client is told nothing: firegex does not invent an agreement between two ends that did not reach one. That means HTTP/2 works if your service speaks it, and does not appear if it does not. Before this, ALPN was dropped in both directions โ€” every client fell back to HTTP/1.1 whatever it asked for, and a client that insists on `h2` failed. **TLS versions are negotiated per leg**, which is what terminating means: a client on TLS 1.2 can reach a service on TLS 1.3. Both legs do **TLS 1.2 and 1.3 only**. TLS 1.0 and 1.1 are not supported and cannot be turned on โ€” the engine's TLS library does not implement them at all, so this is a limit of what firegex can be rather than a setting that is off. A service that must be reached over TLS 1.0 has to keep firegex out of its path, on the `external` layer or the NFQUEUE one with the ciphertext filtered as it goes by. The same goes for the cipher list: it is a modern fixed set, not something to configure, and key exchange is always forward-secret. There is no option for RC4, 3DES, or RSA key transport, and there is no switch that would bring them back. Client certificates are not requested. A service that authenticates its clients with mTLS cannot be terminated here, because the certificate the client would present is one only your service can check. **The key has to be one the engine can sign with**: RSA of at least 2048 bits, or an ECDSA or Ed25519 key. A smaller RSA key is refused when the service starts, with the engine's own reason. nginx could be told to accept one; the engine's TLS library has no equivalent switch. #### Watching the decrypted traffic The plaintext never becomes a packet โ€” that is exactly what removed the two ports โ€” so the engine writes it out itself. **`firegex0` carries the decrypted traffic of every TLS service and nothing else:** ```bash sudo tcpdump -i firegex0 -w decrypted.pcap ``` No filter expression, no port to look up, and one capture covering the whole instance rather than one per service. Wireshark takes the same interface live, or opens the file afterwards. Run it on the **host** running firegex: the container shares the host's network namespace, so the interface is there. It exists only while some TLS service is running. What arrives there is a **reconstruction**. The bytes are real โ€” exactly what the filters saw and exactly what was forwarded, after any rewriting โ€” but the framing around them is built by the engine, because the framing that crossed the wire was encrypted. Sequence numbers start at zero, there are no retransmissions, and the segmentation is the engine's read sizes rather than the peer's. What that buys is a stream Wireshark can follow; what it costs is that a capture from here is not evidence of what was on the wire. Opening the socket it writes to needs `CAP_NET_RAW`. Firegex asks for it and starts without it: a host that will not grant it loses the capture, not the firewall, and says so once at startup. What comes out is decrypted traffic, which makes the file exactly as sensitive as the private key that would have produced it. ## Filters ### Regex Patterns matched by [hyperscan](https://github.com/VectorCamp/vectorscan) โ€” the same library on both network layers, so a pattern means the same thing wherever it runs. Every pattern in one filter is compiled into a single database, so matching fifty patterns costs about what matching one costs. That is why patterns live inside a filter rather than each being one, and why adding a pattern rebuilds rather than chains. Matching is **stateful per connection**: a pattern split across two packets is still found, however far apart the halves land, and one client's bytes can never decide another client's verdict. Each pattern has a direction โ€” client to service, service to client, or both. A leaked flag shows up on the way out; an exploit on the way in. A pattern **blocks** the connection. That is the only thing it does: ``` FLAG\{[a-z0-9]+\} service โ†’ client block X-Debug: [^\r\n]* client โ†’ service block ../ both ways block ``` A pattern can be **edited in place**. A typo is usually found while the service is running, and retyping the rule as a new one loses its place in the chain and drops the connections the filter is holding โ€” editing keeps both. What it does not keep is the **counters**, when the pattern text itself changes: those numbers were about the text that used to be there, and carrying them over would credit blocks to a matcher that never made them. Changing anything else โ€” the direction, the case sensitivity, whether it is active โ€” leaves the counters alone. The new pattern is checked by the engine before it is stored, and a rule that would collide with another in the same filter is refused rather than silently duplicated. **A pattern cannot rewrite, on any layer.** It used to be able to, and the reason it no longer can is worth stating rather than leaving as a missing feature. Rewriting scanned one chunk at a time, because bytes already forwarded cannot be taken back โ€” so a match straddling two chunks was never rewritten. Measured: a pattern sent whole reached the service redacted, and the *same* pattern split across two TCP segments reached it intact. Blocking, which scans in hyperscan's stream mode, caught the split version. What made that unacceptable was not the miss but its silence: no block, no log line, no counter โ€” just a rule the operator believed was applying. A filter that works on the sender's segmentation is worse than no filter, because it is trusted. Blocking is the verdict a pattern can honestly reach on a stream, so it is the only one offered. To change bytes rather than refuse them, use a Python filter, which sees the reassembled stream and can say so. ### Python (pyfilter) Your own code, written against the [`firegex`](https://pypi.org/project/firegex/) library and documented in [pyfilter.md](pyfilter.md). On the proxy layer it runs in a process of its own, so code that hangs is killed rather than stalling traffic; on NFQUEUE it runs inside the C++ process through an embedded interpreter. A file normally holds **several `@pyfilter` functions**, and each one is listed on the filter's card with its own switch and its own count of what it has refused. Switching one off leaves the code exactly where it is: only the list of names handed to the library changes, and that list is what decides whether a function is ever called. The alternative โ€” deleting a function to stop consulting it, and pasting it back to resume โ€” is what these switches exist to replace, and it is the one that loses code mid-round. The code itself is visible on the card too, without opening the editor: expand it to read what is running. The code decides which functions exist; you decide which of them run. So saving a new version drops the functions that have gone from the file, adds the new ones switched on, and leaves whatever you had set for the ones still there โ€” an edit elsewhere in the file must not quietly switch a function back on. **You do not tell firegex which protocol a filter speaks โ€” the code does.** The library already decides when to call a filter from what its parameters are annotated with, so asking for an `HttpRequest` is what makes a file an HTTP filter, and asking only for a `RawPacket` is what makes one run on anything. Firegex reads it back off the file when you save it and shows it on the card. A file whose filters want two *different* application protocols is refused when saved, naming both โ€” a connection is only ever one of them, so split them into two filters on the same service. A Python filter runs on UDP too, on either layer, as long as it asks for a `RawPacket`: the stream and HTTP models need a connection underneath them and would never be built on a datagram. Saving one that asks for them against a UDP service is refused rather than accepted and never called. A filter reads metadata about the layers below it โ€” addresses, ports, address family, direction โ€” and can change **only the payload**. That boundary is described in [pyfilter.md](pyfilter.md), and it is what lets the same filter mean the same thing on either network layer. ## Testing a pattern Every regex filter card has a **try** button โ€” and only a regex filter card, because trying a pattern is a thing you do to a set of patterns, not to a service. It is a regex tester inside firegex: patterns on the left, a sample below, matches highlighted. It runs **the same engine that will enforce the answer**. That is the entire point. A tester built on Python's `re` or JavaScript's `RegExp` would accept backreferences and lookarounds hyperscan rejects, and would disagree about what matches โ€” so you would tune a pattern against it and find out mid-round that it was never valid. Here, a pattern the tester accepts is one you can save, and a pattern it rejects tells you why in hyperscan's own words. One detail follows from being honest rather than approximate. A pattern is judged against the mode it will actually run in โ€” stream matching, which is how a pattern follows a connection across chunk boundaries, and hyperscan does not accept quite the same patterns in every mode โ€” so the tester's verdict is the one the datapath will reach. And occasionally a pattern is valid, will run, and still cannot be highlighted here, because showing *where* a match starts needs the scan mode that accepts slightly less. The tester says so instead of silently showing no matches. ## What has been refused The service page answers three questions, because a single number answers none of them. **Which rule is doing the work.** Every filter, every pattern and every `@pyfilter` function is listed with what it refused and **what share of the total is its doing**. A chain where one pattern accounts for 96% of the blocking and the other nineteen for the rest is a chain you would drop; you cannot see that from raw counts. **When it started.** A timeline, which a cumulative counter cannot give you: a filter that blocked a thousand connections an hour ago looks exactly like one blocking them now. **How much of the traffic it is** โ€” see below, where each layer reports what it can honestly measure. ### Choosing a window The last 15 minutes, hour, 6 hours, 24 hours, everything kept โ€” or **two exact instants**, for looking at something that has already happened: a round that ended, an attack somebody described afterwards. **Everything on the page obeys the window.** The chart, the per-filter, per-pattern and per-function totals, and the shares are all computed over the same range. A page where the chart honours a fifteen-minute window and the table beside it quietly reports all of time is a page that contradicts itself. The lifetime figure is reported once, beside the range total, so a narrow window showing nothing is not mistaken for a service that has never blocked anything. Widening the window widens the **bars**, not their number: twelve hours at one-minute resolution is seven hundred bars in a few hundred pixels, which is a texture rather than a chart. Blocks are stored once a minute and everything else is a sum of those, so no range costs more than another. That is the **Auto** step, and you can override it. Pick a fixed one โ€” a minute, five, an hour โ€” and it stays that whatever range you look at, which is what makes two windows comparable by eye: five minutes of a quiet hour against five minutes of a busy one. A step too fine for the range is widened rather than refused, and the header always names the step actually drawn. History is kept for **48 hours**. Asking for more is clamped, and the panel says how far back it actually goes. **No range starts before the service did.** A window is also floored at the first moment the service could have refused anything โ€” running, with a filter attached โ€” so asking for 24 hours of a service that has been up for ten minutes gives you ten minutes, not twenty-three hours of blank followed by a smudge. Those hours are not quiet hours; they are hours in which the question was not being asked, and drawing them flat says the opposite of what is true. The header states the range it actually drew, and why. The mark is set once and never moved. A service stopped for an hour and started again has a real gap in the middle of its chart, which is worth seeing. A window that ended *before* the service began is left exactly as you asked for it, and comes back empty โ€” which is the true answer, and a more useful one than being quietly moved to now. ### Choosing a shape The same numbers, four ways, because they answer different questions: | | Good for | |---|---| | **Bars** | how much was refused in each step, stacked by filter | | **Lines** | comparing filters against each other over time | | **Area** | seeing where a burst begins and ends as a shape | | **Share** | what proportion each filter contributed, step by step | Hovering anywhere over the plot marks the step under the cursor and reads it out: the time it covers, what each filter refused in it, and the total. Reading a stacked bar by eye tells you something was refused; it does not tell you which of two similar colours was the larger, and it never tells you a number. ### How much of the traffic Reported in the unit each layer can honestly produce, and never as a ratio between two different units: - **Packets and bytes** that reached the service, counted by the **kernel** on the rules that intercept it. Free, and nothing in userspace can skew it โ€” the count comes from netfilter, not from a filter reporting on itself. Reported on the layers whose rules the kernel walks for every packet: NFQUEUE and the hand-off. - **Connections seen and refused**, from the proxy engine. That layer works in connections, which is the same unit a block is in, so the share of refused traffic there is exact. Each layer reports what it can actually measure. NFQUEUE has no connection to count โ€” it works per packet, which is the whole reason it is cheap โ€” so it leaves the connection share empty rather than dividing refused connections by a packet count and calling the result a percentage. The proxy layer has no honest packet count: its rule lives in a `nat` chain, and conntrack translates a connection once, so every packet after the first is handled without the rule being walked again. That counter is new connections wearing a packet label, so it is not shown at all. The asymmetry is not a gap in the reporting; it is the difference between the two layers showing through in what each of them can honestly say. Blocks are written out at most every few seconds and the history is pruned as it is written. Both are deliberate: a service refusing thousands of connections a minute must not be able to take the backend down through its own bookkeeping, which is exactly what one row per block would do. ## The live log Every service page carries a rolling log of what that service is actually doing, pushed as it happens rather than polled: - **blocks**, naming what refused the connection โ€” `connection refused by patterns: /BLOCKME/`, or `refused by http: refuse_traversal()` for a Python filter, down to the function rather than the file; not an opaque id you would have to go and look up mid-round; - **whatever your Python prints**, so `print()` while debugging a filter works the way you expect; - **what the datapath says about its own health** โ€” a filter that lost its say after missing its deadline, a fallback, a ruleset that was refused. These used to go to the backend's stderr, where nobody looks during a competition. It is bounded at both ends. A service under attack refuses thousands of connections a minute; the tail keeps the last few hundred lines and the rest fall off, and lines are coalesced before being sent so a burst arrives as one message instead of thousands. Nothing about logging can turn into an outage. ## Trying a ruleset without an instance The tester above needs a running firegex. The `firegex` pip package carries the same matching, so a ruleset can be tried anywhere โ€” on the laptop you are writing it on, before the competition starts: ```bash pip install -U firegex fgex regex check rules.json # does it compile? what is in it? fgex regex test rules.json --sample req.txt # what would it do to this? fgex regex proxy rules.json 10.60.3.1 8080 # a local proxy applying it ``` The ruleset file is **the same shape firegex uses**, so one file works in both places: ```json [ {"id": "traversal", "pattern": "\\.\\./", "direction": "c2s"}, {"id": "flag-leak", "pattern": "FLAG\\{[a-z0-9]+\\}", "direction": "s2c", "case_sensitive": false} ] ``` It binds the same hyperscan rather than approximating it with Python's `re`, so a pattern it accepts is one firegex accepts, and one it rejects reports hyperscan's own message. Where hyperscan is not installed it says so and stops, rather than quietly answering with a different engine. One honest limit. `fgex regex proxy` matches **per chunk**, while a rule on a real service matches across the whole stream โ€” so a pattern split over two reads is caught in production and not in the simulator, never the other way round. A ruleset that looks clean here may still block something on a live service; one that blocks here always would. ## Editing while it runs Adding, removing, reordering, enabling and disabling filters, patterns and individual `@pyfilter` functions all take effect on live connections **without dropping any of them**. Both layers swap their configuration underneath the traffic. Adding an address does not drop anything either, and removing or moving one costs only the connections on that address. Changing the service's own definition โ€” its protocol, network layer, or TLS โ€” is different: that changes what the datapath *is*, so the service is stopped and started around it, and the connections it was carrying are dropped. The UI says so before you do it. ## See also - [Firewall rules](firewall.md) โ€” plain nftables rules, unrelated to services. - [Writing a Python filter](pyfilter.md) โ€” the `@pyfilter` API and its models.