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
This commit is contained in:
fanyang authored and GitHub committed 2026-10-10 12:33:47 +08:00
1 parent bd06395573
commit d96eaa668b
4 files changed
+470 -7

No files matched your search

+55
View File
@@ -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.
+1 -1
View File
@@ -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"]
+382 -4
View File
@@ -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<cidr::Ipv4Inet> {
.map_err(|error| anyhow::anyhow!("invalid client virtual IPv4 CIDR ({value}): {error}"))
}
#[derive(Debug, serde::Serialize)]
struct CredentialPeersData {
credentials: Vec<CredentialPeersEntry>,
}
#[derive(Debug, serde::Serialize)]
struct CredentialPeersEntry {
credential_id: String,
peers: Vec<CredentialPeerNode>,
}
#[derive(Clone, Debug, serde::Serialize)]
struct CredentialPeerNode {
peer_id: u32,
ipv4: Option<String>,
ipv6: Option<String>,
hostname: String,
}
fn build_credential_peers(
credentials: Vec<CredentialInfo>,
peers: Vec<PeerInfo>,
routes: Vec<ApiRoute>,
) -> CredentialPeersData {
let routes = routes
.into_iter()
.map(|route| (route.peer_id, route))
.collect::<HashMap<_, _>>();
let mut peers_by_fingerprint = HashMap::<String, BTreeMap<u32, CredentialPeerNode>>::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::<String>();
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::<Vec<_>>();
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();
+32 -2
View File
@@ -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"
}