Compare commits
15 Commits
feat/confi
...
feat/broca
| Author | SHA1 | Date | |
|---|---|---|---|
| a646f1f4d0 | |||
| 2728fc8989 | |||
| 8c8baaf9cc | |||
| a1c9bfeabd | |||
| d8dab12834 | |||
| 7422534018 | |||
| b67275662d | |||
| 6237e1d877 | |||
| 88e6990051 | |||
| 8e9f8ce405 | |||
| d87aa3c7e9 | |||
| 90ec2b524a | |||
| 5572f98d5f | |||
| 8024e0d5c3 | |||
| 238e7da175 |
12
.gitmodules
vendored
12
.gitmodules
vendored
@@ -1,3 +1,15 @@
|
|||||||
[submodule "examples/try_rust_webapp/tryrust.org"]
|
[submodule "examples/try_rust_webapp/tryrust.org"]
|
||||||
path = examples/try_rust_webapp/tryrust.org
|
path = examples/try_rust_webapp/tryrust.org
|
||||||
url = https://github.com/rust-dd/tryrust.org.git
|
url = https://github.com/rust-dd/tryrust.org.git
|
||||||
|
[submodule "/home/jeangab/work/nationtech/harmony2/opnsense-codegen/vendor/core"]
|
||||||
|
path = /home/jeangab/work/nationtech/harmony2/opnsense-codegen/vendor/core
|
||||||
|
url = https://github.com/opnsense/core.git
|
||||||
|
[submodule "/home/jeangab/work/nationtech/harmony2/opnsense-codegen/vendor/plugins"]
|
||||||
|
path = /home/jeangab/work/nationtech/harmony2/opnsense-codegen/vendor/plugins
|
||||||
|
url = https://github.com/opnsense/plugins.git
|
||||||
|
[submodule "opnsense-codegen/vendor/core"]
|
||||||
|
path = opnsense-codegen/vendor/core
|
||||||
|
url = https://github.com/opnsense/core.git
|
||||||
|
[submodule "opnsense-codegen/vendor/plugins"]
|
||||||
|
path = opnsense-codegen/vendor/plugins
|
||||||
|
url = https://github.com/opnsense/plugins.git
|
||||||
|
|||||||
36
Cargo.lock
generated
36
Cargo.lock
generated
@@ -5271,12 +5271,38 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opnsense-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"env_logger",
|
||||||
|
"http 1.4.0",
|
||||||
|
"inquire 0.7.5",
|
||||||
|
"log",
|
||||||
|
"pretty_assertions",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-test",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opnsense-codegen"
|
name = "opnsense-codegen"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"env_logger",
|
||||||
|
"heck",
|
||||||
|
"log",
|
||||||
|
"pretty_assertions",
|
||||||
|
"quick-xml",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5789,6 +5815,16 @@ version = "0.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e9e1dcb320d6839f6edb64f7a4a59d39b30480d4d1765b56873f7c858538a5fe"
|
checksum = "e9e1dcb320d6839f6edb64f7a4a59d39b30480d4d1765b56873f7c858538a5fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.37.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ members = [
|
|||||||
"harmony_agent/deploy",
|
"harmony_agent/deploy",
|
||||||
"harmony_node_readiness",
|
"harmony_node_readiness",
|
||||||
"harmony-k8s",
|
"harmony-k8s",
|
||||||
"harmony_assets",
|
"harmony_assets", "opnsense-codegen", "opnsense-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -93,3 +93,4 @@ reqwest = { version = "0.12", features = [
|
|||||||
assertor = "0.0.4"
|
assertor = "0.0.4"
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|||||||
4
brocade/examples/env.sh
Normal file
4
brocade/examples/env.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export HARMONY_SECRET_NAMESPACE=brocade-example
|
||||||
|
export HARMONY_SECRET_STORE=file
|
||||||
|
export HARMONY_DATABASE_URL=sqlite://harmony_brocade_example.sqlite
|
||||||
|
export RUST_LOG=info
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
use brocade::{BrocadeOptions, ssh};
|
use brocade::{BrocadeOptions, Vlan, ssh};
|
||||||
use harmony_secret::{Secret, SecretManager};
|
use harmony_secret::{Secret, SecretManager};
|
||||||
use harmony_types::switch::PortLocation;
|
use harmony_types::switch::PortLocation;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
@@ -17,9 +17,12 @@ async fn main() {
|
|||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
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(10, 0, 0, 250)); // old brocade @ ianlet
|
||||||
let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); // brocade @ sto1
|
// let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); // brocade @ sto1
|
||||||
// let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 4, 11)); // brocade @ st
|
// let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 4, 11)); // brocade @ st
|
||||||
let switch_addresses = vec![ip];
|
//let switch_addresses = vec![ip];
|
||||||
|
let ip0 = IpAddr::V4(Ipv4Addr::new(192, 168, 12, 147)); // brocade @ test
|
||||||
|
let ip1 = IpAddr::V4(Ipv4Addr::new(192, 168, 12, 109)); // brocade @ test
|
||||||
|
let switch_addresses = vec![ip0, ip1];
|
||||||
|
|
||||||
let config = SecretManager::get_or_prompt::<BrocadeSwitchAuth>()
|
let config = SecretManager::get_or_prompt::<BrocadeSwitchAuth>()
|
||||||
.await
|
.await
|
||||||
@@ -32,7 +35,7 @@ async fn main() {
|
|||||||
&BrocadeOptions {
|
&BrocadeOptions {
|
||||||
dry_run: true,
|
dry_run: true,
|
||||||
ssh: ssh::SshOptions {
|
ssh: ssh::SshOptions {
|
||||||
port: 2222,
|
port: 22,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -58,12 +61,32 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("--------------");
|
println!("--------------");
|
||||||
todo!();
|
println!("Creating VLAN 100 (test-vlan)...");
|
||||||
|
brocade
|
||||||
|
.create_vlan(&Vlan {
|
||||||
|
id: 100,
|
||||||
|
name: "test-vlan".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("--------------");
|
||||||
|
println!("Deleting VLAN 100...");
|
||||||
|
brocade
|
||||||
|
.delete_vlan(&Vlan {
|
||||||
|
id: 100,
|
||||||
|
name: "test-vlan".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("--------------");
|
||||||
|
todo!("STOP!");
|
||||||
let channel_name = "1";
|
let channel_name = "1";
|
||||||
brocade.clear_port_channel(channel_name).await.unwrap();
|
brocade.clear_port_channel(channel_name).await.unwrap();
|
||||||
|
|
||||||
println!("--------------");
|
println!("--------------");
|
||||||
let channel_id = brocade.find_available_channel_id().await.unwrap();
|
let channel_id = 1;
|
||||||
|
|
||||||
println!("--------------");
|
println!("--------------");
|
||||||
let channel_name = "HARMONY_LAG";
|
let channel_name = "HARMONY_LAG";
|
||||||
|
|||||||
242
brocade/examples/main_vlan_demo.rs
Normal file
242
brocade/examples/main_vlan_demo.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
use brocade::{
|
||||||
|
BrocadeOptions, InterfaceConfig, InterfaceSpeed, InterfaceType, PortOperatingMode,
|
||||||
|
SwitchInterface, Vlan, VlanList, ssh,
|
||||||
|
};
|
||||||
|
use harmony_secret::{Secret, SecretManager};
|
||||||
|
use harmony_types::switch::PortLocation;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Secret, Clone, Debug, JsonSchema, Serialize, Deserialize)]
|
||||||
|
struct BrocadeSwitchAuth {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_enter() {
|
||||||
|
println!("\n--- Press ENTER to continue ---");
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
io::stdin().read_line(&mut String::new()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
let ip0 = std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 12, 147));
|
||||||
|
let ip1 = std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 12, 109));
|
||||||
|
let switch_addresses = vec![ip0, ip1];
|
||||||
|
|
||||||
|
let config = SecretManager::get_or_prompt::<BrocadeSwitchAuth>()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let brocade = brocade::init(
|
||||||
|
&switch_addresses,
|
||||||
|
&config.username,
|
||||||
|
&config.password,
|
||||||
|
&BrocadeOptions {
|
||||||
|
dry_run: false,
|
||||||
|
ssh: ssh::SshOptions {
|
||||||
|
port: 22,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Brocade client failed to connect");
|
||||||
|
|
||||||
|
println!("=== Connecting to Brocade switches ===");
|
||||||
|
let version = brocade.version().await.unwrap();
|
||||||
|
println!("Version: {version:?}");
|
||||||
|
let entries = brocade.get_stack_topology().await.unwrap();
|
||||||
|
println!("Stack topology: {entries:#?}");
|
||||||
|
|
||||||
|
println!("\n=== Creating VLANs 100, 200, 300 ===");
|
||||||
|
brocade
|
||||||
|
.create_vlan(&Vlan {
|
||||||
|
id: 100,
|
||||||
|
name: "vlan100".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!("Created VLAN 100 (vlan100)");
|
||||||
|
brocade
|
||||||
|
.create_vlan(&Vlan {
|
||||||
|
id: 200,
|
||||||
|
name: "vlan200".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!("Created VLAN 200 (vlan200)");
|
||||||
|
brocade
|
||||||
|
.create_vlan(&Vlan {
|
||||||
|
id: 300,
|
||||||
|
name: "vlan300".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!("Created VLAN 300 (vlan300)");
|
||||||
|
|
||||||
|
println!("\n=== Press ENTER to continue to port configuration tests ---");
|
||||||
|
wait_for_enter();
|
||||||
|
|
||||||
|
println!("\n=== TEST 1: Trunk port (all VLANs, speed 10Gbps) on TenGigabitEthernet 1/0/1 ===");
|
||||||
|
println!("Configuring port as trunk with all VLANs and speed 10Gbps...");
|
||||||
|
let configs = vec![InterfaceConfig {
|
||||||
|
interface: SwitchInterface::Ethernet(
|
||||||
|
InterfaceType::TenGigabitEthernet,
|
||||||
|
PortLocation(1, 0, 1),
|
||||||
|
),
|
||||||
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: Some(VlanList::All),
|
||||||
|
speed: Some(InterfaceSpeed::Gbps10),
|
||||||
|
}];
|
||||||
|
brocade.configure_interfaces(&configs).await.unwrap();
|
||||||
|
println!("Querying interfaces...");
|
||||||
|
let interfaces = brocade.get_interfaces().await.unwrap();
|
||||||
|
for iface in &interfaces {
|
||||||
|
if iface.name.contains("1/0/1") {
|
||||||
|
println!(" {iface:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wait_for_enter();
|
||||||
|
|
||||||
|
println!("\n=== TEST 2: Trunk port (specific VLANs) on TenGigabitEthernet 1/0/2 ===");
|
||||||
|
println!("Configuring port as trunk with VLANs 100, 200...");
|
||||||
|
let configs = vec![InterfaceConfig {
|
||||||
|
interface: SwitchInterface::Ethernet(
|
||||||
|
InterfaceType::TenGigabitEthernet,
|
||||||
|
PortLocation(1, 0, 2),
|
||||||
|
),
|
||||||
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: Some(VlanList::Specific(vec![
|
||||||
|
Vlan {
|
||||||
|
id: 100,
|
||||||
|
name: "vlan100".to_string(),
|
||||||
|
},
|
||||||
|
Vlan {
|
||||||
|
id: 200,
|
||||||
|
name: "vlan200".to_string(),
|
||||||
|
},
|
||||||
|
])),
|
||||||
|
speed: None,
|
||||||
|
}];
|
||||||
|
brocade.configure_interfaces(&configs).await.unwrap();
|
||||||
|
println!("Querying interfaces...");
|
||||||
|
let interfaces = brocade.get_interfaces().await.unwrap();
|
||||||
|
for iface in &interfaces {
|
||||||
|
if iface.name.contains("1/0/2") {
|
||||||
|
println!(" {iface:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wait_for_enter();
|
||||||
|
|
||||||
|
println!("\n=== TEST 3: Access port (default VLAN 1) on TenGigabitEthernet 1/0/3 ===");
|
||||||
|
println!("Configuring port as access (default VLAN 1)...");
|
||||||
|
let configs = vec![InterfaceConfig {
|
||||||
|
interface: SwitchInterface::Ethernet(
|
||||||
|
InterfaceType::TenGigabitEthernet,
|
||||||
|
PortLocation(1, 0, 3),
|
||||||
|
),
|
||||||
|
mode: PortOperatingMode::Access,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: None,
|
||||||
|
speed: None,
|
||||||
|
}];
|
||||||
|
brocade.configure_interfaces(&configs).await.unwrap();
|
||||||
|
println!("Querying interfaces...");
|
||||||
|
let interfaces = brocade.get_interfaces().await.unwrap();
|
||||||
|
for iface in &interfaces {
|
||||||
|
if iface.name.contains("1/0/3") {
|
||||||
|
println!(" {iface:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wait_for_enter();
|
||||||
|
|
||||||
|
println!("\n=== TEST 4: Access port (custom VLAN 100) on TenGigabitEthernet 1/0/4 ===");
|
||||||
|
println!("Configuring port as access with VLAN 100...");
|
||||||
|
let configs = vec![InterfaceConfig {
|
||||||
|
interface: SwitchInterface::Ethernet(
|
||||||
|
InterfaceType::TenGigabitEthernet,
|
||||||
|
PortLocation(1, 0, 4),
|
||||||
|
),
|
||||||
|
mode: PortOperatingMode::Access,
|
||||||
|
access_vlan: Some(100),
|
||||||
|
trunk_vlans: None,
|
||||||
|
speed: None,
|
||||||
|
}];
|
||||||
|
brocade.configure_interfaces(&configs).await.unwrap();
|
||||||
|
println!("Querying interfaces...");
|
||||||
|
let interfaces = brocade.get_interfaces().await.unwrap();
|
||||||
|
for iface in &interfaces {
|
||||||
|
if iface.name.contains("1/0/4") {
|
||||||
|
println!(" {iface:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wait_for_enter();
|
||||||
|
|
||||||
|
println!("\n=== TEST 5: Port-channel on TenGigabitEthernet 1/0/5 and 1/0/6 ===");
|
||||||
|
let channel_id = 1;
|
||||||
|
println!("Using channel ID: {channel_id}");
|
||||||
|
println!("Creating port-channel with ports 1/0/5 and 1/0/6...");
|
||||||
|
let ports = [PortLocation(1, 0, 5), PortLocation(1, 0, 6)];
|
||||||
|
brocade
|
||||||
|
.create_port_channel(channel_id, "HARMONY_LAG", &ports)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!("Port-channel created.");
|
||||||
|
println!("Querying port-channel summary...");
|
||||||
|
let interfaces = brocade.get_interfaces().await.unwrap();
|
||||||
|
for iface in &interfaces {
|
||||||
|
if iface.name.contains("1/0/5") || iface.name.contains("1/0/6") {
|
||||||
|
println!(" {iface:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wait_for_enter();
|
||||||
|
|
||||||
|
println!("\n=== TEARDOWN: Clearing port-channels and deleting VLANs ===");
|
||||||
|
println!("Clearing port-channel {channel_id}...");
|
||||||
|
brocade
|
||||||
|
.clear_port_channel(&channel_id.to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!("Resetting interfaces...");
|
||||||
|
for port in 1..=6 {
|
||||||
|
let interface = format!("TenGigabitEthernet 1/0/{port}");
|
||||||
|
println!(" Resetting {interface}...");
|
||||||
|
brocade.reset_interface(&interface).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Deleting VLAN 100...");
|
||||||
|
brocade
|
||||||
|
.delete_vlan(&Vlan {
|
||||||
|
id: 100,
|
||||||
|
name: "vlan100".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!("Deleting VLAN 200...");
|
||||||
|
brocade
|
||||||
|
.delete_vlan(&Vlan {
|
||||||
|
id: 200,
|
||||||
|
name: "vlan200".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!("Deleting VLAN 300...");
|
||||||
|
brocade
|
||||||
|
.delete_vlan(&Vlan {
|
||||||
|
id: 300,
|
||||||
|
name: "vlan300".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("\n=== DONE ===");
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
use super::BrocadeClient;
|
use super::BrocadeClient;
|
||||||
use crate::{
|
use crate::{
|
||||||
BrocadeInfo, Error, ExecutionMode, InterSwitchLink, InterfaceInfo, MacAddressEntry,
|
BrocadeInfo, Error, ExecutionMode, InterSwitchLink, InterfaceConfig, InterfaceInfo,
|
||||||
PortChannelId, PortOperatingMode, parse_brocade_mac_address, shell::BrocadeShell,
|
MacAddressEntry, PortChannelId, PortOperatingMode, Vlan, parse_brocade_mac_address,
|
||||||
|
shell::BrocadeShell,
|
||||||
};
|
};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -138,10 +139,15 @@ impl BrocadeClient for FastIronClient {
|
|||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn configure_interfaces(
|
async fn configure_interfaces(&self, _interfaces: &Vec<InterfaceConfig>) -> Result<(), Error> {
|
||||||
&self,
|
todo!()
|
||||||
_interfaces: &Vec<(String, PortOperatingMode)>,
|
}
|
||||||
) -> Result<(), Error> {
|
|
||||||
|
async fn create_vlan(&self, _vlan: &Vlan) -> Result<(), Error> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_vlan(&self, _vlan: &Vlan) -> Result<(), Error> {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +200,25 @@ impl BrocadeClient for FastIronClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn reset_interface(&self, interface: &str) -> Result<(), Error> {
|
||||||
|
info!("[Brocade] Resetting interface: {interface}");
|
||||||
|
|
||||||
|
let commands = vec![
|
||||||
|
"configure terminal".into(),
|
||||||
|
format!("interface {interface}"),
|
||||||
|
"no switchport".into(),
|
||||||
|
"no speed".into(),
|
||||||
|
"exit".into(),
|
||||||
|
];
|
||||||
|
|
||||||
|
self.shell
|
||||||
|
.run_commands(commands, ExecutionMode::Privileged)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("[Brocade] Interface '{interface}' reset.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn clear_port_channel(&self, channel_name: &str) -> Result<(), Error> {
|
async fn clear_port_channel(&self, channel_name: &str) -> Result<(), Error> {
|
||||||
info!("[Brocade] Clearing port-channel: {channel_name}");
|
info!("[Brocade] Clearing port-channel: {channel_name}");
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,74 @@ pub struct MacAddressEntry {
|
|||||||
|
|
||||||
pub type PortChannelId = u8;
|
pub type PortChannelId = u8;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct Vlan {
|
||||||
|
pub id: u16,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub enum VlanList {
|
||||||
|
All,
|
||||||
|
Specific(Vec<Vlan>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub enum SwitchInterface {
|
||||||
|
Ethernet(InterfaceType, PortLocation),
|
||||||
|
PortChannel(PortChannelId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SwitchInterface {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
SwitchInterface::Ethernet(itype, loc) => write!(f, "{itype} {loc}"),
|
||||||
|
SwitchInterface::PortChannel(id) => write!(f, "port-channel {id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub enum InterfaceSpeed {
|
||||||
|
Mbps100,
|
||||||
|
Gbps1,
|
||||||
|
Gbps1Auto,
|
||||||
|
Gbps10,
|
||||||
|
Auto,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for InterfaceSpeed {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
InterfaceSpeed::Mbps100 => write!(f, "100"),
|
||||||
|
InterfaceSpeed::Gbps1 => write!(f, "1000"),
|
||||||
|
InterfaceSpeed::Gbps1Auto => write!(f, "1000-auto"),
|
||||||
|
InterfaceSpeed::Gbps10 => write!(f, "10000"),
|
||||||
|
InterfaceSpeed::Auto => write!(f, "auto"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct InterfaceConfig {
|
||||||
|
pub interface: SwitchInterface,
|
||||||
|
pub mode: PortOperatingMode,
|
||||||
|
pub access_vlan: Option<u16>,
|
||||||
|
pub trunk_vlans: Option<VlanList>,
|
||||||
|
pub speed: Option<InterfaceSpeed>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct PortChannelConfig {
|
||||||
|
pub id: PortChannelId,
|
||||||
|
pub name: String,
|
||||||
|
pub ports: Vec<PortLocation>,
|
||||||
|
pub mode: PortOperatingMode,
|
||||||
|
pub access_vlan: Option<Vlan>,
|
||||||
|
pub trunk_vlans: Option<VlanList>,
|
||||||
|
pub speed: Option<InterfaceSpeed>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Represents a single physical or logical link connecting two switches within a stack or fabric.
|
/// 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
|
/// This structure provides a standardized view of the topology regardless of the
|
||||||
@@ -104,16 +172,17 @@ pub struct InterfaceInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Categorizes the functional type of a switch interface.
|
/// Categorizes the functional type of a switch interface.
|
||||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
#[derive(Debug, PartialEq, Eq, Clone, Serialize)]
|
||||||
pub enum InterfaceType {
|
pub enum InterfaceType {
|
||||||
/// Physical or virtual Ethernet interface (e.g., TenGigabitEthernet, FortyGigabitEthernet).
|
TenGigabitEthernet,
|
||||||
Ethernet(String),
|
FortyGigabitEthernet,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for InterfaceType {
|
impl fmt::Display for InterfaceType {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
InterfaceType::Ethernet(name) => write!(f, "{name}"),
|
InterfaceType::TenGigabitEthernet => write!(f, "TenGigabitEthernet"),
|
||||||
|
InterfaceType::FortyGigabitEthernet => write!(f, "FortyGigabitEthernet"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,10 +275,13 @@ pub trait BrocadeClient: std::fmt::Debug {
|
|||||||
async fn get_interfaces(&self) -> Result<Vec<InterfaceInfo>, Error>;
|
async fn get_interfaces(&self) -> Result<Vec<InterfaceInfo>, Error>;
|
||||||
|
|
||||||
/// Configures a set of interfaces to be operated with a specified mode (access ports, ISL, etc.).
|
/// Configures a set of interfaces to be operated with a specified mode (access ports, ISL, etc.).
|
||||||
async fn configure_interfaces(
|
async fn configure_interfaces(&self, interfaces: &Vec<InterfaceConfig>) -> Result<(), Error>;
|
||||||
&self,
|
|
||||||
interfaces: &Vec<(String, PortOperatingMode)>,
|
/// Creates a new VLAN on the switch.
|
||||||
) -> Result<(), Error>;
|
async fn create_vlan(&self, vlan: &Vlan) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Deletes a VLAN from the switch.
|
||||||
|
async fn delete_vlan(&self, vlan: &Vlan) -> Result<(), Error>;
|
||||||
|
|
||||||
/// Scans the existing configuration to find the next available (unused)
|
/// Scans the existing configuration to find the next available (unused)
|
||||||
/// Port-Channel ID (`lag` or `trunk`) for assignment.
|
/// Port-Channel ID (`lag` or `trunk`) for assignment.
|
||||||
@@ -246,6 +318,9 @@ pub trait BrocadeClient: std::fmt::Debug {
|
|||||||
/// * `des`: The Data Encryption Standard algorithm key
|
/// * `des`: The Data Encryption Standard algorithm key
|
||||||
async fn enable_snmp(&self, user_name: &str, auth: &str, des: &str) -> Result<(), Error>;
|
async fn enable_snmp(&self, user_name: &str, auth: &str, des: &str) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Resets an interface to its default state by removing switchport configuration.
|
||||||
|
async fn reset_interface(&self, interface: &str) -> Result<(), Error>;
|
||||||
|
|
||||||
/// Removes all configuration associated with the specified Port-Channel name.
|
/// Removes all configuration associated with the specified Port-Channel name.
|
||||||
///
|
///
|
||||||
/// This operation should be idempotent; attempting to clear a non-existent
|
/// This operation should be idempotent; attempting to clear a non-existent
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ use log::{debug, info};
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
BrocadeClient, BrocadeInfo, Error, ExecutionMode, InterSwitchLink, InterfaceInfo,
|
BrocadeClient, BrocadeInfo, Error, ExecutionMode, InterSwitchLink, InterfaceConfig,
|
||||||
InterfaceStatus, InterfaceType, MacAddressEntry, PortChannelId, PortOperatingMode,
|
InterfaceInfo, InterfaceStatus, InterfaceType, MacAddressEntry, PortChannelId,
|
||||||
parse_brocade_mac_address, shell::BrocadeShell,
|
PortOperatingMode, SwitchInterface, Vlan, VlanList, parse_brocade_mac_address,
|
||||||
|
shell::BrocadeShell,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -84,8 +85,8 @@ impl NetworkOperatingSystemClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let interface_type = match parts[0] {
|
let interface_type = match parts[0] {
|
||||||
"Fo" => InterfaceType::Ethernet("FortyGigabitEthernet".to_string()),
|
"Fo" => InterfaceType::FortyGigabitEthernet,
|
||||||
"Te" => InterfaceType::Ethernet("TenGigabitEthernet".to_string()),
|
"Te" => InterfaceType::TenGigabitEthernet,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
let port_location = PortLocation::from_str(parts[1]).ok()?;
|
let port_location = PortLocation::from_str(parts[1]).ok()?;
|
||||||
@@ -185,18 +186,20 @@ impl BrocadeClient for NetworkOperatingSystemClient {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn configure_interfaces(
|
async fn configure_interfaces(&self, interfaces: &Vec<InterfaceConfig>) -> Result<(), Error> {
|
||||||
&self,
|
|
||||||
interfaces: &Vec<(String, PortOperatingMode)>,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
info!("[Brocade] Configuring {} interface(s)...", interfaces.len());
|
info!("[Brocade] Configuring {} interface(s)...", interfaces.len());
|
||||||
|
|
||||||
let mut commands = vec!["configure terminal".to_string()];
|
let mut commands = vec!["configure terminal".to_string()];
|
||||||
|
|
||||||
for interface in interfaces {
|
for interface in interfaces {
|
||||||
commands.push(format!("interface {}", interface.0));
|
debug!(
|
||||||
|
"[Brocade] Configuring interface {} as {:?}",
|
||||||
|
interface.interface, interface.mode
|
||||||
|
);
|
||||||
|
|
||||||
match interface.1 {
|
commands.push(format!("interface {}", interface.interface));
|
||||||
|
|
||||||
|
match interface.mode {
|
||||||
PortOperatingMode::Fabric => {
|
PortOperatingMode::Fabric => {
|
||||||
commands.push("fabric isl enable".into());
|
commands.push("fabric isl enable".into());
|
||||||
commands.push("fabric trunk enable".into());
|
commands.push("fabric trunk enable".into());
|
||||||
@@ -204,23 +207,50 @@ impl BrocadeClient for NetworkOperatingSystemClient {
|
|||||||
PortOperatingMode::Trunk => {
|
PortOperatingMode::Trunk => {
|
||||||
commands.push("switchport".into());
|
commands.push("switchport".into());
|
||||||
commands.push("switchport mode trunk".into());
|
commands.push("switchport mode trunk".into());
|
||||||
commands.push("switchport trunk allowed vlan all".into());
|
match &interface.trunk_vlans {
|
||||||
|
Some(VlanList::All) => {
|
||||||
|
commands.push("switchport trunk allowed vlan all".into());
|
||||||
|
}
|
||||||
|
Some(VlanList::Specific(vlans)) => {
|
||||||
|
for vlan in vlans {
|
||||||
|
commands.push(format!("switchport trunk allowed vlan add {}", vlan.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
commands.push("switchport trunk allowed vlan all".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
commands.push("no switchport trunk tag native-vlan".into());
|
commands.push("no switchport trunk tag native-vlan".into());
|
||||||
commands.push("spanning-tree shutdown".into());
|
if matches!(interface.interface, SwitchInterface::Ethernet(..)) {
|
||||||
commands.push("no fabric isl enable".into());
|
commands.push("spanning-tree shutdown".into());
|
||||||
commands.push("no fabric trunk enable".into());
|
commands.push("no fabric isl enable".into());
|
||||||
commands.push("no shutdown".into());
|
commands.push("no fabric trunk enable".into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PortOperatingMode::Access => {
|
PortOperatingMode::Access => {
|
||||||
commands.push("switchport".into());
|
commands.push("switchport".into());
|
||||||
commands.push("switchport mode access".into());
|
commands.push("switchport mode access".into());
|
||||||
commands.push("switchport access vlan 1".into());
|
let access_vlan = interface.access_vlan.unwrap_or(1);
|
||||||
commands.push("no spanning-tree shutdown".into());
|
commands.push(format!("switchport access vlan {access_vlan}"));
|
||||||
commands.push("no fabric isl enable".into());
|
if matches!(interface.interface, SwitchInterface::Ethernet(..)) {
|
||||||
commands.push("no fabric trunk enable".into());
|
commands.push("no spanning-tree shutdown".into());
|
||||||
|
commands.push("no fabric isl enable".into());
|
||||||
|
commands.push("no fabric trunk enable".into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(speed) = &interface.speed {
|
||||||
|
info!(
|
||||||
|
"[Brocade] Overriding speed on {} to {speed}",
|
||||||
|
interface.interface
|
||||||
|
);
|
||||||
|
if matches!(interface.interface, SwitchInterface::PortChannel(..)) {
|
||||||
|
commands.push("shutdown".into());
|
||||||
|
}
|
||||||
|
commands.push(format!("speed {speed}"));
|
||||||
|
}
|
||||||
|
|
||||||
commands.push("no shutdown".into());
|
commands.push("no shutdown".into());
|
||||||
commands.push("exit".into());
|
commands.push("exit".into());
|
||||||
}
|
}
|
||||||
@@ -235,6 +265,40 @@ impl BrocadeClient for NetworkOperatingSystemClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_vlan(&self, vlan: &Vlan) -> Result<(), Error> {
|
||||||
|
info!("[Brocade] Creating VLAN {} ({})", vlan.id, vlan.name);
|
||||||
|
|
||||||
|
let commands = vec![
|
||||||
|
"configure terminal".into(),
|
||||||
|
format!("interface Vlan {}", vlan.id),
|
||||||
|
format!("name {}", vlan.name),
|
||||||
|
"exit".into(),
|
||||||
|
];
|
||||||
|
|
||||||
|
self.shell
|
||||||
|
.run_commands(commands, ExecutionMode::Regular)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("[Brocade] VLAN {} ({}) created.", vlan.id, vlan.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_vlan(&self, vlan: &Vlan) -> Result<(), Error> {
|
||||||
|
info!("[Brocade] Deleting VLAN {}", vlan.id);
|
||||||
|
|
||||||
|
let commands = vec![
|
||||||
|
"configure terminal".into(),
|
||||||
|
format!("no interface Vlan {}", vlan.id),
|
||||||
|
];
|
||||||
|
|
||||||
|
self.shell
|
||||||
|
.run_commands(commands, ExecutionMode::Regular)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("[Brocade] VLAN {} deleted.", vlan.id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn find_available_channel_id(&self) -> Result<PortChannelId, Error> {
|
async fn find_available_channel_id(&self) -> Result<PortChannelId, Error> {
|
||||||
info!("[Brocade] Finding next available channel id...");
|
info!("[Brocade] Finding next available channel id...");
|
||||||
|
|
||||||
@@ -283,22 +347,20 @@ impl BrocadeClient for NetworkOperatingSystemClient {
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
|
|
||||||
let interfaces = self.get_interfaces().await?;
|
|
||||||
|
|
||||||
let mut commands = vec![
|
let mut commands = vec![
|
||||||
"configure terminal".into(),
|
"configure terminal".into(),
|
||||||
format!("interface port-channel {}", channel_id),
|
format!("interface port-channel {}", channel_id),
|
||||||
"no shutdown".into(),
|
"no shutdown".into(),
|
||||||
|
format!("description {channel_name}"),
|
||||||
"exit".into(),
|
"exit".into(),
|
||||||
];
|
];
|
||||||
|
|
||||||
for port in ports {
|
for port in ports {
|
||||||
let interface = interfaces.iter().find(|i| i.port_location == *port);
|
debug!(
|
||||||
let Some(interface) = interface else {
|
"[Brocade] Adding port TenGigabitEthernet {} to channel-group {}",
|
||||||
continue;
|
port, channel_id
|
||||||
};
|
);
|
||||||
|
commands.push(format!("interface TenGigabitEthernet {}", port));
|
||||||
commands.push(format!("interface {}", interface.name));
|
|
||||||
commands.push("no switchport".into());
|
commands.push("no switchport".into());
|
||||||
commands.push("no ip address".into());
|
commands.push("no ip address".into());
|
||||||
commands.push("no fabric isl enable".into());
|
commands.push("no fabric isl enable".into());
|
||||||
@@ -317,6 +379,25 @@ impl BrocadeClient for NetworkOperatingSystemClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn reset_interface(&self, interface: &str) -> Result<(), Error> {
|
||||||
|
info!("[Brocade] Resetting interface: {interface}");
|
||||||
|
|
||||||
|
let commands = vec![
|
||||||
|
"configure terminal".into(),
|
||||||
|
format!("interface {interface}"),
|
||||||
|
"no switchport".into(),
|
||||||
|
"no speed".into(),
|
||||||
|
"exit".into(),
|
||||||
|
];
|
||||||
|
|
||||||
|
self.shell
|
||||||
|
.run_commands(commands, ExecutionMode::Regular)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("[Brocade] Interface '{interface}' reset.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn clear_port_channel(&self, channel_name: &str) -> Result<(), Error> {
|
async fn clear_port_channel(&self, channel_name: &str) -> Result<(), Error> {
|
||||||
info!("[Brocade] Clearing port-channel: {channel_name}");
|
info!("[Brocade] Clearing port-channel: {channel_name}");
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ set -e
|
|||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
git submodule init
|
||||||
|
git submodule update
|
||||||
|
|
||||||
rustc --version
|
rustc --version
|
||||||
cargo check --all-targets --all-features --keep-going
|
cargo check --all-targets --all-features --keep-going
|
||||||
cargo fmt --check
|
cargo fmt --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use brocade::{BrocadeOptions, PortOperatingMode};
|
use brocade::{BrocadeOptions, InterfaceConfig, InterfaceType, PortOperatingMode, SwitchInterface, VlanList};
|
||||||
use harmony::{
|
use harmony::{
|
||||||
infra::brocade::BrocadeSwitchConfig,
|
infra::brocade::BrocadeSwitchConfig,
|
||||||
inventory::Inventory,
|
inventory::Inventory,
|
||||||
@@ -9,6 +9,13 @@ use harmony::{
|
|||||||
use harmony_macros::ip;
|
use harmony_macros::ip;
|
||||||
use harmony_types::{id::Id, switch::PortLocation};
|
use harmony_types::{id::Id, switch::PortLocation};
|
||||||
|
|
||||||
|
fn tengig(stack: u8, slot: u8, port: u8) -> SwitchInterface {
|
||||||
|
SwitchInterface::Ethernet(
|
||||||
|
InterfaceType::TenGigabitEthernet,
|
||||||
|
PortLocation(stack, slot, port),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn get_switch_config() -> BrocadeSwitchConfig {
|
fn get_switch_config() -> BrocadeSwitchConfig {
|
||||||
let mut options = BrocadeOptions::default();
|
let mut options = BrocadeOptions::default();
|
||||||
options.ssh.port = 2222;
|
options.ssh.port = 2222;
|
||||||
@@ -33,9 +40,27 @@ async fn main() {
|
|||||||
Id::from_str("18").unwrap(),
|
Id::from_str("18").unwrap(),
|
||||||
],
|
],
|
||||||
ports_to_configure: vec![
|
ports_to_configure: vec![
|
||||||
(PortLocation(2, 0, 17), PortOperatingMode::Trunk),
|
InterfaceConfig {
|
||||||
(PortLocation(2, 0, 19), PortOperatingMode::Trunk),
|
interface: tengig(2, 0, 17),
|
||||||
(PortLocation(1, 0, 18), PortOperatingMode::Trunk),
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: Some(VlanList::All),
|
||||||
|
speed: None,
|
||||||
|
},
|
||||||
|
InterfaceConfig {
|
||||||
|
interface: tengig(2, 0, 19),
|
||||||
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: Some(VlanList::All),
|
||||||
|
speed: None,
|
||||||
|
},
|
||||||
|
InterfaceConfig {
|
||||||
|
interface: tengig(1, 0, 18),
|
||||||
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: Some(VlanList::All),
|
||||||
|
speed: None,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
18
examples/brocade_switch_configuration/Cargo.toml
Normal file
18
examples/brocade_switch_configuration/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "brocade-switch-configuration"
|
||||||
|
edition = "2024"
|
||||||
|
version.workspace = true
|
||||||
|
readme.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
harmony = { path = "../../harmony" }
|
||||||
|
harmony_cli = { path = "../../harmony_cli" }
|
||||||
|
harmony_macros = { path = "../../harmony_macros" }
|
||||||
|
harmony_types = { path = "../../harmony_types" }
|
||||||
|
tokio.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
env_logger.workspace = true
|
||||||
|
brocade = { path = "../../brocade" }
|
||||||
4
examples/brocade_switch_configuration/env.sh
Normal file
4
examples/brocade_switch_configuration/env.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export HARMONY_SECRET_NAMESPACE=brocade-example
|
||||||
|
export HARMONY_SECRET_STORE=file
|
||||||
|
export HARMONY_DATABASE_URL=sqlite://harmony_brocade_example.sqlite
|
||||||
|
export RUST_LOG=info
|
||||||
143
examples/brocade_switch_configuration/src/main.rs
Normal file
143
examples/brocade_switch_configuration/src/main.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
use brocade::{
|
||||||
|
BrocadeOptions, InterfaceConfig, InterfaceSpeed, InterfaceType, PortChannelConfig,
|
||||||
|
PortOperatingMode, SwitchInterface, Vlan, VlanList,
|
||||||
|
};
|
||||||
|
use harmony::{
|
||||||
|
infra::brocade::BrocadeSwitchConfig,
|
||||||
|
inventory::Inventory,
|
||||||
|
modules::brocade::{BrocadeSwitchAuth, BrocadeSwitchConfigurationScore, SwitchTopology},
|
||||||
|
};
|
||||||
|
use harmony_macros::ip;
|
||||||
|
use harmony_types::switch::PortLocation;
|
||||||
|
|
||||||
|
fn tengig(stack: u8, slot: u8, port: u8) -> SwitchInterface {
|
||||||
|
SwitchInterface::Ethernet(
|
||||||
|
InterfaceType::TenGigabitEthernet,
|
||||||
|
PortLocation(stack, slot, port),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_switch_config() -> BrocadeSwitchConfig {
|
||||||
|
let auth = BrocadeSwitchAuth {
|
||||||
|
username: "admin".to_string(),
|
||||||
|
password: "password".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
BrocadeSwitchConfig {
|
||||||
|
ips: vec![ip!("192.168.12.147"), ip!("192.168.12.109")],
|
||||||
|
auth,
|
||||||
|
options: BrocadeOptions {
|
||||||
|
dry_run: false,
|
||||||
|
ssh: brocade::ssh::SshOptions {
|
||||||
|
port: 22,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
harmony_cli::cli_logger::init();
|
||||||
|
|
||||||
|
// ===================================================
|
||||||
|
// Step 1: Define VLANs once, use them everywhere
|
||||||
|
// ===================================================
|
||||||
|
let mgmt = Vlan {
|
||||||
|
id: 100,
|
||||||
|
name: "MGMT".to_string(),
|
||||||
|
};
|
||||||
|
let data = Vlan {
|
||||||
|
id: 200,
|
||||||
|
name: "DATA".to_string(),
|
||||||
|
};
|
||||||
|
let storage = Vlan {
|
||||||
|
id: 300,
|
||||||
|
name: "STORAGE".to_string(),
|
||||||
|
};
|
||||||
|
let backup = Vlan {
|
||||||
|
id: 400,
|
||||||
|
name: "BACKUP".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===================================================
|
||||||
|
// Step 2: Build the score
|
||||||
|
// ===================================================
|
||||||
|
let score = BrocadeSwitchConfigurationScore {
|
||||||
|
// All VLANs that need to exist on the switch
|
||||||
|
vlans: vec![mgmt.clone(), data.clone(), storage.clone(), backup.clone()],
|
||||||
|
|
||||||
|
// Standalone interfaces (not part of any port-channel)
|
||||||
|
interfaces: vec![
|
||||||
|
// Trunk port with ALL VLANs, forced to 10Gbps
|
||||||
|
InterfaceConfig {
|
||||||
|
interface: tengig(1, 0, 1),
|
||||||
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: Some(VlanList::All),
|
||||||
|
speed: Some(InterfaceSpeed::Gbps10),
|
||||||
|
},
|
||||||
|
// Trunk port with specific VLANs (MGMT + DATA only)
|
||||||
|
InterfaceConfig {
|
||||||
|
interface: tengig(1, 0, 2),
|
||||||
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: Some(VlanList::Specific(vec![mgmt.clone(), data.clone()])),
|
||||||
|
speed: None,
|
||||||
|
},
|
||||||
|
// Access port on the MGMT VLAN
|
||||||
|
InterfaceConfig {
|
||||||
|
interface: tengig(1, 0, 3),
|
||||||
|
mode: PortOperatingMode::Access,
|
||||||
|
access_vlan: Some(mgmt.id),
|
||||||
|
trunk_vlans: None,
|
||||||
|
speed: None,
|
||||||
|
},
|
||||||
|
// Access port on the STORAGE VLAN
|
||||||
|
InterfaceConfig {
|
||||||
|
interface: tengig(1, 0, 4),
|
||||||
|
mode: PortOperatingMode::Access,
|
||||||
|
access_vlan: Some(storage.id),
|
||||||
|
trunk_vlans: None,
|
||||||
|
speed: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Port-channels: member ports are bundled, L2 config goes on the port-channel
|
||||||
|
port_channels: vec![
|
||||||
|
// Port-channel 1: trunk with DATA + STORAGE VLANs, forced to 1Gbps
|
||||||
|
PortChannelConfig {
|
||||||
|
id: 1,
|
||||||
|
name: "SERVER_BOND".to_string(),
|
||||||
|
ports: vec![PortLocation(1, 0, 5), PortLocation(1, 0, 6)],
|
||||||
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: Some(VlanList::Specific(vec![data.clone(), storage.clone()])),
|
||||||
|
speed: Some(InterfaceSpeed::Gbps1),
|
||||||
|
},
|
||||||
|
// Port-channel 2: trunk with all VLANs, default speed
|
||||||
|
PortChannelConfig {
|
||||||
|
id: 2,
|
||||||
|
name: "BACKUP_BOND".to_string(),
|
||||||
|
ports: vec![PortLocation(1, 0, 7), PortLocation(1, 0, 8)],
|
||||||
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: Some(VlanList::All),
|
||||||
|
speed: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===================================================
|
||||||
|
// Step 3: Run
|
||||||
|
// ===================================================
|
||||||
|
harmony_cli::run(
|
||||||
|
Inventory::autoload(),
|
||||||
|
SwitchTopology::new(get_switch_config()).await,
|
||||||
|
vec![Box::new(score)],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use brocade::{InterfaceConfig, PortChannelConfig, PortChannelId, Vlan};
|
||||||
use harmony_k8s::K8sClient;
|
use harmony_k8s::K8sClient;
|
||||||
use harmony_macros::ip;
|
use harmony_macros::ip;
|
||||||
use harmony_types::{
|
use harmony_types::{
|
||||||
@@ -11,7 +12,7 @@ use log::info;
|
|||||||
|
|
||||||
use crate::topology::{HelmCommand, PxeOptions};
|
use crate::topology::{HelmCommand, PxeOptions};
|
||||||
use crate::{data::FileContent, executors::ExecutorError, topology::node_exporter::NodeExporter};
|
use crate::{data::FileContent, executors::ExecutorError, topology::node_exporter::NodeExporter};
|
||||||
use crate::{infra::network_manager::OpenShiftNmStateNetworkManager, topology::PortConfig};
|
use crate::infra::network_manager::OpenShiftNmStateNetworkManager;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
DHCPStaticEntry, DhcpServer, DnsRecord, DnsRecordType, DnsServer, Firewall, HostNetworkConfig,
|
DHCPStaticEntry, DhcpServer, DnsRecord, DnsRecordType, DnsServer, Firewall, HostNetworkConfig,
|
||||||
@@ -316,23 +317,51 @@ impl Switch for HAClusterTopology {
|
|||||||
self.switch_client.find_port(mac_address).await
|
self.switch_client.find_port(mac_address).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn configure_port_channel(&self, config: &HostNetworkConfig) -> Result<(), SwitchError> {
|
async fn configure_port_channel(
|
||||||
|
&self,
|
||||||
|
channel_id: PortChannelId,
|
||||||
|
config: &HostNetworkConfig,
|
||||||
|
) -> Result<(), SwitchError> {
|
||||||
debug!("Configuring port channel: {config:#?}");
|
debug!("Configuring port channel: {config:#?}");
|
||||||
let switch_ports = config.switch_ports.iter().map(|s| s.port.clone()).collect();
|
let switch_ports = config.switch_ports.iter().map(|s| s.port.clone()).collect();
|
||||||
|
|
||||||
self.switch_client
|
self.switch_client
|
||||||
.configure_port_channel(&format!("Harmony_{}", config.host_id), switch_ports)
|
.configure_port_channel(
|
||||||
|
channel_id,
|
||||||
|
&format!("Harmony_{}", config.host_id),
|
||||||
|
switch_ports,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SwitchError::new(format!("Failed to configure port-channel: {e}")))?;
|
.map_err(|e| SwitchError::new(format!("Failed to configure port-channel: {e}")))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn configure_port_channel_from_config(
|
||||||
|
&self,
|
||||||
|
config: &PortChannelConfig,
|
||||||
|
) -> Result<(), SwitchError> {
|
||||||
|
self.switch_client
|
||||||
|
.configure_port_channel(config.id, &config.name, config.ports.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| SwitchError::new(format!("Failed to create port-channel: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn clear_port_channel(&self, _ids: &Vec<Id>) -> Result<(), SwitchError> {
|
async fn clear_port_channel(&self, _ids: &Vec<Id>) -> Result<(), SwitchError> {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
async fn configure_interface(&self, _ports: &Vec<PortConfig>) -> Result<(), SwitchError> {
|
async fn configure_interfaces(
|
||||||
todo!()
|
&self,
|
||||||
|
interfaces: &Vec<InterfaceConfig>,
|
||||||
|
) -> Result<(), SwitchError> {
|
||||||
|
self.switch_client.configure_interfaces(interfaces).await
|
||||||
|
}
|
||||||
|
async fn create_vlan(&self, vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
|
self.switch_client.create_vlan(vlan).await
|
||||||
|
}
|
||||||
|
async fn delete_vlan(&self, vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
|
self.switch_client.delete_vlan(vlan).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,6 +621,7 @@ impl SwitchClient for DummyInfra {
|
|||||||
|
|
||||||
async fn configure_port_channel(
|
async fn configure_port_channel(
|
||||||
&self,
|
&self,
|
||||||
|
_channel_id: PortChannelId,
|
||||||
_channel_name: &str,
|
_channel_name: &str,
|
||||||
_switch_ports: Vec<PortLocation>,
|
_switch_ports: Vec<PortLocation>,
|
||||||
) -> Result<u8, SwitchError> {
|
) -> Result<u8, SwitchError> {
|
||||||
@@ -600,7 +630,16 @@ impl SwitchClient for DummyInfra {
|
|||||||
async fn clear_port_channel(&self, _ids: &Vec<Id>) -> Result<(), SwitchError> {
|
async fn clear_port_channel(&self, _ids: &Vec<Id>) -> Result<(), SwitchError> {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
async fn configure_interface(&self, _ports: &Vec<PortConfig>) -> Result<(), SwitchError> {
|
async fn configure_interfaces(
|
||||||
|
&self,
|
||||||
|
_interfaces: &Vec<InterfaceConfig>,
|
||||||
|
) -> Result<(), SwitchError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
async fn create_vlan(&self, _vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
async fn delete_vlan(&self, _vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use brocade::PortOperatingMode;
|
use brocade::{InterfaceConfig, PortChannelConfig, PortChannelId, Vlan};
|
||||||
use derive_new::new;
|
use derive_new::new;
|
||||||
use harmony_k8s::K8sClient;
|
use harmony_k8s::K8sClient;
|
||||||
use harmony_types::{
|
use harmony_types::{
|
||||||
@@ -220,8 +220,6 @@ impl From<String> for NetworkError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type PortConfig = (PortLocation, PortOperatingMode);
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Switch: Send + Sync {
|
pub trait Switch: Send + Sync {
|
||||||
async fn setup_switch(&self) -> Result<(), SwitchError>;
|
async fn setup_switch(&self) -> Result<(), SwitchError>;
|
||||||
@@ -231,9 +229,24 @@ pub trait Switch: Send + Sync {
|
|||||||
mac_address: &MacAddress,
|
mac_address: &MacAddress,
|
||||||
) -> Result<Option<PortLocation>, SwitchError>;
|
) -> Result<Option<PortLocation>, SwitchError>;
|
||||||
|
|
||||||
async fn configure_port_channel(&self, config: &HostNetworkConfig) -> Result<(), SwitchError>;
|
async fn configure_port_channel(
|
||||||
|
&self,
|
||||||
|
channel_id: PortChannelId,
|
||||||
|
config: &HostNetworkConfig,
|
||||||
|
) -> Result<(), SwitchError>;
|
||||||
|
/// Creates a port-channel from a PortChannelConfig (id, name, member ports).
|
||||||
|
/// Does NOT configure L2 mode — use configure_interfaces for that.
|
||||||
|
async fn configure_port_channel_from_config(
|
||||||
|
&self,
|
||||||
|
config: &PortChannelConfig,
|
||||||
|
) -> Result<(), SwitchError>;
|
||||||
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError>;
|
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError>;
|
||||||
async fn configure_interface(&self, ports: &Vec<PortConfig>) -> Result<(), SwitchError>;
|
async fn configure_interfaces(
|
||||||
|
&self,
|
||||||
|
interfaces: &Vec<InterfaceConfig>,
|
||||||
|
) -> Result<(), SwitchError>;
|
||||||
|
async fn create_vlan(&self, vlan: &Vlan) -> Result<(), SwitchError>;
|
||||||
|
async fn delete_vlan(&self, vlan: &Vlan) -> Result<(), SwitchError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
@@ -290,12 +303,18 @@ pub trait SwitchClient: Debug + Send + Sync {
|
|||||||
|
|
||||||
async fn configure_port_channel(
|
async fn configure_port_channel(
|
||||||
&self,
|
&self,
|
||||||
|
channel_id: PortChannelId,
|
||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
switch_ports: Vec<PortLocation>,
|
switch_ports: Vec<PortLocation>,
|
||||||
) -> Result<u8, SwitchError>;
|
) -> Result<u8, SwitchError>;
|
||||||
|
|
||||||
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError>;
|
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError>;
|
||||||
async fn configure_interface(&self, ports: &Vec<PortConfig>) -> Result<(), SwitchError>;
|
async fn configure_interfaces(
|
||||||
|
&self,
|
||||||
|
interfaces: &Vec<InterfaceConfig>,
|
||||||
|
) -> Result<(), SwitchError>;
|
||||||
|
async fn create_vlan(&self, vlan: &Vlan) -> Result<(), SwitchError>;
|
||||||
|
async fn delete_vlan(&self, vlan: &Vlan) -> Result<(), SwitchError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use brocade::{BrocadeClient, BrocadeOptions, InterSwitchLink, InterfaceStatus, PortOperatingMode};
|
use brocade::{
|
||||||
|
BrocadeClient, BrocadeOptions, InterSwitchLink, InterfaceConfig, InterfaceStatus,
|
||||||
|
PortChannelId, PortOperatingMode, Vlan,
|
||||||
|
};
|
||||||
|
|
||||||
use harmony_types::{
|
use harmony_types::{
|
||||||
id::Id,
|
id::Id,
|
||||||
net::{IpAddress, MacAddress},
|
net::{IpAddress, MacAddress},
|
||||||
switch::{PortDeclaration, PortLocation},
|
switch::{PortDeclaration, PortLocation},
|
||||||
};
|
};
|
||||||
use log::{info, warn};
|
use log::info;
|
||||||
use option_ext::OptionExt;
|
use option_ext::OptionExt;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
modules::brocade::BrocadeSwitchAuth,
|
modules::brocade::BrocadeSwitchAuth,
|
||||||
topology::{PortConfig, SwitchClient, SwitchError},
|
topology::{SwitchClient, SwitchError},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -54,7 +58,7 @@ impl SwitchClient for BrocadeSwitchClient {
|
|||||||
|
|
||||||
info!("Brocade found interfaces {interfaces:#?}");
|
info!("Brocade found interfaces {interfaces:#?}");
|
||||||
|
|
||||||
let interfaces: Vec<(String, PortOperatingMode)> = interfaces
|
let interfaces: Vec<InterfaceConfig> = interfaces
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|interface| {
|
.filter(|interface| {
|
||||||
interface.operating_mode.is_none() && interface.status == InterfaceStatus::Connected
|
interface.operating_mode.is_none() && interface.status == InterfaceStatus::Connected
|
||||||
@@ -65,7 +69,16 @@ impl SwitchClient for BrocadeSwitchClient {
|
|||||||
|| link.remote_port.contains(&interface.port_location)
|
|| link.remote_port.contains(&interface.port_location)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map(|interface| (interface.name.clone(), PortOperatingMode::Trunk))
|
.map(|interface| InterfaceConfig {
|
||||||
|
interface: brocade::SwitchInterface::Ethernet(
|
||||||
|
interface.interface_type.clone(),
|
||||||
|
interface.port_location.clone(),
|
||||||
|
),
|
||||||
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: None,
|
||||||
|
speed: None,
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if interfaces.is_empty() {
|
if interfaces.is_empty() {
|
||||||
@@ -114,50 +127,18 @@ impl SwitchClient for BrocadeSwitchClient {
|
|||||||
|
|
||||||
async fn configure_port_channel(
|
async fn configure_port_channel(
|
||||||
&self,
|
&self,
|
||||||
|
channel_id: PortChannelId,
|
||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
switch_ports: Vec<PortLocation>,
|
switch_ports: Vec<PortLocation>,
|
||||||
) -> Result<u8, SwitchError> {
|
) -> Result<u8, SwitchError> {
|
||||||
let mut channel_id = self
|
self.brocade
|
||||||
.brocade
|
.create_port_channel(channel_id, channel_name, &switch_ports)
|
||||||
.find_available_channel_id()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SwitchError::new(format!("{e}")))?;
|
.map_err(|e| SwitchError::new(format!("{e}")))?;
|
||||||
|
|
||||||
info!("Found next available channel id : {channel_id}");
|
info!(
|
||||||
|
"Successfully configured port channel {channel_id} {channel_name} for ports {switch_ports:?}"
|
||||||
loop {
|
);
|
||||||
match self
|
|
||||||
.brocade
|
|
||||||
.create_port_channel(channel_id, channel_name, &switch_ports)
|
|
||||||
.await
|
|
||||||
.map_err(|e| SwitchError::new(format!("{e}")))
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
info!(
|
|
||||||
"Successfully configured port channel {channel_id} {channel_name} for ports {switch_ports:?}"
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"Could not configure port channel {channel_id} {channel_name} for ports {switch_ports:?}"
|
|
||||||
);
|
|
||||||
let previous_id = channel_id;
|
|
||||||
|
|
||||||
while previous_id == channel_id {
|
|
||||||
channel_id = inquire::Text::new(
|
|
||||||
"Type the port channel number to use (or CTRL+C to exit) :",
|
|
||||||
)
|
|
||||||
.prompt()
|
|
||||||
.map_err(|e| {
|
|
||||||
SwitchError::new(format!("Failed to prompt for channel id : {e}"))
|
|
||||||
})?
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(channel_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(channel_id)
|
Ok(channel_id)
|
||||||
}
|
}
|
||||||
@@ -170,14 +151,28 @@ impl SwitchClient for BrocadeSwitchClient {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn configure_interface(&self, ports: &Vec<PortConfig>) -> Result<(), SwitchError> {
|
async fn configure_interfaces(
|
||||||
// FIXME hardcoded TenGigabitEthernet = bad
|
&self,
|
||||||
let ports = ports
|
interfaces: &Vec<InterfaceConfig>,
|
||||||
.iter()
|
) -> Result<(), SwitchError> {
|
||||||
.map(|p| (format!("TenGigabitEthernet {}", p.0), p.1.clone()))
|
|
||||||
.collect();
|
|
||||||
self.brocade
|
self.brocade
|
||||||
.configure_interfaces(&ports)
|
.configure_interfaces(interfaces)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SwitchError::new(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_vlan(&self, vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
|
self.brocade
|
||||||
|
.create_vlan(vlan)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SwitchError::new(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_vlan(&self, vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
|
self.brocade
|
||||||
|
.delete_vlan(vlan)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SwitchError::new(e.to_string()))?;
|
.map_err(|e| SwitchError::new(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -208,8 +203,9 @@ impl SwitchClient for UnmanagedSwitch {
|
|||||||
|
|
||||||
async fn configure_port_channel(
|
async fn configure_port_channel(
|
||||||
&self,
|
&self,
|
||||||
channel_name: &str,
|
_channel_id: PortChannelId,
|
||||||
switch_ports: Vec<PortLocation>,
|
_channel_name: &str,
|
||||||
|
_switch_ports: Vec<PortLocation>,
|
||||||
) -> Result<u8, SwitchError> {
|
) -> Result<u8, SwitchError> {
|
||||||
todo!("unmanaged switch. Nothing to do.")
|
todo!("unmanaged switch. Nothing to do.")
|
||||||
}
|
}
|
||||||
@@ -217,8 +213,19 @@ impl SwitchClient for UnmanagedSwitch {
|
|||||||
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError> {
|
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError> {
|
||||||
todo!("unmanged switch. Nothing to do.")
|
todo!("unmanged switch. Nothing to do.")
|
||||||
}
|
}
|
||||||
async fn configure_interface(&self, ports: &Vec<PortConfig>) -> Result<(), SwitchError> {
|
async fn configure_interfaces(
|
||||||
todo!("unmanged switch. Nothing to do.")
|
&self,
|
||||||
|
_interfaces: &Vec<InterfaceConfig>,
|
||||||
|
) -> Result<(), SwitchError> {
|
||||||
|
todo!("unmanaged switch. Nothing to do.")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_vlan(&self, _vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
|
todo!("unmanaged switch. Nothing to do.")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_vlan(&self, _vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
|
todo!("unmanaged switch. Nothing to do.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,8 +236,9 @@ mod tests {
|
|||||||
use assertor::*;
|
use assertor::*;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use brocade::{
|
use brocade::{
|
||||||
BrocadeClient, BrocadeInfo, Error, InterSwitchLink, InterfaceInfo, InterfaceStatus,
|
BrocadeClient, BrocadeInfo, Error, InterSwitchLink, InterfaceConfig, InterfaceInfo,
|
||||||
InterfaceType, MacAddressEntry, PortChannelId, PortOperatingMode, SecurityLevel,
|
InterfaceStatus, InterfaceType, MacAddressEntry, PortChannelId, PortOperatingMode,
|
||||||
|
SecurityLevel, Vlan,
|
||||||
};
|
};
|
||||||
use harmony_types::switch::PortLocation;
|
use harmony_types::switch::PortLocation;
|
||||||
|
|
||||||
@@ -258,8 +266,26 @@ mod tests {
|
|||||||
//TODO not sure about this
|
//TODO not sure about this
|
||||||
let configured_interfaces = brocade.configured_interfaces.lock().unwrap();
|
let configured_interfaces = brocade.configured_interfaces.lock().unwrap();
|
||||||
assert_that!(*configured_interfaces).contains_exactly(vec![
|
assert_that!(*configured_interfaces).contains_exactly(vec![
|
||||||
(first_interface.name.clone(), PortOperatingMode::Trunk),
|
InterfaceConfig {
|
||||||
(second_interface.name.clone(), PortOperatingMode::Trunk),
|
interface: brocade::SwitchInterface::Ethernet(
|
||||||
|
InterfaceType::TenGigabitEthernet,
|
||||||
|
PortLocation(1, 0, 1),
|
||||||
|
),
|
||||||
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: None,
|
||||||
|
speed: None,
|
||||||
|
},
|
||||||
|
InterfaceConfig {
|
||||||
|
interface: brocade::SwitchInterface::Ethernet(
|
||||||
|
InterfaceType::TenGigabitEthernet,
|
||||||
|
PortLocation(1, 0, 4),
|
||||||
|
),
|
||||||
|
mode: PortOperatingMode::Trunk,
|
||||||
|
access_vlan: None,
|
||||||
|
trunk_vlans: None,
|
||||||
|
speed: None,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +369,7 @@ mod tests {
|
|||||||
struct FakeBrocadeClient {
|
struct FakeBrocadeClient {
|
||||||
stack_topology: Vec<InterSwitchLink>,
|
stack_topology: Vec<InterSwitchLink>,
|
||||||
interfaces: Vec<InterfaceInfo>,
|
interfaces: Vec<InterfaceInfo>,
|
||||||
configured_interfaces: Arc<Mutex<Vec<(String, PortOperatingMode)>>>,
|
configured_interfaces: Arc<Mutex<Vec<InterfaceConfig>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -366,7 +392,7 @@ mod tests {
|
|||||||
|
|
||||||
async fn configure_interfaces(
|
async fn configure_interfaces(
|
||||||
&self,
|
&self,
|
||||||
interfaces: &Vec<(String, PortOperatingMode)>,
|
interfaces: &Vec<InterfaceConfig>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut configured_interfaces = self.configured_interfaces.lock().unwrap();
|
let mut configured_interfaces = self.configured_interfaces.lock().unwrap();
|
||||||
*configured_interfaces = interfaces.clone();
|
*configured_interfaces = interfaces.clone();
|
||||||
@@ -374,6 +400,14 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_vlan(&self, _vlan: &Vlan) -> Result<(), Error> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_vlan(&self, _vlan: &Vlan) -> Result<(), Error> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn find_available_channel_id(&self) -> Result<PortChannelId, Error> {
|
async fn find_available_channel_id(&self) -> Result<PortChannelId, Error> {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
@@ -387,6 +421,10 @@ mod tests {
|
|||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn reset_interface(&self, _interface: &str) -> Result<(), Error> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn clear_port_channel(&self, _channel_name: &str) -> Result<(), Error> {
|
async fn clear_port_channel(&self, _channel_name: &str) -> Result<(), Error> {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
@@ -418,7 +456,7 @@ mod tests {
|
|||||||
let interface_type = self
|
let interface_type = self
|
||||||
.interface_type
|
.interface_type
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or(InterfaceType::Ethernet("TenGigabitEthernet".into()));
|
.unwrap_or(InterfaceType::TenGigabitEthernet);
|
||||||
let port_location = self.port_location.clone().unwrap_or(PortLocation(1, 0, 1));
|
let port_location = self.port_location.clone().unwrap_or(PortLocation(1, 0, 1));
|
||||||
let name = format!("{interface_type} {port_location}");
|
let name = format!("{interface_type} {port_location}");
|
||||||
let status = self.status.clone().unwrap_or(InterfaceStatus::Connected);
|
let status = self.status.clone().unwrap_or(InterfaceStatus::Connected);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use brocade::{BrocadeOptions, PortOperatingMode};
|
use brocade::{BrocadeOptions, InterfaceConfig, PortChannelConfig, PortChannelId, PortOperatingMode, Vlan};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
data::Version,
|
data::Version,
|
||||||
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
inventory::Inventory,
|
inventory::Inventory,
|
||||||
score::Score,
|
score::Score,
|
||||||
topology::{
|
topology::{
|
||||||
HostNetworkConfig, PortConfig, PreparationError, PreparationOutcome, Switch, SwitchClient,
|
HostNetworkConfig, PreparationError, PreparationOutcome, Switch, SwitchClient,
|
||||||
SwitchError, Topology,
|
SwitchError, Topology,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -20,7 +20,7 @@ use serde::Serialize;
|
|||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct BrocadeSwitchScore {
|
pub struct BrocadeSwitchScore {
|
||||||
pub port_channels_to_clear: Vec<Id>,
|
pub port_channels_to_clear: Vec<Id>,
|
||||||
pub ports_to_configure: Vec<PortConfig>,
|
pub ports_to_configure: Vec<InterfaceConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Topology + Switch> Score<T> for BrocadeSwitchScore {
|
impl<T: Topology + Switch> Score<T> for BrocadeSwitchScore {
|
||||||
@@ -59,7 +59,7 @@ impl<T: Topology + Switch> Interpret<T> for BrocadeSwitchInterpret {
|
|||||||
.map_err(|e| InterpretError::new(e.to_string()))?;
|
.map_err(|e| InterpretError::new(e.to_string()))?;
|
||||||
debug!("Configuring interfaces {:?}", self.score.ports_to_configure);
|
debug!("Configuring interfaces {:?}", self.score.ports_to_configure);
|
||||||
topology
|
topology
|
||||||
.configure_interface(&self.score.ports_to_configure)
|
.configure_interfaces(&self.score.ports_to_configure)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| InterpretError::new(e.to_string()))?;
|
.map_err(|e| InterpretError::new(e.to_string()))?;
|
||||||
Ok(Outcome::success("switch configured".to_string()))
|
Ok(Outcome::success("switch configured".to_string()))
|
||||||
@@ -126,13 +126,38 @@ impl Switch for SwitchTopology {
|
|||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn configure_port_channel(&self, _config: &HostNetworkConfig) -> Result<(), SwitchError> {
|
async fn configure_port_channel(
|
||||||
|
&self,
|
||||||
|
_channel_id: PortChannelId,
|
||||||
|
_config: &HostNetworkConfig,
|
||||||
|
) -> Result<(), SwitchError> {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
|
async fn configure_port_channel_from_config(
|
||||||
|
&self,
|
||||||
|
config: &PortChannelConfig,
|
||||||
|
) -> Result<(), SwitchError> {
|
||||||
|
self.client
|
||||||
|
.configure_port_channel(config.id, &config.name, config.ports.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| SwitchError::new(format!("Failed to create port-channel: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError> {
|
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError> {
|
||||||
self.client.clear_port_channel(ids).await
|
self.client.clear_port_channel(ids).await
|
||||||
}
|
}
|
||||||
async fn configure_interface(&self, ports: &Vec<PortConfig>) -> Result<(), SwitchError> {
|
async fn configure_interfaces(
|
||||||
self.client.configure_interface(ports).await
|
&self,
|
||||||
|
interfaces: &Vec<InterfaceConfig>,
|
||||||
|
) -> Result<(), SwitchError> {
|
||||||
|
self.client.configure_interfaces(interfaces).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_vlan(&self, vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
|
self.client.create_vlan(vlan).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_vlan(&self, vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
|
self.client.delete_vlan(vlan).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
179
harmony/src/modules/brocade/brocade_switch_configuration.rs
Normal file
179
harmony/src/modules/brocade/brocade_switch_configuration.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use brocade::{InterfaceConfig, PortChannelConfig, Vlan};
|
||||||
|
use harmony_types::id::Id;
|
||||||
|
use log::{debug, info};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
data::Version,
|
||||||
|
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||||
|
inventory::Inventory,
|
||||||
|
score::Score,
|
||||||
|
topology::{Switch, SwitchError, Topology},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct BrocadeSwitchConfigurationScore {
|
||||||
|
/// VLANs to create on the switch. Define once, reference everywhere.
|
||||||
|
pub vlans: Vec<Vlan>,
|
||||||
|
/// Standalone interfaces (NOT members of a port-channel).
|
||||||
|
/// Each has its own VLAN/mode configuration.
|
||||||
|
pub interfaces: Vec<InterfaceConfig>,
|
||||||
|
/// Port-channels: bundles of ports with VLAN/mode config
|
||||||
|
/// applied on the logical port-channel interface, not on the members.
|
||||||
|
pub port_channels: Vec<PortChannelConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Topology + Switch> Score<T> for BrocadeSwitchConfigurationScore {
|
||||||
|
fn name(&self) -> String {
|
||||||
|
"BrocadeSwitchConfigurationScore".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||||
|
Box::new(BrocadeSwitchConfigurationInterpret {
|
||||||
|
score: self.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct BrocadeSwitchConfigurationInterpret {
|
||||||
|
score: BrocadeSwitchConfigurationScore,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: Topology + Switch> Interpret<T> for BrocadeSwitchConfigurationInterpret {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_inventory: &Inventory,
|
||||||
|
topology: &T,
|
||||||
|
) -> Result<Outcome, InterpretError> {
|
||||||
|
self.create_vlans(topology).await?;
|
||||||
|
self.create_port_channels(topology).await?;
|
||||||
|
self.configure_port_channel_interfaces(topology).await?;
|
||||||
|
self.configure_standalone_interfaces(topology).await?;
|
||||||
|
|
||||||
|
Ok(Outcome::success(
|
||||||
|
"Switch configuration applied successfully".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_name(&self) -> InterpretName {
|
||||||
|
InterpretName::Custom("BrocadeSwitchConfigurationInterpret")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_version(&self) -> Version {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_status(&self) -> InterpretStatus {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_children(&self) -> Vec<Id> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrocadeSwitchConfigurationInterpret {
|
||||||
|
async fn create_vlans<T: Topology + Switch>(
|
||||||
|
&self,
|
||||||
|
topology: &T,
|
||||||
|
) -> Result<(), InterpretError> {
|
||||||
|
for vlan in &self.score.vlans {
|
||||||
|
info!("Creating VLAN {} ({})", vlan.id, vlan.name);
|
||||||
|
topology
|
||||||
|
.create_vlan(vlan)
|
||||||
|
.await
|
||||||
|
.map_err(|e| InterpretError::new(format!("Failed to create VLAN {}: {e}", vlan.id)))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_port_channels<T: Topology + Switch>(
|
||||||
|
&self,
|
||||||
|
topology: &T,
|
||||||
|
) -> Result<(), InterpretError> {
|
||||||
|
for pc in &self.score.port_channels {
|
||||||
|
info!(
|
||||||
|
"Creating port-channel {} ({}) with ports: {:?}",
|
||||||
|
pc.id, pc.name, pc.ports
|
||||||
|
);
|
||||||
|
topology
|
||||||
|
.configure_port_channel_from_config(pc)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
InterpretError::new(format!(
|
||||||
|
"Failed to create port-channel {} ({}): {e}",
|
||||||
|
pc.id, pc.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn configure_port_channel_interfaces<T: Topology + Switch>(
|
||||||
|
&self,
|
||||||
|
topology: &T,
|
||||||
|
) -> Result<(), InterpretError> {
|
||||||
|
let pc_interfaces: Vec<InterfaceConfig> = self
|
||||||
|
.score
|
||||||
|
.port_channels
|
||||||
|
.iter()
|
||||||
|
.map(|pc| InterfaceConfig {
|
||||||
|
interface: brocade::SwitchInterface::PortChannel(pc.id),
|
||||||
|
mode: pc.mode.clone(),
|
||||||
|
access_vlan: pc.access_vlan.as_ref().map(|v| v.id),
|
||||||
|
trunk_vlans: pc.trunk_vlans.clone(),
|
||||||
|
speed: pc.speed.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !pc_interfaces.is_empty() {
|
||||||
|
info!(
|
||||||
|
"Configuring L2 mode on {} port-channel interface(s)",
|
||||||
|
pc_interfaces.len()
|
||||||
|
);
|
||||||
|
for pc in &self.score.port_channels {
|
||||||
|
debug!(
|
||||||
|
" port-channel {} ({}): mode={:?}, vlans={:?}, speed={:?}",
|
||||||
|
pc.id, pc.name, pc.mode, pc.trunk_vlans, pc.speed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
topology
|
||||||
|
.configure_interfaces(&pc_interfaces)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
InterpretError::new(format!(
|
||||||
|
"Failed to configure port-channel interfaces: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn configure_standalone_interfaces<T: Topology + Switch>(
|
||||||
|
&self,
|
||||||
|
topology: &T,
|
||||||
|
) -> Result<(), InterpretError> {
|
||||||
|
if !self.score.interfaces.is_empty() {
|
||||||
|
info!(
|
||||||
|
"Configuring {} standalone interface(s)",
|
||||||
|
self.score.interfaces.len()
|
||||||
|
);
|
||||||
|
for iface in &self.score.interfaces {
|
||||||
|
debug!(
|
||||||
|
" {}: mode={:?}, speed={:?}",
|
||||||
|
iface.interface, iface.mode, iface.speed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
topology
|
||||||
|
.configure_interfaces(&self.score.interfaces)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
InterpretError::new(format!("Failed to configure interfaces: {e}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,6 @@ pub use brocade::*;
|
|||||||
|
|
||||||
pub mod brocade_snmp;
|
pub mod brocade_snmp;
|
||||||
pub use brocade_snmp::*;
|
pub use brocade_snmp::*;
|
||||||
|
|
||||||
|
pub mod brocade_switch_configuration;
|
||||||
|
pub use brocade_switch_configuration::*;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use brocade::{InterfaceConfig, PortChannelConfig, PortChannelId, Vlan};
|
||||||
use harmony_types::{id::Id, switch::PortLocation};
|
use harmony_types::{id::Id, switch::PortLocation};
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -11,7 +12,10 @@ use crate::{
|
|||||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||||
inventory::Inventory,
|
inventory::Inventory,
|
||||||
score::Score,
|
score::Score,
|
||||||
topology::{HostNetworkConfig, NetworkInterface, NetworkManager, Switch, SwitchPort, Topology},
|
topology::{
|
||||||
|
HostNetworkConfig, NetworkInterface, NetworkManager, Switch, SwitchPort,
|
||||||
|
Topology,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Configures high-availability networking for a set of physical hosts.
|
/// Configures high-availability networking for a set of physical hosts.
|
||||||
@@ -152,8 +156,9 @@ impl HostNetworkConfigurationInterpret {
|
|||||||
InterpretError::new(format!("Failed to configure host network: {e}"))
|
InterpretError::new(format!("Failed to configure host network: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let channel_id = todo!("Determine port-channel ID for this host");
|
||||||
topology
|
topology
|
||||||
.configure_port_channel(&config)
|
.configure_port_channel(channel_id, &config)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
InterpretError::new(format!("Failed to configure host network: {e}"))
|
InterpretError::new(format!("Failed to configure host network: {e}"))
|
||||||
@@ -389,7 +394,7 @@ mod tests {
|
|||||||
use crate::{
|
use crate::{
|
||||||
hardware::HostCategory,
|
hardware::HostCategory,
|
||||||
topology::{
|
topology::{
|
||||||
HostNetworkConfig, NetworkError, PortConfig, PreparationError, PreparationOutcome,
|
HostNetworkConfig, NetworkError, PreparationError, PreparationOutcome,
|
||||||
SwitchError, SwitchPort,
|
SwitchError, SwitchPort,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -836,6 +841,7 @@ mod tests {
|
|||||||
|
|
||||||
async fn configure_port_channel(
|
async fn configure_port_channel(
|
||||||
&self,
|
&self,
|
||||||
|
_channel_id: PortChannelId,
|
||||||
config: &HostNetworkConfig,
|
config: &HostNetworkConfig,
|
||||||
) -> Result<(), SwitchError> {
|
) -> Result<(), SwitchError> {
|
||||||
let mut configured_port_channels = self.configured_port_channels.lock().unwrap();
|
let mut configured_port_channels = self.configured_port_channels.lock().unwrap();
|
||||||
@@ -843,14 +849,26 @@ mod tests {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
async fn configure_port_channel_from_config(
|
||||||
|
&self,
|
||||||
|
_config: &PortChannelConfig,
|
||||||
|
) -> Result<(), SwitchError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError> {
|
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError> {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
async fn configure_interface(
|
async fn configure_interfaces(
|
||||||
&self,
|
&self,
|
||||||
port_config: &Vec<PortConfig>,
|
_interfaces: &Vec<InterfaceConfig>,
|
||||||
) -> Result<(), SwitchError> {
|
) -> Result<(), SwitchError> {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
|
async fn create_vlan(&self, _vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
async fn delete_vlan(&self, _vlan: &Vlan) -> Result<(), SwitchError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ readme.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.5.35", features = ["derive"] }
|
clap.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
env_logger.workspace = true
|
env_logger.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|||||||
22
opnsense-api/Cargo.toml
Normal file
22
opnsense-api/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "opnsense-api"
|
||||||
|
edition = "2024"
|
||||||
|
version.workspace = true
|
||||||
|
readme.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
env_logger.workspace = true
|
||||||
|
inquire.workspace = true
|
||||||
|
http.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test.workspace = true
|
||||||
|
pretty_assertions.workspace = true
|
||||||
173
opnsense-api/examples/firmware_update.rs
Normal file
173
opnsense-api/examples/firmware_update.rs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
//! Example: check for firmware updates and apply them.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --example firmware_update
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This runs a full firmware update workflow:
|
||||||
|
//! 1. POST /api/core/firmware/check — triggers a background check for updates
|
||||||
|
//! 2. POST /api/core/firmware/status — retrieves the check results
|
||||||
|
//! 3. If updates are available, POST /api/core/firmware/update — applies them
|
||||||
|
//!
|
||||||
|
//! The check can take a while since it connects to the update mirror.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use opnsense_api::client::OpnsenseClient;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FirmwareCheckResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub msg_uuid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct FirmwareStatusResponse {
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub status_msg: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub all_packages: serde_json::Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub product: serde_json::Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub status_reboot: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FirmwareActionResponse {
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub msg_uuid: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub status_msg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_client() -> OpnsenseClient {
|
||||||
|
let base_url = env::var("OPNSENSE_BASE_URL")
|
||||||
|
.unwrap_or_else(|_| "https://192.168.1.1/api".to_string());
|
||||||
|
|
||||||
|
match (env::var("OPNSENSE_API_KEY").ok(), env::var("OPNSENSE_API_SECRET").ok()) {
|
||||||
|
(Some(key), Some(secret)) => OpnsenseClient::builder()
|
||||||
|
.base_url(&base_url)
|
||||||
|
.auth_from_key_secret(&key, &secret)
|
||||||
|
.skip_tls_verify()
|
||||||
|
.timeout_secs(120)
|
||||||
|
.build()
|
||||||
|
.expect("failed to build HTTP client"),
|
||||||
|
_ => {
|
||||||
|
eprintln!("ERROR: OPNSENSE_API_KEY and OPNSENSE_API_SECRET must be set.");
|
||||||
|
eprintln!(" export OPNSENSE_API_KEY=your_key");
|
||||||
|
eprintln!(" export OPNSENSE_API_SECRET=your_secret");
|
||||||
|
eprintln!(" export OPNSENSE_BASE_URL=https://your-firewall/api");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
let client = build_client();
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("╔═══════════════════════════════════════════════════════════╗");
|
||||||
|
println!("║ OPNsense Firmware Update ║");
|
||||||
|
println!("╚═══════════════════════════════════════════════════════════╝");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!(" [1/2] Checking for updates (this may take a moment) ...");
|
||||||
|
println!();
|
||||||
|
log::info!("POST /api/core/firmware/check");
|
||||||
|
|
||||||
|
let check: FirmwareCheckResponse = client
|
||||||
|
.post_typed("core", "firmware", "check", None::<&()>)
|
||||||
|
.await
|
||||||
|
.expect("check request failed");
|
||||||
|
|
||||||
|
println!(" Check triggered, msg_uuid: {}", check.msg_uuid);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!(" Waiting for check to complete ...");
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(" [2/2] Fetching update status ...");
|
||||||
|
log::info!("POST /api/core/firmware/status");
|
||||||
|
|
||||||
|
let status: FirmwareStatusResponse = client
|
||||||
|
.post_typed("core", "firmware", "status", None::<&()>)
|
||||||
|
.await
|
||||||
|
.expect("status request failed");
|
||||||
|
|
||||||
|
println!();
|
||||||
|
match status.status.as_str() {
|
||||||
|
"none" => {
|
||||||
|
println!(" ✓ {}", status.status_msg.as_deref().unwrap_or("No updates available."));
|
||||||
|
}
|
||||||
|
"update" | "upgrade" => {
|
||||||
|
println!(" ⚠ {}", status.status_msg.as_deref().unwrap_or("Updates available."));
|
||||||
|
if let Some(reboot) = status.status_reboot {
|
||||||
|
if reboot == "1" {
|
||||||
|
println!(" ⚠ This update requires a reboot.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pkg_count = if let serde_json::Value::Object(ref map) = status.all_packages {
|
||||||
|
map.len()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
if pkg_count > 0 {
|
||||||
|
println!(" {} package(s) to update:", pkg_count);
|
||||||
|
if let serde_json::Value::Object(ref packages) = status.all_packages {
|
||||||
|
for (name, info) in packages.iter().take(10) {
|
||||||
|
let reason = info.get("reason").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let old_ver = info.get("old").and_then(|v| v.as_str()).unwrap_or("N/A");
|
||||||
|
let new_ver = info.get("new").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
println!(" {:40} {} {} → {}", name, reason, old_ver, new_ver);
|
||||||
|
}
|
||||||
|
if pkg_count > 10 {
|
||||||
|
println!(" ... and {} more", pkg_count - 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(" Run with environment variable OPNSENSE_FIRMWARE_UPDATE=1 to apply updates.");
|
||||||
|
println!(" WARNING: firmware updates can cause connectivity interruptions.");
|
||||||
|
}
|
||||||
|
"error" => {
|
||||||
|
println!(" ✗ Error: {}", status.status_msg.as_deref().unwrap_or("Unknown error"));
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
println!(" ? Unexpected status: {other}");
|
||||||
|
println!(" Full response: {}", serde_json::to_string_pretty(&status).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if env::var("OPNSENSE_FIRMWARE_UPDATE").as_deref() == Ok("1") {
|
||||||
|
if status.status == "update" || status.status == "upgrade" {
|
||||||
|
println!();
|
||||||
|
println!(" Applying firmware update ...");
|
||||||
|
log::info!("POST /api/core/firmware/update");
|
||||||
|
|
||||||
|
let result: FirmwareActionResponse = client
|
||||||
|
.post_typed("core", "firmware", "update", None::<&()>)
|
||||||
|
.await
|
||||||
|
.expect("update request failed");
|
||||||
|
|
||||||
|
if result.status == "ok" {
|
||||||
|
println!(" ✓ Update started, msg_uuid: {}", result.msg_uuid);
|
||||||
|
println!(" The firewall is updating in the background.");
|
||||||
|
} else {
|
||||||
|
println!(" ✗ Update failed: {:?}", result.status_msg);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!();
|
||||||
|
println!(" No updates to apply.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
opnsense-api/examples/install_package.rs
Normal file
86
opnsense-api/examples/install_package.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
//! Example: install OPNsense packages (os-haproxy, os-caddy) via the firmware API.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --example install_package -- os-haproxy
|
||||||
|
//! cargo run --example install_package -- os-caddy os-acme
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Calls `POST /api/core/firmware/install/<pkg_name>` for each package.
|
||||||
|
//! These are the standard OPNsense plugin packages.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use opnsense_api::client::OpnsenseClient;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FirmwareActionResponse {
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub msg_uuid: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub status_msg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_client() -> OpnsenseClient {
|
||||||
|
let base_url = env::var("OPNSENSE_BASE_URL")
|
||||||
|
.unwrap_or_else(|_| "https://192.168.1.1/api".to_string());
|
||||||
|
|
||||||
|
match (env::var("OPNSENSE_API_KEY").ok(), env::var("OPNSENSE_API_SECRET").ok()) {
|
||||||
|
(Some(key), Some(secret)) => OpnsenseClient::builder()
|
||||||
|
.base_url(&base_url)
|
||||||
|
.auth_from_key_secret(&key, &secret)
|
||||||
|
.skip_tls_verify()
|
||||||
|
.timeout_secs(300)
|
||||||
|
.build()
|
||||||
|
.expect("failed to build HTTP client"),
|
||||||
|
_ => {
|
||||||
|
eprintln!("ERROR: OPNSENSE_API_KEY and OPNSENSE_API_SECRET must be set.");
|
||||||
|
eprintln!(" export OPNSENSE_API_KEY=your_key");
|
||||||
|
eprintln!(" export OPNSENSE_API_SECRET=your_secret");
|
||||||
|
eprintln!(" export OPNSENSE_BASE_URL=https://your-firewall/api");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
let packages: Vec<String> = env::args().skip(1).collect();
|
||||||
|
|
||||||
|
if packages.is_empty() {
|
||||||
|
eprintln!("Usage: cargo run --example install_package -- <package1> [package2 ...]");
|
||||||
|
eprintln!("Example: cargo run --example install_package -- os-haproxy os-caddy");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = build_client();
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("╔═══════════════════════════════════════════════════════════╗");
|
||||||
|
println!("║ OPNsense Package Installer ║");
|
||||||
|
println!("╚═══════════════════════════════════════════════════════════╝");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for pkg in &packages {
|
||||||
|
println!(" Installing {pkg} ...");
|
||||||
|
log::info!("POST /api/core/firmware/install/{pkg}");
|
||||||
|
|
||||||
|
let response: FirmwareActionResponse = client
|
||||||
|
.post_typed("core", "firmware", &format!("install/{pkg}"), None::<&()>)
|
||||||
|
.await
|
||||||
|
.expect("API call failed");
|
||||||
|
|
||||||
|
if response.status == "ok" {
|
||||||
|
println!(" ✓ {pkg} installed (msg_uuid: {})", response.msg_uuid);
|
||||||
|
} else {
|
||||||
|
println!(" ✗ {pkg} failed: {:?}", response.status_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(" Done.");
|
||||||
|
}
|
||||||
134
opnsense-api/examples/list_dnsmasq.rs
Normal file
134
opnsense-api/examples/list_dnsmasq.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
//! Example: fetch and display OPNsense Dnsmasq DNS/DHCP settings.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --example list_dnsmasq
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This demonstrates the **full target DX** for the opnsense-api crate:
|
||||||
|
//!
|
||||||
|
//! 1. Build a typed [`OpnsenseClient`] — prompted for credentials if not set.
|
||||||
|
//! 2. Call `GET /api/dnsmasq/settings/get` with full type safety.
|
||||||
|
//! 3. Pretty-print the deserialized response.
|
||||||
|
//!
|
||||||
|
//! ## Credentials
|
||||||
|
//!
|
||||||
|
//! The client first checks for `OPNSENSE_API_KEY` and `OPNSENSE_API_SECRET`
|
||||||
|
//! environment variables. If neither is set, it prompts interactively.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use opnsense_api::client::OpnsenseClient;
|
||||||
|
use opnsense_api::generated::dnsmasq::DnsmasqSettingsResponse;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
let base_url = env::var("OPNSENSE_BASE_URL")
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
eprintln!("OPNSENSE_BASE_URL not set, using https://192.168.1.1/api");
|
||||||
|
"https://192.168.1.1/api".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = match (env::var("OPNSENSE_API_KEY").ok(), env::var("OPNSENSE_API_SECRET").ok()) {
|
||||||
|
(Some(key), Some(secret)) => {
|
||||||
|
log::info!("Using credentials from environment variables");
|
||||||
|
OpnsenseClient::builder()
|
||||||
|
.base_url(&base_url)
|
||||||
|
.auth_from_key_secret(&key, &secret)
|
||||||
|
.skip_tls_verify()
|
||||||
|
.build()
|
||||||
|
.expect("failed to build HTTP client")
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("ERROR: OPNSENSE_API_KEY and OPNSENSE_API_SECRET must be set.");
|
||||||
|
eprintln!(" export OPNSENSE_API_KEY=your_key");
|
||||||
|
eprintln!(" export OPNSENSE_API_SECRET=your_secret");
|
||||||
|
eprintln!(" export OPNSENSE_BASE_URL=https://your-firewall/api");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!("Fetching /api/dnsmasq/settings/get ...");
|
||||||
|
|
||||||
|
let response: DnsmasqSettingsResponse = client
|
||||||
|
.get_typed("dnsmasq", "settings", "get")
|
||||||
|
.await
|
||||||
|
.expect("API call failed");
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("╔═══════════════════════════════════════════════════════════╗");
|
||||||
|
println!("║ OPNsense Dnsmasq Settings ║");
|
||||||
|
println!("╚═══════════════════════════════════════════════════════════╝");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let s = &response.dnsmasq;
|
||||||
|
|
||||||
|
println!(" General");
|
||||||
|
println!(" ─────────────────────────────────────────────────────────");
|
||||||
|
println!(" DNS service enabled: {}", toggle(s.enable));
|
||||||
|
println!(" DNSSEC validation: {}", toggle(s.dnssec));
|
||||||
|
println!(" Log queries: {}", toggle(s.log_queries));
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!(" DNS Options");
|
||||||
|
println!(" ─────────────────────────────────────────────────────────");
|
||||||
|
println!(" Domain required: {}", toggle(s.domain_needed));
|
||||||
|
println!(" No private revers: {}", toggle(s.no_private_reverse));
|
||||||
|
println!(" Strict order: {}", toggle(s.strict_order));
|
||||||
|
println!(" No /etc/hosts: {}", toggle(s.no_hosts));
|
||||||
|
println!(" Strict bind: {}", toggle(s.strictbind));
|
||||||
|
println!(" Cache size: {}", s.cache_size.map(|v| v.to_string()).unwrap_or_else(|| "—".to_string()));
|
||||||
|
println!(" Local TTL: {}", s.local_ttl.map(|v| v.to_string()).unwrap_or_else(|| "—".to_string()));
|
||||||
|
println!(" DNS port: {}", s.port.map(|v| v.to_string()).unwrap_or_else(|| "53".to_string()));
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!(" DHCP Registration");
|
||||||
|
println!(" ─────────────────────────────────────────────────────────");
|
||||||
|
println!(" Register DHCP leases: {}", toggle(s.regdhcp));
|
||||||
|
println!(" Register static DHCP: {}", toggle(s.regdhcpstatic));
|
||||||
|
println!(" DHCP first: {}", toggle(s.dhcpfirst));
|
||||||
|
println!(" No ident: {}", toggle(s.no_ident));
|
||||||
|
if let Some(ref domain) = s.regdhcpdomain {
|
||||||
|
println!(" Domain: {domain}");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!(" DHCP Options");
|
||||||
|
println!(" ─────────────────────────────────────────────────────────");
|
||||||
|
println!(" Add MAC address: {}", add_mac_label(&s.add_mac));
|
||||||
|
println!(" Add subnet: {}", toggle(s.add_subnet));
|
||||||
|
println!(" Strip subnet: {}", toggle(s.strip_subnet));
|
||||||
|
println!(" No /etc/resolv: {}", toggle(s.no_resolv));
|
||||||
|
if let Some(v) = s.dns_forward_max {
|
||||||
|
println!(" DNS forward max: {v}");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!(" Full response (JSON)");
|
||||||
|
println!(" ─────────────────────────────────────────────────────────");
|
||||||
|
println!(" {}", serde_json::to_string_pretty(&response).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle(b: bool) -> &'static str {
|
||||||
|
if b { "enabled ✓" } else { "disabled ✗" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_mac_label(mac: &Option<serde_json::Value>) -> String {
|
||||||
|
match mac {
|
||||||
|
Some(serde_json::Value::Object(map)) => {
|
||||||
|
map.iter()
|
||||||
|
.find(|(_, v)| v.get("selected").and_then(|s| s.as_i64()).map(|x| x == 1).unwrap_or(false))
|
||||||
|
.map(|(k, v)| {
|
||||||
|
if k.is_empty() {
|
||||||
|
v.get("value").and_then(|x| x.as_str()).unwrap_or("not set")
|
||||||
|
} else {
|
||||||
|
k.as_str()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or("not set")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
_ => "not set".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
152
opnsense-api/examples/list_packages.rs
Normal file
152
opnsense-api/examples/list_packages.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! Example: list all OPNsense packages (installed and available).
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --example list_packages
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Calls `GET /api/core/firmware/info` which returns package listings
|
||||||
|
//! from OPNsense's firmware API.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use opnsense_api::client::OpnsenseClient;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FirmwareInfoResponse {
|
||||||
|
pub product: ProductInfo,
|
||||||
|
#[serde(default)]
|
||||||
|
pub package: Vec<PackageInfo>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub plugin: Vec<PackageInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ProductInfo {
|
||||||
|
pub product_name: String,
|
||||||
|
pub product_version: String,
|
||||||
|
pub product_arch: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub product_check: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PackageInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub comment: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub flatsize: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub locked: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub automatic: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub license: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repository: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub origin: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub provided: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub installed: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub configured: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tier: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
let base_url = env::var("OPNSENSE_BASE_URL")
|
||||||
|
.unwrap_or_else(|_| "https://192.168.1.1/api".to_string());
|
||||||
|
|
||||||
|
let client = match (env::var("OPNSENSE_API_KEY").ok(), env::var("OPNSENSE_API_SECRET").ok()) {
|
||||||
|
(Some(key), Some(secret)) => {
|
||||||
|
OpnsenseClient::builder()
|
||||||
|
.base_url(&base_url)
|
||||||
|
.auth_from_key_secret(&key, &secret)
|
||||||
|
.skip_tls_verify()
|
||||||
|
.build()
|
||||||
|
.expect("failed to build HTTP client")
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("ERROR: OPNSENSE_API_KEY and OPNSENSE_API_SECRET must be set.");
|
||||||
|
eprintln!(" export OPNSENSE_API_KEY=your_key");
|
||||||
|
eprintln!(" export OPNSENSE_API_SECRET=your_secret");
|
||||||
|
eprintln!(" export OPNSENSE_BASE_URL=https://your-firewall/api");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!("Fetching /api/core/firmware/info ...");
|
||||||
|
|
||||||
|
let response: FirmwareInfoResponse = client
|
||||||
|
.get_typed("core", "firmware", "info")
|
||||||
|
.await
|
||||||
|
.expect("API call failed");
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("╔═══════════════════════════════════════════════════════════╗");
|
||||||
|
println!("║ OPNsense Package Information ║");
|
||||||
|
println!("╚═══════════════════════════════════════════════════════════╝");
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} {} ({})",
|
||||||
|
response.product.product_name,
|
||||||
|
response.product.product_version,
|
||||||
|
response.product.product_arch
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let installed: Vec<_> = response
|
||||||
|
.plugin
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.installed == "1")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let available: Vec<_> = response
|
||||||
|
.plugin
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.installed != "1" && p.provided == "1")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
println!(" Installed plugins: {} (showing first 30)", installed.len());
|
||||||
|
println!(" ─────────────────────────────────────────────────────────");
|
||||||
|
for pkg in installed.iter().take(30) {
|
||||||
|
println!(" {:40} {}", pkg.name, pkg.version);
|
||||||
|
}
|
||||||
|
if installed.len() > 30 {
|
||||||
|
println!(" ... and {} more", installed.len() - 30);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!(" Available plugins: {} (showing first 20)", available.len());
|
||||||
|
println!(" ─────────────────────────────────────────────────────────");
|
||||||
|
for pkg in available.iter().take(20) {
|
||||||
|
println!(" {:40} {}", pkg.name, pkg.version);
|
||||||
|
}
|
||||||
|
if available.len() > 20 {
|
||||||
|
println!(" ... and {} more", available.len() - 20);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let os_packages: Vec<_> = response
|
||||||
|
.package
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.name.starts_with("os-"))
|
||||||
|
.collect();
|
||||||
|
println!(" System packages (os-*): {} (showing first 20)", os_packages.len());
|
||||||
|
println!(" ─────────────────────────────────────────────────────────");
|
||||||
|
for pkg in os_packages.iter().take(20) {
|
||||||
|
let installed = if pkg.installed == "1" { " [installed]" } else { "" };
|
||||||
|
println!(" {:40} {}{}", pkg.name, pkg.version, installed);
|
||||||
|
}
|
||||||
|
if os_packages.len() > 20 {
|
||||||
|
println!(" ... and {} more", os_packages.len() - 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
opnsense-api/src/auth.rs
Normal file
44
opnsense-api/src/auth.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
//! Authentication helpers for OPNsense API clients.
|
||||||
|
//!
|
||||||
|
//! OPNsense uses HTTP Basic Auth with an API key/secret pair. The key is used as
|
||||||
|
//! the username and the secret as the password.
|
||||||
|
|
||||||
|
use http::header::{HeaderMap, HeaderValue, AUTHORIZATION};
|
||||||
|
|
||||||
|
/// OPNsense API credentials: key (username) and secret (password).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Credentials {
|
||||||
|
pub key: String,
|
||||||
|
pub secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build [`Credentials`] by prompting the user for key and secret.
|
||||||
|
///
|
||||||
|
/// Uses [`inquire`] for interactive input. Call this when you want the CLI to
|
||||||
|
/// ask the user directly rather than requiring environment variables.
|
||||||
|
pub fn prompt_credentials() -> Result<Credentials, inquire::InquireError> {
|
||||||
|
use inquire::Password;
|
||||||
|
|
||||||
|
let key = inquire::Text::new("OPNsense API key:")
|
||||||
|
.with_help_message("Found in System → Access → Users → API Keys")
|
||||||
|
.prompt()?;
|
||||||
|
|
||||||
|
let secret = Password::new("OPNsense API secret:")
|
||||||
|
.without_confirmation()
|
||||||
|
.prompt()?;
|
||||||
|
|
||||||
|
Ok(Credentials { key, secret })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add Basic Auth headers to a [`HeaderMap`].
|
||||||
|
///
|
||||||
|
/// Constructs `Authorization: Basic <base64(key:secret)>`.
|
||||||
|
pub fn add_auth_headers(headers: &mut HeaderMap, creds: &Credentials) {
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
let credentials = format!("{}:{}", creds.key, creds.secret);
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials.as_bytes());
|
||||||
|
let header_value = HeaderValue::from_str(&format!("Basic {}", encoded))
|
||||||
|
.expect("Basic auth header value is always valid ASCII");
|
||||||
|
headers.insert(AUTHORIZATION, header_value);
|
||||||
|
}
|
||||||
265
opnsense-api/src/client.rs
Normal file
265
opnsense-api/src/client.rs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
//! Typed OPNsense API client.
|
||||||
|
//!
|
||||||
|
//! ## Core pattern
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use opnsense_api::OpnsenseClient;
|
||||||
|
//!
|
||||||
|
//! let client = OpnsenseClient::builder()
|
||||||
|
//! .base_url("https://my-opnsense.local/api")
|
||||||
|
//! .auth_from_key_secret("mykey", "mysecret")
|
||||||
|
//! .build()?;
|
||||||
|
//!
|
||||||
|
//! // GET /api/interfaces/settings/get
|
||||||
|
//! let response = client.get_typed("interfaces", "settings", "get").await?;
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Response wrapper keys
|
||||||
|
//!
|
||||||
|
//! OPNsense API responses wrap the model in a key that is the controller's
|
||||||
|
//! `internalModelName` (`settings`, `haproxy`, `dnsmasq`, …). Generated
|
||||||
|
//! response types in [`crate::generated`] use the correct key, so pass them
|
||||||
|
//! directly as the type parameter.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use http::header::{HeaderMap, CONTENT_TYPE};
|
||||||
|
use log::{debug, trace, warn};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
|
|
||||||
|
use crate::auth::{add_auth_headers, Credentials};
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
/// Builder for [`OpnsenseClient`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OpnsenseClientBuilder {
|
||||||
|
base_url: String,
|
||||||
|
credentials: Option<Credentials>,
|
||||||
|
timeout_secs: Option<u64>,
|
||||||
|
skip_tls_verify: bool,
|
||||||
|
user_agent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpnsenseClientBuilder {
|
||||||
|
/// Set the OPNsense API base URL — include the `/api` suffix.
|
||||||
|
///
|
||||||
|
/// Example: `"https://192.168.1.1/api"`
|
||||||
|
pub fn base_url(mut self, url: impl Into<String>) -> Self {
|
||||||
|
self.base_url = url.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provide credentials directly as key/secret.
|
||||||
|
pub fn auth_from_key_secret(mut self, key: impl Into<String>, secret: impl Into<String>) -> Self {
|
||||||
|
self.credentials = Some(Credentials {
|
||||||
|
key: key.into(),
|
||||||
|
secret: secret.into(),
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt the user for API key and secret using the terminal.
|
||||||
|
///
|
||||||
|
/// Uses [`crate::auth::prompt_credentials`] internally.
|
||||||
|
pub fn auth_interactive(self) -> Result<Self, inquire::InquireError> {
|
||||||
|
let creds = crate::auth::prompt_credentials()?;
|
||||||
|
Ok(self.auth_from_key_secret(creds.key, creds.secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Override the request timeout (in seconds). Default: 30 s.
|
||||||
|
pub fn timeout_secs(mut self, secs: u64) -> Self {
|
||||||
|
self.timeout_secs = Some(secs);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip TLS certificate verification. Useful for self-signed certs.
|
||||||
|
///
|
||||||
|
/// **Never use this in production.**
|
||||||
|
pub fn skip_tls_verify(mut self) -> Self {
|
||||||
|
self.skip_tls_verify = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the [`OpnsenseClient`].
|
||||||
|
pub fn build(self) -> Result<OpnsenseClient, Error> {
|
||||||
|
let credentials = self.credentials.ok_or(Error::NoCredentials)?;
|
||||||
|
|
||||||
|
let mut builder = reqwest::Client::builder();
|
||||||
|
|
||||||
|
if self.skip_tls_verify {
|
||||||
|
builder = builder.danger_accept_invalid_certs(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(secs) = self.timeout_secs {
|
||||||
|
builder = builder.timeout(std::time::Duration::from_secs(secs));
|
||||||
|
}
|
||||||
|
|
||||||
|
builder = builder.user_agent(&self.user_agent);
|
||||||
|
|
||||||
|
let http = builder.build().map_err(Error::Client)?;
|
||||||
|
|
||||||
|
Ok(OpnsenseClient {
|
||||||
|
base_url: self.base_url.trim_end_matches('/').to_string(),
|
||||||
|
credentials: Arc::new(credentials),
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed OPNsense API client.
|
||||||
|
///
|
||||||
|
/// Construct with [`OpnsenseClient::builder()`].
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct OpnsenseClient {
|
||||||
|
base_url: String,
|
||||||
|
credentials: Arc<Credentials>,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for OpnsenseClient {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("OpnsenseClient")
|
||||||
|
.field("base_url", &self.base_url)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpnsenseClient {
|
||||||
|
/// Start configuring a client.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// OpnsenseClient::builder()
|
||||||
|
/// .base_url("https://firewall.example.com/api")
|
||||||
|
/// .auth_from_key_secret("key", "secret")
|
||||||
|
/// .build()?
|
||||||
|
/// ```
|
||||||
|
pub fn builder() -> OpnsenseClientBuilder {
|
||||||
|
OpnsenseClientBuilder {
|
||||||
|
base_url: String::new(),
|
||||||
|
credentials: None,
|
||||||
|
timeout_secs: Some(30),
|
||||||
|
skip_tls_verify: false,
|
||||||
|
user_agent: concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue a GET request and deserialize the response into a concrete type.
|
||||||
|
///
|
||||||
|
/// This is the main entry point for typed model endpoints.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let resp: InterfacesSettingsResponse = client
|
||||||
|
/// .get_typed("interfaces", "settings", "get")
|
||||||
|
/// .await?;
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// For endpoints that do not return JSON (or don't need typed parsing),
|
||||||
|
/// use [`Self::get_untyped`] instead.
|
||||||
|
pub async fn get_typed<R>(&self, module: &str, controller: &str, command: &str) -> Result<R, Error>
|
||||||
|
where
|
||||||
|
R: DeserializeOwned + std::fmt::Debug,
|
||||||
|
{
|
||||||
|
let url = format!(
|
||||||
|
"{}/{}/{}/{}",
|
||||||
|
self.base_url, module, controller, command
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
add_auth_headers(&mut headers, &self.credentials);
|
||||||
|
|
||||||
|
debug!(target: "opnsense-api", "GET {}", url);
|
||||||
|
trace!("headers: {:#?}", headers);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.headers(headers)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(Error::Client)?;
|
||||||
|
|
||||||
|
self.handle_response_typed(response, "GET", &url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue a POST request with an optional JSON body and deserialize the response.
|
||||||
|
///
|
||||||
|
/// Use this for action endpoints like `POST /api/core/firmware/install/os-haproxy`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let resp: FirmwareInstallResponse = client
|
||||||
|
/// .post_typed("core", "firmware", "install", Some(&json!({"pkg_name": "os-haproxy"})))
|
||||||
|
/// .await?;
|
||||||
|
/// ```
|
||||||
|
pub async fn post_typed<R, B>(&self, module: &str, controller: &str, command: &str, body: Option<B>) -> Result<R, Error>
|
||||||
|
where
|
||||||
|
R: DeserializeOwned + std::fmt::Debug,
|
||||||
|
B: serde::Serialize,
|
||||||
|
{
|
||||||
|
let url = format!(
|
||||||
|
"{}/{}/{}/{}",
|
||||||
|
self.base_url, module, controller, command
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
add_auth_headers(&mut headers, &self.credentials);
|
||||||
|
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
|
||||||
|
|
||||||
|
debug!(target: "opnsense-api", "POST {}", url);
|
||||||
|
|
||||||
|
let request = self.http.post(&url).headers(headers);
|
||||||
|
let request = match body {
|
||||||
|
Some(b) => {
|
||||||
|
let json = serde_json::to_string(&b).map_err(|e| Error::JsonDecode {
|
||||||
|
context: url.clone(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
trace!("body: {json}");
|
||||||
|
request.body(json)
|
||||||
|
}
|
||||||
|
None => request,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(Error::Client)?;
|
||||||
|
self.handle_response_typed(response, "POST", &url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_response_typed<R>(
|
||||||
|
&self,
|
||||||
|
response: reqwest::Response,
|
||||||
|
method: &str,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<R, Error>
|
||||||
|
where
|
||||||
|
R: DeserializeOwned + std::fmt::Debug,
|
||||||
|
{
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
if status.is_success() {
|
||||||
|
debug!("Reponse success, content : {response:#?}");
|
||||||
|
let body = response.text().await?;
|
||||||
|
debug!("Reponse success, body : {:#?}", body);
|
||||||
|
let json = serde_json::from_str(&body);
|
||||||
|
debug!("Reponse success, json : {:#?}", json);
|
||||||
|
let json: R = json.map_err(|e| Error::JsonDecode {
|
||||||
|
context: url.to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
debug!(target: "opnsense-api", "{} {} → HTTP {status}", method, url);
|
||||||
|
Ok(json)
|
||||||
|
} else {
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
warn!(target: "opnsense-api", "{} {} → HTTP {status}: {}", method, url, body);
|
||||||
|
Err(Error::Api {
|
||||||
|
status,
|
||||||
|
method: method.to_string(),
|
||||||
|
path: url.to_string(),
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
opnsense-api/src/error.rs
Normal file
37
opnsense-api/src/error.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//! Typed error types for the opnsense-api client.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors that can occur when calling the OPNsense API.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
/// Failed to build or send the HTTP request.
|
||||||
|
#[error("http client error: {0}")]
|
||||||
|
Client(#[source] reqwest::Error),
|
||||||
|
|
||||||
|
/// Server returned a non-2xx HTTP status.
|
||||||
|
#[error("OPNsense returned HTTP {status} for {method} {path}: {body}")]
|
||||||
|
Api {
|
||||||
|
status: reqwest::StatusCode,
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Response body could not be decoded as JSON.
|
||||||
|
#[error("json decode error for {context}: {source}")]
|
||||||
|
JsonDecode {
|
||||||
|
context: String,
|
||||||
|
source: Box<dyn std::error::Error + Send + Sync>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// No API key or secret was provided.
|
||||||
|
#[error("no credentials provided — call `.auth_from_key_secret()` or `.auth_interactive()`")]
|
||||||
|
NoCredentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for Error {
|
||||||
|
fn from(value: reqwest::Error) -> Self {
|
||||||
|
Error::Client(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
226
opnsense-api/src/generated/dnsmasq.rs
Normal file
226
opnsense-api/src/generated/dnsmasq.rs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
//! Auto-generated types for the **Dnsmasq** OPNsense model.
|
||||||
|
//!
|
||||||
|
//! Source XML: `core/src/opnsense/mvc/app/models/OPNsense/Dnsmasq/Dnsmasq.xml`
|
||||||
|
//!
|
||||||
|
//! Mount: `//dnsmasq`
|
||||||
|
//!
|
||||||
|
//! API endpoint: `GET /api/dnsmasq/settings/get`
|
||||||
|
//!
|
||||||
|
//! **DO NOT EDIT** — produced by `opnsense-codegen`.
|
||||||
|
//!
|
||||||
|
//! ## Response wrapper key
|
||||||
|
//!
|
||||||
|
//! This model uses `"dnsmasq"` as the JSON response key (the controller's
|
||||||
|
//! `internalModelName`). The wrapper struct is [`DnsmasqSettingsResponse`].
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod serde_helpers {
|
||||||
|
pub mod opn_bool {
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
pub fn serialize<S: Serializer>(
|
||||||
|
value: &Option<bool>,
|
||||||
|
serializer: S,
|
||||||
|
) -> Result<S::Ok, S::Error> {
|
||||||
|
match value {
|
||||||
|
Some(true) => serializer.serialize_str("1"),
|
||||||
|
Some(false) => serializer.serialize_str("0"),
|
||||||
|
None => serializer.serialize_str(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Option<bool>, D::Error> {
|
||||||
|
let v = serde_json::Value::deserialize(deserializer)?;
|
||||||
|
match &v {
|
||||||
|
serde_json::Value::String(s) => match s.as_str() {
|
||||||
|
"1" | "true" => Ok(Some(true)),
|
||||||
|
"0" | "false" => Ok(Some(false)),
|
||||||
|
"" => Ok(None),
|
||||||
|
other => Err(serde::de::Error::custom(format!(
|
||||||
|
"invalid bool string: {other}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
serde_json::Value::Bool(b) => Ok(Some(*b)),
|
||||||
|
serde_json::Value::Number(n) => match n.as_u64() {
|
||||||
|
Some(1) => Ok(Some(true)),
|
||||||
|
Some(0) => Ok(Some(false)),
|
||||||
|
_ => Err(serde::de::Error::custom(format!(
|
||||||
|
"invalid bool number: {n}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
serde_json::Value::Null => Ok(None),
|
||||||
|
_ => Err(serde::de::Error::custom(
|
||||||
|
"expected string, bool, or number for bool field",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod opn_bool_req {
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
pub fn serialize<S: Serializer>(value: &bool, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(if *value { "1" } else { "0" })
|
||||||
|
}
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error> {
|
||||||
|
let v = serde_json::Value::deserialize(deserializer)?;
|
||||||
|
match &v {
|
||||||
|
serde_json::Value::String(s) => match s.as_str() {
|
||||||
|
"1" | "true" => Ok(true),
|
||||||
|
"0" | "false" => Ok(false),
|
||||||
|
other => Err(serde::de::Error::custom(format!(
|
||||||
|
"invalid required bool: {other}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
serde_json::Value::Bool(b) => Ok(*b),
|
||||||
|
serde_json::Value::Number(n) => match n.as_u64() {
|
||||||
|
Some(1) => Ok(true),
|
||||||
|
Some(0) => Ok(false),
|
||||||
|
_ => Err(serde::de::Error::custom(format!(
|
||||||
|
"invalid required bool number: {n}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
_ => Err(serde::de::Error::custom(
|
||||||
|
"expected string, bool, or number for required bool",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod opn_u32 {
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
pub fn serialize<S: Serializer>(
|
||||||
|
value: &Option<u32>,
|
||||||
|
serializer: S,
|
||||||
|
) -> Result<S::Ok, S::Error> {
|
||||||
|
match value {
|
||||||
|
Some(v) => serializer.serialize_str(&v.to_string()),
|
||||||
|
None => serializer.serialize_str(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Option<u32>, D::Error> {
|
||||||
|
let v = serde_json::Value::deserialize(deserializer)?;
|
||||||
|
match &v {
|
||||||
|
serde_json::Value::String(s) if s.is_empty() => Ok(None),
|
||||||
|
serde_json::Value::String(s) => {
|
||||||
|
s.parse::<u32>().map(Some).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
serde_json::Value::Number(n) => n
|
||||||
|
.as_u64()
|
||||||
|
.and_then(|n| u32::try_from(n).ok())
|
||||||
|
.map(Some)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("number out of u32 range")),
|
||||||
|
serde_json::Value::Null => Ok(None),
|
||||||
|
_ => Err(serde::de::Error::custom(
|
||||||
|
"expected string or number for u32",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root model for `GET /api/dnsmasq/settings/get`.
|
||||||
|
///
|
||||||
|
/// All boolean fields use OPNsense's `"1"`/`"0"` wire encoding.
|
||||||
|
/// Array fields (`hosts`, `domainoverrides`, etc.) are represented as
|
||||||
|
/// `serde_json::Value` — use `client.get_raw()` if you need to inspect them.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsmasqSettings {
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub enable: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub regdhcp: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub regdhcpstatic: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub dhcpfirst: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub strict_order: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub domain_needed: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub no_private_reverse: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub no_resolv: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub log_queries: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub no_hosts: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub strictbind: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub dnssec: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub regdhcpdomain: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub interface: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
#[serde(default, with = "crate::generated::dnsmasq::serde_helpers::opn_u32")]
|
||||||
|
pub port: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(default, with = "crate::generated::dnsmasq::serde_helpers::opn_u32")]
|
||||||
|
pub dns_forward_max: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(default, with = "crate::generated::dnsmasq::serde_helpers::opn_u32")]
|
||||||
|
pub cache_size: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(default, with = "crate::generated::dnsmasq::serde_helpers::opn_u32")]
|
||||||
|
pub local_ttl: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub add_mac: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub add_subnet: bool,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub strip_subnet: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub dhcp: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
#[serde(with = "crate::generated::dnsmasq::serde_helpers::opn_bool_req")]
|
||||||
|
pub no_ident: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub hosts: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub domainoverrides: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub dhcp_tags: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub dhcp_ranges: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub dhcp_options: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub dhcp_boot: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response wrapper for `GET /api/dnsmasq/settings/get`.
|
||||||
|
///
|
||||||
|
/// OPNsense returns `{ "dnsmasq": { ... } }` where the inner object is a
|
||||||
|
/// [`DnsmasqSettings`].
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsmasqSettingsResponse {
|
||||||
|
pub dnsmasq: DnsmasqSettings,
|
||||||
|
}
|
||||||
227
opnsense-api/src/generated/interfaces.rs
Normal file
227
opnsense-api/src/generated/interfaces.rs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
//! Auto-generated types for the **Interfaces/Settings** OPNsense model.
|
||||||
|
//!
|
||||||
|
//! Source XML: `core/src/opnsense/mvc/app/models/OPNsense/Interfaces/Settings.xml`
|
||||||
|
//!
|
||||||
|
//! Mount: `//OPNsense/Interfaces/settings`
|
||||||
|
//!
|
||||||
|
//! API endpoint: `GET /api/interfaces/settings/get`
|
||||||
|
//!
|
||||||
|
//! **DO NOT EDIT** — produced by `opnsense-codegen`.
|
||||||
|
//!
|
||||||
|
//! ## Response wrapper key
|
||||||
|
//!
|
||||||
|
//! This model uses `"settings"` as the JSON response key (the controller's
|
||||||
|
//! `internalModelName`). The wrapper struct is [`InterfacesSettingsResponse`].
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// VLAN hardware-filtering preference.
|
||||||
|
///
|
||||||
|
/// Serialized by OPNsense as `"opt0"` / `"opt1"` / `"opt2"`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Disablevlanhwfilter {
|
||||||
|
EnableVlanHardwareFiltering,
|
||||||
|
DisableVlanHardwareFiltering,
|
||||||
|
LeaveDefault,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-variant serde for [`Disablevlanhwfilter`].
|
||||||
|
pub(crate) mod serde_disablevlanhwfilter {
|
||||||
|
use super::Disablevlanhwfilter;
|
||||||
|
use log::debug;
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize<S: Serializer>(
|
||||||
|
value: &Option<Disablevlanhwfilter>,
|
||||||
|
serializer: S,
|
||||||
|
) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(match value {
|
||||||
|
Some(Disablevlanhwfilter::EnableVlanHardwareFiltering) => "opt0",
|
||||||
|
Some(Disablevlanhwfilter::DisableVlanHardwareFiltering) => "opt1",
|
||||||
|
Some(Disablevlanhwfilter::LeaveDefault) => "opt2",
|
||||||
|
None => "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Option<Disablevlanhwfilter>, D::Error> {
|
||||||
|
let v = serde_json::Value::deserialize(deserializer)?;
|
||||||
|
debug!("Disablevlanhwfilter deserializing {v}");
|
||||||
|
match v {
|
||||||
|
serde_json::Value::String(s) => match s.as_str() {
|
||||||
|
"opt0" => Ok(Some(Disablevlanhwfilter::EnableVlanHardwareFiltering)),
|
||||||
|
"opt1" => Ok(Some(Disablevlanhwfilter::DisableVlanHardwareFiltering)),
|
||||||
|
"opt2" => Ok(Some(Disablevlanhwfilter::LeaveDefault)),
|
||||||
|
"" => Ok(None),
|
||||||
|
other => Err(serde::de::Error::custom(format!(
|
||||||
|
"unknown Disablevlanhwfilter variant: {other}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
serde_json::Value::Null => Ok(None),
|
||||||
|
_ => Err(serde::de::Error::custom(
|
||||||
|
"expected string for Disablevlanhwfilter",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OPNsense serde helpers (same helpers used across all generated models) ───
|
||||||
|
|
||||||
|
pub mod opn_bool {
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
pub fn serialize<S: Serializer>(
|
||||||
|
value: &Option<bool>,
|
||||||
|
serializer: S,
|
||||||
|
) -> Result<S::Ok, S::Error> {
|
||||||
|
match value {
|
||||||
|
Some(true) => serializer.serialize_str("1"),
|
||||||
|
Some(false) => serializer.serialize_str("0"),
|
||||||
|
None => serializer.serialize_str(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Option<bool>, D::Error> {
|
||||||
|
let v = serde_json::Value::deserialize(deserializer)?;
|
||||||
|
match &v {
|
||||||
|
serde_json::Value::String(s) => match s.as_str() {
|
||||||
|
"1" | "true" => Ok(Some(true)),
|
||||||
|
"0" | "false" => Ok(Some(false)),
|
||||||
|
"" => Ok(None),
|
||||||
|
other => Err(serde::de::Error::custom(format!(
|
||||||
|
"invalid bool string: {other}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
serde_json::Value::Bool(b) => Ok(Some(*b)),
|
||||||
|
serde_json::Value::Number(n) => match n.as_u64() {
|
||||||
|
Some(1) => Ok(Some(true)),
|
||||||
|
Some(0) => Ok(Some(false)),
|
||||||
|
_ => Err(serde::de::Error::custom(format!(
|
||||||
|
"invalid bool number: {n}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
serde_json::Value::Null => Ok(None),
|
||||||
|
_ => Err(serde::de::Error::custom(
|
||||||
|
"expected string, bool, or number for bool field",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod opn_bool_req {
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
pub fn serialize<S: Serializer>(value: &bool, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(if *value { "1" } else { "0" })
|
||||||
|
}
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error> {
|
||||||
|
let v = serde_json::Value::deserialize(deserializer)?;
|
||||||
|
match &v {
|
||||||
|
serde_json::Value::String(s) => match s.as_str() {
|
||||||
|
"1" | "true" => Ok(true),
|
||||||
|
"0" | "false" => Ok(false),
|
||||||
|
other => Err(serde::de::Error::custom(format!(
|
||||||
|
"invalid required bool: {other}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
serde_json::Value::Bool(b) => Ok(*b),
|
||||||
|
serde_json::Value::Number(n) => match n.as_u64() {
|
||||||
|
Some(1) => Ok(true),
|
||||||
|
Some(0) => Ok(false),
|
||||||
|
_ => Err(serde::de::Error::custom(format!(
|
||||||
|
"invalid required bool number: {n}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
_ => Err(serde::de::Error::custom(
|
||||||
|
"expected string, bool, or number for required bool",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod opn_u32 {
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
pub fn serialize<S: Serializer>(value: &Option<u32>, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
match value {
|
||||||
|
Some(v) => serializer.serialize_str(&v.to_string()),
|
||||||
|
None => serializer.serialize_str(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn deserialize<'de, D: Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Option<u32>, D::Error> {
|
||||||
|
let v = serde_json::Value::deserialize(deserializer)?;
|
||||||
|
match &v {
|
||||||
|
serde_json::Value::String(s) if s.is_empty() => Ok(None),
|
||||||
|
serde_json::Value::String(s) => {
|
||||||
|
s.parse::<u32>().map(Some).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
serde_json::Value::Number(n) => n
|
||||||
|
.as_u64()
|
||||||
|
.and_then(|n| u32::try_from(n).ok())
|
||||||
|
.map(Some)
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("number out of u32 range")),
|
||||||
|
serde_json::Value::Null => Ok(None),
|
||||||
|
_ => Err(serde::de::Error::custom(
|
||||||
|
"expected string or number for u32",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Root model ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Root model for `GET /api/interfaces/settings/get`.
|
||||||
|
///
|
||||||
|
/// All boolean fields use OPNsense's `"1"`/`"0"` wire encoding.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct InterfacesSettings {
|
||||||
|
/// BooleanField — disable checksum offloading
|
||||||
|
#[serde(with = "crate::generated::interfaces::opn_bool_req")]
|
||||||
|
pub disablechecksumoffloading: bool,
|
||||||
|
|
||||||
|
/// BooleanField — disable segmentation offloading
|
||||||
|
#[serde(with = "crate::generated::interfaces::opn_bool_req")]
|
||||||
|
pub disablesegmentationoffloading: bool,
|
||||||
|
|
||||||
|
/// BooleanField — disable large receive offloading
|
||||||
|
#[serde(with = "crate::generated::interfaces::opn_bool_req")]
|
||||||
|
pub disablelargereceiveoffloading: bool,
|
||||||
|
|
||||||
|
/// OptionField: opt0|opt1|opt2 — VLAN hardware filtering
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
with = "crate::generated::interfaces::serde_disablevlanhwfilter"
|
||||||
|
)]
|
||||||
|
pub disablevlanhwfilter: Option<Disablevlanhwfilter>,
|
||||||
|
|
||||||
|
/// BooleanField — disable IPv6
|
||||||
|
#[serde(with = "crate::generated::interfaces::opn_bool_req")]
|
||||||
|
pub disableipv6: bool,
|
||||||
|
|
||||||
|
/// BooleanField — DHCPv6 no-release
|
||||||
|
#[serde(with = "crate::generated::interfaces::opn_bool_req")]
|
||||||
|
pub dhcp6_norelease: bool,
|
||||||
|
|
||||||
|
/// BooleanField — DHCPv6 debug
|
||||||
|
#[serde(with = "crate::generated::interfaces::opn_bool_req")]
|
||||||
|
pub dhcp6_debug: bool,
|
||||||
|
|
||||||
|
/// DUIDField — DHCPv6 DUID (optional)
|
||||||
|
#[serde(default)]
|
||||||
|
pub dhcp6_duid: Option<String>,
|
||||||
|
|
||||||
|
/// IntegerField — DHCPv6 release timeout (seconds)
|
||||||
|
#[serde(default, with = "crate::generated::interfaces::opn_u32")]
|
||||||
|
pub dhcp6_ratimeout: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response wrapper for `GET /api/interfaces/settings/get`.
|
||||||
|
///
|
||||||
|
/// OPNsense returns `{ "settings": { ... } }` where the inner object is an
|
||||||
|
/// [`InterfacesSettings`].
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct InterfacesSettingsResponse {
|
||||||
|
/// The controller's `internalModelName` is `"settings"`.
|
||||||
|
pub settings: InterfacesSettings,
|
||||||
|
}
|
||||||
73
opnsense-api/src/lib.rs
Normal file
73
opnsense-api/src/lib.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//! OPNsense typed API client.
|
||||||
|
//!
|
||||||
|
//! ## Design goals
|
||||||
|
//!
|
||||||
|
//! - **Generated types**: Model structs and enums are produced by `opnsense-codegen`
|
||||||
|
//! from OPNsense XML model files and placed in [`generated`].
|
||||||
|
//!
|
||||||
|
//! - **Hand-written runtime**: HTTP client, auth, and error handling live in this crate
|
||||||
|
//! and are never auto-generated.
|
||||||
|
//!
|
||||||
|
//! ## Crate layout
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! opnsense_api/
|
||||||
|
//! src/
|
||||||
|
//! lib.rs — public re-exports
|
||||||
|
//! error.rs — Error type
|
||||||
|
//! client.rs — OpnsenseClient
|
||||||
|
//! auth.rs — credentials helpers
|
||||||
|
//! generated/
|
||||||
|
//! dnsmasq.rs — types from Dnsmasq.xml
|
||||||
|
//! interfaces.rs — types from Interfaces/Settings.xml
|
||||||
|
//! ...
|
||||||
|
//! examples/
|
||||||
|
//! list_dnsmasq.rs
|
||||||
|
//! list_packages.rs
|
||||||
|
//! install_package.rs
|
||||||
|
//! firmware_update.rs
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Usage
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use opnsense_api::OpnsenseClient;
|
||||||
|
//!
|
||||||
|
//! let client = OpnsenseClient::builder()
|
||||||
|
//! .base_url("https://my-firewall.local/api")
|
||||||
|
//! .auth_from_key_secret("key", "secret")
|
||||||
|
//! .build()?;
|
||||||
|
//!
|
||||||
|
//! // GET a typed model response
|
||||||
|
//! let resp = client.get_typed::<dnsmasq::DnsmasqSettingsResponse>("dnsmasq", "settings", "get").await?;
|
||||||
|
//! println!("{:#?}", resp);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
pub use error::Error;
|
||||||
|
pub use client::OpnsenseClient;
|
||||||
|
|
||||||
|
pub mod generated {
|
||||||
|
//! Auto-generated model types.
|
||||||
|
//!
|
||||||
|
//! Each module corresponds to one OPNsense model (e.g. `interfaces`, `haproxy`,
|
||||||
|
//! `dnsmasq`). These files are produced by `opnsense-codegen` — do not edit
|
||||||
|
//! by hand.
|
||||||
|
//!
|
||||||
|
//! ## Standard module structure
|
||||||
|
//!
|
||||||
|
//! Every generated module exposes:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! pub mod serde_helpers { /* per-enum serde modules */ }
|
||||||
|
//! pub struct RootModel { ... }
|
||||||
|
//! pub struct RootModelResponse { pub model_key: RootModel }
|
||||||
|
//! pub enum SomeEnum { ... }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod dnsmasq;
|
||||||
|
pub mod interfaces;
|
||||||
|
}
|
||||||
24
opnsense-codegen/Cargo.toml
Normal file
24
opnsense-codegen/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "opnsense-codegen"
|
||||||
|
edition = "2024"
|
||||||
|
version.workspace = true
|
||||||
|
readme.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "opnsense-codegen"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
env_logger.workspace = true
|
||||||
|
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||||
|
heck = "0.5"
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions.workspace = true
|
||||||
92
opnsense-codegen/fixtures/example_service.xml
Normal file
92
opnsense-codegen/fixtures/example_service.xml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<model>
|
||||||
|
<mount>/example/service</mount>
|
||||||
|
<description>Example Service for Codegen Testing</description>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<items>
|
||||||
|
<enable type="BooleanField"/>
|
||||||
|
<name type="TextField">
|
||||||
|
<Required>Y</Required>
|
||||||
|
</name>
|
||||||
|
<port type="IntegerField">
|
||||||
|
<MinimumValue>1</MinimumValue>
|
||||||
|
<MaximumValue>65535</MaximumValue>
|
||||||
|
<Default>8080</Default>
|
||||||
|
</port>
|
||||||
|
<log_level type="OptionField">
|
||||||
|
<OptionValues>
|
||||||
|
<debug>Debug</debug>
|
||||||
|
<info>Info</info>
|
||||||
|
<warn>Warning</warn>
|
||||||
|
<error>Error</error>
|
||||||
|
</OptionValues>
|
||||||
|
<Default>info</Default>
|
||||||
|
</log_level>
|
||||||
|
<listen_address type="NetworkField">
|
||||||
|
<NetMaskAllowed>N</NetMaskAllowed>
|
||||||
|
</listen_address>
|
||||||
|
<interface type="InterfaceField">
|
||||||
|
<Multiple>Y</Multiple>
|
||||||
|
</interface>
|
||||||
|
<domain type="HostnameField">
|
||||||
|
<IsDNSName>Y</IsDNSName>
|
||||||
|
</domain>
|
||||||
|
<cache_size type="IntegerField">
|
||||||
|
<MinimumValue>0</MinimumValue>
|
||||||
|
</cache_size>
|
||||||
|
<upstream>
|
||||||
|
<dns_servers type="NetworkField">
|
||||||
|
<NetMaskAllowed>N</NetMaskAllowed>
|
||||||
|
<AsList>Y</AsList>
|
||||||
|
</dns_servers>
|
||||||
|
<use_system_dns type="BooleanField">
|
||||||
|
<Required>Y</Required>
|
||||||
|
<Default>1</Default>
|
||||||
|
</use_system_dns>
|
||||||
|
</upstream>
|
||||||
|
<hosts type="ArrayField">
|
||||||
|
<enabled type="BooleanField">
|
||||||
|
<Default>1</Default>
|
||||||
|
</enabled>
|
||||||
|
<hostname type="HostnameField">
|
||||||
|
<Required>Y</Required>
|
||||||
|
</hostname>
|
||||||
|
<ip type="NetworkField">
|
||||||
|
<NetMaskAllowed>N</NetMaskAllowed>
|
||||||
|
<Required>Y</Required>
|
||||||
|
</ip>
|
||||||
|
<tag type="ModelRelationField">
|
||||||
|
<Model>
|
||||||
|
<tag>
|
||||||
|
<source>OPNsense.Example.ExampleService</source>
|
||||||
|
<items>tags</items>
|
||||||
|
<display>name</display>
|
||||||
|
</tag>
|
||||||
|
</Model>
|
||||||
|
</tag>
|
||||||
|
<aliases type="HostnameField">
|
||||||
|
<AsList>Y</AsList>
|
||||||
|
</aliases>
|
||||||
|
<description type="TextField"/>
|
||||||
|
</hosts>
|
||||||
|
<tags type="ArrayField">
|
||||||
|
<name type="TextField">
|
||||||
|
<Required>Y</Required>
|
||||||
|
<Mask>/^[a-zA-Z0-9_]{1,64}$/</Mask>
|
||||||
|
<Constraints>
|
||||||
|
<check001>
|
||||||
|
<type>UniqueConstraint</type>
|
||||||
|
<ValidationMessage>Tag names must be unique.</ValidationMessage>
|
||||||
|
</check001>
|
||||||
|
</Constraints>
|
||||||
|
</name>
|
||||||
|
<color type="OptionField">
|
||||||
|
<OptionValues>
|
||||||
|
<red>Red</red>
|
||||||
|
<green>Green</green>
|
||||||
|
<blue>Blue</blue>
|
||||||
|
</OptionValues>
|
||||||
|
</color>
|
||||||
|
<description type="TextField"/>
|
||||||
|
</tags>
|
||||||
|
</items>
|
||||||
|
</model>
|
||||||
41
opnsense-codegen/fixtures/example_service_api_get.json
Normal file
41
opnsense-codegen/fixtures/example_service_api_get.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"exampleservice": {
|
||||||
|
"enable": "1",
|
||||||
|
"name": "myservice",
|
||||||
|
"port": "8080",
|
||||||
|
"log_level": "info",
|
||||||
|
"listen_address": "192.168.1.1",
|
||||||
|
"interface": "lan,wan",
|
||||||
|
"domain": "example.local",
|
||||||
|
"cache_size": "1000",
|
||||||
|
"upstream": {
|
||||||
|
"dns_servers": "8.8.8.8,8.8.4.4",
|
||||||
|
"use_system_dns": "1"
|
||||||
|
},
|
||||||
|
"hosts": {
|
||||||
|
"c9a1b2d3-e4f5-6789-abcd-ef0123456789": {
|
||||||
|
"enabled": "1",
|
||||||
|
"hostname": "server1",
|
||||||
|
"ip": "10.0.0.1",
|
||||||
|
"tag": "d1e2f3a4-b5c6-7890-abcd-ef0123456789",
|
||||||
|
"aliases": "srv1,server1.lan",
|
||||||
|
"description": "Primary server"
|
||||||
|
},
|
||||||
|
"a1b2c3d4-e5f6-7890-abcd-ef0123456789": {
|
||||||
|
"enabled": "0",
|
||||||
|
"hostname": "server2",
|
||||||
|
"ip": "10.0.0.2",
|
||||||
|
"tag": "",
|
||||||
|
"aliases": "",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"d1e2f3a4-b5c6-7890-abcd-ef0123456789": {
|
||||||
|
"name": "production",
|
||||||
|
"color": "green",
|
||||||
|
"description": "Production servers"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
254
opnsense-codegen/fixtures/example_service_ir.json
Normal file
254
opnsense-codegen/fixtures/example_service_ir.json
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
{
|
||||||
|
"mount": "/example/service",
|
||||||
|
"description": "Example Service for Codegen Testing",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"api_key": "exampleservice",
|
||||||
|
"root_struct_name": "ExampleService",
|
||||||
|
"enums": [
|
||||||
|
{
|
||||||
|
"name": "LogLevel",
|
||||||
|
"variants": [
|
||||||
|
{ "rust_name": "Debug", "wire_value": "debug" },
|
||||||
|
{ "rust_name": "Info", "wire_value": "info" },
|
||||||
|
{ "rust_name": "Warn", "wire_value": "warn" },
|
||||||
|
{ "rust_name": "Error", "wire_value": "error" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TagColor",
|
||||||
|
"variants": [
|
||||||
|
{ "rust_name": "Red", "wire_value": "red" },
|
||||||
|
{ "rust_name": "Green", "wire_value": "green" },
|
||||||
|
{ "rust_name": "Blue", "wire_value": "blue" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"structs": [
|
||||||
|
{
|
||||||
|
"name": "ExampleService",
|
||||||
|
"kind": "root",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "enable",
|
||||||
|
"rust_type": "Option<bool>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_bool",
|
||||||
|
"opn_type": "BooleanField",
|
||||||
|
"required": false,
|
||||||
|
"doc": "Enable service"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"rust_type": "String",
|
||||||
|
"opn_type": "TextField",
|
||||||
|
"required": true,
|
||||||
|
"doc": "Service name (required)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "port",
|
||||||
|
"rust_type": "Option<u16>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_u16",
|
||||||
|
"opn_type": "IntegerField",
|
||||||
|
"required": false,
|
||||||
|
"default": "8080",
|
||||||
|
"min": 1,
|
||||||
|
"max": 65535,
|
||||||
|
"doc": "Port number [1-65535] default: 8080"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "log_level",
|
||||||
|
"rust_type": "Option<LogLevel>",
|
||||||
|
"serde_with": "serde_log_level",
|
||||||
|
"opn_type": "OptionField",
|
||||||
|
"required": false,
|
||||||
|
"default": "info",
|
||||||
|
"enum_ref": "LogLevel",
|
||||||
|
"doc": "Log level: debug|info|warn|error, default: info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listen_address",
|
||||||
|
"rust_type": "Option<String>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_string",
|
||||||
|
"opn_type": "NetworkField",
|
||||||
|
"required": false,
|
||||||
|
"doc": "Listen IP address"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "interface",
|
||||||
|
"rust_type": "Option<Vec<String>>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_csv",
|
||||||
|
"opn_type": "InterfaceField",
|
||||||
|
"required": false,
|
||||||
|
"multiple": true,
|
||||||
|
"doc": "Bind interfaces (comma-separated)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "domain",
|
||||||
|
"rust_type": "Option<String>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_string",
|
||||||
|
"opn_type": "HostnameField",
|
||||||
|
"required": false,
|
||||||
|
"doc": "DNS domain name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cache_size",
|
||||||
|
"rust_type": "Option<u32>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_u32",
|
||||||
|
"opn_type": "IntegerField",
|
||||||
|
"required": false,
|
||||||
|
"min": 0,
|
||||||
|
"doc": "Cache size [0-∞)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "upstream",
|
||||||
|
"rust_type": "ExampleServiceUpstream",
|
||||||
|
"opn_type": "Container",
|
||||||
|
"required": true,
|
||||||
|
"field_kind": "container",
|
||||||
|
"struct_ref": "ExampleServiceUpstream",
|
||||||
|
"doc": "Upstream DNS settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hosts",
|
||||||
|
"rust_type": "HashMap<String, ExampleServiceHost>",
|
||||||
|
"opn_type": "ArrayField",
|
||||||
|
"required": false,
|
||||||
|
"field_kind": "array_field",
|
||||||
|
"struct_ref": "ExampleServiceHost",
|
||||||
|
"doc": "Host override entries (keyed by UUID)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tags",
|
||||||
|
"rust_type": "HashMap<String, ExampleServiceTag>",
|
||||||
|
"opn_type": "ArrayField",
|
||||||
|
"required": false,
|
||||||
|
"field_kind": "array_field",
|
||||||
|
"struct_ref": "ExampleServiceTag",
|
||||||
|
"doc": "Tag definitions (keyed by UUID)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ExampleServiceUpstream",
|
||||||
|
"kind": "container",
|
||||||
|
"json_key": "upstream",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "dns_servers",
|
||||||
|
"rust_type": "Option<Vec<String>>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_csv",
|
||||||
|
"opn_type": "NetworkField",
|
||||||
|
"required": false,
|
||||||
|
"as_list": true,
|
||||||
|
"doc": "Upstream DNS servers (comma-separated IPs)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "use_system_dns",
|
||||||
|
"rust_type": "bool",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_bool_req",
|
||||||
|
"opn_type": "BooleanField",
|
||||||
|
"required": true,
|
||||||
|
"default": "1",
|
||||||
|
"doc": "Use system DNS servers (required, default: true)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ExampleServiceHost",
|
||||||
|
"kind": "array_item",
|
||||||
|
"json_key": "hosts",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "enabled",
|
||||||
|
"rust_type": "Option<bool>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_bool",
|
||||||
|
"opn_type": "BooleanField",
|
||||||
|
"required": false,
|
||||||
|
"default": "1",
|
||||||
|
"doc": "Entry enabled (default: true)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hostname",
|
||||||
|
"rust_type": "String",
|
||||||
|
"opn_type": "HostnameField",
|
||||||
|
"required": true,
|
||||||
|
"doc": "Hostname (required)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ip",
|
||||||
|
"rust_type": "String",
|
||||||
|
"opn_type": "NetworkField",
|
||||||
|
"required": true,
|
||||||
|
"doc": "IP address (required)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tag",
|
||||||
|
"rust_type": "Option<String>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_string",
|
||||||
|
"opn_type": "ModelRelationField",
|
||||||
|
"required": false,
|
||||||
|
"relation": {
|
||||||
|
"source": "OPNsense.Example.ExampleService",
|
||||||
|
"items": "tags",
|
||||||
|
"display": "name"
|
||||||
|
},
|
||||||
|
"doc": "Associated tag UUID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aliases",
|
||||||
|
"rust_type": "Option<Vec<String>>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_csv",
|
||||||
|
"opn_type": "HostnameField",
|
||||||
|
"required": false,
|
||||||
|
"as_list": true,
|
||||||
|
"doc": "Hostname aliases (comma-separated)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"rust_type": "Option<String>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_string",
|
||||||
|
"opn_type": "TextField",
|
||||||
|
"required": false,
|
||||||
|
"doc": "Description"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ExampleServiceTag",
|
||||||
|
"kind": "array_item",
|
||||||
|
"json_key": "tags",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"rust_type": "String",
|
||||||
|
"opn_type": "TextField",
|
||||||
|
"required": true,
|
||||||
|
"mask": "/^[a-zA-Z0-9_]{1,64}$/",
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"type": "UniqueConstraint",
|
||||||
|
"message": "Tag names must be unique."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"doc": "Tag name (required, unique, alphanumeric+underscore, 1-64 chars)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "color",
|
||||||
|
"rust_type": "Option<TagColor>",
|
||||||
|
"serde_with": "serde_tag_color",
|
||||||
|
"opn_type": "OptionField",
|
||||||
|
"required": false,
|
||||||
|
"enum_ref": "TagColor",
|
||||||
|
"doc": "Tag color: red|green|blue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"rust_type": "Option<String>",
|
||||||
|
"serde_with": "crate::serde_helpers::opn_string",
|
||||||
|
"opn_type": "TextField",
|
||||||
|
"required": false,
|
||||||
|
"doc": "Description"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
304
opnsense-codegen/src/codegen.rs
Normal file
304
opnsense-codegen/src/codegen.rs
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
use crate::ir::{EnumIR, FieldIR, ModelIR, StructIR, StructKind};
|
||||||
|
use std::fmt::{Result as FmtResult, Write};
|
||||||
|
|
||||||
|
pub struct CodeGenerator {
|
||||||
|
output: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodeGenerator {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
output: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate(&mut self, model: &ModelIR) -> FmtResult {
|
||||||
|
let module_name = derive_module_name(&model.root_struct_name);
|
||||||
|
|
||||||
|
writeln!(self.output, "//! Auto-generated from OPNsense model XML")?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"//! Mount: `{}` — Version: `{}`",
|
||||||
|
model.mount, model.version
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, "//!")?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"//! **DO NOT EDIT** — produced by opnsense-codegen"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output)?;
|
||||||
|
writeln!(self.output, "use serde::{{Deserialize, Serialize}};")?;
|
||||||
|
writeln!(self.output, "use std::collections::HashMap;")?;
|
||||||
|
writeln!(self.output)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"// ═══════════════════════════════════════════════════════════════════════════"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, "// Enums")?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"// ═══════════════════════════════════════════════════════════════════════════"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output)?;
|
||||||
|
|
||||||
|
for enum_ir in &model.enums {
|
||||||
|
self.generate_enum(enum_ir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(self.output)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"// ═══════════════════════════════════════════════════════════════════════════"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, "// Structs")?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"// ═══════════════════════════════════════════════════════════════════════════"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output)?;
|
||||||
|
|
||||||
|
for struct_ir in &model.structs {
|
||||||
|
self.generate_struct(struct_ir, model)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(self.output)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"// ═══════════════════════════════════════════════════════════════════════════"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, "// API Wrapper")?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"// ═══════════════════════════════════════════════════════════════════════════"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output)?;
|
||||||
|
|
||||||
|
let response_name = format!("{}Response", model.root_struct_name);
|
||||||
|
let api_key = if model.api_key.is_empty() {
|
||||||
|
model.mount.trim_start_matches('/').replace('/', "")
|
||||||
|
} else {
|
||||||
|
model.api_key.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"/// Wrapper matching the OPNsense GET response envelope."
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"/// `GET /api/{}/get` returns {{ \"{}\": {{ ... }} }}",
|
||||||
|
api_key, api_key
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"#[derive(Debug, Clone, Serialize, Deserialize)]"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, "pub struct {} {{", response_name)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" pub {}: {},",
|
||||||
|
api_key, model.root_struct_name
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, "}}")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_enum(&mut self, enum_ir: &EnumIR) -> FmtResult {
|
||||||
|
let snake_name = to_snake_case(&enum_ir.name);
|
||||||
|
|
||||||
|
writeln!(self.output, "/// {}", enum_ir.name)?;
|
||||||
|
writeln!(self.output, "#[derive(Debug, Clone, PartialEq, Eq, Hash)]")?;
|
||||||
|
writeln!(self.output, "pub enum {} {{", enum_ir.name)?;
|
||||||
|
for variant in &enum_ir.variants {
|
||||||
|
writeln!(self.output, " {},", variant.rust_name)?;
|
||||||
|
}
|
||||||
|
writeln!(self.output, "}}")?;
|
||||||
|
writeln!(self.output)?;
|
||||||
|
|
||||||
|
writeln!(self.output, "pub(crate) mod serde_{} {{", snake_name)?;
|
||||||
|
writeln!(self.output, " use super::{};", enum_ir.name)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" use serde::{{Deserialize, Deserializer, Serializer}};"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output)?;
|
||||||
|
writeln!(self.output, " pub fn serialize<S: Serializer>(")?;
|
||||||
|
writeln!(self.output, " value: &Option<{}>,", enum_ir.name)?;
|
||||||
|
writeln!(self.output, " serializer: S,")?;
|
||||||
|
writeln!(self.output, " ) -> Result<S::Ok, S::Error> {{")?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" serializer.serialize_str(match value {{"
|
||||||
|
)?;
|
||||||
|
for variant in &enum_ir.variants {
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" Some({}::{}) => \"{}\",",
|
||||||
|
enum_ir.name, variant.rust_name, variant.wire_value
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
writeln!(self.output, " None => \"\",")?;
|
||||||
|
writeln!(self.output, " }})")?;
|
||||||
|
writeln!(self.output, " }}")?;
|
||||||
|
writeln!(self.output)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" pub fn deserialize<'de, D: Deserializer<'de>>("
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, " deserializer: D,")?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" ) -> Result<Option<{}>, D::Error> {{",
|
||||||
|
enum_ir.name
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" let v = serde_json::Value::deserialize(deserializer)?;"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, " match v {{")?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" serde_json::Value::String(s) => match s.as_str() {{"
|
||||||
|
)?;
|
||||||
|
for variant in &enum_ir.variants {
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" \"{}\" => Ok(Some({}::{})),",
|
||||||
|
variant.wire_value, enum_ir.name, variant.rust_name
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
writeln!(self.output, " \"\" => Ok(None),")?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" other => Err(serde::de::Error::custom(format!("
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" \"unknown {} variant: {{}}\", other",
|
||||||
|
enum_ir.name
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, " ))),")?;
|
||||||
|
writeln!(self.output, " }},")?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" serde_json::Value::Null => Ok(None),"
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" _ => Err(serde::de::Error::custom(\"expected string for {}\")),",
|
||||||
|
enum_ir.name
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, " }}")?;
|
||||||
|
writeln!(self.output, " }}")?;
|
||||||
|
writeln!(self.output, "}}")?;
|
||||||
|
writeln!(self.output)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_struct(&mut self, struct_ir: &StructIR, model: &ModelIR) -> FmtResult {
|
||||||
|
match struct_ir.kind {
|
||||||
|
StructKind::Root => {
|
||||||
|
writeln!(self.output, "/// Root model for `{}`", model.mount)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"#[derive(Debug, Clone, Serialize, Deserialize)]"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, "pub struct {} {{", struct_ir.name)?;
|
||||||
|
for field in &struct_ir.fields {
|
||||||
|
self.generate_field(field)?;
|
||||||
|
}
|
||||||
|
writeln!(self.output, "}}")?;
|
||||||
|
}
|
||||||
|
StructKind::Container => {
|
||||||
|
let doc = struct_ir
|
||||||
|
.json_key
|
||||||
|
.as_ref()
|
||||||
|
.map(|k| format!("Container for `{}`", k))
|
||||||
|
.unwrap_or_else(|| "Container".to_string());
|
||||||
|
writeln!(self.output, "/// {}", doc)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"#[derive(Debug, Clone, Serialize, Deserialize)]"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, "pub struct {} {{", struct_ir.name)?;
|
||||||
|
for field in &struct_ir.fields {
|
||||||
|
self.generate_field(field)?;
|
||||||
|
}
|
||||||
|
writeln!(self.output, "}}")?;
|
||||||
|
}
|
||||||
|
StructKind::ArrayItem => {
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"/// Array item for `{}`",
|
||||||
|
struct_ir.json_key.as_deref().unwrap_or("items")
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
"#[derive(Debug, Clone, Serialize, Deserialize)]"
|
||||||
|
)?;
|
||||||
|
writeln!(self.output, "pub struct {} {{", struct_ir.name)?;
|
||||||
|
for field in &struct_ir.fields {
|
||||||
|
self.generate_field(field)?;
|
||||||
|
}
|
||||||
|
writeln!(self.output, "}}")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeln!(self.output)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_field(&mut self, field: &FieldIR) -> FmtResult {
|
||||||
|
if let Some(ref doc) = field.doc {
|
||||||
|
writeln!(self.output, " /// {}", doc)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref serde_with) = field.serde_with {
|
||||||
|
if field.required {
|
||||||
|
writeln!(self.output, " #[serde(with = \"{}\")]", serde_with)?;
|
||||||
|
} else {
|
||||||
|
writeln!(
|
||||||
|
self.output,
|
||||||
|
" #[serde(default, with = \"{}\")]",
|
||||||
|
serde_with
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
} else if field.field_kind.as_deref() == Some("array_field") {
|
||||||
|
writeln!(self.output, " #[serde(default)]")?;
|
||||||
|
} else if !field.required {
|
||||||
|
writeln!(self.output, " #[serde(default)]")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(self.output, " pub {}: {},", field.name, field.rust_type)?;
|
||||||
|
writeln!(self.output)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_output(self) -> String {
|
||||||
|
self.output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_snake_case(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
for (i, c) in s.chars().enumerate() {
|
||||||
|
if c.is_uppercase() && i > 0 {
|
||||||
|
result.push('_');
|
||||||
|
}
|
||||||
|
result.push(c.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn derive_module_name(struct_name: &str) -> String {
|
||||||
|
to_snake_case(struct_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate(model: &ModelIR) -> String {
|
||||||
|
let mut generator = CodeGenerator::new();
|
||||||
|
generator
|
||||||
|
.generate(model)
|
||||||
|
.expect("generation should not fail");
|
||||||
|
generator.into_output()
|
||||||
|
}
|
||||||
1718
opnsense-codegen/src/lib.rs
Normal file
1718
opnsense-codegen/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
105
opnsense-codegen/src/main.rs
Normal file
105
opnsense-codegen/src/main.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "opnsense-codegen")]
|
||||||
|
#[command(about = "OPNsense SDK code generator", long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Parse an XML model file and output JSON IR
|
||||||
|
Parse {
|
||||||
|
/// Path to the XML model file
|
||||||
|
#[arg(long)]
|
||||||
|
xml: PathBuf,
|
||||||
|
/// Output the IR as JSON to stdout
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
ir_only: bool,
|
||||||
|
},
|
||||||
|
/// Generate Rust code from an XML model file
|
||||||
|
Generate {
|
||||||
|
/// Path to the XML model file
|
||||||
|
#[arg(long)]
|
||||||
|
xml: PathBuf,
|
||||||
|
/// Path to the TOML manifest (optional)
|
||||||
|
#[arg(long)]
|
||||||
|
manifest: Option<PathBuf>,
|
||||||
|
/// Output directory for generated files
|
||||||
|
#[arg(long)]
|
||||||
|
output_dir: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
/// Build all models from manifests and generate the full opnsense-client
|
||||||
|
Build {
|
||||||
|
/// Path to the manifests directory
|
||||||
|
#[arg(long, default_value = "manifests")]
|
||||||
|
manifests_dir: PathBuf,
|
||||||
|
/// Output directory for generated files
|
||||||
|
#[arg(long, default_value = "../opnsense-client/src")]
|
||||||
|
output_dir: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Parse { xml, ir_only } => {
|
||||||
|
let xml_data = std::fs::read(&xml)?;
|
||||||
|
let model = opnsense_codegen::parser::parse_xml(&xml_data)
|
||||||
|
.map_err(|e| format!("parse error: {}", e))?;
|
||||||
|
|
||||||
|
if ir_only {
|
||||||
|
let json = serde_json::to_string_pretty(&model)?;
|
||||||
|
println!("{}", json);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"Parsed model: {} (struct: {})",
|
||||||
|
model.mount, model.root_struct_name
|
||||||
|
);
|
||||||
|
println!(" {} enums", model.enums.len());
|
||||||
|
println!(" {} structs", model.structs.len());
|
||||||
|
for s in &model.structs {
|
||||||
|
println!(" - {} ({:?}): {} fields", s.name, s.kind, s.fields.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Commands::Generate {
|
||||||
|
xml,
|
||||||
|
manifest,
|
||||||
|
output_dir,
|
||||||
|
} => {
|
||||||
|
let xml_data = std::fs::read(&xml)?;
|
||||||
|
let model = opnsense_codegen::parser::parse_xml(&xml_data)
|
||||||
|
.map_err(|e| format!("parse error: {}", e))?;
|
||||||
|
|
||||||
|
let rust_code = opnsense_codegen::codegen::generate(&model);
|
||||||
|
|
||||||
|
if let Some(dir) = output_dir {
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
let module_name =
|
||||||
|
opnsense_codegen::codegen::derive_module_name(&model.root_struct_name);
|
||||||
|
let out_file = dir.join(format!("{}.rs", module_name));
|
||||||
|
std::fs::write(&out_file, &rust_code)?;
|
||||||
|
println!("Generated: {}", out_file.display());
|
||||||
|
} else {
|
||||||
|
println!("{}", rust_code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Commands::Build {
|
||||||
|
manifests_dir,
|
||||||
|
output_dir,
|
||||||
|
} => {
|
||||||
|
println!("Build command not yet implemented");
|
||||||
|
println!("Manifests dir: {}", manifests_dir.display());
|
||||||
|
println!("Output dir: {}", output_dir.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
1036
opnsense-codegen/src/parser.rs
Normal file
1036
opnsense-codegen/src/parser.rs
Normal file
File diff suppressed because it is too large
Load Diff
1
opnsense-codegen/vendor/core
vendored
Submodule
1
opnsense-codegen/vendor/core
vendored
Submodule
Submodule opnsense-codegen/vendor/core added at 92fa22970b
1
opnsense-codegen/vendor/plugins
vendored
Submodule
1
opnsense-codegen/vendor/plugins
vendored
Submodule
Submodule opnsense-codegen/vendor/plugins added at a24a88b038
Reference in New Issue
Block a user