From d96eaa668bb0748da0f7c5b7aed67ad49df01a18 Mon Sep 17 00:00:00 2001 From: fanyang Date: Sat, 10 Oct 2026 12:33:47 +0800 Subject: [PATCH] feat(cli): show direct peers using temporary credentials (#2663) Add credential peers with table and JSON output while preserving credential list. Match authenticated remote credential keys to stored fingerprints and virtual route addresses. Keep instance results isolated and exclude unproven relay users. Enable SHA-256 for CLI-only builds and add regression and multi-instance tests. Accept omitted protobuf default lists in the existing multi-instance test. Refs #2529 --- docs/credential-peers.md | 55 +++++ easytier/Cargo.toml | 2 +- easytier/src/easytier-cli.rs | 386 +++++++++++++++++++++++++++++- script/test-cli-multi-instance.sh | 34 ++- 4 files changed, 470 insertions(+), 7 deletions(-) create mode 100644 docs/credential-peers.md diff --git a/docs/credential-peers.md b/docs/credential-peers.md new file mode 100644 index 00000000..fd8b737c --- /dev/null +++ b/docs/credential-peers.md @@ -0,0 +1,55 @@ +# Inspect credential users from the CLI + +On a node that stores the issued credentials, list their currently authenticated +direct peers: + +```sh +easytier-cli credential peers + +# Select one network instance and return machine-readable output. +easytier-cli --instance-name my-network --output json credential peers +``` + +The table associates each credential ID with peer IDs, virtual IPv4/IPv6 +addresses, and hostnames. A reusable credential can have multiple peers; +multiple tunnels to the same peer appear only once. Existing `credential list` +output is unchanged. + +JSON output has this shape for one instance: + +```json +{ + "credentials": [ + { + "credential_id": "user-123", + "peers": [ + { + "peer_id": 42, + "ipv4": "10.144.144.2", + "ipv6": null, + "hostname": "laptop" + } + ] + } + ] +} +``` + +Without an explicit instance selector, the command follows the other CLI query +commands: it queries running instances, wrapping multiple results with their +instance IDs and names. + +## Scope + +The association uses the remote public key of an authenticated live credential +connection, matched against the credential's SHA-256 public-key fingerprint. +It never uses a route advertisement alone as proof of credential identity. +Credential secrets and connection keys are not printed. + +This is a per-node snapshot, not a network-wide login history. An empty `peers` +array (shown as `no direct peers` in the table) does not mean a credential is +unused elsewhere: its users may only be connected to another node or reachable +through a relay. Older cores without credential fingerprints cannot be matched. +Missing route addresses are returned as `null` (shown as `-`); a peer may connect +before its route or DHCP address is available. Addresses and connection state +can change between the RPC snapshots. diff --git a/easytier/Cargo.toml b/easytier/Cargo.toml index 225c609a..f31c2110 100644 --- a/easytier/Cargo.toml +++ b/easytier/Cargo.toml @@ -355,7 +355,7 @@ full = [ "extended-services", "tcp-hole-punch", ] -cli = ["management", "dep:tabled", "dep:humansize", "dep:terminal_size", "dep:unicode-width"] +cli = ["management", "dep:tabled", "dep:humansize", "dep:terminal_size", "dep:unicode-width", "dep:sha2"] wireguard = ["vpn-portal", "dep:boringtun", "dep:hkdf", "dep:sha2", "ring-crypto", "easytier-proto/wireguard"] quic = ["wrapped-transport", "easytier-core/proxy-packet", "dep:quinn", "dep:quinn-proto", "dep:seahash", "dep:rustls", "easytier-proto/quic"] kcp = ["wrapped-transport", "easytier-core/proxy-packet", "dep:kcp-sys"] diff --git a/easytier/src/easytier-cli.rs b/easytier/src/easytier-cli.rs index bcb2fa8c..518e8c6e 100644 --- a/easytier/src/easytier-cli.rs +++ b/easytier/src/easytier-cli.rs @@ -24,6 +24,7 @@ use easytier_core::connectivity::stun::StunInfoProvider as _; use humansize::format_size; use rust_i18n::t; use service_manager::*; +use sha2::{Digest as _, Sha256}; use tabled::settings::{Modify, Remove, Style, Width, location::ByColumnName, object::Columns}; use terminal_size::{Width as TerminalWidth, terminal_size}; use unicode_width::UnicodeWidthStr; @@ -43,7 +44,7 @@ use easytier::{ }, instance::{ AclManageRpc, AclManageRpcClientFactory, Connector, ConnectorManageRpc, - ConnectorManageRpcClientFactory, CredentialManageRpc, + ConnectorManageRpcClientFactory, CredentialInfo, CredentialManageRpc, CredentialManageRpcClientFactory, DumpRouteRequest, ForeignNetworkEntryPb, GenerateCredentialRequest, GetAclStatsRequest, GetPrometheusStatsRequest, GetStatsRequest, GetVpnPortalInfoRequest, GetWhitelistRequest, @@ -53,8 +54,8 @@ use easytier::{ ListPeerResponse, ListPortForwardRequest, ListPortForwardResponse, ListPublicIpv6InfoRequest, ListPublicIpv6InfoResponse, ListRouteRequest, ListRouteResponse, MappedListener, MappedListenerManageRpc, - MappedListenerManageRpcClientFactory, MetricSnapshot, NodeInfo, PeerManageRpc, - PeerManageRpcClientFactory, PortForwardManageRpc, + MappedListenerManageRpcClientFactory, MetricSnapshot, NodeInfo, PeerInfo, + PeerManageRpc, PeerManageRpcClientFactory, PortForwardManageRpc, PortForwardManageRpcClientFactory, RevokeCredentialRequest, Route as ApiRoute, ShowNodeInfoRequest, StatsRpc, StatsRpcClientFactory, TcpProxyEntryState, TcpProxyEntryTransportType, TcpProxyRpc, TcpProxyRpcClientFactory, @@ -73,7 +74,10 @@ use easytier::{ }, }, common::{NatType, PortForwardConfigPb, SocketType}, - peer_rpc::{GetGlobalPeerMapRequest, PeerCenterRpc, PeerCenterRpcClientFactory}, + peer_rpc::{ + GetGlobalPeerMapRequest, PeerCenterRpc, PeerCenterRpcClientFactory, PeerIdentityType, + SecureAuthLevel, + }, rpc::standalone::{RuntimeRpcClient, runtime_rpc_client}, rpc_types::{controller::BaseController, error::Error as RpcError}, }, @@ -483,6 +487,11 @@ enum CredentialSubCommand { }, /// List all active credentials List, + /// Show credentials and their currently authenticated direct peers + /// + /// Run on a node storing the credentials. Relayed users are not included; + /// an empty peer list does not mean the credential is unused elsewhere. + Peers, } #[derive(Args, Debug)] @@ -625,9 +634,351 @@ fn parse_vpn_portal_client_cidr(value: &str) -> anyhow::Result { .map_err(|error| anyhow::anyhow!("invalid client virtual IPv4 CIDR ({value}): {error}")) } +#[derive(Debug, serde::Serialize)] +struct CredentialPeersData { + credentials: Vec, +} + +#[derive(Debug, serde::Serialize)] +struct CredentialPeersEntry { + credential_id: String, + peers: Vec, +} + +#[derive(Clone, Debug, serde::Serialize)] +struct CredentialPeerNode { + peer_id: u32, + ipv4: Option, + ipv6: Option, + hostname: String, +} + +fn build_credential_peers( + credentials: Vec, + peers: Vec, + routes: Vec, +) -> CredentialPeersData { + let routes = routes + .into_iter() + .map(|route| (route.peer_id, route)) + .collect::>(); + let mut peers_by_fingerprint = HashMap::>::new(); + for peer in peers { + for conn in &peer.conns { + if conn.is_closed + || conn.peer_identity_type != PeerIdentityType::Credential as i32 + || !matches!( + SecureAuthLevel::try_from(conn.secure_auth_level), + Ok(SecureAuthLevel::PeerVerified | SecureAuthLevel::NetworkSecretConfirmed) + ) + || conn.noise_remote_static_pubkey.len() != 32 + { + continue; + } + // Match the SHA-256 fingerprint returned by ListCredentials. Only + // authenticated remote connection keys identify credential users; + // route advertisements alone do not establish this association. + let fingerprint = Sha256::digest(&conn.noise_remote_static_pubkey) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let route = routes.get(&peer.peer_id); + peers_by_fingerprint + .entry(fingerprint) + .or_default() + .entry(peer.peer_id) + .or_insert_with(|| CredentialPeerNode { + peer_id: peer.peer_id, + ipv4: route + .and_then(|route| route.ipv4_addr) + .and_then(|inet| inet.address) + .map(|address| address.to_string()), + ipv6: route + .and_then(|route| route.ipv6_addr) + .and_then(|inet| inet.address) + .map(|address| address.to_string()), + hostname: route + .map(|route| route.hostname.clone()) + .unwrap_or_default(), + }); + } + } + let mut credentials = credentials + .into_iter() + .map(|credential| CredentialPeersEntry { + peers: peers_by_fingerprint + .get(&credential.public_key_fingerprint) + .map(|peers| peers.values().cloned().collect()) + .unwrap_or_default(), + credential_id: credential.credential_id, + }) + .collect::>(); + credentials.sort_by(|a, b| a.credential_id.cmp(&b.credential_id)); + CredentialPeersData { credentials } +} + +fn format_credential_peers(data: &CredentialPeersData) -> String { + if data.credentials.is_empty() { + return "No active credentials".to_owned(); + } + let mut builder = tabled::builder::Builder::default(); + builder.push_record(["Credential ID", "Peer ID", "IPv4", "IPv6", "Hostname"]); + for credential in &data.credentials { + if credential.peers.is_empty() { + builder.push_record([&credential.credential_id, "no direct peers", "-", "-", "-"]); + } + for peer in &credential.peers { + builder.push_record([ + &credential.credential_id, + &peer.peer_id.to_string(), + peer.ipv4.as_deref().unwrap_or("-"), + peer.ipv6.as_deref().unwrap_or("-"), + &peer.hostname, + ]); + } + } + builder.build().with(Style::rounded()).to_string() +} + #[cfg(test)] mod tests { use super::*; + use easytier::proto::api::instance::PeerConnInfo; + + const CREDENTIAL_FINGERPRINT: &str = + "4bb06f8e4e3a7715d201d573d0aa423762e55dabd61a2c02278fa56cc6d294e0"; + + fn credential(id: &str) -> CredentialInfo { + CredentialInfo { + credential_id: id.to_owned(), + public_key_fingerprint: CREDENTIAL_FINGERPRINT.to_owned(), + ..Default::default() + } + } + + fn credential_peer(peer_id: u32) -> PeerInfo { + PeerInfo { + peer_id, + conns: vec![PeerConnInfo { + peer_id, + noise_remote_static_pubkey: vec![7; 32], + peer_identity_type: PeerIdentityType::Credential as i32, + secure_auth_level: SecureAuthLevel::PeerVerified as i32, + ..Default::default() + }], + ..Default::default() + } + } + + fn credential_route(peer_id: u32, ipv4: &str) -> ApiRoute { + ApiRoute { + peer_id, + ipv4_addr: Some(ipv4.parse().unwrap()), + ipv6_addr: Some("fd00::2/64".parse().unwrap()), + hostname: format!("peer-{peer_id}"), + ..Default::default() + } + } + + #[test] + fn credential_peers_command_parses_without_changing_list() { + let cli = Cli::try_parse_from([ + "easytier-cli", + "-n", + "network", + "-o", + "json", + "credential", + "peers", + ]) + .unwrap(); + assert_eq!(cli.instance_select.name.as_deref(), Some("network")); + assert_eq!(cli.output_format, OutputFormat::Json); + assert!(matches!( + cli.sub_command, + SubCommand::Credential(CredentialArgs { + sub_command: CredentialSubCommand::Peers, + }) + )); + let cli = Cli::try_parse_from(["easytier-cli", "credential", "list"]).unwrap(); + assert!(matches!( + cli.sub_command, + SubCommand::Credential(CredentialArgs { + sub_command: CredentialSubCommand::List, + }) + )); + } + + #[test] + fn credential_peers_matches_remote_fingerprint_and_virtual_addresses() { + let data = build_credential_peers( + vec![credential("user")], + vec![credential_peer(42)], + vec![credential_route(42, "10.252.9.2/24")], + ); + let peer = &data.credentials[0].peers[0]; + assert_eq!(peer.peer_id, 42); + assert_eq!(peer.ipv4.as_deref(), Some("10.252.9.2")); + assert_eq!(peer.ipv6.as_deref(), Some("fd00::2")); + assert_eq!(peer.hostname, "peer-42"); + let json = serde_json::to_value(&data).unwrap(); + assert_eq!(json["credentials"][0]["credential_id"], "user"); + assert_eq!(json["credentials"][0]["peers"][0]["peer_id"], 42); + assert_eq!(json["credentials"][0]["peers"][0]["ipv4"], "10.252.9.2"); + let json = json.to_string(); + assert!(!json.contains("secret")); + assert!(!json.contains("pubkey")); + assert!(!json.contains("fingerprint")); + let table = format_credential_peers(&data); + assert!(table.contains("10.252.9.2")); + assert!(table.contains("fd00::2")); + assert!(table.contains("peer-42")); + } + + #[test] + fn credential_peers_supports_reuse_and_deduplicates_connections() { + let mut peer = credential_peer(9); + peer.conns.push(peer.conns[0].clone()); + let data = build_credential_peers( + vec![credential("user")], + vec![peer, credential_peer(2)], + vec![ + credential_route(9, "10.252.9.9/24"), + credential_route(2, "10.252.9.2/24"), + ], + ); + let peers = &data.credentials[0].peers; + assert_eq!(peers.len(), 2); + assert_eq!(peers[0].peer_id, 2); + assert_eq!(peers[1].peer_id, 9); + } + + #[test] + fn credential_peers_ignores_closed_unverified_and_noncredential_connections() { + let original = credential_peer(42); + let mut variants = Vec::new(); + let mut closed = original.clone(); + closed.conns[0].is_closed = true; + variants.push(closed); + for identity in [PeerIdentityType::Admin, PeerIdentityType::SharedNode] { + let mut peer = original.clone(); + peer.conns[0].peer_identity_type = identity as i32; + variants.push(peer); + } + for auth in [ + SecureAuthLevel::None as i32, + SecureAuthLevel::EncryptedUnauthenticated as i32, + 999, + ] { + let mut peer = original.clone(); + peer.conns[0].secure_auth_level = auth; + variants.push(peer); + } + for key in [Vec::new(), vec![7; 31], vec![7; 33], vec![8; 32]] { + let mut peer = original.clone(); + peer.conns[0].noise_remote_static_pubkey = key; + // A matching local key must not associate a different remote key. + peer.conns[0].noise_local_static_pubkey = vec![7; 32]; + variants.push(peer); + } + for peer in variants { + let data = build_credential_peers( + vec![credential("user")], + vec![peer], + vec![credential_route(42, "10.252.9.2/24")], + ); + assert!(data.credentials[0].peers.is_empty()); + } + } + + #[test] + fn credential_peers_keeps_unmatched_credentials_without_claiming_global_offline_status() { + let mut legacy = credential("legacy"); + legacy.public_key_fingerprint.clear(); + let data = build_credential_peers( + vec![credential("user"), legacy], + Vec::new(), + // A route without an authenticated direct connection is not a match. + vec![credential_route(42, "10.252.9.2/24")], + ); + assert_eq!(data.credentials[0].credential_id, "legacy"); + assert_eq!(data.credentials[1].credential_id, "user"); + assert!(data.credentials.iter().all(|entry| entry.peers.is_empty())); + assert_eq!( + serde_json::to_value(&data).unwrap()["credentials"][0]["peers"], + serde_json::json!([]) + ); + let table = format_credential_peers(&data); + assert!(table.contains("no direct peers")); + assert!(!table.contains("offline")); + } + + #[test] + fn credential_peers_does_not_attribute_relayed_nodes_to_the_next_hop() { + let mut relayed = credential_route(42, "10.252.9.42/24"); + relayed.next_hop_peer_id = 7; + let mut legacy = credential("legacy"); + legacy.public_key_fingerprint.clear(); + let data = build_credential_peers( + vec![credential("user"), legacy], + vec![credential_peer(7)], + vec![credential_route(7, "10.252.9.7/24"), relayed], + ); + assert!(data.credentials[0].peers.is_empty()); + let peers = &data.credentials[1].peers; + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].peer_id, 7); + assert_eq!(peers[0].ipv4.as_deref(), Some("10.252.9.7")); + } + + #[test] + fn credential_peers_keeps_connected_nodes_with_missing_route_addresses() { + for routes in [ + Vec::new(), + vec![ApiRoute { + peer_id: 42, + ipv4_addr: Some(Default::default()), + ipv6_addr: Some(Default::default()), + ..Default::default() + }], + ] { + let data = + build_credential_peers(vec![credential("user")], vec![credential_peer(42)], routes); + let peer = &data.credentials[0].peers[0]; + assert_eq!(peer.peer_id, 42); + assert!(peer.ipv4.is_none()); + assert!(peer.ipv6.is_none()); + assert_eq!(peer.hostname, ""); + assert_eq!( + serde_json::to_value(&data).unwrap()["credentials"][0]["peers"][0]["ipv4"], + serde_json::Value::Null + ); + } + } + + #[test] + fn credential_peers_does_not_associate_another_credentials_key() { + let mut other = credential("other"); + other.public_key_fingerprint = "0".repeat(64); + let data = build_credential_peers( + vec![other, credential("user")], + vec![credential_peer(42)], + vec![credential_route(42, "10.252.9.2/24")], + ); + assert!(data.credentials[0].peers.is_empty()); + assert_eq!(data.credentials[1].peers.len(), 1); + } + + #[test] + fn credential_peers_handles_an_empty_credential_list() { + let data = build_credential_peers( + Vec::new(), + vec![credential_peer(42)], + vec![credential_route(42, "10.252.9.2/24")], + ); + assert!(data.credentials.is_empty()); + assert_eq!(format_credential_peers(&data), "No active credentials"); + } #[test] fn missing_web_client_service_matches_raw_service_name() { @@ -2760,6 +3111,30 @@ impl<'a> CommandHandler<'a> { }) } + async fn handle_credential_peers(&self) -> Result<(), Error> { + let results = self + .collect_instance_results(|handler| { + Box::pin(async move { + let credentials = handler.fetch_credential_list().await?; + let peers = handler.list_peers().await?; + let routes = handler.list_routes().await?; + Ok(build_credential_peers( + credentials.credentials, + peers.peer_infos, + routes.routes, + )) + }) + }) + .await?; + if *self.output_format == OutputFormat::Json { + return self.print_json_results(results); + } + self.print_results(&results, |data| { + println!("{}", format_credential_peers(data)); + Ok(()) + }) + } + async fn handle_peer_center(&self) -> Result<(), Error> { let results = self .collect_instance_results(|handler| Box::pin(handler.fetch_peer_center_rows())) @@ -3686,6 +4061,9 @@ async fn main() -> Result<(), Error> { CredentialSubCommand::List => { handler.handle_credential_list().await?; } + CredentialSubCommand::Peers => { + handler.handle_credential_peers().await?; + } }, SubCommand::GenAutocomplete { shell } => { let mut cmd = Cli::command(); diff --git a/script/test-cli-multi-instance.sh b/script/test-cli-multi-instance.sh index 0ba8a193..5f04b100 100755 --- a/script/test-cli-multi-instance.sh +++ b/script/test-cli-multi-instance.sh @@ -143,7 +143,7 @@ data = json.loads(os.environ["JSON_PAYLOAD"]) assert len(data) == 2, data for item in data: assert item["result"]["tcp_ports"] == ["80", "443"], item - assert item["result"]["udp_ports"] == [], item + assert item["result"].get("udp_ports", []) == [], item PY } @@ -155,7 +155,7 @@ import os data = {item["instance_name"]: item["result"] for item in json.loads(os.environ["JSON_PAYLOAD"])} assert data["e2e-inst-a"]["tcp_ports"] == ["80", "443"], data -assert data["e2e-inst-b"]["tcp_ports"] == [], data +assert data["e2e-inst-b"].get("tcp_ports", []) == [], data PY } @@ -262,6 +262,36 @@ EOF "$CLI_BIN" -p "127.0.0.1:${rpc_port}" -o json whitelist show assert_single_instance_write "$cleared_output" + local credential_table + run_cmd credential_table \ + "Case 8: credential peers table fans out to all instances" \ + "$CLI_BIN" -p "127.0.0.1:${rpc_port}" credential peers + assert_text_output "$credential_table" + + local credential_json + run_cmd credential_json \ + "Case 9: credential peers JSON preserves instance wrappers" \ + "$CLI_BIN" -p "127.0.0.1:${rpc_port}" -o json credential peers + assert_multi_instance_json "$credential_json" + JSON_PAYLOAD="$credential_json" "$PYTHON_BIN" - <<'PY' +import json +import os + +data = json.loads(os.environ["JSON_PAYLOAD"]) +assert all(item["result"] == {"credentials": []} for item in data), data +PY + + local credential_single + run_cmd credential_single \ + "Case 10: credential peers explicit selector returns one instance" \ + "$CLI_BIN" -p "127.0.0.1:${rpc_port}" --instance-name e2e-inst-a -o json credential peers + JSON_PAYLOAD="$credential_single" "$PYTHON_BIN" - <<'PY' +import json +import os + +assert json.loads(os.environ["JSON_PAYLOAD"]) == {"credentials": []} +PY + print_section "Result" echo "CLI multi-instance E2E passed" }