Skip to main content

acme_proxy_admin/webadmin/
filter.rs

1//! `[admin.filter]`: who may reach the admin listener at all.
2//!
3//! The same policy engine as the ACME listener's `[filter]` — named checks,
4//! ordered rules, `trusted_proxies` — built from its own section and evaluated
5//! at the **connection stage only**, on every request this listener serves,
6//! `/health` included. It exists for the deployment that cannot put a host
7//! firewall in front of the port (a container runtime owns the host's
8//! netfilter tables), and it is not a substitute for one where one is
9//! available: a refused request here has still completed a TCP and, with
10//! `admin.tls` on, a TLS handshake.
11//!
12//! ## Connection stage only
13//!
14//! Nothing on this listener ever names an identifier, so a rule that can only
15//! decide at the identifier stage would never run — a rule the operator
16//! believes in and the server ignores. [`build`] refuses one by name, and
17//! refuses the check types that have no meaning without an ACME order
18//! (`identifiers`, `eab`, `ipam`) before the engine gets to phrase the
19//! refusal in profile terms.
20//!
21//! ## The client address
22//!
23//! [`ClientIp`] goes into the request extensions on every request, whether or
24//! not a rule is configured, and [`AdminClientIp`](super::session::AdminClientIp)
25//! prefers it to the socket peer. That is what lets
26//! `admin.filter.trusted_proxies` fix the login limiter behind a reverse proxy:
27//! without it every failed login counts against the proxy's address.
28//!
29//! No `#[instrument]` here, for the reason `middlewares::filter` gives: the
30//! `client_ip` record must land on the `request` span itself.
31
32use std::net::SocketAddr;
33use std::sync::Arc;
34
35use axum::body::Body;
36use axum::extract::{ConnectInfo, State};
37use axum::http::Request;
38use axum::middleware::Next;
39use axum::response::{IntoResponse, Response};
40use tracing::{Span, field, warn};
41
42use acme_proxy_core::client::{ClientIp, ProxyPolicy};
43use acme_proxy_core::config::Config;
44use acme_proxy_policy::filter::{ConnectionContext, Effect, FilterPolicy, Outcome, Stage};
45
46use super::error::AdminError;
47use super::pages::PageError;
48
49/// Check types that decide only about an ACME order's names or account.
50const IDENTIFIER_ONLY_TYPES: &[&str] = &["identifiers", "eab", "ipam"];
51
52/// Builds the admin listener's policy from `admin.filter`, refusing anything
53/// that could not decide here.
54///
55/// With no `rules`, the policy filters nothing but still carries
56/// `trusted_proxies`, so the client address is resolved the same way whether
57/// or not a rule is written. The engine's own builder is skipped in that case:
58/// its "no rules" warning speaks of certificates, which this listener issues
59/// none of.
60pub fn build(config: &Config) -> anyhow::Result<Arc<FilterPolicy>> {
61    let filter = &config.admin.filter;
62    let prefix = |error: anyhow::Error| anyhow::anyhow!("admin.filter: {error}");
63
64    if filter.rules.is_empty() {
65        if let Some(name) = filter.rule.keys().next() {
66            anyhow::bail!(
67                "[admin.filter.rule.{name}] is configured but admin.filter.rules is empty; \
68                 list the rules to evaluate, in order"
69            );
70        }
71        let proxy =
72            ProxyPolicy::new(&filter.trusted_proxies, &filter.forwarded_header).map_err(prefix)?;
73        return Ok(Arc::new(FilterPolicy::new(
74            Vec::new(),
75            Vec::new(),
76            Effect::Allow,
77            proxy,
78        )));
79    }
80
81    for (name, check) in &filter.check {
82        let kind = check.r#type.trim();
83        if IDENTIFIER_ONLY_TYPES.contains(&kind) {
84            anyhow::bail!(
85                "admin.filter.check.{name} is type = \"{kind}\", which decides about the \
86                 names or account of an ACME order; the admin listener never sees one, so it \
87                 could not decide anything. Use allowed_ip, path, reverse_dns or custom"
88            );
89        }
90    }
91
92    let policy = acme_proxy_policy::filter::build::build(filter, &config.dns, None, false)
93        .map_err(prefix)?;
94
95    if let Some(rule) = policy
96        .rules()
97        .iter()
98        .find(|rule| !rule.stages.contains(Stage::Connection))
99    {
100        anyhow::bail!(
101            "admin.filter.rule.{} is evaluated at the identifier stage only, which the admin \
102             listener never reaches, so it would never run; drop the stages override on its \
103             checks or drop the rule",
104            rule.name
105        );
106    }
107
108    Ok(Arc::new(policy))
109}
110
111/// Resolves the client address and runs the connection stage.
112pub async fn admin_filter_middleware(
113    State(policy): State<Arc<FilterPolicy>>,
114    mut request: Request<Body>,
115    next: Next,
116) -> Response {
117    let peer = request
118        .extensions()
119        .get::<ConnectInfo<SocketAddr>>()
120        .map(|ConnectInfo(addr)| addr.ip());
121
122    let client_ip = policy.proxy().resolve(peer, request.headers());
123    request.extensions_mut().insert(ClientIp(client_ip));
124    if let Some(ip) = client_ip {
125        Span::current().record("client_ip", field::display(ip));
126    }
127
128    let path = request.uri().path().to_string();
129    let context = ConnectionContext {
130        client_ip,
131        method: request.method(),
132        path: &path,
133    };
134
135    match policy.check_connection(&context).await {
136        Outcome::Allow => next.run(request).await,
137        outcome => refusal(&outcome, client_ip, &path),
138    }
139}
140
141/// A refusal in the shape the path's caller reads: JSON under `/api`, the HTML
142/// error page everywhere else.
143///
144/// A deny is a `403`; an unknown is the server's own failure, a `500` with a
145/// generic body, since the specifics are already in the policy's log line.
146fn refusal(outcome: &Outcome, client_ip: Option<std::net::IpAddr>, path: &str) -> Response {
147    let error = match outcome {
148        Outcome::Deny(detail) => {
149            warn!(event = "filter_request_blocked", outcome = "failure", listener = "admin", client_ip = ?client_ip, path, %detail);
150            AdminError::access_denied(detail.clone())
151        }
152        Outcome::Undecided(_) | Outcome::Allow => AdminError::internal(),
153    };
154
155    if path == "/api" || path.starts_with("/api/") {
156        error.into_response()
157    } else {
158        PageError::from(error).into_response()
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use axum::http::{StatusCode, header};
166    use axum::{Router, middleware, routing::get};
167    use tower::ServiceExt;
168
169    use acme_proxy_core::config::{CheckConfig, RuleConfig};
170
171    fn config_with(check: CheckConfig) -> Config {
172        let mut config = Config::default();
173        let filter = &mut config.admin.filter;
174        filter.rules = vec!["mgmt".to_string()];
175        filter.check.insert("net".to_string(), check);
176        filter.rule.insert(
177            "mgmt".to_string(),
178            RuleConfig {
179                when: "net".to_string(),
180                then: "allow".to_string(),
181                ..RuleConfig::default()
182            },
183        );
184        config
185    }
186
187    fn allowed_ip(allow: &[&str]) -> CheckConfig {
188        CheckConfig {
189            r#type: "allowed_ip".to_string(),
190            allow: allow.iter().map(ToString::to_string).collect(),
191            ..CheckConfig::default()
192        }
193    }
194
195    fn app(config: &Config) -> Router {
196        let policy = build(config).expect("policy must build");
197        Router::new()
198            .route("/api/x", get(|| async { "ok" }))
199            .route("/ui/x", get(|| async { "ok" }))
200            .route(
201                "/ip",
202                get(|parts: axum::http::request::Parts| async move {
203                    format!("{:?}", parts.extensions.get::<ClientIp>())
204                }),
205            )
206            .layer(middleware::from_fn_with_state(
207                policy,
208                admin_filter_middleware,
209            ))
210    }
211
212    fn request(uri: &str, peer: [u8; 4], forwarded: Option<&str>) -> Request<Body> {
213        let mut builder = Request::builder().uri(uri);
214        if let Some(value) = forwarded {
215            builder = builder.header("x-forwarded-for", value);
216        }
217        let mut request = builder.body(Body::empty()).unwrap();
218        request
219            .extensions_mut()
220            .insert(ConnectInfo(SocketAddr::from((peer, 4711))));
221        request
222    }
223
224    async fn body(response: Response) -> String {
225        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
226            .await
227            .unwrap();
228        String::from_utf8(bytes.to_vec()).unwrap()
229    }
230
231    #[tokio::test]
232    async fn no_rules_serves_everyone() {
233        let app = app(&Config::default());
234        let response = app
235            .oneshot(request("/api/x", [203, 0, 113, 5], None))
236            .await
237            .unwrap();
238        assert_eq!(response.status(), StatusCode::OK);
239    }
240
241    #[tokio::test]
242    async fn an_allowed_address_is_served() {
243        let app = app(&config_with(allowed_ip(&["10.0.0.0/8"])));
244        let response = app
245            .oneshot(request("/api/x", [10, 1, 2, 3], None))
246            .await
247            .unwrap();
248        assert_eq!(response.status(), StatusCode::OK);
249    }
250
251    #[tokio::test]
252    async fn a_refusal_under_api_is_json() {
253        let app = app(&config_with(allowed_ip(&["10.0.0.0/8"])));
254        let response = app
255            .oneshot(request("/api/x", [203, 0, 113, 5], None))
256            .await
257            .unwrap();
258        assert_eq!(response.status(), StatusCode::FORBIDDEN);
259        let json: serde_json::Value = serde_json::from_str(&body(response).await).unwrap();
260        assert_eq!(json["error"], "access_denied");
261    }
262
263    #[tokio::test]
264    async fn a_refusal_elsewhere_is_an_html_page() {
265        let app = app(&config_with(allowed_ip(&["10.0.0.0/8"])));
266        let response = app
267            .oneshot(request("/ui/x", [203, 0, 113, 5], None))
268            .await
269            .unwrap();
270        assert_eq!(response.status(), StatusCode::FORBIDDEN);
271        let content_type = response.headers()[header::CONTENT_TYPE]
272            .to_str()
273            .unwrap()
274            .to_string();
275        assert!(content_type.starts_with("text/html"), "{content_type}");
276        assert!(body(response).await.contains("access_denied"));
277    }
278
279    /// The deployment this section exists for: a reverse proxy in the same
280    /// compose file, and the real client only in its header.
281    #[tokio::test]
282    async fn a_trusted_proxys_forwarded_client_is_the_one_checked() {
283        let mut config = config_with(allowed_ip(&["198.51.100.0/24"]));
284        config.admin.filter.trusted_proxies = vec!["172.16.0.0/12".to_string()];
285        let app = app(&config);
286
287        let allowed = app
288            .clone()
289            .oneshot(request("/api/x", [172, 18, 0, 2], Some("198.51.100.9")))
290            .await
291            .unwrap();
292        assert_eq!(allowed.status(), StatusCode::OK);
293
294        let refused = app
295            .oneshot(request("/api/x", [172, 18, 0, 2], Some("203.0.113.5")))
296            .await
297            .unwrap();
298        assert_eq!(refused.status(), StatusCode::FORBIDDEN);
299    }
300
301    #[tokio::test]
302    async fn an_untrusted_peers_forwarded_header_is_ignored() {
303        let app = app(&config_with(allowed_ip(&["198.51.100.0/24"])));
304        let response = app
305            .oneshot(request("/api/x", [203, 0, 113, 5], Some("198.51.100.9")))
306            .await
307            .unwrap();
308        assert_eq!(response.status(), StatusCode::FORBIDDEN);
309    }
310
311    /// `trusted_proxies` alone, with no rule, still resolves the client: the
312    /// login limiter reads the result.
313    #[tokio::test]
314    async fn the_client_address_is_recorded_without_any_rule() {
315        let mut config = Config::default();
316        config.admin.filter.trusted_proxies = vec!["172.16.0.0/12".to_string()];
317        let response = app(&config)
318            .oneshot(request("/ip", [172, 18, 0, 2], Some("198.51.100.9")))
319            .await
320            .unwrap();
321        assert!(body(response).await.contains("198.51.100.9"));
322    }
323
324    #[test]
325    fn an_unknown_is_a_500() {
326        let response = refusal(
327            &Outcome::Undecided("script died".to_string()),
328            None,
329            "/api/x",
330        );
331        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
332    }
333
334    #[test]
335    fn identifier_only_check_types_are_refused_by_name() {
336        for kind in IDENTIFIER_ONLY_TYPES {
337            let error = build(&config_with(CheckConfig {
338                r#type: (*kind).to_string(),
339                ..CheckConfig::default()
340            }))
341            .unwrap_err()
342            .to_string();
343            assert!(error.contains("admin.filter.check.net"), "{error}");
344            assert!(error.contains(kind), "{error}");
345        }
346    }
347
348    #[test]
349    fn a_rule_moved_to_the_identifier_stage_is_refused() {
350        let mut check = allowed_ip(&["10.0.0.0/8"]);
351        check.stages = vec!["identifiers".to_string()];
352        let error = build(&config_with(check)).unwrap_err().to_string();
353        assert!(error.contains("admin.filter.rule.mgmt"), "{error}");
354    }
355
356    #[test]
357    fn an_engine_error_names_the_section() {
358        let error = build(&config_with(allowed_ip(&["not-a-network"])))
359            .unwrap_err()
360            .to_string();
361        assert!(error.starts_with("admin.filter: "), "{error}");
362    }
363
364    #[test]
365    fn a_rule_without_rules_is_refused() {
366        let mut config = config_with(allowed_ip(&["10.0.0.0/8"]));
367        config.admin.filter.rules.clear();
368        let error = build(&config).unwrap_err().to_string();
369        assert!(error.contains("admin.filter.rules is empty"), "{error}");
370    }
371
372    #[test]
373    fn a_bad_trusted_proxy_is_refused_without_rules() {
374        let mut config = Config::default();
375        config.admin.filter.trusted_proxies = vec!["nope".to_string()];
376        assert!(build(&config).is_err());
377    }
378}