Files
shadowsocks-rust/tests/acl_reload.rs
T
Qiu Yuzhouandty f23366dc0e feat(local): hot-reload the ACL file at runtime via SIGUSR2 (#2183)
* feat(acl): add AclHandle to swap ACL rules atomically at runtime

AccessControl is immutable after loading, and ServiceContext used to store
it directly, so rules were frozen for the lifetime of the process.

Wrap the active rules in an ArcSwap-based AclHandle. Readers take a cheap
Arc snapshot which stays valid across a swap, enabling hot-reloading
without interrupting the service. reload() re-parses the retained file
path and only swaps the new rules in when parsing succeeds.

* feat(local): hot-reload the global ACL file through ServiceContext

Store the ACL as an AclHandle in ServiceContext. set_acl keeps its
signature and wraps the rules in a handle; cloned contexts share it, so
every listener sees the same swappable rules. acl() now returns an
Arc<AccessControl> snapshot instead of a reference, which cannot be
preserved across an atomic swap.

ServiceContext::reload_acl() re-reads the ACL file, swaps the new rules
in atomically and clears the DNS relay's reverse-lookup cache, which
caches decisions derived from the old rules. On failure the previous
rules stay active.

Server keeps the global context and exposes acl_reload_context() so
embedders can trigger reloads. Contexts with a private ACL keep their
own handle and are not covered by the global reload.

* feat(local): reload the ACL file on SIGUSR2 in sslocal

sslocal already reloads its server list from the config file on SIGUSR1.
Add a second reload trigger on SIGUSR2 for the ACL file, so routing
rules can be updated without restarting the process or interrupting
established connections.

Like the SIGUSR1 reloader, the task is Unix-only and compiles away on
Windows. ssservice's local mode shares this module and inherits it.

Document both signals in the README and cover the reload with an
integration test that verifies new connections are routed by the new
rules after each reload.

* refactor(acl): address review feedback for ACL hot-reload

- Rename `AclHandle` to `AccessControlHandle`, aligning the name with `AccessControl`
- Rename `Server::acl_reload_context` to `Server::context`, so the shared global context can be reused for other runtime operations
- Fold reload logging into `AccessControlHandle::reload`, keeping `ServiceContext::reload_acl` thin
- Record the config file and the ACL file as absolute paths at load time, so both SIGUSR1 and SIGUSR2 reloads keep working after daemonizing (which `chdir`s to `/`); symlinks are deliberately preserved by resolving with cwd instead of canonicalizing

* refactor(local): drop needless pending() in the non-Unix ACL reload task stub

The task may complete immediately: the main loop already tolerates early task exit, mirroring the existing Windows behavior of the SIGUSR1 reload task. This also releases the ServiceContext clone right away instead of holding it forever.

---------

Co-authored-by: ty <zonyitoo@gmail.com>
2026-09-29 11:47:28 +08:00

113 lines
4.0 KiB
Rust

//! Integration tests for hot-reloading the ACL file at runtime
//!
//! The ACL is only consulted when a connection is established, so swapping it must
//! change the routing of new connections without affecting the running server.
#![cfg(all(feature = "local", feature = "server"))]
use std::{net::SocketAddr, path::PathBuf, time::Duration};
use tokio::{io::AsyncWriteExt, net::TcpListener, time};
use shadowsocks_service::{
acl::AccessControl,
config::{Config, ConfigType, LocalConfig, LocalInstanceConfig, ProtocolType, ServerInstanceConfig},
local::{Server, socks::client::socks5::Socks5TcpClient},
shadowsocks::{
config::{ServerAddr, ServerConfig},
crypto::CipherKind,
relay::socks5::Address,
},
};
const LOCAL_ADDR: &str = "127.0.0.1:8420";
// A closed port. Connections routed through the shadowsocks server will fail.
const DEAD_SERVER_ADDR: &str = "127.0.0.1:1";
const ACL_BYPASS_LOOPBACK: &str = "[bypass_all]\n[white_list]\n8.8.8.8\n";
const ACL_PROXY_LOOPBACK: &str = "[bypass_all]\n[white_list]\n127.0.0.1\n";
async fn spawn_echo_server() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut sock, _) = match listener.accept().await {
Ok(accepted) => accepted,
Err(..) => return,
};
let _ = sock.write_all(b"OK\n").await;
}
});
addr
}
fn local_config_with_acl(acl_path: &std::path::Path, local_addr: SocketAddr) -> Config {
let mut cfg = Config::new(ConfigType::Local);
cfg.acl = Some(AccessControl::load_from_file(acl_path).unwrap());
cfg.local = vec![LocalInstanceConfig::with_local_config(LocalConfig::new_with_addr(
ServerAddr::from(local_addr),
ProtocolType::Socks,
))];
cfg.server = vec![ServerInstanceConfig::with_server_config(
ServerConfig::new(
DEAD_SERVER_ADDR.parse::<ServerAddr>().unwrap(),
"test-password".to_owned(),
CipherKind::AES_256_GCM,
)
.unwrap(),
)];
cfg
}
#[tokio::test]
async fn acl_hot_reload_changes_new_connection_routing() {
let _ = env_logger::try_init();
let target_addr = spawn_echo_server().await;
let local_addr: SocketAddr = LOCAL_ADDR.parse().unwrap();
let acl_path: PathBuf = std::env::temp_dir().join(format!("shadowsocks-acl-reload-it-{}", std::process::id()));
std::fs::write(&acl_path, ACL_BYPASS_LOOPBACK).unwrap();
let config = local_config_with_acl(&acl_path, local_addr);
let server = Server::new(config).await.unwrap();
let context = server.context();
tokio::spawn(server.run());
// No shadowsocks server is running at all: proxied connections fail because they
// cannot reach the configured server address, while bypassed connections connect
// to the local echo server directly.
time::sleep(Duration::from_secs(1)).await;
// The loopback target is not in white_list, so it is bypassed and reaches the echo server
let mut c = Socks5TcpClient::connect(Address::SocketAddress(target_addr), &local_addr)
.await
.expect("bypassed connection should reach the target");
c.shutdown().await.unwrap();
// Loopback moves into white_list: the target is now proxied, and the proxy is unreachable
std::fs::write(&acl_path, ACL_PROXY_LOOPBACK).unwrap();
context.reload_acl().await.unwrap();
let result = Socks5TcpClient::connect(Address::SocketAddress(target_addr), &local_addr).await;
assert!(
result.is_err(),
"proxied connection through the dead server should fail"
);
// Reload back: new connections are bypassed again
std::fs::write(&acl_path, ACL_BYPASS_LOOPBACK).unwrap();
context.reload_acl().await.unwrap();
let mut c = Socks5TcpClient::connect(Address::SocketAddress(target_addr), &local_addr)
.await
.expect("connection should be bypassed again after reloading");
c.shutdown().await.unwrap();
let _ = std::fs::remove_file(&acl_path);
}