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 <[email protected]>
This commit is contained in:
Qiu Yuzhou
2026-09-29 11:47:28 +08:00
committed by GitHub
co-authored by ty
parent a981de7bbf
commit f23366dc0e
8 changed files with 388 additions and 23 deletions
+16
View File
@@ -1172,6 +1172,22 @@ These Ciphers require `"password"` to be a Base64 string of key that have **exac
8.8.8.8
```
### Runtime reload (Unix only)
`sslocal` reloads files at runtime when it receives signals:
- `SIGUSR1` - Reload the server list from the configuration file. Listeners and established connections are not affected. Requires that `sslocal` was started with a configuration file.
- `SIGUSR2` - Reload the ACL file. The file is parsed completely before the new rules are swapped in; if parsing fails, the previous rules stay active. Established connections keep their routing decisions, and only new connections are checked against the new rules.
The configuration file and the ACL file are recorded as absolute paths at startup, so reloads keep working even if the process changes its working directory afterwards (e.g. when daemonized).
Both signals are no-ops on Windows. Example:
```bash
# After editing the ACL file, make sslocal apply it without a restart
kill -USR2 $(pidof sslocal)
```
## Useful Tools
1. `ssurl` is for encoding and decoding ShadowSocks URLs (SIP002). Example:
+156 -5
View File
@@ -11,12 +11,13 @@ use std::{
net::{IpAddr, SocketAddr},
path::{Path, PathBuf},
str,
sync::LazyLock,
sync::{Arc, LazyLock},
};
use arc_swap::ArcSwap;
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
use iprange::IpRange;
use log::{trace, warn};
use log::{error, info, trace, warn};
use regex::bytes::{Regex, RegexBuilder, RegexSet, RegexSetBuilder};
use shadowsocks::{context::Context, relay::socks5::Address};
@@ -346,15 +347,29 @@ pub struct AccessControl {
file_path: PathBuf,
}
/// Resolve `path` into an absolute path, based on the process's current working directory
///
/// Absolute paths are returned unchanged. Paths are deliberately NOT canonicalized:
/// symlinks must be kept intact, so that deployments switching files by re-pointing
/// a symlink (e.g. `current -> acl-v2`) keep working after a reload.
pub(crate) fn absolutize(path: &Path) -> PathBuf {
match std::env::current_dir() {
// `join` returns `path` itself if it is already absolute
Ok(cwd) => cwd.join(path),
Err(..) => path.to_path_buf(),
}
}
impl AccessControl {
/// Load ACL rules from a file
pub fn load_from_file<P: AsRef<Path>>(p: P) -> io::Result<Self> {
trace!("ACL loading from {:?}", p.as_ref());
let file_path_ref = p.as_ref();
let file_path = file_path_ref.to_path_buf();
// Store an absolute path so that reloading keeps working even if the process
// changed its working directory afterwards (e.g. daemonized with `chdir("/")`)
let file_path = absolutize(p.as_ref());
let fp = File::open(file_path_ref)?;
let fp = File::open(p.as_ref())?;
let r = BufReader::new(fp);
let mut mode = Mode::BlackList;
@@ -666,3 +681,139 @@ impl AccessControl {
}
}
}
/// A shareable handle to an [`AccessControl`] with atomically replaceable rules
///
/// [`AccessControl`] itself is immutable after loading. [`AccessControlHandle`] wraps it in an
/// `ArcSwap`, so the active rules can be swapped at runtime without interrupting the
/// service, for example after the ACL file changed on disk. Readers take a cheap
/// snapshot (`Arc<AccessControl>`), which stays valid even after the rules were
/// replaced.
///
/// This is what makes hot-reloading possible: contexts and in-flight connections keep
/// their snapshot of the old rules, while every new check sees the new ones.
#[derive(Debug)]
pub struct AccessControlHandle {
acl: ArcSwap<AccessControl>,
}
impl AccessControlHandle {
/// Create a handle with the given rules
pub fn new(acl: AccessControl) -> Self {
Self {
acl: ArcSwap::from_pointee(acl),
}
}
/// Get a snapshot of the currently active rules
pub fn acl(&self) -> Arc<AccessControl> {
self.acl.load_full()
}
/// Get the file path that the currently active rules were loaded from
pub fn file_path(&self) -> PathBuf {
self.acl.load().file_path().to_path_buf()
}
/// Reload the rules from the ACL file
///
/// The file is parsed completely before the new rules are swapped in. If parsing
/// fails, the previous rules stay active and the error is returned.
pub fn reload(&self) -> io::Result<()> {
let file_path = self.file_path();
match AccessControl::load_from_file(&file_path) {
Ok(acl) => {
self.acl.store(Arc::new(acl));
info!("ACL reloaded from {:?}", file_path);
Ok(())
}
Err(err) => {
error!(
"ACL reload failed for {:?}, keeping the previous rules, error: {}",
file_path, err
);
Err(err)
}
}
}
}
impl From<Arc<AccessControl>> for AccessControlHandle {
fn from(acl: Arc<AccessControl>) -> Self {
Self { acl: ArcSwap::new(acl) }
}
}
#[cfg(test)]
mod test {
use std::net::IpAddr;
use super::*;
fn test_acl_file_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("shadowsocks-acl-test-{}-{name}", std::process::id()))
}
#[test]
fn absolutize_resolves_relative_paths_against_cwd() {
let cwd = std::env::current_dir().unwrap();
assert_eq!(absolutize(Path::new("acl.list")), cwd.join("acl.list"));
assert_eq!(absolutize(&cwd.join("acl.list")), cwd.join("acl.list"));
}
#[test]
fn access_control_handle_reload_swaps_rules() {
let file_path = test_acl_file_path("swap");
std::fs::write(&file_path, "[bypass_all]\n[white_list]\n127.0.0.1\n").unwrap();
let handle = AccessControlHandle::new(AccessControl::load_from_file(&file_path).unwrap());
let loopback: IpAddr = "127.0.0.1".parse().unwrap();
let anycast: IpAddr = "8.8.8.8".parse().unwrap();
assert!(handle.acl().check_ip_in_proxy_list(&loopback));
assert!(!handle.acl().check_ip_in_proxy_list(&anycast));
std::fs::write(&file_path, "[bypass_all]\n[white_list]\n8.8.8.8\n").unwrap();
handle.reload().unwrap();
assert!(!handle.acl().check_ip_in_proxy_list(&loopback));
assert!(handle.acl().check_ip_in_proxy_list(&anycast));
let _ = std::fs::remove_file(&file_path);
}
#[test]
fn access_control_handle_reload_failure_keeps_old_rules() {
let file_path = test_acl_file_path("failure");
std::fs::write(&file_path, "[bypass_all]\n[white_list]\n127.0.0.1\n").unwrap();
let handle = AccessControlHandle::new(AccessControl::load_from_file(&file_path).unwrap());
let loopback: IpAddr = "127.0.0.1".parse().unwrap();
// `[` is not a valid regular expression, so parsing the file must fail
std::fs::write(&file_path, "[\n").unwrap();
handle.reload().unwrap_err();
assert!(handle.acl().check_ip_in_proxy_list(&loopback));
let _ = std::fs::remove_file(&file_path);
}
#[test]
fn access_control_handle_reload_missing_file_keeps_old_rules() {
let file_path = test_acl_file_path("missing");
std::fs::write(&file_path, "[bypass_all]\n[white_list]\n127.0.0.1\n").unwrap();
let handle = AccessControlHandle::new(AccessControl::load_from_file(&file_path).unwrap());
let loopback: IpAddr = "127.0.0.1".parse().unwrap();
std::fs::remove_file(&file_path).unwrap();
handle.reload().unwrap_err();
assert!(handle.acl().check_ip_in_proxy_list(&loopback));
}
}
+5 -3
View File
@@ -77,7 +77,7 @@ use shadowsocks::{
};
use url::Url;
use crate::acl::AccessControl;
use crate::acl::{AccessControl, absolutize};
#[cfg(feature = "local-dns")]
use crate::local::dns::NameServerAddr;
@@ -2828,8 +2828,10 @@ impl Config {
let mut config = Self::load_from_str(&content[..], config_type)?;
// Record the path of the configuration for auto-reloading
config.config_path = Some(filename.to_owned());
// Record the path of the configuration for auto-reloading.
// Store an absolute path so that reloading keeps working even if the process
// changed its working directory afterwards (e.g. daemonized with `chdir("/")`)
config.config_path = Some(absolutize(filename));
Ok(config)
}
+44 -11
View File
@@ -1,5 +1,6 @@
//! Shadowsocks Local Server Context
use std::io;
use std::sync::Arc;
#[cfg(feature = "local-dns")]
use std::{net::IpAddr, time::Duration};
@@ -19,7 +20,7 @@ use tokio::sync::Mutex;
use tokio::sync::RwLock;
use crate::{
acl::AccessControl,
acl::{AccessControl, AccessControlHandle},
config::{OutboundProxy, SecurityConfig},
net::{FlowStat, OutboundProxyClient},
};
@@ -34,8 +35,9 @@ pub struct ServiceContext {
connect_opts: ConnectOpts,
accept_opts: AcceptOpts,
// Access Control
acl: Option<Arc<AccessControl>>,
// Access Control. Wrapped in `AccessControlHandle` so the active rules can be hot-reloaded
// without interrupting the service. Cloned contexts share the same handle.
acl: Option<Arc<AccessControlHandle>>,
// Flow statistic report
flow_stat: Arc<FlowStat>,
@@ -108,13 +110,44 @@ impl ServiceContext {
}
/// Set Access Control List
///
/// This creates a new [`AccessControlHandle`]. Contexts cloned before this call keep sharing
/// the previous handle, which is how a private ACL overrides the global one.
pub fn set_acl(&mut self, acl: Arc<AccessControl>) {
self.acl = Some(acl);
self.acl = Some(Arc::new(AccessControlHandle::from(acl)));
}
/// Get Access Control List reference
pub fn acl(&self) -> Option<&AccessControl> {
self.acl.as_deref()
/// Get a snapshot of the Access Control List
///
/// Returns `None` if no ACL is configured. The returned `Arc` stays valid even if
/// the rules are hot-reloaded afterwards.
pub fn acl(&self) -> Option<Arc<AccessControl>> {
self.acl.as_ref().map(|handle| handle.acl())
}
/// Hot-reload the ACL file
///
/// Re-reads the file that the active rules were loaded from and swaps in the new
/// rules atomically. Established connections are not affected; only new
/// connections will be checked against the new rules.
///
/// If the file cannot be read or parsed, the previous rules stay active and the
/// error is returned. Does nothing if no ACL is configured.
pub async fn reload_acl(&self) -> io::Result<()> {
use log::warn;
let Some(handle) = self.acl.as_ref() else {
warn!("ACL reload requested, but no ACL is configured");
return Ok(());
};
handle.reload()?;
// Decisions derived from the old rules must not outlive them
#[cfg(feature = "local-dns")]
self.reverse_lookup_cache.lock().await.clear();
Ok(())
}
/// Set outbound proxy chain (connection to SS server routes through these proxies)
@@ -154,9 +187,9 @@ impl ServiceContext {
/// Check if target should be bypassed
pub async fn check_target_bypassed(&self, addr: &Address) -> bool {
match self.acl {
match self.acl() {
None => false,
Some(ref acl) => {
Some(acl) => {
#[cfg(feature = "local-dns")]
{
if let Address::SocketAddress(saddr) = addr {
@@ -178,10 +211,10 @@ impl ServiceContext {
#[cfg(feature = "local-dns")]
pub async fn add_to_reverse_lookup_cache(&self, addr: IpAddr, forward: bool) {
let is_exception = forward
!= match self.acl {
!= match self.acl() {
// Proxy everything by default
None => true,
Some(ref a) => a.check_ip_in_proxy_list(&addr),
Some(acl) => acl.check_ip_in_proxy_list(&addr),
};
let mut reverse_lookup_cache = self.reverse_lookup_cache.lock().await;
match reverse_lookup_cache.get_mut(&addr) {
@@ -617,7 +617,8 @@ fn should_forward_by_query(context: &ServiceContext, balancer: &PingBalancer, qu
}
}
if let Some(acl) = context.acl() {
let acl_snapshot = context.acl();
if let Some(acl) = acl_snapshot.as_deref() {
if query.query_class() != DNSClass::IN {
// unconditionally use default for all non-IN queries
Some(acl.is_default_in_proxy_list())
@@ -810,7 +811,7 @@ impl DnsClient {
let decider = async {
let local_response = self.lookup_local(query, local_addr).await;
if should_forward_by_response(self.context.acl(), &local_response, query) {
if should_forward_by_response(self.context.acl().as_deref(), &local_response, query) {
None
} else {
Some(local_response)
@@ -85,6 +85,9 @@ pub struct Server {
flow_stat: Arc<FlowStat>,
#[cfg(feature = "local-online-config")]
online_config: Option<OnlineConfigService>,
/// Shared global `ServiceContext`, for runtime operations on the whole service
/// (e.g. hot-reloading the global ACL file)
context: Arc<ServiceContext>,
}
impl Server {
@@ -233,6 +236,10 @@ impl Server {
balancer_builder.build().await?
};
// Keep a shared copy of the global context, for runtime operations that need
// a handle to the whole service (e.g. hot-reloading the global ACL)
let global_context = Arc::new(context.clone());
let mut local_server = Self {
balancer: balancer.clone(),
socks_servers: Vec::new(),
@@ -267,6 +274,7 @@ impl Server {
Some(builder.build().await?)
}
},
context: global_context,
};
for local_instance in config.local {
@@ -603,6 +611,16 @@ impl Server {
&self.balancer
}
/// Get a shareable handle to the global [`ServiceContext`]
///
/// The returned context is shared with every listener. It can be used for runtime
/// operations on the whole service, e.g. hot-reloading the global ACL file with
/// [`ServiceContext::reload_acl`], typically triggered by a signal. Contexts with
/// their own private ACL (`local.acl` in the configuration file) are not affected.
pub fn context(&self) -> Arc<ServiceContext> {
self.context.clone()
}
/// Get SOCKS server instances
pub fn socks_servers(&self) -> &[Socks] {
&self.socks_servers
+34 -2
View File
@@ -1,6 +1,5 @@
//! Local server launchers
#[cfg(unix)]
use std::sync::Arc;
use std::{
future::Future,
@@ -28,7 +27,7 @@ use shadowsocks_service::{
Config, ConfigType, LocalConfig, LocalInstanceConfig, ProtocolType, ServerInstanceConfig,
read_variable_field_value,
},
local::{Server, loadbalancing::PingBalancer},
local::{Server, context::ServiceContext, loadbalancing::PingBalancer},
shadowsocks::{
config::{Mode, ServerAddr, ServerConfig, ServerSource},
crypto::{CipherKind, available_ciphers},
@@ -1013,14 +1012,19 @@ pub fn create(matches: &ArgMatches) -> ShadowsocksResult<(Runtime, impl Future<O
None => future::pending().boxed(),
};
// Hot-reload the ACL file on SIGUSR2 (Unix only, pending forever elsewhere)
let acl_reload_task = launch_acl_reload_task(instance.context()).boxed();
let abort_signal = monitor::create_signal_monitor();
let server = instance.run();
let reload_task = reload_task.fuse();
let acl_reload_task = acl_reload_task.fuse();
let abort_signal = abort_signal.fuse();
let server = server.fuse();
tokio::pin!(reload_task);
tokio::pin!(acl_reload_task);
tokio::pin!(abort_signal);
tokio::pin!(server);
@@ -1046,6 +1050,10 @@ pub fn create(matches: &ArgMatches) -> ShadowsocksResult<(Runtime, impl Future<O
// continue.
trace!("server-loader task task exited");
}
_ = acl_reload_task => {
// continue.
trace!("acl-reload task exited");
}
}
}
};
@@ -1141,6 +1149,30 @@ impl ServerReloader {
}
}
/// Task listening for `SIGUSR2` and hot-reloading the ACL file for each signal
///
/// Reload failures keep the previous rules active, see `ServiceContext::reload_acl`.
#[cfg(unix)]
async fn launch_acl_reload_task(context: Arc<ServiceContext>) {
use log::debug;
use tokio::signal::unix::{SignalKind, signal};
let mut sigusr2 = signal(SignalKind::user_defined2()).expect("signal");
debug!("acl-reload task is now listening USR2");
while sigusr2.recv().await.is_some() {
let _ = context.reload_acl().await;
}
}
/// Signal-based ACL reload is only supported on Unix platforms. The task completes
/// immediately; the main loop tolerates it, same as the `SIGUSR1` reload task.
#[cfg(not(unix))]
async fn launch_acl_reload_task(context: Arc<ServiceContext>) {
let _ = context;
}
#[cfg(test)]
mod test {
use clap::Command;
+112
View File
@@ -0,0 +1,112 @@
//! 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);
}