feat(acl): add runtime ACL replacement via TOML (easytier-cli acl set) (#2513)

Adds a new CLI subcommand to replace the entire ACL at runtime
from a TOML input (either inline or from a file). The command is 
`easytier-cli acl set <TOML|@path>`. 

Co-authored-by: bright <nako_ruru@sina.com>
This commit is contained in:
nako-ruru
2026-08-23 13:35:37 +08:00
committed by GitHub
parent e130e9e4c7
commit b93e45a215
+175 -1
View File
@@ -32,7 +32,7 @@ use tokio::time::timeout;
use easytier::{
common::{constants::EASYTIER_VERSION, stun::runtime_stun_info_collector},
proto::{
acl::AclStats,
acl::{Acl, AclStats},
api::{
config::{
AclPatch, ConfigPatchAction, ConfigRpc, ConfigRpcClientFactory,
@@ -313,10 +313,38 @@ struct AclArgs {
sub_command: Option<AclSubCommand>,
}
#[derive(Args, Debug)]
struct AclSetArgs {
/// Full ACL as a TOML string, or a path to a TOML file prefixed by '@'.
///
/// The TOML uses the same shape as the `[acl]` section of an easytier
/// configuration file, for example:
///
/// ```toml
/// [acl.acl_v1]
/// [[acl.acl_v1.chains]]
/// name = "Inbound"
/// chain_type = 1
/// enabled = true
/// default_action = 2
/// [[acl.acl_v1.chains.rules]]
/// protocol = 3
/// action = 1
/// ```
///
/// Using `@/path/to/acl.toml` makes it easy to manage and debug the full
/// ACL offline. The whole ACL is replaced at runtime without restarting
/// easytier-core.
#[arg(help = "full ACL TOML string or '@path/to/acl.toml'")]
acl: String,
}
#[derive(Subcommand, Debug)]
enum AclSubCommand {
/// Show ACL rule hit statistics
Stats,
/// Replace the whole ACL at runtime without restarting easytier-core
Set(AclSetArgs),
}
#[derive(Args, Debug)]
@@ -1965,6 +1993,149 @@ impl<'a> CommandHandler<'a> {
})
}
async fn apply_acl_set(&self, acl: Acl) -> Result<(), Error> {
// tcp_whitelist/udp_whitelist are separate config knobs that remain in
// effect alongside the ACL; updating them is out of scope for `acl set`.
let client = self.get_config_client().await?;
let request = PatchConfigRequest {
instance: Some(self.instance_selector.clone()),
patch: Some(InstanceConfigPatch {
acl: Some(AclPatch {
acl: Some(acl),
..Default::default()
}),
..Default::default()
}),
};
let _response = client
.patch_config(BaseController::default(), request)
.await?;
Ok(())
}
async fn handle_acl_set(&self, raw: &str) -> Result<(), Error> {
// Load the TOML content either from a file (`@path`) or inline.
let toml_text = if let Some(path) = raw.strip_prefix('@') {
tokio::fs::read_to_string(path)
.await
.with_context(|| format!("failed to read ACL file `{path}`"))?
} else {
raw.to_string()
};
// The input mirrors the `[acl]` section of an easytier TOML config, so
// we wrap the parsed `Acl` inside a top-level `acl` table.
#[derive(serde::Deserialize, Default)]
struct AclToml {
acl: Option<Acl>,
}
let parsed: AclToml = toml::from_str(&toml_text)
.with_context(|| "failed to parse ACL TOML (expected `[acl.acl_v1]` structure)")?;
let acl = parsed.acl.unwrap_or_default();
if acl.is_empty() {
anyhow::bail!(
"parsed ACL is empty; provide at least one chain or a non-empty group under `[acl.acl_v1]`"
);
}
// Redact group secrets before any JSON echo so credentials are never
// written to stdout or captured logs (matches core's dump redaction).
let mut sanitized = acl.clone();
if let Some(group) = sanitized.acl_v1.as_mut().and_then(|v| v.group.as_mut()) {
for declaration in group.declares.iter_mut() {
if !declaration.group_secret.is_empty() {
declaration.group_secret = "<redacted>".to_string();
}
}
}
let chain_count = acl
.acl_v1
.as_ref()
.map(|v| v.chains.len())
.unwrap_or_default();
// Apply to each selected instance, collecting per-instance outcomes so
// a failure on one instance does not leave others partially or
// silently updated.
let outcomes: Vec<(String, Result<(), Error>)> = match self.fanout_targets().await? {
Some(targets) => {
let mut list = Vec::with_capacity(targets.len());
for target in targets {
let label = target.label();
let scoped = self.scoped_to_instance(&target);
list.push((label, scoped.apply_acl_set(acl.clone()).await));
}
list
}
None => vec![(
"selected instance".to_string(),
self.apply_acl_set(acl.clone()).await,
)],
};
let mut failures: Vec<(String, Error)> = Vec::new();
let mut ok = 0usize;
for (label, result) in outcomes {
match result {
Ok(()) => {
ok += 1;
if *self.output_format == OutputFormat::Json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"instance": label,
"success": true,
"chains": chain_count,
"acl": &sanitized,
}))?
);
} else {
println!("== {} ==", label);
println!("ACL updated successfully ({chain_count} chain(s))");
}
}
Err(e) => {
if *self.output_format == OutputFormat::Json {
let _ = serde_json::to_writer(
std::io::stdout(),
&serde_json::json!({
"instance": label,
"success": false,
"error": e.to_string(),
}),
);
} else {
println!("== {} ==", label);
println!("ACL update failed: {e:#}");
}
failures.push((label, e));
}
}
}
if ok == 0 {
return Err(anyhow::anyhow!(
"ACL update failed on all {} selected instance(s)",
failures.len()
));
}
if !failures.is_empty() {
return Err(anyhow::anyhow!(
"ACL update failed on {} of {} selected instance(s): {}",
failures.len(),
ok + failures.len(),
failures
.iter()
.map(|(label, _)| label.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
Ok(())
}
async fn handle_mapped_listener_list(&self) -> Result<(), Error> {
let results = self
.collect_instance_results(|handler| Box::pin(handler.fetch_mapped_listener_list()))
@@ -3314,6 +3485,9 @@ async fn main() -> Result<(), Error> {
Some(AclSubCommand::Stats) | None => {
handler.handle_acl_stats().await?;
}
Some(AclSubCommand::Set(args)) => {
handler.handle_acl_set(&args.acl).await?;
}
},
SubCommand::PortForward(port_forward_args) => match &port_forward_args.sub_command {
Some(PortForwardSubCommand::Add {