use std::{collections::BTreeMap, sync::Arc};
use axum::{
Json,
body::Body,
extract::{ConnectInfo, Path, Query, State},
http::{HeaderValue, Method, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use futures::StreamExt;
use tracing::warn;
use crate::alertd::{
certificates::{delivery::Endpoints, peer},
http_server::state::ServerState,
tasks::{TaskContext, TaskEndpointResponse},
};
pub async fn handle_task_endpoint(
State(state): State<Arc<ServerState>>,
ConnectInfo(ends): ConnectInfo<Endpoints>,
method: Method,
Path((task, endpoint)): Path<(String, String)>,
Query(query): Query<BTreeMap<String, String>>,
) -> Response {
let Some(mounted) = state.task_endpoints.get(&(task.clone(), endpoint.clone())) else {
return (
StatusCode::NOT_FOUND,
format!("no endpoint at /tasks/{task}/{endpoint}"),
)
.into_response();
};
if mounted.guarded {
if method != Method::POST {
return (
StatusCode::METHOD_NOT_ALLOWED,
format!("/tasks/{task}/{endpoint} changes state, so it takes a POST"),
)
.into_response();
}
if let Err(err) =
peer::caller_permitted(peer::Permitted::default(), ends.remote, ends.local).await
{
warn!(peer = %ends.remote, path = %format!("/tasks/{task}/{endpoint}"), %err, "refused a request to a guarded task endpoint");
return (StatusCode::FORBIDDEN, format!("{err}")).into_response();
}
}
let mut ctx = TaskContext::from_internal(&state.internal_context);
ctx.query = query;
let response = (mounted.handler)(ctx).await;
match response {
TaskEndpointResponse::Json(value) => Json(value).into_response(),
TaskEndpointResponse::JsonLines(stream) => {
let body = Body::from_stream(stream.map(|value| {
let mut bytes = serde_json::to_vec(&value).unwrap_or_else(|err| {
warn!(%err, "could not serialise task endpoint stream value");
b"{}".to_vec()
});
bytes.push(b'\n');
Ok::<_, std::convert::Infallible>(bytes)
}));
let mut response = Response::new(body);
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/x-ndjson"),
);
response
}
TaskEndpointResponse::Error { status, message } => (
StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
message,
)
.into_response(),
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use axum::{Router, routing::get};
use serde_json::json;
use super::*;
use crate::alertd::tasks::TaskEndpoint;
#[cfg(target_os = "linux")]
fn own_uid() -> u32 {
std::fs::read_to_string("/proc/self/status")
.unwrap()
.lines()
.find_map(|line| line.strip_prefix("Uid:"))
.and_then(|rest| rest.split_whitespace().next()?.parse().ok())
.unwrap()
}
async fn serve() -> String {
let answer = |_ctx: TaskContext| {
Box::pin(async move { TaskEndpointResponse::Json(json!({"ran": true})) })
as futures::future::BoxFuture<'static, TaskEndpointResponse>
};
let mut endpoints = HashMap::new();
for endpoint in [
TaskEndpoint::open("status", Arc::new(answer)),
TaskEndpoint::guarded("mutate", Arc::new(answer)),
] {
endpoints.insert(("t".to_string(), endpoint.name.to_string()), endpoint);
}
let mut server = crate::alertd::http_server::test_utils::create_test_state().await;
Arc::get_mut(&mut server).unwrap().task_endpoints = Arc::new(endpoints);
let app = Router::new()
.route(
"/tasks/{task}/{endpoint}",
get(handle_task_endpoint).post(handle_task_endpoint),
)
.with_state(server);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let _ = axum::serve(
listener,
app.into_make_service_with_connect_info::<Endpoints>(),
)
.await;
});
base
}
#[tokio::test]
async fn an_open_endpoint_answers_a_plain_get() {
let base = serve().await;
let response = reqwest::get(format!("{base}/tasks/t/status"))
.await
.unwrap();
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn a_guarded_endpoint_refuses_a_get() {
let base = serve().await;
let response = reqwest::get(format!("{base}/tasks/t/mutate"))
.await
.unwrap();
assert_eq!(response.status(), 405);
}
#[tokio::test]
async fn a_guarded_endpoint_identifies_its_caller() {
let base = serve().await;
let response = reqwest::Client::new()
.post(format!("{base}/tasks/t/mutate"))
.send()
.await
.unwrap();
#[cfg(target_os = "linux")]
let expected = if own_uid() == 0 { 200 } else { 403 };
#[cfg(not(target_os = "linux"))]
let expected = 403;
assert_eq!(response.status(), expected);
}
}