HAProxy
Two people sit outside the room. One places every outgoing call and checks who answers; the other stands at the door and turns away visitors who are not on the list.
They are the same person hired twice, for two different jobs.
What it is
HAProxy is a proxy for streams. Phobos runs it in transparent mode, so it forwards bytes rather than parsing an application protocol, and it uses two of its features: inspecting the start of a connection before deciding, and resolving a name through a nominated resolver.
Both instances are trusted infrastructure, started before the command and outside the
sandbox. Both run in the foreground with -db, so they stay children of the layer that started
them and inside the run's process group, and both write their own output to a scratch log
rather than to the command's streams. Both are direct children of the network layer, which runs
the rest of the chain as a child and waits. It keeps each process identifier in a shell variable
and stops both in its EXIT trap: TERM, a bounded wait of about two seconds, then KILL.
Role one: the egress broker
The connect guard decides by address and port. It cannot see a Transport Layer Security (TLS) ClientHello, so a rule that names a host constrains nothing about the onward address on its own. The broker is what makes such a rule mean something.
The guard hands every allowed connection to the broker on loopback, with a PROXY protocol version 2 header naming the destination the command meant. The broker then decides the host:
| The rule names | What the broker does |
|---|---|
| an exact name | for a connection to the port the rule names, resolves the name itself, through the phobosdns resolver, sets the destination to that address, and rejects the connection where the name does not resolve |
| an address or a range | accepts as soon as the destination matches, on a port no name rule names; on a port a name rule names, a connection that sends nothing first waits out the five-second inspection delay |
* | accepts everything and forwards to the header's destination |
The exact-name case is the strong one, and it is the reason the network layer refuses such a
rule without --resolver: the broker does not trust the header at all there, so a command that
presents an allowed name while aiming at another address is still sent only to where the name
resolves. A wildcard name has no row in the table on purpose, because it cannot resolve to one
address. classify_connect_host answers invalid for it, and the config generator refuses it
before it writes anything.
Each exact name is mapped to a loopback placeholder in /etc/hosts first, so the command's own
name resolution succeeds without a query the guard would refuse. The broker does not consult
that file: it asks the resolver.
The broker keeps the destination port it was given. It therefore applies a name to a connection
only when the destination port is the one the rule names. The generator writes one pair of
access control lists per port, a name list and dst_port, and routes only a connection that
matches both. The guard lets a connection through on any port some rule names. Without the
pairing, a connection allowed on one port under an address rule, with an allowed name in its
ClientHello, carries that port to the name's address. A loopback rule with no port opens that
route on every port.
Each pair tests the port before the name, and the order is load-bearing. HAProxy stops at the first term that is false, and reading the name of a connection that has sent nothing waits for its first bytes until the five-second inspection delay ends. With the port first, the broker decides a connection to any other port at once and hands it to the address rules. A server that speaks first, as SMTP, MySQL or SSH do, therefore gets its banner to the client without delay. On a port a name rule names, the broker cannot tell a TLS client that has not written yet from a server that speaks first. A connection there still waits out the delay before an address rule accepts it.
Role two: the inbound filter
An [accept] rule produces a second instance with one frontend per public port:
frontend in_18080
bind :::18080 v4v6
tcp-request connection reject if !{ src -f <sources for 18080> }
default_backend be_18080
backend be_18080
server s 127.0.0.1:8080
The source list is a file per port, written beside the configuration. A public port with no source gets an empty file, so the frontend rejects every peer: the fail-closed direction.
The filter binds the public port itself, which is why a public port the command may bind too
is refused by the policy check. It forwards to the backend port on loopback, which is why
a backend port [bind] does not name is refused as well.
What neither role is
The broker is not a boundary on its own. It rests on the connect guard having vetted the destination and redirected the connection; a command that could reach the network without going through the guard would not meet the broker at all.
The inbound filter is defence in depth. It sees only the connections that arrive at it, and that the backend is reachable only through it comes from the connect guard, which pins every listener to the loopback device, rather than from HAProxy.
Further reading
- HAProxy documentation: the configuration manual
- The PROXY protocol: the header the guard sends
haproxy/haproxy: the source repository- RFC 6066: Server Name Indication, the field the broker reads