diff --git a/easytier/src/easytier-cli.rs b/easytier/src/easytier-cli.rs index 52f5d965..a86bf37c 100644 --- a/easytier/src/easytier-cli.rs +++ b/easytier/src/easytier-cli.rs @@ -355,6 +355,11 @@ enum CredentialSubCommand { Generate { #[arg(long, help = "TTL in seconds (required)")] ttl: i64, + #[arg( + long, + help = "custom credential ID, return existing credential if already generated" + )] + credential_id: Option, #[arg(long, value_delimiter = ',', help = "ACL groups (comma-separated)")] groups: Option>, #[arg( @@ -1417,12 +1422,14 @@ impl CommandHandler<'_> { async fn handle_credential_generate( &self, ttl: i64, + credential_id: Option, groups: Vec, allow_relay: bool, allowed_proxy_cidrs: Vec, ) -> Result<(), Error> { let client = self.get_credential_client().await?; let request = GenerateCredentialRequest { + credential_id, groups, allow_relay, allowed_proxy_cidrs, @@ -2362,6 +2369,7 @@ async fn main() -> Result<(), Error> { SubCommand::Credential(credential_args) => match &credential_args.sub_command { CredentialSubCommand::Generate { ttl, + credential_id, groups, allow_relay, allowed_proxy_cidrs, @@ -2369,6 +2377,7 @@ async fn main() -> Result<(), Error> { handler .handle_credential_generate( *ttl, + credential_id.clone(), groups.clone().unwrap_or_default(), *allow_relay, allowed_proxy_cidrs.clone().unwrap_or_default(), diff --git a/easytier/src/peers/credential_manager.rs b/easytier/src/peers/credential_manager.rs index b2a23954..5833948d 100644 --- a/easytier/src/peers/credential_manager.rs +++ b/easytier/src/peers/credential_manager.rs @@ -15,6 +15,8 @@ use crate::proto::peer_rpc::{TrustedCredentialPubkey, TrustedCredentialPubkeyPro #[derive(Debug, Clone, Serialize, Deserialize)] struct CredentialEntry { pubkey: String, + #[serde(default)] + secret: String, groups: Vec, allow_relay: bool, allowed_proxy_cidrs: Vec, @@ -44,9 +46,47 @@ impl CredentialManager { allowed_proxy_cidrs: Vec, ttl: Duration, ) -> (String, String) { + self.generate_credential_with_id(groups, allow_relay, allowed_proxy_cidrs, ttl, None) + } + + pub fn generate_credential_with_id( + &self, + groups: Vec, + allow_relay: bool, + allowed_proxy_cidrs: Vec, + ttl: Duration, + credential_id: Option, + ) -> (String, String) { + let mut credentials = self.credentials.lock().unwrap(); + let id = if let Some(id) = credential_id + .map(|x| x.trim().to_string()) + .filter(|x| !x.is_empty()) + { + if let Some(existing) = credentials.get(&id) { + if !existing.secret.is_empty() { + return (id, existing.secret.clone()); + } + } + id + } else { + uuid::Uuid::new_v4().to_string() + }; + + let (entry, secret) = Self::build_entry(groups, allow_relay, allowed_proxy_cidrs, ttl); + credentials.insert(id.clone(), entry); + drop(credentials); + self.save_to_disk(); + (id, secret) + } + + fn build_entry( + groups: Vec, + allow_relay: bool, + allowed_proxy_cidrs: Vec, + ttl: Duration, + ) -> (CredentialEntry, String) { let private = StaticSecret::random_from_rng(rand::rngs::OsRng); let public = PublicKey::from(&private); - let id = uuid::Uuid::new_v4().to_string(); let pubkey = BASE64_STANDARD.encode(public.as_bytes()); let secret = BASE64_STANDARD.encode(private.as_bytes()); @@ -58,16 +98,14 @@ impl CredentialManager { let entry = CredentialEntry { pubkey, + secret: secret.clone(), groups, allow_relay, allowed_proxy_cidrs, expiry_unix, created_at_unix: now, }; - - self.credentials.lock().unwrap().insert(id.clone(), entry); - self.save_to_disk(); - (id, secret) + (entry, secret) } pub fn revoke_credential(&self, credential_id: &str) -> bool { @@ -404,4 +442,35 @@ mod tests { let list = mgr.list_credentials(); assert_eq!(list.len(), 1); } + + #[test] + fn test_generate_with_specified_id_reuses_existing_result() { + let mgr = CredentialManager::new(None); + let fixed_id = "fixed-credential-id".to_string(); + let (id1, secret1) = mgr.generate_credential_with_id( + vec!["group-a".to_string()], + false, + vec!["10.0.0.0/24".to_string()], + Duration::from_secs(3600), + Some(fixed_id.clone()), + ); + let (id2, secret2) = mgr.generate_credential_with_id( + vec!["group-b".to_string()], + true, + vec!["192.168.0.0/16".to_string()], + Duration::from_secs(7200), + Some(fixed_id.clone()), + ); + + assert_eq!(id1, fixed_id); + assert_eq!(id2, fixed_id); + assert_eq!(secret1, secret2); + + let list = mgr.list_credentials(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].credential_id, fixed_id); + assert_eq!(list[0].groups, vec!["group-a".to_string()]); + assert!(!list[0].allow_relay); + assert_eq!(list[0].allowed_proxy_cidrs, vec!["10.0.0.0/24".to_string()]); + } } diff --git a/easytier/src/peers/rpc_service.rs b/easytier/src/peers/rpc_service.rs index 61e1cda4..354d5d47 100644 --- a/easytier/src/peers/rpc_service.rs +++ b/easytier/src/peers/rpc_service.rs @@ -232,12 +232,15 @@ impl CredentialManageRpc for PeerManagerRpcService { ))); }; - let (id, secret) = global_ctx.get_credential_manager().generate_credential( - request.groups, - request.allow_relay, - request.allowed_proxy_cidrs, - ttl, - ); + let (id, secret) = global_ctx + .get_credential_manager() + .generate_credential_with_id( + request.groups, + request.allow_relay, + request.allowed_proxy_cidrs, + ttl, + request.credential_id, + ); global_ctx.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged); diff --git a/easytier/src/proto/api_instance.proto b/easytier/src/proto/api_instance.proto index 1230adb9..041f29fe 100644 --- a/easytier/src/proto/api_instance.proto +++ b/easytier/src/proto/api_instance.proto @@ -300,6 +300,7 @@ message GenerateCredentialRequest { bool allow_relay = 2; // optional: allow relay through credential node repeated string allowed_proxy_cidrs = 3; // optional: restrict proxy_cidrs int64 ttl_seconds = 4; // must be > 0: credential TTL in seconds (0 / omitted is invalid) + optional string credential_id = 5; // optional: user-specified credential id, reused if already exists } message GenerateCredentialResponse {