use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use hyper::server::conn::http1;
use hyper_util::rt::TokioIo;
use hyper_util::service::TowerToHyperService;
use serde::Deserialize;
use tokio::net::UnixListener;
use tracing::{debug, info, warn};
use super::state::{
self, server_info, ClientDetailDto, ClientSummaryDto, ConnDto, ServerInfoDto, ServerState,
TunnelDetailDto, TunnelDto,
};
const DEFAULT_HISTORY_LIMIT: usize = 50;
pub async fn serve(state: ServerState, path: &Path) -> Result<()> {
let listener = bind(path)?;
info!(socket = %path.display(), "admin api listening");
let router = router(state);
let path_owned: PathBuf = path.to_path_buf();
let result = accept_loop(listener, router).await;
if let Err(e) = std::fs::remove_file(&path_owned) {
debug!(error = %e, "failed to unlink admin socket on shutdown");
}
result
}
pub fn bind(path: &Path) -> Result<UnixListener> {
if path.exists() {
std::fs::remove_file(path)
.with_context(|| format!("removing stale admin socket {}", path.display()))?;
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating admin socket directory {}", parent.display()))?;
}
}
let listener = UnixListener::bind(path)
.with_context(|| format!("binding admin socket {}", path.display()))?;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)
.with_context(|| format!("chmod 0600 {}", path.display()))?;
Ok(listener)
}
async fn accept_loop(listener: UnixListener, router: Router) -> Result<()> {
loop {
let (stream, _addr) = match listener.accept().await {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "admin accept error");
continue;
}
};
let svc = router.clone();
tokio::spawn(async move {
let io = TokioIo::new(stream);
if let Err(e) = http1::Builder::new()
.serve_connection(io, TowerToHyperService::new(svc))
.await
{
debug!(error = %e, "admin connection ended");
}
});
}
}
fn router(state: ServerState) -> Router {
Router::new()
.route("/api/v1/server", get(get_server))
.route("/api/v1/clients", get(list_clients))
.route("/api/v1/clients/:id", get(get_client))
.route("/api/v1/clients/:id/tunnels", get(list_client_tunnels))
.route("/api/v1/clients/:id/conns", get(list_client_conns))
.route("/api/v1/tunnels", get(list_tunnels))
.route("/api/v1/tunnels/:id", get(get_tunnel))
.route("/api/v1/tunnels/:id/conns", get(list_tunnel_conns))
.route("/api/v1/conns", get(list_conns))
.route("/api/v1/conns/:id", get(get_conn))
.route("/api/v1/history", get(list_history))
.with_state(state)
}
async fn get_server(State(state): State<ServerState>) -> Json<ServerInfoDto> {
Json(server_info(&state))
}
async fn list_clients(State(state): State<ServerState>) -> Json<Vec<ClientSummaryDto>> {
let clients = state.clients_snapshot();
let mut out: Vec<ClientSummaryDto> = clients
.iter()
.map(|c| ClientSummaryDto::from_entry(c))
.collect();
out.sort_by_key(|c| c.id);
Json(out)
}
async fn get_client(
State(state): State<ServerState>,
AxumPath(id): AxumPath<u64>,
) -> Result<Json<ClientDetailDto>, ApiError> {
let entry = state.client(id).ok_or(ApiError::NotFound)?;
let summary = ClientSummaryDto::from_entry(&entry);
let mut tunnels: Vec<TunnelDto> = entry
.tunnels
.iter()
.map(|t| TunnelDto::from_entry(t.value()))
.collect();
tunnels.sort_by_key(|t| t.id);
Ok(Json(ClientDetailDto { summary, tunnels }))
}
async fn list_client_tunnels(
State(state): State<ServerState>,
AxumPath(id): AxumPath<u64>,
) -> Result<Json<Vec<TunnelDto>>, ApiError> {
let entry = state.client(id).ok_or(ApiError::NotFound)?;
let mut out: Vec<TunnelDto> = entry
.tunnels
.iter()
.map(|t| TunnelDto::from_entry(t.value()))
.collect();
out.sort_by_key(|t| t.id);
Ok(Json(out))
}
async fn list_tunnels(State(state): State<ServerState>) -> Json<Vec<TunnelDto>> {
let mut out: Vec<TunnelDto> = state
.tunnels_snapshot()
.iter()
.map(|t| TunnelDto::from_entry(t))
.collect();
out.sort_by_key(|t| (t.client_id, t.id));
Json(out)
}
async fn get_tunnel(
State(state): State<ServerState>,
AxumPath(id): AxumPath<u64>,
) -> Result<Json<TunnelDetailDto>, ApiError> {
let entry = state.tunnel(id).ok_or(ApiError::NotFound)?;
let summary = TunnelDto::from_entry(&entry);
let mut conns: Vec<ConnDto> = entry
.conns
.iter()
.map(|c| ConnDto::from_entry(c.value()))
.collect();
conns.sort_by_key(|c| c.id);
Ok(Json(TunnelDetailDto { summary, conns }))
}
async fn list_tunnel_conns(
State(state): State<ServerState>,
AxumPath(id): AxumPath<u64>,
) -> Result<Json<Vec<ConnDto>>, ApiError> {
let entry = state.tunnel(id).ok_or(ApiError::NotFound)?;
let mut out: Vec<ConnDto> = entry
.conns
.iter()
.map(|c| ConnDto::from_entry(c.value()))
.collect();
out.sort_by_key(|c| c.id);
Ok(Json(out))
}
async fn list_conns(State(state): State<ServerState>) -> Json<Vec<ConnDto>> {
let mut out: Vec<ConnDto> = state
.conns_snapshot()
.iter()
.map(|c| ConnDto::from_entry(c))
.collect();
out.sort_by_key(|c| (c.client_id, c.tunnel_id, c.id));
Json(out)
}
async fn get_conn(
State(state): State<ServerState>,
AxumPath(id): AxumPath<u64>,
) -> Result<Json<ConnDto>, ApiError> {
let entry = state.conn(id).ok_or(ApiError::NotFound)?;
Ok(Json(ConnDto::from_entry(&entry)))
}
async fn list_client_conns(
State(state): State<ServerState>,
AxumPath(id): AxumPath<u64>,
) -> Result<Json<Vec<ConnDto>>, ApiError> {
let client = state.client(id).ok_or(ApiError::NotFound)?;
let mut out: Vec<ConnDto> = Vec::new();
for t in client.tunnels.iter() {
for c in t.value().conns.iter() {
out.push(ConnDto::from_entry(c.value()));
}
}
out.sort_by_key(|c| (c.tunnel_id, c.id));
Ok(Json(out))
}
#[derive(Debug, Deserialize)]
struct HistoryQuery {
limit: Option<usize>,
}
async fn list_history(
State(state): State<ServerState>,
Query(q): Query<HistoryQuery>,
) -> Json<Vec<state::HistoryEntry>> {
let limit = q.limit.unwrap_or(DEFAULT_HISTORY_LIMIT);
Json(state.history_snapshot(limit))
}
enum ApiError {
NotFound,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
ApiError::NotFound => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "not found"})),
)
.into_response(),
}
}
}
#[cfg(unix)]
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn socket_is_owner_only() {
let socket = short_socket_path("admin-mode");
let listener = bind(&socket).unwrap();
let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "socket file mode should be 0600, got {mode:o}");
drop(listener);
let _ = std::fs::remove_file(&socket);
}
fn short_socket_path(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
PathBuf::from(format!(
"/tmp/rusnel-{}-{}-{}.sock",
label,
std::process::id(),
nanos
))
}
}