Files
shoes/tests/process_contract.rs
T
cfal 5fdbe696c5 Make reload rollback safe and release TCP setup state (#219)
* Release setup-only routing owners before TCP relay

* Restore failed reloads and retain unchanged TCP listener sockets

* Simplify reload activation and listener lifecycle code

* Reject duplicate fixed TCP addresses during direct startup

* Preserve inherited TUN descriptors across rejected reloads

* Defer overlapping TCP listeners until retirement

* Document reload rollback and listener retention

* Exercise retained TCP sockets in lifecycle retention regression
2026-10-25 07:50:31 -04:00

1697 lines
66 KiB
Rust

use std::io::{self, BufRead};
use std::net::SocketAddr;
use std::path::Path;
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
struct Process {
child: Child,
lines: tokio::sync::mpsc::UnboundedReceiver<String>,
}
#[cfg(unix)]
#[path = "cases/wireguard_support.rs"]
mod wireguard_support;
#[cfg(unix)]
#[path = "cases/process_contract/wireguard_dns.rs"]
mod wireguard_dns;
#[cfg(unix)]
#[path = "cases/process_contract/fake_dns.rs"]
mod fake_dns;
#[cfg(unix)]
#[path = "cases/process_contract/rule_sets.rs"]
mod rule_sets;
#[cfg(unix)]
#[path = "cases/process_contract/clash_api.rs"]
mod clash_api;
#[cfg(unix)]
#[path = "cases/process_contract/certificates.rs"]
mod certificates;
#[cfg(unix)]
#[tokio::test]
async fn wireguard_reload_retains_invalid_candidates_rebinds_and_releases_on_exit() {
wireguard_reload(false).await;
}
#[cfg(unix)]
#[tokio::test]
async fn wireguard_outer_chain_reload_retains_invalid_candidates_and_releases_on_exit() {
wireguard_reload(true).await;
}
#[cfg(unix)]
#[test]
fn wireguard_check_validates_outer_udp_without_dns_or_listeners() {
use serde_json::json;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let occupied = std::net::UdpSocket::bind("0.0.0.0:0").unwrap();
let server = wireguard_support::server(occupied.local_addr().unwrap().port());
let mut endpoint = wireguard_support::client(51820);
endpoint["client_proxies"][0]["address"] = json!("unresolvable.invalid:51820");
for mode in ["check", "--dry-run"] {
for (protocol, valid) in [
(json!({"type": "socks"}), false),
(json!({"type": "trojan", "password": "test"}), false),
(
json!({"type": "vless", "user_id": shoes_test_support::test_fixture::TEST_UUID, "udp_enabled": false}),
false,
),
(wireguard_support::vless_protocol(), true),
] {
endpoint["client_proxies"][0]["protocol"]["peer_client_chain"] = json!([{
"address": "unresolvable.invalid:443", "protocol": protocol,
"transport": "quic",
}]);
if cfg!(any(
target_os = "android",
target_os = "fuchsia",
target_os = "linux"
)) {
endpoint["client_proxies"][0]["protocol"]["peer_client_chain"][0]["bind_interface"] =
json!("shoes-missing-interface");
}
let config = json!([server, endpoint]);
std::fs::write(&path, serde_yaml::to_string(&config).unwrap()).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_shoes"))
.args([mode, "-t", "1"])
.arg(&path)
.output()
.unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.success(), valid, "{error}");
if !valid {
assert!(error.contains("does not support UDP"), "{error}");
}
}
let mut invalid = wireguard_support::server(51820);
invalid["protocol"]["addresses"] = json!(["255.255.255.255/32"]);
std::fs::write(&path, serde_yaml::to_string(&json!([invalid])).unwrap()).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_shoes"))
.args([mode, "-t", "1"])
.arg(&path)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("unicast local address"));
}
}
#[cfg(unix)]
#[tokio::test]
async fn wireguard_conflicting_shared_client_reload_retains_live_flows() {
use serde_json::json;
use shoes_test_support::socks5::Socks5UdpAssociation;
use shoes_test_support::wireguard::{EchoTargets, connect};
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let socket = std::net::UdpSocket::bind("0.0.0.0:0").unwrap();
let port = socket.local_addr().unwrap().port();
let mut ports = shoes_test_support::port_helper::PortHelper::new();
let (_, entry) = ports.get_localhost_listener_port();
let first = wireguard_support::client(port);
let mut second = first.clone();
second["client_group"] = json!("copy");
let mut socks = wireguard_support::socks(entry);
socks["rules"][0]["client_chain"] = json!([{"pool": ["wg", "copy"]}]);
let mut configs = json!([wireguard_support::server(port), first, second, socks]);
std::fs::write(&path, serde_yaml::to_string(&configs).unwrap()).unwrap();
drop(socket);
let mut process = Process::start(&path, &["--no-reload"]);
process.wait_for("Servers ready").await;
let echo = EchoTargets::new(false).await.unwrap();
let mut tcp = connect(entry, echo.tcp()).await.unwrap();
let udp = Socks5UdpAssociation::connect("127.0.0.1", entry)
.await
.unwrap();
roundtrip(&mut tcp).await;
assert_eq!(udp.send_to(echo.udp(), b"before").await.unwrap(), b"before");
configs[2]["client_proxies"][0]["protocol"]["pre_shared_key"] =
json!(wireguard_support::key(44));
std::fs::write(&path, serde_yaml::to_string(&configs).unwrap()).unwrap();
process.signal(libc::SIGHUP);
process
.wait_for("conflicting WireGuard client settings")
.await;
roundtrip(&mut tcp).await;
assert_eq!(udp.send_to(echo.udp(), b"after").await.unwrap(), b"after");
echo.check(entry, None).await.unwrap();
process.signal(libc::SIGTERM);
assert!(process.exit().await.success());
std::net::UdpSocket::bind(("0.0.0.0", port)).unwrap();
std::net::TcpListener::bind(("0.0.0.0", entry)).unwrap();
}
#[cfg(unix)]
async fn wireguard_reload(detour: bool) {
use serde_json::json;
let directory = tempfile::tempdir().unwrap();
let server_path = directory.path().join("server.yaml");
let client_path = directory.path().join("client.yaml");
let socket = std::net::UdpSocket::bind("0.0.0.0:0").unwrap();
let port = socket.local_addr().unwrap().port();
let mut ports = shoes_test_support::port_helper::PortHelper::new();
let (_, entry) = ports.get_localhost_listener_port();
let mut servers = vec![wireguard_support::server(port)];
let mut endpoint = wireguard_support::client(port);
if detour {
let (_, outer_port) = ports.get_localhost_listener_port();
let protocol = wireguard_support::vless_protocol();
endpoint["client_proxies"][0]["address"] = json!(format!("192.0.2.200:{port}"));
endpoint["client_proxies"][0]["protocol"]["peer_client_chain"] =
json!([{"address": format!("127.0.0.1:{outer_port}"), "protocol": protocol}]);
servers.push(json!({
"address": format!("0.0.0.0:{outer_port}"), "protocol": protocol,
"rules": [{"masks": "192.0.2.200/32", "action": "allow", "override_address": "127.0.0.1:0"}],
}));
}
let server_config = serde_yaml::to_string(&servers).unwrap();
let mut client_configs = json!([endpoint, wireguard_support::socks(entry)]);
let client_config = serde_yaml::to_string(&client_configs).unwrap();
std::fs::write(&server_path, &server_config).unwrap();
std::fs::write(&client_path, &client_config).unwrap();
drop(socket);
let mut server = Process::start(&server_path, &["--no-reload"]);
let mut client = Process::start(&client_path, &["--no-reload"]);
server.wait_for("Servers ready").await;
client.wait_for("Servers ready").await;
let echo = shoes_test_support::wireguard::EchoTargets::new(false)
.await
.unwrap();
let mut active = shoes_test_support::wireguard::connect(entry, echo.tcp())
.await
.unwrap();
roundtrip(&mut active).await;
if detour {
client_configs[0]["client_proxies"][0]["protocol"]["peer_client_chain"][0]["protocol"]["udp_enabled"] =
json!(false);
} else {
client_configs[1]["rules"][0]["client_chain"] = json!("missing");
}
let invalid = serde_yaml::to_string(&client_configs).unwrap();
assert_ne!(invalid, client_config);
std::fs::write(&client_path, invalid).unwrap();
client.signal(libc::SIGHUP);
client.wait_for("Reload rejected").await;
roundtrip(&mut active).await;
std::fs::write(&client_path, &client_config).unwrap();
client.signal(libc::SIGHUP);
client.wait_for("Servers ready").await;
let mut byte = [0; 1];
assert!(!matches!(
timeout(Duration::from_secs(3), active.read(&mut byte)).await,
Ok(Ok(1)) | Err(_)
));
let udp = shoes_test_support::socks5::Socks5UdpAssociation::connect("127.0.0.1", entry)
.await
.unwrap();
assert_eq!(
udp.send_to(echo.udp(), b"before server reload")
.await
.unwrap(),
b"before server reload"
);
server.signal(libc::SIGHUP);
server.wait_for("Servers ready").await;
// The unchanged client must replace its stale handshake without operator intervention.
timeout(Duration::from_secs(45), async {
loop {
match udp
.send_to_with_timeout(
shoes_test_support::socks5::SocksDestination::Ip(echo.udp()),
b"after server reload",
Duration::from_secs(1),
)
.await
{
Ok(bytes) => {
assert_eq!(bytes, b"after server reload");
break;
}
Err(error) => assert_eq!(error.kind(), io::ErrorKind::TimedOut),
}
}
})
.await
.unwrap();
echo.check(entry, None).await.unwrap();
// A new association excludes delayed recovery replies from the revocation check.
drop(udp);
let udp = shoes_test_support::socks5::Socks5UdpAssociation::connect("127.0.0.1", entry)
.await
.unwrap();
assert_eq!(
udp.send_to(echo.udp(), b"old key").await.unwrap(),
b"old key"
);
let mut old_stream = shoes_test_support::wireguard::connect(entry, echo.tcp())
.await
.unwrap();
roundtrip(&mut old_stream).await;
servers[0]["protocol"]["peers"][0]["public_key"] = json!(wireguard_support::public(44));
std::fs::write(&server_path, serde_yaml::to_string(&servers).unwrap()).unwrap();
server.signal(libc::SIGHUP);
server.wait_for("Servers ready").await;
assert!(
udp.send_to_with_timeout(
shoes_test_support::socks5::SocksDestination::Ip(echo.udp()),
b"revoked key",
Duration::from_secs(2),
)
.await
.is_err()
);
let old_flow = async {
old_stream.write_all(b"revoked key").await?;
old_stream.read_exact(&mut [0; 11]).await
};
assert!(!matches!(
timeout(Duration::from_secs(2), old_flow).await,
Ok(Ok(_))
));
// A fresh old-key device distinguishes revocation from a stale handshake after reload.
client.signal(libc::SIGHUP);
client.wait_for("Servers ready").await;
let revoked = shoes_test_support::socks5::Socks5UdpAssociation::connect("127.0.0.1", entry)
.await
.unwrap();
assert_eq!(
revoked
.send_to_with_timeout(
shoes_test_support::socks5::SocksDestination::Ip(echo.udp()),
b"fresh revoked key",
Duration::from_secs(3),
)
.await
.unwrap_err()
.kind(),
io::ErrorKind::TimedOut
);
assert!(!matches!(
timeout(
Duration::from_secs(3),
shoes_test_support::wireguard::connect(entry, echo.tcp())
)
.await,
Ok(Ok(_))
));
let mut rotated: serde_json::Value = serde_yaml::from_str(&client_config).unwrap();
rotated[0]["client_proxies"][0]["protocol"]["private_key"] = json!(wireguard_support::key(44));
std::fs::write(&client_path, serde_yaml::to_string(&rotated).unwrap()).unwrap();
client.signal(libc::SIGHUP);
client.wait_for("Servers ready").await;
echo.check(entry, None).await.unwrap();
client.signal(libc::SIGTERM);
server.signal(libc::SIGTERM);
assert!(client.exit().await.success());
assert!(server.exit().await.success());
std::net::UdpSocket::bind(("0.0.0.0", port)).unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn wireguard_partial_startup_releases_previously_started_endpoint() {
use serde_json::json;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let socket = std::net::UdpSocket::bind("0.0.0.0:0").unwrap();
let port = socket.local_addr().unwrap().port();
let occupied = std::net::UdpSocket::bind("0.0.0.0:0").unwrap();
let second = wireguard_support::server(occupied.local_addr().unwrap().port());
std::fs::write(
&path,
serde_yaml::to_string(&json!([wireguard_support::server(port), second])).unwrap(),
)
.unwrap();
drop(socket);
let mut child = Process::start(&path, &["--no-reload"]);
assert_eq!(child.exit().await.code(), Some(1));
std::net::UdpSocket::bind(("0.0.0.0", port)).unwrap();
}
#[test]
fn vless_encryption_generator_prints_only_matched_yaml_credentials() {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
for (args, expected_public_lengths, expected_private_lengths) in [
(vec![], vec![1184], vec![64]),
(
vec![
"--auth", "x25519", "--auth", "mlkem768", "--auth", "mlkem768",
],
vec![32, 1184, 1184],
vec![32, 64, 64],
),
] {
let output = Command::new(env!("CARGO_BIN_EXE_shoes"))
.arg("generate-vless-encryption")
.args(&args)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let values: serde_yaml::Value = serde_yaml::from_slice(&output.stdout).unwrap();
assert_eq!(values.as_mapping().unwrap().len(), 2);
let encryption = values["encryption"].as_str().unwrap();
let decryption = values["decryption"].as_str().unwrap();
assert!(!String::from_utf8_lossy(&output.stderr).contains(decryption));
assert!(encryption.starts_with("mlkem768x25519plus.native.0rtt."));
assert!(decryption.starts_with("mlkem768x25519plus.native.600s."));
let public_lengths: Vec<_> = encryption
.split('.')
.skip(3)
.map(|s| URL_SAFE_NO_PAD.decode(s).unwrap().len())
.collect();
let private_lengths: Vec<_> = decryption
.split('.')
.skip(3)
.map(|s| URL_SAFE_NO_PAD.decode(s).unwrap().len())
.collect();
assert_eq!(public_lengths, expected_public_lengths);
assert_eq!(private_lengths, expected_private_lengths);
}
for args in [
vec!["--auth", "bad"],
vec!["--rtt", "1rtt"],
vec!["--lifetime", "0s"],
vec!["--mode"],
vec!["--mode", "random", "--mode", "native"],
] {
let output = Command::new(env!("CARGO_BIN_EXE_shoes"))
.arg("generate-vless-encryption")
.args(args)
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
}
}
#[test]
fn vless_encryption_rejects_invalid_startup_without_credentials_in_diagnostics() {
use serde_json::json;
let directory = tempfile::Builder::new()
.prefix("vless-invalid-")
.tempdir_in(std::env::var_os("HOME").unwrap())
.unwrap();
let credential = "mlkem768x25519plus.native.600s.private-key-must-not-appear";
for protocol in [
json!({"type": "vless", "user_id": "550e8400-e29b-41d4-a716-446655440000", "decryption": credential}),
json!({"type": "vless", "user_id": "550e8400-e29b-41d4-a716-446655440000", "vision": true}),
json!({"type": "vless", "user_id": "550e8400-e29b-41d4-a716-446655440000", "encryption_limits": {"max_sessions": 0}}),
] {
let address = available_address();
let config = directory.path().join("invalid.json");
std::fs::write(
&config,
serde_json::to_vec(&json!([{"address": address, "protocol": protocol}])).unwrap(),
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_shoes"))
.arg("--no-reload")
.arg(&config)
.output()
.unwrap();
assert!(!output.status.success());
let diagnostic = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
!diagnostic.contains("private-key-must-not-appear"),
"{diagnostic}"
);
assert!(!diagnostic.contains("Servers ready"), "{diagnostic}");
assert!(std::net::TcpListener::bind(address).is_ok());
}
}
#[test]
fn vless_wrong_side_credentials_fail_check_and_startup() {
use serde_json::json;
let directory = tempfile::Builder::new()
.prefix("vless-wrong-side-")
.tempdir_in(std::env::var_os("HOME").unwrap())
.unwrap();
for server in [false, true] {
let address = available_address();
let mut protocol = json!({
"type": "vless", "user_id": "550e8400-e29b-41d4-a716-446655440000",
});
protocol[if server { "encryption" } else { "decryption" }] =
json!("private-key-must-not-appear");
let config = if server {
json!([{"address": address, "protocol": protocol}])
} else {
json!([{"address": address, "protocol": {"type": "socks"},
"rules": [{"masks": "0.0.0.0/0", "action": "allow", "client_chain": {
"address": "127.0.0.1:443", "protocol": protocol,
}}],
}])
};
let path = directory.path().join("invalid.json");
std::fs::write(&path, serde_json::to_vec(&config).unwrap()).unwrap();
for operation in ["check", "--no-reload"] {
let output = Command::new(env!("CARGO_BIN_EXE_shoes"))
.arg(operation)
.arg(&path)
.output()
.unwrap();
assert!(!output.status.success());
let diagnostic = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
diagnostic.contains("Could not parse config file"),
"{diagnostic}"
);
if server {
assert!(diagnostic.contains("VLESS server uses decryption, not encryption"));
}
assert!(
!diagnostic.contains("private-key-must-not-appear"),
"{diagnostic}"
);
assert!(!diagnostic.contains("Servers ready"));
assert!(std::net::TcpListener::bind(address).is_ok());
}
}
}
#[test]
fn reality_mldsa_keygen_outputs_interoperable_seed_and_key() {
use aws_lc_rs::signature::{self, KeyPair};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
let output = Command::new(env!("CARGO_BIN_EXE_shoes"))
.arg("generate-reality-mldsa65-keypair")
.output()
.unwrap();
assert!(output.status.success());
let values: serde_yaml::Value = serde_yaml::from_slice(&output.stdout).unwrap();
let seed = URL_SAFE_NO_PAD
.decode(values["mldsa65_seed"].as_str().unwrap())
.unwrap();
let verify = URL_SAFE_NO_PAD
.decode(values["mldsa65_verify"].as_str().unwrap())
.unwrap();
assert_eq!(seed.len(), 32);
assert_eq!(verify.len(), 1952);
let signing = signature::PqdsaKeyPair::from_seed(&signature::ML_DSA_65_SIGNING, &seed).unwrap();
assert_eq!(signing.public_key().as_ref(), verify);
}
impl Process {
fn start(path: &Path, arguments: &[&str]) -> Self {
let child = Command::new(env!("CARGO_BIN_EXE_shoes"))
.args(["-t", "1"])
.args(arguments)
.arg(path)
.env("RUST_LOG", "error")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
Self::from_child(child)
}
fn from_child(mut child: Child) -> Self {
let (tx, lines) = tokio::sync::mpsc::unbounded_channel();
let readers: Vec<Box<dyn io::Read + Send>> = vec![
Box::new(child.stdout.take().unwrap()),
Box::new(child.stderr.take().unwrap()),
];
for reader in readers {
let tx = tx.clone();
std::thread::spawn(move || {
for line in io::BufReader::new(reader).lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
}
});
}
Self { child, lines }
}
async fn wait_for(&mut self, marker: &str) {
let mut seen = String::new();
let result = timeout(Duration::from_secs(8), async {
while let Some(line) = self.lines.recv().await {
seen.push_str(&line);
seen.push('\n');
if line.contains(marker) {
return;
}
}
panic!("process exited waiting for {marker}: {seen}");
})
.await;
assert!(result.is_ok(), "timed out waiting for {marker}: {seen}");
}
async fn exit(&mut self) -> ExitStatus {
timeout(Duration::from_secs(8), async {
loop {
if let Some(status) = self.child.try_wait().unwrap() {
return status;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("process did not exit")
}
#[cfg(unix)]
fn signal(&self, signal: i32) {
assert_eq!(unsafe { libc::kill(self.child.id() as i32, signal) }, 0);
}
}
impl Drop for Process {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn available_address() -> SocketAddr {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
}
#[cfg(unix)]
#[tokio::test]
async fn tun_worker_failure_exits_nonzero_and_releases_other_listeners() {
use std::os::fd::OwnedFd;
for fail_at_startup in [false, true] {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let address = available_address();
std::fs::write(&path, format!(
"- address: '{address}'\n protocol: {{type: socks}}\n rules: allow-all-direct\n- device_fd: 0\n rules: allow-all-direct\n"
)).unwrap();
let (peer, tun) = std::os::unix::net::UnixStream::pair().unwrap();
let mut peer = Some(peer);
if fail_at_startup {
peer.take();
}
let child = Command::new(env!("CARGO_BIN_EXE_shoes"))
.args(["-t", "1"])
.arg(&path)
.stdin(Stdio::from(OwnedFd::from(tun)))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut process = Process::from_child(child);
if !fail_at_startup {
process.wait_for("Servers ready").await;
let mut stream = TcpStream::connect(address).await.unwrap();
stream.write_all(&[5, 1, 0]).await.unwrap();
let mut greeting = [0; 2];
stream.read_exact(&mut greeting).await.unwrap();
assert_eq!(greeting, [5, 0]);
peer.take();
}
process.wait_for("TUN device closed (EOF)").await;
assert!(!process.exit().await.success());
std::net::TcpListener::bind(address).unwrap();
}
}
fn forward(address: SocketAddr, target: SocketAddr) -> String {
format!(
"- global_limits: {{reload_grace_secs: 2}}\n- address: '{address}'\n protocol:\n type: forward\n target: '{target}'\n"
)
}
async fn roundtrip(stream: &mut TcpStream) {
timeout(Duration::from_secs(2), async {
stream.write_all(b"still serving").await.unwrap();
let mut response = [0; 13];
stream.read_exact(&mut response).await.unwrap();
assert_eq!(&response, b"still serving");
})
.await
.unwrap();
}
fn invalid_protocol_configs(address: SocketAddr) -> Vec<String> {
use serde_json::json;
let reality = json!({
"type": "reality", "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"sni_hostname": "example.com", "protocol": {"type": "socks"}
});
let mut bad_key = reality.clone();
bad_key["public_key"] = json!("invalid");
let mut bad_sni = reality.clone();
bad_sni["sni_hostname"] = json!("invalid name");
let mut ipv4_sni = reality.clone();
ipv4_sni["sni_hostname"] = json!("127.0.0.1");
let mut ipv6_sni = reality.clone();
ipv6_sni["sni_hostname"] = json!("::1");
let mut nested_direct = reality.clone();
nested_direct["protocol"] = json!({"type": "direct"});
let mut missing_sni = reality;
missing_sni.as_object_mut().unwrap().remove("sni_hostname");
let padding = json!({"type": "anytls", "password": "test", "padding_scheme": ["stop=invalid"]});
let protocols = [
bad_key,
bad_sni,
missing_sni,
ipv4_sni,
ipv6_sni,
nested_direct,
padding.clone(),
json!({"type": "tls", "protocol": {"type": "direct"}}),
json!({"type": "shadowtls", "password": "test", "protocol": {"type": "direct"}}),
json!({"type": "websocket", "protocol": {"type": "direct"}}),
];
let mut configs: Vec<_> = protocols
.iter()
.map(|protocol| {
serde_yaml::to_string(&json!([{
"address": address.to_string(), "protocol": {"type": "http"},
"rules": [{"masks": "0.0.0.0/0", "client_proxy": {
"address": "127.0.0.1:443", "protocol": protocol
}}]
}]))
.unwrap()
})
.collect();
let mut invalid_chains: Vec<_> = protocols
.into_iter()
.map(|protocol| {
json!({
"address": "127.0.0.1:443", "protocol": protocol
})
})
.collect();
invalid_chains.extend([
json!("missing-group"),
json!(["direct", "direct"]),
json!({"address": "127.0.0.1:443", "protocol": {
"type": "tls", "cert": "-----BEGIN CERTIFICATE-----bad",
"key": "-----BEGIN PRIVATE KEY-----bad", "protocol": {"type": "socks"}
}}),
]);
for chain in invalid_chains {
for protocol in handshake_protocols(chain) {
configs.push(
serde_yaml::to_string(&json!([{
"address": address.to_string(), "protocol": protocol
}]))
.unwrap(),
);
}
}
configs.push(
serde_yaml::to_string(&json!([{"address": address.to_string(), "protocol": padding}]))
.unwrap(),
);
configs.extend(invalid_server_protocol_configs(address));
configs.extend(invalid_server_auth_configs(address));
configs
}
fn invalid_server_auth_configs(address: SocketAddr) -> Vec<String> {
use serde_json::json;
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
let empty = json!({"type": "trojan", "password": ""});
let tls_target = json!({"cert": cert.cert.pem(), "key": cert.signing_key.serialize_pem(), "protocol": empty});
let xhttp = json!({"type": "xhttp", "protocol": empty});
let mut xhttp_tls_target = tls_target.clone();
xhttp_tls_target["protocol"] = xhttp.clone();
let mut protocols = vec![
empty.clone(),
xhttp.clone(),
json!({"type": "tls", "default_target": xhttp_tls_target}),
json!({"type": "snell", "cipher": "aes-128-gcm", "password": ""}),
json!({"type": "shadowsocks", "cipher": "aes-128-gcm", "password": ""}),
json!({"type": "trojan", "password": "do-not-log-this-secret", "shadowsocks": {"cipher": "aes-128-gcm", "password": ""}}),
json!({"type": "anytls", "users": [{"password": "do-not-log-this-secret"}, {"password": ""}]}),
json!({"type": "websocket", "targets": {"protocol": empty}}),
json!({"type": "tls", "default_target": tls_target}),
json!({"type": "tls", "tls_targets": {"localhost": tls_target}}),
json!({"type": "tls", "reality_targets": {"localhost": {
"private_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "dest": "localhost:443", "protocol": empty
}}}),
json!({"type": "tls", "shadowtls_targets": {"localhost": {
"password": "do-not-log-this-secret", "handshake": {"address": "localhost:443"}, "protocol": empty
}}}),
];
for handshake in [
json!({"address": "localhost:443"}),
json!({"cert": cert.cert.pem(), "key": cert.signing_key.serialize_pem()}),
] {
protocols.push(json!({"type": "tls", "shadowtls_targets": {"localhost": {
"password": "", "handshake": handshake, "protocol": {"type": "socks"}
}}}));
}
let mut naive = tls_target;
naive["protocol"] = json!({"type": "naive", "users": [
{"username": "valid", "password": "do-not-log-this-secret"},
{"username": "invalid", "password": ""}
]});
protocols.push(json!({"type": "tls", "default_target": naive}));
for cipher in ["2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm"] {
for password in ["", "eA=="] {
protocols.push(json!({"type": "shadowsocks", "cipher": cipher, "password": password}));
}
}
for protocol_type in ["http", "socks", "mixed"] {
protocols.push(json!({"type": protocol_type, "username": "user", "password": ""}));
protocols.push(json!({"type": protocol_type, "username": "user"}));
if protocol_type != "http" {
protocols.push(json!({"type": protocol_type, "password": "do-not-log-this-secret"}));
protocols.push(json!({"type": protocol_type, "username": "u".repeat(256), "password": "do-not-log-this-secret"}));
}
}
let mut configs: Vec<_> = protocols
.into_iter()
.map(|protocol| {
json!([{
"address": address.to_string(), "protocol": protocol
}])
})
.collect();
for protocol in [
xhttp,
json!({"type": "hysteria2", "password": ""}),
json!({"type": "tuic", "uuid": "550e8400-e29b-41d4-a716-446655440000", "password": ""}),
] {
configs.push(json!([{
"address": address.to_string(), "transport": "quic", "protocol": protocol,
"quic_settings": {"cert": cert.cert.pem(), "key": cert.signing_key.serialize_pem()}
}]));
}
configs
.into_iter()
.map(|config| serde_yaml::to_string(&config).unwrap())
.collect()
}
fn invalid_server_protocol_configs(address: SocketAddr) -> Vec<String> {
use serde_json::json;
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
let mut protocols = Vec::new();
for inner in [
json!({"type": "hysteria2", "password": "do-not-log-this-secret"}),
json!({"type": "tuic", "uuid": "550e8400-e29b-41d4-a716-446655440000", "password": "do-not-log-this-secret"}),
json!({"type": "naive", "users": [{"username": "user", "password": "do-not-log-this-secret"}]}),
] {
protocols.push(inner.clone());
protocols.push(json!({"type": "xhttp", "protocol": inner}));
protocols.push(json!({"type": "websocket", "targets": {"protocol": inner}}));
protocols.push(json!({"type": "tls", "shadowtls_targets": {"localhost": {
"password": "do-not-log-this-secret", "handshake": {"address": "localhost:443"}, "protocol": inner
}}}));
if inner["type"] != "naive" {
let tls_target = json!({"cert": cert.cert.pem(), "key": cert.signing_key.serialize_pem(), "protocol": inner});
protocols.push(json!({"type": "tls", "default_target": tls_target}));
protocols.push(json!({"type": "tls", "tls_targets": {"localhost": tls_target}}));
protocols.push(json!({"type": "tls", "reality_targets": {"localhost": {
"private_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "dest": "localhost:443", "protocol": inner
}}}));
}
}
for inner in [
json!({"type": "trojan", "password": "do-not-log-this-secret"}),
json!({"type": "naive", "users": [{"username": "user", "password": "do-not-log-this-secret"}]}),
] {
let target = json!({"cert": cert.cert.pem(), "key": cert.signing_key.serialize_pem(), "vision": true, "protocol": inner});
protocols.push(json!({"type": "tls", "default_target": target}));
protocols.push(json!({"type": "tls", "tls_targets": {"localhost": target}}));
protocols.push(json!({"type": "tls", "reality_targets": {"localhost": {
"private_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "dest": "localhost:443", "vision": true, "protocol": inner
}}}));
}
protocols
.into_iter()
.map(|protocol| {
serde_yaml::to_string(&json!([{
"address": address.to_string(), "protocol": protocol
}]))
.unwrap()
})
.collect()
}
fn handshake_protocols(chain: serde_json::Value) -> [serde_json::Value; 2] {
use serde_json::json;
[
json!({"type": "tls", "reality_targets": {"example.com": {
"private_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"dest": "example.com:443", "dest_client_chain": chain,
"protocol": {"type": "socks"}
}}}),
json!({"type": "tls", "shadowtls_targets": {"example.com": {
"password": "test", "handshake": {
"address": "example.com:443", "client_chain": chain
}, "protocol": {"type": "socks"}
}}}),
]
}
#[tokio::test]
async fn handshake_chains_expand_groups_and_load_certificates() {
use serde_json::json;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let cert_path = directory.path().join("client.pem");
let key_path = directory.path().join("client.key");
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
std::fs::write(&cert_path, cert.cert.pem()).unwrap();
std::fs::write(&key_path, cert.signing_key.serialize_pem()).unwrap();
let client = json!({"address": "127.0.0.1:443", "protocol": {
"type": "tls", "cert": cert_path, "key": key_path,
"protocol": {"type": "socks"}
}});
for chain in [json!("fallback"), client.clone()] {
for protocol in handshake_protocols(chain) {
let config = json!([
{"client_group": "fallback", "client_proxies": [client.clone(), client.clone()]},
{"address": available_address().to_string(), "protocol": protocol}
]);
std::fs::write(&path, serde_yaml::to_string(&config).unwrap()).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_shoes"))
.args(["check", "-t", "1"])
.arg(&path)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let mut child = Process::start(&path, &["--no-reload"]);
child.wait_for("Servers ready").await;
}
}
}
#[test]
fn check_rejects_invalid_protocol_constructor_settings() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
for config in invalid_protocol_configs(available_address()) {
std::fs::write(&path, config).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_shoes"))
.args(["check", "-t", "1"])
.arg(&path)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
let error = String::from_utf8_lossy(&output.stderr);
assert!(!error.contains("panicked"), "{error}");
assert!(!error.contains("do-not-log-this-secret"), "{error}");
}
}
#[test]
fn startup_rejects_invalid_server_protocols_without_panics_or_credentials() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
for config in invalid_server_protocol_configs(available_address())
.into_iter()
.chain(invalid_server_auth_configs(available_address()))
{
std::fs::write(&path, config).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_shoes"))
.args(["--no-reload", "-t", "1"])
.arg(&path)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
let error = String::from_utf8_lossy(&output.stderr);
assert!(!error.contains("panicked"), "{error}");
assert!(!error.contains("do-not-log-this-secret"), "{error}");
}
}
#[tokio::test]
async fn check_alias_and_validation_exit_codes() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let occupied = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let valid = format!(
"- address: '{}'\n protocol: {{type: http}}\n",
occupied.local_addr().unwrap()
);
let empty_dns = format!("{valid} dns: {{servers: []}}\n");
for option in ["--dry-run", "check"] {
for (config, expected) in [
(valid.as_str(), 0),
(empty_dns.as_str(), 0),
(
"- address: '127.0.0.1:1'\n transport: udp\n protocol: {type: http}\n",
1,
),
("not: [valid", 1),
] {
std::fs::write(&path, config).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_shoes"))
.args([option, "-t", "1"])
.arg(&path)
.output()
.unwrap();
assert_eq!(
output.status.code(),
Some(expected),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!String::from_utf8_lossy(&output.stderr).contains("panicked"));
}
}
std::fs::remove_file(&path).unwrap();
let mut child = Process::start(&path, &[]);
assert_eq!(child.exit().await.code(), Some(1));
let version = Command::new(env!("CARGO_BIN_EXE_shoes"))
.arg("version")
.output()
.unwrap();
assert!(version.status.success());
assert_eq!(
String::from_utf8(version.stdout).unwrap().trim(),
concat!("shoes ", env!("CARGO_PKG_VERSION"))
);
}
#[tokio::test]
async fn unsupported_quic_allowlists_fail_validation_and_start() {
use serde_json::json;
let groups = json!({"key_exchange_groups": ["X25519MLKEM768"]});
let direct = json!({"protocol": {"type": "direct"}});
let guarded_direct =
json!({"protocol": {"type": "direct"}, "transport": "quic", "quic_settings": groups});
let guarded_proxy = json!({"address": "127.0.0.1:443", "protocol": {"type": "portforward"}, "transport": "quic", "quic_settings": groups});
for chain in [
json!([guarded_direct]),
json!([direct, guarded_proxy]),
json!([direct, {"pool": ["guarded"]}]),
] {
let config = json!([
{"client_group": "guarded", "client_proxies": [guarded_proxy]},
{"address": "0.0.0.0:0", "protocol": {"type": "http"}, "rules": [{"mask": "0.0.0.0/0", "action": "allow", "client_chain": chain}]}
]);
let config_file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(config_file.path(), serde_yaml::to_string(&config).unwrap()).unwrap();
for mode in ["--dry-run", "--no-reload"] {
let mut process = Process::start(config_file.path(), &[mode]);
process.wait_for("QUIC key_exchange_groups").await;
assert_eq!(process.exit().await.code(), Some(1));
}
}
}
#[tokio::test]
async fn initial_partial_bind_failure_exits_and_releases_addresses() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let first = available_address();
let occupied = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
for config in [
format!(
"- address: ['{first}', '{}']\n protocol: {{type: http}}\n",
occupied.local_addr().unwrap()
),
format!(
"- address: '{first}'\n protocol: {{type: http}}\n- address: '{}'\n protocol: {{type: http}}\n",
occupied.local_addr().unwrap()
),
] {
std::fs::write(&path, config).unwrap();
let mut child = Process::start(&path, &["--no-reload"]);
assert_eq!(child.exit().await.code(), Some(1));
std::net::TcpListener::bind(first).unwrap();
}
}
#[cfg(unix)]
#[tokio::test]
async fn rejected_reload_preserves_traffic_and_valid_reload_drains_tcp() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let echo = shoes_test_support::test_servers::start_tcp_stream_echo_server("127.0.0.1", 0)
.await
.unwrap();
let address = available_address();
let valid = forward(address, echo.local_addr());
std::fs::write(&path, &valid).unwrap();
let mut child = Process::start(&path, &["--no-reload"]);
child.wait_for("Servers ready").await;
let mut existing = TcpStream::connect(address).await.unwrap();
roundtrip(&mut existing).await;
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
let other = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
let mismatched_identity = serde_yaml::to_string(&serde_json::json!([{
"address": address.to_string(),
"protocol": {"type": "tls", "tls_targets": {"localhost": {
"cert": cert.cert.pem(),
"key": other.signing_key.serialize_pem(),
"protocol": {"type": "http"}
}}}
}]))
.unwrap();
let mut invalid_configs = vec![
"invalid: [yaml".to_owned(),
format!(
"- address: '{address}'\n protocol:\n type: tls\n tls_targets:\n localhost:\n cert: absent.pem\n key: absent.key\n protocol: {{type: http}}\n"
),
format!(
"- address: '{address}'\n protocol:\n type: tls\n tls_targets:\n localhost:\n cert: '-----BEGIN CERTIFICATE-----bad'\n key: '-----BEGIN PRIVATE KEY-----bad'\n protocol: {{type: http}}\n"
),
mismatched_identity,
];
invalid_configs.extend(invalid_protocol_configs(address));
if cfg!(target_os = "linux") {
invalid_configs.push(format!("{valid}\n- dns_group: failing\n dns_servers:\n - url: udp://127.0.0.1\n client_chain:\n address: '127.0.0.1:443'\n transport: quic\n bind_interface: shoes-missing-interface\n protocol: {{type: socks}}\n"));
}
for invalid in invalid_configs {
std::fs::write(&path, invalid).unwrap();
child.signal(libc::SIGHUP);
child.wait_for("Reload rejected").await;
roundtrip(&mut existing).await;
roundtrip(&mut TcpStream::connect(address).await.unwrap()).await;
}
std::fs::write(&path, valid).unwrap();
child.signal(libc::SIGHUP);
child.wait_for("Servers ready").await;
roundtrip(&mut existing).await;
roundtrip(&mut TcpStream::connect(address).await.unwrap()).await;
let result = timeout(Duration::from_secs(4), existing.read_u8())
.await
.unwrap();
assert!(result.is_err());
child.signal(libc::SIGTERM);
assert_eq!(child.exit().await.code(), Some(0));
}
#[cfg(unix)]
#[tokio::test]
async fn connection_budget_changes_only_with_accepted_reloads_and_counts_draining_flows() {
async fn assert_rejected(address: SocketAddr) {
let mut stream = TcpStream::connect(address).await.unwrap();
let _ = stream.write_all(b"over budget").await;
let result = timeout(Duration::from_secs(5), stream.read_u8())
.await
.unwrap();
assert!(result.is_err(), "an over-budget connection was served");
}
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let echo = shoes_test_support::test_servers::start_tcp_stream_echo_server("127.0.0.1", 0)
.await
.unwrap();
let address = available_address();
let config = |max_connections: usize| {
format!(
"- global_limits: {{max_connections: {max_connections}, reload_grace_secs: 60}}\n- address: '{address}'\n protocol:\n type: forward\n target: '{}'\n",
echo.local_addr()
)
};
std::fs::write(&path, config(1)).unwrap();
let mut child = Process::start(&path, &["--no-reload"]);
child.wait_for("Servers ready").await;
let mut draining = TcpStream::connect(address).await.unwrap();
roundtrip(&mut draining).await;
assert_rejected(address).await;
// A rejected candidate's larger budget is never applied.
std::fs::write(&path, config(2).replace("type: forward", "type: unknown")).unwrap();
child.signal(libc::SIGHUP);
child.wait_for("Reload rejected").await;
assert_rejected(address).await;
roundtrip(&mut draining).await;
// Activation applies the candidate cap, then fails after starting its TCP listener.
std::fs::write(
&path,
format!(
"{}- path: '{}'\n protocol: {{type: http}}\n",
config(2),
directory.path().join("missing/listener.sock").display(),
),
)
.unwrap();
child.signal(libc::SIGHUP);
child
.wait_for("Reload rejected; previous servers restored")
.await;
assert_rejected(address).await;
roundtrip(&mut draining).await;
// The accepted generation's budget still counts the previous generation's flow.
std::fs::write(&path, config(2)).unwrap();
child.signal(libc::SIGHUP);
child.wait_for("Servers ready").await;
let mut current = TcpStream::connect(address).await.unwrap();
roundtrip(&mut current).await;
assert_rejected(address).await;
roundtrip(&mut draining).await;
// Closing the draining flow returns its permit.
drop(draining);
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let mut stream = TcpStream::connect(address).await.unwrap();
stream.write_all(b"still serving").await.unwrap();
let mut response = [0; 13];
if timeout(Duration::from_secs(1), stream.read_exact(&mut response))
.await
.is_ok_and(|result| result.is_ok())
{
assert_eq!(&response, b"still serving");
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"the drained flow's permit was not released"
);
}
roundtrip(&mut current).await;
child.signal(libc::SIGTERM);
assert_eq!(child.exit().await.code(), Some(0));
}
#[cfg(unix)]
#[tokio::test]
async fn shutdown_interrupts_watcher_debounce() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let config = format!(
"- address: '{}'\n protocol: {{type: http}}\n",
available_address()
);
std::fs::write(&path, &config).unwrap();
let mut child = Process::start(&path, &[]);
child.wait_for("Servers ready").await;
std::fs::write(&path, config).unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
child.signal(libc::SIGTERM);
assert_eq!(
timeout(Duration::from_secs(2), child.exit())
.await
.unwrap()
.code(),
Some(0)
);
}
#[cfg(unix)]
#[tokio::test]
async fn sighup_completes_pending_file_reload_without_waiting_for_debounce() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let config = format!(
"- address: '{}'\n protocol: {{type: http}}\n",
available_address()
);
std::fs::write(&path, &config).unwrap();
let mut child = Process::start(&path, &[]);
child.wait_for("Servers ready").await;
std::fs::write(&path, config).unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
child.signal(libc::SIGHUP);
timeout(Duration::from_secs(2), child.wait_for("Servers ready"))
.await
.unwrap();
assert!(
timeout(Duration::from_millis(5500), async {
while let Some(line) = child.lines.recv().await {
assert!(!line.contains("Servers ready"), "duplicate reload");
}
})
.await
.is_err()
);
child.signal(libc::SIGTERM);
assert_eq!(child.exit().await.code(), Some(0));
}
#[cfg(unix)]
#[tokio::test]
async fn atomic_replace_keeps_serving_through_debounce() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let echo = shoes_test_support::test_servers::start_tcp_stream_echo_server("127.0.0.1", 0)
.await
.unwrap();
let address = available_address();
let valid = forward(address, echo.local_addr());
std::fs::write(&path, &valid).unwrap();
let mut child = Process::start(&path, &[]);
child.wait_for("Servers ready").await;
let replacement = directory.path().join("replacement.yaml");
std::fs::write(&replacement, &valid).unwrap();
std::fs::rename(replacement, &path).unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
roundtrip(&mut TcpStream::connect(address).await.unwrap()).await;
child.wait_for("Servers ready").await;
roundtrip(&mut TcpStream::connect(address).await.unwrap()).await;
child.signal(libc::SIGINT);
assert_eq!(child.exit().await.code(), Some(0));
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[tokio::test]
async fn hard_link_updates_trigger_reload() {
let directory = tempfile::tempdir().unwrap();
let aliases = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let config = format!(
"- address: '{}'\n protocol: {{type: http}}\n",
available_address()
);
std::fs::write(&path, &config).unwrap();
let mut child = Process::start(&path, &[]);
child.wait_for("Servers ready").await;
for alias in [
directory.path().join("alias.yaml"),
aliases.path().join("alias.yaml"),
] {
std::fs::hard_link(&path, &alias).unwrap();
std::fs::write(&alias, &config).unwrap();
child.wait_for("Servers ready").await;
}
child.signal(libc::SIGTERM);
assert!(child.exit().await.success());
}
#[cfg(unix)]
#[tokio::test]
async fn quic_reload_readiness_excludes_retired_sockets() {
use std::sync::Arc;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let address = socket.local_addr().unwrap();
drop(socket);
let echo = shoes_test_support::test_servers::start_tcp_stream_echo_server("127.0.0.1", 0)
.await
.unwrap();
let certificate = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
let config = serde_json::json!([{
"address": address.to_string(), "transport": "quic",
"quic_settings": {"cert": certificate.cert.pem(), "key": certificate.signing_key.serialize_pem(), "num_endpoints": 2},
"protocol": {"type": "forward", "target": echo.local_addr().to_string()}
}]);
std::fs::write(&path, serde_yaml::to_string(&config).unwrap()).unwrap();
let mut child = Process::start(&path, &["--no-reload"]);
child.wait_for("Servers ready").await;
let mut roots = rustls::RootCertStore::empty();
roots.add(certificate.cert.der().clone()).unwrap();
let config = quinn::ClientConfig::with_root_certificates(Arc::new(roots)).unwrap();
let connect = || async {
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap()).unwrap();
endpoint.set_default_client_config(config.clone());
let connection = timeout(
Duration::from_secs(2),
endpoint.connect(address, "localhost").unwrap(),
)
.await
.unwrap()
.unwrap();
let (mut send, mut recv) = connection.open_bi().await.unwrap();
send.write_all(b"ping").await.unwrap();
let mut response = [0; 4];
timeout(Duration::from_secs(2), recv.read_exact(&mut response))
.await
.unwrap()
.unwrap();
assert_eq!(&response, b"ping");
(endpoint, connection, send, recv)
};
let mut old = Vec::new();
for _ in 0..8 {
old.push(connect().await);
}
child.signal(libc::SIGHUP);
child.wait_for("Servers ready").await;
for _ in 0..24 {
let (_endpoint, connection, _send, _recv) = connect().await;
connection.close(0u32.into(), b"done");
}
for (_, connection, _, _) in &old {
assert!(matches!(
connection.close_reason(),
Some(quinn::ConnectionError::ApplicationClosed(_))
));
}
child.signal(libc::SIGTERM);
assert!(child.exit().await.success());
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_targets_remain_watched_after_retargeting() {
let directory = tempfile::tempdir().unwrap();
let targets = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let first = targets.path().join("first.yaml");
let second = targets.path().join("second.yaml");
let config = format!(
"- address: '{}'\n protocol: {{type: http}}\n",
available_address()
);
std::fs::write(&first, &config).unwrap();
std::fs::write(&second, &config).unwrap();
std::os::unix::fs::symlink(&first, &path).unwrap();
let mut child = Process::start(&path, &[]);
child.wait_for("Servers ready").await;
std::fs::write(&first, &config).unwrap();
child.wait_for("Servers ready").await;
let link = directory.path().join("replacement.yaml");
std::os::unix::fs::symlink(&second, &link).unwrap();
std::fs::rename(link, &path).unwrap();
child.wait_for("Servers ready").await;
std::fs::write(&second, &config).unwrap();
child.wait_for("Servers ready").await;
std::fs::remove_file(&second).unwrap();
child.wait_for("Reload rejected").await;
std::fs::write(&second, &config).unwrap();
child.wait_for("Servers ready").await;
let missing = targets.path().join("missing.yaml");
let link = directory.path().join("replacement.yaml");
std::os::unix::fs::symlink(&missing, &link).unwrap();
std::fs::rename(link, &path).unwrap();
child.wait_for("Reload rejected").await;
std::fs::write(&missing, &config).unwrap();
child.wait_for("Servers ready").await;
let missing_directory = targets.path().join("missing-directory");
let nested = missing_directory.join("config.yaml");
let link = directory.path().join("replacement.yaml");
std::os::unix::fs::symlink(&nested, &link).unwrap();
std::fs::rename(link, &path).unwrap();
child.wait_for("Reload rejected").await;
std::fs::create_dir(&missing_directory).unwrap();
child.wait_for("Reload rejected").await;
std::fs::write(&nested, &config).unwrap();
child.wait_for("Servers ready").await;
child.signal(libc::SIGTERM);
assert_eq!(child.exit().await.code(), Some(0));
}
#[cfg(unix)]
#[tokio::test]
async fn config_watch_directories_need_only_traverse_permission() {
use std::os::unix::fs::PermissionsExt;
struct RestorePermissions(std::path::PathBuf);
impl Drop for RestorePermissions {
fn drop(&mut self) {
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
}
}
if unsafe { libc::geteuid() } == 0 {
return;
}
let directory = tempfile::tempdir().unwrap();
let private = directory.path().join("private");
let config_dir = private.join("config");
std::fs::create_dir_all(&config_dir).unwrap();
let alias = directory.path().join("alias");
std::os::unix::fs::symlink(&config_dir, &alias).unwrap();
let path = alias.join("config.yaml");
let config = format!(
"- address: '{}'\n protocol: {{type: http}}\n",
available_address()
);
std::fs::write(&path, &config).unwrap();
for restricted in [&private, &config_dir] {
std::fs::set_permissions(restricted, std::fs::Permissions::from_mode(0o111)).unwrap();
let _restore = RestorePermissions(restricted.clone());
assert!(std::fs::read_dir(restricted).is_err());
let mut child = Process::start(&path, &[]);
child.wait_for("Servers ready").await;
std::fs::write(&path, &config).unwrap();
child.wait_for("Servers ready").await;
child.signal(libc::SIGTERM);
assert!(child.exit().await.success());
}
}
#[cfg(unix)]
#[tokio::test]
async fn shutdown_interrupts_dns_preparation_without_retiring_listeners() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let echo = shoes_test_support::test_servers::start_tcp_stream_echo_server("127.0.0.1", 0)
.await
.unwrap();
let blackhole = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
let address = available_address();
let valid = forward(address, echo.local_addr());
std::fs::write(&path, &valid).unwrap();
let mut child = Process::start(&path, &["--no-reload"]);
child.wait_for("Servers ready").await;
std::fs::write(&path, format!("{valid}\n- dns_group: pending\n dns_servers:\n - url: https://pending.test/dns-query\n bootstrap_url: udp://{}\n", blackhole.local_addr().unwrap())).unwrap();
child.signal(libc::SIGHUP);
timeout(Duration::from_secs(3), blackhole.recv(&mut [0; 512]))
.await
.unwrap()
.unwrap();
roundtrip(&mut TcpStream::connect(address).await.unwrap()).await;
child.signal(libc::SIGTERM);
assert_eq!(
timeout(Duration::from_secs(2), child.exit())
.await
.unwrap()
.code(),
Some(0)
);
}
#[cfg(unix)]
#[tokio::test]
async fn replacement_bind_failure_retains_service_and_allows_subsequent_reload() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let echo = shoes_test_support::test_servers::start_tcp_stream_echo_server("127.0.0.1", 0)
.await
.unwrap();
let address = available_address();
let candidate_address = available_address();
let occupied = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let config = forward(address, echo.local_addr());
std::fs::write(&path, &config).unwrap();
let mut child = Process::start(&path, &["--no-reload"]);
child.wait_for("Servers ready").await;
let mut retained = TcpStream::connect(address).await.unwrap();
roundtrip(&mut retained).await;
std::fs::write(
&path,
format!(
"{config}- address: '{candidate_address}'\n protocol: {{type: http}}\n- address: '{}'\n protocol: {{type: http}}\n",
occupied.local_addr().unwrap()
),
)
.unwrap();
child.signal(libc::SIGHUP);
child
.wait_for("Reload rejected; current servers retained")
.await;
roundtrip(&mut retained).await;
roundtrip(&mut TcpStream::connect(address).await.unwrap()).await;
drop(std::net::TcpListener::bind(candidate_address).unwrap());
std::fs::write(
&path,
format!("{config}- address: '{candidate_address}'\n protocol: {{type: http}}\n"),
)
.unwrap();
child.signal(libc::SIGHUP);
child.wait_for("Servers ready").await;
roundtrip(&mut retained).await;
roundtrip(&mut TcpStream::connect(address).await.unwrap()).await;
TcpStream::connect(candidate_address).await.unwrap();
child.signal(libc::SIGTERM);
assert_eq!(child.exit().await.code(), Some(0));
let socket = tokio::net::TcpSocket::new_v4().unwrap();
socket.set_reuseaddr(true).unwrap();
socket.bind(address).unwrap();
socket.listen(128).unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn partial_quic_activation_rolls_back_or_shuts_down_without_leaking_listeners() {
for cancel in [false, true] {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let echo = shoes_test_support::test_servers::start_tcp_stream_echo_server("127.0.0.1", 0)
.await
.unwrap();
let address = available_address();
let live = forward(address, echo.local_addr())
.replace("reload_grace_secs: 2", "reload_grace_secs: 60");
std::fs::write(&path, &live).unwrap();
let mut child = Process::start(&path, &["--no-reload"]);
child.wait_for("Servers ready").await;
let mut retained = TcpStream::connect(address).await.unwrap();
roundtrip(&mut retained).await;
let reservation = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let partial = reservation.local_addr().unwrap();
let occupied = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let blocked = occupied.local_addr().unwrap();
drop(reservation);
let certificate = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
let quic = |address: SocketAddr| {
serde_json::json!({
"address": address.to_string(), "transport":"quic",
"quic_settings":{"cert":certificate.cert.pem(), "key":certificate.signing_key.serialize_pem(), "num_endpoints":1},
"protocol":{"type":"forward", "target":echo.local_addr().to_string()}
})
};
let mut candidate: serde_json::Value = serde_yaml::from_str(&live).unwrap();
candidate
.as_array_mut()
.unwrap()
.extend([quic(partial), quic(blocked)]);
std::fs::write(&path, candidate.to_string()).unwrap();
child.signal(libc::SIGHUP);
if cancel {
child.wait_for(&format!("QUIC server at {blocked}")).await;
} else {
child
.wait_for("Reload rejected; previous servers restored")
.await;
roundtrip(&mut retained).await;
roundtrip(&mut TcpStream::connect(address).await.unwrap()).await;
drop(std::net::UdpSocket::bind(partial).unwrap());
candidate.as_array_mut().unwrap().pop();
std::fs::write(&path, candidate.to_string()).unwrap();
child.signal(libc::SIGHUP);
child.wait_for("Servers ready").await;
}
child.signal(libc::SIGTERM);
assert!(child.exit().await.success());
drop(std::net::UdpSocket::bind(partial).unwrap());
let socket = tokio::net::TcpSocket::new_v4().unwrap();
socket.set_reuseaddr(true).unwrap();
socket.bind(address).unwrap();
socket.listen(128).unwrap();
}
}
#[cfg(unix)]
#[tokio::test]
async fn descriptor_tun_can_restart_after_failed_activation_and_releases_on_exit() {
check_descriptor_tun_reload(true).await;
}
#[cfg(unix)]
#[tokio::test]
async fn candidate_only_tun_descriptor_survives_rejected_reload() {
check_descriptor_tun_reload(false).await;
}
#[cfg(unix)]
async fn check_descriptor_tun_reload(initially_enabled: bool) {
use std::os::fd::OwnedFd;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yaml");
let echo = shoes_test_support::test_servers::start_tcp_stream_echo_server("127.0.0.1", 0)
.await
.unwrap();
let address = available_address();
let config = format!(
"{}- device_fd: 0\n rules: allow-all-direct\n",
forward(address, echo.local_addr())
);
let initial = if initially_enabled {
config.clone()
} else {
forward(address, echo.local_addr())
};
std::fs::write(&path, initial).unwrap();
let (mut peer, device) = std::os::unix::net::UnixStream::pair().unwrap();
peer.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_shoes"))
.args(["-t", "1", "--no-reload"])
.arg(&path)
.stdin(Stdio::from(OwnedFd::from(device)))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut child = Process::from_child(child);
child.wait_for("Servers ready").await;
// Proxy entries start first; a second TUN forces failure after descriptor adoption.
std::fs::write(
&path,
format!("{config}- device_fd: -1\n rules: allow-all-direct\n"),
)
.unwrap();
child.signal(libc::SIGHUP);
child
.wait_for("Reload rejected; previous servers restored")
.await;
let error = std::io::Read::read(&mut peer, &mut [0])
.expect_err("rejected reload closed the inherited TUN descriptor");
assert!(matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
));
roundtrip(&mut TcpStream::connect(address).await.unwrap()).await;
std::fs::write(&path, config).unwrap();
child.signal(libc::SIGHUP);
child.wait_for("Servers ready").await;
std::fs::write(&path, forward(address, echo.local_addr())).unwrap();
child.signal(libc::SIGHUP);
child.wait_for("Servers ready").await;
assert_eq!(std::io::Read::read(&mut peer, &mut [0]).unwrap(), 0);
child.signal(libc::SIGTERM);
assert!(child.exit().await.success());
}
#[cfg(unix)]
#[tokio::test]
async fn rollback_failure_is_reported_and_partial_services_are_stopped() {
let directory = tempfile::tempdir().unwrap();
let parent = directory.path().join("old");
std::fs::create_dir(&parent).unwrap();
let socket = parent.join("listener.sock");
let path = directory.path().join("config.yaml");
std::fs::write(
&path,
format!(
"- path: '{}'\n protocol: {{type: socks}}\n",
socket.display()
),
)
.unwrap();
let mut child = Process::start(&path, &["--no-reload"]);
child.wait_for("Servers ready").await;
std::fs::remove_file(&socket).unwrap();
std::fs::remove_dir(parent).unwrap();
let partial = available_address();
std::fs::write(&path, format!("- address: '{partial}'\n protocol: {{type: socks}}\n- path: '{}'\n protocol: {{type: socks}}\n", directory.path().join("missing/listener.sock").display())).unwrap();
child.signal(libc::SIGHUP);
child.wait_for("rollback failed").await;
assert_eq!(child.exit().await.code(), Some(1));
std::net::TcpListener::bind(partial).unwrap();
}