···5566use anyhow::{Context, anyhow};
77use confique::Config;
88+use trusted_proxies::{ProxyNetError, TrustedProxies};
89use url::Url;
9101011const SYSTEM_CONFIG_PATH: &str = "/etc/bobbin/config.toml";
···1415 "server.binds",
1516 "server.shutdown_grace_secs",
1617 "server.debug_bind",
1818+ "server.trusted_proxies",
1719 "hydrant.url",
1820 "hydrant.start_cursor",
1921 "ingest.parallelism",
···3638 "BOBBIN_BIND",
3739 "BOBBIN_SHUTDOWN_GRACE_SECS",
3840 "BOBBIN_DEBUG_BIND",
4141+ "BOBBIN_TRUSTED_PROXIES",
3942 "BOBBIN_HYDRANT_URL",
4043 "BOBBIN_START_CURSOR",
4144 "BOBBIN_INGEST_PARALLELISM",
···103106 /// never reachable on the public listener. Bind to loopback only.
104107 #[config(env = "BOBBIN_DEBUG_BIND", default = "")]
105108 pub debug_bind: String,
109109+110110+ /// Reverse proxies in front of bobbin,
111111+ /// each a bare IP address without a port or a CIDR block such as `173.245.48.0/20`.
112112+ /// Bobbin will read the client address out of `x-forwarded-for`
113113+ /// and forward that one address to the knot
114114+ /// when a request arrives from a proxy on this list,
115115+ /// so a knot that lists bobbin under its own `xrpc.trusted_proxies`
116116+ /// can rate-limit per browser
117117+ /// instead of pooling everyone bobbin serves into a single bucket.
118118+ /// Bobbin will read the last 32 entries of the chain, at most.
119119+ /// Leave empty when bobbin takes connections directly,
120120+ /// since bobbin would otherwise believe a header any client can write.
121121+ /// When using as an env var, comma-separated.
122122+ #[config(
123123+ env = "BOBBIN_TRUSTED_PROXIES",
124124+ parse_env = trusted_proxies::comma_separated,
125125+ default = []
126126+ )]
127127+ pub trusted_proxies: Vec<String>,
128128+}
129129+130130+impl ServerConfig {
131131+ pub fn trusted_proxies(&self) -> Result<TrustedProxies, ProxyNetError> {
132132+ TrustedProxies::parse(self.trusted_proxies.iter().map(String::as_str))
133133+ }
106134}
107135108136#[derive(Debug, thiserror::Error)]
···269297 if let Some(p) = path {
270298 builder = builder.file(p);
271299 }
272272- builder
300300+ let config = builder
273301 .file(SYSTEM_CONFIG_PATH)
274302 .load()
275275- .context("load configuration")
303303+ .context("load configuration")?;
304304+ config
305305+ .server
306306+ .trusted_proxies()
307307+ .context("server.trusted_proxies takes a bare IP address or a CIDR block")?;
308308+ Ok(config)
276309}
277310278311pub fn template() -> String {
···451484 "KNOWN_ENVS entry {name:?} must start with {ENV_PREFIX:?}"
452485 );
453486 });
487487+ }
488488+489489+ #[test]
490490+ fn known_envs_matches_every_env_attribute_this_crate_declares() {
491491+ let known: HashSet<&str> = KNOWN_ENVS.iter().copied().collect();
492492+ let declared: HashSet<&str> = [include_str!("config.rs"), include_str!("main.rs")]
493493+ .into_iter()
494494+ .flat_map(|source| {
495495+ source
496496+ .split("env = \"")
497497+ .skip(1)
498498+ .filter_map(|rest| rest.split('"').next())
499499+ })
500500+ .collect();
501501+ assert!(
502502+ declared.contains("BOBBIN_BIND") && declared.contains("BOBBIN_CONFIG"),
503503+ "the scan stopped matching config.rs or main.rs and every name in it would pass unchecked, since it came back with {declared:?}"
504504+ );
505505+ let missing: Vec<&&str> = declared.difference(&known).collect();
506506+ assert!(
507507+ missing.is_empty(),
508508+ "confique reads {missing:?} but check_envs will refuse to start with them set. Add them to KNOWN_ENVS"
509509+ );
510510+ let stale: Vec<&&str> = known.difference(&declared).collect();
511511+ assert!(
512512+ stale.is_empty(),
513513+ "KNOWN_ENVS lists {stale:?}, which the fields stopped reading. Drop them, or check_envs will keep accepting a name that stopped meaning anything"
514514+ );
454515 }
455516456517 #[test]
···11+use std::convert::Infallible;
22+use std::net::{IpAddr, SocketAddr};
33+use std::sync::OnceLock;
44+55+use axum::extract::{ConnectInfo, FromRequestParts};
66+use axum::http::request::Parts;
77+use axum::http::{HeaderMap, HeaderName, HeaderValue};
88+use trusted_proxies::TrustedProxies;
99+1010+pub(crate) static X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for");
1111+1212+#[derive(Default)]
1313+pub struct ClientAddress {
1414+ proxies: TrustedProxies,
1515+ ignored_header: OnceLock<IpAddr>,
1616+ no_socket: OnceLock<()>,
1717+}
1818+1919+impl ClientAddress {
2020+ pub fn new(proxies: TrustedProxies) -> Self {
2121+ Self {
2222+ proxies,
2323+ ..Self::default()
2424+ }
2525+ }
2626+2727+ pub(crate) fn of(&self, headers: &HeaderMap, socket: SocketPeer) -> Option<HeaderValue> {
2828+ match socket.0 {
2929+ None => {
3030+ if self.no_socket.set(()).is_ok() {
3131+ tracing::warn!(
3232+ "bobbin won't forward a client address to the knot for this request, and every client will share one rate-limit bucket there, because bobbin doesn't have a socket address for it. Serve the listener with `into_make_service_with_connect_info`. This warning reports the first such request only."
3333+ );
3434+ }
3535+ None
3636+ }
3737+ Some(peer) => {
3838+ let relays = self.proxies.contains(peer);
3939+ if headers.contains_key(&X_FORWARDED_FOR)
4040+ && !relays
4141+ && self.ignored_header.set(peer).is_ok()
4242+ {
4343+ tracing::warn!(
4444+ %peer,
4545+ "bobbin ignored x-forwarded-for and will forward the address this peer connected from, because the peer is outside server.trusted_proxies. Add this address to server.trusted_proxies if it's the reverse proxy, or every client it serves will share one rate-limit bucket on each knot. This warning reports the first such peer only."
4646+ );
4747+ }
4848+ let client = relays
4949+ .then(|| {
5050+ self.proxies.rightmost_untrusted(
5151+ headers
5252+ .get_all(&X_FORWARDED_FOR)
5353+ .iter()
5454+ .filter_map(|value| value.to_str().ok())
5555+ .flat_map(|value| value.split(',')),
5656+ )
5757+ })
5858+ .flatten()
5959+ .unwrap_or_else(|| peer.to_canonical());
6060+ HeaderValue::try_from(client.to_string()).ok()
6161+ }
6262+ }
6363+ }
6464+}
6565+6666+#[derive(Debug, Clone, Copy)]
6767+pub struct SocketPeer(Option<IpAddr>);
6868+6969+impl<S: Send + Sync> FromRequestParts<S> for SocketPeer {
7070+ type Rejection = Infallible;
7171+7272+ async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
7373+ Ok(Self(
7474+ parts
7575+ .extensions
7676+ .get::<ConnectInfo<SocketAddr>>()
7777+ .map(|info| info.0.ip()),
7878+ ))
7979+ }
8080+}
8181+8282+#[cfg(test)]
8383+mod tests {
8484+ use super::*;
8585+8686+ fn ip(value: &str) -> IpAddr {
8787+ value.parse().unwrap()
8888+ }
8989+9090+ fn relaying<'a>(entries: impl IntoIterator<Item = &'a str>) -> ClientAddress {
9191+ ClientAddress::new(TrustedProxies::parse(entries).unwrap())
9292+ }
9393+9494+ fn chain(value: Option<&str>) -> HeaderMap {
9595+ value
9696+ .map(|value| {
9797+ let mut map = HeaderMap::new();
9898+ map.insert(&X_FORWARDED_FOR, value.parse().unwrap());
9999+ map
100100+ })
101101+ .unwrap_or_default()
102102+ }
103103+104104+ fn forwarded(proxies: &[&str], socket: Option<&str>, claimed: Option<&str>) -> Option<String> {
105105+ relaying(proxies.iter().copied())
106106+ .of(&chain(claimed), SocketPeer(socket.map(ip)))
107107+ .map(|value| value.to_str().unwrap().to_owned())
108108+ }
109109+110110+ #[test]
111111+ fn bobbin_forwards_the_socket_unless_a_listed_proxy_relayed_the_request() {
112112+ let listed: &[&str] = &["127.0.0.1", "173.245.48.0/20"];
113113+ [
114114+ (&["127.0.0.1"][..], Some("203.0.113.7"), Some("198.51.100.4"), Some("203.0.113.7"),
115115+ "bobbin must answer for the socket, since a client reaching it directly wrote that header itself"),
116116+ (&[], Some("203.0.113.7"), Some("198.51.100.4"), Some("203.0.113.7"),
117117+ "an operator who hasn't configured a proxy will get the socket, since honoring the header by default would hand every client its own rate-limit bucket on every knot downstream. The knot reads its own empty list the opposite way, as trusting every peer, so don't carry either default across"),
118118+ (listed, Some("127.0.0.1"), Some("198.51.100.4"), Some("198.51.100.4"),
119119+ "a listed proxy hands over the address it recorded"),
120120+ (listed, Some("127.0.0.1"), Some("198.51.100.4, 173.245.48.9"), Some("198.51.100.4"),
121121+ "and a second listed hop is stepped over with it"),
122122+ (listed, Some("127.0.0.1"), Some(" 198.51.100.4 "), Some("198.51.100.4"),
123123+ "padding around an entry won't hide it"),
124124+ (listed, Some("127.0.0.1"), Some("203.0.113.7, 198.51.100.4"), Some("198.51.100.4"),
125125+ "bobbin takes the rightmost unlisted hop"),
126126+ (listed, Some("127.0.0.1"), None, Some("127.0.0.1"),
127127+ "a listed proxy that didn't send the header leaves its own socket to forward"),
128128+ (listed, Some("127.0.0.1"), Some("not-an-ip"), Some("127.0.0.1"),
129129+ "bobbin stops at an entry it can't parse and forwards the socket, because a client can write anything left of the proxy"),
130130+ (listed, Some("127.0.0.1"), Some("127.0.0.1"), Some("127.0.0.1"),
131131+ "a chain of listed hops alone leaves the proxy's socket too"),
132132+ (&["173.245.48.0/20"], Some("173.245.48.9"), Some("198.51.100.4, 203.0.113.7"), Some("203.0.113.7"),
133133+ "bobbin stops before reaching anything a client wrote, since the proxy appends the address it saw to the right of all of it"),
134134+ (&["127.0.0.1"], Some("::ffff:203.0.113.7"), None, Some("203.0.113.7"),
135135+ "a v4-mapped socket and the plain address are one client"),
136136+ (&["127.0.0.1"], Some("::ffff:127.0.0.1"), Some("::ffff:198.51.100.4"), Some("198.51.100.4"),
137137+ "both spellings must key to one bucket, since a dual-stack listener reports the mapped form on both sides"),
138138+ (&["127.0.0.1"], None, Some("198.51.100.4"), None,
139139+ "bobbin won't identify a client it hasn't seen connect, since it can't check the header against anything"),
140140+ ]
141141+ .iter()
142142+ .for_each(|&(proxies, socket, claimed, expected, why)| {
143143+ assert_eq!(
144144+ forwarded(proxies, socket, claimed).as_deref(),
145145+ expected,
146146+ "{why}: {proxies:?} saw {socket:?} claiming {claimed:?}"
147147+ );
148148+ });
149149+ }
150150+}
···2323# Default value: ""
2424#debug_bind = ""
25252626+# Reverse proxies in front of bobbin,
2727+# each a bare IP address without a port or a CIDR block such as `173.245.48.0/20`.
2828+# Bobbin will read the client address out of `x-forwarded-for`
2929+# and forward that one address to the knot
3030+# when a request arrives from a proxy on this list,
3131+# so a knot that lists bobbin under its own `xrpc.trusted_proxies`
3232+# can rate-limit per browser
3333+# instead of pooling everyone bobbin serves into a single bucket.
3434+# Bobbin will read the last 32 entries of the chain, at most.
3535+# Leave empty when bobbin takes connections directly,
3636+# since bobbin would otherwise believe a header any client can write.
3737+# When using as an env var, comma-separated.
3838+#
3939+# Can also be specified via environment variable `BOBBIN_TRUSTED_PROXIES`.
4040+#
4141+# Default value: []
4242+#trusted_proxies = []
4343+2644[hydrant]
2745# Base URL of the hydrant instance - the cursor-replayable /stream lives
2846# under this. Use `ws://` or `wss://` - `http://` and `https://`