mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-01 08:49:16 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user