use std::net::SocketAddr;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{ConnectInfo, State};
use axum::http::Request;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use tracing::{Span, field, warn};
use acme_proxy_core::client::{ClientIp, ProxyPolicy};
use acme_proxy_core::config::Config;
use acme_proxy_policy::filter::{ConnectionContext, Effect, FilterPolicy, Outcome, Stage};
use super::error::AdminError;
use super::pages::PageError;
const IDENTIFIER_ONLY_TYPES: &[&str] = &["identifiers", "eab", "ipam"];
pub fn build(config: &Config) -> anyhow::Result<Arc<FilterPolicy>> {
let filter = &config.admin.filter;
let prefix = |error: anyhow::Error| anyhow::anyhow!("admin.filter: {error}");
if filter.rules.is_empty() {
if let Some(name) = filter.rule.keys().next() {
anyhow::bail!(
"[admin.filter.rule.{name}] is configured but admin.filter.rules is empty; \
list the rules to evaluate, in order"
);
}
let proxy =
ProxyPolicy::new(&filter.trusted_proxies, &filter.forwarded_header).map_err(prefix)?;
return Ok(Arc::new(FilterPolicy::new(
Vec::new(),
Vec::new(),
Effect::Allow,
proxy,
)));
}
for (name, check) in &filter.check {
let kind = check.r#type.trim();
if IDENTIFIER_ONLY_TYPES.contains(&kind) {
anyhow::bail!(
"admin.filter.check.{name} is type = \"{kind}\", which decides about the \
names or account of an ACME order; the admin listener never sees one, so it \
could not decide anything. Use allowed_ip, path, reverse_dns or custom"
);
}
}
let policy = acme_proxy_policy::filter::build::build(filter, &config.dns, None, false)
.map_err(prefix)?;
if let Some(rule) = policy
.rules()
.iter()
.find(|rule| !rule.stages.contains(Stage::Connection))
{
anyhow::bail!(
"admin.filter.rule.{} is evaluated at the identifier stage only, which the admin \
listener never reaches, so it would never run; drop the stages override on its \
checks or drop the rule",
rule.name
);
}
Ok(Arc::new(policy))
}
pub async fn admin_filter_middleware(
State(policy): State<Arc<FilterPolicy>>,
mut request: Request<Body>,
next: Next,
) -> Response {
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.ip());
let client_ip = policy.proxy().resolve(peer, request.headers());
request.extensions_mut().insert(ClientIp(client_ip));
if let Some(ip) = client_ip {
Span::current().record("client_ip", field::display(ip));
}
let path = request.uri().path().to_string();
let context = ConnectionContext {
client_ip,
method: request.method(),
path: &path,
};
match policy.check_connection(&context).await {
Outcome::Allow => next.run(request).await,
outcome => refusal(&outcome, client_ip, &path),
}
}
fn refusal(outcome: &Outcome, client_ip: Option<std::net::IpAddr>, path: &str) -> Response {
let error = match outcome {
Outcome::Deny(detail) => {
warn!(event = "filter_request_blocked", outcome = "failure", listener = "admin", client_ip = ?client_ip, path, %detail);
AdminError::access_denied(detail.clone())
}
Outcome::Undecided(_) | Outcome::Allow => AdminError::internal(),
};
if path == "/api" || path.starts_with("/api/") {
error.into_response()
} else {
PageError::from(error).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{StatusCode, header};
use axum::{Router, middleware, routing::get};
use tower::ServiceExt;
use acme_proxy_core::config::{CheckConfig, RuleConfig};
fn config_with(check: CheckConfig) -> Config {
let mut config = Config::default();
let filter = &mut config.admin.filter;
filter.rules = vec!["mgmt".to_string()];
filter.check.insert("net".to_string(), check);
filter.rule.insert(
"mgmt".to_string(),
RuleConfig {
when: "net".to_string(),
then: "allow".to_string(),
..RuleConfig::default()
},
);
config
}
fn allowed_ip(allow: &[&str]) -> CheckConfig {
CheckConfig {
r#type: "allowed_ip".to_string(),
allow: allow.iter().map(ToString::to_string).collect(),
..CheckConfig::default()
}
}
fn app(config: &Config) -> Router {
let policy = build(config).expect("policy must build");
Router::new()
.route("/api/x", get(|| async { "ok" }))
.route("/ui/x", get(|| async { "ok" }))
.route(
"/ip",
get(|parts: axum::http::request::Parts| async move {
format!("{:?}", parts.extensions.get::<ClientIp>())
}),
)
.layer(middleware::from_fn_with_state(
policy,
admin_filter_middleware,
))
}
fn request(uri: &str, peer: [u8; 4], forwarded: Option<&str>) -> Request<Body> {
let mut builder = Request::builder().uri(uri);
if let Some(value) = forwarded {
builder = builder.header("x-forwarded-for", value);
}
let mut request = builder.body(Body::empty()).unwrap();
request
.extensions_mut()
.insert(ConnectInfo(SocketAddr::from((peer, 4711))));
request
}
async fn body(response: Response) -> String {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn no_rules_serves_everyone() {
let app = app(&Config::default());
let response = app
.oneshot(request("/api/x", [203, 0, 113, 5], None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn an_allowed_address_is_served() {
let app = app(&config_with(allowed_ip(&["10.0.0.0/8"])));
let response = app
.oneshot(request("/api/x", [10, 1, 2, 3], None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn a_refusal_under_api_is_json() {
let app = app(&config_with(allowed_ip(&["10.0.0.0/8"])));
let response = app
.oneshot(request("/api/x", [203, 0, 113, 5], None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let json: serde_json::Value = serde_json::from_str(&body(response).await).unwrap();
assert_eq!(json["error"], "access_denied");
}
#[tokio::test]
async fn a_refusal_elsewhere_is_an_html_page() {
let app = app(&config_with(allowed_ip(&["10.0.0.0/8"])));
let response = app
.oneshot(request("/ui/x", [203, 0, 113, 5], None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let content_type = response.headers()[header::CONTENT_TYPE]
.to_str()
.unwrap()
.to_string();
assert!(content_type.starts_with("text/html"), "{content_type}");
assert!(body(response).await.contains("access_denied"));
}
#[tokio::test]
async fn a_trusted_proxys_forwarded_client_is_the_one_checked() {
let mut config = config_with(allowed_ip(&["198.51.100.0/24"]));
config.admin.filter.trusted_proxies = vec!["172.16.0.0/12".to_string()];
let app = app(&config);
let allowed = app
.clone()
.oneshot(request("/api/x", [172, 18, 0, 2], Some("198.51.100.9")))
.await
.unwrap();
assert_eq!(allowed.status(), StatusCode::OK);
let refused = app
.oneshot(request("/api/x", [172, 18, 0, 2], Some("203.0.113.5")))
.await
.unwrap();
assert_eq!(refused.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_untrusted_peers_forwarded_header_is_ignored() {
let app = app(&config_with(allowed_ip(&["198.51.100.0/24"])));
let response = app
.oneshot(request("/api/x", [203, 0, 113, 5], Some("198.51.100.9")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn the_client_address_is_recorded_without_any_rule() {
let mut config = Config::default();
config.admin.filter.trusted_proxies = vec!["172.16.0.0/12".to_string()];
let response = app(&config)
.oneshot(request("/ip", [172, 18, 0, 2], Some("198.51.100.9")))
.await
.unwrap();
assert!(body(response).await.contains("198.51.100.9"));
}
#[test]
fn an_unknown_is_a_500() {
let response = refusal(
&Outcome::Undecided("script died".to_string()),
None,
"/api/x",
);
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn identifier_only_check_types_are_refused_by_name() {
for kind in IDENTIFIER_ONLY_TYPES {
let error = build(&config_with(CheckConfig {
r#type: (*kind).to_string(),
..CheckConfig::default()
}))
.unwrap_err()
.to_string();
assert!(error.contains("admin.filter.check.net"), "{error}");
assert!(error.contains(kind), "{error}");
}
}
#[test]
fn a_rule_moved_to_the_identifier_stage_is_refused() {
let mut check = allowed_ip(&["10.0.0.0/8"]);
check.stages = vec!["identifiers".to_string()];
let error = build(&config_with(check)).unwrap_err().to_string();
assert!(error.contains("admin.filter.rule.mgmt"), "{error}");
}
#[test]
fn an_engine_error_names_the_section() {
let error = build(&config_with(allowed_ip(&["not-a-network"])))
.unwrap_err()
.to_string();
assert!(error.starts_with("admin.filter: "), "{error}");
}
#[test]
fn a_rule_without_rules_is_refused() {
let mut config = config_with(allowed_ip(&["10.0.0.0/8"]));
config.admin.filter.rules.clear();
let error = build(&config).unwrap_err().to_string();
assert!(error.contains("admin.filter.rules is empty"), "{error}");
}
#[test]
fn a_bad_trusted_proxy_is_refused_without_rules() {
let mut config = Config::default();
config.admin.filter.trusted_proxies = vec!["nope".to_string()];
assert!(build(&config).is_err());
}
}