diff --git a/README.md b/README.md index 91b43ba2..9a1c6e62 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/crates/shadowsocks-service/src/acl/mod.rs b/crates/shadowsocks-service/src/acl/mod.rs index 2847fbcf..ad6d517b 100644 --- a/crates/shadowsocks-service/src/acl/mod.rs +++ b/crates/shadowsocks-service/src/acl/mod.rs @@ -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: P) -> io::Result { 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`), 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, +} + +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 { + 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> for AccessControlHandle { + fn from(acl: Arc) -> 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)); + } +} diff --git a/crates/shadowsocks-service/src/config.rs b/crates/shadowsocks-service/src/config.rs index 1c8bc023..3bc451e2 100644 --- a/crates/shadowsocks-service/src/config.rs +++ b/crates/shadowsocks-service/src/config.rs @@ -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) } diff --git a/crates/shadowsocks-service/src/local/context.rs b/crates/shadowsocks-service/src/local/context.rs index b5f88649..b06467a2 100644 --- a/crates/shadowsocks-service/src/local/context.rs +++ b/crates/shadowsocks-service/src/local/context.rs @@ -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>, + // 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>, // Flow statistic report flow_stat: Arc, @@ -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) { - 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> { + 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) { diff --git a/crates/shadowsocks-service/src/local/dns/server.rs b/crates/shadowsocks-service/src/local/dns/server.rs index 797a1f4c..11f66cda 100644 --- a/crates/shadowsocks-service/src/local/dns/server.rs +++ b/crates/shadowsocks-service/src/local/dns/server.rs @@ -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) diff --git a/crates/shadowsocks-service/src/local/mod.rs b/crates/shadowsocks-service/src/local/mod.rs index 6e0d10e8..1863c2ce 100644 --- a/crates/shadowsocks-service/src/local/mod.rs +++ b/crates/shadowsocks-service/src/local/mod.rs @@ -85,6 +85,9 @@ pub struct Server { flow_stat: Arc, #[cfg(feature = "local-online-config")] online_config: Option, + /// Shared global `ServiceContext`, for runtime operations on the whole service + /// (e.g. hot-reloading the global ACL file) + context: Arc, } 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 { + self.context.clone() + } + /// Get SOCKS server instances pub fn socks_servers(&self) -> &[Socks] { &self.socks_servers diff --git a/src/service/local.rs b/src/service/local.rs index 507d9e91..9fc35080 100644 --- a/src/service/local.rs +++ b/src/service/local.rs @@ -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 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 { + // 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) { + 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) { + let _ = context; +} + #[cfg(test)] mod test { use clap::Command; diff --git a/tests/acl_reload.rs b/tests/acl_reload.rs new file mode 100644 index 00000000..2ec86f52 --- /dev/null +++ b/tests/acl_reload.rs @@ -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::().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); +}