diff --git a/rust/Cargo.lock b/rust/Cargo.lock index fa2c072e0b..3fccd864bf 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -33,7 +33,8 @@ dependencies = [ "indicatif", "log", "nix 0.27.1", - "reqwest", + "reqwest 0.11.27", + "rpassword", "serde", "serde_json", "serde_yaml", @@ -64,6 +65,7 @@ dependencies = [ "futures-util", "jsonschema", "log", + "reqwest 0.12.4", "serde", "serde_json", "serde_repr", @@ -513,7 +515,7 @@ dependencies = [ "axum", "axum-core", "bytes", - "cookie", + "cookie 0.18.0", "futures-util", "headers", "http 1.1.0", @@ -554,6 +556,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" + [[package]] name = "bindgen" version = "0.69.4" @@ -873,6 +881,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "cookie" version = "0.18.0" @@ -884,6 +903,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie_store" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "387461abbc748185c3a6e1673d826918b450b87ff22639429c694619a83b6cf6" +dependencies = [ + "cookie 0.17.0", + "idna 0.3.0", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1621,6 +1657,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -1636,6 +1673,22 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.2.0", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.3" @@ -1643,6 +1696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" dependencies = [ "bytes", + "futures-channel", "futures-util", "http 1.1.0", "http-body 1.0.0", @@ -1650,6 +1704,9 @@ dependencies = [ "pin-project-lite", "socket2 0.5.6", "tokio", + "tower", + "tower-service", + "tracing", ] [[package]] @@ -1681,6 +1738,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.5.0" @@ -2630,6 +2697,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457" +dependencies = [ + "idna 0.3.0", + "psl-types", +] + [[package]] name = "quick-xml" version = "0.28.2" @@ -2732,7 +2815,51 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.28", - "hyper-tls", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.50.0", +] + +[[package]] +name = "reqwest" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10" +dependencies = [ + "base64 0.22.0", + "bytes", + "cookie 0.17.0", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.3", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.2.0", + "hyper-tls 0.6.0", + "hyper-util", "ipnet", "js-sys", "log", @@ -2741,7 +2868,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "rustls-pemfile 2.1.2", "serde", "serde_json", "serde_urlencoded", @@ -2754,7 +2881,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "winreg", + "winreg 0.52.0", ] [[package]] @@ -2784,6 +2911,27 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "rpassword" +version = "7.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.48.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "rust-ini" version = "0.19.0" @@ -2842,6 +2990,22 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pemfile" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +dependencies = [ + "base64 0.22.0", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beb461507cee2c2ff151784c52762cf4d9ff6a61f3e80968600ed24fa837fa54" + [[package]] name = "rustversion" version = "1.0.14" @@ -3711,7 +3875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", ] @@ -4093,6 +4257,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "xdg-home" version = "1.1.0" diff --git a/rust/agama-cli/Cargo.toml b/rust/agama-cli/Cargo.toml index 8a073f259e..bcfcab6dd0 100644 --- a/rust/agama-cli/Cargo.toml +++ b/rust/agama-cli/Cargo.toml @@ -27,6 +27,7 @@ tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] } async-trait = "0.1.77" reqwest = { version = "0.11", features = ["json"] } home = "0.5.9" +rpassword = "7.3.1" [[bin]] name = "agama" diff --git a/rust/agama-cli/src/auth.rs b/rust/agama-cli/src/auth.rs index a93441bcd8..0b669a0856 100644 --- a/rust/agama-cli/src/auth.rs +++ b/rust/agama-cli/src/auth.rs @@ -9,6 +9,7 @@ use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; const DEFAULT_JWT_FILE: &str = ".agama/agama-jwt"; +const DEFAULT_AGAMA_TOKEN_FILE: &str = "/run/agama/token"; const DEFAULT_AUTH_URL: &str = "http://localhost:3000/api/auth"; const DEFAULT_FILE_MODE: u32 = 0o600; @@ -33,8 +34,19 @@ pub async fn run(subcommand: AuthCommands) -> anyhow::Result<()> { } } +/// Returns the stored Agama token. +pub fn agama_token() -> anyhow::Result { + if let Some(file) = agama_token_file() { + if let Ok(token) = read_line_from_file(file.as_path()) { + return Ok(token); + } + } + + Err(anyhow::anyhow!("Authentication token not available")) +} + /// Reads stored token and returns it -fn jwt() -> anyhow::Result { +pub fn jwt() -> anyhow::Result { if let Some(file) = jwt_file() { if let Ok(token) = read_line_from_file(file.as_path()) { return Ok(token); @@ -109,6 +121,10 @@ impl Credentials for MissingCredentials { fn jwt_file() -> Option { Some(home::home_dir()?.join(DEFAULT_JWT_FILE)) } +/// Path to agama-live token file. +fn agama_token_file() -> Option { + home::home_dir().map(|p| p.join(DEFAULT_AGAMA_TOKEN_FILE)) +} /// Reads first line from given file fn read_line_from_file(path: &Path) -> io::Result { @@ -137,12 +153,9 @@ fn read_line_from_file(path: &Path) -> io::Result { /// Asks user to provide a line of input. Displays a prompt. fn read_credential(caption: String) -> io::Result { - let mut cred = String::new(); - - println!("{}: ", caption); - - io::stdin().read_line(&mut cred)?; - if cred.pop().is_none() || cred.is_empty() { + let caption = format!("{}: ", caption); + let cred = rpassword::prompt_password(caption.clone()).unwrap(); + if cred.is_empty() { return Err(io::Error::new( io::ErrorKind::Other, format!("Failed to read {}", caption), diff --git a/rust/agama-cli/src/config.rs b/rust/agama-cli/src/config.rs index 62901e24a3..627dd82c4d 100644 --- a/rust/agama-cli/src/config.rs +++ b/rust/agama-cli/src/config.rs @@ -1,13 +1,17 @@ -use crate::error::CliError; -use crate::printers::{print, Format}; -use agama_lib::connection; -use agama_lib::install_settings::{InstallSettings, Scope}; -use agama_lib::Store as SettingsStore; +use crate::{ + auth, + error::CliError, + printers::{print, Format}, +}; +use agama_lib::{ + connection, + install_settings::{InstallSettings, Scope}, + Store as SettingsStore, +}; use agama_settings::{settings::Settings, SettingObject, SettingValue}; use clap::Subcommand; use convert_case::{Case, Casing}; -use std::str::FromStr; -use std::{collections::HashMap, error::Error, io}; +use std::{collections::HashMap, error::Error, io, str::FromStr}; #[derive(Subcommand, Debug)] pub enum ConfigCommands { @@ -31,8 +35,18 @@ pub enum ConfigAction { Load(String), } +fn token() -> Option { + auth::jwt().or_else(|_| auth::agama_token()).ok() +} + pub async fn run(subcommand: ConfigCommands, format: Format) -> anyhow::Result<()> { - let store = SettingsStore::new(connection().await?).await?; + let Some(token) = token() else { + println!("You need to login for generating a valid token"); + return Ok(()); + }; + + let client = agama_lib::http_client(token)?; + let store = SettingsStore::new(connection().await?, client).await?; let command = parse_config_command(subcommand)?; match command { diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 842c93e38e..8bab6d10a4 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -14,6 +14,7 @@ curl = { version = "0.4.44", features = ["protocol-ftp"] } futures-util = "0.3.29" jsonschema = { version = "0.16.1", default-features = false } log = "0.4" +reqwest = { version = "0.12.4", features = ["json", "cookies"] } serde = { version = "1.0.152", features = ["derive"] } serde_json = "1.0.94" serde_repr = "0.1.18" diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index 30f61af834..b09e7ede3e 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -18,6 +18,9 @@ pub enum ServiceError { // specific error will be printed too #[error("Error: {0}")] Anyhow(#[from] anyhow::Error), + // FIXME: It is too generic and starting to looks like an Anyhow error + #[error("Network client error: '{0}'")] + NetworkClientError(String), #[error("Wrong user parameters: '{0:?}'")] WrongUser(Vec), #[error("Registration failed: '{0}'")] diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index f4ff7dfaea..05869aa81b 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -41,6 +41,7 @@ mod store; pub use store::Store; pub mod questions; use crate::error::ServiceError; +use reqwest::{header, Client}; const ADDRESS: &str = "unix:path=/run/agama/bus"; @@ -55,3 +56,18 @@ pub async fn connection_to(address: &str) -> Result Result { + let mut headers = header::HeaderMap::new(); + let value = header::HeaderValue::from_str(format!("Bearer {}", token).as_str()) + .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; + + headers.insert(header::AUTHORIZATION, value); + + let client = Client::builder() + .default_headers(headers) + .build() + .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; + + Ok(client) +} diff --git a/rust/agama-lib/src/network/client.rs b/rust/agama-lib/src/network/client.rs index a76bf4c431..20f4f7930b 100644 --- a/rust/agama-lib/src/network/client.rs +++ b/rust/agama-lib/src/network/client.rs @@ -1,382 +1,110 @@ -use super::proxies::{ - BondProxy, ConnectionProxy, ConnectionsProxy, DeviceProxy, DevicesProxy, IPProxy, MatchProxy, - WirelessProxy, -}; use super::settings::{BondSettings, MatchSettings, NetworkConnection, WirelessSettings}; use super::types::{Device, DeviceState, DeviceType, SSID}; use crate::error::ServiceError; -use tokio_stream::StreamExt; -use zbus::zvariant::OwnedObjectPath; -use zbus::Connection; +use reqwest::{Client, Response}; +use serde_json; -/// D-BUS client for the network service -pub struct NetworkClient<'a> { - pub connection: Connection, - connections_proxy: ConnectionsProxy<'a>, - devices_proxy: DevicesProxy<'a>, -} +const API_URL: &str = "http://localhost:3000/api/network"; -impl<'a> NetworkClient<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { - Ok(Self { - connections_proxy: ConnectionsProxy::new(&connection).await?, - devices_proxy: DevicesProxy::new(&connection).await?, - connection, - }) - } +/// HTTP/JSON client for the network service +pub struct NetworkClient { + pub client: Client, +} - pub async fn get_connection(&self, id: &str) -> Result { - let path = self.connections_proxy.get_connection_by_id(id).await?; - self.connection_from(path.as_str()).await +impl NetworkClient { + pub async fn new(client: Client) -> Result { + Ok(Self { client }) } - pub async fn available_devices(&self) -> Result, ServiceError> { - let devices_paths = self.devices_proxy.get_devices().await?; - let mut devices = vec![]; + async fn text_for(&self, response: Response) -> Result { + let status = response.status(); + let text = response + .text() + .await + .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; - for path in devices_paths { - let device = self.device_from(path.as_str()).await?; - - devices.push(device); + if status != 200 { + return Err(ServiceError::NetworkClientError(text)); } - Ok(devices) + Ok(text) } - /// Returns an array of network connections - pub async fn connections(&self) -> Result, ServiceError> { - let connection_paths = self.connections_proxy.get_connections().await?; - let mut connections = vec![]; - - for path in connection_paths { - let mut connection = self.connection_from(path.as_str()).await?; - - if let Ok(bond) = self.bond_from(path.as_str()).await { - connection.bond = Some(bond); - } - - if let Ok(wireless) = self.wireless_from(path.as_str()).await { - connection.wireless = Some(wireless); - } - - let match_settings = self.match_settings_from(path.as_str()).await?; - if !match_settings.is_empty() { - connection.match_settings = Some(match_settings); - } + async fn get(&self, path: &str) -> Result { + let response = self + .client + .get(format!("{API_URL}{path}")) + .send() + .await + .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; - connections.push(connection); - } - - Ok(connections) + self.text_for(response).await } - /// Applies the network configuration. - pub async fn apply(&self) -> Result<(), ServiceError> { - self.connections_proxy.apply().await?; - Ok(()) - } + /// Returns an array of network devices + pub async fn devices(&self) -> Result, ServiceError> { + let text = self.get("/devices").await?; - /// Returns the NetworkDevice for the given device path - /// - /// * `path`: the connections path to get the config from - async fn device_from(&self, path: &str) -> Result { - let device_proxy = DeviceProxy::builder(&self.connection) - .path(path)? - .build() - .await?; - let name = device_proxy.name().await?; - let device_type = device_proxy.type_().await?; - let state = DeviceState::try_from(device_proxy.state().await?).expect("Unknown state"); + let json: Vec = serde_json::from_str(&text) + .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; - Ok(Device { - name, - type_: DeviceType::try_from(device_type).expect("Unknown type"), - state, - }) + Ok(json) } - /// Returns the NetworkConnection for the given connection path - /// - /// * `path`: the connections path to get the config from - async fn connection_from(&self, path: &str) -> Result { - let connection_proxy = ConnectionProxy::builder(&self.connection) - .path(path)? - .build() - .await?; - let id = connection_proxy.id().await?; - let interface = match connection_proxy.interface().await?.as_str() { - "" => None, - value => Some(value.to_string()), - }; - let mac_address = match connection_proxy.mac_address().await?.as_str() { - "" => None, - value => Some(value.to_string()), - }; - - let ip_proxy = IPProxy::builder(&self.connection) - .path(path)? - .build() - .await?; - - let method4 = ip_proxy.method4().await?; - let gateway4 = ip_proxy.gateway4().await?.parse().ok(); - let method6 = ip_proxy.method6().await?; - let gateway6 = ip_proxy.gateway6().await?.parse().ok(); - let nameservers = ip_proxy.nameservers().await?; - let nameservers = nameservers.iter().filter_map(|a| a.parse().ok()).collect(); - let addresses = ip_proxy.addresses().await?; - let addresses = addresses.iter().filter_map(|a| a.parse().ok()).collect(); - - Ok(NetworkConnection { - id, - method4: Some(method4.to_string()), - gateway4, - method6: Some(method6.to_string()), - gateway6, - addresses, - nameservers, - interface, - mac_address, - ..Default::default() - }) - } + /// Returns an array of network connections + pub async fn connections(&self) -> Result, ServiceError> { + let text = self.get("/connections").await?; - /// Returns the [bond settings][BondSettings] for the given connection - /// - /// * `path`: the connection's path to get the wireless config from - async fn bond_from(&self, path: &str) -> Result { - let bond_proxy = BondProxy::builder(&self.connection) - .path(path)? - .build() - .await?; - let bond = BondSettings { - mode: bond_proxy.mode().await?, - options: Some(bond_proxy.options().await?), - ports: bond_proxy.ports().await?, - }; + let json: Vec = serde_json::from_str(&text) + .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; - Ok(bond) + Ok(json) } - /// Returns the [wireless settings][WirelessSettings] for the given connection - /// - /// * `path`: the connections path to get the wireless config from - async fn wireless_from(&self, path: &str) -> Result { - let wireless_proxy = WirelessProxy::builder(&self.connection) - .path(path)? - .build() - .await?; - let wireless = WirelessSettings { - mode: wireless_proxy.mode().await?, - password: wireless_proxy.password().await?, - security: wireless_proxy.security().await?, - ssid: SSID(wireless_proxy.ssid().await?).to_string(), - }; - Ok(wireless) - } + /// Returns an array of network connections + pub async fn connection(&self, id: &str) -> Result { + let text = self.get(format!("/connections/{id}").as_str()).await?; + let json: NetworkConnection = serde_json::from_str(&text) + .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; - /// Returns the [match settings][MatchSettings] for the given connection - /// - /// * `path`: the connections path to get the match settings from - async fn match_settings_from(&self, path: &str) -> Result { - let match_proxy = MatchProxy::builder(&self.connection) - .path(path)? - .build() - .await?; - let match_settings = MatchSettings { - path: match_proxy.path().await?, - kernel: match_proxy.kernel().await?, - interface: match_proxy.interface().await?, - driver: match_proxy.driver().await?, - }; - Ok(match_settings) + Ok(json) } - /// Adds or updates a network connection. - /// - /// If a network connection with the same name exists, it updates its settings. Otherwise, it - /// adds a new connection. - /// - /// * `conn`: settings of the network connection to add/update. + /// Returns an array of network connections pub async fn add_or_update_connection( &self, - conn: &NetworkConnection, - ) -> Result<(), ServiceError> { - let path = match self.connections_proxy.get_connection_by_id(&conn.id).await { - Ok(path) => path, - Err(_) => self.add_connection(conn).await?, - }; - - self.update_connection(&path, conn).await?; - Ok(()) - } - - /// Adds a network connection. - /// - /// * `conn`: settings of the network connection to add. - async fn add_connection( - &self, - conn: &NetworkConnection, - ) -> Result { - let mut stream = self.connections_proxy.receive_connection_added().await?; - - self.connections_proxy - .add_connection(&conn.id, conn.device_type() as u8) - .await?; - - loop { - let signal = stream.next().await.unwrap(); - let (id, _path): (String, OwnedObjectPath) = signal.body().unwrap(); - if id == conn.id { - break; - }; - } - - Ok(self - .connections_proxy - .get_connection_by_id(&conn.id) - .await?) - } - - /// Updates a network connection. - /// - /// * `path`: connection D-Bus path. - /// * `conn`: settings of the network connection. - async fn update_connection( - &self, - path: &OwnedObjectPath, - conn: &NetworkConnection, - ) -> Result<(), ServiceError> { - let proxy = ConnectionProxy::builder(&self.connection) - .path(path)? - .build() - .await?; - - if let Some(ref interface) = conn.interface { - proxy.set_interface(interface).await?; - } - - let mac_address = conn.mac_address.as_deref().unwrap_or(""); - proxy.set_mac_address(mac_address).await?; - - self.update_ip_settings(path, conn).await?; - - if let Some(ref bond) = conn.bond { - self.update_bond_settings(path, bond).await?; - } - - if let Some(ref wireless) = conn.wireless { - self.update_wireless_settings(path, wireless).await?; - } - - if let Some(ref match_settings) = conn.match_settings { - self.update_match_settings(path, match_settings).await?; - } - - Ok(()) - } - - /// Updates the IPv4 setttings for the network connection. - /// - /// * `path`: connection D-Bus path. - /// * `conn`: network connection. - async fn update_ip_settings( - &self, - path: &OwnedObjectPath, - conn: &NetworkConnection, + connection: NetworkConnection, ) -> Result<(), ServiceError> { - let proxy = IPProxy::builder(&self.connection) - .path(path)? - .build() - .await?; - - if let Some(ref method) = conn.method4 { - proxy.set_method4(method.as_str()).await?; + let id = connection.id.clone(); + let response = self.connection(id.as_str()).await; + + if response.is_ok() { + let path = format!("{API_URL}/connections/{id}"); + self.client + .put(path) + .json(&connection) + .send() + .await + .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; + } else { + self.client + .post(format!("{API_URL}/connections").as_str()) + .json(&connection) + .send() + .await + .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; } - if let Some(ref method) = conn.method6 { - proxy.set_method6(method.as_str()).await?; - } - - let addresses: Vec<_> = conn.addresses.iter().map(|a| a.to_string()).collect(); - let addresses: Vec<&str> = addresses.iter().map(|a| a.as_str()).collect(); - proxy.set_addresses(&addresses).await?; - - let nameservers: Vec<_> = conn.nameservers.iter().map(|a| a.to_string()).collect(); - let nameservers: Vec<_> = nameservers.iter().map(|a| a.as_str()).collect(); - proxy.set_nameservers(&nameservers).await?; - - let gateway = conn.gateway4.map_or(String::from(""), |g| g.to_string()); - proxy.set_gateway4(&gateway).await?; - - let gateway = conn.gateway6.map_or(String::from(""), |g| g.to_string()); - proxy.set_gateway6(&gateway).await?; - Ok(()) } - /// Updates the bond settings for a network connection. - /// - /// * `path`: connection D-Bus path. - /// * `bond`: bond settings of the network connection. - async fn update_bond_settings( - &self, - path: &OwnedObjectPath, - bond: &BondSettings, - ) -> Result<(), ServiceError> { - let proxy = BondProxy::builder(&self.connection) - .path(path)? - .build() - .await?; - - let ports: Vec<_> = bond.ports.iter().map(String::as_ref).collect(); - proxy.set_ports(ports.as_slice()).await?; - if let Some(ref options) = bond.options { - proxy.set_options(options.to_string().as_str()).await?; - } - proxy.set_mode(bond.mode.as_str()).await?; - - Ok(()) - } - /// Updates the wireless settings for network connection. - /// - /// * `path`: connection D-Bus path. - /// * `wireless`: wireless settings of the network connection. - async fn update_wireless_settings( - &self, - path: &OwnedObjectPath, - wireless: &WirelessSettings, - ) -> Result<(), ServiceError> { - let proxy = WirelessProxy::builder(&self.connection) - .path(path)? - .build() - .await?; - - proxy.set_ssid(wireless.ssid.as_bytes()).await?; - proxy.set_mode(wireless.mode.to_string().as_str()).await?; - proxy - .set_security(wireless.security.to_string().as_str()) - .await?; - proxy.set_password(&wireless.password).await?; - Ok(()) - } - - /// Updates the match settings for network connection. - /// - /// * `path`: connection D-Bus path. - /// * `match_settings`: match settings of the network connection. - async fn update_match_settings( - &self, - path: &OwnedObjectPath, - match_settings: &MatchSettings, - ) -> Result<(), ServiceError> { - let proxy = MatchProxy::builder(&self.connection) - .path(path)? - .build() - .await?; - - let paths: Vec<_> = match_settings.path.iter().map(String::as_ref).collect(); - proxy.set_path(paths.as_slice()).await?; + /// Returns an array of network connections + pub async fn apply(&self) -> Result<(), ServiceError> { + self.client + .put(format!("{API_URL}/system/apply")) + .send() + .await + .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; Ok(()) } diff --git a/rust/agama-lib/src/network/settings.rs b/rust/agama-lib/src/network/settings.rs index 444e9c0b2d..d5d4a3584e 100644 --- a/rust/agama-lib/src/network/settings.rs +++ b/rust/agama-lib/src/network/settings.rs @@ -41,8 +41,8 @@ impl MatchSettings { #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct WirelessSettings { - #[serde(skip_serializing_if = "String::is_empty")] - pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, pub security: String, pub ssid: String, pub mode: String, diff --git a/rust/agama-lib/src/network/store.rs b/rust/agama-lib/src/network/store.rs index 7ed70c332e..401ce0b440 100644 --- a/rust/agama-lib/src/network/store.rs +++ b/rust/agama-lib/src/network/store.rs @@ -1,17 +1,16 @@ use super::settings::NetworkConnection; use crate::error::ServiceError; use crate::network::{NetworkClient, NetworkSettings}; -use zbus::Connection; /// Loads and stores the network settings from/to the D-Bus service. -pub struct NetworkStore<'a> { - network_client: NetworkClient<'a>, +pub struct NetworkStore { + network_client: NetworkClient, } -impl<'a> NetworkStore<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { +impl NetworkStore { + pub async fn new(client: reqwest::Client) -> Result { Ok(Self { - network_client: NetworkClient::new(connection).await?, + network_client: NetworkClient::new(client).await?, }) } @@ -27,7 +26,9 @@ impl<'a> NetworkStore<'a> { let id = id.as_str(); let fallback = default_connection(id); let conn = find_connection(id, &settings.connections).unwrap_or(&fallback); - self.network_client.add_or_update_connection(conn).await?; + self.network_client + .add_or_update_connection(conn.clone()) + .await?; } self.network_client.apply().await?; diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 6243207438..9ff30b8ea6 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -17,7 +17,7 @@ use zbus::Connection; /// This struct uses the default connection built by [connection function](super::connection). pub struct Store<'a> { users: UsersStore<'a>, - network: NetworkStore<'a>, + network: NetworkStore, product: ProductStore<'a>, software: SoftwareStore<'a>, storage: StorageStore<'a>, @@ -25,11 +25,14 @@ pub struct Store<'a> { } impl<'a> Store<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { + pub async fn new( + connection: Connection, + http_client: reqwest::Client, + ) -> Result, ServiceError> { Ok(Self { localization: LocalizationStore::new(connection.clone()).await?, users: UsersStore::new(connection.clone()).await?, - network: NetworkStore::new(connection.clone()).await?, + network: NetworkStore::new(http_client).await?, product: ProductStore::new(connection.clone()).await?, software: SoftwareStore::new(connection.clone()).await?, storage: StorageStore::new(connection).await?, diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index 186828f59b..cd749f4ee8 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -31,6 +31,7 @@ use tracing_subscriber::prelude::*; use utoipa::OpenApi; const DEFAULT_WEB_UI_DIR: &str = "/usr/share/agama/web_ui"; +const TOKEN_FILE: &str = "/run/agama/token"; #[derive(Subcommand, Debug)] enum Commands { @@ -95,8 +96,6 @@ struct ServeArgs { // Directory containing the web UI code. #[arg(long)] web_ui_dir: Option, - #[arg(long)] - generate_token: Option, } impl ServeArgs { @@ -301,9 +300,7 @@ async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { let config = web::ServiceConfig::load()?; - if let Some(token_file) = args.generate_token.clone() { - write_token(&token_file, &config.jwt_secret).context("could not create the token file")?; - } + write_token(TOKEN_FILE, &config.jwt_secret).context("could not create the token file")?; let dbus = connection_to(&args.dbus_address).await?; let web_ui_dir = args.web_ui_dir.clone().unwrap_or(find_web_ui_dir()); @@ -351,7 +348,7 @@ async fn run_command(cli: Cli) -> anyhow::Result<()> { } } -fn write_token(path: &PathBuf, secret: &str) -> io::Result<()> { +fn write_token(path: &str, secret: &str) -> io::Result<()> { let token = generate_token(secret); let mut file = fs::OpenOptions::new() .create(true) diff --git a/rust/agama-server/src/network/model.rs b/rust/agama-server/src/network/model.rs index f30c9f76e3..1e92777403 100644 --- a/rust/agama-server/src/network/model.rs +++ b/rust/agama-server/src/network/model.rs @@ -468,6 +468,7 @@ pub struct Device { // Connection.id pub connection: Option, pub state: DeviceState, + pub state_reason: u8, } /// Represents a known network connection. @@ -969,7 +970,7 @@ impl TryFrom for WirelessConfig { ssid, mode, security, - password: Some(settings.password), + password: settings.password, ..Default::default() }) } @@ -983,7 +984,7 @@ impl TryFrom for WirelessSettings { ssid: wireless.ssid.to_string(), mode: wireless.mode.to_string(), security: wireless.security.to_string(), - password: wireless.password.unwrap_or_default(), + password: wireless.password, }) } } diff --git a/rust/agama-server/src/network/nm/dbus.rs b/rust/agama-server/src/network/nm/dbus.rs index 31d48bcaf7..54a19f68d0 100644 --- a/rust/agama-server/src/network/nm/dbus.rs +++ b/rust/agama-server/src/network/nm/dbus.rs @@ -790,6 +790,9 @@ fn wireless_config_from_dbus(conn: &OwnedNestedHash) -> Option { if let Some(security) = conn.get(WIRELESS_SECURITY_KEY) { let key_mgmt: &str = security.get("key-mgmt")?.downcast_ref()?; wireless_config.security = NmKeyManagement(key_mgmt.to_string()).try_into().ok()?; + if let Some(password) = security.get("psk") { + wireless_config.password = Some(password.to_string()); + } match wireless_config.security { SecurityProtocol::WEP => { diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs index d3b1a19d9e..a580225ba6 100644 --- a/rust/agama-server/src/network/web.rs +++ b/rust/agama-server/src/network/web.rs @@ -98,7 +98,9 @@ pub async fn network_service( .route("/connections", get(connections).post(add_connection)) .route( "/connections/:id", - delete(delete_connection).put(update_connection), + delete(delete_connection) + .put(update_connection) + .get(connection), ) .route("/connections/:id/connect", get(connect)) .route("/connections/:id/disconnect", get(disconnect)) @@ -158,6 +160,24 @@ async fn devices( Ok(Json(state.network.get_devices().await?)) } +#[utoipa::path(get, path = "/network/connections/:id", responses( + (status = 200, description = "Get connection given by its ID", body = NetworkConnection) +))] +async fn connection( + State(state): State, + Path(id): Path, +) -> Result, NetworkError> { + let conn = state + .network + .get_connection(&id) + .await? + .ok_or_else(|| NetworkError::UnknownConnection(id.clone()))?; + + let conn = NetworkConnection::try_from(conn)?; + + Ok(Json(conn)) +} + #[utoipa::path(get, path = "/network/connections", responses( (status = 200, description = "List of known connections", body = Vec) ))] @@ -169,6 +189,7 @@ async fn connections( .iter() .map(|c| NetworkConnection::try_from(c.clone()).unwrap()) .collect(); + Ok(Json(connections)) } diff --git a/rust/agama-server/tests/network.rs b/rust/agama-server/tests/network.rs deleted file mode 100644 index c29411c11d..0000000000 --- a/rust/agama-server/tests/network.rs +++ /dev/null @@ -1,172 +0,0 @@ -pub mod common; - -use self::common::{async_retry, DBusServer}; -use agama_lib::network::{ - settings::{self}, - types::DeviceType, - NetworkClient, -}; -use agama_server::network::{ - self, - model::{self, GeneralState, Ipv4Method, Ipv6Method, StateConfig}, - Adapter, NetworkAdapterError, NetworkService, NetworkState, -}; -use async_trait::async_trait; -use cidr::IpInet; -use std::error::Error; -use tokio::test; - -#[derive(Default)] -pub struct NetworkTestAdapter(network::NetworkState); - -#[async_trait] -impl Adapter for NetworkTestAdapter { - async fn read(&self, _: StateConfig) -> Result { - Ok(self.0.clone()) - } - - async fn write(&self, _network: &network::NetworkState) -> Result<(), NetworkAdapterError> { - unimplemented!("Not used in tests"); - } -} - -#[test] -async fn test_read_connections() -> Result<(), Box> { - let mut server = DBusServer::new().start().await?; - - let general_state = GeneralState::default(); - - let device = model::Device { - name: String::from("eth0"), - type_: DeviceType::Ethernet, - ..Default::default() - }; - let eth0 = model::Connection::new("eth0".to_string(), DeviceType::Ethernet); - let state = NetworkState::new(general_state, vec![], vec![device], vec![eth0]); - let adapter = NetworkTestAdapter(state); - - NetworkService::start(&server.connection(), adapter).await?; - server.request_name().await?; - - let client = NetworkClient::new(server.connection()).await?; - let conns = async_retry(|| client.connections()).await?; - assert_eq!(conns.len(), 1); - let dbus_eth0 = conns.first().unwrap(); - assert_eq!(dbus_eth0.id, "eth0"); - assert_eq!(dbus_eth0.device_type(), DeviceType::Ethernet); - Ok(()) -} - -#[test] -async fn test_add_connection() -> Result<(), Box> { - let mut server = DBusServer::new().start().await?; - - let adapter = NetworkTestAdapter(NetworkState::default()); - - NetworkService::start(&server.connection(), adapter).await?; - server.request_name().await?; - - let client = NetworkClient::new(server.connection().clone()).await?; - - let addresses: Vec = vec!["192.168.0.2/24".parse()?, "::ffff:c0a8:7ac7/64".parse()?]; - let wlan0 = settings::NetworkConnection { - id: "wlan0".to_string(), - mac_address: Some("FD:CB:A9:87:65:43".to_string()), - method4: Some("auto".to_string()), - method6: Some("disabled".to_string()), - addresses: addresses.clone(), - wireless: Some(settings::WirelessSettings { - password: "123456".to_string(), - security: "wpa-psk".to_string(), - ssid: "TEST".to_string(), - mode: "infrastructure".to_string(), - }), - ..Default::default() - }; - client.add_or_update_connection(&wlan0).await?; - - let conns = async_retry(|| client.connections()).await?; - assert_eq!(conns.len(), 1); - - let conn = conns.first().unwrap(); - assert_eq!(conn.id, "wlan0"); - assert_eq!(conn.mac_address, Some("FD:CB:A9:87:65:43".to_string())); - assert_eq!(conn.device_type(), DeviceType::Wireless); - assert_eq!(&conn.addresses, &addresses); - let method4 = conn.method4.as_ref().unwrap(); - assert_eq!(method4, &Ipv4Method::Auto.to_string()); - let method6 = conn.method6.as_ref().unwrap(); - assert_eq!(method6, &Ipv6Method::Disabled.to_string()); - - Ok(()) -} - -#[test] -async fn test_add_bond_connection() -> Result<(), Box> { - let mut server = DBusServer::new().start().await?; - - let adapter = NetworkTestAdapter(NetworkState::default()); - - NetworkService::start(&server.connection(), adapter).await?; - server.request_name().await?; - - let client = NetworkClient::new(server.connection().clone()).await?; - let eth0 = settings::NetworkConnection { - id: "eth0".to_string(), - ..Default::default() - }; - let bond0 = settings::NetworkConnection { - id: "bond0".to_string(), - method4: Some("auto".to_string()), - method6: Some("disabled".to_string()), - interface: Some("bond0".to_string()), - bond: Some(settings::BondSettings { - mode: "active-backup".to_string(), - ports: vec!["eth0".to_string()], - options: Some("primary=eth1".to_string()), - }), - ..Default::default() - }; - - client.add_or_update_connection(ð0).await?; - client.add_or_update_connection(&bond0).await?; - let conns = async_retry(|| client.connections()).await?; - assert_eq!(conns.len(), 2); - - let conn = conns.iter().find(|c| &c.id == "bond0").unwrap(); - assert_eq!(conn.id, "bond0"); - assert_eq!(conn.device_type(), DeviceType::Bond); - let bond = conn.bond.clone().unwrap(); - assert_eq!(bond.mode, "active-backup"); - - Ok(()) -} - -#[test] -async fn test_update_connection() -> Result<(), Box> { - let mut server = DBusServer::new().start().await?; - - let general_state = GeneralState::default(); - let device = model::Device { - name: String::from("eth0"), - type_: DeviceType::Ethernet, - ..Default::default() - }; - let eth0 = model::Connection::new("eth0".to_string(), DeviceType::Ethernet); - let state = NetworkState::new(general_state, vec![], vec![device], vec![eth0]); - let adapter = NetworkTestAdapter(state); - - NetworkService::start(&server.connection(), adapter).await?; - server.request_name().await?; - - let client = NetworkClient::new(server.connection()).await?; - // make sure connections have been published. - let _conns = async_retry(|| client.connections()).await?; - - let mut dbus_eth0 = async_retry(|| client.get_connection("eth0")).await?; - dbus_eth0.interface = Some("eth0".to_string()); - client.add_or_update_connection(&dbus_eth0).await?; - let dbus_eth0 = client.get_connection("eth0").await?; - assert_eq!(dbus_eth0.interface, Some("eth0".to_string())); - Ok(()) -} diff --git a/rust/agama-server/tests/network_service.rs b/rust/agama-server/tests/network_service.rs index e2a686dfba..0fac4c237f 100644 --- a/rust/agama-server/tests/network_service.rs +++ b/rust/agama-server/tests/network_service.rs @@ -2,6 +2,7 @@ pub mod common; use crate::common::DBusServer; use agama_lib::error::ServiceError; +use agama_lib::network::settings::{BondSettings, NetworkConnection}; use agama_lib::network::types::{DeviceType, SSID}; use agama_server::network::web::network_service; use agama_server::network::{ @@ -11,6 +12,7 @@ use agama_server::network::{ }; use async_trait::async_trait; +use axum::body; use axum::http::header; use axum::{ body::Body, @@ -162,3 +164,63 @@ async fn test_network_wifis() -> Result<(), Box> { assert!(body.contains(r#""ssid":"AgamaNetwork2""#)); Ok(()) } + +#[test] +async fn test_add_bond_connection() -> Result<(), Box> { + let state = build_state().await; + let network_service = build_service(state.clone()).await?; + + let eth0 = NetworkConnection { + id: "eth2".to_string(), + ..Default::default() + }; + + let bond0 = NetworkConnection { + id: "bond0".to_string(), + method4: Some("auto".to_string()), + method6: Some("disabled".to_string()), + interface: Some("bond0".to_string()), + bond: Some(BondSettings { + mode: "active-backup".to_string(), + ports: vec!["eth0".to_string()], + options: Some("primary=eth0".to_string()), + }), + ..Default::default() + }; + + let request = Request::builder() + .uri("/connections") + .header("Content-Type", "application/json") + .method(Method::POST) + .body(serde_json::to_string(ð0)?) + .unwrap(); + + let response = network_service.clone().oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + + let request = Request::builder() + .uri("/connections") + .header("Content-Type", "application/json") + .method(Method::POST) + .body(serde_json::to_string(&bond0)?) + .unwrap(); + + let response = network_service.clone().oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + + let request = Request::builder() + .uri("/connections") + .method(Method::GET) + .body(Body::empty()) + .unwrap(); + + let response = network_service.clone().oneshot(request).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_string(response.into_body()).await; + assert!(body.contains(r#""id":"eth0""#)); + assert!(body.contains(r#""id":"bond0""#)); + assert!(body.contains(r#""mode":"active-backup""#)); + assert!(body.contains(r#""primary=eth0""#)); + + Ok(()) +} diff --git a/rust/share/agama-web-server.service b/rust/share/agama-web-server.service index 8c701d8675..0a6dd71b32 100644 --- a/rust/share/agama-web-server.service +++ b/rust/share/agama-web-server.service @@ -4,7 +4,7 @@ After=network-online.target agama.service [Service] Type=simple -ExecStart=/usr/bin/agama-web-server serve --address :::80 --address2 :::443 --generate-token /run/agama/token +ExecStart=/usr/bin/agama-web-server serve --address :::80 --address2 :::443 PIDFile=/run/agama/web.pid User=root TimeoutStopSec=5