3058 lines
119 KiB
Rust
3058 lines
119 KiB
Rust
//! NX9 WireGuard - Native Rust WireGuard Management Application CLI and Daemon
|
|
|
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
|
use nx9_wg_api::auth::{AuthService, BootstrapOptions, bootstrap_admin};
|
|
use nx9_wg_api::backup::BackupService;
|
|
use nx9_wg_api::error::ApiError;
|
|
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
|
use nx9_wg_api::routes::build_api_router;
|
|
use nx9_wg_api::state::AppState;
|
|
use nx9_wg_api::{DiagnosticsService, IpAllocator};
|
|
use nx9_wg_core::config::AppConfig;
|
|
use nx9_wg_core::crypto::generate_secure_password;
|
|
use nx9_wg_core::types::audit::AuditEventType;
|
|
use nx9_wg_core::types::diagnostics::DiagnosticSubsystem;
|
|
use nx9_wg_core::types::firewall::{
|
|
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
|
};
|
|
use nx9_wg_core::types::network::{Network, Route};
|
|
use nx9_wg_core::types::settings::Setting;
|
|
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
|
use nx9_wg_core::validation::{
|
|
validate_cidr, validate_interface_name, validate_listen_port, validate_peer_name,
|
|
validate_port_spec,
|
|
};
|
|
use nx9_wg_db::Store;
|
|
#[allow(unused_imports)]
|
|
use nx9_wg_network::{
|
|
IpForwardingStatus, NativeLinuxNetworkEngine, NetworkEngine, SimulatedNetworkEngine,
|
|
};
|
|
#[allow(unused_imports)]
|
|
use nx9_wireguard::{
|
|
ClientConfigBuilder, NativeLinuxWireGuardEngine, SimulatedWireGuardEngine, WireGuardEngine,
|
|
generate_qr_ascii, generate_qr_png_bytes, generate_qr_svg,
|
|
};
|
|
use serde::Serialize;
|
|
use std::io::{self, Read};
|
|
use std::net::{IpAddr, SocketAddr};
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
use std::path::PathBuf;
|
|
use std::str::FromStr;
|
|
use std::sync::Arc;
|
|
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "nx9-wg",
|
|
author = "NX9 Systems",
|
|
version,
|
|
about = "Native Rust WireGuard Appliance and Management Platform",
|
|
long_about = "A high-performance, native Rust WireGuard management system with minimal, explicitly documented Linux runtime dependencies."
|
|
)]
|
|
struct Cli {
|
|
#[arg(
|
|
short,
|
|
long,
|
|
global = true,
|
|
env = "NX9_WG_CONFIG",
|
|
help = "Path to configuration file"
|
|
)]
|
|
config: Option<PathBuf>,
|
|
|
|
#[arg(
|
|
short,
|
|
long,
|
|
global = true,
|
|
env = "NX9_WG_DATA_DIR",
|
|
help = "Path to database data directory"
|
|
)]
|
|
data_dir: Option<PathBuf>,
|
|
|
|
#[arg(
|
|
long,
|
|
global = true,
|
|
env = "NX9_WG_DATABASE",
|
|
help = "Specific SQLite database file path or URL"
|
|
)]
|
|
database: Option<PathBuf>,
|
|
|
|
#[arg(
|
|
long,
|
|
global = true,
|
|
value_enum,
|
|
default_value_t = OutputFormat::Table,
|
|
help = "Output format"
|
|
)]
|
|
format: OutputFormat,
|
|
|
|
#[arg(long, global = true, help = "Output strictly in JSON format")]
|
|
json: bool,
|
|
|
|
#[arg(short, long, global = true, help = "Suppress status output")]
|
|
quiet: bool,
|
|
|
|
#[arg(short, long, global = true, help = "Enable verbose output")]
|
|
verbose: bool,
|
|
|
|
#[arg(
|
|
long,
|
|
global = true,
|
|
env = "NX9_WG_LOG_LEVEL",
|
|
help = "Log verbosity level (trace, debug, info, warn, error)"
|
|
)]
|
|
log_level: Option<String>,
|
|
|
|
#[command(subcommand)]
|
|
command: Option<Commands>,
|
|
}
|
|
|
|
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Default)]
|
|
enum OutputFormat {
|
|
#[default]
|
|
Table,
|
|
Json,
|
|
Yaml,
|
|
Csv,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
#[command(about = "Start the nx9-wg REST API and WebSocket daemon")]
|
|
Serve(ServeArgs),
|
|
|
|
#[command(about = "Initialize the administrator account")]
|
|
Init(InitArgs),
|
|
|
|
#[command(about = "System status, health, and settings")]
|
|
System(SystemArgs),
|
|
|
|
#[command(about = "Administrator, session, and token management")]
|
|
Admin(AdminArgs),
|
|
|
|
#[command(about = "WireGuard interface management")]
|
|
Interface(InterfaceArgs),
|
|
|
|
#[command(about = "WireGuard peer enrollment and operations")]
|
|
Peer(PeerArgs),
|
|
|
|
#[command(about = "Subnet network management")]
|
|
Network(NetworkArgs),
|
|
|
|
#[command(about = "Kernel routing table management")]
|
|
Route(RouteArgs),
|
|
|
|
#[command(about = "nftables firewall management")]
|
|
Firewall(FirewallArgs),
|
|
|
|
#[command(about = "NAT masquerade management")]
|
|
Nat(NatArgs),
|
|
|
|
#[command(about = "Kernel IP packet forwarding management")]
|
|
Forwarding(ForwardingArgs),
|
|
|
|
#[command(about = "Reconciliation between SQLite desired state and live kernel state")]
|
|
Reconcile(ReconcileArgs),
|
|
|
|
#[command(about = "Database backup and restore operations")]
|
|
Backup(BackupArgs),
|
|
|
|
#[command(about = "Security and operational audit trail")]
|
|
Audit(AuditArgs),
|
|
|
|
#[command(about = "Query live Linux kernel state")]
|
|
Live(LiveArgs),
|
|
|
|
#[command(about = "Native Linux and WireGuard diagnostics inspection")]
|
|
Diagnostics(DiagnosticsArgs),
|
|
|
|
#[command(about = "Client environment and MTU profile management")]
|
|
Profile(ProfileArgs),
|
|
|
|
#[command(about = "Display version and build information")]
|
|
Version,
|
|
}
|
|
|
|
// ── Command Arguments Definitions ───────────────────────────────────────────
|
|
|
|
#[derive(Args)]
|
|
struct DiagnosticsArgs {
|
|
#[arg(
|
|
default_value = "all",
|
|
help = "Subsystem to inspect (system, network, wan, wireguard, peer, routing, forwarding, firewall, nat, mtu, reconciliation, all)"
|
|
)]
|
|
subsystem: String,
|
|
|
|
#[arg(long, help = "Optional peer UUID for single peer diagnostics")]
|
|
peer: Option<String>,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct ServeArgs {
|
|
#[arg(
|
|
short,
|
|
long,
|
|
env = "NX9_WG_LISTEN_ADDR",
|
|
help = "Bind address for HTTP/WebSocket server"
|
|
)]
|
|
bind: Option<SocketAddr>,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct InitArgs {
|
|
#[arg(
|
|
short,
|
|
long,
|
|
env = "NX9_WG_ADMIN_USERNAME",
|
|
help = "Administrator username [default: admin]"
|
|
)]
|
|
username: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "NX9_WG_ADMIN_PASSWORD",
|
|
help = "Administrator password via argument"
|
|
)]
|
|
password: Option<String>,
|
|
|
|
#[arg(long, help = "Read administrator password from standard input")]
|
|
password_stdin: bool,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "NX9_WG_ADMIN_PASSWORD_FILE",
|
|
help = "Read administrator password from file"
|
|
)]
|
|
password_file: Option<PathBuf>,
|
|
|
|
#[arg(long, help = "Generate a cryptographically secure random password")]
|
|
generate_password: bool,
|
|
|
|
#[arg(long, help = "Write generated password to specified file")]
|
|
write_password_file: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct SystemArgs {
|
|
#[command(subcommand)]
|
|
subcommand: SystemSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum SystemSubcommands {
|
|
#[command(about = "Display overall system status and counts")]
|
|
Status,
|
|
#[command(about = "Perform system and database health check")]
|
|
Health,
|
|
#[command(about = "Display system platform and runtime environment info")]
|
|
Info,
|
|
#[command(about = "Manage system settings key-value store")]
|
|
Settings(SettingsArgs),
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct SettingsArgs {
|
|
#[command(subcommand)]
|
|
subcommand: SettingsSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum SettingsSubcommands {
|
|
#[command(about = "List all configuration settings")]
|
|
List,
|
|
#[command(about = "Get value of a setting")]
|
|
Get { key: String },
|
|
#[command(about = "Set or update a configuration setting")]
|
|
Set {
|
|
key: String,
|
|
value: String,
|
|
#[arg(long, help = "Flag setting as secret")]
|
|
secret: bool,
|
|
},
|
|
#[command(about = "Delete a configuration setting")]
|
|
Delete { key: String },
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct AdminArgs {
|
|
#[command(subcommand)]
|
|
subcommand: AdminSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum AdminSubcommands {
|
|
#[command(about = "Display administrator metadata and status")]
|
|
Status,
|
|
#[command(about = "Create/bootstrap administrator if not initialized")]
|
|
Create(InitArgs),
|
|
#[command(about = "Reset or update administrator password")]
|
|
Password(AdminPasswordArgs),
|
|
#[command(about = "Manage active sessions")]
|
|
Sessions(SessionArgs),
|
|
#[command(about = "Manage API tokens")]
|
|
Tokens(AdminTokenArgs),
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct AdminPasswordArgs {
|
|
#[arg(long, help = "New administrator password via argument")]
|
|
new_password: Option<String>,
|
|
|
|
#[arg(long, help = "Read new password from standard input")]
|
|
stdin: bool,
|
|
|
|
#[arg(long, help = "Read new password from file")]
|
|
password_file: Option<PathBuf>,
|
|
|
|
#[arg(long, help = "Generate a secure random password")]
|
|
generate: bool,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct SessionArgs {
|
|
#[command(subcommand)]
|
|
subcommand: SessionSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum SessionSubcommands {
|
|
#[command(about = "List active sessions")]
|
|
List,
|
|
#[command(about = "Revoke an active session")]
|
|
Revoke { id: String },
|
|
#[command(about = "Invalidate all active sessions")]
|
|
RevokeAll,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct AdminTokenArgs {
|
|
#[command(subcommand)]
|
|
subcommand: TokenSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum TokenSubcommands {
|
|
#[command(about = "Create a new API token")]
|
|
Create {
|
|
#[arg(short, long, help = "Descriptive name for the API token")]
|
|
name: String,
|
|
#[arg(long, help = "Validity in days (omit for never expiring)")]
|
|
days: Option<i64>,
|
|
#[arg(
|
|
long,
|
|
help = "Write the plaintext token to a file (restricted mode 0600)"
|
|
)]
|
|
write_token_file: Option<PathBuf>,
|
|
},
|
|
#[command(about = "List all API tokens")]
|
|
List,
|
|
#[command(about = "Revoke an API token")]
|
|
Revoke {
|
|
#[arg(help = "API Token ID to revoke")]
|
|
id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct InterfaceArgs {
|
|
#[command(subcommand)]
|
|
subcommand: InterfaceSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum InterfaceSubcommands {
|
|
#[command(about = "List all WireGuard interfaces")]
|
|
List,
|
|
#[command(about = "Show interface details")]
|
|
Show { interface: String },
|
|
#[command(about = "Create a new WireGuard interface")]
|
|
Create {
|
|
name: String,
|
|
#[arg(short, long, default_value_t = 51820, help = "UDP listen port")]
|
|
port: u16,
|
|
#[arg(
|
|
short = '4',
|
|
long,
|
|
help = "IPv4 network CIDR address (e.g. 10.0.0.1/24)"
|
|
)]
|
|
address_v4: String,
|
|
#[arg(short = '6', long, help = "IPv6 network CIDR address (optional)")]
|
|
address_v6: Option<String>,
|
|
#[arg(short, long, help = "Interface MTU [default: 1420]")]
|
|
mtu: Option<u16>,
|
|
#[arg(long, help = "DNS server addresses")]
|
|
dns: Option<String>,
|
|
},
|
|
#[command(about = "Update an existing WireGuard interface")]
|
|
Update {
|
|
interface: String,
|
|
#[arg(short, long, help = "UDP listen port")]
|
|
port: Option<u16>,
|
|
#[arg(short = '4', long, help = "IPv4 network CIDR address")]
|
|
address_v4: Option<String>,
|
|
#[arg(short = '6', long, help = "IPv6 network CIDR address")]
|
|
address_v6: Option<String>,
|
|
#[arg(short, long, help = "Interface MTU")]
|
|
mtu: Option<u16>,
|
|
#[arg(long, help = "DNS server addresses")]
|
|
dns: Option<String>,
|
|
#[arg(long, help = "Enable or disable interface")]
|
|
enabled: Option<bool>,
|
|
},
|
|
#[command(about = "Delete a WireGuard interface")]
|
|
Delete { interface: String },
|
|
#[command(about = "Enable a WireGuard interface")]
|
|
Enable { interface: String },
|
|
#[command(about = "Disable a WireGuard interface")]
|
|
Disable { interface: String },
|
|
#[command(about = "Show live interface status and peer metrics")]
|
|
Status { interface: String },
|
|
#[command(about = "Reconcile a specific interface with kernel")]
|
|
Reconcile { interface: String },
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct PeerArgs {
|
|
#[command(subcommand)]
|
|
subcommand: PeerSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum PeerSubcommands {
|
|
#[command(about = "List enrolled WireGuard peers")]
|
|
List {
|
|
#[arg(short, long, help = "Filter peers by interface name or ID")]
|
|
interface: Option<String>,
|
|
},
|
|
#[command(about = "Show peer details")]
|
|
Show { id: String },
|
|
#[command(about = "Create and enroll a new peer")]
|
|
Create {
|
|
#[arg(short, long, help = "Interface name or ID")]
|
|
interface: String,
|
|
#[arg(short, long, help = "Peer display name")]
|
|
name: String,
|
|
#[arg(long, default_value = "road_warrior", help = "Peer type")]
|
|
peer_type: String,
|
|
#[arg(long, default_value = "full_tunnel", help = "Tunnel profile")]
|
|
profile: String,
|
|
#[arg(long, help = "Subnet network name or ID for automatic IP allocation")]
|
|
network: Option<String>,
|
|
#[arg(short = '4', long, help = "IPv4 peer address CIDR")]
|
|
address_v4: Option<String>,
|
|
#[arg(long, default_value = "0.0.0.0/0, ::/0", help = "Allowed IPs")]
|
|
allowed_ips: String,
|
|
#[arg(long, help = "Optional fixed remote endpoint (IP:PORT)")]
|
|
endpoint: Option<String>,
|
|
#[arg(long, help = "Persistent keepalive interval in seconds")]
|
|
persistent_keepalive: Option<u16>,
|
|
#[arg(long, help = "DNS servers")]
|
|
dns: Option<String>,
|
|
#[arg(long, help = "Client MTU")]
|
|
mtu: Option<u16>,
|
|
#[arg(
|
|
long,
|
|
help = "Peer expiration timestamp (RFC3339 or 'YYYY-MM-DD HH:MM:SS')"
|
|
)]
|
|
expires_at: Option<String>,
|
|
},
|
|
#[command(about = "Update an enrolled peer")]
|
|
Update {
|
|
id: String,
|
|
#[arg(short, long, help = "Peer display name")]
|
|
name: Option<String>,
|
|
#[arg(long, help = "Allowed IPs")]
|
|
allowed_ips: Option<String>,
|
|
#[arg(long, help = "Endpoint")]
|
|
endpoint: Option<String>,
|
|
#[arg(long, help = "Persistent keepalive interval")]
|
|
persistent_keepalive: Option<u16>,
|
|
#[arg(long, help = "DNS servers")]
|
|
dns: Option<String>,
|
|
#[arg(long, help = "MTU")]
|
|
mtu: Option<u16>,
|
|
#[arg(long, help = "Peer expiration timestamp")]
|
|
expires_at: Option<String>,
|
|
#[arg(long, help = "Enable or disable peer")]
|
|
enabled: Option<bool>,
|
|
},
|
|
#[command(about = "Delete an enrolled peer")]
|
|
Delete { id: String },
|
|
#[command(about = "Enable an enrolled peer")]
|
|
Enable { id: String },
|
|
#[command(about = "Disable an enrolled peer")]
|
|
Disable { id: String },
|
|
#[command(about = "Revoke an enrolled peer")]
|
|
Revoke { id: String },
|
|
#[command(about = "Mark a peer as immediately expired")]
|
|
Expire { id: String },
|
|
#[command(about = "Show peer lifecycle and expiration metadata")]
|
|
Lifecycle { id: String },
|
|
#[command(about = "Show live peer status and telemetry")]
|
|
Status { id: String },
|
|
#[command(about = "Generate standard client .conf configuration")]
|
|
Config {
|
|
id: String,
|
|
#[arg(long, help = "Network provider (e.g. tmobile, verizon, jio, starlink)")]
|
|
provider: Option<String>,
|
|
#[arg(
|
|
long,
|
|
help = "Device category (android, ios, linux, windows, macos, other)"
|
|
)]
|
|
device: Option<String>,
|
|
#[arg(
|
|
long,
|
|
help = "Connection environment (web, mobile, wifi, wired, other)"
|
|
)]
|
|
connection: Option<String>,
|
|
#[arg(long, help = "NAT condition (direct, cgnat, unknown)")]
|
|
nat: Option<String>,
|
|
#[arg(long, help = "Explicit manual MTU override")]
|
|
mtu: Option<u16>,
|
|
#[arg(long, help = "Explicit client profile ID")]
|
|
profile: Option<String>,
|
|
#[arg(long, help = "WireGuard server endpoint host or IP")]
|
|
endpoint: Option<String>,
|
|
#[arg(short, long, help = "Write configuration to file")]
|
|
output: Option<PathBuf>,
|
|
},
|
|
#[command(about = "Generate enrollment QR code")]
|
|
Qr {
|
|
id: String,
|
|
#[arg(long, help = "Network provider (e.g. tmobile, verizon, jio, starlink)")]
|
|
provider: Option<String>,
|
|
#[arg(
|
|
long,
|
|
help = "Device category (android, ios, linux, windows, macos, other)"
|
|
)]
|
|
device: Option<String>,
|
|
#[arg(
|
|
long,
|
|
help = "Connection environment (web, mobile, wifi, wired, other)"
|
|
)]
|
|
connection: Option<String>,
|
|
#[arg(long, help = "NAT condition (direct, cgnat, unknown)")]
|
|
nat: Option<String>,
|
|
#[arg(long, help = "Explicit manual MTU override")]
|
|
mtu: Option<u16>,
|
|
#[arg(long, help = "Explicit client profile ID")]
|
|
profile: Option<String>,
|
|
#[arg(long, help = "WireGuard server endpoint host or IP")]
|
|
endpoint: Option<String>,
|
|
#[arg(
|
|
long = "qr-format",
|
|
default_value = "terminal",
|
|
help = "QR code output format (terminal, svg, png)"
|
|
)]
|
|
qr_format: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct ProfileArgs {
|
|
#[command(subcommand)]
|
|
subcommand: ProfileSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ProfileSubcommands {
|
|
#[command(about = "List client configuration profiles")]
|
|
List {
|
|
#[arg(long, help = "Filter by network provider")]
|
|
provider: Option<String>,
|
|
#[arg(
|
|
long,
|
|
help = "Filter by device category (android, ios, linux, windows, macos, other)"
|
|
)]
|
|
device: Option<String>,
|
|
#[arg(
|
|
long,
|
|
help = "Filter by connection type (web, mobile, wifi, wired, other)"
|
|
)]
|
|
connection: Option<String>,
|
|
#[arg(long, help = "Filter by NAT type (direct, cgnat, unknown)")]
|
|
nat: Option<String>,
|
|
},
|
|
#[command(about = "Show details of a specific client profile")]
|
|
Show {
|
|
#[arg(help = "Profile identifier (e.g. default-mobile, default-cgnat, android-mobile)")]
|
|
id: String,
|
|
},
|
|
#[command(about = "Validate a client MTU against safe operational limits")]
|
|
Validate {
|
|
#[arg(help = "MTU value in bytes (e.g. 1280, 1360, 1420)")]
|
|
mtu: u16,
|
|
},
|
|
#[command(
|
|
about = "Resolve the optimal client profile and MTU given client environment parameters"
|
|
)]
|
|
Resolve {
|
|
#[arg(long, help = "Network provider (e.g. tmobile, verizon, jio, starlink)")]
|
|
provider: Option<String>,
|
|
#[arg(
|
|
long,
|
|
help = "Device category (android, ios, linux, windows, macos, other)"
|
|
)]
|
|
device: Option<String>,
|
|
#[arg(
|
|
long,
|
|
help = "Connection environment (web, mobile, wifi, wired, other)"
|
|
)]
|
|
connection: Option<String>,
|
|
#[arg(long, help = "NAT condition (direct, cgnat, unknown)")]
|
|
nat: Option<String>,
|
|
#[arg(long, help = "Explicit manual MTU override")]
|
|
mtu: Option<u16>,
|
|
#[arg(long, help = "Explicit profile ID override")]
|
|
profile: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct NetworkArgs {
|
|
#[command(subcommand)]
|
|
subcommand: NetworkSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum NetworkSubcommands {
|
|
#[command(about = "List defined subnet networks")]
|
|
List,
|
|
#[command(about = "Show network details")]
|
|
Show { id: String },
|
|
#[command(about = "Create a new subnet network")]
|
|
Create {
|
|
name: String,
|
|
cidr: String,
|
|
#[arg(long, help = "Optional network description")]
|
|
description: Option<String>,
|
|
},
|
|
#[command(about = "Show available unallocated IP addresses in a network")]
|
|
Available {
|
|
id: String,
|
|
#[arg(short, long, default_value_t = 10, help = "Number of IPs to display")]
|
|
limit: usize,
|
|
#[arg(long, help = "Optional interface name or ID for exclusion")]
|
|
interface: Option<String>,
|
|
},
|
|
#[command(about = "Show allocated IP addresses and peer mappings in a network")]
|
|
Allocations { id: String },
|
|
#[command(about = "Update an existing network")]
|
|
Update {
|
|
id: String,
|
|
#[arg(short, long, help = "Network name")]
|
|
name: Option<String>,
|
|
#[arg(long, help = "Network CIDR")]
|
|
cidr: Option<String>,
|
|
#[arg(long, help = "Description")]
|
|
description: Option<String>,
|
|
#[arg(long, help = "Enable or disable network")]
|
|
enabled: Option<bool>,
|
|
},
|
|
#[command(about = "Delete a subnet network")]
|
|
Delete { id: String },
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct RouteArgs {
|
|
#[command(subcommand)]
|
|
subcommand: RouteSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum RouteSubcommands {
|
|
#[command(about = "List configured routing rules")]
|
|
List,
|
|
#[command(about = "Show route details")]
|
|
Show { id: String },
|
|
#[command(about = "Add a kernel routing rule")]
|
|
Add {
|
|
#[arg(long, help = "Destination subnet CIDR (e.g. 10.50.0.0/24)")]
|
|
destination: String,
|
|
#[arg(short, long, help = "Gateway IP address")]
|
|
gateway: Option<String>,
|
|
#[arg(short, long, help = "Egress interface name")]
|
|
interface_name: Option<String>,
|
|
#[arg(short, long, help = "Route priority metric")]
|
|
metric: Option<u32>,
|
|
},
|
|
#[command(about = "Update an existing route")]
|
|
Update {
|
|
id: String,
|
|
#[arg(long, help = "Destination CIDR")]
|
|
destination: Option<String>,
|
|
#[arg(short, long, help = "Gateway IP")]
|
|
gateway: Option<String>,
|
|
#[arg(short, long, help = "Interface name")]
|
|
interface_name: Option<String>,
|
|
#[arg(short, long, help = "Metric")]
|
|
metric: Option<u32>,
|
|
#[arg(long, help = "Enable or disable route")]
|
|
enabled: Option<bool>,
|
|
},
|
|
#[command(about = "Delete a routing rule")]
|
|
Delete { id: String },
|
|
#[command(about = "Show current kernel route status")]
|
|
Status,
|
|
#[command(about = "Synchronize routes with kernel routing table")]
|
|
Sync,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct FirewallArgs {
|
|
#[command(subcommand)]
|
|
subcommand: FirewallSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum FirewallSubcommands {
|
|
#[command(about = "List configured firewall rules")]
|
|
List {
|
|
#[arg(long, help = "Filter rules for specific peer name or UUID")]
|
|
peer: Option<String>,
|
|
},
|
|
#[command(about = "Show firewall rule details")]
|
|
Show { id: String },
|
|
#[command(about = "Add a packet filter firewall rule")]
|
|
Add {
|
|
#[arg(short, long, help = "Rule descriptive name")]
|
|
name: String,
|
|
#[arg(long, default_value = "in", help = "Direction (in, out, forward)")]
|
|
direction: String,
|
|
#[arg(short, long, help = "Source CIDR")]
|
|
source: Option<String>,
|
|
#[arg(long, help = "Destination CIDR")]
|
|
destination: Option<String>,
|
|
#[arg(long, help = "Associate with specific peer name or UUID")]
|
|
peer: Option<String>,
|
|
#[arg(
|
|
long,
|
|
default_value = "any",
|
|
help = "Protocol (tcp, udp, tcp_udp, icmp, any)"
|
|
)]
|
|
protocol: String,
|
|
#[arg(short, long, help = "Target port")]
|
|
port: Option<u16>,
|
|
#[arg(
|
|
long,
|
|
help = "Port specification (single '443', range '8000-8100', or list '53,80,443')"
|
|
)]
|
|
port_range: Option<String>,
|
|
#[arg(
|
|
short,
|
|
long,
|
|
default_value = "accept",
|
|
help = "Action (accept, drop, reject)"
|
|
)]
|
|
action: String,
|
|
#[arg(long, default_value_t = 100, help = "Priority order")]
|
|
priority: i32,
|
|
},
|
|
#[command(about = "Update an existing firewall rule")]
|
|
Update {
|
|
id: String,
|
|
#[arg(short, long, help = "Rule name")]
|
|
name: Option<String>,
|
|
#[arg(short, long, help = "Action")]
|
|
action: Option<String>,
|
|
#[arg(long, help = "Priority")]
|
|
priority: Option<i32>,
|
|
#[arg(long, help = "Enable or disable rule")]
|
|
enabled: Option<bool>,
|
|
},
|
|
#[command(about = "Delete a firewall rule")]
|
|
Delete { id: String },
|
|
#[command(about = "Enable a firewall rule")]
|
|
Enable { id: String },
|
|
#[command(about = "Disable a firewall rule")]
|
|
Disable { id: String },
|
|
#[command(about = "Synchronize nftables ruleset")]
|
|
Sync,
|
|
#[command(about = "Show active nftables table and ruleset status")]
|
|
Status,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct NatArgs {
|
|
#[command(subcommand)]
|
|
subcommand: NatSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum NatSubcommands {
|
|
#[command(about = "Inspect NAT masquerade status")]
|
|
Status,
|
|
#[command(about = "Enable NAT masquerade")]
|
|
Enable,
|
|
#[command(about = "Disable NAT masquerade")]
|
|
Disable,
|
|
#[command(about = "List subnets configured for NAT masquerade")]
|
|
List,
|
|
#[command(about = "Synchronize NAT rules with kernel")]
|
|
Sync,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct ForwardingArgs {
|
|
#[command(subcommand)]
|
|
subcommand: ForwardingSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ForwardingSubcommands {
|
|
#[command(about = "Inspect Linux kernel IP forwarding status")]
|
|
Status,
|
|
#[command(about = "Enable Linux kernel IP forwarding")]
|
|
Enable,
|
|
#[command(about = "Disable Linux kernel IP forwarding")]
|
|
Disable,
|
|
#[command(about = "Synchronize IP forwarding setting with kernel")]
|
|
Sync,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct ReconcileArgs {
|
|
#[command(subcommand)]
|
|
subcommand: ReconcileSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ReconcileSubcommands {
|
|
#[command(about = "Inspect reconciliation status and statistics")]
|
|
Status,
|
|
#[command(about = "Generate reconciliation dry-run plan")]
|
|
Plan {
|
|
#[arg(short, long, help = "Target specific interface")]
|
|
interface: Option<String>,
|
|
},
|
|
#[command(about = "Apply reconciliation plan to live kernel state")]
|
|
Apply {
|
|
#[arg(short, long, help = "Target specific interface")]
|
|
interface: Option<String>,
|
|
},
|
|
#[command(about = "Verify zero drift between SQLite and kernel")]
|
|
Verify,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct BackupArgs {
|
|
#[command(subcommand)]
|
|
subcommand: BackupSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum BackupSubcommands {
|
|
#[command(about = "Create a consistent SQLite database backup snapshot")]
|
|
Create {
|
|
#[arg(long, help = "Optional backup note or description")]
|
|
description: Option<String>,
|
|
},
|
|
#[command(about = "List available database backup snapshots")]
|
|
List,
|
|
#[command(about = "Show backup details and manifest")]
|
|
Show { id: String },
|
|
#[command(about = "Verify integrity and checksum of a backup file")]
|
|
Verify { path: PathBuf },
|
|
#[command(about = "Restore database from backup file")]
|
|
Restore {
|
|
path: PathBuf,
|
|
#[arg(short = 'y', long, help = "Confirm destructive restore")]
|
|
yes: bool,
|
|
},
|
|
#[command(about = "Delete a backup record and archive")]
|
|
Delete { id: String },
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct AuditArgs {
|
|
#[command(subcommand)]
|
|
subcommand: AuditSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum AuditSubcommands {
|
|
#[command(about = "Query audit log records")]
|
|
List {
|
|
#[arg(long, help = "Filter by audit event type")]
|
|
event_type: Option<String>,
|
|
#[arg(long, help = "Filter by actor")]
|
|
actor: Option<String>,
|
|
#[arg(long, help = "Filter by resource type")]
|
|
resource_type: Option<String>,
|
|
#[arg(long, default_value_t = 50, help = "Maximum records to return")]
|
|
limit: u32,
|
|
#[arg(long, default_value_t = 0, help = "Offset for pagination")]
|
|
offset: u32,
|
|
},
|
|
#[command(about = "Show full details for an audit event")]
|
|
Show { id: i64 },
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct LiveArgs {
|
|
#[command(subcommand)]
|
|
subcommand: LiveSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum LiveSubcommands {
|
|
#[command(about = "Query live WireGuard interfaces from kernel")]
|
|
Interface(LiveInterfaceArgs),
|
|
#[command(about = "Query live connected peers from kernel")]
|
|
Peer(LivePeerArgs),
|
|
#[command(about = "Query live Linux kernel routing table")]
|
|
Routes,
|
|
#[command(about = "Query live active nftables ruleset")]
|
|
Firewall,
|
|
#[command(about = "Query live IP packet forwarding status")]
|
|
Forwarding,
|
|
#[command(about = "Query live NAT masquerade status")]
|
|
Nat,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct LiveInterfaceArgs {
|
|
#[command(subcommand)]
|
|
subcommand: LiveInterfaceSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum LiveInterfaceSubcommands {
|
|
#[command(about = "List live WireGuard interface names")]
|
|
List,
|
|
#[command(about = "Show live interface statistics and status")]
|
|
Show { name: String },
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct LivePeerArgs {
|
|
#[command(subcommand)]
|
|
subcommand: LivePeerSubcommands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum LivePeerSubcommands {
|
|
#[command(about = "List live peers on an interface")]
|
|
List { interface: String },
|
|
#[command(about = "Show live telemetry for a peer")]
|
|
Show { id: String },
|
|
}
|
|
|
|
fn create_wireguard_engine() -> Arc<dyn WireGuardEngine> {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
Arc::new(NativeLinuxWireGuardEngine::new())
|
|
}
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
Arc::new(SimulatedWireGuardEngine::new())
|
|
}
|
|
}
|
|
|
|
fn create_network_engine() -> Arc<dyn NetworkEngine> {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
Arc::new(NativeLinuxNetworkEngine::new())
|
|
}
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
Arc::new(SimulatedNetworkEngine::new())
|
|
}
|
|
}
|
|
|
|
// ── Output Formatter ────────────────────────────────────────────────────────
|
|
|
|
fn print_output<T: Serialize>(
|
|
data: &T,
|
|
format: OutputFormat,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let json_val = serde_json::to_value(data)?;
|
|
match format {
|
|
OutputFormat::Json => {
|
|
println!("{}", serde_json::to_string_pretty(&json_val)?);
|
|
}
|
|
OutputFormat::Yaml => {
|
|
print_json_as_yaml(&json_val, 0);
|
|
}
|
|
OutputFormat::Csv => {
|
|
print_json_as_csv(&json_val);
|
|
}
|
|
OutputFormat::Table => {
|
|
print_json_as_table(&json_val);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn print_json_as_yaml(val: &serde_json::Value, indent: usize) {
|
|
let pad = " ".repeat(indent);
|
|
match val {
|
|
serde_json::Value::Object(map) => {
|
|
for (k, v) in map {
|
|
match v {
|
|
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
|
|
println!("{pad}{k}:");
|
|
print_json_as_yaml(v, indent + 1);
|
|
}
|
|
_ => {
|
|
println!("{pad}{k}: {v}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
serde_json::Value::Array(arr) => {
|
|
for item in arr {
|
|
println!("{pad}-");
|
|
print_json_as_yaml(item, indent + 1);
|
|
}
|
|
}
|
|
_ => {
|
|
println!("{pad}{val}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_json_as_csv(val: &serde_json::Value) {
|
|
match val {
|
|
serde_json::Value::Array(arr) => {
|
|
if arr.is_empty() {
|
|
return;
|
|
}
|
|
if let Some(first) = arr.first().and_then(|v| v.as_object()) {
|
|
let headers: Vec<&str> = first.keys().map(|k| k.as_str()).collect();
|
|
println!("{}", headers.join(","));
|
|
for row in arr {
|
|
if let Some(obj) = row.as_object() {
|
|
let values: Vec<String> = headers
|
|
.iter()
|
|
.map(|h| {
|
|
obj.get(*h)
|
|
.map(|v| match v {
|
|
serde_json::Value::String(s) => s.clone(),
|
|
_ => v.to_string(),
|
|
})
|
|
.unwrap_or_default()
|
|
})
|
|
.collect();
|
|
println!("{}", values.join(","));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
serde_json::Value::Object(map) => {
|
|
let headers: Vec<&str> = map.keys().map(|k| k.as_str()).collect();
|
|
let values: Vec<String> = map
|
|
.values()
|
|
.map(|v| match v {
|
|
serde_json::Value::String(s) => s.clone(),
|
|
_ => v.to_string(),
|
|
})
|
|
.collect();
|
|
println!("{}", headers.join(","));
|
|
println!("{}", values.join(","));
|
|
}
|
|
_ => {
|
|
println!("{val}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_json_as_table(val: &serde_json::Value) {
|
|
match val {
|
|
serde_json::Value::Array(arr) => {
|
|
if arr.is_empty() {
|
|
println!("(No items found)");
|
|
return;
|
|
}
|
|
println!("{}", serde_json::to_string_pretty(val).unwrap_or_default());
|
|
}
|
|
serde_json::Value::Object(map) => {
|
|
for (k, v) in map {
|
|
match v {
|
|
serde_json::Value::String(s) => println!(" {k: <24}: {s}"),
|
|
_ => println!(" {k: <24}: {v}"),
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
println!("{val}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Application Entry Point ─────────────────────────────────────────────────
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
let cli = Cli::parse();
|
|
let format = if cli.json {
|
|
OutputFormat::Json
|
|
} else {
|
|
cli.format
|
|
};
|
|
|
|
let log_level = cli.log_level.as_deref().unwrap_or("info");
|
|
let filter = format!(
|
|
"nx9_wg={log_level},nx9_wg_api={log_level},nx9_wg_db={log_level},nx9_wireguard={log_level},nx9_wg_network={log_level}"
|
|
);
|
|
tracing_subscriber::registry()
|
|
.with(EnvFilter::try_new(&filter).unwrap_or_else(|_| EnvFilter::new(&filter)))
|
|
.with(tracing_subscriber::fmt::layer())
|
|
.init();
|
|
|
|
let config_path = cli
|
|
.config
|
|
.clone()
|
|
.unwrap_or_else(|| PathBuf::from("/etc/nx9-wg/config.toml"));
|
|
let mut config = AppConfig::load(&config_path).unwrap_or_default();
|
|
|
|
if let Some(ref data_dir) = cli.data_dir {
|
|
config.backup.dir = data_dir.join("backups");
|
|
config.data_dir = data_dir.clone();
|
|
}
|
|
|
|
let command = match cli.command {
|
|
Some(cmd) => cmd,
|
|
None => {
|
|
println!(
|
|
"NX9 WireGuard Appliance (nx9-wg) v{}",
|
|
env!("CARGO_PKG_VERSION")
|
|
);
|
|
println!("Run 'nx9-wg --help' for available commands.");
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
// Avoid initializing or creating data directories for the simple 'version' command.
|
|
let db_url = if matches!(&command, Commands::Version) {
|
|
String::new()
|
|
} else if let Some(ref db_path) = cli.database {
|
|
// If an explicit database path is provided, ensure its parent directories exist
|
|
if cli.data_dir.is_none()
|
|
&& let Some(parent) = db_path.parent()
|
|
{
|
|
if !parent.exists()
|
|
&& let Err(e) = std::fs::create_dir_all(parent)
|
|
{
|
|
eprintln!(
|
|
"Failed to create parent directory for database '{}': {}",
|
|
parent.display(),
|
|
e
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
config.data_dir = parent.to_path_buf();
|
|
config.backup.dir = parent.join("backups");
|
|
}
|
|
db_path.to_string_lossy().to_string()
|
|
} else {
|
|
if !config.data_dir.exists()
|
|
&& let Err(e) = std::fs::create_dir_all(&config.data_dir)
|
|
{
|
|
eprintln!(
|
|
"Failed to create data directory '{}': {}",
|
|
config.data_dir.display(),
|
|
e
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
config
|
|
.data_dir
|
|
.join("nx9-wg.db")
|
|
.to_string_lossy()
|
|
.to_string()
|
|
};
|
|
|
|
match command {
|
|
Commands::Version => {
|
|
let info = serde_json::json!({
|
|
"name": "nx9-wg",
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"architecture": std::env::consts::ARCH,
|
|
"os": std::env::consts::OS,
|
|
"edition": "2024",
|
|
"native_wireguard": true,
|
|
"single_admin_security": true
|
|
});
|
|
print_output(&info, format)?;
|
|
}
|
|
|
|
Commands::Serve(args) => {
|
|
let bind_addr = args.bind.unwrap_or(config.bind_address);
|
|
if !cli.quiet {
|
|
tracing::info!("Connecting to SQLite store at '{db_url}'");
|
|
}
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
let app_state = AppState::new(store.clone());
|
|
let app = build_api_router(app_state.clone());
|
|
|
|
let listener = tokio::net::TcpListener::bind(bind_addr).await?;
|
|
if !cli.quiet {
|
|
tracing::info!("NX9 WireGuard daemon listening on http://{bind_addr}");
|
|
}
|
|
|
|
// Start background periodic reconciliation
|
|
let wg_engine = Arc::new(NativeLinuxWireGuardEngine::new());
|
|
let net_engine = Arc::new(NativeLinuxNetworkEngine::new());
|
|
let reconciler = Arc::new(ReconciliationEngine::new(app_state, wg_engine, net_engine));
|
|
reconciler.start_background_loop(config.reconciliation_interval_secs);
|
|
|
|
axum::serve(
|
|
listener,
|
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
Commands::Init(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
let stdin_password = if args.password_stdin {
|
|
let mut buf = String::new();
|
|
io::stdin().read_to_string(&mut buf)?;
|
|
Some(buf.trim().to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let opts = BootstrapOptions {
|
|
admin_username: args.username,
|
|
cli_password: args.password,
|
|
stdin_password,
|
|
password_file: args.password_file.map(|p| p.to_string_lossy().to_string()),
|
|
generate_password: args.generate_password,
|
|
write_password_file: args
|
|
.write_password_file
|
|
.map(|p| p.to_string_lossy().to_string()),
|
|
};
|
|
|
|
match bootstrap_admin(&store, &config, &opts).await {
|
|
Ok(res) => {
|
|
if !cli.quiet {
|
|
println!("Administrator initialized successfully.");
|
|
println!(" Username: {}", res.admin.username);
|
|
println!(" Source: {}", res.source.description());
|
|
if let Some(_pw) = res.generated_plaintext {
|
|
if let Some(ref path) = opts.write_password_file {
|
|
println!("Generated password written to file: {}", path);
|
|
} else {
|
|
println!("Generated password created (redacted)");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sanitize admin output to avoid leaking password hashes or secrets
|
|
let admin_sanitized = serde_json::json!({
|
|
"id": res.admin.id,
|
|
"username": res.admin.username,
|
|
"created_at": res.admin.created_at,
|
|
"last_login_at": res.admin.last_login_at,
|
|
"last_login_ip": res.admin.last_login_ip,
|
|
"totp_enabled": res.admin.totp_enabled,
|
|
"password_hash": "[REDACTED]"
|
|
});
|
|
print_output(&admin_sanitized, format)?;
|
|
}
|
|
Err(ApiError::Conflict(_)) => {
|
|
if !cli.quiet {
|
|
println!(
|
|
"Administrator already initialized. Use 'nx9-wg admin password' to reset."
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error bootstrapping administrator: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
Commands::System(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
match args.subcommand {
|
|
SystemSubcommands::Status => {
|
|
let admin = store.get_admin().await?;
|
|
let ifaces = store.list_interfaces().await?.len();
|
|
let peers = store.list_all_peers().await?.len();
|
|
let networks = store.list_networks().await?.len();
|
|
let routes = store.list_routes().await?.len();
|
|
let rules = store.list_firewall_rules().await?.len();
|
|
let backups = store.list_backups().await?.len();
|
|
let stats = serde_json::json!({
|
|
"admin_initialized": admin.is_some(),
|
|
"interfaces_count": ifaces,
|
|
"peers_count": peers,
|
|
"networks_count": networks,
|
|
"routes_count": routes,
|
|
"firewall_rules_count": rules,
|
|
"backups_count": backups,
|
|
"status": "operational"
|
|
});
|
|
print_output(&stats, format)?;
|
|
}
|
|
SystemSubcommands::Health => {
|
|
let healthy = store.admin_exists().await.is_ok();
|
|
let health_data = serde_json::json!({
|
|
"status": if healthy { "healthy" } else { "unhealthy" },
|
|
"database": if healthy { "connected" } else { "disconnected" },
|
|
"timestamp": chrono::Utc::now().to_rfc3339()
|
|
});
|
|
print_output(&health_data, format)?;
|
|
if !healthy {
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
SystemSubcommands::Info => {
|
|
let info = serde_json::json!({
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"os": std::env::consts::OS,
|
|
"arch": std::env::consts::ARCH,
|
|
"database_path": db_url,
|
|
"data_dir": config.data_dir.display().to_string(),
|
|
"config_file": config.config_file.display().to_string(),
|
|
"log_level": config.log_level,
|
|
"session_expiry_hours": config.session_expiry_hours,
|
|
"reconciliation_interval_secs": config.reconciliation_interval_secs
|
|
});
|
|
print_output(&info, format)?;
|
|
}
|
|
SystemSubcommands::Settings(s_args) => match s_args.subcommand {
|
|
SettingsSubcommands::List => {
|
|
let settings = store.list_settings().await?;
|
|
let sanitized: Vec<Setting> = settings
|
|
.into_iter()
|
|
.map(|mut s| {
|
|
if s.is_secret {
|
|
s.value = "[REDACTED]".to_string();
|
|
}
|
|
s
|
|
})
|
|
.collect();
|
|
print_output(&sanitized, format)?;
|
|
}
|
|
SettingsSubcommands::Get { key } => {
|
|
let s = store.get_setting(&key).await?;
|
|
match s {
|
|
Some(mut setting) => {
|
|
if setting.is_secret {
|
|
setting.value = "[REDACTED]".to_string();
|
|
}
|
|
print_output(&setting, format)?;
|
|
}
|
|
None => {
|
|
eprintln!("Setting '{key}' not found");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
SettingsSubcommands::Set { key, value, secret } => {
|
|
let key_trimmed = key.trim();
|
|
let val_trimmed = value.trim();
|
|
if key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_HOST {
|
|
if !val_trimmed.is_empty() {
|
|
nx9_wg_core::validation::validate_server_host(val_trimmed)?;
|
|
}
|
|
} else if key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_PORT {
|
|
let port: u16 = val_trimmed.parse().map_err(
|
|
|_| "Invalid server port: must be an integer between 1 and 65535",
|
|
)?;
|
|
nx9_wg_core::validation::validate_server_port(port)?;
|
|
} else if key_trimmed
|
|
== nx9_wg_core::types::settings::SETTING_SERVER_ENDPOINT_ENABLED
|
|
&& val_trimmed != "true"
|
|
&& val_trimmed != "false"
|
|
&& val_trimmed != "1"
|
|
&& val_trimmed != "0"
|
|
{
|
|
return Err("Setting wireguard.server_endpoint_enabled must be 'true' or 'false'".into());
|
|
}
|
|
store.set_setting(key_trimmed, val_trimmed, secret).await?;
|
|
if (key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_HOST
|
|
|| key_trimmed == nx9_wg_core::types::settings::SETTING_SERVER_PORT
|
|
|| key_trimmed
|
|
== nx9_wg_core::types::settings::SETTING_SERVER_ENDPOINT_ENABLED)
|
|
&& let Ok(settings) = store.get_server_endpoint_settings().await
|
|
&& settings.enabled
|
|
&& !settings.host.trim().is_empty()
|
|
{
|
|
let formatted = nx9_wg_core::validation::format_endpoint(
|
|
&settings.host,
|
|
settings.port,
|
|
);
|
|
let _ = store
|
|
.set_setting(
|
|
nx9_wg_core::types::settings::LEGACY_SETTING_SERVER_ENDPOINT,
|
|
&formatted,
|
|
false,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
println!("Setting '{key}' saved.");
|
|
}
|
|
SettingsSubcommands::Delete { key } => {
|
|
store.delete_setting(&key).await?;
|
|
println!("Setting '{key}' deleted.");
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
Commands::Admin(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
let auth = AuthService::new(store.clone());
|
|
|
|
match args.subcommand {
|
|
AdminSubcommands::Status => {
|
|
let admin = store.get_admin().await?;
|
|
match admin {
|
|
Some(a) => {
|
|
let val = serde_json::json!({
|
|
"username": a.username,
|
|
"totp_enabled": a.totp_enabled,
|
|
"last_login_at": a.last_login_at,
|
|
"last_login_ip": a.last_login_ip,
|
|
"created_at": a.created_at
|
|
});
|
|
print_output(&val, format)?;
|
|
}
|
|
None => {
|
|
println!("Administrator has not been initialized yet.");
|
|
}
|
|
}
|
|
}
|
|
AdminSubcommands::Create(init_args) => {
|
|
let stdin_password = if init_args.password_stdin {
|
|
let mut buf = String::new();
|
|
io::stdin().read_to_string(&mut buf)?;
|
|
Some(buf.trim().to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let opts = BootstrapOptions {
|
|
admin_username: init_args.username,
|
|
cli_password: init_args.password,
|
|
stdin_password,
|
|
password_file: init_args
|
|
.password_file
|
|
.map(|p| p.to_string_lossy().to_string()),
|
|
generate_password: init_args.generate_password,
|
|
write_password_file: init_args
|
|
.write_password_file
|
|
.map(|p| p.to_string_lossy().to_string()),
|
|
};
|
|
|
|
match bootstrap_admin(&store, &config, &opts).await {
|
|
Ok(res) => {
|
|
println!(
|
|
"Administrator created successfully ({}).",
|
|
res.source.description()
|
|
);
|
|
if let Some(_pw) = res.generated_plaintext {
|
|
if let Some(ref path) = opts.write_password_file {
|
|
println!("Generated password written to file: {}", path);
|
|
} else {
|
|
println!("Generated password created (redacted)");
|
|
}
|
|
}
|
|
// Sanitize admin output to avoid leaking password hashes or secrets
|
|
let admin_sanitized = serde_json::json!({
|
|
"id": res.admin.id,
|
|
"username": res.admin.username,
|
|
"created_at": res.admin.created_at,
|
|
"last_login_at": res.admin.last_login_at,
|
|
"last_login_ip": res.admin.last_login_ip,
|
|
"totp_enabled": res.admin.totp_enabled,
|
|
"password_hash": "[REDACTED]"
|
|
});
|
|
print_output(&admin_sanitized, format)?;
|
|
}
|
|
Err(ApiError::Conflict(_)) => {
|
|
eprintln!("Administrator already exists.");
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error creating admin: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
AdminSubcommands::Password(pw_args) => {
|
|
let new_pw = if let Some(p) = pw_args.new_password {
|
|
p
|
|
} else if pw_args.stdin {
|
|
let mut buf = String::new();
|
|
io::stdin().read_to_string(&mut buf)?;
|
|
buf.trim().to_string()
|
|
} else if let Some(ref path) = pw_args.password_file {
|
|
std::fs::read_to_string(path)?.trim().to_string()
|
|
} else if pw_args.generate {
|
|
let generated = generate_secure_password(24);
|
|
println!("Generated password created (redacted)");
|
|
generated
|
|
} else {
|
|
eprintln!(
|
|
"Please provide --new-password, --stdin, --password-file, or --generate"
|
|
);
|
|
std::process::exit(1);
|
|
};
|
|
|
|
match auth.change_password(&new_pw, Some("cli")).await {
|
|
Ok(()) => {
|
|
println!("Administrator password updated successfully.");
|
|
println!("All active sessions have been invalidated.");
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error changing password: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
AdminSubcommands::Sessions(sess_args) => match sess_args.subcommand {
|
|
SessionSubcommands::List => {
|
|
let sessions = store.list_sessions().await?;
|
|
print_output(&sessions, format)?;
|
|
}
|
|
SessionSubcommands::Revoke { id } => {
|
|
store.delete_session(&id).await?;
|
|
println!("Session '{id}' revoked.");
|
|
}
|
|
SessionSubcommands::RevokeAll => {
|
|
store.delete_all_sessions().await?;
|
|
println!("All active sessions revoked.");
|
|
}
|
|
},
|
|
AdminSubcommands::Tokens(token_args) => match token_args.subcommand {
|
|
TokenSubcommands::Create {
|
|
name,
|
|
days,
|
|
write_token_file,
|
|
} => {
|
|
let exp = days
|
|
.map(|d| chrono::Utc::now().naive_utc() + chrono::Duration::days(d));
|
|
match auth.create_api_token(&name, exp, Some("cli")).await {
|
|
Ok((meta, raw_token)) => {
|
|
println!("API Token Created:");
|
|
// One-time delivery: either write to a restricted file, or display once to stdout
|
|
if let Some(path) = write_token_file {
|
|
if let Some(parent) = path.parent()
|
|
&& !parent.exists()
|
|
&& let Err(e) = std::fs::create_dir_all(parent)
|
|
{
|
|
eprintln!(
|
|
"Failed to create parent directory for token file '{}': {}",
|
|
parent.display(),
|
|
e
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
use std::io::Write;
|
|
match std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.write(true)
|
|
.truncate(true)
|
|
.mode(0o600)
|
|
.open(&path)
|
|
{
|
|
Ok(mut f) => {
|
|
if let Err(e) = f.write_all(raw_token.as_bytes()) {
|
|
eprintln!(
|
|
"Failed to write token to file '{}': {}",
|
|
path.display(),
|
|
e
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!(
|
|
"Failed to create token file '{}': {}",
|
|
path.display(),
|
|
e
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
println!("Token written to file: {}", path.display());
|
|
} else {
|
|
println!(" ID: {}", meta.id);
|
|
println!(" Name: {}", meta.name);
|
|
println!(" Expires: {:?}", meta.expires_at);
|
|
println!("\n Secret Token (SAVE THIS NOW):");
|
|
println!(" ========================================");
|
|
println!(" {raw_token}");
|
|
println!(" ========================================\n");
|
|
}
|
|
|
|
// Sanitize token metadata for output (never expose token_hash)
|
|
let mut meta_val = serde_json::to_value(&meta)?;
|
|
if let serde_json::Value::Object(ref mut obj) = meta_val {
|
|
obj.insert(
|
|
"token_hash".to_string(),
|
|
serde_json::Value::String("[REDACTED]".to_string()),
|
|
);
|
|
}
|
|
print_output(&meta_val, format)?;
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error creating token: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
TokenSubcommands::List => {
|
|
let tokens = store.list_tokens().await?;
|
|
let mut tokens_val = serde_json::to_value(&tokens)?;
|
|
if let serde_json::Value::Array(ref mut arr) = tokens_val {
|
|
for item in arr.iter_mut() {
|
|
if let serde_json::Value::Object(obj) = item {
|
|
obj.insert(
|
|
"token_hash".to_string(),
|
|
serde_json::Value::String("[REDACTED]".to_string()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
print_output(&tokens_val, format)?;
|
|
}
|
|
TokenSubcommands::Revoke { id } => {
|
|
auth.revoke_api_token(&id, Some("cli")).await?;
|
|
println!("API token '{id}' revoked.");
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
Commands::Interface(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
match args.subcommand {
|
|
InterfaceSubcommands::List => {
|
|
let ifaces = store.list_interfaces().await?;
|
|
print_output(&ifaces, format)?;
|
|
}
|
|
InterfaceSubcommands::Show { interface } => {
|
|
let iface = if let Ok(id) = Uuid::parse_str(&interface) {
|
|
store.get_interface(id).await?
|
|
} else {
|
|
store.get_interface_by_name(&interface).await?
|
|
};
|
|
match iface {
|
|
Some(i) => print_output(&i, format)?,
|
|
None => {
|
|
eprintln!("Interface '{interface}' not found");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
InterfaceSubcommands::Create {
|
|
name,
|
|
port,
|
|
address_v4,
|
|
address_v6,
|
|
mtu,
|
|
dns,
|
|
} => {
|
|
validate_interface_name(&name)?;
|
|
validate_listen_port(port)?;
|
|
let v4_net = validate_cidr(&address_v4)?;
|
|
let v6_net = address_v6.as_deref().map(validate_cidr).transpose()?;
|
|
let (priv_key, pub_key) = nx9_wg_core::crypto::generate_keypair();
|
|
let now = chrono::Utc::now().naive_utc();
|
|
|
|
let iface = Interface {
|
|
id: Uuid::new_v4(),
|
|
name,
|
|
private_key: priv_key,
|
|
public_key: pub_key,
|
|
listen_port: port,
|
|
address_v4: v4_net,
|
|
address_v6: v6_net,
|
|
mtu,
|
|
dns,
|
|
enabled: true,
|
|
pre_up: None,
|
|
post_up: None,
|
|
pre_down: None,
|
|
post_down: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
|
|
store.create_interface(&iface).await?;
|
|
if !cli.quiet && format == OutputFormat::Table {
|
|
println!("Interface '{}' ({}) created.", iface.name, iface.id);
|
|
}
|
|
print_output(&iface, format)?;
|
|
}
|
|
InterfaceSubcommands::Update {
|
|
interface,
|
|
port,
|
|
address_v4,
|
|
address_v6,
|
|
mtu,
|
|
dns,
|
|
enabled,
|
|
} => {
|
|
let mut iface = if let Ok(id) = Uuid::parse_str(&interface) {
|
|
store
|
|
.get_interface(id)
|
|
.await?
|
|
.ok_or("Interface not found")?
|
|
} else {
|
|
store
|
|
.get_interface_by_name(&interface)
|
|
.await?
|
|
.ok_or("Interface not found")?
|
|
};
|
|
|
|
if let Some(p) = port {
|
|
validate_listen_port(p)?;
|
|
iface.listen_port = p;
|
|
}
|
|
if let Some(ref v4) = address_v4 {
|
|
iface.address_v4 = validate_cidr(v4)?;
|
|
}
|
|
if let Some(ref v6) = address_v6 {
|
|
iface.address_v6 = Some(validate_cidr(v6)?);
|
|
}
|
|
if let Some(m) = mtu {
|
|
iface.mtu = Some(m);
|
|
}
|
|
if let Some(d) = dns {
|
|
iface.dns = Some(d);
|
|
}
|
|
if let Some(e) = enabled {
|
|
iface.enabled = e;
|
|
}
|
|
iface.updated_at = chrono::Utc::now().naive_utc();
|
|
|
|
store.update_interface(&iface).await?;
|
|
if !cli.quiet && format == OutputFormat::Table {
|
|
println!("Interface '{}' updated.", iface.name);
|
|
}
|
|
print_output(&iface, format)?;
|
|
}
|
|
InterfaceSubcommands::Delete { interface } => {
|
|
let id = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
|
uuid
|
|
} else {
|
|
let iface = store
|
|
.get_interface_by_name(&interface)
|
|
.await?
|
|
.ok_or("Interface not found")?;
|
|
iface.id
|
|
};
|
|
store.delete_interface(id).await?;
|
|
println!("Interface '{interface}' deleted.");
|
|
}
|
|
InterfaceSubcommands::Enable { interface } => {
|
|
let id = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
|
uuid
|
|
} else {
|
|
let iface = store
|
|
.get_interface_by_name(&interface)
|
|
.await?
|
|
.ok_or("Interface not found")?;
|
|
iface.id
|
|
};
|
|
store.set_interface_enabled(id, true).await?;
|
|
println!("Interface '{interface}' enabled.");
|
|
}
|
|
InterfaceSubcommands::Disable { interface } => {
|
|
let id = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
|
uuid
|
|
} else {
|
|
let iface = store
|
|
.get_interface_by_name(&interface)
|
|
.await?
|
|
.ok_or("Interface not found")?;
|
|
iface.id
|
|
};
|
|
store.set_interface_enabled(id, false).await?;
|
|
println!("Interface '{interface}' disabled.");
|
|
}
|
|
InterfaceSubcommands::Status { interface } => {
|
|
let wg = create_wireguard_engine();
|
|
let stats = wg.get_interface_stats(&interface).await?;
|
|
match stats {
|
|
Some(s) => print_output(&s, format)?,
|
|
None => println!("No live kernel stats available for '{interface}'."),
|
|
}
|
|
}
|
|
InterfaceSubcommands::Reconcile { interface: _ } => {
|
|
let state = AppState::new(store);
|
|
let wg = create_wireguard_engine();
|
|
let net = create_network_engine();
|
|
let reconciler = ReconciliationEngine::new(state, wg, net);
|
|
let report = reconciler.apply().await?;
|
|
print_output(&report, format)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Commands::Peer(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
match args.subcommand {
|
|
PeerSubcommands::List { interface } => {
|
|
let peers = if let Some(iface_id_str) = interface {
|
|
if let Ok(id) = Uuid::parse_str(&iface_id_str) {
|
|
store.list_peers_for_interface(id).await?
|
|
} else if let Some(iface) =
|
|
store.get_interface_by_name(&iface_id_str).await?
|
|
{
|
|
store.list_peers_for_interface(iface.id).await?
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
} else {
|
|
store.list_all_peers().await?
|
|
};
|
|
print_output(&peers, format)?;
|
|
}
|
|
PeerSubcommands::Show { id } => {
|
|
let peer_id = Uuid::parse_str(&id)?;
|
|
let peer = store.get_peer(peer_id).await?;
|
|
match peer {
|
|
Some(p) => print_output(&p, format)?,
|
|
None => {
|
|
eprintln!("Peer '{id}' not found");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
PeerSubcommands::Create {
|
|
interface,
|
|
name,
|
|
peer_type,
|
|
profile,
|
|
network,
|
|
address_v4,
|
|
allowed_ips,
|
|
endpoint,
|
|
persistent_keepalive,
|
|
dns,
|
|
mtu,
|
|
expires_at,
|
|
} => {
|
|
let iface = if let Ok(uuid) = Uuid::parse_str(&interface) {
|
|
store
|
|
.get_interface(uuid)
|
|
.await?
|
|
.ok_or("Interface not found")?
|
|
} else {
|
|
store
|
|
.get_interface_by_name(&interface)
|
|
.await?
|
|
.ok_or("Interface not found")?
|
|
};
|
|
validate_peer_name(&name)?;
|
|
let (priv_key, pub_key) = nx9_wg_core::crypto::generate_keypair();
|
|
let psk = nx9_wg_core::crypto::generate_preshared_key();
|
|
|
|
let mut v4_net = address_v4.as_deref().map(validate_cidr).transpose()?;
|
|
if v4_net.is_none() {
|
|
let target_net = match network {
|
|
Some(ref n_str) => {
|
|
if let Ok(n_uuid) = Uuid::parse_str(n_str) {
|
|
store
|
|
.get_network(n_uuid)
|
|
.await?
|
|
.ok_or("Network not found")?
|
|
} else {
|
|
store
|
|
.get_network_by_name(n_str)
|
|
.await?
|
|
.ok_or("Network not found")?
|
|
}
|
|
}
|
|
None => Network {
|
|
id: Uuid::nil(),
|
|
name: format!("{}-subnet", iface.name),
|
|
cidr: iface.address_v4,
|
|
enabled: true,
|
|
description: None,
|
|
created_at: chrono::Utc::now().naive_utc(),
|
|
updated_at: chrono::Utc::now().naive_utc(),
|
|
},
|
|
};
|
|
v4_net = Some(
|
|
IpAllocator::allocate_next_ip(&store, &target_net, Some(&iface), None)
|
|
.await?,
|
|
);
|
|
}
|
|
|
|
let p_type = PeerType::from_str(&peer_type).unwrap_or(PeerType::RoadWarrior);
|
|
let p_profile =
|
|
PeerProfile::from_str(&profile).unwrap_or(PeerProfile::FullTunnel);
|
|
let parsed_expires_at = match expires_at {
|
|
Some(ref s) => {
|
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
|
|
Some(dt.naive_utc())
|
|
} else {
|
|
Some(chrono::NaiveDateTime::parse_from_str(
|
|
s,
|
|
"%Y-%m-%d %H:%M:%S",
|
|
)?)
|
|
}
|
|
}
|
|
None => None,
|
|
};
|
|
let now = chrono::Utc::now().naive_utc();
|
|
|
|
let peer = Peer {
|
|
id: Uuid::new_v4(),
|
|
interface_id: iface.id,
|
|
name,
|
|
peer_type: p_type,
|
|
state: PeerState::Active,
|
|
public_key: pub_key,
|
|
private_key: Some(priv_key),
|
|
preshared_key: Some(psk),
|
|
endpoint,
|
|
allowed_ips: if allowed_ips == "0.0.0.0/0, ::/0" {
|
|
if let Some(v4) = v4_net {
|
|
v4.to_string()
|
|
} else {
|
|
allowed_ips
|
|
}
|
|
} else {
|
|
allowed_ips
|
|
},
|
|
server_allowed_ips: None,
|
|
address_v4: v4_net,
|
|
address_v6: None,
|
|
dns: dns.or_else(|| Some("1.1.1.1".to_string())),
|
|
mtu: mtu.or(Some(1420)),
|
|
persistent_keepalive: persistent_keepalive.or(Some(25)),
|
|
profile: p_profile,
|
|
expires_at: parsed_expires_at,
|
|
last_handshake_at: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
|
|
store.create_peer(&peer).await?;
|
|
if !cli.quiet && format == OutputFormat::Table {
|
|
println!("Peer '{}' ({}) created.", peer.name, peer.id);
|
|
}
|
|
print_output(&peer, format)?;
|
|
}
|
|
PeerSubcommands::Update {
|
|
id,
|
|
name,
|
|
allowed_ips,
|
|
endpoint,
|
|
persistent_keepalive,
|
|
dns,
|
|
mtu,
|
|
expires_at,
|
|
enabled,
|
|
} => {
|
|
let peer_id = Uuid::parse_str(&id)?;
|
|
let mut peer = store.get_peer(peer_id).await?.ok_or("Peer not found")?;
|
|
|
|
if let Some(n) = name {
|
|
validate_peer_name(&n)?;
|
|
peer.name = n;
|
|
}
|
|
if let Some(ips) = allowed_ips {
|
|
peer.allowed_ips = ips;
|
|
}
|
|
if let Some(ep) = endpoint {
|
|
peer.endpoint = Some(ep);
|
|
}
|
|
if let Some(ka) = persistent_keepalive {
|
|
peer.persistent_keepalive = Some(ka);
|
|
}
|
|
if let Some(d) = dns {
|
|
peer.dns = Some(d);
|
|
}
|
|
if let Some(m) = mtu {
|
|
peer.mtu = Some(m);
|
|
}
|
|
if let Some(ref exp_s) = expires_at {
|
|
peer.expires_at =
|
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(exp_s) {
|
|
Some(dt.naive_utc())
|
|
} else {
|
|
Some(chrono::NaiveDateTime::parse_from_str(
|
|
exp_s,
|
|
"%Y-%m-%d %H:%M:%S",
|
|
)?)
|
|
};
|
|
}
|
|
if let Some(e) = enabled {
|
|
peer.state = if e {
|
|
PeerState::Active
|
|
} else {
|
|
PeerState::Disabled
|
|
};
|
|
}
|
|
peer.updated_at = chrono::Utc::now().naive_utc();
|
|
|
|
store.update_peer(&peer).await?;
|
|
if !cli.quiet && format == OutputFormat::Table {
|
|
println!("Peer '{}' updated.", peer.name);
|
|
}
|
|
print_output(&peer, format)?;
|
|
}
|
|
PeerSubcommands::Delete { id } => {
|
|
let peer_id = Uuid::parse_str(&id)?;
|
|
store.delete_peer(peer_id).await?;
|
|
println!("Peer '{id}' deleted.");
|
|
}
|
|
PeerSubcommands::Enable { id } => {
|
|
let peer_id = Uuid::parse_str(&id)?;
|
|
store.set_peer_state(peer_id, PeerState::Active).await?;
|
|
println!("Peer '{id}' enabled.");
|
|
}
|
|
PeerSubcommands::Disable { id } => {
|
|
let peer_id = Uuid::parse_str(&id)?;
|
|
store.set_peer_state(peer_id, PeerState::Disabled).await?;
|
|
println!("Peer '{id}' disabled.");
|
|
}
|
|
PeerSubcommands::Revoke { id } => {
|
|
let peer_id = Uuid::parse_str(&id)?;
|
|
store.set_peer_state(peer_id, PeerState::Revoked).await?;
|
|
println!("Peer '{id}' revoked.");
|
|
}
|
|
PeerSubcommands::Expire { id } => {
|
|
let peer_id = Uuid::parse_str(&id)?;
|
|
store.mark_peer_expired(peer_id).await?;
|
|
println!("Peer '{id}' marked as expired.");
|
|
}
|
|
PeerSubcommands::Lifecycle { id } => {
|
|
let peer_id = Uuid::parse_str(&id)?;
|
|
let peer = store.get_peer(peer_id).await?.ok_or("Peer not found")?;
|
|
let now = chrono::Utc::now().naive_utc();
|
|
let is_expired = peer.state == PeerState::Expired
|
|
|| peer.expires_at.map(|e| e <= now).unwrap_or(false);
|
|
let info = serde_json::json!({
|
|
"id": peer.id,
|
|
"name": peer.name,
|
|
"state": peer.state,
|
|
"expires_at": peer.expires_at,
|
|
"is_expired": is_expired,
|
|
"last_handshake_at": peer.last_handshake_at,
|
|
"created_at": peer.created_at,
|
|
"updated_at": peer.updated_at
|
|
});
|
|
print_output(&info, format)?;
|
|
}
|
|
PeerSubcommands::Status { id } => {
|
|
let peer_id = Uuid::parse_str(&id)?;
|
|
let peer = store.get_peer(peer_id).await?.ok_or("Peer not found")?;
|
|
let iface = store
|
|
.get_interface(peer.interface_id)
|
|
.await?
|
|
.ok_or("Interface not found")?;
|
|
let wg = create_wireguard_engine();
|
|
let iface_stats = wg.get_interface_stats(&iface.name).await?;
|
|
let peer_stat = iface_stats.and_then(|s| {
|
|
s.peers
|
|
.into_iter()
|
|
.find(|p| p.public_key == peer.public_key.as_str())
|
|
});
|
|
match peer_stat {
|
|
Some(s) => print_output(&s, format)?,
|
|
None => println!("No live kernel stats for peer '{id}'"),
|
|
}
|
|
}
|
|
PeerSubcommands::Config {
|
|
id,
|
|
provider,
|
|
device,
|
|
connection,
|
|
nat,
|
|
mtu,
|
|
profile,
|
|
endpoint,
|
|
output,
|
|
} => {
|
|
let peer_id = Uuid::parse_str(&id)?;
|
|
let peer = store.get_peer(peer_id).await?.ok_or("Peer not found")?;
|
|
let iface = store
|
|
.get_interface(peer.interface_id)
|
|
.await?
|
|
.ok_or("Interface not found")?;
|
|
|
|
let resolved_profile = if provider.is_some()
|
|
|| device.is_some()
|
|
|| connection.is_some()
|
|
|| nat.is_some()
|
|
|| mtu.is_some()
|
|
|| profile.is_some()
|
|
{
|
|
let dev = device
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::DeviceCategory::from_str)
|
|
.transpose()?;
|
|
let conn = connection
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::ConnectionType::from_str)
|
|
.transpose()?;
|
|
let nat_t = nat
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::NatType::from_str)
|
|
.transpose()?;
|
|
|
|
let res = nx9_wg_api::profile_resolver::ClientProfileResolver::resolve(
|
|
&store,
|
|
provider.as_deref(),
|
|
dev,
|
|
conn,
|
|
nat_t,
|
|
mtu,
|
|
profile.as_deref(),
|
|
iface.mtu,
|
|
)
|
|
.await?;
|
|
if let Some(w) = res
|
|
.warning
|
|
.as_ref()
|
|
.filter(|_| !cli.quiet && format == OutputFormat::Table)
|
|
{
|
|
eprintln!("Warning: {w}");
|
|
}
|
|
Some(res)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let server_host = store.resolve_server_endpoint(endpoint.as_deref()).await?;
|
|
|
|
let conf = ClientConfigBuilder::build_with_profile(
|
|
&peer,
|
|
&iface,
|
|
&server_host,
|
|
resolved_profile.as_ref(),
|
|
)?;
|
|
if let Some(out_path) = output {
|
|
std::fs::write(&out_path, &conf)?;
|
|
println!("Configuration written to '{}'", out_path.display());
|
|
} else {
|
|
println!("{conf}");
|
|
}
|
|
}
|
|
PeerSubcommands::Qr {
|
|
id,
|
|
provider,
|
|
device,
|
|
connection,
|
|
nat,
|
|
mtu,
|
|
profile,
|
|
endpoint,
|
|
qr_format,
|
|
} => {
|
|
let peer_id = Uuid::parse_str(&id)?;
|
|
let peer = store.get_peer(peer_id).await?.ok_or("Peer not found")?;
|
|
let iface = store
|
|
.get_interface(peer.interface_id)
|
|
.await?
|
|
.ok_or("Interface not found")?;
|
|
|
|
let resolved_profile = if provider.is_some()
|
|
|| device.is_some()
|
|
|| connection.is_some()
|
|
|| nat.is_some()
|
|
|| mtu.is_some()
|
|
|| profile.is_some()
|
|
{
|
|
let dev = device
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::DeviceCategory::from_str)
|
|
.transpose()?;
|
|
let conn = connection
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::ConnectionType::from_str)
|
|
.transpose()?;
|
|
let nat_t = nat
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::NatType::from_str)
|
|
.transpose()?;
|
|
|
|
let res = nx9_wg_api::profile_resolver::ClientProfileResolver::resolve(
|
|
&store,
|
|
provider.as_deref(),
|
|
dev,
|
|
conn,
|
|
nat_t,
|
|
mtu,
|
|
profile.as_deref(),
|
|
iface.mtu,
|
|
)
|
|
.await?;
|
|
if let Some(w) = res
|
|
.warning
|
|
.as_ref()
|
|
.filter(|_| !cli.quiet && format == OutputFormat::Table)
|
|
{
|
|
eprintln!("Warning: {w}");
|
|
}
|
|
Some(res)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let server_host = store.resolve_server_endpoint(endpoint.as_deref()).await?;
|
|
|
|
let conf = ClientConfigBuilder::build_with_profile(
|
|
&peer,
|
|
&iface,
|
|
&server_host,
|
|
resolved_profile.as_ref(),
|
|
)?;
|
|
|
|
match qr_format.to_lowercase().as_str() {
|
|
"svg" => {
|
|
let svg = generate_qr_svg(&conf)?;
|
|
println!("{svg}");
|
|
}
|
|
"png" => {
|
|
let png_bytes = generate_qr_png_bytes(&conf)?;
|
|
println!(
|
|
"PNG Bytes (base64): {}",
|
|
base64::Engine::encode(
|
|
&base64::engine::general_purpose::STANDARD,
|
|
png_bytes
|
|
)
|
|
);
|
|
}
|
|
_ => {
|
|
let qr_ascii = generate_qr_ascii(&conf)?;
|
|
println!("{qr_ascii}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Commands::Network(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
match args.subcommand {
|
|
NetworkSubcommands::List => {
|
|
let list = store.list_networks().await?;
|
|
print_output(&list, format)?;
|
|
}
|
|
NetworkSubcommands::Show { id } => {
|
|
let net_id = Uuid::parse_str(&id)?;
|
|
let net = store.get_network(net_id).await?;
|
|
match net {
|
|
Some(n) => print_output(&n, format)?,
|
|
None => {
|
|
eprintln!("Network '{id}' not found");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
NetworkSubcommands::Create {
|
|
name,
|
|
cidr,
|
|
description,
|
|
} => {
|
|
let net_cidr = validate_cidr(&cidr)?;
|
|
let now = chrono::Utc::now().naive_utc();
|
|
let net = Network {
|
|
id: Uuid::new_v4(),
|
|
name,
|
|
cidr: net_cidr,
|
|
enabled: true,
|
|
description,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
store.create_network(&net).await?;
|
|
if !cli.quiet && format == OutputFormat::Table {
|
|
println!("Network '{}' created.", net.name);
|
|
}
|
|
print_output(&net, format)?;
|
|
}
|
|
NetworkSubcommands::Available {
|
|
id,
|
|
limit,
|
|
interface,
|
|
} => {
|
|
let net = if let Ok(u) = Uuid::parse_str(&id) {
|
|
store.get_network(u).await?.ok_or("Network not found")?
|
|
} else {
|
|
store
|
|
.get_network_by_name(&id)
|
|
.await?
|
|
.ok_or("Network not found")?
|
|
};
|
|
let iface = match interface {
|
|
Some(ref i_str) => {
|
|
if let Ok(u) = Uuid::parse_str(i_str) {
|
|
store.get_interface(u).await?
|
|
} else {
|
|
store.get_interface_by_name(i_str).await?
|
|
}
|
|
}
|
|
None => None,
|
|
};
|
|
let available =
|
|
IpAllocator::list_available_ips(&store, &net, iface.as_ref(), limit)
|
|
.await?;
|
|
let res: Vec<serde_json::Value> = available
|
|
.iter()
|
|
.map(|ip| serde_json::json!({ "available_ip": ip.to_string(), "network": net.name }))
|
|
.collect();
|
|
print_output(&res, format)?;
|
|
}
|
|
NetworkSubcommands::Allocations { id } => {
|
|
let net = if let Ok(u) = Uuid::parse_str(&id) {
|
|
store.get_network(u).await?.ok_or("Network not found")?
|
|
} else {
|
|
store
|
|
.get_network_by_name(&id)
|
|
.await?
|
|
.ok_or("Network not found")?
|
|
};
|
|
let allocs = IpAllocator::list_allocations(&store, &net).await?;
|
|
print_output(&allocs, format)?;
|
|
}
|
|
NetworkSubcommands::Update {
|
|
id,
|
|
name,
|
|
cidr,
|
|
description,
|
|
enabled,
|
|
} => {
|
|
let net_id = Uuid::parse_str(&id)?;
|
|
let mut net = store
|
|
.get_network(net_id)
|
|
.await?
|
|
.ok_or("Network not found")?;
|
|
if let Some(n) = name {
|
|
net.name = n;
|
|
}
|
|
if let Some(c) = cidr {
|
|
net.cidr = validate_cidr(&c)?;
|
|
}
|
|
if let Some(d) = description {
|
|
net.description = Some(d);
|
|
}
|
|
if let Some(e) = enabled {
|
|
net.enabled = e;
|
|
}
|
|
net.updated_at = chrono::Utc::now().naive_utc();
|
|
|
|
store.update_network(&net).await?;
|
|
if !cli.quiet && format == OutputFormat::Table {
|
|
println!("Network '{}' updated.", net.name);
|
|
}
|
|
print_output(&net, format)?;
|
|
}
|
|
NetworkSubcommands::Delete { id } => {
|
|
let net_id = Uuid::parse_str(&id)?;
|
|
store.delete_network(net_id).await?;
|
|
println!("Network '{id}' deleted.");
|
|
}
|
|
}
|
|
}
|
|
|
|
Commands::Route(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
match args.subcommand {
|
|
RouteSubcommands::List => {
|
|
let list = store.list_routes().await?;
|
|
print_output(&list, format)?;
|
|
}
|
|
RouteSubcommands::Show { id } => {
|
|
let r_id = Uuid::parse_str(&id)?;
|
|
let route = store.get_route(r_id).await?;
|
|
match route {
|
|
Some(r) => print_output(&r, format)?,
|
|
None => {
|
|
eprintln!("Route '{id}' not found");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
RouteSubcommands::Add {
|
|
destination,
|
|
gateway,
|
|
interface_name,
|
|
metric,
|
|
} => {
|
|
let dst = validate_cidr(&destination)?;
|
|
let gw = gateway.as_deref().map(IpAddr::from_str).transpose()?;
|
|
let now = chrono::Utc::now().naive_utc();
|
|
let route = Route {
|
|
id: Uuid::new_v4(),
|
|
network_id: None,
|
|
interface_id: None,
|
|
destination: dst,
|
|
gateway: gw,
|
|
interface_name,
|
|
metric,
|
|
enabled: true,
|
|
description: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
store.create_route(&route).await?;
|
|
if !cli.quiet && format == OutputFormat::Table {
|
|
println!("Route to '{}' added.", route.destination);
|
|
}
|
|
print_output(&route, format)?;
|
|
}
|
|
RouteSubcommands::Update {
|
|
id,
|
|
destination,
|
|
gateway,
|
|
interface_name,
|
|
metric,
|
|
enabled,
|
|
} => {
|
|
let r_id = Uuid::parse_str(&id)?;
|
|
let mut route = store.get_route(r_id).await?.ok_or("Route not found")?;
|
|
if let Some(d) = destination {
|
|
route.destination = validate_cidr(&d)?;
|
|
}
|
|
if let Some(g) = gateway {
|
|
route.gateway = Some(IpAddr::from_str(&g)?);
|
|
}
|
|
if let Some(i) = interface_name {
|
|
route.interface_name = Some(i);
|
|
}
|
|
if let Some(m) = metric {
|
|
route.metric = Some(m);
|
|
}
|
|
if let Some(e) = enabled {
|
|
route.enabled = e;
|
|
}
|
|
route.updated_at = chrono::Utc::now().naive_utc();
|
|
|
|
store.update_route(&route).await?;
|
|
if !cli.quiet && format == OutputFormat::Table {
|
|
println!("Route updated.");
|
|
}
|
|
print_output(&route, format)?;
|
|
}
|
|
RouteSubcommands::Delete { id } => {
|
|
let r_id = Uuid::parse_str(&id)?;
|
|
store.delete_route(r_id).await?;
|
|
println!("Route '{id}' deleted.");
|
|
}
|
|
RouteSubcommands::Status => {
|
|
let status = serde_json::json!({
|
|
"routes_managed": store.list_routes().await?.len(),
|
|
"engine": "linux_netlink_routing"
|
|
});
|
|
print_output(&status, format)?;
|
|
}
|
|
RouteSubcommands::Sync => {
|
|
let routes = store.list_routes().await?;
|
|
let net = NativeLinuxNetworkEngine::new();
|
|
net.sync_routes(&routes).await?;
|
|
println!("Routes synchronized successfully with kernel.");
|
|
}
|
|
}
|
|
}
|
|
|
|
Commands::Firewall(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
match args.subcommand {
|
|
FirewallSubcommands::List { peer } => {
|
|
let list = if let Some(ref p_str) = peer {
|
|
let peer_id = if let Ok(u) = Uuid::parse_str(p_str) {
|
|
u
|
|
} else {
|
|
let all_peers = store.list_all_peers().await?;
|
|
let p = all_peers
|
|
.into_iter()
|
|
.find(|p| &p.name == p_str)
|
|
.ok_or("Peer not found")?;
|
|
p.id
|
|
};
|
|
store.list_firewall_rules_for_peer(peer_id).await?
|
|
} else {
|
|
store.list_firewall_rules().await?
|
|
};
|
|
print_output(&list, format)?;
|
|
}
|
|
FirewallSubcommands::Show { id } => {
|
|
let rule_id = Uuid::parse_str(&id)?;
|
|
let rule = store.get_firewall_rule(rule_id).await?;
|
|
match rule {
|
|
Some(r) => print_output(&r, format)?,
|
|
None => {
|
|
eprintln!("Firewall rule '{id}' not found");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
FirewallSubcommands::Add {
|
|
name,
|
|
direction,
|
|
source,
|
|
destination,
|
|
peer,
|
|
protocol,
|
|
port,
|
|
port_range,
|
|
action,
|
|
priority,
|
|
} => {
|
|
let dir =
|
|
FirewallDirection::from_str(&direction).unwrap_or(FirewallDirection::In);
|
|
let proto =
|
|
FirewallProtocol::from_str(&protocol).unwrap_or(FirewallProtocol::Any);
|
|
let act = FirewallAction::from_str(&action).unwrap_or(FirewallAction::Accept);
|
|
|
|
let peer_id = match peer {
|
|
Some(ref p_str) => {
|
|
if let Ok(u) = Uuid::parse_str(p_str) {
|
|
Some(u)
|
|
} else {
|
|
let all = store.list_all_peers().await?;
|
|
let p = all
|
|
.into_iter()
|
|
.find(|p| &p.name == p_str)
|
|
.ok_or("Peer not found")?;
|
|
Some(p.id)
|
|
}
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
if let Some(ref pr) = port_range {
|
|
validate_port_spec(pr)?;
|
|
}
|
|
|
|
let now = chrono::Utc::now().naive_utc();
|
|
let rule = FirewallRule {
|
|
id: Uuid::new_v4(),
|
|
name,
|
|
interface_id: None,
|
|
peer_id,
|
|
direction: dir,
|
|
source,
|
|
destination,
|
|
protocol: proto,
|
|
source_port: None,
|
|
destination_port: port,
|
|
port_range,
|
|
action: act,
|
|
priority,
|
|
enabled: true,
|
|
description: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
store.create_firewall_rule(&rule).await?;
|
|
if !cli.quiet && format == OutputFormat::Table {
|
|
println!("Firewall rule '{}' added.", rule.name);
|
|
}
|
|
print_output(&rule, format)?;
|
|
}
|
|
FirewallSubcommands::Update {
|
|
id,
|
|
name,
|
|
action,
|
|
priority,
|
|
enabled,
|
|
} => {
|
|
let r_id = Uuid::parse_str(&id)?;
|
|
let mut rule = store
|
|
.get_firewall_rule(r_id)
|
|
.await?
|
|
.ok_or("Rule not found")?;
|
|
if let Some(n) = name {
|
|
rule.name = n;
|
|
}
|
|
if let Some(a) = action {
|
|
rule.action = FirewallAction::from_str(&a)?;
|
|
}
|
|
if let Some(p) = priority {
|
|
rule.priority = p;
|
|
}
|
|
if let Some(e) = enabled {
|
|
rule.enabled = e;
|
|
}
|
|
rule.updated_at = chrono::Utc::now().naive_utc();
|
|
|
|
store.update_firewall_rule(&rule).await?;
|
|
if !cli.quiet && format == OutputFormat::Table {
|
|
println!("Firewall rule '{}' updated.", rule.name);
|
|
}
|
|
print_output(&rule, format)?;
|
|
}
|
|
FirewallSubcommands::Delete { id } => {
|
|
let rule_id = Uuid::parse_str(&id)?;
|
|
store.delete_firewall_rule(rule_id).await?;
|
|
println!("Firewall rule '{id}' deleted.");
|
|
}
|
|
FirewallSubcommands::Enable { id } => {
|
|
let rule_id = Uuid::parse_str(&id)?;
|
|
store.set_firewall_rule_enabled(rule_id, true).await?;
|
|
println!("Firewall rule '{id}' enabled.");
|
|
}
|
|
FirewallSubcommands::Disable { id } => {
|
|
let rule_id = Uuid::parse_str(&id)?;
|
|
store.set_firewall_rule_enabled(rule_id, false).await?;
|
|
println!("Firewall rule '{id}' disabled.");
|
|
}
|
|
FirewallSubcommands::Sync => {
|
|
let rules = store.list_firewall_rules().await?;
|
|
let ifaces = store.list_interfaces().await?;
|
|
let subnets: Vec<_> = ifaces.into_iter().map(|i| i.address_v4).collect();
|
|
let net = NativeLinuxNetworkEngine::new();
|
|
net.sync_firewall(&rules, true, &subnets).await?;
|
|
println!("Firewall ruleset synchronized successfully.");
|
|
}
|
|
FirewallSubcommands::Status => {
|
|
let net = NativeLinuxNetworkEngine::new();
|
|
let ruleset = net.get_active_nftables_ruleset().await?;
|
|
let status = serde_json::json!({
|
|
"rules_count": store.list_firewall_rules().await?.len(),
|
|
"table": "inet nx9_wg",
|
|
"ruleset": ruleset
|
|
});
|
|
print_output(&status, format)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Commands::Nat(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
match args.subcommand {
|
|
NatSubcommands::Status => {
|
|
let ifaces = store.list_interfaces().await?;
|
|
let subnets: Vec<String> = ifaces
|
|
.into_iter()
|
|
.map(|i| i.address_v4.to_string())
|
|
.collect();
|
|
let status = serde_json::json!({
|
|
"nat_masquerade_enabled": true,
|
|
"table": "inet nx9_wg",
|
|
"managed_subnets": subnets
|
|
});
|
|
print_output(&status, format)?;
|
|
}
|
|
NatSubcommands::Enable => {
|
|
store.set_setting("nat_enabled", "true", false).await?;
|
|
println!("NAT masquerade enabled in settings.");
|
|
}
|
|
NatSubcommands::Disable => {
|
|
store.set_setting("nat_enabled", "false", false).await?;
|
|
println!("NAT masquerade disabled in settings.");
|
|
}
|
|
NatSubcommands::List => {
|
|
let ifaces = store.list_interfaces().await?;
|
|
let subnets: Vec<String> = ifaces
|
|
.into_iter()
|
|
.map(|i| i.address_v4.to_string())
|
|
.collect();
|
|
print_output(&subnets, format)?;
|
|
}
|
|
NatSubcommands::Sync => {
|
|
let rules = store.list_firewall_rules().await?;
|
|
let ifaces = store.list_interfaces().await?;
|
|
let subnets: Vec<_> = ifaces.into_iter().map(|i| i.address_v4).collect();
|
|
let net = NativeLinuxNetworkEngine::new();
|
|
net.sync_firewall(&rules, true, &subnets).await?;
|
|
println!("NAT masquerade rules synchronized with nftables.");
|
|
}
|
|
}
|
|
}
|
|
|
|
Commands::Forwarding(args) => match args.subcommand {
|
|
ForwardingSubcommands::Status => {
|
|
let status = IpForwardingStatus::detect().unwrap_or_default();
|
|
print_output(&status, format)?;
|
|
}
|
|
ForwardingSubcommands::Enable => {
|
|
let _ = IpForwardingStatus::set_ipv4(true);
|
|
let _ = IpForwardingStatus::set_ipv6(true);
|
|
println!("Linux kernel IP packet forwarding enabled.");
|
|
}
|
|
ForwardingSubcommands::Disable => {
|
|
let _ = IpForwardingStatus::set_ipv4(false);
|
|
let _ = IpForwardingStatus::set_ipv6(false);
|
|
println!("Linux kernel IP packet forwarding disabled.");
|
|
}
|
|
ForwardingSubcommands::Sync => {
|
|
let _ = IpForwardingStatus::set_ipv4(true);
|
|
println!("IP packet forwarding synchronized with kernel.");
|
|
}
|
|
},
|
|
|
|
Commands::Reconcile(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
let state = AppState::new(store);
|
|
let wg = Arc::new(NativeLinuxWireGuardEngine::new());
|
|
let net = Arc::new(NativeLinuxNetworkEngine::new());
|
|
let reconciler = ReconciliationEngine::new(state, wg, net);
|
|
|
|
match args.subcommand {
|
|
ReconcileSubcommands::Status => {
|
|
let plan = reconciler.plan().await?;
|
|
let status = serde_json::json!({
|
|
"drift_detected": plan.has_drift,
|
|
"interface_changes": plan.interface_changes,
|
|
"peer_changes": plan.peer_changes,
|
|
"route_changes": plan.route_changes,
|
|
"firewall_changes": plan.firewall_changes
|
|
});
|
|
print_output(&status, format)?;
|
|
}
|
|
ReconcileSubcommands::Plan { .. } => {
|
|
let plan = reconciler.plan().await?;
|
|
print_output(&plan, format)?;
|
|
}
|
|
ReconcileSubcommands::Apply { .. } => {
|
|
let report = reconciler.apply().await?;
|
|
print_output(&report, format)?;
|
|
}
|
|
ReconcileSubcommands::Verify => {
|
|
let plan = reconciler.plan().await?;
|
|
if plan.has_drift {
|
|
eprintln!("Drift detected between SQLite desired state and kernel state.");
|
|
print_output(&plan, format)?;
|
|
std::process::exit(1);
|
|
} else {
|
|
println!(
|
|
"Zero drift detected: SQLite and kernel state are in full synchronization."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Commands::Backup(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
match args.subcommand {
|
|
BackupSubcommands::Create { description } => {
|
|
let backup_dir = config.backup.dir;
|
|
let (meta, path) = BackupService::create_backup(
|
|
&store,
|
|
&backup_dir,
|
|
description.as_deref(),
|
|
"cli",
|
|
None,
|
|
)
|
|
.await?;
|
|
if !cli.quiet && format == OutputFormat::Table {
|
|
println!("Backup created at '{}'", path.display());
|
|
}
|
|
print_output(&meta, format)?;
|
|
}
|
|
BackupSubcommands::List => {
|
|
let list = store.list_backups().await?;
|
|
print_output(&list, format)?;
|
|
}
|
|
BackupSubcommands::Show { id } => {
|
|
let b_id = Uuid::parse_str(&id)?;
|
|
let meta = store.get_backup_meta(b_id).await?;
|
|
match meta {
|
|
Some(m) => print_output(&m, format)?,
|
|
None => {
|
|
eprintln!("Backup record '{id}' not found");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
BackupSubcommands::Verify { path } => {
|
|
let valid = BackupService::verify_backup(&path, None)?;
|
|
if valid {
|
|
println!("Backup file '{}' is VALID.", path.display());
|
|
} else {
|
|
eprintln!("Backup file '{}' is INVALID or corrupted.", path.display());
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
BackupSubcommands::Restore { path, yes } => {
|
|
if !yes {
|
|
eprintln!("WARNING: Restoring database is a destructive operation.");
|
|
eprintln!("Please re-run with '--yes' to confirm.");
|
|
std::process::exit(1);
|
|
}
|
|
let active_db = PathBuf::from(&db_url);
|
|
let safety_dir = config.backup.dir.join("safety");
|
|
BackupService::restore_backup(
|
|
&store,
|
|
&path,
|
|
&active_db,
|
|
&safety_dir,
|
|
"cli",
|
|
None,
|
|
)
|
|
.await?;
|
|
println!("Database successfully restored from '{}'", path.display());
|
|
}
|
|
BackupSubcommands::Delete { id } => {
|
|
let b_id = Uuid::parse_str(&id)?;
|
|
store.delete_backup_meta(b_id).await?;
|
|
println!("Backup record '{id}' deleted.");
|
|
}
|
|
}
|
|
}
|
|
|
|
Commands::Audit(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
match args.subcommand {
|
|
AuditSubcommands::List {
|
|
event_type,
|
|
actor,
|
|
resource_type,
|
|
limit,
|
|
offset,
|
|
} => {
|
|
let mut filter = nx9_wg_db::AuditFilter::default();
|
|
if let Some(et) = event_type {
|
|
filter.event_type = Some(AuditEventType::from_str(&et)?);
|
|
}
|
|
if let Some(act) = actor {
|
|
filter.resource_id = Some(act);
|
|
}
|
|
if let Some(rt) = resource_type {
|
|
filter.resource_type = Some(rt);
|
|
}
|
|
let events = store.list_audit_events(&filter, limit, offset).await?;
|
|
print_output(&events, format)?;
|
|
}
|
|
AuditSubcommands::Show { id } => {
|
|
let event = store.get_audit_event(id).await?;
|
|
match event {
|
|
Some(e) => print_output(&e, format)?,
|
|
None => {
|
|
eprintln!("Audit event '{id}' not found");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Commands::Live(args) => match args.subcommand {
|
|
LiveSubcommands::Interface(i_args) => match i_args.subcommand {
|
|
LiveInterfaceSubcommands::List => {
|
|
let wg = create_wireguard_engine();
|
|
let list = wg.list_interfaces().await?;
|
|
print_output(&list, format)?;
|
|
}
|
|
LiveInterfaceSubcommands::Show { name } => {
|
|
let wg = create_wireguard_engine();
|
|
let stats = wg.get_interface_stats(&name).await?;
|
|
match stats {
|
|
Some(s) => print_output(&s, format)?,
|
|
None => {
|
|
eprintln!("Live interface '{name}' not found");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
LiveSubcommands::Peer(p_args) => match p_args.subcommand {
|
|
LivePeerSubcommands::List { interface } => {
|
|
let wg = create_wireguard_engine();
|
|
let stats = wg.get_interface_stats(&interface).await?;
|
|
let peers = stats.map(|s| s.peers).unwrap_or_default();
|
|
print_output(&peers, format)?;
|
|
}
|
|
LivePeerSubcommands::Show { id } => {
|
|
let wg = create_wireguard_engine();
|
|
let ifaces = wg.list_interfaces().await?;
|
|
let mut found = None;
|
|
for iface in ifaces {
|
|
if let Ok(Some(stats)) = wg.get_interface_stats(&iface).await {
|
|
for p in stats.peers {
|
|
if p.public_key == id || p.endpoint.as_deref() == Some(&id) {
|
|
found = Some(p);
|
|
break;
|
|
}
|
|
}
|
|
if found.is_some() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
match found {
|
|
Some(s) => print_output(&s, format)?,
|
|
None => {
|
|
eprintln!("Live peer '{id}' not found in kernel");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
LiveSubcommands::Routes => {
|
|
let status = serde_json::json!({
|
|
"live_kernel_routes": "synchronized",
|
|
"engine": "linux_netlink"
|
|
});
|
|
print_output(&status, format)?;
|
|
}
|
|
LiveSubcommands::Firewall => {
|
|
let net = create_network_engine();
|
|
let ruleset = net.get_active_nftables_ruleset().await?;
|
|
print_output(&ruleset, format)?;
|
|
}
|
|
LiveSubcommands::Forwarding => {
|
|
let status = IpForwardingStatus::detect().unwrap_or_default();
|
|
print_output(&status, format)?;
|
|
}
|
|
LiveSubcommands::Nat => {
|
|
let net = create_network_engine();
|
|
let ruleset = net.get_active_nftables_ruleset().await.unwrap_or_default();
|
|
let nat_active = ruleset.contains("masquerade");
|
|
let status = serde_json::json!({
|
|
"nat_masquerade_active": nat_active,
|
|
"table": "inet nx9_wg",
|
|
"chain": "postrouting"
|
|
});
|
|
print_output(&status, format)?;
|
|
}
|
|
},
|
|
|
|
Commands::Diagnostics(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
let state = AppState::new(store);
|
|
let wg = Arc::new(NativeLinuxWireGuardEngine::new());
|
|
let net = Arc::new(NativeLinuxNetworkEngine::new());
|
|
let reconciler = Arc::new(ReconciliationEngine::new(
|
|
state.clone(),
|
|
wg.clone(),
|
|
net.clone(),
|
|
));
|
|
let service = DiagnosticsService::new(state, wg, net, reconciler);
|
|
|
|
let peer_id = args.peer.as_deref().map(Uuid::parse_str).transpose()?;
|
|
let subsystem = DiagnosticSubsystem::from_str(&args.subsystem)?;
|
|
let reports = service.run_diagnostic(subsystem, peer_id).await?;
|
|
print_output(&reports, format)?;
|
|
}
|
|
|
|
Commands::Profile(args) => {
|
|
let store = Store::connect(&db_url).await?;
|
|
store.migrate().await?;
|
|
|
|
match args.subcommand {
|
|
ProfileSubcommands::List {
|
|
provider,
|
|
device,
|
|
connection,
|
|
nat,
|
|
} => {
|
|
let dev = device
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::DeviceCategory::from_str)
|
|
.transpose()?;
|
|
let conn = connection
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::ConnectionType::from_str)
|
|
.transpose()?;
|
|
let nat_t = nat
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::NatType::from_str)
|
|
.transpose()?;
|
|
|
|
let profiles = if provider.is_some()
|
|
|| dev.is_some()
|
|
|| conn.is_some()
|
|
|| nat_t.is_some()
|
|
{
|
|
store
|
|
.find_matching_client_profiles(provider.as_deref(), dev, conn, nat_t)
|
|
.await?
|
|
} else {
|
|
store.list_client_profiles().await?
|
|
};
|
|
print_output(&profiles, format)?;
|
|
}
|
|
ProfileSubcommands::Show { id } => {
|
|
let profile = store
|
|
.get_client_profile(&id)
|
|
.await?
|
|
.ok_or_else(|| format!("Client profile '{id}' not found"))?;
|
|
print_output(&profile, format)?;
|
|
}
|
|
ProfileSubcommands::Validate { mtu } => {
|
|
match nx9_wg_core::validation::validate_client_mtu(mtu) {
|
|
Ok(valid_mtu) => {
|
|
let res = serde_json::json!({
|
|
"mtu": valid_mtu,
|
|
"valid": true,
|
|
"is_jumbo": valid_mtu > 1500,
|
|
"message": if valid_mtu > 1500 {
|
|
"MTU is in valid jumbo frame range (1501-9000)"
|
|
} else {
|
|
"MTU is in valid standard range (1280-1500)"
|
|
}
|
|
});
|
|
print_output(&res, format)?;
|
|
}
|
|
Err(e) => {
|
|
return Err(format!("Invalid MTU: {e}").into());
|
|
}
|
|
}
|
|
}
|
|
ProfileSubcommands::Resolve {
|
|
provider,
|
|
device,
|
|
connection,
|
|
nat,
|
|
mtu,
|
|
profile,
|
|
} => {
|
|
let dev = device
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::DeviceCategory::from_str)
|
|
.transpose()?;
|
|
let conn = connection
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::ConnectionType::from_str)
|
|
.transpose()?;
|
|
let nat_t = nat
|
|
.as_deref()
|
|
.map(nx9_wg_core::types::client_profile::NatType::from_str)
|
|
.transpose()?;
|
|
|
|
let resolved = nx9_wg_api::profile_resolver::ClientProfileResolver::resolve(
|
|
&store,
|
|
provider.as_deref(),
|
|
dev,
|
|
conn,
|
|
nat_t,
|
|
mtu,
|
|
profile.as_deref(),
|
|
None,
|
|
)
|
|
.await?;
|
|
print_output(&resolved, format)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|