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:
1 parent
cfe0ae522d
commit
3ea3f56de7
19 files changed
+925
-908
No files matched your search
Generated
+139
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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};
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 { .. })));
|
||||
}
|
||||
}
|
||||
+289
-12
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user