diff --git a/Cargo.lock b/Cargo.lock index 2af94a0..7d9cdcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -429,6 +429,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "assertor" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff24d87260733dc86d38a11c60d9400ce4a74a05d0dafa2a6f5ab249cd857cb" +dependencies = [ + "num-traits", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -665,6 +674,22 @@ dependencies = [ "serde_with", ] +[[package]] +name = "brocade" +version = "0.1.0" +dependencies = [ + "async-trait", + "env_logger", + "harmony_secret", + "harmony_types", + "log", + "regex", + "russh", + "russh-keys", + "serde", + "tokio", +] + [[package]] name = "brotli" version = "8.0.2" @@ -1755,6 +1780,7 @@ dependencies = [ name = "example-nanodc" version = "0.1.0" dependencies = [ + "brocade", "cidr", "env_logger", "harmony", @@ -1763,6 +1789,7 @@ dependencies = [ "harmony_tui", "harmony_types", "log", + "serde", "tokio", "url", ] @@ -1781,6 +1808,7 @@ dependencies = [ name = "example-okd-install" version = "0.1.0" dependencies = [ + "brocade", "cidr", "env_logger", "harmony", @@ -1795,17 +1823,32 @@ dependencies = [ "url", ] +[[package]] +name = "example-openbao" +version = "0.1.0" +dependencies = [ + "harmony", + "harmony_cli", + "harmony_macros", + "harmony_types", + "tokio", + "url", +] + [[package]] name = "example-opnsense" version = "0.1.0" dependencies = [ + "brocade", "cidr", "env_logger", "harmony", "harmony_macros", + "harmony_secret", "harmony_tui", "harmony_types", "log", + "serde", "tokio", "url", ] @@ -1814,6 +1857,7 @@ dependencies = [ name = "example-pxe" version = "0.1.0" dependencies = [ + "brocade", "cidr", "env_logger", "harmony", @@ -1828,6 +1872,15 @@ dependencies = [ "url", ] +[[package]] +name = "example-remove-rook-osd" +version = "0.1.0" +dependencies = [ + "harmony", + "harmony_cli", + "tokio", +] + [[package]] name = "example-rust" version = "0.1.0" @@ -2305,9 +2358,11 @@ name = "harmony" version = "0.1.0" dependencies = [ "askama", + "assertor", "async-trait", "base64 0.22.1", "bollard", + "brocade", "chrono", "cidr", "convert_case", @@ -2338,6 +2393,7 @@ dependencies = [ "once_cell", "opnsense-config", "opnsense-config-xml", + "option-ext", "pretty_assertions", "reqwest 0.11.27", "russh", @@ -3878,6 +3934,7 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" name = "opnsense-config" version = "0.1.0" dependencies = [ + "assertor", "async-trait", "chrono", "env_logger", @@ -4537,9 +4594,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick 1.1.3", "memchr", @@ -4549,9 +4606,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick 1.1.3", "memchr", diff --git a/Cargo.toml b/Cargo.toml index d92c0e7..a256234 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,9 @@ members = [ "harmony_composer", "harmony_inventory_agent", "harmony_secret_derive", - "harmony_secret", "adr/agent_discovery/mdns", + "harmony_secret", + "adr/agent_discovery/mdns", + "brocade", ] [workspace.package] @@ -66,5 +68,12 @@ thiserror = "2.0.14" serde = { version = "1.0.209", features = ["derive", "rc"] } serde_json = "1.0.127" askama = "0.14" -sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite" ] } -reqwest = { version = "0.12", features = ["blocking", "stream", "rustls-tls", "http2", "json"], default-features = false } +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } +reqwest = { version = "0.12", features = [ + "blocking", + "stream", + "rustls-tls", + "http2", + "json", +], default-features = false } +assertor = "0.0.4" diff --git a/brocade/Cargo.toml b/brocade/Cargo.toml new file mode 100644 index 0000000..89c4fb8 --- /dev/null +++ b/brocade/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "brocade" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true + +[dependencies] +async-trait.workspace = true +harmony_types = { path = "../harmony_types" } +russh.workspace = true +russh-keys.workspace = true +tokio.workspace = true +log.workspace = true +env_logger.workspace = true +regex = "1.11.3" +harmony_secret = { path = "../harmony_secret" } +serde.workspace = true diff --git a/brocade/examples/main.rs b/brocade/examples/main.rs new file mode 100644 index 0000000..34dec21 --- /dev/null +++ b/brocade/examples/main.rs @@ -0,0 +1,70 @@ +use std::net::{IpAddr, Ipv4Addr}; + +use brocade::BrocadeOptions; +use harmony_secret::{Secret, SecretManager}; +use harmony_types::switch::PortLocation; +use serde::{Deserialize, Serialize}; + +#[derive(Secret, Clone, Debug, Serialize, Deserialize)] +struct BrocadeSwitchAuth { + username: String, + password: String, +} + +#[tokio::main] +async fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + // let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 250)); // old brocade @ ianlet + let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 55, 101)); // brocade @ sto1 + // let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 4, 11)); // brocade @ st + let switch_addresses = vec![ip]; + + let config = SecretManager::get_or_prompt::() + .await + .unwrap(); + + let brocade = brocade::init( + &switch_addresses, + 22, + &config.username, + &config.password, + Some(BrocadeOptions { + dry_run: true, + ..Default::default() + }), + ) + .await + .expect("Brocade client failed to connect"); + + let entries = brocade.get_stack_topology().await.unwrap(); + println!("Stack topology: {entries:#?}"); + + let entries = brocade.get_interfaces().await.unwrap(); + println!("Interfaces: {entries:#?}"); + + let version = brocade.version().await.unwrap(); + println!("Version: {version:?}"); + + println!("--------------"); + let mac_adddresses = brocade.get_mac_address_table().await.unwrap(); + println!("VLAN\tMAC\t\t\tPORT"); + for mac in mac_adddresses { + println!("{}\t{}\t{}", mac.vlan, mac.mac_address, mac.port); + } + + println!("--------------"); + let channel_name = "1"; + brocade.clear_port_channel(channel_name).await.unwrap(); + + println!("--------------"); + let channel_id = brocade.find_available_channel_id().await.unwrap(); + + println!("--------------"); + let channel_name = "HARMONY_LAG"; + let ports = [PortLocation(2, 0, 35)]; + brocade + .create_port_channel(channel_id, channel_name, &ports) + .await + .unwrap(); +} diff --git a/brocade/src/fast_iron.rs b/brocade/src/fast_iron.rs new file mode 100644 index 0000000..5a3474e --- /dev/null +++ b/brocade/src/fast_iron.rs @@ -0,0 +1,212 @@ +use super::BrocadeClient; +use crate::{ + BrocadeInfo, Error, ExecutionMode, InterSwitchLink, InterfaceInfo, MacAddressEntry, + PortChannelId, PortOperatingMode, parse_brocade_mac_address, shell::BrocadeShell, +}; + +use async_trait::async_trait; +use harmony_types::switch::{PortDeclaration, PortLocation}; +use log::{debug, info}; +use regex::Regex; +use std::{collections::HashSet, str::FromStr}; + +#[derive(Debug)] +pub struct FastIronClient { + shell: BrocadeShell, + version: BrocadeInfo, +} + +impl FastIronClient { + pub fn init(mut shell: BrocadeShell, version_info: BrocadeInfo) -> Self { + shell.before_all(vec!["skip-page-display".into()]); + shell.after_all(vec!["page".into()]); + + Self { + shell, + version: version_info, + } + } + + fn parse_mac_entry(&self, line: &str) -> Option> { + debug!("[Brocade] Parsing mac address entry: {line}"); + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 3 { + return None; + } + + let (vlan, mac_address, port) = match parts.len() { + 3 => ( + u16::from_str(parts[0]).ok()?, + parse_brocade_mac_address(parts[1]).ok()?, + parts[2].to_string(), + ), + _ => ( + 1, + parse_brocade_mac_address(parts[0]).ok()?, + parts[1].to_string(), + ), + }; + + let port = + PortDeclaration::parse(&port).map_err(|e| Error::UnexpectedError(format!("{e}"))); + + match port { + Ok(p) => Some(Ok(MacAddressEntry { + vlan, + mac_address, + port: p, + })), + Err(e) => Some(Err(e)), + } + } + + fn parse_stack_port_entry(&self, line: &str) -> Option> { + debug!("[Brocade] Parsing stack port entry: {line}"); + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 10 { + return None; + } + + let local_port = PortLocation::from_str(parts[0]).ok()?; + + Some(Ok(InterSwitchLink { + local_port, + remote_port: None, + })) + } + + fn build_port_channel_commands( + &self, + channel_id: PortChannelId, + channel_name: &str, + ports: &[PortLocation], + ) -> Vec { + let mut commands = vec![ + "configure terminal".to_string(), + format!("lag {channel_name} static id {channel_id}"), + ]; + + for port in ports { + commands.push(format!("ports ethernet {port}")); + } + + commands.push(format!("primary-port {}", ports[0])); + commands.push("deploy".into()); + commands.push("exit".into()); + commands.push("write memory".into()); + commands.push("exit".into()); + + commands + } +} + +#[async_trait] +impl BrocadeClient for FastIronClient { + async fn version(&self) -> Result { + Ok(self.version.clone()) + } + + async fn get_mac_address_table(&self) -> Result, Error> { + info!("[Brocade] Showing MAC address table..."); + + let output = self + .shell + .run_command("show mac-address", ExecutionMode::Regular) + .await?; + + output + .lines() + .skip(2) + .filter_map(|line| self.parse_mac_entry(line)) + .collect() + } + + async fn get_stack_topology(&self) -> Result, Error> { + let output = self + .shell + .run_command("show interface stack-ports", crate::ExecutionMode::Regular) + .await?; + + output + .lines() + .skip(1) + .filter_map(|line| self.parse_stack_port_entry(line)) + .collect() + } + + async fn get_interfaces(&self) -> Result, Error> { + todo!() + } + + async fn configure_interfaces( + &self, + _interfaces: Vec<(String, PortOperatingMode)>, + ) -> Result<(), Error> { + todo!() + } + + async fn find_available_channel_id(&self) -> Result { + info!("[Brocade] Finding next available channel id..."); + + let output = self + .shell + .run_command("show lag", ExecutionMode::Regular) + .await?; + let re = Regex::new(r"=== LAG .* ID\s+(\d+)").expect("Invalid regex"); + + let used_ids: HashSet = output + .lines() + .filter_map(|line| { + re.captures(line) + .and_then(|c| c.get(1)) + .and_then(|id_match| id_match.as_str().parse().ok()) + }) + .collect(); + + let mut next_id: u8 = 1; + loop { + if !used_ids.contains(&next_id) { + break; + } + next_id += 1; + } + + info!("[Brocade] Found channel id: {next_id}"); + Ok(next_id) + } + + async fn create_port_channel( + &self, + channel_id: PortChannelId, + channel_name: &str, + ports: &[PortLocation], + ) -> Result<(), Error> { + info!( + "[Brocade] Configuring port-channel '{channel_name} {channel_id}' with ports: {ports:?}" + ); + + let commands = self.build_port_channel_commands(channel_id, channel_name, ports); + self.shell + .run_commands(commands, ExecutionMode::Privileged) + .await?; + + info!("[Brocade] Port-channel '{channel_name}' configured."); + Ok(()) + } + + async fn clear_port_channel(&self, channel_name: &str) -> Result<(), Error> { + info!("[Brocade] Clearing port-channel: {channel_name}"); + + let commands = vec![ + "configure terminal".to_string(), + format!("no lag {channel_name}"), + "write memory".to_string(), + ]; + self.shell + .run_commands(commands, ExecutionMode::Privileged) + .await?; + + info!("[Brocade] Port-channel '{channel_name}' cleared."); + Ok(()) + } +} diff --git a/brocade/src/lib.rs b/brocade/src/lib.rs new file mode 100644 index 0000000..57b464a --- /dev/null +++ b/brocade/src/lib.rs @@ -0,0 +1,336 @@ +use std::net::IpAddr; +use std::{ + fmt::{self, Display}, + time::Duration, +}; + +use crate::network_operating_system::NetworkOperatingSystemClient; +use crate::{ + fast_iron::FastIronClient, + shell::{BrocadeSession, BrocadeShell}, +}; + +use async_trait::async_trait; +use harmony_types::net::MacAddress; +use harmony_types::switch::{PortDeclaration, PortLocation}; +use regex::Regex; + +mod fast_iron; +mod network_operating_system; +mod shell; +mod ssh; + +#[derive(Default, Clone, Debug)] +pub struct BrocadeOptions { + pub dry_run: bool, + pub ssh: ssh::SshOptions, + pub timeouts: TimeoutConfig, +} + +#[derive(Clone, Debug)] +pub struct TimeoutConfig { + pub shell_ready: Duration, + pub command_execution: Duration, + pub cleanup: Duration, + pub message_wait: Duration, +} + +impl Default for TimeoutConfig { + fn default() -> Self { + Self { + shell_ready: Duration::from_secs(10), + command_execution: Duration::from_secs(60), // Commands like `deploy` (for a LAG) can take a while + cleanup: Duration::from_secs(10), + message_wait: Duration::from_millis(500), + } + } +} + +enum ExecutionMode { + Regular, + Privileged, +} + +#[derive(Clone, Debug)] +pub struct BrocadeInfo { + os: BrocadeOs, + version: String, +} + +#[derive(Clone, Debug)] +pub enum BrocadeOs { + NetworkOperatingSystem, + FastIron, + Unknown, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +pub struct MacAddressEntry { + pub vlan: u16, + pub mac_address: MacAddress, + pub port: PortDeclaration, +} + +pub type PortChannelId = u8; + +/// Represents a single physical or logical link connecting two switches within a stack or fabric. +/// +/// This structure provides a standardized view of the topology regardless of the +/// underlying Brocade OS configuration (stacking vs. fabric). +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct InterSwitchLink { + /// The local port on the switch where the topology command was run. + pub local_port: PortLocation, + /// The port on the directly connected neighboring switch. + pub remote_port: Option, +} + +/// Represents the key running configuration status of a single switch interface. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct InterfaceInfo { + /// The full configuration name (e.g., "TenGigabitEthernet 1/0/1", "FortyGigabitEthernet 2/0/2"). + pub name: String, + /// The physical location of the interface. + pub port_location: PortLocation, + /// The parsed type and name prefix of the interface. + pub interface_type: InterfaceType, + /// The primary configuration mode defining the interface's behavior (L2, L3, Fabric). + pub operating_mode: Option, + /// Indicates the current state of the interface. + pub status: InterfaceStatus, +} + +/// Categorizes the functional type of a switch interface. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum InterfaceType { + /// Physical or virtual Ethernet interface (e.g., TenGigabitEthernet, FortyGigabitEthernet). + Ethernet(String), +} + +impl fmt::Display for InterfaceType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + InterfaceType::Ethernet(name) => write!(f, "{name}"), + } + } +} + +/// Defines the primary configuration mode of a switch interface, representing mutually exclusive roles. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum PortOperatingMode { + /// The interface is explicitly configured for Brocade fabric roles (ISL or Trunk enabled). + Fabric, + /// The interface is configured for standard Layer 2 switching as Trunk port (`switchport mode trunk`). + Trunk, + /// The interface is configured for standard Layer 2 switching as Access port (`switchport` without trunk mode). + Access, +} + +/// Defines the possible status of an interface. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum InterfaceStatus { + /// The interface is connected. + Connected, + /// The interface is not connected and is not expected to be. + NotConnected, + /// The interface is not connected but is expected to be (configured with `no shutdown`). + SfpAbsent, +} + +pub async fn init( + ip_addresses: &[IpAddr], + port: u16, + username: &str, + password: &str, + options: Option, +) -> Result, Error> { + let shell = BrocadeShell::init(ip_addresses, port, username, password, options).await?; + + let version_info = shell + .with_session(ExecutionMode::Regular, |session| { + Box::pin(get_brocade_info(session)) + }) + .await?; + + Ok(match version_info.os { + BrocadeOs::FastIron => Box::new(FastIronClient::init(shell, version_info)), + BrocadeOs::NetworkOperatingSystem => { + Box::new(NetworkOperatingSystemClient::init(shell, version_info)) + } + BrocadeOs::Unknown => todo!(), + }) +} + +#[async_trait] +pub trait BrocadeClient: std::fmt::Debug { + /// Retrieves the operating system and version details from the connected Brocade switch. + /// + /// This is typically the first call made after establishing a connection to determine + /// the switch OS family (e.g., FastIron, NOS) for feature compatibility. + /// + /// # Returns + /// + /// A `BrocadeInfo` structure containing parsed OS type and version string. + async fn version(&self) -> Result; + + /// Retrieves the dynamically learned MAC address table from the switch. + /// + /// This is crucial for discovering where specific network endpoints (MAC addresses) + /// are currently located on the physical ports. + /// + /// # Returns + /// + /// A vector of `MacAddressEntry`, where each entry typically contains VLAN, MAC address, + /// and the associated port name/index. + async fn get_mac_address_table(&self) -> Result, Error>; + + /// Derives the physical connections used to link multiple switches together + /// to form a single logical entity (stack, fabric, etc.). + /// + /// This abstracts the underlying configuration (e.g., stack ports, fabric ports) + /// to return a standardized view of the topology. + /// + /// # Returns + /// + /// A vector of `InterSwitchLink` structs detailing which ports are used for stacking/fabric. + /// If the switch is not stacked, returns an empty vector. + async fn get_stack_topology(&self) -> Result, Error>; + + /// Retrieves the status for all interfaces + /// + /// # Returns + /// + /// A vector of `InterfaceInfo` structures. + async fn get_interfaces(&self) -> Result, Error>; + + /// Configures a set of interfaces to be operated with a specified mode (access ports, ISL, etc.). + async fn configure_interfaces( + &self, + interfaces: Vec<(String, PortOperatingMode)>, + ) -> Result<(), Error>; + + /// Scans the existing configuration to find the next available (unused) + /// Port-Channel ID (`lag` or `trunk`) for assignment. + /// + /// # Returns + /// + /// The smallest, unassigned `PortChannelId` within the supported range. + async fn find_available_channel_id(&self) -> Result; + + /// Creates and configures a new Port-Channel (Link Aggregation Group or LAG) + /// using the specified channel ID and ports. + /// + /// The resulting configuration must be persistent (saved to startup-config). + /// Assumes a static LAG configuration mode unless specified otherwise by the implementation. + /// + /// # Parameters + /// + /// * `channel_id`: The ID (e.g., 1-128) for the logical port channel. + /// * `channel_name`: A descriptive name for the LAG (used in configuration context). + /// * `ports`: A slice of `PortLocation` structs defining the physical member ports. + async fn create_port_channel( + &self, + channel_id: PortChannelId, + channel_name: &str, + ports: &[PortLocation], + ) -> Result<(), Error>; + + /// Removes all configuration associated with the specified Port-Channel name. + /// + /// This operation should be idempotent; attempting to clear a non-existent + /// channel should succeed (or return a benign error). + /// + /// # Parameters + /// + /// * `channel_name`: The name of the Port-Channel (LAG) to delete. + /// + async fn clear_port_channel(&self, channel_name: &str) -> Result<(), Error>; +} + +async fn get_brocade_info(session: &mut BrocadeSession) -> Result { + let output = session.run_command("show version").await?; + + if output.contains("Network Operating System") { + let re = Regex::new(r"Network Operating System Version:\s*(?P[a-zA-Z0-9.\-]+)") + .expect("Invalid regex"); + let version = re + .captures(&output) + .and_then(|cap| cap.name("version")) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + + return Ok(BrocadeInfo { + os: BrocadeOs::NetworkOperatingSystem, + version, + }); + } else if output.contains("ICX") { + let re = Regex::new(r"(?m)^\s*SW: Version\s*(?P[a-zA-Z0-9.\-]+)") + .expect("Invalid regex"); + let version = re + .captures(&output) + .and_then(|cap| cap.name("version")) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + + return Ok(BrocadeInfo { + os: BrocadeOs::FastIron, + version, + }); + } + + Err(Error::UnexpectedError("Unknown Brocade OS version".into())) +} + +fn parse_brocade_mac_address(value: &str) -> Result { + let cleaned_mac = value.replace('.', ""); + + if cleaned_mac.len() != 12 { + return Err(format!("Invalid MAC address: {value}")); + } + + let mut bytes = [0u8; 6]; + for (i, pair) in cleaned_mac.as_bytes().chunks(2).enumerate() { + let byte_str = std::str::from_utf8(pair).map_err(|_| "Invalid UTF-8")?; + bytes[i] = + u8::from_str_radix(byte_str, 16).map_err(|_| format!("Invalid hex in MAC: {value}"))?; + } + + Ok(MacAddress(bytes)) +} + +#[derive(Debug)] +pub enum Error { + NetworkError(String), + AuthenticationError(String), + ConfigurationError(String), + TimeoutError(String), + UnexpectedError(String), + CommandError(String), +} + +impl Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::NetworkError(msg) => write!(f, "Network error: {msg}"), + Error::AuthenticationError(msg) => write!(f, "Authentication error: {msg}"), + Error::ConfigurationError(msg) => write!(f, "Configuration error: {msg}"), + Error::TimeoutError(msg) => write!(f, "Timeout error: {msg}"), + Error::UnexpectedError(msg) => write!(f, "Unexpected error: {msg}"), + Error::CommandError(msg) => write!(f, "{msg}"), + } + } +} + +impl From for String { + fn from(val: Error) -> Self { + format!("{val}") + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(value: russh::Error) -> Self { + Error::NetworkError(format!("Russh client error: {value}")) + } +} diff --git a/brocade/src/network_operating_system.rs b/brocade/src/network_operating_system.rs new file mode 100644 index 0000000..0ee4a88 --- /dev/null +++ b/brocade/src/network_operating_system.rs @@ -0,0 +1,307 @@ +use std::str::FromStr; + +use async_trait::async_trait; +use harmony_types::switch::{PortDeclaration, PortLocation}; +use log::{debug, info}; + +use crate::{ + BrocadeClient, BrocadeInfo, Error, ExecutionMode, InterSwitchLink, InterfaceInfo, + InterfaceStatus, InterfaceType, MacAddressEntry, PortChannelId, PortOperatingMode, + parse_brocade_mac_address, shell::BrocadeShell, +}; + +#[derive(Debug)] +pub struct NetworkOperatingSystemClient { + shell: BrocadeShell, + version: BrocadeInfo, +} + +impl NetworkOperatingSystemClient { + pub fn init(mut shell: BrocadeShell, version_info: BrocadeInfo) -> Self { + shell.before_all(vec!["terminal length 0".into()]); + + Self { + shell, + version: version_info, + } + } + + fn parse_mac_entry(&self, line: &str) -> Option> { + debug!("[Brocade] Parsing mac address entry: {line}"); + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 5 { + return None; + } + + let (vlan, mac_address, port) = match parts.len() { + 5 => ( + u16::from_str(parts[0]).ok()?, + parse_brocade_mac_address(parts[1]).ok()?, + parts[4].to_string(), + ), + _ => ( + u16::from_str(parts[0]).ok()?, + parse_brocade_mac_address(parts[1]).ok()?, + parts[5].to_string(), + ), + }; + + let port = + PortDeclaration::parse(&port).map_err(|e| Error::UnexpectedError(format!("{e}"))); + + match port { + Ok(p) => Some(Ok(MacAddressEntry { + vlan, + mac_address, + port: p, + })), + Err(e) => Some(Err(e)), + } + } + + fn parse_inter_switch_link_entry(&self, line: &str) -> Option> { + debug!("[Brocade] Parsing inter switch link entry: {line}"); + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 10 { + return None; + } + + let local_port = PortLocation::from_str(parts[2]).ok()?; + let remote_port = PortLocation::from_str(parts[5]).ok()?; + + Some(Ok(InterSwitchLink { + local_port, + remote_port: Some(remote_port), + })) + } + + fn parse_interface_status_entry(&self, line: &str) -> Option> { + debug!("[Brocade] Parsing interface status entry: {line}"); + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 6 { + return None; + } + + let interface_type = match parts[0] { + "Fo" => InterfaceType::Ethernet("FortyGigabitEthernet".to_string()), + "Te" => InterfaceType::Ethernet("TenGigabitEthernet".to_string()), + _ => return None, + }; + let port_location = PortLocation::from_str(parts[1]).ok()?; + let status = match parts[2] { + "connected" => InterfaceStatus::Connected, + "notconnected" => InterfaceStatus::NotConnected, + "sfpAbsent" => InterfaceStatus::SfpAbsent, + _ => return None, + }; + let operating_mode = match parts[3] { + "ISL" => Some(PortOperatingMode::Fabric), + "Trunk" => Some(PortOperatingMode::Trunk), + "Access" => Some(PortOperatingMode::Access), + "--" => None, + _ => return None, + }; + + Some(Ok(InterfaceInfo { + name: format!("{} {}", interface_type, port_location), + port_location, + interface_type, + operating_mode, + status, + })) + } +} + +#[async_trait] +impl BrocadeClient for NetworkOperatingSystemClient { + async fn version(&self) -> Result { + Ok(self.version.clone()) + } + + async fn get_mac_address_table(&self) -> Result, Error> { + let output = self + .shell + .run_command("show mac-address-table", ExecutionMode::Regular) + .await?; + + output + .lines() + .skip(1) + .filter_map(|line| self.parse_mac_entry(line)) + .collect() + } + + async fn get_stack_topology(&self) -> Result, Error> { + let output = self + .shell + .run_command("show fabric isl", ExecutionMode::Regular) + .await?; + + output + .lines() + .skip(6) + .filter_map(|line| self.parse_inter_switch_link_entry(line)) + .collect() + } + + async fn get_interfaces(&self) -> Result, Error> { + let output = self + .shell + .run_command( + "show interface status rbridge-id all", + ExecutionMode::Regular, + ) + .await?; + + output + .lines() + .skip(2) + .filter_map(|line| self.parse_interface_status_entry(line)) + .collect() + } + + async fn configure_interfaces( + &self, + interfaces: Vec<(String, PortOperatingMode)>, + ) -> Result<(), Error> { + info!("[Brocade] Configuring {} interface(s)...", interfaces.len()); + + let mut commands = vec!["configure terminal".to_string()]; + + for interface in interfaces { + commands.push(format!("interface {}", interface.0)); + + match interface.1 { + PortOperatingMode::Fabric => { + commands.push("fabric isl enable".into()); + commands.push("fabric trunk enable".into()); + } + PortOperatingMode::Trunk => { + commands.push("switchport".into()); + commands.push("switchport mode trunk".into()); + commands.push("no spanning-tree shutdown".into()); + commands.push("no fabric isl enable".into()); + commands.push("no fabric trunk enable".into()); + } + PortOperatingMode::Access => { + commands.push("switchport".into()); + commands.push("switchport mode access".into()); + commands.push("switchport access vlan 1".into()); + commands.push("no spanning-tree shutdown".into()); + commands.push("no fabric isl enable".into()); + commands.push("no fabric trunk enable".into()); + } + } + + commands.push("no shutdown".into()); + commands.push("exit".into()); + } + + commands.push("write memory".into()); + + self.shell + .run_commands(commands, ExecutionMode::Regular) + .await?; + + info!("[Brocade] Interfaces configured."); + + Ok(()) + } + + async fn find_available_channel_id(&self) -> Result { + info!("[Brocade] Finding next available channel id..."); + + let output = self + .shell + .run_command("show port-channel", ExecutionMode::Regular) + .await?; + + let used_ids: Vec = output + .lines() + .skip(6) + .filter_map(|line| { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 8 { + return None; + } + + u8::from_str(parts[0]).ok() + }) + .collect(); + + let mut next_id: u8 = 1; + loop { + if !used_ids.contains(&next_id) { + break; + } + next_id += 1; + } + + info!("[Brocade] Found channel id: {next_id}"); + Ok(next_id) + } + + async fn create_port_channel( + &self, + channel_id: PortChannelId, + channel_name: &str, + ports: &[PortLocation], + ) -> Result<(), Error> { + info!( + "[Brocade] Configuring port-channel '{channel_name} {channel_id}' with ports: {ports:?}" + ); + + let interfaces = self.get_interfaces().await?; + + let mut commands = vec![ + "configure terminal".into(), + format!("interface port-channel {}", channel_id), + "no shutdown".into(), + "exit".into(), + ]; + + for port in ports { + let interface = interfaces.iter().find(|i| i.port_location == *port); + let Some(interface) = interface else { + continue; + }; + + commands.push(format!("interface {}", interface.name)); + commands.push("no switchport".into()); + commands.push("no ip address".into()); + commands.push("no fabric isl enable".into()); + commands.push("no fabric trunk enable".into()); + commands.push(format!("channel-group {channel_id} mode active")); + commands.push("no shutdown".into()); + commands.push("exit".into()); + } + + commands.push("write memory".into()); + + self.shell + .run_commands(commands, ExecutionMode::Regular) + .await?; + + info!("[Brocade] Port-channel '{channel_name}' configured."); + + Ok(()) + } + + async fn clear_port_channel(&self, channel_name: &str) -> Result<(), Error> { + info!("[Brocade] Clearing port-channel: {channel_name}"); + + let commands = vec![ + "configure terminal".into(), + format!("no interface port-channel {}", channel_name), + "exit".into(), + "write memory".into(), + ]; + + self.shell + .run_commands(commands, ExecutionMode::Regular) + .await?; + + info!("[Brocade] Port-channel '{channel_name}' cleared."); + Ok(()) + } +} diff --git a/brocade/src/shell.rs b/brocade/src/shell.rs new file mode 100644 index 0000000..28eceb8 --- /dev/null +++ b/brocade/src/shell.rs @@ -0,0 +1,368 @@ +use std::net::IpAddr; +use std::time::Duration; +use std::time::Instant; + +use crate::BrocadeOptions; +use crate::Error; +use crate::ExecutionMode; +use crate::TimeoutConfig; +use crate::ssh; + +use log::debug; +use log::info; +use russh::ChannelMsg; +use tokio::time::timeout; + +#[derive(Debug)] +pub struct BrocadeShell { + ip: IpAddr, + port: u16, + username: String, + password: String, + options: BrocadeOptions, + before_all_commands: Vec, + after_all_commands: Vec, +} + +impl BrocadeShell { + pub async fn init( + ip_addresses: &[IpAddr], + port: u16, + username: &str, + password: &str, + options: Option, + ) -> Result { + let ip = ip_addresses + .first() + .ok_or_else(|| Error::ConfigurationError("No IP addresses provided".to_string()))?; + + let base_options = options.unwrap_or_default(); + let options = ssh::try_init_client(username, password, ip, base_options).await?; + + Ok(Self { + ip: *ip, + port, + username: username.to_string(), + password: password.to_string(), + before_all_commands: vec![], + after_all_commands: vec![], + options, + }) + } + + pub async fn open_session(&self, mode: ExecutionMode) -> Result { + BrocadeSession::open( + self.ip, + self.port, + &self.username, + &self.password, + self.options.clone(), + mode, + ) + .await + } + + pub async fn with_session(&self, mode: ExecutionMode, callback: F) -> Result + where + F: FnOnce( + &mut BrocadeSession, + ) -> std::pin::Pin< + Box> + Send + '_>, + >, + { + let mut session = self.open_session(mode).await?; + + let _ = session.run_commands(self.before_all_commands.clone()).await; + let result = callback(&mut session).await; + let _ = session.run_commands(self.after_all_commands.clone()).await; + + session.close().await?; + result + } + + pub async fn run_command(&self, command: &str, mode: ExecutionMode) -> Result { + let mut session = self.open_session(mode).await?; + + let _ = session.run_commands(self.before_all_commands.clone()).await; + let result = session.run_command(command).await; + let _ = session.run_commands(self.after_all_commands.clone()).await; + + session.close().await?; + result + } + + pub async fn run_commands( + &self, + commands: Vec, + mode: ExecutionMode, + ) -> Result<(), Error> { + let mut session = self.open_session(mode).await?; + + let _ = session.run_commands(self.before_all_commands.clone()).await; + let result = session.run_commands(commands).await; + let _ = session.run_commands(self.after_all_commands.clone()).await; + + session.close().await?; + result + } + + pub fn before_all(&mut self, commands: Vec) { + self.before_all_commands = commands; + } + + pub fn after_all(&mut self, commands: Vec) { + self.after_all_commands = commands; + } +} + +pub struct BrocadeSession { + pub channel: russh::Channel, + pub mode: ExecutionMode, + pub options: BrocadeOptions, +} + +impl BrocadeSession { + pub async fn open( + ip: IpAddr, + port: u16, + username: &str, + password: &str, + options: BrocadeOptions, + mode: ExecutionMode, + ) -> Result { + let client = ssh::create_client(ip, port, username, password, &options).await?; + let mut channel = client.channel_open_session().await?; + + channel + .request_pty(false, "vt100", 80, 24, 0, 0, &[]) + .await?; + channel.request_shell(false).await?; + + wait_for_shell_ready(&mut channel, &options.timeouts).await?; + + if let ExecutionMode::Privileged = mode { + try_elevate_session(&mut channel, username, password, &options.timeouts).await?; + } + + Ok(Self { + channel, + mode, + options, + }) + } + + pub async fn close(&mut self) -> Result<(), Error> { + debug!("[Brocade] Closing session..."); + + self.channel.data(&b"exit\n"[..]).await?; + if let ExecutionMode::Privileged = self.mode { + self.channel.data(&b"exit\n"[..]).await?; + } + + let start = Instant::now(); + while start.elapsed() < self.options.timeouts.cleanup { + match timeout(self.options.timeouts.message_wait, self.channel.wait()).await { + Ok(Some(ChannelMsg::Close)) => break, + Ok(Some(_)) => continue, + Ok(None) | Err(_) => break, + } + } + + debug!("[Brocade] Session closed."); + Ok(()) + } + + pub async fn run_command(&mut self, command: &str) -> Result { + if self.should_skip_command(command) { + return Ok(String::new()); + } + + debug!("[Brocade] Running command: '{command}'..."); + + self.channel + .data(format!("{}\n", command).as_bytes()) + .await?; + tokio::time::sleep(Duration::from_millis(100)).await; + + let output = self.collect_command_output().await?; + let output = String::from_utf8(output) + .map_err(|_| Error::UnexpectedError("Invalid UTF-8 in command output".to_string()))?; + + self.check_for_command_errors(&output, command)?; + Ok(output) + } + + pub async fn run_commands(&mut self, commands: Vec) -> Result<(), Error> { + for command in commands { + self.run_command(&command).await?; + } + Ok(()) + } + + fn should_skip_command(&self, command: &str) -> bool { + if (command.starts_with("write") || command.starts_with("deploy")) && self.options.dry_run { + info!("[Brocade] Dry-run mode enabled, skipping command: {command}"); + return true; + } + false + } + + async fn collect_command_output(&mut self) -> Result, Error> { + let mut output = Vec::new(); + let start = Instant::now(); + let read_timeout = Duration::from_millis(500); + let log_interval = Duration::from_secs(3); + let mut last_log = Instant::now(); + + loop { + if start.elapsed() > self.options.timeouts.command_execution { + return Err(Error::TimeoutError( + "Timeout waiting for command completion.".into(), + )); + } + + if start.elapsed() > Duration::from_secs(5) && last_log.elapsed() > log_interval { + info!("[Brocade] Waiting for command output..."); + last_log = Instant::now(); + } + + match timeout(read_timeout, self.channel.wait()).await { + Ok(Some(ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. })) => { + output.extend_from_slice(&data); + let current_output = String::from_utf8_lossy(&output); + if current_output.contains('>') || current_output.contains('#') { + return Ok(output); + } + } + Ok(Some(ChannelMsg::Eof | ChannelMsg::Close)) => return Ok(output), + Ok(Some(ChannelMsg::ExitStatus { exit_status })) => { + debug!("[Brocade] Command exit status: {exit_status}"); + } + Ok(Some(_)) => continue, + Ok(None) | Err(_) => { + if output.is_empty() { + if let Ok(None) = timeout(read_timeout, self.channel.wait()).await { + break; + } + continue; + } + + tokio::time::sleep(Duration::from_millis(100)).await; + let current_output = String::from_utf8_lossy(&output); + if current_output.contains('>') || current_output.contains('#') { + return Ok(output); + } + } + } + } + + Ok(output) + } + + fn check_for_command_errors(&self, output: &str, command: &str) -> Result<(), Error> { + const ERROR_PATTERNS: &[&str] = &[ + "invalid input", + "syntax error", + "command not found", + "unknown command", + "permission denied", + "access denied", + "authentication failed", + "configuration error", + "failed to", + "error:", + ]; + + let output_lower = output.to_lowercase(); + if ERROR_PATTERNS.iter().any(|&p| output_lower.contains(p)) { + return Err(Error::CommandError(format!( + "Command '{command}' failed: {}", + output.trim() + ))); + } + + if !command.starts_with("show") && output.trim().is_empty() { + return Err(Error::CommandError(format!( + "Command '{command}' produced no output" + ))); + } + + Ok(()) + } +} + +async fn wait_for_shell_ready( + channel: &mut russh::Channel, + timeouts: &TimeoutConfig, +) -> Result<(), Error> { + let mut buffer = Vec::new(); + let start = Instant::now(); + + while start.elapsed() < timeouts.shell_ready { + match timeout(timeouts.message_wait, channel.wait()).await { + Ok(Some(ChannelMsg::Data { data })) => { + buffer.extend_from_slice(&data); + let output = String::from_utf8_lossy(&buffer); + let output = output.trim(); + if output.ends_with('>') || output.ends_with('#') { + debug!("[Brocade] Shell ready"); + return Ok(()); + } + } + Ok(Some(_)) => continue, + Ok(None) => break, + Err(_) => continue, + } + } + Ok(()) +} + +async fn try_elevate_session( + channel: &mut russh::Channel, + username: &str, + password: &str, + timeouts: &TimeoutConfig, +) -> Result<(), Error> { + channel.data(&b"enable\n"[..]).await?; + let start = Instant::now(); + let mut buffer = Vec::new(); + + while start.elapsed() < timeouts.shell_ready { + match timeout(timeouts.message_wait, channel.wait()).await { + Ok(Some(ChannelMsg::Data { data })) => { + buffer.extend_from_slice(&data); + let output = String::from_utf8_lossy(&buffer); + + if output.ends_with('#') { + debug!("[Brocade] Privileged mode established"); + return Ok(()); + } + + if output.contains("User Name:") { + channel.data(format!("{}\n", username).as_bytes()).await?; + buffer.clear(); + } else if output.contains("Password:") { + channel.data(format!("{}\n", password).as_bytes()).await?; + buffer.clear(); + } else if output.contains('>') { + return Err(Error::AuthenticationError( + "Enable authentication failed".into(), + )); + } + } + Ok(Some(_)) => continue, + Ok(None) => break, + Err(_) => continue, + } + } + + let output = String::from_utf8_lossy(&buffer); + if output.ends_with('#') { + debug!("[Brocade] Privileged mode established"); + Ok(()) + } else { + Err(Error::AuthenticationError(format!( + "Enable failed. Output:\n{output}" + ))) + } +} diff --git a/brocade/src/ssh.rs b/brocade/src/ssh.rs new file mode 100644 index 0000000..08ff96f --- /dev/null +++ b/brocade/src/ssh.rs @@ -0,0 +1,113 @@ +use std::borrow::Cow; +use std::sync::Arc; + +use async_trait::async_trait; +use russh::client::Handler; +use russh::kex::DH_G1_SHA1; +use russh::kex::ECDH_SHA2_NISTP256; +use russh_keys::key::SSH_RSA; + +use super::BrocadeOptions; +use super::Error; + +#[derive(Default, Clone, Debug)] +pub struct SshOptions { + pub preferred_algorithms: russh::Preferred, +} + +impl SshOptions { + fn ecdhsa_sha2_nistp256() -> Self { + Self { + preferred_algorithms: russh::Preferred { + kex: Cow::Borrowed(&[ECDH_SHA2_NISTP256]), + key: Cow::Borrowed(&[SSH_RSA]), + ..Default::default() + }, + } + } + + fn legacy() -> Self { + Self { + preferred_algorithms: russh::Preferred { + kex: Cow::Borrowed(&[DH_G1_SHA1]), + key: Cow::Borrowed(&[SSH_RSA]), + ..Default::default() + }, + } + } +} + +pub struct Client; + +#[async_trait] +impl Handler for Client { + type Error = Error; + + async fn check_server_key( + &mut self, + _server_public_key: &russh_keys::key::PublicKey, + ) -> Result { + Ok(true) + } +} + +pub async fn try_init_client( + username: &str, + password: &str, + ip: &std::net::IpAddr, + base_options: BrocadeOptions, +) -> Result { + let ssh_options = vec![ + SshOptions::default(), + SshOptions::ecdhsa_sha2_nistp256(), + SshOptions::legacy(), + ]; + + for ssh in ssh_options { + let opts = BrocadeOptions { + ssh, + ..base_options.clone() + }; + let client = create_client(*ip, 22, username, password, &opts).await; + + match client { + Ok(_) => { + return Ok(opts); + } + Err(e) => match e { + Error::NetworkError(e) => { + if e.contains("No common key exchange algorithm") { + continue; + } else { + return Err(Error::NetworkError(e)); + } + } + _ => return Err(e), + }, + } + } + + Err(Error::NetworkError( + "Could not establish ssh connection: wrong key exchange algorithm)".to_string(), + )) +} + +pub async fn create_client( + ip: std::net::IpAddr, + port: u16, + username: &str, + password: &str, + options: &BrocadeOptions, +) -> Result, Error> { + let config = russh::client::Config { + preferred: options.ssh.preferred_algorithms.clone(), + ..Default::default() + }; + let mut client = russh::client::connect(Arc::new(config), (ip, port), Client {}).await?; + if !client.authenticate_password(username, password).await? { + return Err(Error::AuthenticationError( + "ssh authentication failed".to_string(), + )); + } + Ok(client) +} diff --git a/docs/doc-clone-and-restore-coreos.md b/docs/doc-clone-and-restore-coreos.md new file mode 100644 index 0000000..9b392ed --- /dev/null +++ b/docs/doc-clone-and-restore-coreos.md @@ -0,0 +1,127 @@ +## Working procedure to clone and restore CoreOS disk from OKD Cluster + +### **Step 1 - take a backup** +``` +sudo dd if=/dev/old of=/dev/backup status=progress +``` + +### **Step 2 - clone beginning of old disk to new** +``` +sudo dd if=/dev/old of=/dev/backup status=progress count=1000Mib +``` + +### **Step 3 - verify and modify disk partitions** +list disk partitions +``` +sgdisk -p /dev/new +``` +if new disk is smaller than old disk and there is space on the xfs partition of the old disk, modify partitions of new disk +``` +gdisk /dev/new +``` +inside of gdisk commands +``` +-v -> verify table +-p -> print table +-d -> select partition to delete partition +-n -> recreate partition with same partition number as deleted partition +``` +For end sector, either specify the new end or just press Enter for maximum available +When asked about partition type, enter the same type code (it will show the old one) +``` +p - >to verify +w -> to write +``` +make xfs file system for new partition +``` +sudo mkfs.xfs -f /dev/new4 +``` + +### **Step 4 - copy old PARTUUID ** + +**careful here** +get old patuuid: +``` +sgdisk -i /dev/old_disk # Note the "Partition unique GUID" +``` +get labels +``` +sgdisk -p /dev/old_disk # Shows partition names in the table + +blkid /dev/old_disk* # Shows PARTUUIDs and labels for all partitions +``` +set it on new disk +``` +sgdisk -u : /dev/sdc +``` +partition name: +``` +sgdisk -c :"" /dev/sdc +``` +verify all: +``` +lsblk -o NAME,SIZE,PARTUUID,PARTLABEL /dev/old_disk +``` + +### **Step 5 - Mount disks and copy files from old to new disk** + +mount files before copy: + +``` +mkdir -p /mnt/new +mkdir -p /mnt/old +mount /dev/old4 /mnt/old +mount /dev/new4 /mnt/new +``` +copy: +``` +rsync -aAXHv --numeric-ids /source/ /destination/ +``` + +### **Step 6 - Set correct UUID for new partition 4** +to set correct uuid for partition 4 +``` +blkid /dev/old4 +``` +``` +xfs_admin -U /dev/new_partition +``` +to set labels +get it +``` +sgdisk -i 4 /dev/sda | grep "Partition name" +``` +set it +``` +sgdisk -c 4:"" /dev/sdc + +or + +(check existing with xfs_admin -l /dev/old_partition) +Use xfs_admin -L