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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
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]]
|
[[package]]
|
||||||
name = "blocking"
|
name = "blocking"
|
||||||
version = "1.6.2"
|
version = "1.6.2"
|
||||||
@@ -483,12 +492,31 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.21"
|
version = "0.8.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
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]]
|
[[package]]
|
||||||
name = "dashmap"
|
name = "dashmap"
|
||||||
version = "6.1.0"
|
version = "6.1.0"
|
||||||
@@ -532,6 +560,16 @@ dependencies = [
|
|||||||
"powerfmt",
|
"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]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -755,6 +793,16 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -827,6 +875,12 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -1473,6 +1527,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -1497,6 +1560,35 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
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]]
|
[[package]]
|
||||||
name = "rcgen"
|
name = "rcgen"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -1614,10 +1706,12 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hex",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
|
"rand",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -1628,7 +1722,9 @@ dependencies = [
|
|||||||
"sd-notify",
|
"sd-notify",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"signal-hook",
|
"signal-hook",
|
||||||
|
"subtle",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1897,6 +1993,17 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -2367,6 +2474,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -2415,6 +2528,12 @@ version = "0.2.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2799,6 +2918,26 @@ dependencies = [
|
|||||||
"synstructure",
|
"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]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ futures = "=0.3.31"
|
|||||||
dashmap = "=6.1"
|
dashmap = "=6.1"
|
||||||
serde_json = "=1.0.140"
|
serde_json = "=1.0.140"
|
||||||
sd-notify = "=0.4"
|
sd-notify = "=0.4"
|
||||||
|
subtle = "2"
|
||||||
|
sha2 = "0.10"
|
||||||
|
rand = "0.9"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rcgen = "=0.13"
|
rcgen = "=0.13"
|
||||||
|
|||||||
+19
-7
@@ -20,7 +20,13 @@ docker build -t reverse-proxy .
|
|||||||
sudo mkdir -p /etc/reverse-proxy
|
sudo mkdir -p /etc/reverse-proxy
|
||||||
sudo mkdir -p /var/lib/reverse-proxy/acme-cache
|
sudo mkdir -p /var/lib/reverse-proxy/acme-cache
|
||||||
sudo mkdir -p /var/log/reverse-proxy
|
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
|
### 3. Create the config file
|
||||||
@@ -31,6 +37,7 @@ Let's Encrypt:
|
|||||||
```toml
|
```toml
|
||||||
allow_wildcard_bind = true
|
allow_wildcard_bind = true
|
||||||
health_check_port = 9900
|
health_check_port = 9900
|
||||||
|
admin_key_path = "/etc/reverse-proxy/admin-key"
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
level = "info"
|
level = "info"
|
||||||
@@ -143,7 +150,6 @@ sudo cp deploy/reverse-proxy.service /etc/systemd/system/
|
|||||||
sudo mkdir -p /etc/reverse-proxy
|
sudo mkdir -p /etc/reverse-proxy
|
||||||
sudo mkdir -p /var/lib/reverse-proxy/acme-cache
|
sudo mkdir -p /var/lib/reverse-proxy/acme-cache
|
||||||
sudo mkdir -p /var/log/reverse-proxy
|
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
|
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
|
```toml
|
||||||
# Single-domain bare metal example
|
# Single-domain bare metal example
|
||||||
health_check_port = 9900
|
health_check_port = 9900
|
||||||
|
admin_key_path = "/etc/reverse-proxy/admin-key"
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
level = "info"
|
level = "info"
|
||||||
@@ -209,11 +216,15 @@ journalctl -u reverse-proxy -f
|
|||||||
# Via SIGHUP (no feedback)
|
# Via SIGHUP (no feedback)
|
||||||
sudo kill -SIGHUP $(pidof reverse-proxy)
|
sudo kill -SIGHUP $(pidof reverse-proxy)
|
||||||
|
|
||||||
# Via admin socket (returns success/failure JSON)
|
# Via admin HTTP API (returns success/failure JSON)
|
||||||
echo "reload" | socat - UNIX-CONNECT:/run/reverse-proxy/admin.sock
|
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
|
# 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
|
## Multi-Domain Setup
|
||||||
@@ -295,8 +306,9 @@ certificate store.
|
|||||||
This prevents accidental exposure on unintended interfaces.
|
This prevents accidental exposure on unintended interfaces.
|
||||||
- The health check endpoint binds to `127.0.0.1` only and is never exposed on
|
- The health check endpoint binds to `127.0.0.1` only and is never exposed on
|
||||||
public ports.
|
public ports.
|
||||||
- The admin socket should be protected by file permissions. It defaults to
|
- The admin API requires Bearer token authentication on port 9900 (localhost
|
||||||
`/run/reverse-proxy/admin.sock`.
|
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
|
- Rate limiting is global per-IP (IPv4: /32, IPv6: /64) in the current
|
||||||
version. Per-site rate limits may be added later.
|
version. Per-site rate limits may be added later.
|
||||||
- All log output disables ANSI escape codes for fail2ban and container
|
- 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
|
- /etc/reverse-proxy/config.toml:/etc/reverse-proxy/config.toml:ro
|
||||||
- /var/lib/reverse-proxy/acme-cache:/var/lib/reverse-proxy/acme-cache
|
- /var/lib/reverse-proxy/acme-cache:/var/lib/reverse-proxy/acme-cache
|
||||||
- /var/log/reverse-proxy:/var/log/reverse-proxy
|
- /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:
|
networks:
|
||||||
- proxy-net
|
- proxy-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ NoNewPrivileges=yes
|
|||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
PrivateTmp=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
|
# ACME challenge cache directory
|
||||||
StateDirectory=reverse-proxy
|
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 {
|
if old.health_check_port != new.health_check_port {
|
||||||
changes.push("health_check_port".to_string());
|
changes.push("health_check_port".to_string());
|
||||||
}
|
}
|
||||||
if old.admin_socket_path != new.admin_socket_path {
|
if old.admin_key_path != new.admin_key_path {
|
||||||
changes.push("admin_socket_path".to_string());
|
changes.push("admin_key_path".to_string());
|
||||||
}
|
}
|
||||||
if old.shutdown_timeout_secs != new.shutdown_timeout_secs {
|
if old.shutdown_timeout_secs != new.shutdown_timeout_secs {
|
||||||
changes.push("shutdown_timeout_secs".to_string());
|
changes.push("shutdown_timeout_secs".to_string());
|
||||||
|
|||||||
+3
-3
@@ -20,8 +20,8 @@ pub struct FullConfig {
|
|||||||
pub allow_wildcard_bind: bool,
|
pub allow_wildcard_bind: bool,
|
||||||
#[serde(default = "static_config::default_health_check_port")]
|
#[serde(default = "static_config::default_health_check_port")]
|
||||||
pub health_check_port: u16,
|
pub health_check_port: u16,
|
||||||
#[serde(default = "static_config::default_admin_socket_path")]
|
#[serde(default = "static_config::default_admin_key_path")]
|
||||||
pub admin_socket_path: String,
|
pub admin_key_path: String,
|
||||||
#[serde(default = "static_config::default_shutdown_timeout_secs")]
|
#[serde(default = "static_config::default_shutdown_timeout_secs")]
|
||||||
pub shutdown_timeout_secs: u64,
|
pub shutdown_timeout_secs: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -40,7 +40,7 @@ impl FullConfig {
|
|||||||
listeners: self.listeners,
|
listeners: self.listeners,
|
||||||
allow_wildcard_bind: self.allow_wildcard_bind,
|
allow_wildcard_bind: self.allow_wildcard_bind,
|
||||||
health_check_port: self.health_check_port,
|
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,
|
shutdown_timeout_secs: self.shutdown_timeout_secs,
|
||||||
logging: self.logging,
|
logging: self.logging,
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-12
@@ -7,8 +7,8 @@ pub struct StaticConfig {
|
|||||||
pub allow_wildcard_bind: bool,
|
pub allow_wildcard_bind: bool,
|
||||||
#[serde(default = "default_health_check_port")]
|
#[serde(default = "default_health_check_port")]
|
||||||
pub health_check_port: u16,
|
pub health_check_port: u16,
|
||||||
#[serde(default = "default_admin_socket_path")]
|
#[serde(default = "default_admin_key_path")]
|
||||||
pub admin_socket_path: String,
|
pub admin_key_path: String,
|
||||||
#[serde(default = "default_shutdown_timeout_secs")]
|
#[serde(default = "default_shutdown_timeout_secs")]
|
||||||
pub shutdown_timeout_secs: u64,
|
pub shutdown_timeout_secs: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -19,8 +19,8 @@ pub fn default_health_check_port() -> u16 {
|
|||||||
9900
|
9900
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_admin_socket_path() -> String {
|
pub fn default_admin_key_path() -> String {
|
||||||
"/run/reverse-proxy/admin.sock".to_string()
|
"/etc/reverse-proxy/admin-key".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_shutdown_timeout_secs() -> u64 {
|
pub fn default_shutdown_timeout_secs() -> u64 {
|
||||||
@@ -103,7 +103,7 @@ mod tests {
|
|||||||
fn multi_config_toml() -> &'static str {
|
fn multi_config_toml() -> &'static str {
|
||||||
r#"
|
r#"
|
||||||
health_check_port = 9900
|
health_check_port = 9900
|
||||||
admin_socket_path = "/run/reverse-proxy/admin.sock"
|
admin_key_path = "/etc/reverse-proxy/admin-key"
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
level = "info"
|
level = "info"
|
||||||
@@ -152,7 +152,7 @@ upstream_scheme = "http"
|
|||||||
fn shared_ip_san_toml() -> &'static str {
|
fn shared_ip_san_toml() -> &'static str {
|
||||||
r#"
|
r#"
|
||||||
health_check_port = 9900
|
health_check_port = 9900
|
||||||
admin_socket_path = "/run/reverse-proxy/admin.sock"
|
admin_key_path = "/etc/reverse-proxy/admin-key"
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
level = "info"
|
level = "info"
|
||||||
@@ -195,8 +195,8 @@ upstream = "127.0.0.1:8080"
|
|||||||
allow_wildcard_bind: bool,
|
allow_wildcard_bind: bool,
|
||||||
#[serde(default = "default_health_check_port")]
|
#[serde(default = "default_health_check_port")]
|
||||||
health_check_port: u16,
|
health_check_port: u16,
|
||||||
#[serde(default = "default_admin_socket_path")]
|
#[serde(default = "default_admin_key_path")]
|
||||||
admin_socket_path: String,
|
admin_key_path: String,
|
||||||
#[serde(default = "default_shutdown_timeout_secs")]
|
#[serde(default = "default_shutdown_timeout_secs")]
|
||||||
shutdown_timeout_secs: u64,
|
shutdown_timeout_secs: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -213,7 +213,7 @@ upstream = "127.0.0.1:8080"
|
|||||||
assert_eq!(config.listeners.len(), 2);
|
assert_eq!(config.listeners.len(), 2);
|
||||||
assert!(!config.allow_wildcard_bind);
|
assert!(!config.allow_wildcard_bind);
|
||||||
assert_eq!(config.health_check_port, 9900);
|
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.shutdown_timeout_secs, 30);
|
||||||
assert_eq!(config.logging.level, "info");
|
assert_eq!(config.logging.level, "info");
|
||||||
assert_eq!(config.logging.format, "text");
|
assert_eq!(config.logging.format, "text");
|
||||||
@@ -291,8 +291,8 @@ acme_cache_dir = "/tmp/cache"
|
|||||||
allow_wildcard_bind: bool,
|
allow_wildcard_bind: bool,
|
||||||
#[serde(default = "default_health_check_port")]
|
#[serde(default = "default_health_check_port")]
|
||||||
health_check_port: u16,
|
health_check_port: u16,
|
||||||
#[serde(default = "default_admin_socket_path")]
|
#[serde(default = "default_admin_key_path")]
|
||||||
admin_socket_path: String,
|
admin_key_path: String,
|
||||||
#[serde(default = "default_shutdown_timeout_secs")]
|
#[serde(default = "default_shutdown_timeout_secs")]
|
||||||
shutdown_timeout_secs: u64,
|
shutdown_timeout_secs: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -305,7 +305,7 @@ acme_cache_dir = "/tmp/cache"
|
|||||||
|
|
||||||
assert!(!config.allow_wildcard_bind);
|
assert!(!config.allow_wildcard_bind);
|
||||||
assert_eq!(config.health_check_port, 9900);
|
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.shutdown_timeout_secs, 30);
|
||||||
assert_eq!(config.logging.level, "info");
|
assert_eq!(config.logging.level, "info");
|
||||||
assert_eq!(config.logging.format, "text");
|
assert_eq!(config.logging.format, "text");
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub fn test_static_config() -> StaticConfig {
|
|||||||
}],
|
}],
|
||||||
allow_wildcard_bind: false,
|
allow_wildcard_bind: false,
|
||||||
health_check_port: 9900,
|
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,
|
shutdown_timeout_secs: 30,
|
||||||
logging: LoggingConfig::default(),
|
logging: LoggingConfig::default(),
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-3
@@ -73,6 +73,10 @@ pub enum ValidationError {
|
|||||||
UpstreamSchemeInvalid { host: String, scheme: String },
|
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\")")]
|
#[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 },
|
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(
|
pub fn validate(
|
||||||
@@ -265,6 +269,19 @@ pub fn validate(
|
|||||||
errors.push(ValidationError::BodyLimitBytesZero { value: 0 });
|
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() {
|
if errors.is_empty() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -357,7 +374,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
allow_wildcard_bind: false,
|
allow_wildcard_bind: false,
|
||||||
health_check_port: 9900,
|
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,
|
shutdown_timeout_secs: 30,
|
||||||
logging: LoggingConfig::default(),
|
logging: LoggingConfig::default(),
|
||||||
}
|
}
|
||||||
@@ -393,7 +410,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
allow_wildcard_bind: false,
|
allow_wildcard_bind: false,
|
||||||
health_check_port: 9900,
|
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,
|
shutdown_timeout_secs: 30,
|
||||||
logging: LoggingConfig::default(),
|
logging: LoggingConfig::default(),
|
||||||
}
|
}
|
||||||
@@ -1081,7 +1098,7 @@ mod tests {
|
|||||||
listeners: vec![],
|
listeners: vec![],
|
||||||
allow_wildcard_bind: false,
|
allow_wildcard_bind: false,
|
||||||
health_check_port: 9900,
|
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,
|
shutdown_timeout_secs: 30,
|
||||||
logging: LoggingConfig::default(),
|
logging: LoggingConfig::default(),
|
||||||
};
|
};
|
||||||
@@ -1189,4 +1206,102 @@ mod tests {
|
|||||||
fn rule17_upstream_hostname_with_dots() {
|
fn rule17_upstream_hostname_with_dots() {
|
||||||
assert!(is_valid_upstream("app.example.com:8080"));
|
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::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
use axum::middleware;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use axum::routing::get;
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::admin::handler::AdminState;
|
||||||
|
use crate::admin::auth::admin_auth_middleware;
|
||||||
|
|
||||||
async fn health_handler() -> impl IntoResponse {
|
async fn health_handler() -> impl IntoResponse {
|
||||||
axum::http::StatusCode::OK
|
axum::http::StatusCode::OK
|
||||||
}
|
}
|
||||||
@@ -14,20 +20,39 @@ pub fn health_router() -> Router {
|
|||||||
Router::new().route("/health", get(health_handler))
|
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(
|
pub async fn start_health_check_listener(
|
||||||
port: u16,
|
port: u16,
|
||||||
|
admin_state: Option<Arc<AdminState>>,
|
||||||
|
key_hash: Option<Arc<ArcSwap<[u8; 32]>>>,
|
||||||
) -> anyhow::Result<(SocketAddr, tokio::task::JoinHandle<anyhow::Result<()>>)> {
|
) -> anyhow::Result<(SocketAddr, tokio::task::JoinHandle<anyhow::Result<()>>)> {
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||||
let listener = TcpListener::bind(addr).await?;
|
let listener = TcpListener::bind(addr).await?;
|
||||||
let local_addr = listener.local_addr()?;
|
let local_addr = listener.local_addr()?;
|
||||||
|
|
||||||
info!(
|
let app = match (admin_state, key_hash) {
|
||||||
addr = %local_addr,
|
(Some(state), Some(hash)) => {
|
||||||
"Health check listener bound"
|
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 {
|
let handle = tokio::spawn(async move {
|
||||||
axum::serve(listener, health_router())
|
axum::serve(listener, app)
|
||||||
.await
|
.await
|
||||||
.map_err(anyhow::Error::from)
|
.map_err(anyhow::Error::from)
|
||||||
});
|
});
|
||||||
@@ -38,10 +63,46 @@ pub async fn start_health_check_listener(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[tokio::test]
|
||||||
async fn test_health_check_returns_200() {
|
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 client = reqwest::Client::new();
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -51,15 +112,231 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
||||||
let body = resp.text().await.unwrap();
|
|
||||||
assert!(body.is_empty());
|
|
||||||
|
|
||||||
handle.abort();
|
handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_health_check_binds_to_localhost() {
|
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());
|
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::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
@@ -7,7 +8,8 @@ use tokio::net::TcpListener;
|
|||||||
use tokio_rustls::TlsAcceptor;
|
use tokio_rustls::TlsAcceptor;
|
||||||
use tracing::{error, info, warn};
|
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::cli;
|
||||||
use reverse_proxy::config::ConfigReloadHandle;
|
use reverse_proxy::config::ConfigReloadHandle;
|
||||||
use reverse_proxy::config::DynamicConfig;
|
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(),
|
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 {
|
if loaded_config.static_config.health_check_port > 0 {
|
||||||
let (health_addr, _health_handle) =
|
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
|
.await
|
||||||
.context("failed to bind health check port")?;
|
.context("failed to bind health check port")?;
|
||||||
info!(addr = %health_addr, "Health check listener bound");
|
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();
|
let mut bound_listeners = Vec::new();
|
||||||
|
|
||||||
for listener_config in &loaded_config.static_config.listeners {
|
for listener_config in &loaded_config.static_config.listeners {
|
||||||
|
|||||||
+1
-1
@@ -175,7 +175,7 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let config_content = r#"
|
let config_content = r#"
|
||||||
health_check_port = 9900
|
health_check_port = 9900
|
||||||
admin_socket_path = "/tmp/test-admin.sock"
|
admin_key_path = "/tmp/test-admin-key"
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
level = "info"
|
level = "info"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: fix/admin-http-api
|
id: fix/admin-http-api
|
||||||
name: Replace Unix domain socket admin API with authenticated HTTP admin API (ADR-028)
|
name: Replace Unix domain socket admin API with authenticated HTTP admin API (ADR-028)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: []
|
depends_on: []
|
||||||
scope: broad
|
scope: broad
|
||||||
risk: high
|
risk: high
|
||||||
@@ -181,4 +181,10 @@ Bearer token authentication middleware.
|
|||||||
|
|
||||||
## Summary
|
## 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]
|
#[tokio::test]
|
||||||
async fn test_health_check_local_port_returns_200() {
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ async fn test_health_check_local_port_returns_200() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_health_check_local_port_binds_localhost() {
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ async fn test_health_check_local_port_binds_localhost() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_health_check_binds_random_port_when_zero() {
|
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());
|
assert!(result.is_ok());
|
||||||
let (addr, handle) = result.unwrap();
|
let (addr, handle) = result.unwrap();
|
||||||
assert_ne!(addr.port(), 0);
|
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_path = dir.join("config.toml");
|
||||||
let config = r#"
|
let config = r#"
|
||||||
health_check_port = 9900
|
health_check_port = 9900
|
||||||
admin_socket_path = "/tmp/reverse-proxy-test/admin.sock"
|
admin_key_path = "/etc/reverse-proxy/admin-key"
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
level = "info"
|
level = "info"
|
||||||
@@ -870,7 +870,7 @@ async fn test_sighup_config_reload_valid_config() {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let config_content = r#"
|
let config_content = r#"
|
||||||
health_check_port = 9900
|
health_check_port = 9900
|
||||||
admin_socket_path = "/tmp/test-admin.sock"
|
admin_key_path = "/tmp/test-admin-key"
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
level = "info"
|
level = "info"
|
||||||
@@ -939,7 +939,7 @@ async fn test_sighup_config_reload_invalid_config_keeps_old() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_graceful_shutdown_with_health_check() {
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user