use serde::{Deserialize, Serialize};
use crate::error::{SdkError, SdkResult};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Method {
Dhcp,
Static,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetworkConfig {
pub connection: String,
pub interface: String,
pub method: Method,
#[serde(default)]
pub address: Option<String>,
#[serde(default)]
pub prefix: Option<u8>,
#[serde(default)]
pub gateway: Option<String>,
#[serde(default)]
pub dns: Vec<String>,
}
struct CmdOutput {
success: bool,
stderr: String,
}
trait CommandRunner {
fn run(&self, args: &[&str]) -> SdkResult<CmdOutput>;
}
struct SystemRunner;
impl CommandRunner for SystemRunner {
fn run(&self, args: &[&str]) -> SdkResult<CmdOutput> {
let out = std::process::Command::new("nmcli")
.args(args)
.output()
.map_err(|e| SdkError::Net(format!("could not run nmcli (is it installed?): {e}")))?;
Ok(CmdOutput {
success: out.status.success(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
}
fn ip_args(cfg: &NetworkConfig) -> SdkResult<Vec<String>> {
let mut v: Vec<String> = Vec::new();
match cfg.method {
Method::Dhcp => {
v.extend(["ipv4.method".into(), "auto".into()]);
v.extend(["ipv4.addresses".into(), String::new()]);
v.extend(["ipv4.gateway".into(), String::new()]);
}
Method::Static => {
let address = cfg
.address
.as_deref()
.ok_or_else(|| SdkError::Net("static method requires an address".into()))?;
let prefix = cfg
.prefix
.ok_or_else(|| SdkError::Net("static method requires a prefix".into()))?;
v.extend(["ipv4.method".into(), "manual".into()]);
v.extend(["ipv4.addresses".into(), format!("{address}/{prefix}")]);
if let Some(gw) = &cfg.gateway {
v.extend(["ipv4.gateway".into(), gw.clone()]);
}
}
}
if !cfg.dns.is_empty() {
v.extend(["ipv4.dns".into(), cfg.dns.join(" ")]);
}
Ok(v)
}
pub fn apply(cfg: &NetworkConfig) -> SdkResult<()> {
apply_with(cfg, &SystemRunner)
}
fn apply_with(cfg: &NetworkConfig, runner: &dyn CommandRunner) -> SdkResult<()> {
let ip = ip_args(cfg)?;
let exists = runner
.run(&["connection", "show", &cfg.connection])?
.success;
let mut args: Vec<String> = if exists {
vec!["connection".into(), "modify".into(), cfg.connection.clone()]
} else {
vec![
"connection".into(),
"add".into(),
"type".into(),
"ethernet".into(),
"con-name".into(),
cfg.connection.clone(),
"ifname".into(),
cfg.interface.clone(),
]
};
args.extend(ip);
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
let out = runner.run(&argv)?;
if !out.success {
let verb = if exists { "modify" } else { "add" };
return Err(SdkError::Net(format!(
"nmcli connection {verb} failed: {}",
out.stderr.trim()
)));
}
let up = runner.run(&["connection", "up", &cfg.connection])?;
if !up.success {
return Err(SdkError::Net(format!(
"nmcli connection up failed: {}",
up.stderr.trim()
)));
}
tracing::info!(connection = %cfg.connection, "applied retained network config");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct RecordingRunner {
calls: RefCell<Vec<Vec<String>>>,
exists: bool,
fail_apply: bool,
}
impl RecordingRunner {
fn new(exists: bool, fail_apply: bool) -> Self {
Self {
calls: RefCell::new(Vec::new()),
exists,
fail_apply,
}
}
fn calls(&self) -> Vec<Vec<String>> {
self.calls.borrow().clone()
}
}
impl CommandRunner for RecordingRunner {
fn run(&self, args: &[&str]) -> SdkResult<CmdOutput> {
let argv: Vec<String> = args.iter().map(|s| s.to_string()).collect();
self.calls.borrow_mut().push(argv.clone());
if argv.first().map(String::as_str) == Some("connection")
&& argv.get(1).map(String::as_str) == Some("show")
{
return Ok(CmdOutput {
success: self.exists,
stderr: String::new(),
});
}
let verb = argv.get(1).map(String::as_str);
if self.fail_apply && (verb == Some("add") || verb == Some("modify")) {
return Ok(CmdOutput {
success: false,
stderr: "nmcli: boom".into(),
});
}
Ok(CmdOutput {
success: true,
stderr: String::new(),
})
}
}
fn dhcp_cfg() -> NetworkConfig {
NetworkConfig {
connection: "lan".into(),
interface: "eth0".into(),
method: Method::Dhcp,
address: None,
prefix: None,
gateway: None,
dns: vec![],
}
}
fn static_cfg() -> NetworkConfig {
NetworkConfig {
connection: "lan".into(),
interface: "eth0".into(),
method: Method::Static,
address: Some("192.168.1.50".into()),
prefix: Some(24),
gateway: Some("192.168.1.1".into()),
dns: vec!["1.1.1.1".into(), "8.8.8.8".into()],
}
}
#[test]
fn dhcp_ip_args_use_auto() {
let args = ip_args(&dhcp_cfg()).unwrap();
let joined = args.join(" ");
assert!(joined.contains("ipv4.method auto"), "got {joined:?}");
}
#[test]
fn static_ip_args_carry_address_gateway_dns() {
let args = ip_args(&static_cfg()).unwrap();
let joined = args.join(" ");
assert!(joined.contains("ipv4.method manual"), "got {joined:?}");
assert!(
joined.contains("ipv4.addresses 192.168.1.50/24"),
"got {joined:?}"
);
assert!(
joined.contains("ipv4.gateway 192.168.1.1"),
"got {joined:?}"
);
assert!(
joined.contains("ipv4.dns 1.1.1.1 8.8.8.8"),
"got {joined:?}"
);
}
#[test]
fn static_without_address_errors() {
let mut cfg = static_cfg();
cfg.address = None;
assert!(matches!(ip_args(&cfg), Err(SdkError::Net(_))));
}
#[test]
fn apply_adds_when_connection_absent() {
let runner = RecordingRunner::new(false, false);
apply_with(&static_cfg(), &runner).unwrap();
let calls = runner.calls();
assert_eq!(calls[0][0..2], ["connection", "show"]);
assert_eq!(calls[1][0..2], ["connection", "add"]);
assert!(calls[1].contains(&"ifname".to_string()));
assert!(calls[1].contains(&"eth0".to_string()));
assert_eq!(calls[2][0..3], ["connection", "up", "lan"]);
}
#[test]
fn apply_modifies_when_connection_exists() {
let runner = RecordingRunner::new(true, false);
apply_with(&dhcp_cfg(), &runner).unwrap();
let calls = runner.calls();
assert_eq!(calls[1][0..3], ["connection", "modify", "lan"]);
assert!(!calls[1].contains(&"add".to_string()));
}
#[test]
fn apply_surfaces_nmcli_failure_with_stderr() {
let runner = RecordingRunner::new(false, true);
let err = apply_with(&dhcp_cfg(), &runner).unwrap_err();
match err {
SdkError::Net(msg) => assert!(msg.contains("boom"), "got {msg}"),
other => panic!("expected Net error, got {other:?}"),
}
}
#[cfg(feature = "retain")]
#[test]
fn network_config_serde_round_trips() {
let cfg = static_cfg();
let bytes = postcard::to_stdvec(&cfg).unwrap();
let back: NetworkConfig = postcard::from_bytes(&bytes).unwrap();
assert_eq!(cfg, back);
}
}