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" }