Add Tailscale network support for host connections
All checks were successful
Build and Release / build-and-release (push) Successful in 1m31s
All checks were successful
Build and Release / build-and-release (push) Successful in 1m31s
Implement configurable network routing for both local and Tailscale networks. Dashboard now supports intelligent connection selection with automatic fallback between network types. Add IP configuration fields and connection routing logic for ZMQ and SSH operations. Features: - Host configuration with local and Tailscale IP addresses - Configurable connection types (local/tailscale/auto) - Automatic fallback between network connections - Updated ZMQ connection logic with retry support - SSH command routing through configured IP addresses
This commit is contained in:
parent
d31c2384df
commit
d33ec5d225
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -270,7 +270,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.65"
|
version = "0.1.66"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -292,7 +292,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.65"
|
version = "0.1.66"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@ -315,7 +315,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cm-dashboard-shared"
|
name = "cm-dashboard-shared"
|
||||||
version = "0.1.65"
|
version = "0.1.66"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-agent"
|
name = "cm-dashboard-agent"
|
||||||
version = "0.1.66"
|
version = "0.1.67"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard"
|
name = "cm-dashboard"
|
||||||
version = "0.1.66"
|
version = "0.1.67"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -67,11 +67,8 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Connect to configured hosts from configuration
|
|
||||||
let hosts: Vec<String> = config.hosts.keys().cloned().collect();
|
|
||||||
|
|
||||||
// Try to connect to hosts but don't fail if none are available
|
// Try to connect to hosts but don't fail if none are available
|
||||||
match zmq_consumer.connect_to_predefined_hosts(&hosts).await {
|
match zmq_consumer.connect_to_predefined_hosts(&config.hosts).await {
|
||||||
Ok(_) => info!("Successfully connected to ZMQ hosts"),
|
Ok(_) => info!("Successfully connected to ZMQ hosts"),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
|
|||||||
@ -84,13 +84,13 @@ impl ZmqConsumer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to predefined hosts
|
/// Connect to predefined hosts using their configuration
|
||||||
pub async fn connect_to_predefined_hosts(&mut self, hosts: &[String]) -> Result<()> {
|
pub async fn connect_to_predefined_hosts(&mut self, hosts: &std::collections::HashMap<String, crate::config::HostDetails>) -> Result<()> {
|
||||||
let default_port = self.config.subscriber_ports[0];
|
let default_port = self.config.subscriber_ports[0];
|
||||||
|
|
||||||
for hostname in hosts {
|
for (hostname, host_details) in hosts {
|
||||||
// Try to connect, but don't fail if some hosts are unreachable
|
// Try to connect using configured IP, but don't fail if some hosts are unreachable
|
||||||
if let Err(e) = self.connect_to_host(hostname, default_port).await {
|
if let Err(e) = self.connect_to_host_with_details(hostname, host_details, default_port).await {
|
||||||
warn!("Could not connect to {}: {}", hostname, e);
|
warn!("Could not connect to {}: {}", hostname, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -104,6 +104,29 @@ impl ZmqConsumer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Connect to a host using its configuration details with fallback support
|
||||||
|
pub async fn connect_to_host_with_details(&mut self, hostname: &str, host_details: &crate::config::HostDetails, port: u16) -> Result<()> {
|
||||||
|
// Get primary connection IP
|
||||||
|
let primary_ip = host_details.get_connection_ip(hostname);
|
||||||
|
|
||||||
|
// Try primary connection
|
||||||
|
if let Ok(()) = self.connect_to_host(&primary_ip, port).await {
|
||||||
|
info!("Connected to {} via primary address: {}", hostname, primary_ip);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try fallback IPs if primary fails
|
||||||
|
let fallbacks = host_details.get_fallback_ips(hostname);
|
||||||
|
for fallback_ip in fallbacks {
|
||||||
|
if let Ok(()) = self.connect_to_host(&fallback_ip, port).await {
|
||||||
|
info!("Connected to {} via fallback address: {}", hostname, fallback_ip);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow::anyhow!("Failed to connect to {} using all available addresses", hostname))
|
||||||
|
}
|
||||||
|
|
||||||
/// Receive command output from any connected agent (non-blocking)
|
/// Receive command output from any connected agent (non-blocking)
|
||||||
pub async fn receive_command_output(&mut self) -> Result<Option<CommandOutputMessage>> {
|
pub async fn receive_command_output(&mut self) -> Result<Option<CommandOutputMessage>> {
|
||||||
match self.subscriber.recv_bytes(zmq::DONTWAIT) {
|
match self.subscriber.recv_bytes(zmq::DONTWAIT) {
|
||||||
|
|||||||
@ -29,6 +29,77 @@ fn default_heartbeat_timeout_seconds() -> u64 {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct HostDetails {
|
pub struct HostDetails {
|
||||||
pub mac_address: Option<String>,
|
pub mac_address: Option<String>,
|
||||||
|
/// Primary IP address (local network)
|
||||||
|
pub ip: Option<String>,
|
||||||
|
/// Tailscale network IP address
|
||||||
|
pub tailscale_ip: Option<String>,
|
||||||
|
/// Preferred connection type: "local", "tailscale", or "auto" (fallback)
|
||||||
|
#[serde(default = "default_connection_type")]
|
||||||
|
pub connection_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_connection_type() -> String {
|
||||||
|
"auto".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostDetails {
|
||||||
|
/// Get the preferred IP address for connection based on connection_type
|
||||||
|
pub fn get_connection_ip(&self, hostname: &str) -> String {
|
||||||
|
match self.connection_type.as_str() {
|
||||||
|
"tailscale" => {
|
||||||
|
if let Some(ref ts_ip) = self.tailscale_ip {
|
||||||
|
ts_ip.clone()
|
||||||
|
} else {
|
||||||
|
// Fallback to local IP or hostname
|
||||||
|
self.ip.as_ref().unwrap_or(&hostname.to_string()).clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"local" => {
|
||||||
|
if let Some(ref local_ip) = self.ip {
|
||||||
|
local_ip.clone()
|
||||||
|
} else {
|
||||||
|
hostname.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"auto" | _ => {
|
||||||
|
// Try tailscale first, then local, then hostname
|
||||||
|
if let Some(ref ts_ip) = self.tailscale_ip {
|
||||||
|
ts_ip.clone()
|
||||||
|
} else if let Some(ref local_ip) = self.ip {
|
||||||
|
local_ip.clone()
|
||||||
|
} else {
|
||||||
|
hostname.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get fallback IP addresses for connection retry
|
||||||
|
pub fn get_fallback_ips(&self, hostname: &str) -> Vec<String> {
|
||||||
|
let mut fallbacks = Vec::new();
|
||||||
|
|
||||||
|
// Add all available IPs except the primary one
|
||||||
|
let primary = self.get_connection_ip(hostname);
|
||||||
|
|
||||||
|
if let Some(ref ts_ip) = self.tailscale_ip {
|
||||||
|
if ts_ip != &primary {
|
||||||
|
fallbacks.push(ts_ip.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref local_ip) = self.ip {
|
||||||
|
if local_ip != &primary {
|
||||||
|
fallbacks.push(local_ip.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include hostname as final fallback if not already primary
|
||||||
|
if hostname != primary {
|
||||||
|
fallbacks.push(hostname.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbacks
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// System configuration
|
/// System configuration
|
||||||
|
|||||||
@ -251,12 +251,14 @@ impl TuiApp {
|
|||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
// System rebuild command - works on any panel for current host
|
// System rebuild command - works on any panel for current host
|
||||||
if let Some(hostname) = self.current_host.clone() {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
// Create command that shows logo, rebuilds, and waits for user input
|
// Create command that shows logo, rebuilds, and waits for user input
|
||||||
let logo_and_rebuild = format!(
|
let logo_and_rebuild = format!(
|
||||||
"bash -c 'cat << \"EOF\"\nNixOS System Rebuild\nTarget: {}\n\nEOF\nssh -tt {}@{} \"bash -ic {}\"\necho\necho \"========================================\"\necho \"Rebuild completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'",
|
"bash -c 'cat << \"EOF\"\nNixOS System Rebuild\nTarget: {} ({})\n\nEOF\nssh -tt {}@{} \"bash -ic {}\"\necho\necho \"========================================\"\necho \"Rebuild completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'",
|
||||||
hostname,
|
hostname,
|
||||||
|
connection_ip,
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
hostname,
|
connection_ip,
|
||||||
self.config.ssh.rebuild_alias
|
self.config.ssh.rebuild_alias
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -289,10 +291,11 @@ impl TuiApp {
|
|||||||
KeyCode::Char('J') => {
|
KeyCode::Char('J') => {
|
||||||
// Show service logs via journalctl in tmux split window
|
// Show service logs via journalctl in tmux split window
|
||||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||||
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
let journalctl_command = format!(
|
let journalctl_command = format!(
|
||||||
"bash -c \"ssh -tt {}@{} 'sudo journalctl -u {}.service -f --no-pager -n 50'; exit\"",
|
"bash -c \"ssh -tt {}@{} 'sudo journalctl -u {}.service -f --no-pager -n 50'; exit\"",
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
hostname,
|
connection_ip,
|
||||||
service_name
|
service_name
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -312,10 +315,11 @@ impl TuiApp {
|
|||||||
// Check if this service has a custom log file configured
|
// Check if this service has a custom log file configured
|
||||||
if let Some(host_logs) = self.config.service_logs.get(&hostname) {
|
if let Some(host_logs) = self.config.service_logs.get(&hostname) {
|
||||||
if let Some(log_config) = host_logs.iter().find(|config| config.service_name == service_name) {
|
if let Some(log_config) = host_logs.iter().find(|config| config.service_name == service_name) {
|
||||||
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
let tail_command = format!(
|
let tail_command = format!(
|
||||||
"bash -c \"ssh -tt {}@{} 'sudo tail -n 50 -f {}'; exit\"",
|
"bash -c \"ssh -tt {}@{} 'sudo tail -n 50 -f {}'; exit\"",
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
hostname,
|
connection_ip,
|
||||||
log_config.log_file_path
|
log_config.log_file_path
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -368,10 +372,11 @@ impl TuiApp {
|
|||||||
KeyCode::Char('t') => {
|
KeyCode::Char('t') => {
|
||||||
// Open SSH terminal session in tmux window
|
// Open SSH terminal session in tmux window
|
||||||
if let Some(hostname) = self.current_host.clone() {
|
if let Some(hostname) = self.current_host.clone() {
|
||||||
|
let connection_ip = self.get_connection_ip(&hostname);
|
||||||
let ssh_command = format!(
|
let ssh_command = format!(
|
||||||
"ssh -tt {}@{}",
|
"ssh -tt {}@{}",
|
||||||
self.config.ssh.rebuild_user,
|
self.config.ssh.rebuild_user,
|
||||||
hostname
|
connection_ip
|
||||||
);
|
);
|
||||||
|
|
||||||
std::process::Command::new("tmux")
|
std::process::Command::new("tmux")
|
||||||
@ -917,6 +922,15 @@ impl TuiApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse MAC address string (e.g., "AA:BB:CC:DD:EE:FF") to [u8; 6]
|
/// Parse MAC address string (e.g., "AA:BB:CC:DD:EE:FF") to [u8; 6]
|
||||||
|
/// Get the connection IP for a hostname based on host configuration
|
||||||
|
fn get_connection_ip(&self, hostname: &str) -> String {
|
||||||
|
if let Some(host_details) = self.config.hosts.get(hostname) {
|
||||||
|
host_details.get_connection_ip(hostname)
|
||||||
|
} else {
|
||||||
|
hostname.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_mac_address(mac_str: &str) -> Result<[u8; 6], &'static str> {
|
fn parse_mac_address(mac_str: &str) -> Result<[u8; 6], &'static str> {
|
||||||
let parts: Vec<&str> = mac_str.split(':').collect();
|
let parts: Vec<&str> = mac_str.split(':').collect();
|
||||||
if parts.len() != 6 {
|
if parts.len() != 6 {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cm-dashboard-shared"
|
name = "cm-dashboard-shared"
|
||||||
version = "0.1.66"
|
version = "0.1.67"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user