diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b6bca2a29a..6be6d96e5e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -28,11 +28,13 @@ dependencies = [ "clap", "console", "curl", + "home", "indicatif", "inquire", "nix 0.27.1", "regex", "reqwest", + "serde", "serde_json", "tempfile", "thiserror", @@ -416,7 +418,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -484,7 +486,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -501,7 +503,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -659,7 +661,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -901,7 +903,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -1157,7 +1159,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -1168,7 +1170,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -1288,7 +1290,7 @@ checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -1477,7 +1479,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -1689,11 +1691,11 @@ dependencies = [ [[package]] name = "home" -version = "0.5.9" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2605,7 +2607,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -2777,7 +2779,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -2871,7 +2873,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -3311,22 +3313,22 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.210" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -3369,7 +3371,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -3420,7 +3422,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -3589,7 +3591,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -3621,9 +3623,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.79" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -3723,7 +3725,7 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -3817,7 +3819,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -4025,7 +4027,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -4233,7 +4235,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.79", + "syn 2.0.87", "uuid", ] @@ -4318,7 +4320,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", "wasm-bindgen-shared", ] @@ -4352,7 +4354,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -4670,7 +4672,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", "zvariant_utils", ] @@ -4703,7 +4705,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -4735,7 +4737,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", "zvariant_utils", ] @@ -4749,6 +4751,6 @@ dependencies = [ "quote", "serde", "static_assertions", - "syn 2.0.79", + "syn 2.0.87", "winnow", ] diff --git a/rust/agama-cli/Cargo.toml b/rust/agama-cli/Cargo.toml index caac1c328c..315d9266fe 100644 --- a/rust/agama-cli/Cargo.toml +++ b/rust/agama-cli/Cargo.toml @@ -23,6 +23,8 @@ url = "2.5.2" inquire = { version = "0.7.5", default-features = false, features = ["crossterm", "one-liners"] } chrono = "0.4.38" regex = "1.11.1" +home = "0.5.11" +serde = { version = "1.0.219", features = ["derive"] } [[bin]] name = "agama" diff --git a/rust/agama-cli/src/auth.rs b/rust/agama-cli/src/auth.rs index 03a37bbb5c..829f9b4d8f 100644 --- a/rust/agama-cli/src/auth.rs +++ b/rust/agama-cli/src/auth.rs @@ -20,7 +20,9 @@ use agama_lib::{auth::AuthToken, error::ServiceError}; use clap::Subcommand; +use url::Url; +use crate::auth_tokens_file::AuthTokensFile; use crate::error::CliError; use agama_lib::base_http_client::BaseHTTPClient; use inquire::Password; @@ -75,8 +77,8 @@ pub async fn run(client: BaseHTTPClient, subcommand: AuthCommands) -> anyhow::Re match subcommand { AuthCommands::Login => login(auth_client, read_password()?).await, - AuthCommands::Logout => logout(), - AuthCommands::Show => show(), + AuthCommands::Logout => logout(auth_client), + AuthCommands::Show => show(&auth_client.api.base_url), } } @@ -112,21 +114,32 @@ async fn login(client: AuthHTTPClient, password: String) -> anyhow::Result<()> { // 1) ask web server for JWT let res = client.authenticate(password).await?; let token = AuthToken::new(&res); - Ok(token.write_user_token()?) + let mut hosts_config = AuthTokensFile::read().unwrap_or_default(); + let hostname = client.api.base_url.host_str().unwrap_or("localhost"); + hosts_config.update_token(hostname, &token); + Ok(hosts_config.write()?) } /// Releases JWT -fn logout() -> anyhow::Result<()> { - Ok(AuthToken::remove_user_token()?) +fn logout(client: AuthHTTPClient) -> anyhow::Result<()> { + let hostname = client.api.base_url.host_str().unwrap_or("localhost"); + if let Ok(mut file) = AuthTokensFile::read() { + file.remove_host(hostname); + file.write()?; + } + Ok(()) } /// Shows stored JWT on stdout -fn show() -> anyhow::Result<()> { - // we do not care if jwt() fails or not. If there is something to print, show it otherwise - // stay silent - if let Some(token) = AuthToken::find() { - println!("{}", token.as_str()); +fn show(url: &Url) -> anyhow::Result<()> { + let hostname = url.host_str().unwrap_or("localhost"); + if let Ok(file) = AuthTokensFile::read() { + if let Some(token) = file.get_token(hostname) { + println!("{}", token.as_str()); + return Ok(()); + } } + println!("Not authenticated in {}", hostname); Ok(()) } diff --git a/rust/agama-cli/src/auth_tokens_file.rs b/rust/agama-cli/src/auth_tokens_file.rs new file mode 100644 index 0000000000..b91efa5124 --- /dev/null +++ b/rust/agama-cli/src/auth_tokens_file.rs @@ -0,0 +1,162 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use std::{ + collections::HashMap, + fs, + io::{self, Write}, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, +}; + +use agama_lib::auth::AuthToken; + +const AUTH_TOKENS_FILE: &str = ".local/share/agama/tokens.json"; + +/// Authentication tokens file. +/// +/// It contains the authentication tokens for each host. This file is not supposed +/// to be modified by hand, but using the `agama auth` commands. +#[derive(Debug, Default)] +pub struct AuthTokensFile { + tokens: HashMap, +} + +impl AuthTokensFile { + /// Default path for the tokens file in user's home directory. + pub fn default_path() -> io::Result { + let Some(path) = home::home_dir() else { + return Err(io::Error::new( + io::ErrorKind::Other, + "Cannot find the user's home directory", + )); + }; + + Ok(path.join(AUTH_TOKENS_FILE)) + } + + /// Reads the tokens file from the default path. + /// + /// * `path`: path to read the file from. + pub fn read() -> io::Result { + Self::read_from_path(Self::default_path()?) + } + + /// Reads the tokens file from the given path. + pub fn read_from_path>(path: P) -> io::Result { + let content = fs::read_to_string(&path)?; + let tokens: HashMap = serde_json::from_str(&content).unwrap(); + Ok(Self { tokens }) + } + + /// Writes the tokens file from the default path. + pub fn write(&self) -> io::Result<()> { + self.write_to_path(Self::default_path()?) + } + + /// Writes the tokens file to the given path. + /// + /// * `path`: path to write the file to. + pub fn write_to_path>(&self, path: P) -> io::Result<()> { + if let Some(parent) = path.as_ref().parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path)?; + let content = serde_json::to_string_pretty(&self.tokens).unwrap(); + file.write_all(content.as_bytes())?; + Ok(()) + } + + /// Returns the authentication token for the given host. + /// + /// * `host`: host name. + pub fn get_token(&self, host: &str) -> Option { + self.tokens.get(host).map(|t| AuthToken(t.to_string())) + } + + /// Returns the authentication token for the given host. + /// + /// * `host`: host name. + /// * `token': authentication token.` + pub fn update_token(&mut self, host: &str, token: &AuthToken) { + self.tokens.insert(host.to_string(), token.to_string()); + } + + /// Removes the configuration for the given host. + /// + /// * `host`: host name. + pub fn remove_host(&mut self, host: &str) { + self.tokens.remove(host); + } +} + +#[cfg(test)] +mod tests { + use agama_lib::auth::AuthToken; + + use super::AuthTokensFile; + use std::path::Path; + + #[test] + fn test_get_token() { + let path = Path::new("tests/tokens.json"); + let file = AuthTokensFile::read_from_path(path).unwrap(); + let token = file.get_token("my-server.lan").unwrap(); + assert_eq!(token, AuthToken("abcdefghij".to_string())); + } + + #[test] + fn test_update_token() { + let path = Path::new("tests/tokens.json"); + let mut file = AuthTokensFile::read_from_path(path).unwrap(); + file.update_token("my-server.lan", &AuthToken("123456".to_string())); + assert_eq!( + file.get_token("my-server.lan"), + Some(AuthToken("123456".to_string())) + ); + } + + #[test] + fn test_remove_host() { + let path = Path::new("tests/tokens.json"); + let mut file = AuthTokensFile::read_from_path(&path).unwrap(); + assert!(file.get_token("my-server.lan").is_some()); + + file.remove_host("my-server.lan"); + assert!(file.get_token("my-server.lan").is_none()); + } + + #[test] + fn test_write_file() { + let path = Path::new("tests/tokens.json"); + let tmpdir = tempfile::TempDir::with_prefix("agama-tests-").unwrap(); + let file = AuthTokensFile::read_from_path(path).unwrap(); + + let path2 = tmpdir.path().join("tokens.json"); + file.write_to_path(&path2).unwrap(); + + assert!(AuthTokensFile::read_from_path(path2).is_ok()); + } +} diff --git a/rust/agama-cli/src/lib.rs b/rust/agama-cli/src/lib.rs index ce1f8fef33..275626bab6 100644 --- a/rust/agama-cli/src/lib.rs +++ b/rust/agama-cli/src/lib.rs @@ -18,11 +18,14 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_lib::auth::AuthToken; use agama_lib::manager::FinishMethod; -use anyhow::{anyhow, Context}; +use anyhow::Context; +use auth_tokens_file::AuthTokensFile; use clap::{Args, Parser}; mod auth; +mod auth_tokens_file; mod commands; mod config; mod error; @@ -39,7 +42,6 @@ use agama_lib::{ use auth::run as run_auth_cmd; use commands::Commands; use config::run as run_config_cmd; -use inquire::Confirm; use logs::run as run_logs_cmd; use profile::run as run_profile_cmd; use progress::InstallerProgress; @@ -48,7 +50,6 @@ use std::fs; use std::os::unix::fs::OpenOptionsExt; use std::path::PathBuf; use std::{ - collections::HashMap, process::{ExitCode, Termination}, thread::sleep, time::Duration, @@ -184,27 +185,6 @@ async fn build_manager<'a>() -> anyhow::Result> { Ok(ManagerClient::new(conn).await?) } -/// True if use of the remote API is allowed (yes by default when the API is secure, the user is -/// asked if the API is insecure - e.g. when it uses self-signed certificate) -async fn allowed_insecure_api(use_insecure: bool, api_url: &str) -> Result { - // fake client used for remote site detection - let mut ping_client = BaseHTTPClient::default(); - ping_client.base_url = api_url.to_string(); - - // decide whether access to remote site has to be insecure (self-signed certificate or not) - match ping_client.get::>("/ping").await { - // Problem with http remote API reachability - Err(ServiceError::HTTPError(_)) => Ok(use_insecure || Confirm::new("There was a problem with the remote API and it is treated as insecure. Do you want to continue?") - .with_default(false) - .prompt() - .unwrap_or(false)), - // another error - Err(e) => Err(e), - // success doesn't bother us here - Ok(_) => Ok(false) - } -} - pub fn download_file(url: &str, path: &PathBuf) -> Result<(), ServiceError> { let mut file = fs::OpenOptions::new() .create(true) @@ -221,21 +201,13 @@ pub fn download_file(url: &str, path: &PathBuf) -> Result<(), ServiceError> { } async fn build_http_client( - api_url: &str, + api_url: Url, insecure: bool, authenticated: bool, ) -> Result { - let parsed = Url::parse(&api_url).context("Parsing API URL")?; - if parsed.cannot_be_a_base() { - return Err(ServiceError::Anyhow(anyhow!( - "Do not try data: or mailto: as the API URL" - ))); - } - - let mut client = BaseHTTPClient::default(); - client.base_url = api_url.to_string(); + let mut client = BaseHTTPClient::new(api_url)?; - if allowed_insecure_api(insecure, &client.base_url).await? { + if insecure { client = client.insecure(); } @@ -243,7 +215,10 @@ async fn build_http_client( // available and those which not (or don't need it) if authenticated { // this deals with authentication need inside - client.authenticated() + if let Some(token) = find_client_token(&client.base_url) { + return client.authenticated(&token); + } + return Err(ServiceError::NotAuthenticated); } else { client.unauthenticated() } @@ -253,24 +228,38 @@ async fn build_http_client( /// /// * `host`: ip or host name. The protocol is optional, using https if omitted (e.g, "myserver", /// "http://myserver", "192.168.100.101"). -fn api_url(host: String) -> String { +fn api_url(host: String) -> anyhow::Result { let sanitized_host = host.trim_end_matches('/').to_string(); - if sanitized_host.starts_with("http://") || sanitized_host.starts_with("https://") { - format!("{}/api", sanitized_host) + let url_str = if sanitized_host.starts_with("http://") || sanitized_host.starts_with("https://") + { + format!("{}/api/", sanitized_host) } else { - format!("https://{}/api", sanitized_host) + format!("https://{}/api/", sanitized_host) + }; + + Url::parse(&url_str).context("The given URL is not valid.") +} + +fn find_client_token(api_url: &Url) -> Option { + let hostname = api_url.host_str().unwrap_or("localhost"); + if let Ok(hosts_file) = AuthTokensFile::read() { + if let Some(token) = hosts_file.get_token(hostname) { + return Some(token); + } } + + AuthToken::master() } pub async fn run_command(cli: Cli) -> Result<(), ServiceError> { // somehow check whether we need to ask user for self-signed certificate acceptance - let api_url = api_url(cli.opts.host); + let api_url = api_url(cli.opts.host)?; match cli.command { Commands::Config(subcommand) => { - let client = build_http_client(&api_url, cli.opts.insecure, true).await?; + let client = build_http_client(api_url, cli.opts.insecure, true).await?; run_config_cmd(client, subcommand).await? } Commands::Probe => { @@ -279,7 +268,7 @@ pub async fn run_command(cli: Cli) -> Result<(), ServiceError> { probe().await? } Commands::Profile(subcommand) => { - let client = build_http_client(&api_url, cli.opts.insecure, true).await?; + let client = build_http_client(api_url, cli.opts.insecure, true).await?; run_profile_cmd(client, subcommand).await?; } Commands::Install => { @@ -292,16 +281,16 @@ pub async fn run_command(cli: Cli) -> Result<(), ServiceError> { finish(&manager, method).await?; } Commands::Questions(subcommand) => { - let client = build_http_client(&api_url, cli.opts.insecure, true).await?; + let client = build_http_client(api_url, cli.opts.insecure, true).await?; run_questions_cmd(client, subcommand).await? } Commands::Logs(subcommand) => { - let client = build_http_client(&api_url, cli.opts.insecure, true).await?; + let client = build_http_client(api_url, cli.opts.insecure, true).await?; run_logs_cmd(client, subcommand).await? } Commands::Download { url, destination } => download_file(&url, &destination)?, Commands::Auth(subcommand) => { - let client = build_http_client(&api_url, cli.opts.insecure, false).await?; + let client = build_http_client(api_url, cli.opts.insecure, false).await?; run_auth_cmd(client, subcommand).await?; } }; diff --git a/rust/agama-cli/src/profile.rs b/rust/agama-cli/src/profile.rs index 87db083aef..f0f010f5a0 100644 --- a/rust/agama-cli/src/profile.rs +++ b/rust/agama-cli/src/profile.rs @@ -164,12 +164,7 @@ async fn validate_client( client: &BaseHTTPClient, url_or_path: CliInput, ) -> anyhow::Result { - let mut url = Url::parse(&client.base_url)?; - // unwrap OK: only fails for cannot_be_a_base URLs like data: and mailto: - url.path_segments_mut() - .unwrap() - .push("profile") - .push("validate"); + let mut url = client.base_url.join("profile/validate").unwrap(); url_or_path.add_query(&mut url)?; let body = url_or_path.body_for_web()?; @@ -202,12 +197,7 @@ async fn validate(client: &BaseHTTPClient, url_or_path: CliInput) -> anyhow::Res /// Evaluate a Jsonnet profile, by doing a HTTP client request. /// Return well-formed Agama JSON on success. async fn evaluate_client(client: &BaseHTTPClient, url_or_path: CliInput) -> anyhow::Result { - let mut url = Url::parse(&client.base_url)?; - // unwrap OK: only fails for cannot_be_a_base URLs like data: and mailto: - url.path_segments_mut() - .unwrap() - .push("profile") - .push("evaluate"); + let mut url = client.base_url.join("profile/evaluate").unwrap(); url_or_path.add_query(&mut url)?; let body = url_or_path.body_for_web()?; diff --git a/rust/agama-cli/tests/tokens.json b/rust/agama-cli/tests/tokens.json new file mode 100644 index 0000000000..3dcc317bea --- /dev/null +++ b/rust/agama-cli/tests/tokens.json @@ -0,0 +1,4 @@ +{ + "my-server.lan": "abcdefghij", + "my-server2.lan": "0123456789" +} diff --git a/rust/agama-lib/src/auth.rs b/rust/agama-lib/src/auth.rs index 896ca0c6d3..6cdab442e7 100644 --- a/rust/agama-lib/src/auth.rs +++ b/rust/agama-lib/src/auth.rs @@ -62,7 +62,8 @@ use thiserror::Error; pub struct AuthTokenError(#[from] jsonwebtoken::errors::Error); /// Represents an authentication token (JWT). -pub struct AuthToken(String); +#[derive(Clone, Debug, PartialEq)] +pub struct AuthToken(pub String); impl AuthToken { /// Creates a new token with the given content. @@ -99,6 +100,10 @@ impl AuthToken { Self::read(AGAMA_TOKEN_FILE).ok() } + pub fn master() -> Option { + Self::read(AGAMA_TOKEN_FILE).ok() + } + /// Reads the token from the given path. /// /// * `path`: file's path to read the token from. diff --git a/rust/agama-lib/src/base_http_client.rs b/rust/agama-lib/src/base_http_client.rs index fabd636dd4..852ef5bc09 100644 --- a/rust/agama-lib/src/base_http_client.rs +++ b/rust/agama-lib/src/base_http_client.rs @@ -18,8 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use reqwest::{header, Response}; +use reqwest::{header, IntoUrl, Response}; use serde::{de::DeserializeOwned, Serialize}; +use url::Url; use crate::{auth::AuthToken, error::ServiceError}; @@ -36,8 +37,8 @@ use crate::{auth::AuthToken, error::ServiceError}; /// use agama_lib::error::ServiceError; /// /// async fn get_questions() -> Result, ServiceError> { -/// let client = BaseHTTPClient::default(); -/// client.get("/questions").await +/// let client = BaseHTTPClient::new("http://localhost/api/").unwrap(); +/// client.get("questions").await /// } /// ``` @@ -45,25 +46,30 @@ use crate::{auth::AuthToken, error::ServiceError}; pub struct BaseHTTPClient { pub client: reqwest::Client, insecure: bool, - pub base_url: String, + pub base_url: Url, } -const API_URL: &str = "http://localhost/api"; +impl BaseHTTPClient { + /// It builds a new client. + /// + /// * `base_url`: base URL of the API to connect to. A trailing "/" is relevant if the URL + /// has a path. + pub fn new(base_url: T) -> Result { + let mut url = base_url.into_url()?; + + // A trailing slash is significant. Let's make sure that it is there. + // See https://docs.rs/url/2.5.4/url/struct.Url.html#method.join. + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", &url.path())); + } -impl Default for BaseHTTPClient { - /// A `default` client - /// - is NOT authenticated (maybe you want to call `new` instead) - /// - uses `localhost` - fn default() -> Self { - Self { + Ok(Self { client: reqwest::Client::new(), insecure: false, - base_url: API_URL.to_owned(), - } + base_url: url, + }) } -} -impl BaseHTTPClient { /// Allows the client to connect to remote API with insecure certificate (e.g. self-signed) pub fn insecure(self) -> Self { Self { @@ -72,10 +78,12 @@ impl BaseHTTPClient { } } - /// Uses `localhost`, authenticates with [`AuthToken`]. - pub fn authenticated(self) -> Result { + /// Turns the client into an authenticated one using the given token. + /// + /// * `token`: authentication token. + pub fn authenticated(self, token: &AuthToken) -> Result { Ok(Self { - client: Self::authenticated_client(self.insecure)?, + client: Self::authenticated_client(self.insecure, token)?, ..self }) } @@ -91,11 +99,10 @@ impl BaseHTTPClient { }) } - fn authenticated_client(insecure: bool) -> Result { - // TODO: this error is subtly misleading, leading me to believe the SERVER said it, - // but in fact it is the CLIENT not finding an auth token - let token = AuthToken::find().ok_or(ServiceError::NotAuthenticated)?; - + fn authenticated_client( + insecure: bool, + token: &AuthToken, + ) -> Result { let mut headers = header::HeaderMap::new(); // just use generic anyhow error here as Bearer format is constructed by us, so failures can come only from token let value = header::HeaderValue::from_str(format!("Bearer {}", token).as_str()) @@ -110,8 +117,9 @@ impl BaseHTTPClient { Ok(client) } - fn url(&self, path: &str) -> String { - self.base_url.clone() + path + fn url(&self, path: &str) -> Result { + let relative_path = path.trim_start_matches('/'); + self.base_url.join(relative_path) } /// Simple wrapper around [`Response`] to get object from response. @@ -125,7 +133,7 @@ impl BaseHTTPClient { { let response: Result<_, ServiceError> = self .client - .get(self.url(path)) + .get(self.url(path)?) .send() .await .map_err(|e| e.into()); @@ -219,7 +227,7 @@ impl BaseHTTPClient { pub async fn delete_void(&self, path: &str) -> Result<(), ServiceError> { let response: Result<_, ServiceError> = self .client - .delete(self.url(path)) + .delete(self.url(path)?) .send() .await .map_err(|e| e.into()); @@ -231,7 +239,7 @@ impl BaseHTTPClient { pub async fn get_raw(&self, path: &str) -> Result { let raw: Result<_, ServiceError> = self .client - .get(self.url(path)) + .get(self.url(path)?) .send() .await .map_err(|e| e.into()); @@ -262,7 +270,7 @@ impl BaseHTTPClient { object: &impl Serialize, ) -> Result { self.client - .request(method, self.url(path)) + .request(method, self.url(path)?) .json(object) .send() .await diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index f02aafb1a0..21b50db8d5 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -37,7 +37,7 @@ pub enum ServiceError { DBusProtocol(#[from] zbus::fdo::Error), #[error("Unexpected type on D-Bus '{0}'")] ZVariant(#[from] zvariant::Error), - #[error("Failed to communicate with the HTTP backend '{0}'")] + #[error(transparent)] HTTPError(#[from] reqwest::Error), // it's fine to say only "Error" because the original // specific error will be printed too @@ -73,6 +73,8 @@ pub enum ServiceError { // FIXME reroute the error to a better place #[error("Profile error: {0}")] Profile(#[from] ProfileError), + #[error("Invalid URL: {0}")] + InvalidURL(#[from] url::ParseError), } #[derive(Error, Debug)] diff --git a/rust/agama-lib/src/localization/store.rs b/rust/agama-lib/src/localization/store.rs index 7946e8476c..0104206480 100644 --- a/rust/agama-lib/src/localization/store.rs +++ b/rust/agama-lib/src/localization/store.rs @@ -99,8 +99,7 @@ mod test { async fn localization_store( mock_server_url: String, ) -> Result { - let mut bhc = BaseHTTPClient::default(); - bhc.base_url = mock_server_url; + let bhc = BaseHTTPClient::new(mock_server_url)?; let client = LocalizationHTTPClient::new(bhc)?; LocalizationStore::new_with_client(client) } diff --git a/rust/agama-lib/src/product/store.rs b/rust/agama-lib/src/product/store.rs index 8bc2719b36..c8afa18b99 100644 --- a/rust/agama-lib/src/product/store.rs +++ b/rust/agama-lib/src/product/store.rs @@ -104,8 +104,7 @@ mod test { use tokio::test; // without this, "error: async functions cannot be used for tests" fn product_store(mock_server_url: String) -> ProductStore { - let mut bhc = BaseHTTPClient::default(); - bhc.base_url = mock_server_url; + let bhc = BaseHTTPClient::new(mock_server_url).unwrap(); let p_client = ProductHTTPClient::new(bhc.clone()); let m_client = ManagerHTTPClient::new(bhc); ProductStore { diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs index a63520ed3a..b47f8dd9df 100644 --- a/rust/agama-lib/src/questions/http_client.rs +++ b/rust/agama-lib/src/questions/http_client.rs @@ -93,8 +93,7 @@ mod test { use tokio::test; // without this, "error: async functions cannot be used for tests" fn questions_client(mock_server_url: String) -> HTTPClient { - let mut bhc = BaseHTTPClient::default(); - bhc.base_url = mock_server_url; + let bhc = BaseHTTPClient::new(mock_server_url).unwrap(); HTTPClient { client: bhc } } diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs index e139677437..a47c4a1a82 100644 --- a/rust/agama-lib/src/software/store.rs +++ b/rust/agama-lib/src/software/store.rs @@ -80,8 +80,7 @@ mod test { use tokio::test; // without this, "error: async functions cannot be used for tests" fn software_store(mock_server_url: String) -> SoftwareStore { - let mut bhc = BaseHTTPClient::default(); - bhc.base_url = mock_server_url; + let bhc = BaseHTTPClient::new(mock_server_url).unwrap(); let client = SoftwareHTTPClient::new(bhc); SoftwareStore { software_client: client, diff --git a/rust/agama-lib/src/storage/store.rs b/rust/agama-lib/src/storage/store.rs index 3ffc4fb997..f8aa97e9ed 100644 --- a/rust/agama-lib/src/storage/store.rs +++ b/rust/agama-lib/src/storage/store.rs @@ -56,8 +56,7 @@ mod test { use tokio::test; // without this, "error: async functions cannot be used for tests" fn storage_store(mock_server_url: String) -> StorageStore { - let mut bhc = BaseHTTPClient::default(); - bhc.base_url = mock_server_url; + let bhc = BaseHTTPClient::new(mock_server_url).unwrap(); let client = StorageHTTPClient::new(bhc); StorageStore { storage_client: client, diff --git a/rust/agama-lib/src/users/store.rs b/rust/agama-lib/src/users/store.rs index 5235a4b66e..edaf6a14c3 100644 --- a/rust/agama-lib/src/users/store.rs +++ b/rust/agama-lib/src/users/store.rs @@ -110,8 +110,7 @@ mod test { use tokio::test; // without this, "error: async functions cannot be used for tests" fn users_store(mock_server_url: String) -> Result { - let mut bhc = BaseHTTPClient::default(); - bhc.base_url = mock_server_url; + let bhc = BaseHTTPClient::new(mock_server_url)?; let client = UsersHTTPClient::new(bhc)?; UsersStore::new_with_client(client) } diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 14caa0ef44..db0b10b0e5 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Apr 21 13:42:13 UTC 2025 - Imobach Gonzalez Sosa + +- Allow to log in into multiple systems (gh#agama-project/agama#2261). +- Do not interactively ask for accepting insecure connections. + ------------------------------------------------------------------- Mon Apr 21 12:46:07 UTC 2025 - Imobach Gonzalez Sosa