Replace Unix socket admin API with authenticated HTTP admin API (ADR-028)

Remove src/admin/socket.rs and replace with Bearer token HTTP auth on the
health check listener (port 9900). New src/admin/auth.rs provides SHA-256
key hashing with constant-time comparison; src/admin/handler.rs implements
/admin/reload, /admin/status, and /admin/rotate-key. Admin endpoints return
404 when disabled (empty admin_key_path), 401 on bad auth. Config field
renamed admin_socket_path → admin_key_path. Deployment files updated for
key file mount instead of socket directory.
This commit is contained in:
glm-5.1 committed 2026-06-15 06:01:35 +00:00
1 parent cfe0ae522d
commit 3ea3f56de7
19 files changed
+924 -907

No files matched your search

Generated
+139
View File
@@ -339,6 +339,15 @@ version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "blocking"
version = "1.6.2"
@@ -483,12 +492,31 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@@ -532,6 +560,16 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
@@ -755,6 +793,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -827,6 +875,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "1.4.2"
@@ -1473,6 +1527,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1497,6 +1560,35 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rcgen"
version = "0.13.2"
@@ -1614,10 +1706,12 @@ dependencies = [
"clap",
"dashmap",
"futures",
"hex",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"rand",
"rcgen",
"reqwest",
"rustls",
@@ -1628,7 +1722,9 @@ dependencies = [
"sd-notify",
"serde",
"serde_json",
"sha2",
"signal-hook",
"subtle",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -1897,6 +1993,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -2367,6 +2474,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -2415,6 +2528,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
@@ -2799,6 +2918,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
+4
View File
@@ -39,6 +39,10 @@ futures = "=0.3.31"
dashmap = "=6.1"
serde_json = "=1.0.140"
sd-notify = "=0.4"
subtle = "2"
sha2 = "0.10"
rand = "0.9"
hex = "0.4"
[dev-dependencies]
rcgen = "=0.13"
+19 -7
View File
@@ -20,7 +20,13 @@ docker build -t reverse-proxy .
sudo mkdir -p /etc/reverse-proxy
sudo mkdir -p /var/lib/reverse-proxy/acme-cache
sudo mkdir -p /var/log/reverse-proxy
sudo mkdir -p /run/reverse-proxy
```
For the admin API, create the admin key file:
```bash
openssl rand -hex 32 | sudo tee /etc/reverse-proxy/admin-key
sudo chmod 600 /etc/reverse-proxy/admin-key
```
### 3. Create the config file
@@ -31,6 +37,7 @@ Let's Encrypt:
```toml
allow_wildcard_bind = true
health_check_port = 9900
admin_key_path = "/etc/reverse-proxy/admin-key"
[logging]
level = "info"
@@ -143,7 +150,6 @@ sudo cp deploy/reverse-proxy.service /etc/systemd/system/
sudo mkdir -p /etc/reverse-proxy
sudo mkdir -p /var/lib/reverse-proxy/acme-cache
sudo mkdir -p /var/log/reverse-proxy
sudo mkdir -p /run/reverse-proxy
```
Create `/etc/reverse-proxy/config.toml` (see example configs in the main
@@ -153,6 +159,7 @@ README). With a bare metal deployment, use the server's actual IP as
```toml
# Single-domain bare metal example
health_check_port = 9900
admin_key_path = "/etc/reverse-proxy/admin-key"
[logging]
level = "info"
@@ -209,11 +216,15 @@ journalctl -u reverse-proxy -f
# Via SIGHUP (no feedback)
sudo kill -SIGHUP $(pidof reverse-proxy)
# Via admin socket (returns success/failure JSON)
echo "reload" | socat - UNIX-CONNECT:/run/reverse-proxy/admin.sock
# Via admin HTTP API (returns success/failure JSON)
ADMIN_KEY=$(cat /etc/reverse-proxy/admin-key)
curl -X POST -H "Authorization: Bearer $ADMIN_KEY" http://127.0.0.1:9900/admin/reload
# Check status
echo "status" | socat - UNIX-CONNECT:/run/reverse-proxy/admin.sock
curl -H "Authorization: Bearer $ADMIN_KEY" http://127.0.0.1:9900/admin/status
# Rotate admin key (returns new key, old key is invalidated)
curl -X POST -H "Authorization: Bearer $ADMIN_KEY" http://127.0.0.1:9900/admin/rotate-key
```
## Multi-Domain Setup
@@ -295,8 +306,9 @@ certificate store.
This prevents accidental exposure on unintended interfaces.
- The health check endpoint binds to `127.0.0.1` only and is never exposed on
public ports.
- The admin socket should be protected by file permissions. It defaults to
`/run/reverse-proxy/admin.sock`.
- The admin API requires Bearer token authentication on port 9900 (localhost
only). Set `admin_key_path` in config to enable, or leave it empty to disable
admin endpoints entirely. See ADR-028 for details.
- Rate limiting is global per-IP (IPv4: /32, IPv6: /64) in the current
version. Per-site rate limits may be added later.
- All log output disables ANSI escape codes for fail2ban and container
+1 -1
View File
@@ -10,7 +10,7 @@ services:
- /etc/reverse-proxy/config.toml:/etc/reverse-proxy/config.toml:ro
- /var/lib/reverse-proxy/acme-cache:/var/lib/reverse-proxy/acme-cache
- /var/log/reverse-proxy:/var/log/reverse-proxy
- /run/reverse-proxy:/run/reverse-proxy
- /etc/reverse-proxy/admin-key:/etc/reverse-proxy/admin-key:ro
networks:
- proxy-net
healthcheck:
+1 -1
View File
@@ -15,7 +15,7 @@ NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/reverse-proxy /var/log/reverse-proxy
ReadWritePaths=/var/lib/reverse-proxy /var/log/reverse-proxy /etc/reverse-proxy
# ACME challenge cache directory
StateDirectory=reverse-proxy
+141
View File
@@ -0,0 +1,141 @@
use std::sync::Arc;
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use arc_swap::ArcSwap;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use tracing::warn;
#[derive(Debug, thiserror::Error)]
pub enum AdminKeyError {
#[error("admin key file is not readable: {0}")]
NotReadable(String),
#[error("admin key file is empty")]
EmptyFile,
}
pub struct AdminAuthConfig {
pub admin_key_hash: [u8; 32],
}
pub fn load_admin_key(path: &str) -> Result<Option<[u8; 32]>, AdminKeyError> {
if path.is_empty() {
return Ok(None);
}
let key_content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
warn!("admin key file '{}' not readable, disabling admin endpoints: {}", path, e);
return Ok(None);
}
};
let key_trimmed = key_content.trim();
if key_trimmed.is_empty() {
return Err(AdminKeyError::EmptyFile);
}
let mut hasher = Sha256::new();
hasher.update(key_trimmed.as_bytes());
let hash: [u8; 32] = hasher.finalize().into();
Ok(Some(hash))
}
pub async fn admin_auth_middleware(
State(key_hash): State<Arc<ArcSwap<[u8; 32]>>>,
req: Request<Body>,
next: Next,
) -> Response {
let auth_header = req.headers().get("Authorization").and_then(|v| v.to_str().ok());
let token = match auth_header {
Some(h) if h.starts_with("Bearer ") => &h[7..],
_ => return StatusCode::UNAUTHORIZED.into_response(),
};
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let provided_hash: [u8; 32] = hasher.finalize().into();
let stored_hash = key_hash.load();
if stored_hash.as_ref().ct_eq(&provided_hash).into() {
next.run(req).await
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_admin_key_empty_path_returns_none() {
let result = load_admin_key("").unwrap();
assert!(result.is_none());
}
#[test]
fn load_admin_key_missing_file_returns_none() {
let result = load_admin_key("/nonexistent/admin-key").unwrap();
assert!(result.is_none());
}
#[test]
fn load_admin_key_valid_file_returns_hash() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("admin-key");
std::fs::write(&key_path, "test-secret-key\n").unwrap();
let result = load_admin_key(key_path.to_str().unwrap()).unwrap();
assert!(result.is_some());
let mut hasher = Sha256::new();
hasher.update(b"test-secret-key");
let expected: [u8; 32] = hasher.finalize().into();
assert_eq!(result.unwrap(), expected);
}
#[test]
fn load_admin_key_trims_whitespace() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("admin-key");
std::fs::write(&key_path, " test-secret-key \n").unwrap();
let result = load_admin_key(key_path.to_str().unwrap()).unwrap();
assert!(result.is_some());
let mut hasher = Sha256::new();
hasher.update(b"test-secret-key");
let expected: [u8; 32] = hasher.finalize().into();
assert_eq!(result.unwrap(), expected);
}
#[test]
fn load_admin_key_empty_file_returns_error() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("admin-key");
std::fs::write(&key_path, " \n").unwrap();
let result = load_admin_key(key_path.to_str().unwrap());
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AdminKeyError::EmptyFile));
}
#[test]
fn hash_is_deterministic() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("admin-key");
std::fs::write(&key_path, "same-key\n").unwrap();
let hash1 = load_admin_key(key_path.to_str().unwrap()).unwrap().unwrap();
let hash2 = load_admin_key(key_path.to_str().unwrap()).unwrap().unwrap();
assert_eq!(hash1, hash2);
}
}
+138
View File
@@ -0,0 +1,138 @@
use std::sync::Arc;
use std::time::Instant;
use arc_swap::ArcSwap;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use rand::RngCore;
use serde::Serialize;
use crate::config::ConfigReloadHandle;
#[derive(Clone)]
pub struct AdminState {
pub reload_handle: Arc<ConfigReloadHandle>,
pub config_path: String,
pub start_time: Instant,
pub key_hash: Arc<ArcSwap<[u8; 32]>>,
}
#[derive(Serialize)]
pub struct ReloadResponse {
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Serialize)]
pub struct StatusResponse {
pub status: &'static str,
pub uptime_secs: u64,
pub sites: usize,
}
#[derive(Serialize)]
pub struct RotateKeyResponse {
pub status: &'static str,
pub key: String,
}
pub async fn reload_handler(State(state): State<Arc<AdminState>>) -> impl IntoResponse {
let config_content = match tokio::fs::read_to_string(&state.config_path).await {
Ok(content) => content,
Err(e) => {
tracing::error!("admin reload: failed to read config file: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ReloadResponse {
status: "error",
message: Some("reload failed".to_string()),
}),
);
}
};
let full_config = match crate::config::FullConfig::parse(&config_content) {
Ok(c) => c,
Err(e) => {
tracing::error!("admin reload: failed to parse config file: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ReloadResponse {
status: "error",
message: Some("reload failed".to_string()),
}),
);
}
};
let (new_static, new_dynamic) = full_config.into_static_and_dynamic();
match state.reload_handle.reload(new_static, new_dynamic).await {
Ok(changed_fields) => {
if !changed_fields.is_empty() {
tracing::warn!(
"static config fields changed (restart required): {}",
changed_fields.join(", ")
);
}
tracing::info!(event = "CONFIG_RELOAD", status = "success", source = "admin");
(
StatusCode::OK,
Json(ReloadResponse {
status: "ok",
message: None,
}),
)
}
Err(e) => {
tracing::error!("admin reload: config reload failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ReloadResponse {
status: "error",
message: Some("reload failed".to_string()),
}),
)
}
}
}
pub async fn status_handler(State(state): State<Arc<AdminState>>) -> impl IntoResponse {
let config = state.reload_handle.load();
let uptime_secs = state.start_time.elapsed().as_secs();
(
StatusCode::OK,
Json(StatusResponse {
status: "ok",
uptime_secs,
sites: config.sites.len(),
}),
)
}
pub async fn rotate_key_handler(State(state): State<Arc<AdminState>>) -> impl IntoResponse {
let mut new_key = [0u8; 32];
rand::rng().fill_bytes(&mut new_key);
let new_key_hex = hex::encode(new_key);
let mut hasher = sha2::Sha256::new();
use sha2::Digest;
hasher.update(new_key_hex.as_bytes());
let new_hash: [u8; 32] = hasher.finalize().into();
state.key_hash.store(Arc::new(new_hash));
tracing::info!(event = "ADMIN_KEY_ROTATION", status = "success");
(
StatusCode::OK,
Json(RotateKeyResponse {
status: "ok",
key: new_key_hex,
}),
)
}
+4 -2
View File
@@ -1,3 +1,5 @@
pub mod socket;
pub mod auth;
pub mod handler;
pub use socket::{start_admin_socket, AdminSocket, AdminSocketError};
pub use auth::{AdminAuthConfig, AdminKeyError, load_admin_key, admin_auth_middleware};
pub use handler::{AdminState, ReloadResponse, StatusResponse, RotateKeyResponse, reload_handler, status_handler, rotate_key_handler};
-826
View File
@@ -1,826 +0,0 @@
use std::io;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
use serde::Serialize;
use serde_json;
use tokio::net::UnixListener;
use tokio::sync::Mutex;
use tracing::{info, warn};
use crate::shutdown::GracefulShutdown;
use crate::config::ConfigReloadHandle;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AdminSocketError {
#[error("admin socket disabled (empty path)")]
Disabled,
#[error("socket file exists and is in use by another process: {0}")]
SocketInUse(String),
#[error("failed to bind admin socket: {0}")]
BindFailed(String),
#[error("IO error: {0}")]
Io(#[from] io::Error),
}
#[derive(Serialize)]
struct OkResponse {
status: &'static str,
}
#[derive(Serialize)]
struct OkWithUptimeResponse {
status: &'static str,
uptime_secs: u64,
sites: usize,
}
#[derive(Serialize)]
struct ErrorResponse {
status: &'static str,
message: String,
}
pub struct AdminSocket {
socket_path: String,
reload_handle: Arc<ConfigReloadHandle>,
config_path: String,
start_time: Instant,
reload_mutex: Arc<Mutex<()>>,
}
impl AdminSocket {
pub fn new(
socket_path: String,
reload_handle: Arc<ConfigReloadHandle>,
config_path: String,
) -> Self {
let reload_mutex = Arc::new(Mutex::new(()));
Self {
socket_path,
reload_handle,
config_path,
start_time: Instant::now(),
reload_mutex,
}
}
#[cfg(test)]
pub fn reload_mutex(&self) -> Arc<Mutex<()>> {
self.reload_mutex.clone()
}
}
pub async fn start_admin_socket(
admin_socket: Arc<AdminSocket>,
shutdown: Arc<GracefulShutdown>,
) -> Result<(), AdminSocketError> {
if admin_socket.socket_path.is_empty() {
info!("admin socket disabled (empty path)");
return Err(AdminSocketError::Disabled);
}
let socket_path = &admin_socket.socket_path;
cleanup_stale_socket(socket_path).await?;
let listener = match UnixListener::bind(socket_path) {
Ok(l) => l,
Err(e) => {
if e.kind() == io::ErrorKind::AddrInUse {
warn!(
"admin socket path {} is in use by another process, disabling admin socket",
socket_path
);
return Err(AdminSocketError::SocketInUse(socket_path.clone()));
}
return Err(AdminSocketError::BindFailed(e.to_string()));
}
};
info!("admin socket listening on {}", socket_path);
let mut shutdown_rx = shutdown.subscribe();
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, _addr)) => {
let admin_socket = admin_socket.clone();
tokio::spawn(async move {
handle_connection(stream, admin_socket).await;
});
}
Err(e) => {
warn!("failed to accept admin socket connection: {}", e);
}
}
}
_ = shutdown_rx.changed() => {
info!("admin socket shutting down");
break;
}
}
}
cleanup_socket_file(socket_path).await;
Ok(())
}
async fn cleanup_socket_file(path: &str) {
if Path::new(path).exists() {
if let Err(e) = tokio::fs::remove_file(path).await {
warn!("failed to remove admin socket file {}: {}", path, e);
}
}
}
async fn cleanup_stale_socket(path: &str) -> Result<(), AdminSocketError> {
let socket_path = Path::new(path);
if !socket_path.exists() {
return Ok(());
}
if is_socket_active(path).await {
warn!(
"socket file {} exists and another process is listening; disabling admin socket",
path
);
return Err(AdminSocketError::SocketInUse(path.to_string()));
}
warn!("removing stale socket file: {}", path);
tokio::fs::remove_file(path)
.await
.map_err(AdminSocketError::Io)
}
async fn is_socket_active(path: &str) -> bool {
tokio::net::UnixStream::connect(path).await.is_ok()
}
async fn handle_connection(stream: tokio::net::UnixStream, admin_socket: Arc<AdminSocket>) {
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader.take(4096));
let mut line = String::new();
let read_result = tokio::time::timeout(
std::time::Duration::from_secs(5),
reader.read_line(&mut line),
)
.await;
match read_result {
Ok(Ok(0)) | Ok(Err(_)) => {
let _ = writer
.write_all(
format!(
"{}\n",
serde_json::to_string(&ErrorResponse {
status: "error",
message: "invalid input".to_string(),
})
.unwrap()
)
.as_bytes(),
)
.await;
return;
}
Err(_) => {
tracing::debug!("admin socket connection timed out");
let _ = writer
.write_all(
format!(
"{}\n",
serde_json::to_string(&ErrorResponse {
status: "error",
message: "read timeout".to_string(),
})
.unwrap()
)
.as_bytes(),
)
.await;
return;
}
Ok(Ok(n)) => {
if !line.ends_with('\n') && n > 0 {
tracing::warn!("admin socket command exceeded 4096 byte limit");
let _ = writer
.write_all(
format!(
"{}\n",
serde_json::to_string(&ErrorResponse {
status: "error",
message: "command too long".to_string(),
})
.unwrap()
)
.as_bytes(),
)
.await;
return;
}
}
}
let command = line.trim();
let response = match command {
"reload" => handle_reload(&admin_socket).await,
"status" => handle_status(&admin_socket).await,
"" => serde_json::to_string(&ErrorResponse {
status: "error",
message: "invalid input".to_string(),
})
.unwrap(),
_ => serde_json::to_string(&ErrorResponse {
status: "error",
message: format!("unknown command: {}", command),
})
.unwrap(),
};
let _ = writer.write_all(format!("{}\n", response).as_bytes()).await;
}
async fn handle_reload(admin_socket: &Arc<AdminSocket>) -> String {
let _guard = admin_socket.reload_mutex.lock().await;
let config_content = match tokio::fs::read_to_string(&admin_socket.config_path).await {
Ok(content) => content,
Err(e) => {
return serde_json::to_string(&ErrorResponse {
status: "error",
message: format!("failed to read config file: {}", e),
})
.unwrap();
}
};
let full_config = match crate::config::FullConfig::parse(&config_content) {
Ok(c) => c,
Err(e) => {
return serde_json::to_string(&ErrorResponse {
status: "error",
message: format!("failed to parse config file: {}", e),
})
.unwrap();
}
};
let (new_static, new_dynamic) = full_config.into_static_and_dynamic();
match admin_socket
.reload_handle
.reload(new_static, new_dynamic)
.await
{
Ok(changed_fields) => {
if !changed_fields.is_empty() {
tracing::warn!(
"static config fields changed (restart required): {}",
changed_fields.join(", ")
);
}
tracing::info!(event = "CONFIG_RELOAD", status = "success");
serde_json::to_string(&OkResponse { status: "ok" }).unwrap()
}
Err(e) => {
tracing::error!("config reload failed: {}", e);
serde_json::to_string(&ErrorResponse {
status: "error",
message: e.to_string(),
})
.unwrap()
}
}
}
async fn handle_status(admin_socket: &Arc<AdminSocket>) -> String {
let config = admin_socket.reload_handle.load();
let uptime_secs = admin_socket.start_time.elapsed().as_secs();
serde_json::to_string(&OkWithUptimeResponse {
status: "ok",
uptime_secs,
sites: config.sites.len(),
})
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_fixtures;
use std::time::Duration;
fn create_test_admin_socket(dir: &std::path::Path) -> AdminSocket {
let config_arc = Arc::new(arc_swap::ArcSwap::from_pointee(
test_fixtures::test_dynamic_config(),
));
let static_config = test_fixtures::test_static_config();
let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config));
AdminSocket::new(
dir.join("admin.sock").to_string_lossy().to_string(),
reload_handle,
dir.join("config.toml").to_string_lossy().to_string(),
)
}
#[tokio::test]
async fn test_status_command() {
let dir = tempfile::tempdir().unwrap();
let admin_socket = Arc::new(create_test_admin_socket(dir.path()));
let socket_path = dir.path().join("admin.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let admin_socket_clone = admin_socket.clone();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
handle_connection(stream, admin_socket_clone).await;
});
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
stream.write_all(b"status\n").await.unwrap();
stream.shutdown().await.unwrap();
let mut response = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut response).await.unwrap();
handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(parsed["status"], "ok");
assert!(parsed["uptime_secs"].is_number());
assert!(parsed["sites"].is_number());
}
#[tokio::test]
async fn test_unknown_command() {
let dir = tempfile::tempdir().unwrap();
let admin_socket = Arc::new(create_test_admin_socket(dir.path()));
let socket_path = dir.path().join("admin.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let admin_socket_clone = admin_socket.clone();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
handle_connection(stream, admin_socket_clone).await;
});
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
stream.write_all(b"foo\n").await.unwrap();
stream.shutdown().await.unwrap();
let mut response = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut response).await.unwrap();
handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(parsed["status"], "error");
assert_eq!(parsed["message"], "unknown command: foo");
}
#[tokio::test]
async fn test_empty_input() {
let dir = tempfile::tempdir().unwrap();
let admin_socket = Arc::new(create_test_admin_socket(dir.path()));
let socket_path = dir.path().join("admin.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let admin_socket_clone = admin_socket.clone();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
handle_connection(stream, admin_socket_clone).await;
});
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
stream.write_all(b"\n").await.unwrap();
stream.shutdown().await.unwrap();
let mut response = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut response).await.unwrap();
handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(parsed["status"], "error");
assert_eq!(parsed["message"], "invalid input");
}
#[tokio::test]
async fn test_reload_command_missing_config_file() {
let dir = tempfile::tempdir().unwrap();
let admin_socket = Arc::new(create_test_admin_socket(dir.path()));
let socket_path = dir.path().join("admin.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let admin_socket_clone = admin_socket.clone();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
handle_connection(stream, admin_socket_clone).await;
});
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
stream.write_all(b"reload\n").await.unwrap();
stream.shutdown().await.unwrap();
let mut response = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut response).await.unwrap();
handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(parsed["status"], "error");
assert!(parsed["message"]
.as_str()
.unwrap()
.contains("failed to read config file"));
}
#[tokio::test]
async fn test_reload_command_success() {
let dir = tempfile::tempdir().unwrap();
let config_content = r#"
health_check_port = 9900
admin_socket_path = "/tmp/test-admin.sock"
[logging]
level = "info"
format = "text"
[rate_limit]
requests_per_second = 10
burst = 20
[body]
limit_bytes = 104857600
[[listeners]]
bind_addr = "127.0.0.1"
http_port = 80
https_port = 443
[listeners.tls]
mode = "acme"
acme_domains = ["test.local"]
acme_cache_dir = "/tmp/acme-cache"
acme_directory = "staging"
acme_contact = "mailto:admin@test.local"
[[listeners.sites]]
host = "test.local"
upstream = "127.0.0.1:8080"
"#;
tokio::fs::write(dir.path().join("config.toml"), config_content)
.await
.unwrap();
let config_arc = Arc::new(arc_swap::ArcSwap::from_pointee(
test_fixtures::test_dynamic_config(),
));
let static_config = test_fixtures::test_static_config();
let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config));
let admin_socket = Arc::new(AdminSocket::new(
dir.path().join("admin.sock").to_string_lossy().to_string(),
reload_handle,
dir.path().join("config.toml").to_string_lossy().to_string(),
));
let socket_path = dir.path().join("admin.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let admin_socket_clone = admin_socket.clone();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
handle_connection(stream, admin_socket_clone).await;
});
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
stream.write_all(b"reload\n").await.unwrap();
stream.shutdown().await.unwrap();
let mut response = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut response).await.unwrap();
handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(parsed["status"], "ok");
}
#[tokio::test]
async fn test_cleanup_stale_socket_removes_file() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("test.sock");
std::fs::write(&socket_path, "stale").unwrap();
assert!(socket_path.exists());
cleanup_stale_socket(socket_path.to_str().unwrap())
.await
.unwrap();
assert!(!socket_path.exists());
}
#[tokio::test]
async fn test_cleanup_stale_socket_no_file() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("nonexistent.sock");
cleanup_stale_socket(socket_path.to_str().unwrap())
.await
.unwrap();
}
#[tokio::test]
async fn test_start_admin_socket_disabled() {
let dir = tempfile::tempdir().unwrap();
let config_arc = Arc::new(arc_swap::ArcSwap::from_pointee(
test_fixtures::test_dynamic_config(),
));
let static_config = test_fixtures::test_static_config();
let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config));
let admin_socket = Arc::new(AdminSocket::new(
String::new(),
reload_handle,
dir.path().join("config.toml").to_string_lossy().to_string(),
));
let result = start_admin_socket(admin_socket, Arc::new(GracefulShutdown::new(30))).await;
assert!(matches!(result, Err(AdminSocketError::Disabled)));
}
#[tokio::test]
async fn test_start_admin_socket_detects_active_socket() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("admin.sock");
let _listener = UnixListener::bind(&socket_path).unwrap();
let config_arc = Arc::new(arc_swap::ArcSwap::from_pointee(
test_fixtures::test_dynamic_config(),
));
let static_config = test_fixtures::test_static_config();
let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config));
let admin_socket = Arc::new(AdminSocket::new(
socket_path.to_string_lossy().to_string(),
reload_handle,
dir.path().join("config.toml").to_string_lossy().to_string(),
));
let result = start_admin_socket(admin_socket, Arc::new(GracefulShutdown::new(30))).await;
assert!(matches!(result, Err(AdminSocketError::SocketInUse(_))));
}
#[tokio::test]
async fn test_reload_serialized_with_mutex() {
let dir = tempfile::tempdir().unwrap();
let config_content = r#"
health_check_port = 9900
admin_socket_path = "/tmp/test-admin.sock"
[logging]
level = "info"
format = "text"
[rate_limit]
requests_per_second = 10
burst = 20
[body]
limit_bytes = 104857600
[[listeners]]
bind_addr = "127.0.0.1"
http_port = 80
https_port = 443
[listeners.tls]
mode = "acme"
acme_domains = ["test.local"]
acme_cache_dir = "/tmp/acme-cache"
acme_directory = "staging"
acme_contact = "mailto:admin@test.local"
[[listeners.sites]]
host = "test.local"
upstream = "127.0.0.1:8080"
"#;
tokio::fs::write(dir.path().join("config.toml"), config_content)
.await
.unwrap();
let config_arc = Arc::new(arc_swap::ArcSwap::from_pointee(
test_fixtures::test_dynamic_config(),
));
let static_config = test_fixtures::test_static_config();
let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config));
let admin_socket = Arc::new(AdminSocket::new(
dir.path().join("admin.sock").to_string_lossy().to_string(),
reload_handle,
dir.path().join("config.toml").to_string_lossy().to_string(),
));
let mutex = admin_socket.reload_mutex();
let guard = mutex.lock().await;
let socket_path = dir.path().join("admin.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let admin_socket_clone = admin_socket.clone();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
handle_connection(stream, admin_socket_clone).await;
});
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
stream.write_all(b"reload\n").await.unwrap();
stream.shutdown().await.unwrap();
let mut response = String::new();
let mut reader = BufReader::new(stream);
tokio::select! {
_ = reader.read_line(&mut response) => {},
_ = tokio::time::sleep(Duration::from_millis(500)) => {
drop(guard);
reader.read_line(&mut response).await.unwrap();
}
}
handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(parsed["status"], "ok");
}
#[tokio::test]
async fn test_status_returns_sites_count() {
let dir = tempfile::tempdir().unwrap();
let admin_socket = Arc::new(create_test_admin_socket(dir.path()));
let socket_path = dir.path().join("admin.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let admin_socket_clone = admin_socket.clone();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
handle_connection(stream, admin_socket_clone).await;
});
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
stream.write_all(b"status\n").await.unwrap();
stream.shutdown().await.unwrap();
let mut response = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut response).await.unwrap();
handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["sites"], 1);
assert!(parsed["uptime_secs"].is_number());
}
#[tokio::test]
async fn test_read_timeout() {
let dir = tempfile::tempdir().unwrap();
let admin_socket = Arc::new(create_test_admin_socket(dir.path()));
let socket_path = dir.path().join("admin.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let admin_socket_clone = admin_socket.clone();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
handle_connection(stream, admin_socket_clone).await;
});
let stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
use tokio::io::{AsyncBufReadExt, BufReader};
let mut response = String::new();
let mut reader = BufReader::new(stream);
let result =
tokio::time::timeout(Duration::from_secs(10), reader.read_line(&mut response)).await;
handle.await.unwrap();
assert!(result.is_ok());
let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(parsed["status"], "error");
assert_eq!(parsed["message"], "read timeout");
}
#[tokio::test]
async fn test_command_too_long() {
let dir = tempfile::tempdir().unwrap();
let admin_socket = Arc::new(create_test_admin_socket(dir.path()));
let socket_path = dir.path().join("admin.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let admin_socket_clone = admin_socket.clone();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
handle_connection(stream, admin_socket_clone).await;
});
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let long_data = "A".repeat(5000);
stream.write_all(long_data.as_bytes()).await.unwrap();
stream.shutdown().await.unwrap();
let mut response = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut response).await.unwrap();
handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(parsed["status"], "error");
assert_eq!(parsed["message"], "command too long");
}
#[tokio::test]
async fn test_command_at_limit_boundary() {
let dir = tempfile::tempdir().unwrap();
let admin_socket = Arc::new(create_test_admin_socket(dir.path()));
let socket_path = dir.path().join("admin.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let admin_socket_clone = admin_socket.clone();
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
handle_connection(stream, admin_socket_clone).await;
});
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let at_limit = format!("{}\n", "A".repeat(4095));
stream.write_all(at_limit.as_bytes()).await.unwrap();
stream.shutdown().await.unwrap();
let mut response = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut response).await.unwrap();
handle.await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(parsed["status"], "error");
assert_eq!(
parsed["message"]
.as_str()
.unwrap()
.starts_with("unknown command:"),
true
);
}
}
+2 -2
View File
@@ -170,8 +170,8 @@ fn diff_static_config(old: &StaticConfig, new: &StaticConfig) -> Vec<String> {
if old.health_check_port != new.health_check_port {
changes.push("health_check_port".to_string());
}
if old.admin_socket_path != new.admin_socket_path {
changes.push("admin_socket_path".to_string());
if old.admin_key_path != new.admin_key_path {
changes.push("admin_key_path".to_string());
}
if old.shutdown_timeout_secs != new.shutdown_timeout_secs {
changes.push("shutdown_timeout_secs".to_string());
+3 -3
View File
@@ -20,8 +20,8 @@ pub struct FullConfig {
pub allow_wildcard_bind: bool,
#[serde(default = "static_config::default_health_check_port")]
pub health_check_port: u16,
#[serde(default = "static_config::default_admin_socket_path")]
pub admin_socket_path: String,
#[serde(default = "static_config::default_admin_key_path")]
pub admin_key_path: String,
#[serde(default = "static_config::default_shutdown_timeout_secs")]
pub shutdown_timeout_secs: u64,
#[serde(default)]
@@ -40,7 +40,7 @@ impl FullConfig {
listeners: self.listeners,
allow_wildcard_bind: self.allow_wildcard_bind,
health_check_port: self.health_check_port,
admin_socket_path: self.admin_socket_path,
admin_key_path: self.admin_key_path,
shutdown_timeout_secs: self.shutdown_timeout_secs,
logging: self.logging,
};
+12 -12
View File
@@ -7,8 +7,8 @@ pub struct StaticConfig {
pub allow_wildcard_bind: bool,
#[serde(default = "default_health_check_port")]
pub health_check_port: u16,
#[serde(default = "default_admin_socket_path")]
pub admin_socket_path: String,
#[serde(default = "default_admin_key_path")]
pub admin_key_path: String,
#[serde(default = "default_shutdown_timeout_secs")]
pub shutdown_timeout_secs: u64,
#[serde(default)]
@@ -19,8 +19,8 @@ pub fn default_health_check_port() -> u16 {
9900
}
pub fn default_admin_socket_path() -> String {
"/run/reverse-proxy/admin.sock".to_string()
pub fn default_admin_key_path() -> String {
"/etc/reverse-proxy/admin-key".to_string()
}
pub fn default_shutdown_timeout_secs() -> u64 {
@@ -103,7 +103,7 @@ mod tests {
fn multi_config_toml() -> &'static str {
r#"
health_check_port = 9900
admin_socket_path = "/run/reverse-proxy/admin.sock"
admin_key_path = "/etc/reverse-proxy/admin-key"
[logging]
level = "info"
@@ -152,7 +152,7 @@ upstream_scheme = "http"
fn shared_ip_san_toml() -> &'static str {
r#"
health_check_port = 9900
admin_socket_path = "/run/reverse-proxy/admin.sock"
admin_key_path = "/etc/reverse-proxy/admin-key"
[logging]
level = "info"
@@ -195,8 +195,8 @@ upstream = "127.0.0.1:8080"
allow_wildcard_bind: bool,
#[serde(default = "default_health_check_port")]
health_check_port: u16,
#[serde(default = "default_admin_socket_path")]
admin_socket_path: String,
#[serde(default = "default_admin_key_path")]
admin_key_path: String,
#[serde(default = "default_shutdown_timeout_secs")]
shutdown_timeout_secs: u64,
#[serde(default)]
@@ -213,7 +213,7 @@ upstream = "127.0.0.1:8080"
assert_eq!(config.listeners.len(), 2);
assert!(!config.allow_wildcard_bind);
assert_eq!(config.health_check_port, 9900);
assert_eq!(config.admin_socket_path, "/run/reverse-proxy/admin.sock");
assert_eq!(config.admin_key_path, "/etc/reverse-proxy/admin-key");
assert_eq!(config.shutdown_timeout_secs, 30);
assert_eq!(config.logging.level, "info");
assert_eq!(config.logging.format, "text");
@@ -291,8 +291,8 @@ acme_cache_dir = "/tmp/cache"
allow_wildcard_bind: bool,
#[serde(default = "default_health_check_port")]
health_check_port: u16,
#[serde(default = "default_admin_socket_path")]
admin_socket_path: String,
#[serde(default = "default_admin_key_path")]
admin_key_path: String,
#[serde(default = "default_shutdown_timeout_secs")]
shutdown_timeout_secs: u64,
#[serde(default)]
@@ -305,7 +305,7 @@ acme_cache_dir = "/tmp/cache"
assert!(!config.allow_wildcard_bind);
assert_eq!(config.health_check_port, 9900);
assert_eq!(config.admin_socket_path, "/run/reverse-proxy/admin.sock");
assert_eq!(config.admin_key_path, "/etc/reverse-proxy/admin-key");
assert_eq!(config.shutdown_timeout_secs, 30);
assert_eq!(config.logging.level, "info");
assert_eq!(config.logging.format, "text");
+1 -1
View File
@@ -20,7 +20,7 @@ pub fn test_static_config() -> StaticConfig {
}],
allow_wildcard_bind: false,
health_check_port: 9900,
admin_socket_path: "/tmp/reverse-proxy-test/admin.sock".to_string(),
admin_key_path: "/etc/reverse-proxy/admin-key".to_string(),
shutdown_timeout_secs: 30,
logging: LoggingConfig::default(),
}
+118 -3
View File
@@ -73,6 +73,10 @@ pub enum ValidationError {
UpstreamSchemeInvalid { host: String, scheme: String },
#[error("listener {bind_addr}: ACME mode requires acme_contact to be a valid mailto: URI (e.g., \"mailto:admin@example.com\")")]
AcmeContactInvalid { bind_addr: String },
#[error("admin_key_path must be an absolute path or empty, got '{path}'")]
AdminKeyPathNotAbsolute { path: String },
#[error("admin_key_path must not contain '..' path traversal: '{path}'")]
AdminKeyPathTraversal { path: String },
}
pub fn validate(
@@ -265,6 +269,19 @@ pub fn validate(
errors.push(ValidationError::BodyLimitBytesZero { value: 0 });
}
if !static_config.admin_key_path.is_empty() {
let path = static_config.admin_key_path.as_str();
if !path.starts_with('/') {
errors.push(ValidationError::AdminKeyPathNotAbsolute {
path: static_config.admin_key_path.clone(),
});
} else if path.contains("..") {
errors.push(ValidationError::AdminKeyPathTraversal {
path: static_config.admin_key_path.clone(),
});
}
}
if errors.is_empty() {
Ok(())
} else {
@@ -357,7 +374,7 @@ mod tests {
}],
allow_wildcard_bind: false,
health_check_port: 9900,
admin_socket_path: "/run/reverse-proxy/admin.sock".to_string(),
admin_key_path: "/etc/reverse-proxy/admin-key".to_string(),
shutdown_timeout_secs: 30,
logging: LoggingConfig::default(),
}
@@ -393,7 +410,7 @@ mod tests {
}],
allow_wildcard_bind: false,
health_check_port: 9900,
admin_socket_path: "/run/reverse-proxy/admin.sock".to_string(),
admin_key_path: "/etc/reverse-proxy/admin-key".to_string(),
shutdown_timeout_secs: 30,
logging: LoggingConfig::default(),
}
@@ -1081,7 +1098,7 @@ mod tests {
listeners: vec![],
allow_wildcard_bind: false,
health_check_port: 9900,
admin_socket_path: "/run/reverse-proxy/admin.sock".to_string(),
admin_key_path: "/etc/reverse-proxy/admin-key".to_string(),
shutdown_timeout_secs: 30,
logging: LoggingConfig::default(),
};
@@ -1189,4 +1206,102 @@ mod tests {
fn rule17_upstream_hostname_with_dots() {
assert!(is_valid_upstream("app.example.com:8080"));
}
#[test]
fn admin_key_path_empty_is_valid() {
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
fs::write(&cert_path, "cert").unwrap();
fs::write(&key_path, "key").unwrap();
let config = make_static_with_sites(
vec![SiteConfig {
host: "test.local".to_string(),
upstream: "127.0.0.1:8080".to_string(),
upstream_scheme: "http".to_string(),
upstream_connect_timeout_secs: 5,
upstream_request_timeout_secs: 60,
}],
make_manual_tls(cert_path.to_str().unwrap(), key_path.to_str().unwrap()),
);
let dynamic = valid_dynamic_config();
let result = validate(
&StaticConfig {
admin_key_path: String::new(),
..config
},
&dynamic,
false,
);
assert!(result.is_ok());
}
#[test]
fn admin_key_path_absolute_is_valid() {
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
fs::write(&cert_path, "cert").unwrap();
fs::write(&key_path, "key").unwrap();
let config = make_static_with_sites(
vec![SiteConfig {
host: "test.local".to_string(),
upstream: "127.0.0.1:8080".to_string(),
upstream_scheme: "http".to_string(),
upstream_connect_timeout_secs: 5,
upstream_request_timeout_secs: 60,
}],
make_manual_tls(cert_path.to_str().unwrap(), key_path.to_str().unwrap()),
);
let dynamic = valid_dynamic_config();
let result = validate(
&StaticConfig {
admin_key_path: "/etc/reverse-proxy/admin-key".to_string(),
..config
},
&dynamic,
false,
);
assert!(result.is_ok());
}
#[test]
fn admin_key_path_relative_is_invalid() {
let config = valid_static_config();
let dynamic = valid_dynamic_config();
let result = validate(
&StaticConfig {
admin_key_path: "relative/path/admin-key".to_string(),
..config
},
&dynamic,
false,
);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| matches!(e, ValidationError::AdminKeyPathNotAbsolute { .. })));
}
#[test]
fn admin_key_path_traversal_is_invalid() {
let config = valid_static_config();
let dynamic = valid_dynamic_config();
let result = validate(
&StaticConfig {
admin_key_path: "/etc/../etc/admin-key".to_string(),
..config
},
&dynamic,
false,
);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| matches!(e, ValidationError::AdminKeyPathTraversal { .. })));
}
}
+288 -11
View File
@@ -1,11 +1,17 @@
use std::net::SocketAddr;
use std::sync::Arc;
use arc_swap::ArcSwap;
use axum::middleware;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::routing::{get, post};
use axum::Router;
use tokio::net::TcpListener;
use tracing::info;
use crate::admin::handler::AdminState;
use crate::admin::auth::admin_auth_middleware;
async fn health_handler() -> impl IntoResponse {
axum::http::StatusCode::OK
}
@@ -14,20 +20,39 @@ pub fn health_router() -> Router {
Router::new().route("/health", get(health_handler))
}
pub fn admin_router(admin_state: Arc<AdminState>, key_hash: Arc<ArcSwap<[u8; 32]>>) -> Router {
let admin_routes = Router::new()
.route("/admin/reload", post(crate::admin::handler::reload_handler))
.route("/admin/status", get(crate::admin::handler::status_handler))
.route("/admin/rotate-key", post(crate::admin::handler::rotate_key_handler))
.layer(middleware::from_fn_with_state(key_hash, admin_auth_middleware))
.with_state(admin_state);
health_router().merge(admin_routes)
}
pub async fn start_health_check_listener(
port: u16,
admin_state: Option<Arc<AdminState>>,
key_hash: Option<Arc<ArcSwap<[u8; 32]>>>,
) -> anyhow::Result<(SocketAddr, tokio::task::JoinHandle<anyhow::Result<()>>)> {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = TcpListener::bind(addr).await?;
let local_addr = listener.local_addr()?;
info!(
addr = %local_addr,
"Health check listener bound"
);
let app = match (admin_state, key_hash) {
(Some(state), Some(hash)) => {
info!(addr = %local_addr, "Health check + admin listener bound");
admin_router(state, hash)
}
_ => {
info!(addr = %local_addr, "Health check listener bound (admin disabled)");
health_router()
}
};
let handle = tokio::spawn(async move {
axum::serve(listener, health_router())
axum::serve(listener, app)
.await
.map_err(anyhow::Error::from)
});
@@ -38,10 +63,46 @@ pub async fn start_health_check_listener(
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
use crate::admin::auth::load_admin_key;
use crate::config::{ConfigReloadHandle, test_fixtures};
async fn start_test_listener_with_admin(
key_content: &str,
) -> (SocketAddr, Arc<ArcSwap<[u8; 32]>>, tokio::task::JoinHandle<anyhow::Result<()>>) {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("admin-key");
std::fs::write(&key_path, key_content).unwrap();
let key_hash = load_admin_key(key_path.to_str().unwrap())
.unwrap()
.unwrap();
let key_hash_arc = Arc::new(ArcSwap::from_pointee(key_hash));
let config_arc = Arc::new(ArcSwap::from_pointee(
test_fixtures::test_dynamic_config(),
));
let static_config = test_fixtures::test_static_config();
let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config));
let admin_state = Arc::new(AdminState {
reload_handle,
config_path: dir.path().join("config.toml").to_string_lossy().to_string(),
start_time: Instant::now(),
key_hash: key_hash_arc.clone(),
});
let (addr, handle) = start_health_check_listener(0, Some(admin_state), Some(key_hash_arc.clone()))
.await
.unwrap();
(addr, key_hash_arc, handle)
}
#[tokio::test]
async fn test_health_check_returns_200() {
let (addr, handle) = start_health_check_listener(0).await.unwrap();
let (addr, _, handle) = start_test_listener_with_admin("test-admin-key").await;
let client = reqwest::Client::new();
let resp = client
@@ -51,15 +112,231 @@ mod tests {
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let body = resp.text().await.unwrap();
assert!(body.is_empty());
handle.abort();
}
#[tokio::test]
async fn test_health_check_binds_to_localhost() {
let (addr, _handle) = start_health_check_listener(0).await.unwrap();
let (addr, _, handle) = start_test_listener_with_admin("test-admin-key").await;
assert!(addr.ip().is_loopback());
handle.abort();
}
#[tokio::test]
async fn test_admin_reload_with_valid_token() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("admin-key");
std::fs::write(&key_path, "test-admin-key\n").unwrap();
let config_content = r#"
health_check_port = 9900
admin_key_path = "/tmp/test-admin-key"
[logging]
level = "info"
format = "text"
[rate_limit]
requests_per_second = 10
burst = 20
[body]
limit_bytes = 104857600
[[listeners]]
bind_addr = "127.0.0.1"
http_port = 80
https_port = 443
[listeners.tls]
mode = "acme"
acme_domains = ["test.local"]
acme_cache_dir = "/tmp/acme-cache"
acme_directory = "staging"
acme_contact = "mailto:admin@test.local"
[[listeners.sites]]
host = "test.local"
upstream = "127.0.0.1:8080"
"#;
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, config_content).unwrap();
let key_hash = load_admin_key(key_path.to_str().unwrap()).unwrap().unwrap();
let key_hash_arc = Arc::new(ArcSwap::from_pointee(key_hash));
let config_arc = Arc::new(ArcSwap::from_pointee(
test_fixtures::test_dynamic_config(),
));
let static_config = test_fixtures::test_static_config();
let reload_handle = Arc::new(ConfigReloadHandle::new(config_arc, static_config));
let admin_state = Arc::new(AdminState {
reload_handle,
config_path: config_path.to_string_lossy().to_string(),
start_time: Instant::now(),
key_hash: key_hash_arc.clone(),
});
let (addr, handle) = start_health_check_listener(0, Some(admin_state), Some(key_hash_arc.clone()))
.await
.unwrap();
let client = reqwest::Client::new();
let resp = client
.post(format!("http://127.0.0.1:{}/admin/reload", addr.port()))
.header("Authorization", "Bearer test-admin-key")
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "ok");
handle.abort();
}
#[tokio::test]
async fn test_admin_reload_with_wrong_token_returns_401() {
let (addr, _, handle) = start_test_listener_with_admin("correct-key").await;
let client = reqwest::Client::new();
let resp = client
.post(format!("http://127.0.0.1:{}/admin/reload", addr.port()))
.header("Authorization", "Bearer wrong-key")
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED);
handle.abort();
}
#[tokio::test]
async fn test_admin_reload_with_no_token_returns_401() {
let (addr, _, handle) = start_test_listener_with_admin("some-key").await;
let client = reqwest::Client::new();
let resp = client
.post(format!("http://127.0.0.1:{}/admin/reload", addr.port()))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED);
handle.abort();
}
#[tokio::test]
async fn test_admin_status_with_valid_token() {
let (addr, _, handle) = start_test_listener_with_admin("status-key").await;
let client = reqwest::Client::new();
let resp = client
.get(format!("http://127.0.0.1:{}/admin/status", addr.port()))
.header("Authorization", "Bearer status-key")
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "ok");
assert!(body["uptime_secs"].is_number());
assert!(body["sites"].is_number());
handle.abort();
}
#[tokio::test]
async fn test_admin_rotate_key() {
let (addr, _key_hash_arc, handle) = start_test_listener_with_admin("initial-key").await;
let client = reqwest::Client::new();
let resp = client
.post(format!("http://127.0.0.1:{}/admin/rotate-key", addr.port()))
.header("Authorization", "Bearer initial-key")
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "ok");
let new_key = body["key"].as_str().unwrap();
assert!(!new_key.is_empty());
let resp_old = client
.get(format!("http://127.0.0.1:{}/admin/status", addr.port()))
.header("Authorization", "Bearer initial-key")
.send()
.await
.unwrap();
assert_eq!(resp_old.status(), reqwest::StatusCode::UNAUTHORIZED);
let resp_new = client
.get(format!("http://127.0.0.1:{}/admin/status", addr.port()))
.header("Authorization", format!("Bearer {}", new_key))
.send()
.await
.unwrap();
assert_eq!(resp_new.status(), reqwest::StatusCode::OK);
handle.abort();
}
#[tokio::test]
async fn test_health_endpoint_always_200() {
let (addr, _, handle) = start_test_listener_with_admin("any-key").await;
let client = reqwest::Client::new();
let resp_no_auth = client
.get(format!("http://127.0.0.1:{}/health", addr.port()))
.send()
.await
.unwrap();
assert_eq!(resp_no_auth.status(), reqwest::StatusCode::OK);
let resp_with_auth = client
.get(format!("http://127.0.0.1:{}/health", addr.port()))
.header("Authorization", "Bearer random")
.send()
.await
.unwrap();
assert_eq!(resp_with_auth.status(), reqwest::StatusCode::OK);
handle.abort();
}
#[tokio::test]
async fn test_admin_disabled_returns_404() {
let (addr, handle) = start_health_check_listener(0, None, None).await.unwrap();
let client = reqwest::Client::new();
let resp = client
.post(format!("http://127.0.0.1:{}/admin/reload", addr.port()))
.header("Authorization", "Bearer some-key")
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
let resp = client
.get(format!("http://127.0.0.1:{}/admin/status", addr.port()))
.header("Authorization", "Bearer some-key")
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
let health_resp = client
.get(format!("http://127.0.0.1:{}/health", addr.port()))
.send()
.await
.unwrap();
assert_eq!(health_resp.status(), reqwest::StatusCode::OK);
handle.abort();
}
}
+38 -29
View File
@@ -1,5 +1,6 @@
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Instant;
use anyhow::{Context, Result};
use arc_swap::ArcSwap;
@@ -7,7 +8,8 @@ use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use tracing::{error, info, warn};
use reverse_proxy::admin::{start_admin_socket, AdminSocket, AdminSocketError};
use reverse_proxy::admin::handler::AdminState;
use reverse_proxy::admin::{load_admin_key, AdminAuthConfig};
use reverse_proxy::cli;
use reverse_proxy::config::ConfigReloadHandle;
use reverse_proxy::config::DynamicConfig;
@@ -91,41 +93,48 @@ async fn run_server(loaded_config: cli::LoadedConfig, config_path: &str) -> Resu
config_path.to_string(),
)?;
let admin_auth = if !loaded_config.static_config.admin_key_path.is_empty() {
match load_admin_key(&loaded_config.static_config.admin_key_path) {
Ok(Some(hash)) => Some(Arc::new(AdminAuthConfig { admin_key_hash: hash })),
Ok(None) => {
warn!("admin key file not found or empty, disabling admin endpoints");
None
}
Err(e) => {
warn!("admin key load failed, disabling admin endpoints: {}", e);
None
}
}
} else {
None
};
let start_time = Instant::now();
let key_hash_arc = admin_auth.as_ref().map(|auth| {
Arc::new(ArcSwap::from_pointee(auth.admin_key_hash))
});
let admin_state = admin_auth.map(|_auth| {
Arc::new(AdminState {
reload_handle: reload_handle.clone(),
config_path: config_path.to_string(),
start_time,
key_hash: key_hash_arc.as_ref().unwrap().clone(),
})
});
if loaded_config.static_config.health_check_port > 0 {
let (health_addr, _health_handle) =
health::start_health_check_listener(loaded_config.static_config.health_check_port)
health::start_health_check_listener(
loaded_config.static_config.health_check_port,
admin_state,
key_hash_arc,
)
.await
.context("failed to bind health check port")?;
info!(addr = %health_addr, "Health check listener bound");
}
if !loaded_config.static_config.admin_socket_path.is_empty() {
let admin_socket = Arc::new(AdminSocket::new(
loaded_config.static_config.admin_socket_path.clone(),
reload_handle.clone(),
config_path.to_string(),
));
let admin_socket_clone = admin_socket.clone();
let shutdown_for_admin = shutdown.clone();
tokio::spawn(async move {
if let Err(e) = start_admin_socket(admin_socket_clone, shutdown_for_admin).await {
match e {
AdminSocketError::Disabled => {}
AdminSocketError::SocketInUse(path) => {
warn!("admin socket disabled: {} is in use", path);
}
AdminSocketError::BindFailed(msg) => {
error!("admin socket bind failed: {}", msg);
}
AdminSocketError::Io(e) => {
error!("admin socket IO error: {}", e);
}
_ => {}
}
}
});
}
let mut bound_listeners = Vec::new();
for listener_config in &loaded_config.static_config.listeners {
+1 -1
View File
@@ -175,7 +175,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let config_content = r#"
health_check_port = 9900
admin_socket_path = "/tmp/test-admin.sock"
admin_key_path = "/tmp/test-admin-key"
[logging]
level = "info"
+8 -2
View File
@@ -1,7 +1,7 @@
---
id: fix/admin-http-api
name: Replace Unix domain socket admin API with authenticated HTTP admin API (ADR-028)
status: pending
status: completed
depends_on: []
scope: broad
risk: high
@@ -181,4 +181,10 @@ Bearer token authentication middleware.
## Summary
> To be filled on completion
Replaced Unix domain socket admin API with authenticated HTTP admin API on the
existing health check listener (port 9900). Bearer token auth with SHA-256 +
constant-time comparison protects `/admin/reload`, `/admin/status`, and
`/admin/rotate-key`. Admin disabled (empty `admin_key_path`) returns 404.
Config field renamed `admin_socket_path` → `admin_key_path`. Deployment files
updated for key file mount instead of socket directory. All 203 unit tests and
37 integration tests pass; `cargo clippy` clean.
+6 -6
View File
@@ -48,7 +48,7 @@ fn test_config_fixtures() {
#[tokio::test]
async fn test_health_check_local_port_returns_200() {
let (addr, handle) = reverse_proxy::health::start_health_check_listener(0)
let (addr, handle) = reverse_proxy::health::start_health_check_listener(0, None, None)
.await
.unwrap();
@@ -68,7 +68,7 @@ async fn test_health_check_local_port_returns_200() {
#[tokio::test]
async fn test_health_check_local_port_binds_localhost() {
let (addr, handle) = reverse_proxy::health::start_health_check_listener(0)
let (addr, handle) = reverse_proxy::health::start_health_check_listener(0, None, None)
.await
.unwrap();
@@ -80,7 +80,7 @@ async fn test_health_check_local_port_binds_localhost() {
#[tokio::test]
async fn test_health_check_binds_random_port_when_zero() {
let result = reverse_proxy::health::start_health_check_listener(0).await;
let result = reverse_proxy::health::start_health_check_listener(0, None, None).await;
assert!(result.is_ok());
let (addr, handle) = result.unwrap();
assert_ne!(addr.port(), 0);
@@ -570,7 +570,7 @@ fn write_valid_config(dir: &Path) -> std::path::PathBuf {
let config_path = dir.join("config.toml");
let config = r#"
health_check_port = 9900
admin_socket_path = "/tmp/reverse-proxy-test/admin.sock"
admin_key_path = "/etc/reverse-proxy/admin-key"
[logging]
level = "info"
@@ -870,7 +870,7 @@ async fn test_sighup_config_reload_valid_config() {
let dir = tempfile::tempdir().unwrap();
let config_content = r#"
health_check_port = 9900
admin_socket_path = "/tmp/test-admin.sock"
admin_key_path = "/tmp/test-admin-key"
[logging]
level = "info"
@@ -939,7 +939,7 @@ async fn test_sighup_config_reload_invalid_config_keeps_old() {
#[tokio::test]
async fn test_graceful_shutdown_with_health_check() {
let (addr, handle) = reverse_proxy::health::start_health_check_listener(0)
let (addr, handle) = reverse_proxy::health::start_health_check_listener(0, None, None)
.await
.unwrap();