firegex/backend/proxysrc/src/proxy.rs
Domingo Dirutigliano a3d42f66d2
Update Rust dependencies and clear clippy warnings
Every crate to its latest compatible release, rcgen 0.13 -> 0.14 for
the tests (only the features they use). Clippy: async fn in trait
impls, try_update over the deprecated fetch_update, no-op casts, a
duplicated attribute, Database::is_empty.
2026-10-16 20:44:13 +02:00

1199 lines
54 KiB
Rust

//! The datapath: accept, dial the real service, relay both halves through the chain.
use std::io;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::filter::{
next_connection_id, ChainHandle, ChainSessions, ConnectionId, ConnectionMeta, Direction,
FilterChain, Verdict, L4,
};
use crate::capture::{Capture, Tap};
use crate::tls;
use crate::transparent::{
bind_listener, connect_as, connect_plain, original_destination, unmap,
};
/// Either half of a connection, whether or not TLS is in the way.
///
/// Boxed only when TLS is on: a plain connection keeps the concrete types the
/// benchmark was run against, and a TLS one is paying for cryptography anyway, so a
/// virtual call per read is not what it will notice.
type Duplex = Box<dyn AsyncReadWrite + Unpin + Send>;
pub trait AsyncReadWrite: AsyncRead + AsyncWrite {}
impl<T: AsyncRead + AsyncWrite> AsyncReadWrite for T {}
/// What the engine does about TLS for one service.
#[derive(Clone, Default)]
pub struct TlsSetup {
/// Terminate the client's TLS here, with this certificate.
pub server: Option<std::sync::Arc<rustls::ServerConfig>>,
/// Re-encrypt towards the service, the way nginx's `proxy_ssl on` does.
pub upstream: Option<std::sync::Arc<rustls::ClientConfig>>,
/// Terminate it **only if the client actually starts a handshake**.
///
/// What an `http` service is: one chain in front of a daemon that answers in the
/// clear on one port and under TLS on another, with one certificate and one set of
/// filters. The alternative was one service per edge with the chain copied between
/// them by hand, which is the failure several addresses under one service already
/// exist to prevent.
///
/// It is a per-connection question because that is the only place it has an answer,
/// and answering it from what the client sent is the same rule the ALPN mirroring
/// follows: firegex carries what the two ends are doing rather than deciding it for
/// them. The upstream leg mirrors it too — a connection that arrived in the clear is
/// forwarded in the clear, one that arrived under TLS is re-encrypted — because the
/// service is being dialled on the port the client chose, and that port's edge is the
/// one the client just demonstrated.
pub optional: bool,
}
impl TlsSetup {
pub fn is_off(&self) -> bool {
self.server.is_none() && self.upstream.is_none()
}
}
/// What a connection turned out to be, from the bytes the client opened it with.
///
/// **Peeked, never read.** Everything stays in the socket, so a connection this tells
/// apart is handed on exactly as it arrived — there is nothing buffered to replay, and no
/// way for the question to lose a byte.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Opening {
/// A TLS record: `0x16` handshake, major version 3. Nothing else begins that way.
Tls,
/// The HTTP/2 connection preface, which is what "prior knowledge" looks like.
Http2,
/// Anything else, including a service that spoke before its client did.
Other,
}
const RELAY_BUF: usize = 64 * 1024;
/// Backstop for a direction that will not wind down — one blocked writing to a peer
/// that has stopped reading, say. The normal path is the stop signal below, which is
/// prompt.
const REJECT_GRACE: Duration = Duration::from_secs(2);
/// What is spoken at one published address, and where it goes.
///
/// Firegex is transparent by default and was transparent only, for as long as there was
/// one answer to "where is the service": the address the client dialled. The engine
/// recovers that from conntrack and dials it as the client, so the service goes on
/// seeing connections to the port it listens on from the addresses they came from. An
/// address with no entry here behaves exactly that way, which is why the empty map is
/// the default and why every field below is optional.
///
/// An entry is the operator saying something about one address in particular:
///
/// * **where the service is**, when it is not on this port — one daemon answering on
/// `:80` reached over TLS on `:443`, without being moved or reconfigured;
/// * **what is spoken here**, when the service is reached in more than one way at once.
/// Only `http` services have that, and only there is the question asked.
///
/// Only the TCP path needs any of it. A UDP or QUIC address already has a relay of its
/// own, bound to the service it fronts and terminating what it was built to terminate.
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub enum Edge {
/// Whatever the client turns out to be speaking. The default and the ordinary case:
/// one listener fronts a whole service, a cleartext port and an encrypted one are
/// the same listener, and which a connection is cannot be known before it starts.
///
/// There is deliberately no "cleartext only" beside this. Refusing a client that
/// brings TLS to a port nobody promised would be a rule with nothing behind it, and
/// the permissive answer is what an `http` service has always given.
#[default]
Whatever,
/// This address is the encrypted one, and was said to be. A client that opens it in
/// the clear is refused rather than carried, because "this port is HTTPS" is a
/// promise about the port rather than a guess about the connection — and a service
/// expecting HTTPS being handed a cleartext request is the kind of surprise an
/// operator would rather have at the door.
Tls,
}
#[derive(Clone, Copy, Default)]
pub struct Published {
pub target: Option<SocketAddr>,
pub edge: Edge,
/// What the service behind *this* address speaks. Per address rather than per
/// process 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, and what firegex does on the way out is a property
/// of the way in.
pub upstream: Onward,
}
/// What this engine speaks onward, to the service behind one address.
///
/// Not to be confused with [`Upstream`] beside it, which is *where* to forward. This is
/// what is spoken when it gets there.
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash, Debug)]
pub enum Onward {
/// Whatever arrived: a connection terminated here goes back out encrypted, one that
/// arrived in the clear is forwarded in the clear. The default, and what every
/// address did before there was a choice.
#[default]
Same,
/// The service speaks in the clear, whatever the client used to get here.
Plain,
/// The service speaks TLS, whatever the client used to get here.
Tls,
}
impl Onward {
/// The word the backend uses for it, on `FGEX_PROXY_UDP`, `ADD_UDP` and `PUBLISH`, and
/// the one this engine answers with on a `UDP` line.
pub fn word(self) -> &'static str {
match self {
Onward::Same => "same",
Onward::Plain => "plain",
Onward::Tls => "tls",
}
}
/// The other way round. `None` for a word nobody defined, so each caller can decide
/// whether that is fatal (at startup) or a refused command (on the control channel).
pub fn from_word(word: &str) -> Option<Onward> {
match word {
"same" => Some(Onward::Same),
"plain" => Some(Onward::Plain),
"tls" => Some(Onward::Tls),
_ => None,
}
}
}
#[derive(Default)]
pub struct Targets(std::sync::RwLock<std::collections::HashMap<SocketAddr, Published>>);
impl Targets {
/// Say something about one address. Replaces any previous answer for it.
pub fn publish(&self, public: SocketAddr, what: Published) {
if let Ok(mut map) = self.0.write() {
map.insert(public, what);
}
}
/// Forget one published address. A stale entry could only ever be consulted by
/// traffic the rules no longer send here, but an engine whose map disagrees with the
/// rules is a thing somebody will one day read and believe.
pub fn withdraw(&self, public: &SocketAddr) {
if let Ok(mut map) = self.0.write() {
map.remove(public);
}
}
/// What was said about the address this connection was dialled at, if anything.
pub fn resolve(&self, original: SocketAddr) -> Published {
self.0
.read()
.ok()
.and_then(|map| map.get(&original).copied())
.unwrap_or_default()
}
}
/// Where a connection should be forwarded.
#[derive(Clone, Copy, Debug)]
pub enum Upstream {
/// One service behind one listener.
Fixed(SocketAddr),
/// Wherever the client was originally headed — one listener in front of many
/// services, which is the point of a central proxy.
Original,
}
pub struct ProxyConfig {
pub listen: SocketAddr,
pub upstream: Upstream,
/// Dial the service as the client, so it sees the real source address.
///
/// Not a setting the operator sees: the proxy is supposed to be invisible, and a
/// service that suddenly sees one address for every client is the opposite of
/// that. It is here only so the fallback below has something to turn off.
pub spoof_source: bool,
/// Cap on the upstream handshake. A spoofed connect whose return path is not
/// diverted does not fail, it hangs — this turns that into a visible fallback.
pub connect_timeout: Duration,
/// TLS on either edge. Rules always see plaintext.
pub tls: TlsSetup,
/// Where the decrypted stream is written out, when TLS is terminated here and the
/// capture interface exists. `None` is the ordinary case, not a failure.
pub capture: Option<std::sync::Arc<Capture>>,
/// How many connections may be in flight at once. `0` means no limit.
///
/// There was none, and the cost of that was measured rather than argued: roughly 505
/// connections that are opened and then say nothing take a service down, because each
/// one holds two descriptors — one from the client, one to the service, since the
/// upstream is dialled on accept — against a 1024 limit. No data has to be sent.
pub max_connections: usize,
/// What happens to a connection that arrives while the limit is reached.
///
/// Refusing keeps the promise that everything reaching the service was inspected,
/// and costs reachability under attack. Forwarding keeps the service reachable and
/// admits traffic nothing looked at. Neither is right in general, which is why it is
/// the operator's to choose; refusing is the default because a filter that quietly
/// stops filtering is the worse surprise.
pub over_limit_forwards: bool,
/// Where published addresses send their traffic, when it is not where they were
/// dialled. Empty is the transparent case and the default.
pub targets: Arc<Targets>,
/// How long a connection may carry no bytes at all before it is closed. `None` is
/// off, which is what it was before.
///
/// Until the **first** byte moves, and never afterwards: this is not an idle
/// timeout. A connection that has said something and then goes quiet is a session,
/// and sessions are allowed to think. One that has said nothing in either direction
/// since it was accepted is the shape of the attack the limit only contains — it
/// holds a descriptor here and one on the service, having asked for nothing.
///
/// Either direction counts, which is what makes it safe for the protocols where the
/// *server* speaks first. A banner from SMTP or SSH satisfies the deadline exactly as
/// a request would; requiring the client to speak would have hung every one of them.
pub first_byte_timeout: Option<Duration>,
/// Stamped on the connections the engine opens, so the intercept rules can skip
/// them. `None` only where nothing could loop back.
pub self_mark: Option<u32>,
}
impl ProxyConfig {
pub fn fixed(listen: SocketAddr, upstream: SocketAddr) -> Self {
Self {
listen,
upstream: Upstream::Fixed(upstream),
spoof_source: false,
connect_timeout: Duration::from_secs(5),
tls: TlsSetup::default(),
capture: None,
max_connections: 0,
over_limit_forwards: false,
first_byte_timeout: None,
self_mark: None,
targets: Arc::new(Targets::default()),
}
}
}
#[derive(Debug, Default)]
pub struct ProxyStats {
pub accepted: AtomicU64,
pub upstream_failures: AtomicU64,
pub closed_by_filter: AtomicU64,
/// Connections that reached the service under the proxy's own address because
/// the transparent dial did not work. Traffic flows, identity is lost.
pub source_spoof_failures: AtomicU64,
/// Connections dropped because their original destination could not be recovered.
pub origin_lookup_failures: AtomicU64,
/// How many connections are being carried right now. The number the limit is about.
pub live: AtomicU64,
/// How many were closed for never saying anything. Cumulative, like the rest.
pub no_first_byte: AtomicU64,
/// How many arrived while the limit was reached — refused or forwarded unfiltered,
/// whichever the operator chose. Cumulative, so a service that hit the wall once an
/// hour ago still says so.
pub over_limit: AtomicU64,
pub warned_spoof: AtomicBool,
/// Whether the limit has already been announced. Shared by both paths, so a service
/// carrying TCP and UDP says it once rather than twice.
pub warned_limit: AtomicBool,
}
/// Holds a slot in the connection count for as long as the connection lives.
///
/// A guard rather than a decrement at the end of `handle_connection`: that function has
/// a dozen ways out — a refused chain, a dial that fails, a handshake that times out, a
/// panic — and a counter that leaks on any one of them is a limit that tightens until it
/// refuses everything.
pub(crate) struct Slot(pub(crate) Arc<ProxyStats>);
impl Drop for Slot {
fn drop(&mut self) {
self.0.live.fetch_sub(1, Ordering::Relaxed);
}
}
/// How long to wait after an accept that failed before trying again.
const ACCEPT_BACKOFF: Duration = Duration::from_millis(20);
pub struct Proxy {
listener: TcpListener,
cfg: Arc<ProxyConfig>,
chain: ChainHandle,
pub stats: Arc<ProxyStats>,
}
impl Proxy {
pub async fn bind(cfg: ProxyConfig, chain: ChainHandle) -> io::Result<Self> {
let listener = bind_listener(cfg.listen)?;
Ok(Self {
listener,
cfg: Arc::new(cfg),
chain,
stats: Arc::new(ProxyStats::default()),
})
}
/// The address actually bound, so a caller passing port 0 can find it.
pub fn local_addr(&self) -> io::Result<SocketAddr> {
self.listener.local_addr()
}
pub fn stats(&self) -> Arc<ProxyStats> {
Arc::clone(&self.stats)
}
/// Accept forever.
///
/// Nothing a single connection does may end this loop: each one is handled in
/// its own task, so even an outright panic in there costs that connection and
/// nothing else. The listener outliving its connections is the whole point.
pub async fn serve(self) -> io::Result<()> {
loop {
let (client, peer) = match self.listener.accept().await {
// Unmapped here and nowhere else: past this line the rest of the engine
// only ever sees the address the client really had, whether it arrived
// on a v4 listener or on the v4 half of a dual-stack one.
Ok((client, peer)) => (client, unmap(peer)),
// Per-connection accept errors (fd limits, a client gone between
// SYN and accept) must not be fatal.
Err(e) => {
crate::diag_throttled!("[warn] [proxy] failed accepts", 5, 10, "[warn] [proxy] accept failed: {e}");
// Out of descriptors, the condition does not clear by trying again
// immediately: the loop spins on `accept` → `EMFILE` → print →
// `accept`, which was measured burning about 40% of a core with four
// clients knocking, and writing a log line per attempt into the pipe
// the backend reads. A pause costs a few milliseconds of latency when
// accepting was going to fail anyway.
tokio::time::sleep(ACCEPT_BACKOFF).await;
continue;
}
};
self.stats.accepted.fetch_add(1, Ordering::Relaxed);
// Claimed before the connection is spawned and released when it ends,
// whichever way it ends.
let live = self.stats.live.fetch_add(1, Ordering::Relaxed) + 1;
let slot = Slot(Arc::clone(&self.stats));
let limit = self.cfg.max_connections;
let over = limit > 0 && live > limit as u64;
if over {
self.stats.over_limit.fetch_add(1, Ordering::Relaxed);
// Once, not once per connection: the whole point of being at the limit
// is that connections are arriving faster than they leave, and a line
// each would be the flood arriving twice.
if !self.stats.warned_limit.swap(true, Ordering::Relaxed) {
crate::diag!(
"[warn] [proxy] {limit} concurrent connections reached; further \
connections are being {} until it clears",
if self.cfg.over_limit_forwards { "forwarded unfiltered" } else { "refused" },
);
}
if !self.cfg.over_limit_forwards {
// Dropped here, which closes it: refusing costs the client a
// connection and costs the service nothing.
drop(client);
drop(slot);
continue;
}
} else if live * 2 <= limit as u64 {
// Armed again once there is real room, so the *next* time the wall is
// hit is reported too. Halfway rather than at the limit, or a service
// sitting exactly at it would log on every connection.
self.stats.warned_limit.store(false, Ordering::Relaxed);
}
let cfg = Arc::clone(&self.cfg);
let chain = self.chain.clone();
let stats = Arc::clone(&self.stats);
tokio::spawn(async move {
let _slot = slot;
// Over the limit and told to forward: the connection is relayed with no
// chain at all. That is the operator's choice made literal — the traffic
// reaches the service, and nothing claims to have looked at it.
let result = if over {
handle_connection(client, peer, cfg, ChainHandle::new(FilterChain::empty()), stats).await
} else {
handle_connection(client, peer, cfg, chain, stats).await
};
if let Err(e) = result {
crate::diag_throttled!("[info] [proxy] connections that ended with an error", 20, 10, "[info] [proxy] connection from {peer} ended: {e}");
}
});
}
}
}
async fn dial(
peer: SocketAddr,
upstream: SocketAddr,
cfg: &ProxyConfig,
stats: &ProxyStats,
) -> io::Result<TcpStream> {
if !cfg.spoof_source {
return tokio::time::timeout(cfg.connect_timeout, connect_plain(upstream, cfg.self_mark))
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "upstream connect timed out"))?;
}
match tokio::time::timeout(
cfg.connect_timeout,
connect_as(peer.ip(), upstream, cfg.self_mark),
)
.await
{
Ok(Ok(stream)) => Ok(stream),
// The service answered, and the answer was no: nothing is listening there. That
// says nothing about impersonating the client, so it is not a reason to try again
// as ourselves — the second attempt would be refused the same way, and the warning
// below would blame source preservation for a service that is simply down.
Ok(Err(e)) if e.kind() == io::ErrorKind::ConnectionRefused => Err(e),
// Losing the client's address is bad; losing the connection is worse. Fall
// back, but make the degradation loud — a service silently seeing one source
// address for everyone is exactly the kind of thing nobody notices in time.
failed => {
stats.source_spoof_failures.fetch_add(1, Ordering::Relaxed);
if !stats.warned_spoof.swap(true, Ordering::Relaxed) {
let why = match failed {
Ok(Err(e)) => e.to_string(),
_ => "timed out (is the return path diverted?)".to_string(),
};
crate::diag!(
"[warn] [proxy] cannot reach {upstream} as {}: {why}. \
Falling back to our own address — the service will not see real client IPs.",
peer.ip()
);
}
tokio::time::timeout(cfg.connect_timeout, connect_plain(upstream, cfg.self_mark))
.await
.map_err(|_| {
io::Error::new(io::ErrorKind::TimedOut, "upstream connect timed out")
})?
}
}
}
/// Whether a connection was dialled straight at this engine instead of being steered here.
///
/// Every connection the engine should carry arrives through a `redirect`, so conntrack
/// remembers a destination that is somebody else's: the service's own address and port.
/// A client that dials the listener itself — its port is an ephemeral one on every
/// address of the host, and a port scan finds it — leaves conntrack nothing to translate,
/// and `SO_ORIGINAL_DST` then answers with the very address the connection was accepted
/// on. Forwarding there is dialling ourselves, and the new connection asks the same
/// question and gets the same answer: measured, one such client grew into as many
/// connections as the limit allowed within two seconds, and with no limit (the default) it
/// grows until the process runs out of descriptors and the service stops answering anyone.
///
/// The configured listen address could not catch it, because that is the wildcard the
/// listener was bound to (`[::]:0`) and never the address a connection is accepted on.
fn dialled_at_ourselves(destination: SocketAddr, accepted_on: SocketAddr, listen: SocketAddr) -> bool {
destination == accepted_on || destination == listen
}
async fn handle_connection(
client: TcpStream,
peer: SocketAddr,
cfg: Arc<ProxyConfig>,
chain: ChainHandle,
stats: Arc<ProxyStats>,
) -> io::Result<()> {
let original = match cfg.upstream {
Upstream::Fixed(addr) => addr,
Upstream::Original => match original_destination(&client) {
Ok(addr) => addr,
Err(e) => {
stats.origin_lookup_failures.fetch_add(1, Ordering::Relaxed);
return Err(e);
}
},
};
// What the operator said about the address this was dialled at: nothing, for every
// address that is simply the service, which is the transparent case and the default.
let published = cfg.targets.resolve(original);
let upstream = published.target.unwrap_or(original);
// Something that would have this engine dial itself: a client connecting straight to
// the listener, or a rule or a publication that points back at it. Either would spin
// for as long as there are descriptors to spend — see `dialled_at_ourselves`.
let accepted_on = unmap(client.local_addr()?);
if dialled_at_ourselves(original, accepted_on, cfg.listen)
|| dialled_at_ourselves(upstream, accepted_on, cfg.listen)
{
stats.origin_lookup_failures.fetch_add(1, Ordering::Relaxed);
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"refusing to forward {upstream} to ourselves: this connection was dialled \
at the engine's own port rather than steered here by a rule"
),
));
}
let server = match dial(peer, upstream, &cfg, &stats).await {
Ok(s) => s,
Err(e) => {
stats.upstream_failures.fetch_add(1, Ordering::Relaxed);
return Err(e);
}
};
let _ = client.set_nodelay(true);
let _ = server.set_nodelay(true);
// What the client opened with, asked only where the answer decides something.
//
// On a listener that terminates TLS **optionally** — an `http` service — it always
// does, whatever the chain: whether this connection is decrypted is what makes an
// address declared as the encrypted one keep its promise, and what lets a service
// that answers in the clear be reached over TLS at all (`Onward::Plain`). This was
// skipped for a chain with nothing to say, on the reasoning that a bypassed service
// is a byte pump either way — and every connection to a TLS address of such a service
// was then refused as "not TLS", which is what a brand new HTTPS service with no
// filter yet did, and what one did the moment its filters were switched off or lost
// their say. The opposite of failing open.
//
// On a plain listener the only question is HTTP/2 in the clear, which matters only to
// a chain that renders it — so there, and only there, a bypassed chain skips the peek.
// A listener that always terminates TLS has no question to ask.
let opening = if cfg.tls.optional
|| (cfg.tls.is_off() && !chain.current().is_bypassed())
{
sniff(&client, &server).await
} else {
Opening::Other
};
// An address declared to be the encrypted one keeps that promise: a client opening
// it in the clear is refused rather than carried to a service expecting HTTPS. Every
// other address is `Whatever`, where the sniff is the only answer there can be —
// one listener fronts both a cleartext port and an encrypted one, and which a
// connection is cannot be known before it starts.
if published.edge == Edge::Tls && opening != Opening::Tls && !cfg.tls.is_off() {
stats.closed_by_filter.fetch_add(1, Ordering::Relaxed);
// Said out loud, and at `warn`, because the operator on the other end of this has
// no way to guess it. What a cleartext client gets back is either nothing or a
// TLS alert — seven bytes a browser offers to save as a file — and neither says
// which of the two ends is wrong. It is nearly always the address: a port carrying
// the cleartext site declared as the encrypted one. Naming the address and what
// was expected turns an afternoon into a sentence.
crate::diag_throttled!("[warn] [proxy] clients that spoke something other than TLS at an encrypted address", 1, 30,
"[warn] [proxy] {peer} spoke something that is not TLS to {original}, which \
this service declares as its encrypted address. The connection is refused \
rather than carried to a service expecting HTTPS. If that address is meant \
to carry the site in the clear, it is the one to change."
);
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{original} is published as TLS and this connection did not start one"),
));
}
if cfg.tls.is_off() || (cfg.tls.optional && opening != Opening::Tls) {
// HTTP/2 in the clear, which a client announces by opening with a fixed 24-byte
// preface. It is worth catching for the same reason the TLS path catches `h2`:
// without it a gRPC service that speaks plaintext — which is most of them behind
// a load balancer — is carried as HPACK nobody can read. Not for a chain with
// nothing to say, which is a byte pump whatever the client speaks: the peek is
// only taken on its behalf here because an optional TLS listener needed it anyway.
if opening == Opening::Http2 && !chain.current().is_bypassed() {
return crate::h2::carry(
client,
server,
crate::h2::Carriage {
client: peer,
upstream,
chain: chain.clone(),
stats: Arc::clone(&stats),
// Unlike every other plaintext connection, this one *is* worth
// reconstructing: what is on the wire is a compression format, so the
// capture interface is the only place the exchange can be read as the
// filters saw it.
capture: cfg.capture.clone(),
first_byte_timeout: cfg.first_byte_timeout,
},
)
.await;
}
// Only where there is something to reconstruct: with no TLS and no HTTP/2, the
// bytes on the wire *are* the plaintext, and anybody wanting them can capture the
// interface they are already crossing rather than a copy this process invents.
let (client_rd, client_wr) = tokio::io::split(client);
let (server_rd, server_wr) = tokio::io::split(server);
return relay(
client_rd, client_wr, server_rd, server_wr, peer, upstream, &cfg, &chain, &stats, None,
)
.await;
}
// Handshakes before anything is relayed, and all of them on the deadline: a peer
// that opens a connection and then says nothing must not tie up a task.
//
// The order is the interesting part. The client's handshake is *started* and
// then held open at the ClientHello, because that message carries the protocols
// the client is willing to speak and this proxy has no business deciding them.
// The upstream handshake goes next, offering the service exactly that list; what
// the service picks is what the client is then told. Terminating in the middle
// of a connection means answering for a service, and answering something the
// service did not say is how a proxy breaks a protocol it was only supposed to
// carry — a client told `h2` while the service speaks HTTP/1.1 sends frames to
// something that cannot read them.
//
// Terminating HTTP/2 below changes nothing about that: the client is still told
// exactly what the service chose. What changes is what firegex then *does* with a
// connection that agreed on `h2` — it speaks it, on both sides, instead of
// forwarding frames no filter can read.
let (started, wanted, sni) = match &cfg.tls.server {
Some(_) => {
let start = tokio::time::timeout(
cfg.connect_timeout,
tokio_rustls::LazyConfigAcceptor::new(rustls::server::Acceptor::default(), client),
)
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "TLS handshake timed out"))??;
let hello = start.client_hello();
let wanted: Vec<Vec<u8>> = hello
.alpn()
.map(|it| it.map(|p| p.to_vec()).collect())
.unwrap_or_default();
// The name the client asked for, carried on for the same reason the protocol
// list is: a service choosing its certificate or its virtual host by SNI would
// otherwise be handed this engine's idea of a name — the bare address, which
// TLS does not even send — and answer as its default host.
let sni = hello.server_name().map(str::to_owned);
(Some(start), wanted, sni)
}
None => (None, Vec::new(), None),
};
// Per address: the configuration is built once, and whether this connection uses it
// is the address's answer. `Same` is the old rule — re-encrypt what was terminated —
// and is why a plain TCP service, which terminates nothing, is untouched by any of
// this and always was.
let upstream_tls = match published.upstream {
Onward::Plain => None,
// The two coincide here and that is not an oversight: this point is only
// reached for a connection whose TLS *was* terminated, so "what arrived" and
// "TLS" are the same answer. They differ on the QUIC edge, where there is no
// arriving TLS to mirror and the choice decides the whole upstream leg.
Onward::Tls | Onward::Same => cfg.tls.upstream.as_ref(),
};
let (server, agreed): (Duplex, Option<Vec<u8>>) = match upstream_tls {
Some(config) => {
let name = match sni.as_deref().map(tls::server_name) {
Some(Ok(name)) => name,
_ => tls::server_name(&upstream.ip().to_string())?,
};
let connected = tokio::time::timeout(
cfg.connect_timeout,
tls::connector(tls::with_alpn(config, &wanted)).connect(name, server),
)
.await
.map_err(|_| {
io::Error::new(io::ErrorKind::TimedOut, "upstream TLS handshake timed out")
})??;
let agreed = connected.get_ref().1.alpn_protocol().map(|p| p.to_vec());
(Box::new(connected), agreed)
}
// Nothing upstream to *ask*, which is two situations wearing one shape. Where
// the connection was never terminated there is nothing to decide and `wanted` is
// empty anyway. Where it was terminated and the address says the service speaks
// HTTP/1.1 whatever arrives (`Onward::Plain`), firegex is not carrying somebody
// else's protocol — it **is** the far end — so it answers for itself, and saying
// `http/1.1` is the whole of what it can honestly promise today.
//
// **`h2` is promised only while the chain will render it.** A bypassed chain is a
// byte pump, and a byte pump between an HTTP/2 client and an HTTP/1.1 service
// carries frames nothing at the far end can read — the exact breakage the
// mirroring rule exists to prevent, arriving from the other direction. The trade
// is the one the bypass already documents: a connection admitted while the chain
// was empty keeps the protocol it was admitted with.
//
// This is the other half of an asymmetry that stood for a while and had no reason
// behind it: the same cleartext HTTP/1.1 service was reachable over HTTP/3,
// because the QUIC edge picks from its own candidate list and speaks HTTP/1.1
// onwards through `h1up`, and not reachable over HTTP/2 at all, because this edge
// had nobody to copy and therefore said nothing. One rendering underneath,
// opposite answers.
None => {
let picked = if matches!(published.upstream, Onward::Plain) {
let renders = !chain.current().is_bypassed();
let offers = |name: &[u8]| wanted.iter().any(|p| p.as_slice() == name);
if renders && offers(b"h2") {
Some(b"h2".to_vec())
} else if offers(b"http/1.1") {
Some(b"http/1.1".to_vec())
} else {
None
}
} else {
None
};
(Box::new(server), picked)
}
};
let client: Duplex = match (started, &cfg.tls.server) {
(Some(start), Some(config)) => {
let accepted = tokio::time::timeout(
cfg.connect_timeout,
start.into_stream(tls::answering_with(config, agreed.as_deref())),
)
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "TLS handshake timed out"))??;
Box::new(accepted)
}
_ => unreachable!("a started handshake means a server config"),
};
// Both ends agreed to speak HTTP/2, so the engine speaks it: terminated here, each
// exchange rendered to the chain as the HTTP/1.1 it would have been, and re-encoded
// on the way to the service. Without this the connection was forwarded as frames,
// and a pattern written against a request line matched nothing while an
// `HttpRequest` filter was never called — switched on, reporting nothing, running
// never.
//
// Not when the chain is bypassed. Rendering for a chain that has nothing to say is
// work paid for no answer, and this is the path a service with no filters and the
// `over_limit_forwards` fallback both take. The cost is that a connection carried
// while the chain was empty stays a byte pump for its whole life, even if a filter
// is pushed a moment later — the same trade the fallback itself makes, and the
// reason it is written down here.
if agreed.as_deref() == Some(b"h2") && !chain.current().is_bypassed() {
let carriage = crate::h2::Carriage {
client: peer,
upstream,
chain: chain.clone(),
stats: Arc::clone(&stats),
capture: cfg.capture.clone(),
first_byte_timeout: cfg.first_byte_timeout,
};
// Which version leaves is the address's answer, not the client's. `Plain` is the
// operator saying the service speaks HTTP/1.1 whatever arrives, so what leaves is
// the HTTP/1.1 the chain was already shown — the same bargain the QUIC edge makes,
// and the reason `h2.rs` takes its upstream as an `Outbound` rather than a
// connection of its own kind.
return match published.upstream {
Onward::Plain => {
// Each exchange dials a connection of its own, so the one opened before
// anything was known about the client goes now — held, it sat idle
// towards the service for as long as the client stayed.
drop(server);
crate::h2::carry_to_h1(
client,
crate::h1up::H1Upstream {
upstream,
client: peer,
tls: None,
server_name: None,
connect_timeout: cfg.connect_timeout,
self_mark: cfg.self_mark,
spoof: cfg.spoof_source,
},
carriage,
)
.await
}
_ => crate::h2::carry(client, server, carriage).await,
};
}
let (client_rd, client_wr) = tokio::io::split(client);
let (server_rd, server_wr) = tokio::io::split(server);
relay(
client_rd,
client_wr,
server_rd,
server_wr,
peer,
upstream,
&cfg,
&chain,
&stats,
Tap::open(cfg.capture.clone(), peer, upstream),
)
.await
}
/// What a client opening an HTTP/2 connection in the clear sends before anything else.
///
/// RFC 9113's connection preface: a fixed 24 bytes, written as one piece, that no other
/// protocol begins with. It is what "prior knowledge" means — the client has been told the
/// service speaks HTTP/2 and does not negotiate.
const H2C_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
/// How long the opening is waited for when neither end has said anything yet.
///
/// Short, and paid only by a connection where *nobody* has spoken — which is the shape the
/// first-byte deadline already exists to close, measured in seconds, so this adds nothing
/// anybody will notice. It is here so that two silent peers cannot hold this function, and
/// the two descriptors behind it, open forever.
const SNIFF_WAIT: Duration = Duration::from_millis(250);
/// What this connection opens with, without taking anything out of the socket.
///
/// It races the service, and that is the part that matters: a protocol where the *server*
/// speaks first — SMTP, SSH, most game protocols — would otherwise be held here waiting for
/// a client that is correctly waiting for a banner, and firegex would look like it was
/// breaking them at random. Neither TLS nor HTTP/2 has a client that waits to be spoken to,
/// so the service having spoken is proof this is neither.
async fn sniff(client: &TcpStream, server: &TcpStream) -> Opening {
async fn opening(client: &TcpStream) -> Opening {
let mut buf = [0u8; H2C_PREFACE.len()];
loop {
let n = match client.peek(&mut buf).await {
Ok(0) | Err(_) => return Opening::Other,
Ok(n) => n,
};
// Decided from three bytes, which arrive in the first segment of every TLS
// connection there has ever been: a handshake record, major version 3. Not
// from one — an opening byte on its own says nothing, and guessing from it
// would send a connection down the wrong edge for good.
if buf[0] == 0x16 {
if n < 3 {
tokio::time::sleep(Duration::from_millis(1)).await;
continue;
}
return if buf[1] == 0x03 {
Opening::Tls
} else {
Opening::Other
};
}
if n >= H2C_PREFACE.len() {
return if buf == H2C_PREFACE {
Opening::Http2
} else {
Opening::Other
};
}
// Fewer bytes than the preface, but so far they *are* the preface: the
// segment boundary fell inside it. Peeking again costs nothing and is bounded
// by the caller — and treating a short read as "not HTTP/2" would be a
// connection carried unfiltered for a reason nobody could see.
if H2C_PREFACE.starts_with(&buf[..n]) {
tokio::time::sleep(Duration::from_millis(1)).await;
} else {
return Opening::Other;
}
}
}
let mut first = [0u8; 1];
tokio::select! {
biased;
answer = opening(client) => answer,
_ = server.peek(&mut first) => Opening::Other,
_ = tokio::time::sleep(SNIFF_WAIT) => Opening::Other,
}
}
/// Carry one connection as two streams of bytes, which is every case HTTP/2 is not.
///
/// Its own function because the plaintext path and the TLS one reach it from different
/// places now: the h2 branch returns before this, and what is left has to open the
/// chain's connection, pump, and close it in one piece rather than in two copies.
#[allow(clippy::too_many_arguments)]
async fn relay<CR, CW, SR, SW>(
client_rd: CR,
client_wr: CW,
server_rd: SR,
server_wr: SW,
peer: SocketAddr,
upstream: SocketAddr,
cfg: &Arc<ProxyConfig>,
chain: &ChainHandle,
stats: &Arc<ProxyStats>,
tap: Option<Arc<Tap>>,
) -> io::Result<()>
where
CR: AsyncReadExt + Unpin + Send + 'static,
CW: AsyncWriteExt + Unpin + Send + 'static,
SR: AsyncReadExt + Unpin + Send + 'static,
SW: AsyncWriteExt + Unpin + Send + 'static,
{
// Both directions share it: to a filter this is one stream, and a filter keeping
// per-stream state must not see the two halves as unrelated clients.
let connection = next_connection_id();
// Told once, up front. These are the real addresses — this proxy knows both ends,
// which is more than the header on the wire could say once the connection has been
// terminated and reopened.
chain.current().connection_opened(
connection,
&ConnectionMeta {
client: peer,
server: upstream,
l4: L4::Tcp,
},
);
let (up, down) = spawn_pumps(
client_rd,
client_wr,
server_rd,
server_wr,
chain,
stats,
connection,
tap.clone(),
Arc::new(AtomicBool::new(false)),
cfg.first_byte_timeout,
);
join_pumps(up, down).await;
// Both directions are finished, so anything a filter was keeping for this stream
// can go. Filters whose state lives in a session were already freed by dropping
// it; this is for the ones whose state lives somewhere the session cannot reach.
chain.current().connection_closed(connection);
// The reconstruction gets an end as well as a beginning, or Wireshark holds the
// stream open waiting for bytes that are never coming.
if let Some(tap) = &tap {
tap.closed();
}
Ok(())
}
/// Why a direction stopped moving bytes.
#[derive(Debug, PartialEq, Eq)]
pub enum PumpOutcome {
/// The peer closed, or the stream simply ended.
Finished,
/// A rule refused the traffic, and the connection goes with it.
Rejected,
}
/// Wait for both directions to finish, and report whether a rule ended them.
///
/// A rule blocks a connection, not a direction. Half-closing would leave the other side
/// free to answer anyway — a service that replies without waiting for the request would
/// then still reach a client whose request was refused. The refused direction is already
/// winding the other one down through the stop channel; the grace period is the backstop
/// for one blocked writing to a peer that has stopped reading.
///
/// The answer comes back rather than being acted on here, because what a refusal ends
/// differs by layer: on TCP the connection *is* the stream, while a QUIC stream is one of
/// many and the connection carrying it has to be closed on purpose.
pub(crate) async fn join_pumps(
mut up: tokio::task::JoinHandle<io::Result<PumpOutcome>>,
mut down: tokio::task::JoinHandle<io::Result<PumpOutcome>>,
) -> bool {
fn rejected(r: &Result<io::Result<PumpOutcome>, tokio::task::JoinError>) -> bool {
matches!(r, Ok(Ok(PumpOutcome::Rejected)))
}
async fn wind_down(
other: &mut tokio::task::JoinHandle<io::Result<PumpOutcome>>,
rejected: bool,
) -> bool {
if !rejected {
return matches!(other.await, Ok(Ok(PumpOutcome::Rejected)));
}
if tokio::time::timeout(REJECT_GRACE, &mut *other).await.is_err() {
other.abort();
}
true
}
tokio::select! {
r = &mut up => wind_down(&mut down, rejected(&r)).await,
r = &mut down => wind_down(&mut up, rejected(&r)).await,
}
}
/// Start both directions. Split out so the plain and the TLS paths share it.
#[allow(clippy::type_complexity)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn spawn_pumps<CR, CW, SR, SW>(
client_rd: CR,
client_wr: CW,
server_rd: SR,
server_wr: SW,
chain: &ChainHandle,
stats: &Arc<ProxyStats>,
connection: ConnectionId,
tap: Option<Arc<Tap>>,
spoken: Arc<AtomicBool>,
cfg_first_byte: Option<Duration>,
) -> (
tokio::task::JoinHandle<io::Result<PumpOutcome>>,
tokio::task::JoinHandle<io::Result<PumpOutcome>>,
)
where
CR: AsyncReadExt + Unpin + Send + 'static,
CW: AsyncWriteExt + Unpin + Send + 'static,
SR: AsyncReadExt + Unpin + Send + 'static,
SW: AsyncWriteExt + Unpin + Send + 'static,
{
// A refusal has to stop the other direction too, but stopping it by aborting the
// task drops its socket mid-stream: over TLS that reaches the peer as a
// truncation error instead of a close. Telling it to stop lets it forward nothing
// more and still shut down properly.
let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
// Set by whichever direction moves a byte first. Both pumps hold it, so a server
// that speaks before its client satisfies the deadline just as a request would.
//
// Passed in rather than made here: on QUIC one connection carries many streams, and
// the question the deadline asks — has this peer said anything at all — is about the
// connection. A flag per stream would answer it once per stream and never for the
// connection that opened a hundred of them and spoke on none.
if let Some(deadline) = cfg_first_byte {
let spoken = Arc::clone(&spoken);
let stop = stop_tx.clone();
let stats = Arc::clone(stats);
tokio::spawn(async move {
// Raced against the connection ending, not just slept through. Both pumps
// hold a receiver, so `closed()` resolves the moment the connection is over
// — and without that the timer outlives it for the whole deadline. A service
// taking a few thousand short connections a second with a minute's deadline
// would hold hundreds of thousands of sleeping tasks, which is the resource
// exhaustion this feature exists to prevent, arriving by the front door.
tokio::select! {
_ = tokio::time::sleep(deadline) => {
if !spoken.load(Ordering::Relaxed) {
stats.no_first_byte.fetch_add(1, Ordering::Relaxed);
// Through the same channel a refusal uses, so both directions are
// shut down rather than dropped: a peer dropped mid-stream reads
// a truncation, and this connection has done nothing to deserve
// one.
let _ = stop.send(true);
}
}
_ = stop.closed() => {}
}
});
}
let up = tokio::spawn(pump(
client_rd,
server_wr,
Direction::ClientToServer,
chain.clone(),
Arc::clone(stats),
connection,
tap.clone(),
Arc::clone(&spoken),
stop_tx.clone(),
stop_rx.clone(),
));
let down = tokio::spawn(pump(
server_rd,
client_wr,
Direction::ServerToClient,
chain.clone(),
Arc::clone(stats),
connection,
tap,
spoken,
stop_tx,
stop_rx,
));
(up, down)
}
/// Move one direction of the stream, asking the chain about every chunk.
// Seven arguments rather than a struct: every one of them is moved into the task and
// used exactly once, so a struct would be a wrapper around the argument list.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn pump<R, W>(
mut rd: R,
mut wr: W,
dir: Direction,
chain: ChainHandle,
stats: Arc<ProxyStats>,
connection: ConnectionId,
tap: Option<Arc<Tap>>,
spoken: Arc<AtomicBool>,
stop_tx: tokio::sync::watch::Sender<bool>,
mut stop_rx: tokio::sync::watch::Receiver<bool>,
) -> io::Result<PumpOutcome>
where
R: AsyncReadExt + Unpin,
W: AsyncWriteExt + Unpin,
{
// This direction's filter state, opened on the first chunk and dropped with the
// connection. Nothing here is shared with any other connection.
let mut sessions = ChainSessions::new(connection);
let mut buf = vec![0u8; RELAY_BUF];
loop {
let read = tokio::select! {
r = rd.read(&mut buf) => r,
// The other direction refused the connection. Nothing more is forwarded,
// and this side is closed rather than dropped.
_ = stop_rx.changed() => {
let _ = wr.shutdown().await;
return Ok(PumpOutcome::Finished);
}
};
let n = match read {
Ok(0) => break,
Ok(n) => n,
// However badly the other end goes away, this end is closed properly.
// Returning here without shutting down leaves a TLS peer reading a
// truncated stream instead of a close, which is the peer having to guess
// again.
Err(e) => {
let _ = wr.shutdown().await;
return Err(e);
}
};
// Before the chain has a say: a chunk a filter goes on to refuse is still a
// connection that said something, and the deadline is about silence.
spoken.store(true, Ordering::Relaxed);
// Re-read the handle every chunk so a chain swapped in mid-connection
// takes effect without dropping anyone.
let verdict = chain.current().run(dir, &buf[..n], &mut sessions).await;
match verdict {
Verdict::Accept => {
if let Err(e) = wr.write_all(&buf[..n]).await {
let _ = wr.shutdown().await;
return Err(e);
}
// After the write, and only what was written: a reconstruction that
// showed bytes the peer never received would disagree with the one
// thing it exists to report.
if let Some(tap) = &tap {
tap.wrote(dir == Direction::ClientToServer, &buf[..n]);
}
}
Verdict::Reject(_) => {
stats.closed_by_filter.fetch_add(1, Ordering::Relaxed);
// Owning the connection means we can close it cleanly, instead of
// dropping packets and leaving the peer to guess.
let _ = stop_tx.send(true);
let _ = wr.shutdown().await;
return Ok(PumpOutcome::Rejected);
}
}
}
let _ = wr.shutdown().await;
Ok(PumpOutcome::Finished)
}
#[cfg(test)]
mod tests {
use super::dialled_at_ourselves;
/// What `SO_ORIGINAL_DST` hands back for a connection dialled straight at the listener
/// is the address it was accepted on, and forwarding there is dialling ourselves.
#[test]
fn a_connection_nothing_redirected_is_recognised() {
let listen = "[::]:0".parse().unwrap();
let accepted_on = "10.0.0.5:41234".parse().unwrap();
assert!(dialled_at_ourselves(accepted_on, accepted_on, listen));
// The configured address is the wildcard, which is why comparing with it alone
// never caught this.
assert!(!dialled_at_ourselves(accepted_on, "10.0.0.6:41234".parse().unwrap(), listen));
// A redirected connection remembers the service it was headed for.
assert!(!dialled_at_ourselves("10.0.0.5:80".parse().unwrap(), accepted_on, listen));
}
}