mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-10 21:06:14 -08:00
* feat(vpn): hot add/remove WireGuard portal clients without restart WireGuard portal clients were frozen at instance construction: the engine slot maps, host key table, and PortalModule state were all immutable after startup, so any client change required recreating the whole instance and dropping every established session. Wire dynamic client management through the existing config-patch channel (ConfigRpc.patch_config -> apply_config_patch), following the same pattern as connectors, port forwards, and proxy networks: - proto: InstanceConfigPatch gains repeated VpnPortalClientPatch (Add/Remove/Clear by client name) - engine: slot maps move under an RwLock with a free-index allocator; add_client/remove_client recycle indices, mark removed slots retired, and expire active sessions so Core tears down the attached peer via the regular channel-close path (credential revocation and disconnect events included); untouched clients keep their sessions intact. The retired flag is re-checked under the session lock so a datagram that races with removal cannot resurrect a session - host: WireGuardPortalHost derives keys deterministically per name (HKDF), keeps a mutable client table for render_client_config, and forwards updates to the live engine; changed clients are re-added so they re-handshake into a fresh generation with the new virtual IP or groups - PortalModule: client set, statuses, and session locks become shared mutable state; run_session resolves clients from the shared map at accept time; update_clients() validates against a caller-supplied runtime snapshot. An empty client set is legal in every lifecycle stage, so clearing all clients never produces a configuration that fails instance recreation - config_patch: apply_vpn_portal_client_patches mutates the candidate TOML; the sub-patch runs last and is deep-validated and hot-applied before the candidate commits, so a rejected client set leaves neither the shared model nor the live portal changed, and validation sees the fully patched state including routes and node IPv4 from the same request. Rejects patches when no portal is configured or a removed client does not exist - cli: vpn-portal add-client/remove-client/clear-clients subcommands Tests: engine index recycling, module update validation/state/host notification, TOML patch application, and a three-node integration test that adds a second WireGuard client live, removes the first while the second stays online, and asserts rejected patches leave the shared model unchanged. * feat(web): reconcile WireGuard portal client edits as hot patches The web console reconciles desired network config against the running instance and patches it in place when possible. VPN portal changes were not part of that: any client edit made the base configs differ, so every save recreated the instance and dropped all established sessions. Exclude vpn_portal_config from the base comparison and diff its clients by name instead. Client add/remove/change now produces VpnPortalClientPatch entries (removals first, changed clients as remove+add) applied through the existing PatchConfig channel. Listener identity changes (address or private key) and enabling or disabling the portal still fall back to a full instance recreate, since those change the listener lifecycle. * feat(web/gui): map portal client patches to frontend RPC backends Extend the RemoteClient seam with add/remove/clear VPN portal client operations so frontend hosts can drive the same PatchConfig channel as the CLI. There is deliberately no dedicated editing UI: the config form stays the single editing surface (aligned with port forwards), and these methods exist for programmatic and future use. - web console: JSON proxy-rpc to ConfigRpcService.patch_config with VpnPortalClientPatch entries (pbjson string enum actions) - desktop GUI: patch_vpn_portal_clients tauri command forwarding the same patch through the typed ConfigRpc client
3423 lines
115 KiB
Rust
3423 lines
115 KiB
Rust
use std::{
|
|
collections::{BTreeMap, HashMap},
|
|
ffi::OsString,
|
|
future::Future,
|
|
net::{IpAddr, SocketAddr},
|
|
path::PathBuf,
|
|
pin::Pin,
|
|
str::FromStr,
|
|
sync::Arc,
|
|
time::Duration,
|
|
vec,
|
|
};
|
|
|
|
use anyhow::Context;
|
|
use base64::Engine as _;
|
|
use base64::prelude::BASE64_STANDARD;
|
|
use cidr::Ipv4Inet;
|
|
use clap::{ArgAction, Args, CommandFactory, Parser, Subcommand, builder::BoolishValueParser};
|
|
use dashmap::DashMap;
|
|
use easytier::ShellType;
|
|
use easytier_core::connectivity::stun::StunInfoProvider as _;
|
|
use humansize::format_size;
|
|
use rust_i18n::t;
|
|
use service_manager::*;
|
|
use tabled::settings::{Disable, Modify, Style, Width, location::ByColumnName, object::Columns};
|
|
use terminal_size::{Width as TerminalWidth, terminal_size};
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use easytier::service_manager::{Service, ServiceInstallOptions};
|
|
use tokio::time::timeout;
|
|
|
|
use easytier::{
|
|
common::{constants::EASYTIER_VERSION, stun::runtime_stun_info_collector},
|
|
proto::{
|
|
acl::AclStats,
|
|
api::{
|
|
config::{
|
|
AclPatch, ConfigPatchAction, ConfigRpc, ConfigRpcClientFactory,
|
|
InstanceConfigPatch, PatchConfigRequest, PortForwardPatch, StringPatch, UrlPatch,
|
|
VpnPortalClientPatch,
|
|
},
|
|
instance::{
|
|
AclManageRpc, AclManageRpcClientFactory, Connector, ConnectorManageRpc,
|
|
ConnectorManageRpcClientFactory, CredentialManageRpc,
|
|
CredentialManageRpcClientFactory, DumpRouteRequest, ForeignNetworkEntryPb,
|
|
GenerateCredentialRequest, GetAclStatsRequest, GetPrometheusStatsRequest,
|
|
GetStatsRequest, GetVpnPortalInfoRequest, GetWhitelistRequest,
|
|
GetWhitelistResponse, InstanceIdentifier, ListConnectorRequest,
|
|
ListCredentialsRequest, ListCredentialsResponse, ListForeignNetworkRequest,
|
|
ListGlobalForeignNetworkRequest, ListMappedListenerRequest, ListPeerRequest,
|
|
ListPeerResponse, ListPortForwardRequest, ListPortForwardResponse,
|
|
ListPublicIpv6InfoRequest, ListPublicIpv6InfoResponse, ListRouteRequest,
|
|
ListRouteResponse, MappedListener, MappedListenerManageRpc,
|
|
MappedListenerManageRpcClientFactory, MetricSnapshot, NodeInfo, PeerManageRpc,
|
|
PeerManageRpcClientFactory, PortForwardManageRpc,
|
|
PortForwardManageRpcClientFactory, RevokeCredentialRequest, Route as ApiRoute,
|
|
ShowNodeInfoRequest, StatsRpc, StatsRpcClientFactory, TcpProxyEntryState,
|
|
TcpProxyEntryTransportType, TcpProxyRpc, TcpProxyRpcClientFactory,
|
|
TrustedKeySourcePb, VpnPortalInfo, VpnPortalRpc, VpnPortalRpcClientFactory,
|
|
instance_identifier::{InstanceSelector, Selector},
|
|
list_global_foreign_network_response, list_peer_route_pair,
|
|
},
|
|
logger::{
|
|
GetLoggerConfigRequest, LogLevel, LoggerRpc, LoggerRpcClientFactory,
|
|
SetLoggerConfigRequest,
|
|
},
|
|
manage::{
|
|
ListNetworkInstanceMetaRequest, ListNetworkInstanceRequest,
|
|
VpnPortalClientConfig as ManageVpnPortalClientConfig, WebClientService,
|
|
WebClientServiceClientFactory,
|
|
},
|
|
},
|
|
common::{NatType, PortForwardConfigPb, SocketType},
|
|
peer_rpc::{GetGlobalPeerMapRequest, PeerCenterRpc, PeerCenterRpcClientFactory},
|
|
rpc::standalone::{RuntimeRpcClient, runtime_rpc_client},
|
|
rpc_types::{controller::BaseController, error::Error as RpcError},
|
|
},
|
|
tunnel::TunnelScheme,
|
|
utils::{PeerRoutePair, string::cost_to_str},
|
|
};
|
|
|
|
rust_i18n::i18n!("locales", fallback = "en");
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(name = "easytier-cli", author, version = EASYTIER_VERSION, about, long_about = None)]
|
|
struct Cli {
|
|
#[arg(
|
|
short = 'p',
|
|
long,
|
|
default_value = "127.0.0.1:15888",
|
|
help = "easytier-core rpc portal address"
|
|
)]
|
|
rpc_portal: SocketAddr,
|
|
|
|
#[arg(short, long, default_value = "false", help = "verbose output")]
|
|
verbose: bool,
|
|
|
|
#[arg(
|
|
short = 'o',
|
|
long = "output",
|
|
value_enum,
|
|
default_value = "table",
|
|
help = "output format"
|
|
)]
|
|
output_format: OutputFormat,
|
|
|
|
#[arg(
|
|
long = "no-trunc",
|
|
default_value = "false",
|
|
help = "disable column truncation"
|
|
)]
|
|
no_trunc: bool,
|
|
|
|
#[command(flatten)]
|
|
instance_select: InstanceSelectArgs,
|
|
|
|
#[command(subcommand)]
|
|
sub_command: SubCommand,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum SubCommand {
|
|
#[command(about = "show peers info")]
|
|
Peer(PeerArgs),
|
|
#[command(about = "manage connectors")]
|
|
Connector(ConnectorArgs),
|
|
#[command(about = "manage mapped listeners")]
|
|
MappedListener(MappedListenerArgs),
|
|
#[command(about = "do stun test")]
|
|
Stun,
|
|
#[command(about = "show route info")]
|
|
Route(RouteArgs),
|
|
#[command(about = "show global peers info")]
|
|
PeerCenter,
|
|
#[command(about = "manage vpn portal (wireguard) clients")]
|
|
VpnPortal(VpnPortalArgs),
|
|
#[command(about = "inspect self easytier-core status")]
|
|
Node(NodeArgs),
|
|
#[command(about = "manage easytier-core as a system service")]
|
|
Service(ServiceArgs),
|
|
#[command(about = "show tcp/kcp proxy status")]
|
|
Proxy,
|
|
#[command(about = "show ACL rules statistics")]
|
|
Acl(AclArgs),
|
|
#[command(about = "manage port forwarding")]
|
|
PortForward(PortForwardArgs),
|
|
#[command(about = "manage TCP/UDP whitelist")]
|
|
Whitelist(WhitelistArgs),
|
|
#[command(about = "show statistics information")]
|
|
Stats(StatsArgs),
|
|
#[command(about = "manage logger configuration")]
|
|
Logger(LoggerArgs),
|
|
#[command(about = "manage temporary credentials")]
|
|
Credential(CredentialArgs),
|
|
#[command(about = t!("core_clap.generate_completions").to_string())]
|
|
GenAutocomplete { shell: ShellType },
|
|
}
|
|
|
|
#[derive(clap::ValueEnum, Debug, Clone, PartialEq)]
|
|
enum OutputFormat {
|
|
Table,
|
|
Json,
|
|
}
|
|
|
|
#[derive(Parser, Debug)]
|
|
struct InstanceSelectArgs {
|
|
#[arg(short = 'i', long = "instance-id", help = "the instance id")]
|
|
id: Option<uuid::Uuid>,
|
|
|
|
#[arg(short = 'n', long = "instance-name", help = "the instance name")]
|
|
name: Option<String>,
|
|
}
|
|
|
|
impl From<&InstanceSelectArgs> for InstanceIdentifier {
|
|
fn from(args: &InstanceSelectArgs) -> Self {
|
|
InstanceIdentifier {
|
|
selector: match args.id {
|
|
Some(id) => Some(Selector::Id(id.into())),
|
|
None => Some(Selector::InstanceSelector(InstanceSelector {
|
|
name: args.name.clone(),
|
|
})),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct PeerArgs {
|
|
#[command(subcommand)]
|
|
sub_command: Option<PeerSubCommand>,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum PeerSubCommand {
|
|
/// List connected peers
|
|
List,
|
|
/// Show public IPv6 address information
|
|
Ipv6,
|
|
/// List foreign networks discovered by this instance
|
|
ListForeign {
|
|
#[arg(
|
|
long,
|
|
default_value = "false",
|
|
help = "include trusted keys for each foreign network"
|
|
)]
|
|
trusted_keys: bool,
|
|
},
|
|
/// List global foreign networks from the peer center
|
|
ListGlobalForeign,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct RouteArgs {
|
|
#[command(subcommand)]
|
|
sub_command: Option<RouteSubCommand>,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum RouteSubCommand {
|
|
/// List routes propagated by peers
|
|
List,
|
|
/// Dump routes in CIDR format
|
|
Dump,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct ConnectorArgs {
|
|
#[arg(short, long, help = "filter connectors by virtual IPv4 address")]
|
|
ipv4: Option<String>,
|
|
|
|
#[arg(short, long, help = "filter connectors by peer URL")]
|
|
peers: Vec<String>,
|
|
|
|
#[command(subcommand)]
|
|
sub_command: Option<ConnectorSubCommand>,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum ConnectorSubCommand {
|
|
/// Add a connector
|
|
Add {
|
|
#[arg(help = "connector url, e.g., tcp://1.2.3.4:11010")]
|
|
url: String,
|
|
},
|
|
/// Remove a connector
|
|
Remove {
|
|
#[arg(help = "connector url, e.g., tcp://1.2.3.4:11010")]
|
|
url: String,
|
|
},
|
|
/// List connectors
|
|
List,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct MappedListenerArgs {
|
|
#[command(subcommand)]
|
|
sub_command: Option<MappedListenerSubCommand>,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum MappedListenerSubCommand {
|
|
/// Add Mapped Listerner
|
|
Add { url: String },
|
|
/// Remove Mapped Listener
|
|
Remove { url: String },
|
|
/// List Existing Mapped Listener
|
|
List,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct VpnPortalArgs {
|
|
#[command(subcommand)]
|
|
sub_command: Option<VpnPortalSubCommand>,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum VpnPortalSubCommand {
|
|
/// Add a WireGuard portal client
|
|
AddClient {
|
|
#[arg(help = "client name")]
|
|
name: String,
|
|
#[arg(long, help = "client virtual IPv4 address inside the mesh network")]
|
|
virtual_ip: String,
|
|
#[arg(long, help = "ACL groups assigned to the client")]
|
|
groups: Vec<String>,
|
|
},
|
|
/// Remove a WireGuard portal client
|
|
RemoveClient {
|
|
#[arg(help = "client name")]
|
|
name: String,
|
|
},
|
|
/// Remove all WireGuard portal clients
|
|
ClearClients,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum NodeSubCommand {
|
|
#[command(about = "show node info")]
|
|
Info,
|
|
#[command(about = "show node config")]
|
|
Config,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct NodeArgs {
|
|
#[command(subcommand)]
|
|
sub_command: Option<NodeSubCommand>,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct AclArgs {
|
|
#[command(subcommand)]
|
|
sub_command: Option<AclSubCommand>,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum AclSubCommand {
|
|
/// Show ACL rule hit statistics
|
|
Stats,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct PortForwardArgs {
|
|
#[command(subcommand)]
|
|
sub_command: Option<PortForwardSubCommand>,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum PortForwardSubCommand {
|
|
/// Add port forward rule
|
|
Add {
|
|
#[arg(help = "Protocol (tcp/udp)")]
|
|
protocol: String,
|
|
#[arg(help = "Local bind address (e.g., 0.0.0.0:8080)")]
|
|
bind_addr: String,
|
|
#[arg(help = "Destination address (e.g., 10.1.1.1:80)")]
|
|
dst_addr: String,
|
|
},
|
|
/// Remove port forward rule
|
|
Remove {
|
|
#[arg(help = "Protocol (tcp/udp)")]
|
|
protocol: String,
|
|
#[arg(help = "Local bind address (e.g., 0.0.0.0:8080)")]
|
|
bind_addr: String,
|
|
#[arg(help = "Optional Destination address (e.g., 10.1.1.1:80)")]
|
|
dst_addr: Option<String>,
|
|
},
|
|
/// List port forward rules
|
|
List,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct WhitelistArgs {
|
|
#[command(subcommand)]
|
|
sub_command: Option<WhitelistSubCommand>,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum WhitelistSubCommand {
|
|
/// Set TCP port whitelist
|
|
SetTcp {
|
|
#[arg(help = "TCP ports (e.g., 80,443,8000-9000)")]
|
|
ports: String,
|
|
},
|
|
/// Set UDP port whitelist
|
|
SetUdp {
|
|
#[arg(help = "UDP ports (e.g., 53,5000-6000)")]
|
|
ports: String,
|
|
},
|
|
/// Clear TCP whitelist
|
|
ClearTcp,
|
|
/// Clear UDP whitelist
|
|
ClearUdp,
|
|
/// Show current whitelist configuration
|
|
Show,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct StatsArgs {
|
|
#[command(subcommand)]
|
|
sub_command: Option<StatsSubCommand>,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum StatsSubCommand {
|
|
/// Show general statistics
|
|
Show,
|
|
/// Show statistics in Prometheus format
|
|
Prometheus,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct LoggerArgs {
|
|
#[command(subcommand)]
|
|
sub_command: Option<LoggerSubCommand>,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum LoggerSubCommand {
|
|
/// Get current logger configuration
|
|
Get,
|
|
/// Set logger level
|
|
Set {
|
|
#[arg(help = "Log level (disabled, error, warning, info, debug, trace)")]
|
|
level: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct CredentialArgs {
|
|
#[command(subcommand)]
|
|
sub_command: CredentialSubCommand,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum CredentialSubCommand {
|
|
/// Generate a new temporary credential
|
|
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<String>,
|
|
#[arg(long, value_delimiter = ',', help = "ACL groups (comma-separated)")]
|
|
groups: Option<Vec<String>>,
|
|
#[arg(
|
|
long,
|
|
default_value = "false",
|
|
help = "allow relay through this credential node"
|
|
)]
|
|
allow_relay: bool,
|
|
#[arg(
|
|
long,
|
|
value_delimiter = ',',
|
|
help = "allowed proxy CIDRs (comma-separated)"
|
|
)]
|
|
allowed_proxy_cidrs: Option<Vec<String>>,
|
|
#[arg(
|
|
long,
|
|
action = ArgAction::Set,
|
|
default_value = "true",
|
|
value_parser = BoolishValueParser::new(),
|
|
help = "whether this credential may be reused by multiple peers concurrently"
|
|
)]
|
|
reusable: bool,
|
|
},
|
|
/// Revoke a credential by its ID
|
|
Revoke {
|
|
#[arg(help = "credential ID (UUID)")]
|
|
credential_id: String,
|
|
},
|
|
/// List all active credentials
|
|
List,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct ServiceArgs {
|
|
#[arg(short, long, default_value = env!("CARGO_PKG_NAME"), help = "service name")]
|
|
name: String,
|
|
|
|
#[command(subcommand)]
|
|
sub_command: ServiceSubCommand,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum ServiceSubCommand {
|
|
#[command(about = "register easytier-core as a system service")]
|
|
Install(InstallArgs),
|
|
#[command(about = "unregister easytier-core system service")]
|
|
Uninstall,
|
|
#[command(about = "check easytier-core system service status")]
|
|
Status,
|
|
#[command(about = "start easytier-core system service")]
|
|
Start,
|
|
#[command(about = "stop easytier-core system service")]
|
|
Stop,
|
|
}
|
|
|
|
#[derive(Args, Debug)]
|
|
struct InstallArgs {
|
|
#[arg(long, default_value = env!("CARGO_PKG_DESCRIPTION"), help = "service description")]
|
|
description: String,
|
|
|
|
#[arg(long, help = "display name shown by the service manager")]
|
|
display_name: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
help = "whether to disable starting the service automatically on boot (true/false)"
|
|
)]
|
|
disable_autostart: Option<bool>,
|
|
|
|
#[arg(
|
|
long,
|
|
help = "whether to disable automatic restart when the service fails (true/false)"
|
|
)]
|
|
disable_restart_on_failure: Option<bool>,
|
|
|
|
#[arg(long, help = "path to easytier-core binary")]
|
|
core_path: Option<PathBuf>,
|
|
|
|
#[arg(long, help = "working directory for the easytier-core service")]
|
|
service_work_dir: Option<PathBuf>,
|
|
|
|
#[arg(
|
|
trailing_var_arg = true,
|
|
allow_hyphen_values = true,
|
|
help = "args to pass to easytier-core"
|
|
)]
|
|
core_args: Option<Vec<OsString>>,
|
|
}
|
|
|
|
type Error = anyhow::Error;
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct InstanceTarget {
|
|
identifier: InstanceIdentifier,
|
|
instance_id: String,
|
|
instance_name: String,
|
|
}
|
|
|
|
struct InstanceResult<T> {
|
|
target: Option<InstanceTarget>,
|
|
value: T,
|
|
}
|
|
|
|
impl InstanceTarget {
|
|
fn label(&self) -> String {
|
|
match (self.instance_name.is_empty(), self.instance_id.is_empty()) {
|
|
(false, false) => format!("{} ({})", self.instance_name, self.instance_id),
|
|
(false, true) => self.instance_name.clone(),
|
|
(true, false) => self.instance_id.clone(),
|
|
(true, true) => "selected instance".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T> InstanceResult<T> {
|
|
fn new(target: Option<InstanceTarget>, value: T) -> Self {
|
|
Self { target, value }
|
|
}
|
|
|
|
fn map<U>(self, f: impl FnOnce(T) -> U) -> InstanceResult<U> {
|
|
InstanceResult {
|
|
target: self.target,
|
|
value: f(self.value),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CommandHandler<'a> {
|
|
client: Arc<tokio::sync::Mutex<RpcClient>>,
|
|
verbose: bool,
|
|
output_format: &'a OutputFormat,
|
|
no_trunc: bool,
|
|
instance_select: &'a InstanceSelectArgs,
|
|
instance_selector: InstanceIdentifier,
|
|
resolved_target: Option<InstanceTarget>,
|
|
}
|
|
|
|
type RpcClient = RuntimeRpcClient;
|
|
type LocalBoxFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, Error>> + 'a>>;
|
|
type ForeignNetworkMap = BTreeMap<String, ForeignNetworkEntryPb>;
|
|
type GlobalForeignNetworkMap = BTreeMap<u32, list_global_foreign_network_response::ForeignNetworks>;
|
|
|
|
const ROUTE_OPTIONAL_COLUMNS: &[&str] = &["hostname"];
|
|
const ROUTE_DROP_COLUMNS: &[&str] = &[
|
|
"version",
|
|
"next_hop_hostname_lat_first",
|
|
"next_hop_ipv4_lat_first",
|
|
"path_len_lat_first",
|
|
"path_latency_lat_first",
|
|
"next_hop_hostname",
|
|
"next_hop_lat",
|
|
];
|
|
|
|
fn is_missing_web_client_service(error: &RpcError) -> bool {
|
|
matches!(
|
|
error,
|
|
RpcError::InvalidServiceKey(service_name, _)
|
|
if service_name.trim_matches('"') == "WebClientService"
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn missing_web_client_service_matches_raw_service_name() {
|
|
let error = RpcError::InvalidServiceKey("WebClientService".to_string(), "".to_string());
|
|
|
|
assert!(is_missing_web_client_service(&error));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_web_client_service_matches_serialized_service_name() {
|
|
let error = RpcError::InvalidServiceKey("\"WebClientService\"".to_string(), "".to_string());
|
|
|
|
assert!(is_missing_web_client_service(&error));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_web_client_service_rejects_other_services() {
|
|
let error = RpcError::InvalidServiceKey("PeerManageRpc".to_string(), "".to_string());
|
|
|
|
assert!(!is_missing_web_client_service(&error));
|
|
}
|
|
|
|
#[test]
|
|
fn proxy_cidrs_are_displayed_one_per_line() {
|
|
assert_eq!(
|
|
format_proxy_cidrs("10.0.0.0/24, 192.168.0.0/16"),
|
|
"10.0.0.0/24\n192.168.0.0/16"
|
|
);
|
|
assert_eq!(format_proxy_cidrs("10.0.0.0/24"), "10.0.0.0/24");
|
|
assert_eq!(format_proxy_cidrs(""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn route_column_priority_preserves_proxy_cidrs() {
|
|
let headers = [
|
|
"ipv4",
|
|
"hostname",
|
|
"proxy_cidrs",
|
|
"next_hop_ipv4",
|
|
"next_hop_hostname",
|
|
"next_hop_lat",
|
|
"path_len",
|
|
"path_latency",
|
|
"next_hop_ipv4_lat_first",
|
|
"next_hop_hostname_lat_first",
|
|
"path_len_lat_first",
|
|
"path_latency_lat_first",
|
|
"version",
|
|
]
|
|
.map(str::to_string);
|
|
let col_widths = headers
|
|
.iter()
|
|
.map(|header| text_width(header))
|
|
.collect::<Vec<_>>();
|
|
let drop_indices = header_indices(&headers, ROUTE_DROP_COLUMNS);
|
|
|
|
let (active, dropped, total_width) =
|
|
select_columns_to_drop(Some(79), &drop_indices, &col_widths);
|
|
|
|
let proxy_index = headers
|
|
.iter()
|
|
.position(|header| header == "proxy_cidrs")
|
|
.unwrap();
|
|
assert!(active[proxy_index]);
|
|
assert!(!dropped.contains(&proxy_index));
|
|
assert!(total_width <= 79);
|
|
}
|
|
}
|
|
|
|
fn format_proxy_cidrs(value: &str) -> String {
|
|
value
|
|
.split(',')
|
|
.map(str::trim)
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct PeerListData {
|
|
node_info: NodeInfo,
|
|
peer_routes: Vec<PeerRoutePair>,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct RouteListData {
|
|
node_info: NodeInfo,
|
|
peer_routes: Vec<PeerRoutePair>,
|
|
}
|
|
|
|
struct PeerIpv6DataRaw {
|
|
node_info: NodeInfo,
|
|
routes: Vec<ApiRoute>,
|
|
provider_info: ListPublicIpv6InfoResponse,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct PeerCenterRowData {
|
|
node_id: String,
|
|
hostname: String,
|
|
ipv4: String,
|
|
direct_peers: Vec<PeerCenterDirectPeerData>,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct PeerCenterDirectPeerData {
|
|
node_id: String,
|
|
hostname: String,
|
|
ipv4: String,
|
|
latency_ms: i32,
|
|
}
|
|
|
|
impl<'a> CommandHandler<'a> {
|
|
fn has_explicit_instance_selector(&self) -> bool {
|
|
self.instance_select.id.is_some() || self.instance_select.name.is_some()
|
|
}
|
|
|
|
fn scoped_to_instance(&self, target: &InstanceTarget) -> Self {
|
|
Self {
|
|
client: self.client.clone(),
|
|
verbose: self.verbose,
|
|
output_format: self.output_format,
|
|
no_trunc: self.no_trunc,
|
|
instance_select: self.instance_select,
|
|
instance_selector: target.identifier.clone(),
|
|
resolved_target: Some(target.clone()),
|
|
}
|
|
}
|
|
|
|
fn print_target_header(&self, target: &InstanceTarget) {
|
|
println!("== {} ==", target.label());
|
|
}
|
|
|
|
async fn get_manage_client(
|
|
&self,
|
|
) -> Result<Box<dyn WebClientService<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<WebClientServiceClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get manage client")?)
|
|
}
|
|
|
|
async fn fanout_targets(&self) -> Result<Option<Vec<InstanceTarget>>, Error> {
|
|
if self.resolved_target.is_some() || self.has_explicit_instance_selector() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let client = self.get_manage_client().await?;
|
|
let list_response = match client
|
|
.list_network_instance(BaseController::default(), ListNetworkInstanceRequest {})
|
|
.await
|
|
{
|
|
Ok(response) => response,
|
|
Err(error) if is_missing_web_client_service(&error) => return Ok(None),
|
|
Err(error) => return Err(error.into()),
|
|
};
|
|
let inst_ids = list_response
|
|
.inst_ids
|
|
.into_iter()
|
|
.map(uuid::Uuid::from)
|
|
.collect::<Vec<_>>();
|
|
|
|
if inst_ids.is_empty() {
|
|
return Err(anyhow::anyhow!("no running instances found"));
|
|
}
|
|
|
|
let metas = client
|
|
.list_network_instance_meta(
|
|
BaseController::default(),
|
|
ListNetworkInstanceMetaRequest {
|
|
inst_ids: inst_ids.iter().cloned().map(Into::into).collect(),
|
|
},
|
|
)
|
|
.await?
|
|
.metas;
|
|
|
|
let mut name_map = HashMap::new();
|
|
for meta in metas {
|
|
if let Some(inst_id) = meta.inst_id {
|
|
name_map.insert(
|
|
uuid::Uuid::from(inst_id),
|
|
if meta.instance_name.is_empty() {
|
|
meta.network_name
|
|
} else {
|
|
meta.instance_name
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
let mut targets = inst_ids
|
|
.into_iter()
|
|
.map(|inst_id| InstanceTarget {
|
|
identifier: InstanceIdentifier {
|
|
selector: Some(Selector::Id(inst_id.into())),
|
|
},
|
|
instance_id: inst_id.to_string(),
|
|
instance_name: name_map.remove(&inst_id).unwrap_or_default(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
targets.sort_by_key(|a| a.label());
|
|
Ok(Some(targets))
|
|
}
|
|
|
|
async fn collect_instance_results<T, F>(
|
|
&self,
|
|
fetch: F,
|
|
) -> Result<Vec<InstanceResult<T>>, Error>
|
|
where
|
|
F: for<'b> Fn(&'b CommandHandler<'a>) -> LocalBoxFuture<'b, T>,
|
|
{
|
|
if let Some(targets) = self.fanout_targets().await? {
|
|
let mut results = Vec::with_capacity(targets.len());
|
|
for target in targets {
|
|
let scoped = self.scoped_to_instance(&target);
|
|
let value = fetch(&scoped)
|
|
.await
|
|
.with_context(|| format!("instance {}", target.label()))?;
|
|
results.push(InstanceResult::new(Some(target), value));
|
|
}
|
|
Ok(results)
|
|
} else {
|
|
Ok(vec![InstanceResult::new(None, fetch(self).await?)])
|
|
}
|
|
}
|
|
|
|
async fn apply_to_instances<F>(&self, apply: F) -> Result<(), Error>
|
|
where
|
|
F: for<'b> Fn(&'b CommandHandler<'a>) -> LocalBoxFuture<'b, ()>,
|
|
{
|
|
self.collect_instance_results(apply).await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn print_results<T>(
|
|
&self,
|
|
results: &[InstanceResult<T>],
|
|
mut render: impl FnMut(&T) -> Result<(), Error>,
|
|
) -> Result<(), Error> {
|
|
let multi = results.len() > 1;
|
|
for (idx, result) in results.iter().enumerate() {
|
|
if multi {
|
|
if idx > 0 {
|
|
println!();
|
|
}
|
|
if let Some(target) = result.target.as_ref() {
|
|
self.print_target_header(target);
|
|
}
|
|
}
|
|
render(&result.value)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn print_json_results<T: serde::Serialize>(
|
|
&self,
|
|
results: Vec<InstanceResult<T>>,
|
|
) -> Result<(), Error> {
|
|
if results.len() == 1 {
|
|
println!("{}", serde_json::to_string_pretty(&results[0].value)?);
|
|
return Ok(());
|
|
}
|
|
|
|
let wrapped = results
|
|
.into_iter()
|
|
.map(|result| {
|
|
let target = result
|
|
.target
|
|
.ok_or_else(|| anyhow::anyhow!("missing instance target for multi-result"))?;
|
|
Ok(serde_json::json!({
|
|
"instance_id": target.instance_id,
|
|
"instance_name": target.instance_name,
|
|
"result": result.value,
|
|
}))
|
|
})
|
|
.collect::<Result<Vec<_>, Error>>()?;
|
|
println!("{}", serde_json::to_string_pretty(&wrapped)?);
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_peer_manager_client(
|
|
&self,
|
|
) -> Result<Box<dyn PeerManageRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<PeerManageRpcClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get peer manager client")?)
|
|
}
|
|
|
|
async fn get_connector_manager_client(
|
|
&self,
|
|
) -> Result<Box<dyn ConnectorManageRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<ConnectorManageRpcClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get connector manager client")?)
|
|
}
|
|
|
|
async fn get_mapped_listener_manager_client(
|
|
&self,
|
|
) -> Result<Box<dyn MappedListenerManageRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<MappedListenerManageRpcClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get mapped listener manager client")?)
|
|
}
|
|
|
|
async fn get_peer_center_client(
|
|
&self,
|
|
) -> Result<Box<dyn PeerCenterRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<PeerCenterRpcClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get peer center client")?)
|
|
}
|
|
|
|
async fn get_vpn_portal_client(
|
|
&self,
|
|
) -> Result<Box<dyn VpnPortalRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<VpnPortalRpcClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get vpn portal client")?)
|
|
}
|
|
|
|
async fn get_acl_manager_client(
|
|
&self,
|
|
) -> Result<Box<dyn AclManageRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<AclManageRpcClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get acl manager client")?)
|
|
}
|
|
|
|
async fn get_tcp_proxy_client(
|
|
&self,
|
|
transport_type: &str,
|
|
) -> Result<Box<dyn TcpProxyRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<TcpProxyRpcClientFactory<BaseController>>(transport_type.to_string())
|
|
.await
|
|
.with_context(|| "failed to get vpn portal client")?)
|
|
}
|
|
|
|
async fn get_port_forward_manager_client(
|
|
&self,
|
|
) -> Result<Box<dyn PortForwardManageRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<PortForwardManageRpcClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get port forward manager client")?)
|
|
}
|
|
|
|
async fn get_stats_client(
|
|
&self,
|
|
) -> Result<Box<dyn StatsRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<StatsRpcClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get stats client")?)
|
|
}
|
|
|
|
async fn get_logger_client(
|
|
&self,
|
|
) -> Result<Box<dyn LoggerRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<LoggerRpcClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get logger client")?)
|
|
}
|
|
|
|
async fn get_config_client(
|
|
&self,
|
|
) -> Result<Box<dyn ConfigRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<ConfigRpcClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get config client")?)
|
|
}
|
|
|
|
async fn get_credential_client(
|
|
&self,
|
|
) -> Result<Box<dyn CredentialManageRpc<Controller = BaseController>>, Error> {
|
|
Ok(self
|
|
.client
|
|
.lock()
|
|
.await
|
|
.scoped_client::<CredentialManageRpcClientFactory<BaseController>>("".to_string())
|
|
.await
|
|
.with_context(|| "failed to get credential client")?)
|
|
}
|
|
|
|
async fn list_peers(&self) -> Result<ListPeerResponse, Error> {
|
|
let client = self.get_peer_manager_client().await?;
|
|
let request = ListPeerRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
};
|
|
let response = client.list_peer(BaseController::default(), request).await?;
|
|
Ok(response)
|
|
}
|
|
|
|
async fn list_routes(&self) -> Result<ListRouteResponse, Error> {
|
|
let client = self.get_peer_manager_client().await?;
|
|
let request = ListRouteRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
};
|
|
let response = client
|
|
.list_route(BaseController::default(), request)
|
|
.await?;
|
|
Ok(response)
|
|
}
|
|
|
|
async fn list_peer_route_pair(&self) -> Result<Vec<PeerRoutePair>, Error> {
|
|
let peers = self.list_peers().await?.peer_infos;
|
|
let routes = self.list_routes().await?.routes;
|
|
Ok(list_peer_route_pair(peers, routes))
|
|
}
|
|
|
|
async fn fetch_node_info(&self) -> Result<NodeInfo, Error> {
|
|
self.get_peer_manager_client()
|
|
.await?
|
|
.show_node_info(
|
|
BaseController::default(),
|
|
ShowNodeInfoRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?
|
|
.node_info
|
|
.ok_or(anyhow::anyhow!("node info not found"))
|
|
}
|
|
|
|
async fn fetch_peer_list_data(&self) -> Result<PeerListData, Error> {
|
|
Ok(PeerListData {
|
|
node_info: self.fetch_node_info().await?,
|
|
peer_routes: self.list_peer_route_pair().await?,
|
|
})
|
|
}
|
|
|
|
async fn fetch_route_dump(&self) -> Result<String, Error> {
|
|
Ok(self
|
|
.get_peer_manager_client()
|
|
.await?
|
|
.dump_route(
|
|
BaseController::default(),
|
|
DumpRouteRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?
|
|
.result)
|
|
}
|
|
|
|
async fn fetch_foreign_networks(
|
|
&self,
|
|
include_trusted_keys: bool,
|
|
) -> Result<ForeignNetworkMap, Error> {
|
|
Ok(self
|
|
.get_peer_manager_client()
|
|
.await?
|
|
.list_foreign_network(
|
|
BaseController::default(),
|
|
ListForeignNetworkRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
include_trusted_keys,
|
|
},
|
|
)
|
|
.await?
|
|
.foreign_networks)
|
|
}
|
|
|
|
async fn fetch_global_foreign_networks(&self) -> Result<GlobalForeignNetworkMap, Error> {
|
|
Ok(self
|
|
.get_peer_manager_client()
|
|
.await?
|
|
.list_global_foreign_network(
|
|
BaseController::default(),
|
|
ListGlobalForeignNetworkRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?
|
|
.foreign_networks)
|
|
}
|
|
|
|
async fn fetch_route_list_data(&self) -> Result<RouteListData, Error> {
|
|
Ok(RouteListData {
|
|
node_info: self.fetch_node_info().await?,
|
|
peer_routes: self.list_peer_route_pair().await?,
|
|
})
|
|
}
|
|
|
|
async fn fetch_local_public_ipv6_info(&self) -> Result<ListPublicIpv6InfoResponse, Error> {
|
|
Ok(self
|
|
.get_peer_manager_client()
|
|
.await?
|
|
.list_public_ipv6_info(
|
|
BaseController::default(),
|
|
ListPublicIpv6InfoRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?)
|
|
}
|
|
|
|
async fn fetch_peer_ipv6_data(&self) -> Result<PeerIpv6DataRaw, Error> {
|
|
Ok(PeerIpv6DataRaw {
|
|
node_info: self.fetch_node_info().await?,
|
|
routes: self.list_routes().await?.routes,
|
|
provider_info: self.fetch_local_public_ipv6_info().await?,
|
|
})
|
|
}
|
|
|
|
async fn fetch_connector_list(&self) -> Result<Vec<Connector>, Error> {
|
|
Ok(self
|
|
.get_connector_manager_client()
|
|
.await?
|
|
.list_connector(
|
|
BaseController::default(),
|
|
ListConnectorRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?
|
|
.connectors)
|
|
}
|
|
|
|
async fn fetch_acl_stats(&self) -> Result<Option<AclStats>, Error> {
|
|
Ok(self
|
|
.get_acl_manager_client()
|
|
.await?
|
|
.get_acl_stats(
|
|
BaseController::default(),
|
|
GetAclStatsRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?
|
|
.acl_stats)
|
|
}
|
|
|
|
async fn fetch_mapped_listener_list(&self) -> Result<Vec<MappedListener>, Error> {
|
|
Ok(self
|
|
.get_mapped_listener_manager_client()
|
|
.await?
|
|
.list_mapped_listener(
|
|
BaseController::default(),
|
|
ListMappedListenerRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?
|
|
.mappedlisteners)
|
|
}
|
|
|
|
async fn fetch_port_forward_list(&self) -> Result<ListPortForwardResponse, Error> {
|
|
Ok(self
|
|
.get_port_forward_manager_client()
|
|
.await?
|
|
.list_port_forward(
|
|
BaseController::default(),
|
|
ListPortForwardRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?)
|
|
}
|
|
|
|
async fn fetch_whitelist(&self) -> Result<GetWhitelistResponse, Error> {
|
|
Ok(self
|
|
.get_acl_manager_client()
|
|
.await?
|
|
.get_whitelist(
|
|
BaseController::default(),
|
|
GetWhitelistRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?)
|
|
}
|
|
|
|
async fn fetch_credential_list(&self) -> Result<ListCredentialsResponse, Error> {
|
|
Ok(self
|
|
.get_credential_client()
|
|
.await?
|
|
.list_credentials(
|
|
BaseController::default(),
|
|
ListCredentialsRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?)
|
|
}
|
|
|
|
async fn fetch_peer_center_rows(&self) -> Result<Vec<PeerCenterRowData>, Error> {
|
|
struct PeerCenterNodeInfo {
|
|
hostname: String,
|
|
ipv4: String,
|
|
}
|
|
|
|
let resp = self
|
|
.get_peer_center_client()
|
|
.await?
|
|
.get_global_peer_map(
|
|
BaseController::default(),
|
|
GetGlobalPeerMapRequest::default(),
|
|
)
|
|
.await?;
|
|
let route_infos = self.list_peer_route_pair().await?;
|
|
let node_id_to_node_info = DashMap::new();
|
|
let node_info = self.fetch_node_info().await?;
|
|
node_id_to_node_info.insert(
|
|
node_info.peer_id,
|
|
PeerCenterNodeInfo {
|
|
hostname: node_info.hostname.clone(),
|
|
ipv4: node_info.ipv4_addr,
|
|
},
|
|
);
|
|
for route_info in route_infos {
|
|
let Some(peer_id) = route_info.route.as_ref().map(|x| x.peer_id) else {
|
|
continue;
|
|
};
|
|
node_id_to_node_info.insert(
|
|
peer_id,
|
|
PeerCenterNodeInfo {
|
|
hostname: route_info
|
|
.route
|
|
.as_ref()
|
|
.map(|x| x.hostname.clone())
|
|
.unwrap_or_default(),
|
|
ipv4: route_info
|
|
.route
|
|
.as_ref()
|
|
.and_then(|x| x.ipv4_addr)
|
|
.map(|x| x.to_string())
|
|
.unwrap_or_default(),
|
|
},
|
|
);
|
|
}
|
|
|
|
Ok(resp
|
|
.global_peer_map
|
|
.iter()
|
|
.map(|(node_id, directs)| PeerCenterRowData {
|
|
node_id: node_id.to_string(),
|
|
hostname: node_id_to_node_info
|
|
.get(node_id)
|
|
.map(|x| x.hostname.clone())
|
|
.unwrap_or_default(),
|
|
ipv4: node_id_to_node_info
|
|
.get(node_id)
|
|
.map(|x| x.ipv4.clone())
|
|
.unwrap_or_default(),
|
|
direct_peers: directs
|
|
.direct_peers
|
|
.iter()
|
|
.map(|(k, v)| PeerCenterDirectPeerData {
|
|
node_id: k.to_string(),
|
|
hostname: node_id_to_node_info
|
|
.get(k)
|
|
.map(|x| x.hostname.clone())
|
|
.unwrap_or_default(),
|
|
ipv4: node_id_to_node_info
|
|
.get(k)
|
|
.map(|x| x.ipv4.clone())
|
|
.unwrap_or_default(),
|
|
latency_ms: v.latency_ms,
|
|
})
|
|
.collect(),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn fetch_vpn_portal_info(&self) -> Result<VpnPortalInfo, Error> {
|
|
Ok(self
|
|
.get_vpn_portal_client()
|
|
.await?
|
|
.get_vpn_portal_info(
|
|
BaseController::default(),
|
|
GetVpnPortalInfoRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?
|
|
.vpn_portal_info
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
async fn fetch_stats(&self) -> Result<Vec<MetricSnapshot>, Error> {
|
|
Ok(self
|
|
.get_stats_client()
|
|
.await?
|
|
.get_stats(
|
|
BaseController::default(),
|
|
GetStatsRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?
|
|
.metrics)
|
|
}
|
|
|
|
async fn fetch_prometheus_stats(&self) -> Result<String, Error> {
|
|
Ok(self
|
|
.get_stats_client()
|
|
.await?
|
|
.get_prometheus_stats(
|
|
BaseController::default(),
|
|
GetPrometheusStatsRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await?
|
|
.prometheus_text)
|
|
}
|
|
|
|
fn connector_validate_url(url: &str) -> Result<url::Url, Error> {
|
|
let url = url::Url::parse(url).map_err(|e| anyhow::anyhow!("invalid url ({url}): {e}"))?;
|
|
TunnelScheme::try_from(&url).map_err(|_| {
|
|
anyhow::anyhow!("unsupported scheme \"{}\" in url ({url})", url.scheme())
|
|
})?;
|
|
Ok(url)
|
|
}
|
|
|
|
async fn apply_connector_modify(
|
|
&self,
|
|
url: &str,
|
|
action: ConfigPatchAction,
|
|
) -> Result<(), Error> {
|
|
let url = match action {
|
|
ConfigPatchAction::Add => Self::connector_validate_url(url)?,
|
|
ConfigPatchAction::Remove => {
|
|
url::Url::parse(url).map_err(|e| anyhow::anyhow!("invalid url ({url}): {e}"))?
|
|
}
|
|
ConfigPatchAction::Clear => {
|
|
return Err(anyhow::anyhow!(
|
|
"unsupported connector patch action: {:?}",
|
|
action
|
|
));
|
|
}
|
|
};
|
|
let client = self.get_config_client().await?;
|
|
let request = PatchConfigRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
patch: Some(InstanceConfigPatch {
|
|
connectors: vec![UrlPatch {
|
|
action: action.into(),
|
|
url: Some(url.into()),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
};
|
|
let _response = client
|
|
.patch_config(BaseController::default(), request)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_connector_modify(
|
|
&self,
|
|
url: &str,
|
|
action: ConfigPatchAction,
|
|
) -> Result<(), Error> {
|
|
let url = url.to_string();
|
|
self.apply_to_instances(|handler| {
|
|
let url = url.clone();
|
|
Box::pin(async move { handler.apply_connector_modify(&url, action).await })
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn handle_peer_list(&self) -> Result<(), Error> {
|
|
#[derive(tabled::Tabled, serde::Serialize)]
|
|
struct PeerTableItem {
|
|
#[tabled(rename = "ipv4")]
|
|
cidr: String,
|
|
#[tabled(skip)]
|
|
ipv4: String,
|
|
hostname: String,
|
|
cost: String,
|
|
#[tabled(rename = "lat(ms)")]
|
|
lat_ms: String,
|
|
#[tabled(rename = "loss")]
|
|
loss_rate: String,
|
|
#[tabled(rename = "rx")]
|
|
rx_bytes: String,
|
|
#[tabled(rename = "tx")]
|
|
tx_bytes: String,
|
|
#[tabled(rename = "tunnel")]
|
|
tunnel_proto: String,
|
|
#[tabled(rename = "NAT")]
|
|
nat_type: String,
|
|
#[tabled(skip)]
|
|
id: String,
|
|
version: String,
|
|
}
|
|
|
|
impl From<PeerRoutePair> for PeerTableItem {
|
|
fn from(p: PeerRoutePair) -> Self {
|
|
let route = p.route.clone().unwrap_or_default();
|
|
let lat_ms = if route.cost == 1 {
|
|
p.get_latency_ms().unwrap_or(0.0)
|
|
} else {
|
|
route.path_latency_latency_first() as f64
|
|
};
|
|
PeerTableItem {
|
|
cidr: route.ipv4_addr.map(|ip| ip.to_string()).unwrap_or_default(),
|
|
ipv4: route
|
|
.ipv4_addr
|
|
.map(|ip: easytier::proto::common::Ipv4Inet| ip.address.unwrap_or_default())
|
|
.map(|ip| ip.to_string())
|
|
.unwrap_or_default(),
|
|
hostname: route.hostname.clone(),
|
|
cost: cost_to_str(route.cost),
|
|
lat_ms: format!("{:.2}", lat_ms),
|
|
loss_rate: format!("{:.1}%", p.get_loss_rate().unwrap_or(0.0) * 100.0),
|
|
rx_bytes: format_size(p.get_rx_bytes().unwrap_or(0), humansize::DECIMAL),
|
|
tx_bytes: format_size(p.get_tx_bytes().unwrap_or(0), humansize::DECIMAL),
|
|
tunnel_proto: p.get_conn_protos().unwrap_or_default().join(","),
|
|
nat_type: p.get_udp_nat_type(),
|
|
id: route.peer_id.to_string(),
|
|
version: if route.version.is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
route.version
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<NodeInfo> for PeerTableItem {
|
|
fn from(p: NodeInfo) -> Self {
|
|
PeerTableItem {
|
|
cidr: p.ipv4_addr.clone(),
|
|
ipv4: Ipv4Inet::from_str(&p.ipv4_addr)
|
|
.map(|ip| ip.address().to_string())
|
|
.unwrap_or_default(),
|
|
hostname: p.hostname.clone(),
|
|
cost: "Local".to_string(),
|
|
lat_ms: "-".to_string(),
|
|
loss_rate: "-".to_string(),
|
|
rx_bytes: "-".to_string(),
|
|
tx_bytes: "-".to_string(),
|
|
tunnel_proto: "-".to_string(),
|
|
nat_type: if let Some(info) = p.stun_info {
|
|
info.udp_nat_type().as_str_name().to_string()
|
|
} else {
|
|
"Unknown".to_string()
|
|
},
|
|
id: p.peer_id.to_string(),
|
|
version: p.version,
|
|
}
|
|
}
|
|
}
|
|
|
|
let build_items = |data: &PeerListData| {
|
|
let mut items = Vec::with_capacity(data.peer_routes.len() + 1);
|
|
items.push(PeerTableItem::from(data.node_info.clone()));
|
|
items.extend(data.peer_routes.iter().cloned().map(Into::into));
|
|
items.sort_by(|a, b| {
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
|
|
let a_is_local = a.cost == "Local";
|
|
let b_is_local = b.cost == "Local";
|
|
if a_is_local != b_is_local {
|
|
return if a_is_local {
|
|
std::cmp::Ordering::Less
|
|
} else {
|
|
std::cmp::Ordering::Greater
|
|
};
|
|
}
|
|
|
|
let a_is_public = a.hostname.starts_with(
|
|
easytier_core::peers::foreign_network::PUBLIC_SERVER_HOSTNAME_PREFIX,
|
|
);
|
|
let b_is_public = b.hostname.starts_with(
|
|
easytier_core::peers::foreign_network::PUBLIC_SERVER_HOSTNAME_PREFIX,
|
|
);
|
|
if a_is_public != b_is_public {
|
|
return if a_is_public {
|
|
std::cmp::Ordering::Less
|
|
} else {
|
|
std::cmp::Ordering::Greater
|
|
};
|
|
}
|
|
|
|
let a_ip = IpAddr::from_str(&a.ipv4).unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
|
|
let b_ip = IpAddr::from_str(&b.ipv4).unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
|
|
match a_ip.cmp(&b_ip) {
|
|
std::cmp::Ordering::Equal => a.hostname.cmp(&b.hostname),
|
|
other => other,
|
|
}
|
|
});
|
|
items
|
|
};
|
|
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_peer_list_data()))
|
|
.await?;
|
|
|
|
if self.verbose {
|
|
return self.print_json_results(
|
|
results
|
|
.into_iter()
|
|
.map(|result| result.map(|data| data.peer_routes))
|
|
.collect(),
|
|
);
|
|
}
|
|
if *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(
|
|
results
|
|
.into_iter()
|
|
.map(|result| result.map(|data| build_items(&data)))
|
|
.collect(),
|
|
);
|
|
}
|
|
|
|
self.print_results(&results, |data| {
|
|
let items = build_items(data);
|
|
print_output(
|
|
&items,
|
|
self.output_format,
|
|
&["tunnel", "version"],
|
|
&["version", "tunnel", "nat", "tx", "rx", "loss", "lat(ms)"],
|
|
self.no_trunc,
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn handle_peer_ipv6(&self) -> Result<(), Error> {
|
|
#[derive(tabled::Tabled, serde::Serialize)]
|
|
struct PeerIpv6NodeRow {
|
|
peer_id: u32,
|
|
hostname: String,
|
|
inst_id: String,
|
|
ipv4: String,
|
|
public_ipv6_addr: String,
|
|
provider_prefix: String,
|
|
}
|
|
|
|
#[derive(tabled::Tabled, serde::Serialize)]
|
|
struct ProviderLeaseRow {
|
|
peer_id: u32,
|
|
inst_id: String,
|
|
leased_addr: String,
|
|
valid_until: String,
|
|
reused: bool,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct ProviderLeaseSection {
|
|
provider_prefix: String,
|
|
leases: Vec<ProviderLeaseRow>,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct PeerIpv6View {
|
|
nodes: Vec<PeerIpv6NodeRow>,
|
|
local_provider: Option<ProviderLeaseSection>,
|
|
}
|
|
|
|
fn fmt_ipv6_inet(value: Option<easytier::proto::common::Ipv6Inet>) -> String {
|
|
value
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "-".to_string())
|
|
}
|
|
|
|
fn fmt_valid_until(unix_seconds: i64) -> String {
|
|
chrono::DateTime::<chrono::Utc>::from_timestamp(unix_seconds, 0)
|
|
.map(|ts| {
|
|
ts.with_timezone(&chrono::Local)
|
|
.format("%Y-%m-%d %H:%M:%S")
|
|
.to_string()
|
|
})
|
|
.unwrap_or_else(|| unix_seconds.to_string())
|
|
}
|
|
|
|
let build_view = |data: &PeerIpv6DataRaw| {
|
|
let mut nodes = Vec::with_capacity(data.routes.len() + 1);
|
|
nodes.push(PeerIpv6NodeRow {
|
|
peer_id: data.node_info.peer_id,
|
|
hostname: data.node_info.hostname.clone(),
|
|
inst_id: data.node_info.inst_id.clone(),
|
|
ipv4: data.node_info.ipv4_addr.clone(),
|
|
public_ipv6_addr: fmt_ipv6_inet(data.node_info.public_ipv6_addr),
|
|
provider_prefix: fmt_ipv6_inet(data.node_info.ipv6_public_addr_prefix),
|
|
});
|
|
nodes.extend(data.routes.iter().map(|route| {
|
|
PeerIpv6NodeRow {
|
|
peer_id: route.peer_id,
|
|
hostname: route.hostname.clone(),
|
|
inst_id: route.inst_id.clone(),
|
|
ipv4: route
|
|
.ipv4_addr
|
|
.map(|ipv4| ipv4.to_string())
|
|
.unwrap_or_else(|| "-".to_string()),
|
|
public_ipv6_addr: fmt_ipv6_inet(route.public_ipv6_addr),
|
|
provider_prefix: fmt_ipv6_inet(route.ipv6_public_addr_prefix),
|
|
}
|
|
}));
|
|
nodes.sort_by_key(|row| {
|
|
(
|
|
row.peer_id != data.node_info.peer_id,
|
|
row.peer_id,
|
|
row.inst_id.clone(),
|
|
)
|
|
});
|
|
|
|
let local_provider = data.provider_info.provider_prefix.map(|provider_prefix| {
|
|
let mut leases = data
|
|
.provider_info
|
|
.provider_leases
|
|
.iter()
|
|
.map(|lease| ProviderLeaseRow {
|
|
peer_id: lease.peer_id,
|
|
inst_id: lease.inst_id.clone(),
|
|
leased_addr: fmt_ipv6_inet(lease.leased_addr),
|
|
valid_until: fmt_valid_until(lease.valid_until_unix_seconds),
|
|
reused: lease.reused,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
leases.sort_by_key(|lease| {
|
|
(
|
|
lease.peer_id,
|
|
lease.inst_id.clone(),
|
|
lease.leased_addr.clone(),
|
|
)
|
|
});
|
|
ProviderLeaseSection {
|
|
provider_prefix: provider_prefix.to_string(),
|
|
leases,
|
|
}
|
|
});
|
|
|
|
PeerIpv6View {
|
|
nodes,
|
|
local_provider,
|
|
}
|
|
};
|
|
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_peer_ipv6_data()))
|
|
.await?;
|
|
|
|
if self.verbose || *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(
|
|
results
|
|
.into_iter()
|
|
.map(|result| result.map(|data| build_view(&data)))
|
|
.collect(),
|
|
);
|
|
}
|
|
|
|
self.print_results(&results, |data| {
|
|
let view = build_view(data);
|
|
print_output(&view.nodes, self.output_format, &[], &[], self.no_trunc)?;
|
|
|
|
if let Some(local_provider) = view.local_provider {
|
|
println!();
|
|
println!("Local provider prefix: {}", local_provider.provider_prefix);
|
|
if local_provider.leases.is_empty() {
|
|
println!("No active provider leases");
|
|
} else {
|
|
print_output(
|
|
&local_provider.leases,
|
|
self.output_format,
|
|
&[],
|
|
&[],
|
|
self.no_trunc,
|
|
)?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn handle_route_dump(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_route_dump()))
|
|
.await?;
|
|
if self.verbose || *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
self.print_results(&results, |result| {
|
|
println!("response: {}", result);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn handle_foreign_network_list(&self, include_trusted_keys: bool) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| {
|
|
Box::pin(handler.fetch_foreign_networks(include_trusted_keys))
|
|
})
|
|
.await?;
|
|
if self.verbose || *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
|
|
self.print_results(&results, |networks| {
|
|
for (idx, (k, v)) in networks.iter().enumerate() {
|
|
println!("{} Network Name: {}", idx + 1, k);
|
|
for peer in v.peers.iter() {
|
|
println!(
|
|
" peer_id: {}, peer_conn_count: {}, conns: [ {} ]",
|
|
peer.peer_id,
|
|
peer.conns.len(),
|
|
peer.conns
|
|
.iter()
|
|
.map(|conn| format!(
|
|
"remote_addr: {}, rx_bytes: {}, tx_bytes: {}, latency_us: {}",
|
|
conn.tunnel
|
|
.as_ref()
|
|
.and_then(|t| t.display_remote_addr())
|
|
.unwrap_or_default(),
|
|
conn.stats.as_ref().map(|s| s.rx_bytes).unwrap_or_default(),
|
|
conn.stats.as_ref().map(|s| s.tx_bytes).unwrap_or_default(),
|
|
conn.stats
|
|
.as_ref()
|
|
.map(|s| s.latency_us)
|
|
.unwrap_or_default(),
|
|
))
|
|
.collect::<Vec<_>>()
|
|
.join("; ")
|
|
);
|
|
}
|
|
if include_trusted_keys {
|
|
println!(" trusted_keys:");
|
|
for trusted_key in &v.trusted_keys {
|
|
let source = TrustedKeySourcePb::try_from(trusted_key.source)
|
|
.map(|source| source.as_str_name())
|
|
.unwrap_or("TRUSTED_KEY_SOURCE_PB_UNSPECIFIED");
|
|
let expiry = trusted_key
|
|
.expiry_unix
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "-".to_string());
|
|
println!(
|
|
" source: {}, expiry_unix: {}, pubkey: {}",
|
|
source,
|
|
expiry,
|
|
BASE64_STANDARD.encode(&trusted_key.pubkey),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn handle_global_foreign_network_list(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_global_foreign_networks()))
|
|
.await?;
|
|
if self.verbose || *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
|
|
self.print_results(&results, |networks| {
|
|
for (k, v) in networks.iter() {
|
|
println!("Peer ID: {}", k);
|
|
for n in v.foreign_networks.iter() {
|
|
println!(
|
|
" Network Name: {}, Last Updated: {}, Version: {}, PeerIds: {:?}",
|
|
n.network_name, n.last_updated, n.version, n.peer_ids
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn handle_route_list(&self) -> Result<(), Error> {
|
|
#[derive(tabled::Tabled, serde::Serialize)]
|
|
struct RouteTableItem {
|
|
ipv4: String,
|
|
hostname: String,
|
|
#[tabled(display_with = "format_proxy_cidrs")]
|
|
proxy_cidrs: String,
|
|
|
|
next_hop_ipv4: String,
|
|
next_hop_hostname: String,
|
|
next_hop_lat: f64,
|
|
path_len: i32,
|
|
path_latency: i32,
|
|
|
|
next_hop_ipv4_lat_first: String,
|
|
next_hop_hostname_lat_first: String,
|
|
path_len_lat_first: i32,
|
|
path_latency_lat_first: i32,
|
|
|
|
version: String,
|
|
}
|
|
|
|
let build_items = |data: &RouteListData| {
|
|
let mut items = vec![RouteTableItem {
|
|
ipv4: data.node_info.ipv4_addr.clone(),
|
|
hostname: data.node_info.hostname.clone(),
|
|
proxy_cidrs: data.node_info.proxy_cidrs.join(", "),
|
|
next_hop_ipv4: "-".to_string(),
|
|
next_hop_hostname: "Local".to_string(),
|
|
next_hop_lat: 0.0,
|
|
path_len: 0,
|
|
path_latency: 0,
|
|
next_hop_ipv4_lat_first: "-".to_string(),
|
|
next_hop_hostname_lat_first: "Local".to_string(),
|
|
path_len_lat_first: 0,
|
|
path_latency_lat_first: 0,
|
|
version: data.node_info.version.clone(),
|
|
}];
|
|
|
|
for p in data.peer_routes.iter() {
|
|
let Some(next_hop_pair) = data.peer_routes.iter().find(|pair| {
|
|
pair.route.clone().unwrap_or_default().peer_id
|
|
== p.route.clone().unwrap_or_default().next_hop_peer_id
|
|
}) else {
|
|
continue;
|
|
};
|
|
|
|
let next_hop_pair_latency_first = data.peer_routes.iter().find(|pair| {
|
|
pair.route.clone().unwrap_or_default().peer_id
|
|
== p.route
|
|
.clone()
|
|
.unwrap_or_default()
|
|
.next_hop_peer_id_latency_first
|
|
.unwrap_or_default()
|
|
});
|
|
|
|
let route = p.route.clone().unwrap_or_default();
|
|
items.push(RouteTableItem {
|
|
ipv4: route.ipv4_addr.map(|ip| ip.to_string()).unwrap_or_default(),
|
|
hostname: route.hostname.clone(),
|
|
proxy_cidrs: route.proxy_cidrs.clone().join(","),
|
|
next_hop_ipv4: if route.cost == 1 {
|
|
"DIRECT".to_string()
|
|
} else {
|
|
next_hop_pair
|
|
.route
|
|
.clone()
|
|
.unwrap_or_default()
|
|
.ipv4_addr
|
|
.map(|ip| ip.to_string())
|
|
.unwrap_or_default()
|
|
},
|
|
next_hop_hostname: if route.cost == 1 {
|
|
"DIRECT".to_string()
|
|
} else {
|
|
next_hop_pair.route.clone().unwrap_or_default().hostname
|
|
},
|
|
next_hop_lat: next_hop_pair.get_latency_ms().unwrap_or(0.0),
|
|
path_len: route.cost,
|
|
path_latency: route.path_latency,
|
|
next_hop_ipv4_lat_first: if route.cost_latency_first.unwrap_or_default() == 1 {
|
|
"DIRECT".to_string()
|
|
} else {
|
|
next_hop_pair_latency_first
|
|
.map(|pair| pair.route.clone().unwrap_or_default().ipv4_addr)
|
|
.unwrap_or_default()
|
|
.map(|ip| ip.to_string())
|
|
.unwrap_or_default()
|
|
},
|
|
next_hop_hostname_lat_first: if route.cost_latency_first.unwrap_or_default()
|
|
== 1
|
|
{
|
|
"DIRECT".to_string()
|
|
} else {
|
|
next_hop_pair_latency_first
|
|
.map(|pair| pair.route.clone().unwrap_or_default().hostname)
|
|
.unwrap_or_default()
|
|
},
|
|
path_latency_lat_first: route.path_latency_latency_first.unwrap_or_default(),
|
|
path_len_lat_first: route.cost_latency_first.unwrap_or_default(),
|
|
version: if route.version.is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
route.version
|
|
},
|
|
});
|
|
}
|
|
|
|
items
|
|
};
|
|
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_route_list_data()))
|
|
.await?;
|
|
|
|
if self.verbose {
|
|
return self.print_json_results(results);
|
|
}
|
|
if *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(
|
|
results
|
|
.into_iter()
|
|
.map(|result| result.map(|data| build_items(&data)))
|
|
.collect(),
|
|
);
|
|
}
|
|
|
|
self.print_results(&results, |data| {
|
|
let items = build_items(data);
|
|
print_output(
|
|
&items,
|
|
self.output_format,
|
|
ROUTE_OPTIONAL_COLUMNS,
|
|
ROUTE_DROP_COLUMNS,
|
|
self.no_trunc,
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn handle_connector_list(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_connector_list()))
|
|
.await?;
|
|
if self.verbose || *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
self.print_results(&results, |connectors| {
|
|
println!("response: {:#?}", connectors);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn handle_acl_stats(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_acl_stats()))
|
|
.await?;
|
|
if *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
|
|
self.print_results(&results, |acl_stats| {
|
|
if let Some(acl_stats) = acl_stats {
|
|
println!("{}", acl_stats);
|
|
} else {
|
|
println!("No ACL statistics available");
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn handle_mapped_listener_list(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_mapped_listener_list()))
|
|
.await?;
|
|
if self.verbose || *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
self.print_results(&results, |listeners| {
|
|
println!("response: {:#?}", listeners);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn apply_mapped_listener_modify(
|
|
&self,
|
|
url: &str,
|
|
action: ConfigPatchAction,
|
|
) -> Result<(), Error> {
|
|
let url = Self::mapped_listener_validate_url(url)?;
|
|
let client = self.get_config_client().await?;
|
|
let request = PatchConfigRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
patch: Some(InstanceConfigPatch {
|
|
mapped_listeners: vec![UrlPatch {
|
|
action: action.into(),
|
|
url: Some(url.into()),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
};
|
|
let _response = client
|
|
.patch_config(BaseController::default(), request)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_mapped_listener_modify(
|
|
&self,
|
|
url: &str,
|
|
action: ConfigPatchAction,
|
|
) -> Result<(), Error> {
|
|
let url = url.to_string();
|
|
self.apply_to_instances(|handler| {
|
|
let url = url.clone();
|
|
Box::pin(async move { handler.apply_mapped_listener_modify(&url, action).await })
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn mapped_listener_validate_url(url: &str) -> Result<url::Url, Error> {
|
|
let url = url::Url::parse(url)?;
|
|
if url.scheme() != "tcp" && url.scheme() != "udp" {
|
|
return Err(anyhow::anyhow!(
|
|
"Url ({url}) must start with tcp:// or udp://"
|
|
));
|
|
} else if url.port().is_none() {
|
|
return Err(anyhow::anyhow!("Url ({url}) is missing port num"));
|
|
}
|
|
Ok(url)
|
|
}
|
|
|
|
async fn apply_port_forward_modify(
|
|
&self,
|
|
action: ConfigPatchAction,
|
|
protocol: &str,
|
|
bind_addr: &str,
|
|
dst_addr: Option<&str>,
|
|
) -> Result<(), Error> {
|
|
let bind_addr: std::net::SocketAddr = bind_addr
|
|
.parse()
|
|
.with_context(|| format!("Invalid bind address: {}", bind_addr))?;
|
|
|
|
let socket_type = match protocol {
|
|
"tcp" => SocketType::Tcp,
|
|
"udp" => SocketType::Udp,
|
|
_ => return Err(anyhow::anyhow!("Protocol must be 'tcp' or 'udp'")),
|
|
};
|
|
|
|
let client = self.get_config_client().await?;
|
|
let request = PatchConfigRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
patch: Some(InstanceConfigPatch {
|
|
port_forwards: vec![PortForwardPatch {
|
|
action: action.into(),
|
|
cfg: Some(PortForwardConfigPb {
|
|
bind_addr: Some(bind_addr.into()),
|
|
dst_addr: dst_addr.map(|s| s.parse::<SocketAddr>().unwrap().into()),
|
|
socket_type: socket_type.into(),
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
client
|
|
.patch_config(BaseController::default(), request)
|
|
.await?;
|
|
println!(
|
|
"Port forward rule {}: {} {}",
|
|
action.as_str_name().to_lowercase(),
|
|
protocol,
|
|
bind_addr
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_port_forward_modify(
|
|
&self,
|
|
action: ConfigPatchAction,
|
|
protocol: &str,
|
|
bind_addr: &str,
|
|
dst_addr: Option<&str>,
|
|
) -> Result<(), Error> {
|
|
let protocol = protocol.to_string();
|
|
let bind_addr = bind_addr.to_string();
|
|
let dst_addr = dst_addr.map(str::to_string);
|
|
self.apply_to_instances(|handler| {
|
|
let protocol = protocol.clone();
|
|
let bind_addr = bind_addr.clone();
|
|
let dst_addr = dst_addr.clone();
|
|
Box::pin(async move {
|
|
handler
|
|
.apply_port_forward_modify(action, &protocol, &bind_addr, dst_addr.as_deref())
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn handle_port_forward_list(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_port_forward_list()))
|
|
.await?;
|
|
if self.verbose || *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
|
|
#[derive(tabled::Tabled, serde::Serialize)]
|
|
struct PortForwardTableItem {
|
|
protocol: String,
|
|
bind_addr: String,
|
|
dst_addr: String,
|
|
}
|
|
|
|
self.print_results(&results, |response| {
|
|
let items: Vec<PortForwardTableItem> = response
|
|
.cfgs
|
|
.iter()
|
|
.cloned()
|
|
.map(|rule| PortForwardTableItem {
|
|
protocol: format!(
|
|
"{:?}",
|
|
SocketType::try_from(rule.socket_type).unwrap_or(SocketType::Tcp)
|
|
),
|
|
bind_addr: rule
|
|
.bind_addr
|
|
.map(|addr| addr.to_string())
|
|
.unwrap_or_default(),
|
|
dst_addr: rule
|
|
.dst_addr
|
|
.map(|addr| addr.to_string())
|
|
.unwrap_or_default(),
|
|
})
|
|
.collect();
|
|
|
|
print_output(&items, self.output_format, &[], &[], self.no_trunc)
|
|
})
|
|
}
|
|
|
|
async fn apply_whitelist_set(&self, ports: &str, is_tcp: bool) -> Result<(), Error> {
|
|
let mut whitelist = Self::parse_port_list(ports)?
|
|
.into_iter()
|
|
.map(|p| StringPatch {
|
|
action: ConfigPatchAction::Add.into(),
|
|
value: p,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
whitelist.insert(
|
|
0,
|
|
StringPatch {
|
|
action: ConfigPatchAction::Clear.into(),
|
|
value: "".to_string(),
|
|
},
|
|
);
|
|
let client = self.get_config_client().await?;
|
|
|
|
let request = PatchConfigRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
patch: Some(InstanceConfigPatch {
|
|
acl: Some(AclPatch {
|
|
tcp_whitelist: if is_tcp { whitelist.clone() } else { vec![] },
|
|
udp_whitelist: if is_tcp { vec![] } else { whitelist },
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
client
|
|
.patch_config(BaseController::default(), request)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_whitelist_set_tcp(&self, ports: &str) -> Result<(), Error> {
|
|
let ports = ports.to_string();
|
|
self.apply_to_instances(|handler| {
|
|
let ports = ports.clone();
|
|
Box::pin(async move { handler.apply_whitelist_set(&ports, true).await })
|
|
})
|
|
.await?;
|
|
println!("TCP whitelist updated: {}", ports);
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_whitelist_set_udp(&self, ports: &str) -> Result<(), Error> {
|
|
let ports = ports.to_string();
|
|
self.apply_to_instances(|handler| {
|
|
let ports = ports.clone();
|
|
Box::pin(async move { handler.apply_whitelist_set(&ports, false).await })
|
|
})
|
|
.await?;
|
|
println!("UDP whitelist updated: {}", ports);
|
|
Ok(())
|
|
}
|
|
|
|
async fn apply_whitelist_clear(&self, is_tcp: bool) -> Result<(), Error> {
|
|
let client = self.get_config_client().await?;
|
|
|
|
let request = PatchConfigRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
patch: Some(InstanceConfigPatch {
|
|
acl: Some(AclPatch {
|
|
tcp_whitelist: if is_tcp {
|
|
vec![StringPatch {
|
|
action: ConfigPatchAction::Clear.into(),
|
|
value: "".to_string(),
|
|
}]
|
|
} else {
|
|
vec![]
|
|
},
|
|
udp_whitelist: if is_tcp {
|
|
vec![]
|
|
} else {
|
|
vec![StringPatch {
|
|
action: ConfigPatchAction::Clear.into(),
|
|
value: "".to_string(),
|
|
}]
|
|
},
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
client
|
|
.patch_config(BaseController::default(), request)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_whitelist_clear_tcp(&self) -> Result<(), Error> {
|
|
self.apply_to_instances(|handler| Box::pin(handler.apply_whitelist_clear(true)))
|
|
.await?;
|
|
println!("TCP whitelist cleared");
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_whitelist_clear_udp(&self) -> Result<(), Error> {
|
|
self.apply_to_instances(|handler| Box::pin(handler.apply_whitelist_clear(false)))
|
|
.await?;
|
|
println!("UDP whitelist cleared");
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_whitelist_show(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_whitelist()))
|
|
.await?;
|
|
if self.verbose || *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
|
|
self.print_results(&results, |response| {
|
|
println!(
|
|
"TCP Whitelist: {}",
|
|
if response.tcp_ports.is_empty() {
|
|
"None".to_string()
|
|
} else {
|
|
response.tcp_ports.join(", ")
|
|
}
|
|
);
|
|
|
|
println!(
|
|
"UDP Whitelist: {}",
|
|
if response.udp_ports.is_empty() {
|
|
"None".to_string()
|
|
} else {
|
|
response.udp_ports.join(", ")
|
|
}
|
|
);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn handle_logger_get(&self) -> Result<(), Error> {
|
|
let client = self.get_logger_client().await?;
|
|
let request = GetLoggerConfigRequest::default();
|
|
let response = client
|
|
.get_logger_config(BaseController::default(), request)
|
|
.await?;
|
|
|
|
match self.output_format {
|
|
OutputFormat::Table => {
|
|
let level_str = match response.level() {
|
|
LogLevel::Disabled => "disabled",
|
|
LogLevel::Error => "error",
|
|
LogLevel::Warning => "warning",
|
|
LogLevel::Info => "info",
|
|
LogLevel::Debug => "debug",
|
|
LogLevel::Trace => "trace",
|
|
};
|
|
println!("Current Log Level: {}", level_str);
|
|
}
|
|
OutputFormat::Json => {
|
|
let json = serde_json::to_string_pretty(&response)?;
|
|
println!("{}", json);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_logger_set(&self, level: &str) -> Result<(), Error> {
|
|
let log_level = match level.to_lowercase().as_str() {
|
|
"disabled" => LogLevel::Disabled,
|
|
"error" => LogLevel::Error,
|
|
"warning" => LogLevel::Warning,
|
|
"info" => LogLevel::Info,
|
|
"debug" => LogLevel::Debug,
|
|
"trace" => LogLevel::Trace,
|
|
_ => {
|
|
return Err(anyhow::anyhow!(
|
|
"Invalid log level: {}. Valid levels are: disabled, error, warning, info, debug, trace",
|
|
level
|
|
));
|
|
}
|
|
};
|
|
|
|
let client = self.get_logger_client().await?;
|
|
let request = SetLoggerConfigRequest {
|
|
level: log_level.into(),
|
|
};
|
|
let response = client
|
|
.set_logger_config(BaseController::default(), request)
|
|
.await?;
|
|
|
|
match self.output_format {
|
|
OutputFormat::Table => {
|
|
println!("Log level successfully set to: {}", level);
|
|
}
|
|
OutputFormat::Json => {
|
|
let json = serde_json::to_string_pretty(&response)?;
|
|
println!("{}", json);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_credential_generate(
|
|
&self,
|
|
ttl: i64,
|
|
credential_id: Option<String>,
|
|
groups: Vec<String>,
|
|
allow_relay: bool,
|
|
allowed_proxy_cidrs: Vec<String>,
|
|
reusable: bool,
|
|
) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| {
|
|
let credential_id = credential_id.clone();
|
|
let groups = groups.clone();
|
|
let allowed_proxy_cidrs = allowed_proxy_cidrs.clone();
|
|
Box::pin(async move {
|
|
handler
|
|
.get_credential_client()
|
|
.await?
|
|
.generate_credential(
|
|
BaseController::default(),
|
|
GenerateCredentialRequest {
|
|
credential_id,
|
|
groups,
|
|
allow_relay,
|
|
allowed_proxy_cidrs,
|
|
ttl_seconds: ttl,
|
|
instance: Some(handler.instance_selector.clone()),
|
|
reusable: Some(reusable),
|
|
},
|
|
)
|
|
.await
|
|
.map_err(Into::into)
|
|
})
|
|
})
|
|
.await?;
|
|
|
|
if *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
|
|
self.print_results(&results, |response| {
|
|
println!("Credential generated successfully:");
|
|
println!(" credential_id: {}", response.credential_id);
|
|
println!(" credential_secret: {}", response.credential_secret);
|
|
println!();
|
|
println!("To use this credential on a new node:");
|
|
println!(
|
|
" easytier-core --network-name <name> --secure-mode --credential {} -p <node-url>",
|
|
response.credential_secret
|
|
);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn handle_credential_revoke(&self, credential_id: &str) -> Result<(), Error> {
|
|
let credential_id = credential_id.to_string();
|
|
let results = self
|
|
.collect_instance_results(|handler| {
|
|
let credential_id = credential_id.clone();
|
|
Box::pin(async move {
|
|
handler
|
|
.get_credential_client()
|
|
.await?
|
|
.revoke_credential(
|
|
BaseController::default(),
|
|
RevokeCredentialRequest {
|
|
credential_id,
|
|
instance: Some(handler.instance_selector.clone()),
|
|
},
|
|
)
|
|
.await
|
|
.map_err(Into::into)
|
|
})
|
|
})
|
|
.await?;
|
|
|
|
if *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
|
|
self.print_results(&results, |response| {
|
|
if response.success {
|
|
println!("Credential revoked successfully");
|
|
} else {
|
|
println!("Credential not found");
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn handle_credential_list(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_credential_list()))
|
|
.await?;
|
|
|
|
if *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
|
|
self.print_results(&results, |response| {
|
|
if response.credentials.is_empty() {
|
|
println!("No active credentials");
|
|
} else {
|
|
use tabled::{builder::Builder, settings::Style};
|
|
let mut builder = Builder::default();
|
|
builder.push_record([
|
|
"ID",
|
|
"Groups",
|
|
"Relay",
|
|
"Reusable",
|
|
"Expiry",
|
|
"Allowed CIDRs",
|
|
]);
|
|
for cred in &response.credentials {
|
|
let expiry = {
|
|
let secs = cred.expiry_unix;
|
|
let remaining = secs
|
|
- std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs() as i64;
|
|
if remaining > 0 {
|
|
format!("{}s remaining", remaining)
|
|
} else {
|
|
"expired".to_string()
|
|
}
|
|
};
|
|
builder.push_record([
|
|
&cred.credential_id[..],
|
|
&cred.groups.join(","),
|
|
if cred.allow_relay { "yes" } else { "no" },
|
|
if cred.reusable.unwrap_or(true) {
|
|
"yes"
|
|
} else {
|
|
"no"
|
|
},
|
|
&expiry,
|
|
&cred.allowed_proxy_cidrs.join(","),
|
|
]);
|
|
}
|
|
let table = builder.build().with(Style::rounded()).to_string();
|
|
println!("{}", table);
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn handle_peer_center(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_peer_center_rows()))
|
|
.await?;
|
|
|
|
if *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
|
|
#[derive(tabled::Tabled, serde::Serialize)]
|
|
struct PeerCenterTableItem {
|
|
node_id: String,
|
|
hostname: String,
|
|
ipv4: String,
|
|
#[tabled(rename = "direct_peers")]
|
|
direct_peers_str: String,
|
|
}
|
|
|
|
self.print_results(&results, |rows| {
|
|
let table_rows = rows
|
|
.iter()
|
|
.map(|row| PeerCenterTableItem {
|
|
node_id: row.node_id.clone(),
|
|
hostname: row.hostname.clone(),
|
|
ipv4: row.ipv4.clone(),
|
|
direct_peers_str: row
|
|
.direct_peers
|
|
.iter()
|
|
.map(|x| {
|
|
format!(
|
|
"{}({}[{}]): {}ms",
|
|
x.node_id, x.hostname, x.ipv4, x.latency_ms,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
print_output(
|
|
&table_rows,
|
|
self.output_format,
|
|
&["direct_peers"],
|
|
&["direct_peers"],
|
|
self.no_trunc,
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn apply_vpn_portal_client_patch(
|
|
&self,
|
|
patch: VpnPortalClientPatch,
|
|
) -> Result<(), Error> {
|
|
let client = self.get_config_client().await?;
|
|
let request = PatchConfigRequest {
|
|
instance: Some(self.instance_selector.clone()),
|
|
patch: Some(InstanceConfigPatch {
|
|
vpn_portal_clients: vec![patch],
|
|
..Default::default()
|
|
}),
|
|
};
|
|
let _response = client
|
|
.patch_config(BaseController::default(), request)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_vpn_portal_add_client(
|
|
&self,
|
|
name: String,
|
|
virtual_ip: String,
|
|
groups: Vec<String>,
|
|
) -> Result<(), Error> {
|
|
virtual_ip
|
|
.parse::<std::net::Ipv4Addr>()
|
|
.map_err(|e| anyhow::anyhow!("invalid virtual ip ({virtual_ip}): {e}"))?;
|
|
self.apply_to_instances(|handler| {
|
|
let name = name.clone();
|
|
let virtual_ip = virtual_ip.clone();
|
|
let groups = groups.clone();
|
|
Box::pin(async move {
|
|
handler
|
|
.apply_vpn_portal_client_patch(VpnPortalClientPatch {
|
|
action: ConfigPatchAction::Add as i32,
|
|
client: Some(ManageVpnPortalClientConfig {
|
|
name,
|
|
virtual_ip,
|
|
groups,
|
|
}),
|
|
})
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn handle_vpn_portal_remove_client(&self, name: String) -> Result<(), Error> {
|
|
self.apply_to_instances(|handler| {
|
|
let name = name.clone();
|
|
Box::pin(async move {
|
|
handler
|
|
.apply_vpn_portal_client_patch(VpnPortalClientPatch {
|
|
action: ConfigPatchAction::Remove as i32,
|
|
client: Some(ManageVpnPortalClientConfig {
|
|
name,
|
|
virtual_ip: String::new(),
|
|
groups: Vec::new(),
|
|
}),
|
|
})
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn handle_vpn_portal_clear_clients(&self) -> Result<(), Error> {
|
|
self.apply_to_instances(|handler| {
|
|
Box::pin(async move {
|
|
handler
|
|
.apply_vpn_portal_client_patch(VpnPortalClientPatch {
|
|
action: ConfigPatchAction::Clear as i32,
|
|
client: None,
|
|
})
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn handle_vpn_portal(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_vpn_portal_info()))
|
|
.await?;
|
|
|
|
if *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
|
|
self.print_results(&results, |resp| {
|
|
println!("portal_name: {}", resp.vpn_type);
|
|
if let Some(listener) = &resp.listener {
|
|
println!("listener: {listener}");
|
|
}
|
|
for client in &resp.clients {
|
|
let state = easytier_proto::api::instance::VpnPortalClientState::try_from(
|
|
client.state,
|
|
)
|
|
.map_or("UNKNOWN", |state| state.as_str_name());
|
|
println!(
|
|
"\nclient: {}\nvirtual_ip: {}\nstate: {}\npeer_id: {}\nendpoint: {}\ntunnel_ip: {}\ngroups: {}",
|
|
client.name,
|
|
client.virtual_ip,
|
|
state,
|
|
client
|
|
.peer_id
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "-".to_owned()),
|
|
client.endpoint.as_deref().unwrap_or("-"),
|
|
client.tunnel_ip.as_deref().unwrap_or("-"),
|
|
client.groups.join(", "),
|
|
);
|
|
println!(
|
|
"############### client_config_start ###############\n{}############### client_config_end ###############",
|
|
client.client_config
|
|
);
|
|
if let Some(error) = &client.error {
|
|
println!("error: {error}");
|
|
}
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
async fn handle_node(&self, sub_command: Option<&NodeSubCommand>) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_node_info()))
|
|
.await?;
|
|
|
|
if self.verbose || *self.output_format == OutputFormat::Json {
|
|
return match sub_command {
|
|
Some(NodeSubCommand::Config) => self.print_json_results(
|
|
results
|
|
.into_iter()
|
|
.map(|result| result.map(|node| node.config))
|
|
.collect(),
|
|
),
|
|
_ => self.print_json_results(results),
|
|
};
|
|
}
|
|
|
|
self.print_results(&results, |node_info| match sub_command {
|
|
Some(NodeSubCommand::Config) => {
|
|
println!("{}", node_info.config);
|
|
Ok(())
|
|
}
|
|
Some(NodeSubCommand::Info) | None => {
|
|
let stun_info = node_info.stun_info.clone().unwrap_or_default();
|
|
let ip_list = node_info.ip_list.clone().unwrap_or_default();
|
|
|
|
let mut builder = tabled::builder::Builder::default();
|
|
builder.push_record(vec!["Virtual IP", node_info.ipv4_addr.as_str()]);
|
|
builder.push_record(vec!["Hostname", node_info.hostname.as_str()]);
|
|
builder.push_record(vec![
|
|
"Proxy CIDRs",
|
|
node_info.proxy_cidrs.join(", ").as_str(),
|
|
]);
|
|
builder.push_record(vec!["Peer ID", node_info.peer_id.to_string().as_str()]);
|
|
stun_info.public_ip.iter().for_each(|ip| {
|
|
let Ok(ip) = ip.parse::<IpAddr>() else {
|
|
return;
|
|
};
|
|
if ip.is_ipv4() {
|
|
builder.push_record(vec!["Public IPv4", ip.to_string().as_str()]);
|
|
} else {
|
|
builder.push_record(vec!["Public IPv6", ip.to_string().as_str()]);
|
|
}
|
|
});
|
|
builder.push_record(vec![
|
|
"UDP Stun Type",
|
|
format!("{:?}", stun_info.udp_nat_type()).as_str(),
|
|
]);
|
|
ip_list.interface_ipv4s.iter().for_each(|ip| {
|
|
builder.push_record(vec!["Interface IPv4", ip.to_string().as_str()]);
|
|
});
|
|
ip_list.interface_ipv6s.iter().for_each(|ip| {
|
|
builder.push_record(vec!["Interface IPv6", ip.to_string().as_str()]);
|
|
});
|
|
for (idx, l) in node_info.listeners.iter().enumerate() {
|
|
if l.starts_with("ring") {
|
|
continue;
|
|
}
|
|
builder.push_record(vec![format!("Listener {}", idx).as_str(), l]);
|
|
}
|
|
|
|
println!("{}", builder.build().with(Style::markdown()));
|
|
Ok(())
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn handle_stats_show(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_stats()))
|
|
.await?;
|
|
|
|
if *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(results);
|
|
}
|
|
|
|
#[derive(tabled::Tabled, serde::Serialize)]
|
|
struct StatsTableRow {
|
|
#[tabled(rename = "Metric Name")]
|
|
name: String,
|
|
#[tabled(rename = "Value")]
|
|
value: String,
|
|
#[tabled(rename = "Labels")]
|
|
labels: String,
|
|
}
|
|
|
|
self.print_results(&results, |metrics| {
|
|
let table_rows: Vec<StatsTableRow> = metrics
|
|
.iter()
|
|
.map(|metric| {
|
|
let labels_str = if metric.labels.is_empty() {
|
|
"-".to_string()
|
|
} else {
|
|
metric
|
|
.labels
|
|
.iter()
|
|
.map(|(k, v)| format!("{}={}", k, v))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
};
|
|
|
|
let formatted_value = if metric.name.contains("bytes") {
|
|
format_size(metric.value, humansize::BINARY)
|
|
} else if metric.name.contains("duration") {
|
|
format!("{} ms", metric.value)
|
|
} else {
|
|
metric.value.to_string()
|
|
};
|
|
|
|
StatsTableRow {
|
|
name: metric.name.clone(),
|
|
value: formatted_value,
|
|
labels: labels_str,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
print_output(
|
|
&table_rows,
|
|
self.output_format,
|
|
&["labels"],
|
|
&["labels"],
|
|
self.no_trunc,
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn handle_stats_prometheus(&self) -> Result<(), Error> {
|
|
let results = self
|
|
.collect_instance_results(|handler| Box::pin(handler.fetch_prometheus_stats()))
|
|
.await?;
|
|
|
|
if *self.output_format == OutputFormat::Json {
|
|
return self.print_json_results(
|
|
results
|
|
.into_iter()
|
|
.map(|result| result.map(|text| serde_json::json!({ "prometheus_text": text })))
|
|
.collect(),
|
|
);
|
|
}
|
|
|
|
self.print_results(&results, |text| {
|
|
println!("{}", text);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn parse_port_list(ports_str: &str) -> Result<Vec<String>, Error> {
|
|
let mut ports = Vec::new();
|
|
for port_spec in ports_str.split(',') {
|
|
let port_spec = port_spec.trim();
|
|
if port_spec.contains('-') {
|
|
// Handle port range
|
|
let parts: Vec<&str> = port_spec.split('-').collect();
|
|
if parts.len() != 2 {
|
|
return Err(anyhow::anyhow!("Invalid port range: {}", port_spec));
|
|
}
|
|
let start: u16 = parts[0]
|
|
.parse()
|
|
.with_context(|| format!("Invalid start port: {}", parts[0]))?;
|
|
let end: u16 = parts[1]
|
|
.parse()
|
|
.with_context(|| format!("Invalid end port: {}", parts[1]))?;
|
|
if start > end {
|
|
return Err(anyhow::anyhow!("Invalid port range: start > end"));
|
|
}
|
|
ports.push(format!("{}-{}", start, end));
|
|
} else {
|
|
// Handle single port
|
|
let port: u16 = port_spec
|
|
.parse()
|
|
.with_context(|| format!("Invalid port number: {}", port_spec))?;
|
|
ports.push(port.to_string());
|
|
}
|
|
}
|
|
Ok(ports)
|
|
}
|
|
}
|
|
|
|
fn print_output<T>(
|
|
items: &[T],
|
|
format: &OutputFormat,
|
|
optional_columns: &[&str],
|
|
drop_columns: &[&str],
|
|
no_trunc: bool,
|
|
) -> Result<(), Error>
|
|
where
|
|
T: tabled::Tabled + serde::Serialize,
|
|
{
|
|
match format {
|
|
OutputFormat::Table => {
|
|
let mut table = tabled::Table::new(items);
|
|
table.with(Style::markdown());
|
|
if no_trunc {
|
|
println!("{}", table);
|
|
return Ok(());
|
|
}
|
|
let headers = T::headers()
|
|
.iter()
|
|
.map(|header| header.as_ref().to_string())
|
|
.collect::<Vec<_>>();
|
|
let col_widths = compute_column_widths(items);
|
|
let terminal_width = terminal_table_width();
|
|
let drop_indices = header_indices(&headers, drop_columns);
|
|
let optional_indices = header_indices(&headers, optional_columns);
|
|
let (active, drop_indices, total_width) =
|
|
select_columns_to_drop(terminal_width, &drop_indices, &col_widths);
|
|
apply_column_drops(&mut table, &drop_indices);
|
|
apply_optional_column_truncation(
|
|
&mut table,
|
|
terminal_width,
|
|
&headers,
|
|
&optional_indices,
|
|
&col_widths,
|
|
&active,
|
|
total_width,
|
|
);
|
|
println!("{}", table);
|
|
}
|
|
OutputFormat::Json => {
|
|
println!("{}", serde_json::to_string_pretty(items)?);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn terminal_table_width() -> Option<usize> {
|
|
let (TerminalWidth(width), _) = terminal_size()?;
|
|
let width = width as usize;
|
|
// Avoid wrapping at the last column which can still trigger a hard line break.
|
|
width.checked_sub(1)
|
|
}
|
|
|
|
fn apply_optional_column_truncation(
|
|
table: &mut tabled::Table,
|
|
terminal_width: Option<usize>,
|
|
headers: &[String],
|
|
optional_indices: &[usize],
|
|
col_widths: &[usize],
|
|
active: &[bool],
|
|
total_width: usize,
|
|
) {
|
|
let Some(terminal_width) = terminal_width else {
|
|
return;
|
|
};
|
|
if optional_indices.is_empty() || total_width <= terminal_width {
|
|
return;
|
|
}
|
|
|
|
let targets = optional_column_targets(terminal_width, optional_indices, col_widths, active);
|
|
for (index, width) in targets {
|
|
if let Some(name) = headers.get(index) {
|
|
table.with(
|
|
Modify::new(ByColumnName::new(name)).with(Width::truncate(width).suffix("...")),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_column_drops(table: &mut tabled::Table, drop_indices: &[usize]) {
|
|
let mut indices = drop_indices.to_vec();
|
|
indices.sort_unstable_by(|a, b| b.cmp(a));
|
|
for index in indices {
|
|
table.with(Disable::column(Columns::single(index)));
|
|
}
|
|
}
|
|
|
|
fn compute_column_widths<T>(items: &[T]) -> Vec<usize>
|
|
where
|
|
T: tabled::Tabled,
|
|
{
|
|
let mut widths = vec![0usize; T::LENGTH];
|
|
for (idx, header) in T::headers().iter().enumerate() {
|
|
widths[idx] = widths[idx].max(text_width(header.as_ref()));
|
|
}
|
|
for item in items {
|
|
for (idx, field) in item.fields().iter().enumerate() {
|
|
widths[idx] = widths[idx].max(text_width(field.as_ref()));
|
|
}
|
|
}
|
|
widths
|
|
}
|
|
|
|
fn text_width(text: &str) -> usize {
|
|
text.split('\n')
|
|
.map(UnicodeWidthStr::width)
|
|
.max()
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn header_indices(headers: &[String], names: &[&str]) -> Vec<usize> {
|
|
let mut indices = Vec::new();
|
|
for name in names {
|
|
if let Some(index) = headers
|
|
.iter()
|
|
.position(|header| header.eq_ignore_ascii_case(name))
|
|
&& !indices.contains(&index)
|
|
{
|
|
indices.push(index);
|
|
}
|
|
}
|
|
indices
|
|
}
|
|
|
|
fn select_columns_to_drop(
|
|
terminal_width: Option<usize>,
|
|
drop_indices: &[usize],
|
|
col_widths: &[usize],
|
|
) -> (Vec<bool>, Vec<usize>, usize) {
|
|
let mut active = vec![true; col_widths.len()];
|
|
let Some(terminal_width) = terminal_width else {
|
|
let total = table_total_width(col_widths, &active);
|
|
return (active, vec![], total);
|
|
};
|
|
|
|
let mut total = table_total_width(col_widths, &active);
|
|
if total <= terminal_width {
|
|
return (active, vec![], total);
|
|
}
|
|
|
|
let mut dropped = vec![];
|
|
for &index in drop_indices {
|
|
if total <= terminal_width {
|
|
break;
|
|
}
|
|
if active[index] {
|
|
active[index] = false;
|
|
dropped.push(index);
|
|
total = table_total_width(col_widths, &active);
|
|
}
|
|
}
|
|
|
|
(active, dropped, total)
|
|
}
|
|
|
|
fn table_total_width(col_widths: &[usize], active: &[bool]) -> usize {
|
|
let col_count = active.iter().filter(|value| **value).count();
|
|
if col_count == 0 {
|
|
return 0;
|
|
}
|
|
let content_width = col_widths
|
|
.iter()
|
|
.zip(active.iter())
|
|
.filter_map(|(width, keep)| keep.then_some(*width))
|
|
.sum::<usize>();
|
|
content_width + 3 * col_count + 1
|
|
}
|
|
|
|
fn optional_column_targets(
|
|
terminal_width: usize,
|
|
optional_indices: &[usize],
|
|
col_widths: &[usize],
|
|
active: &[bool],
|
|
) -> Vec<(usize, usize)> {
|
|
if optional_indices.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
let mut is_optional = vec![false; col_widths.len()];
|
|
for &index in optional_indices {
|
|
if let Some(flag) = is_optional.get_mut(index) {
|
|
*flag = true;
|
|
}
|
|
}
|
|
|
|
let optional_indices = optional_indices
|
|
.iter()
|
|
.copied()
|
|
.filter(|idx| active.get(*idx).copied().unwrap_or(false))
|
|
.collect::<Vec<_>>();
|
|
if optional_indices.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
let col_count = active.iter().filter(|value| **value).count();
|
|
let overhead = 3 * col_count + 1;
|
|
let mut required_width = overhead;
|
|
for (idx, width) in col_widths.iter().enumerate() {
|
|
if active.get(idx).copied().unwrap_or(false) && !is_optional[idx] {
|
|
required_width += *width;
|
|
}
|
|
}
|
|
|
|
let remaining = terminal_width.saturating_sub(required_width);
|
|
let min_width = 6usize;
|
|
let per_column = if remaining == 0 {
|
|
min_width
|
|
} else {
|
|
(remaining / optional_indices.len()).clamp(min_width, 24)
|
|
};
|
|
|
|
optional_indices
|
|
.into_iter()
|
|
.map(|idx| (idx, col_widths[idx].min(per_column)))
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::main]
|
|
#[tracing::instrument]
|
|
async fn main() -> Result<(), Error> {
|
|
let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"));
|
|
rust_i18n::set_locale(&locale);
|
|
let cli = Cli::parse();
|
|
|
|
let client = runtime_rpc_client(
|
|
format!("tcp://{}:{}", cli.rpc_portal.ip(), cli.rpc_portal.port())
|
|
.parse()
|
|
.unwrap(),
|
|
);
|
|
let handler = CommandHandler {
|
|
client: Arc::new(tokio::sync::Mutex::new(client)),
|
|
verbose: cli.verbose,
|
|
output_format: &cli.output_format,
|
|
no_trunc: cli.no_trunc,
|
|
instance_select: &cli.instance_select,
|
|
instance_selector: (&cli.instance_select).into(),
|
|
resolved_target: None,
|
|
};
|
|
|
|
match cli.sub_command {
|
|
SubCommand::Peer(peer_args) => match &peer_args.sub_command {
|
|
Some(PeerSubCommand::List) => {
|
|
handler.handle_peer_list().await?;
|
|
}
|
|
Some(PeerSubCommand::Ipv6) => {
|
|
handler.handle_peer_ipv6().await?;
|
|
}
|
|
Some(PeerSubCommand::ListForeign { trusted_keys }) => {
|
|
handler.handle_foreign_network_list(*trusted_keys).await?;
|
|
}
|
|
Some(PeerSubCommand::ListGlobalForeign) => {
|
|
handler.handle_global_foreign_network_list().await?;
|
|
}
|
|
None => {
|
|
handler.handle_peer_list().await?;
|
|
}
|
|
},
|
|
SubCommand::Connector(conn_args) => match conn_args.sub_command {
|
|
Some(ConnectorSubCommand::Add { url }) => {
|
|
handler
|
|
.handle_connector_modify(&url, ConfigPatchAction::Add)
|
|
.await?;
|
|
println!("connector add applied to selected instance(s): {url}");
|
|
}
|
|
Some(ConnectorSubCommand::Remove { url }) => {
|
|
handler
|
|
.handle_connector_modify(&url, ConfigPatchAction::Remove)
|
|
.await?;
|
|
println!("connector remove applied to selected instance(s): {url}");
|
|
}
|
|
Some(ConnectorSubCommand::List) => {
|
|
handler.handle_connector_list().await?;
|
|
}
|
|
None => {
|
|
handler.handle_connector_list().await?;
|
|
}
|
|
},
|
|
SubCommand::MappedListener(mapped_listener_args) => {
|
|
match mapped_listener_args.sub_command {
|
|
Some(MappedListenerSubCommand::Add { url }) => {
|
|
handler
|
|
.handle_mapped_listener_modify(&url, ConfigPatchAction::Add)
|
|
.await?;
|
|
println!("add mapped listener: {url}");
|
|
}
|
|
Some(MappedListenerSubCommand::Remove { url }) => {
|
|
handler
|
|
.handle_mapped_listener_modify(&url, ConfigPatchAction::Remove)
|
|
.await?;
|
|
println!("remove mapped listener: {url}");
|
|
}
|
|
Some(MappedListenerSubCommand::List) | None => {
|
|
handler.handle_mapped_listener_list().await?;
|
|
}
|
|
}
|
|
}
|
|
SubCommand::Route(route_args) => match route_args.sub_command {
|
|
Some(RouteSubCommand::List) | None => handler.handle_route_list().await?,
|
|
Some(RouteSubCommand::Dump) => handler.handle_route_dump().await?,
|
|
},
|
|
SubCommand::Stun => {
|
|
timeout(Duration::from_secs(25), async move {
|
|
let collector = runtime_stun_info_collector(Default::default());
|
|
loop {
|
|
let ret = collector.get_stun_info();
|
|
if ret.udp_nat_type != NatType::Unknown as i32
|
|
&& ret.tcp_nat_type != NatType::Unknown as i32
|
|
{
|
|
if cli.output_format == OutputFormat::Json {
|
|
match serde_json::to_string_pretty(&ret) {
|
|
Ok(json) => println!("{}", json),
|
|
Err(e) => eprintln!("Error serializing to JSON: {}", e),
|
|
}
|
|
} else {
|
|
println!("stun info: {:#?}", ret);
|
|
}
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
SubCommand::PeerCenter => {
|
|
handler.handle_peer_center().await?;
|
|
}
|
|
SubCommand::VpnPortal(args) => match args.sub_command {
|
|
None => handler.handle_vpn_portal().await?,
|
|
Some(VpnPortalSubCommand::AddClient {
|
|
name,
|
|
virtual_ip,
|
|
groups,
|
|
}) => {
|
|
handler
|
|
.handle_vpn_portal_add_client(name, virtual_ip, groups)
|
|
.await?;
|
|
}
|
|
Some(VpnPortalSubCommand::RemoveClient { name }) => {
|
|
handler.handle_vpn_portal_remove_client(name).await?;
|
|
}
|
|
Some(VpnPortalSubCommand::ClearClients) => {
|
|
handler.handle_vpn_portal_clear_clients().await?;
|
|
}
|
|
},
|
|
SubCommand::Node(sub_cmd) => {
|
|
handler.handle_node(sub_cmd.sub_command.as_ref()).await?;
|
|
}
|
|
SubCommand::Service(service_args) => {
|
|
let service = Service::new(service_args.name)?;
|
|
match service_args.sub_command {
|
|
ServiceSubCommand::Install(install_args) => {
|
|
let bin_path = install_args.core_path.unwrap_or_else(|| {
|
|
let mut ret = std::env::current_exe()
|
|
.unwrap()
|
|
.parent()
|
|
.unwrap()
|
|
.join("easytier-core");
|
|
|
|
if cfg!(target_os = "windows") {
|
|
ret.set_extension("exe");
|
|
}
|
|
|
|
ret
|
|
});
|
|
let bin_path = std::fs::canonicalize(bin_path).map_err(|e| {
|
|
anyhow::anyhow!("failed to get easytier core application: {}", e)
|
|
})?;
|
|
let bin_args = install_args.core_args.unwrap_or_default();
|
|
let work_dir = install_args.service_work_dir.unwrap_or_else(|| {
|
|
if cfg!(target_os = "windows") {
|
|
bin_path.parent().unwrap().to_path_buf()
|
|
} else {
|
|
std::env::temp_dir()
|
|
}
|
|
});
|
|
|
|
let work_dir = std::fs::canonicalize(&work_dir).map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"failed to get service work directory[{}]: {}",
|
|
work_dir.display(),
|
|
e
|
|
)
|
|
})?;
|
|
|
|
if !work_dir.is_dir() {
|
|
return Err(anyhow::anyhow!("work directory is not a directory"));
|
|
}
|
|
|
|
let install_options = ServiceInstallOptions {
|
|
program: bin_path,
|
|
args: bin_args,
|
|
work_directory: work_dir,
|
|
disable_autostart: install_args.disable_autostart.unwrap_or(false),
|
|
description: Some(install_args.description),
|
|
display_name: install_args.display_name,
|
|
disable_restart_on_failure: install_args
|
|
.disable_restart_on_failure
|
|
.unwrap_or(false),
|
|
};
|
|
println!("install_options: {:#?}", install_options);
|
|
service.install(&install_options)?;
|
|
}
|
|
ServiceSubCommand::Uninstall => {
|
|
service.uninstall()?;
|
|
}
|
|
ServiceSubCommand::Status => {
|
|
let status = service.status()?;
|
|
match status {
|
|
ServiceStatus::Running => println!("Service is running"),
|
|
ServiceStatus::Stopped(_) => println!("Service is stopped"),
|
|
ServiceStatus::NotInstalled => println!("Service is not installed"),
|
|
}
|
|
}
|
|
ServiceSubCommand::Start => {
|
|
service.start()?;
|
|
}
|
|
ServiceSubCommand::Stop => {
|
|
service.stop()?;
|
|
}
|
|
}
|
|
}
|
|
SubCommand::Proxy => {
|
|
let mut entries = vec![];
|
|
|
|
for client_type in &["tcp", "kcp_src", "kcp_dst", "quic_src", "quic_dst"] {
|
|
let client = handler.get_tcp_proxy_client(client_type).await?;
|
|
let ret = client
|
|
.list_tcp_proxy_entry(BaseController::default(), Default::default())
|
|
.await;
|
|
entries.extend(ret.unwrap_or_default().entries);
|
|
}
|
|
|
|
if cli.verbose {
|
|
println!("{}", serde_json::to_string_pretty(&entries)?);
|
|
return Ok(());
|
|
}
|
|
|
|
#[derive(tabled::Tabled, serde::Serialize)]
|
|
struct TableItem {
|
|
src: String,
|
|
dst: String,
|
|
start_time: String,
|
|
state: String,
|
|
transport_type: String,
|
|
}
|
|
|
|
let table_rows = entries
|
|
.iter()
|
|
.map(|e| TableItem {
|
|
src: SocketAddr::from(e.src.unwrap_or_default()).to_string(),
|
|
dst: SocketAddr::from(e.dst.unwrap_or_default()).to_string(),
|
|
start_time: chrono::DateTime::<chrono::Utc>::from_timestamp_millis(
|
|
(e.start_time * 1000) as i64,
|
|
)
|
|
.unwrap()
|
|
.with_timezone(&chrono::Local)
|
|
.format("%Y-%m-%d %H:%M:%S")
|
|
.to_string(),
|
|
state: format!("{:?}", TcpProxyEntryState::try_from(e.state).unwrap()),
|
|
transport_type: format!(
|
|
"{:?}",
|
|
TcpProxyEntryTransportType::try_from(e.transport_type).unwrap()
|
|
),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
print_output(
|
|
&table_rows,
|
|
&cli.output_format,
|
|
&["start_time", "state", "transport_type"],
|
|
&["start_time", "state", "transport_type"],
|
|
cli.no_trunc,
|
|
)?;
|
|
}
|
|
SubCommand::Acl(acl_args) => match &acl_args.sub_command {
|
|
Some(AclSubCommand::Stats) | None => {
|
|
handler.handle_acl_stats().await?;
|
|
}
|
|
},
|
|
SubCommand::PortForward(port_forward_args) => match &port_forward_args.sub_command {
|
|
Some(PortForwardSubCommand::Add {
|
|
protocol,
|
|
bind_addr,
|
|
dst_addr,
|
|
}) => {
|
|
handler
|
|
.handle_port_forward_modify(
|
|
ConfigPatchAction::Add,
|
|
protocol,
|
|
bind_addr,
|
|
Some(dst_addr),
|
|
)
|
|
.await?;
|
|
}
|
|
Some(PortForwardSubCommand::Remove {
|
|
protocol,
|
|
bind_addr,
|
|
dst_addr,
|
|
}) => {
|
|
handler
|
|
.handle_port_forward_modify(
|
|
ConfigPatchAction::Remove,
|
|
protocol,
|
|
bind_addr,
|
|
dst_addr.as_deref(),
|
|
)
|
|
.await?;
|
|
}
|
|
Some(PortForwardSubCommand::List) | None => {
|
|
handler.handle_port_forward_list().await?;
|
|
}
|
|
},
|
|
SubCommand::Whitelist(whitelist_args) => match &whitelist_args.sub_command {
|
|
Some(WhitelistSubCommand::SetTcp { ports }) => {
|
|
handler.handle_whitelist_set_tcp(ports).await?;
|
|
}
|
|
Some(WhitelistSubCommand::SetUdp { ports }) => {
|
|
handler.handle_whitelist_set_udp(ports).await?;
|
|
}
|
|
Some(WhitelistSubCommand::ClearTcp) => {
|
|
handler.handle_whitelist_clear_tcp().await?;
|
|
}
|
|
Some(WhitelistSubCommand::ClearUdp) => {
|
|
handler.handle_whitelist_clear_udp().await?;
|
|
}
|
|
Some(WhitelistSubCommand::Show) | None => {
|
|
handler.handle_whitelist_show().await?;
|
|
}
|
|
},
|
|
SubCommand::Stats(stats_args) => match &stats_args.sub_command {
|
|
Some(StatsSubCommand::Show) | None => {
|
|
handler.handle_stats_show().await?;
|
|
}
|
|
Some(StatsSubCommand::Prometheus) => {
|
|
handler.handle_stats_prometheus().await?;
|
|
}
|
|
},
|
|
SubCommand::Logger(logger_args) => match &logger_args.sub_command {
|
|
Some(LoggerSubCommand::Get) | None => {
|
|
handler.handle_logger_get().await?;
|
|
}
|
|
Some(LoggerSubCommand::Set { level }) => {
|
|
handler.handle_logger_set(level).await?;
|
|
}
|
|
},
|
|
SubCommand::Credential(credential_args) => match &credential_args.sub_command {
|
|
CredentialSubCommand::Generate {
|
|
ttl,
|
|
credential_id,
|
|
groups,
|
|
allow_relay,
|
|
allowed_proxy_cidrs,
|
|
reusable,
|
|
} => {
|
|
handler
|
|
.handle_credential_generate(
|
|
*ttl,
|
|
credential_id.clone(),
|
|
groups.clone().unwrap_or_default(),
|
|
*allow_relay,
|
|
allowed_proxy_cidrs.clone().unwrap_or_default(),
|
|
*reusable,
|
|
)
|
|
.await?;
|
|
}
|
|
CredentialSubCommand::Revoke { credential_id } => {
|
|
handler.handle_credential_revoke(credential_id).await?;
|
|
}
|
|
CredentialSubCommand::List => {
|
|
handler.handle_credential_list().await?;
|
|
}
|
|
},
|
|
SubCommand::GenAutocomplete { shell } => {
|
|
let mut cmd = Cli::command();
|
|
if let Some(shell) = shell.to_shell() {
|
|
easytier::print_completions(shell, &mut cmd, "easytier-cli");
|
|
} else {
|
|
// Handle Nushell
|
|
easytier::print_nushell_completions(&mut cmd, "easytier-cli");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|