Skip to main content

hennery_kernel/
client_address.rs

1//! Address-only reverse proxy attribution. Never an authentication identity.
2use axum::extract::{ConnectInfo, FromRequestParts, Request};
3use axum::http::{HeaderMap, StatusCode, request::Parts};
4use axum::middleware::Next;
5use axum::response::Response;
6use std::net::{IpAddr, SocketAddr};
7
8pub const HEADER: &str = "hennery-client-ip";
9
10#[derive(Clone, Debug, Default)]
11pub struct ProxyPolicy {
12    peers: Vec<IpAddr>,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Source {
17    Peer,
18    MissingHeader,
19    Proxy,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct ClientAddress {
24    pub peer: IpAddr,
25    pub effective: IpAddr,
26    pub source: Source,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub struct InvalidClientAddress;
31
32fn canonical(ip: IpAddr) -> IpAddr {
33    ip.to_canonical()
34}
35fn valid(ip: IpAddr) -> bool {
36    !ip.is_unspecified() && !ip.is_multicast()
37}
38
39impl ProxyPolicy {
40    pub fn new(peers: Vec<IpAddr>) -> anyhow::Result<Self> {
41        anyhow::ensure!(
42            peers.len() <= 8,
43            "proxy_client_ip_peers allows at most eight exact IP addresses"
44        );
45        let mut normalized = Vec::new();
46        for ip in peers {
47            let ip = canonical(ip);
48            anyhow::ensure!(valid(ip), "proxy_client_ip_peers requires unicast addresses");
49            anyhow::ensure!(
50                !normalized.contains(&ip),
51                "proxy_client_ip_peers contains a duplicate address"
52            );
53            normalized.push(ip);
54        }
55        Ok(Self { peers: normalized })
56    }
57
58    pub fn is_configured(&self) -> bool {
59        !self.peers.is_empty()
60    }
61
62    pub fn resolve(&self, peer: IpAddr, headers: &HeaderMap) -> Result<ClientAddress, InvalidClientAddress> {
63        let peer = canonical(peer);
64        let mut address = ClientAddress {
65            peer,
66            effective: peer,
67            source: Source::Peer,
68        };
69        if !self.peers.contains(&peer) {
70            return Ok(address);
71        }
72        let mut values = headers.get_all(HEADER).iter();
73        let Some(value) = values.next() else {
74            address.source = Source::MissingHeader;
75            return Ok(address);
76        };
77        if values.next().is_some() {
78            return Err(InvalidClientAddress);
79        }
80        let ip = canonical(
81            value
82                .to_str()
83                .map_err(|_| InvalidClientAddress)?
84                .parse::<IpAddr>()
85                .map_err(|_| InvalidClientAddress)?,
86        );
87        if !valid(ip) {
88            return Err(InvalidClientAddress);
89        }
90        address.effective = ip;
91        address.source = Source::Proxy;
92        Ok(address)
93    }
94}
95
96fn refused() -> Response {
97    crate::auth_api::error(
98        StatusCode::BAD_REQUEST,
99        "invalid_client_address",
100        "invalid client address header",
101    )
102}
103
104// A single outer layer resolves before auth/body parsing. Existing standalone
105// routers retain the unconfigured policy through the extractor's fallback.
106pub async fn resolve_request(mut request: Request, next: Next) -> Response {
107    let Some(ConnectInfo(peer)) = request.extensions().get::<ConnectInfo<SocketAddr>>().copied() else {
108        return crate::auth_api::error(
109            StatusCode::INTERNAL_SERVER_ERROR,
110            "internal",
111            "missing connection address",
112        );
113    };
114    let policy = request.extensions().get::<ProxyPolicy>().cloned().unwrap_or_default();
115    match policy.resolve(peer.ip(), request.headers()) {
116        Ok(address) => {
117            request.extensions_mut().insert(address);
118            next.run(request).await
119        }
120        Err(_) => refused(),
121    }
122}
123
124impl<S: Send + Sync> FromRequestParts<S> for ClientAddress {
125    type Rejection = Response;
126    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
127        if let Some(address) = parts.extensions.get::<Self>() {
128            return Ok(*address);
129        }
130        let ConnectInfo(peer) = parts
131            .extensions
132            .get::<ConnectInfo<SocketAddr>>()
133            .copied()
134            .ok_or_else(|| {
135                crate::auth_api::error(
136                    StatusCode::INTERNAL_SERVER_ERROR,
137                    "internal",
138                    "missing connection address",
139                )
140            })?;
141        parts
142            .extensions
143            .get::<ProxyPolicy>()
144            .cloned()
145            .unwrap_or_default()
146            .resolve(peer.ip(), &parts.headers)
147            .map_err(|_| refused())
148    }
149}