mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 16:31:00 +00:00
635 lines
35 KiB
Markdown
635 lines
35 KiB
Markdown
# 🛡️ 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 <iface>` (in prerouting) and `meta oifname <iface>` (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 <iface>`) 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 <iface>`). 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.
|