diff --git a/docs/references/cli.md b/docs/references/cli.md index bd42a52d..162ee738 100644 --- a/docs/references/cli.md +++ b/docs/references/cli.md @@ -646,6 +646,15 @@ msb pull [--image] [--image-group] [options] **Examples:** ```bash +# Pull a private image using env credentials (token) +export MSB_REGISTRY_TOKEN=token123 +msb pull registry.example.com/org/app:1.0 + +# Pull a private image using env credentials (username/password) +export MSB_REGISTRY_USERNAME=user +export MSB_REGISTRY_PASSWORD=pass +msb pull registry.example.com/org/app:1.0 + # Pull an image msb pull --image python:3.11 @@ -686,6 +695,67 @@ msb push myapp:latest === +==- `msb login` +Set registry credentials (persisted in microsandbox home). + +```bash +msb login [registry] [--username ] [--password-stdin] [--token ] +``` + +| Option | Description | +| ----------------- | ---------------------------------- | +| `--username` | Registry username | +| `--password-stdin`| Read password from stdin | +| `--token` | Registry access token (bearer) | + +**Examples:** + +```bash +# Provide a token +msb login ghcr.io --token token123 + +# Provide username and password via stdin +echo "pass" | msb login docker.io --username user --password-stdin + +# Use env fallback if CLI is invalid +export MSB_REGISTRY_TOKEN=token123 +msb login ghcr.io --username user --password-stdin +``` + +!!!note +`msb login` stores credentials locally but does not validate them against the registry. +When pulling images, environment variables take priority over stored credentials. +!!! + +!!!warning Security +Credentials are stored in `~/.microsandbox/registry_auth.json`. Restrict file permissions and avoid sharing it. +!!! + +=== + +==- `msb logout` +Remove stored registry credentials. + +```bash +msb logout [registry] [--all] +``` + +| Option | Description | +| --------- | ---------------------------------------- | +| `--all` | Remove all stored registry credentials | + +**Examples:** + +```bash +# Remove credentials for a registry +msb logout ghcr.io + +# Remove all stored credentials +msb logout --all +``` + +=== + --- ### Maintenance diff --git a/microsandbox-cli/bin/msb/handlers.rs b/microsandbox-cli/bin/msb/handlers.rs index 08201c9d..ba65492e 100644 --- a/microsandbox-cli/bin/msb/handlers.rs +++ b/microsandbox-cli/bin/msb/handlers.rs @@ -8,11 +8,15 @@ use microsandbox_core::{ config::{self, Component, ComponentType, SandboxConfig}, home, menv, orchestra, sandbox, toolchain, }, - oci::Reference, + oci::{Reference, normalize_registry_host}, }; use microsandbox_server::MicrosandboxServerResult; -use microsandbox_utils::{NAMESPACES_SUBDIR, env}; +use microsandbox_utils::{ + NAMESPACES_SUBDIR, StoredRegistryCredentials, clear_registry_credentials, env, + remove_registry_credentials, store_registry_credentials, +}; use std::{collections::HashMap, path::PathBuf}; +use tokio::io::{self, AsyncReadExt}; use typed_path::Utf8UnixPathBuf; //-------------------------------------------------------------------------------------------------- @@ -671,11 +675,60 @@ pub async fn server_status_subcommand( Ok(()) } -pub async fn login_subcommand() -> MicrosandboxCliResult<()> { - println!( - "{} login functionality is not yet implemented", - "error:".error() - ); +pub async fn login_subcommand( + registry: Option, + username: Option, + password_stdin: bool, + token: Option, +) -> MicrosandboxCliResult<()> { + let registry = resolve_registry_host(registry); + let creds = resolve_login_credentials(username, password_stdin, token).await?; + + match creds { + LoginCredentials::Basic { username, password } => { + store_registry_credentials( + ®istry, + StoredRegistryCredentials::Basic { username, password }, + ) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + println!( + "info: credentials saved for registry {} (not validated)", + registry + ); + } + LoginCredentials::Token { token } => { + store_registry_credentials(®istry, StoredRegistryCredentials::Token { token }) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + println!( + "info: token saved for registry {} (not validated)", + registry + ); + } + } + + Ok(()) +} + +pub async fn logout_subcommand(registry: Option, all: bool) -> MicrosandboxCliResult<()> { + if all { + clear_registry_credentials() + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + println!("info: cleared all stored registry credentials"); + return Ok(()); + } + + let registry = resolve_registry_host(registry); + let removed = remove_registry_credentials(®istry) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + if removed { + println!("info: removed stored credentials for registry {}", registry); + } else { + println!( + "info: no stored credentials found for registry {}", + registry + ); + } + Ok(()) } @@ -741,6 +794,102 @@ fn parse_name_and_script(name_and_script: &str) -> (&str, Option<&str>) { (name, script) } +//-------------------------------------------------------------------------------------------------- +// Functions: Login Helpers +//-------------------------------------------------------------------------------------------------- + +enum LoginCredentials { + Basic { username: String, password: String }, + Token { token: String }, +} + +fn resolve_registry_host(registry: Option) -> String { + let host = registry + .or_else(env::get_registry_host) + .unwrap_or_else(env::get_oci_registry); + + normalize_registry_host(&host) +} + +async fn resolve_login_credentials( + username: Option, + password_stdin: bool, + token: Option, +) -> MicrosandboxCliResult { + let cli_password = if password_stdin { + Some(read_password_from_stdin().await?) + } else { + None + }; + + let cli_provided = token.is_some() || username.is_some() || password_stdin; + if cli_provided { + let cli_result = if token.is_some() && (username.is_some() || cli_password.is_some()) { + Err(MicrosandboxCliError::InvalidArgument( + "token cannot be combined with username/password".to_string(), + )) + } else if let Some(token) = token { + Ok(LoginCredentials::Token { token }) + } else { + match (username, cli_password) { + (Some(username), Some(password)) => { + Ok(LoginCredentials::Basic { username, password }) + } + (None, None) => Err(MicrosandboxCliError::InvalidArgument( + "no credentials provided; use flags or environment variables".to_string(), + )), + _ => Err(MicrosandboxCliError::InvalidArgument( + "both username and password are required".to_string(), + )), + } + }; + + if let Ok(creds) = cli_result { + return Ok(creds); + } + + // CLI was provided but invalid; attempt env fallback. + tracing::debug!("login: CLI credentials invalid, falling back to environment variables"); + } + + let env_token = env::get_registry_token(); + let env_username = env::get_registry_username(); + let env_password = env::get_registry_password(); + + if env_token.is_some() && (env_username.is_some() || env_password.is_some()) { + return Err(MicrosandboxCliError::InvalidArgument( + "token cannot be combined with username/password".to_string(), + )); + } + + if let Some(token) = env_token { + return Ok(LoginCredentials::Token { token }); + } + + match (env_username, env_password) { + (Some(username), Some(password)) => Ok(LoginCredentials::Basic { username, password }), + (None, None) => Err(MicrosandboxCliError::InvalidArgument( + "no credentials provided; use flags or environment variables".to_string(), + )), + _ => Err(MicrosandboxCliError::InvalidArgument( + "both username and password are required".to_string(), + )), + } +} + +async fn read_password_from_stdin() -> MicrosandboxCliResult { + let mut input = String::new(); + let mut stdin = io::stdin(); + stdin.read_to_string(&mut input).await?; + let password = input.trim_end_matches(&['\n', '\r'][..]).to_string(); + if password.is_empty() { + return Err(MicrosandboxCliError::InvalidArgument( + "password provided via stdin is empty".to_string(), + )); + } + Ok(password) +} + /// Parse a file path into project path and config file name. /// /// If the file path is a directory, it is treated as the project path. diff --git a/microsandbox-cli/bin/msb/main.rs b/microsandbox-cli/bin/msb/main.rs index 29e62282..34283834 100644 --- a/microsandbox-cli/bin/msb/main.rs +++ b/microsandbox-cli/bin/msb/main.rs @@ -259,8 +259,16 @@ async fn main() -> MicrosandboxCliResult<()> { handlers::server_ssh_subcommand(namespace, sandbox, name).await?; } }, - Some(MicrosandboxSubcommand::Login) => { - handlers::login_subcommand().await?; + Some(MicrosandboxSubcommand::Login { + registry, + username, + password_stdin, + token, + }) => { + handlers::login_subcommand(registry, username, password_stdin, token).await?; + } + Some(MicrosandboxSubcommand::Logout { registry, all }) => { + handlers::logout_subcommand(registry, all).await?; } Some(MicrosandboxSubcommand::Push { image, name }) => { handlers::push_subcommand(image, name).await?; diff --git a/microsandbox-cli/lib/args/msb.rs b/microsandbox-cli/lib/args/msb.rs index 33d27f52..9bfc7840 100644 --- a/microsandbox-cli/lib/args/msb.rs +++ b/microsandbox-cli/lib/args/msb.rs @@ -520,7 +520,33 @@ pub enum MicrosandboxSubcommand { /// Login to a registry #[command(name = "login")] - Login, + Login { + /// Registry host (defaults to OCI_REGISTRY_DOMAIN or docker.io) + registry: Option, + + /// Registry username + #[arg(short, long)] + username: Option, + + /// Read password from stdin + #[arg(long)] + password_stdin: bool, + + /// Registry token + #[arg(long)] + token: Option, + }, + + /// Logout from a registry + #[command(name = "logout")] + Logout { + /// Registry host (defaults to OCI_REGISTRY_DOMAIN or docker.io) + registry: Option, + + /// Remove all stored registry credentials + #[arg(long)] + all: bool, + }, /// Push image to a registry #[command(name = "push")] diff --git a/microsandbox-core/lib/management/image.rs b/microsandbox-core/lib/management/image.rs new file mode 100644 index 00000000..326dc157 --- /dev/null +++ b/microsandbox-core/lib/management/image.rs @@ -0,0 +1,3 @@ +//! Compatibility re-exports for registry auth resolution. + +pub use crate::oci::resolve_registry_auth; diff --git a/microsandbox-core/lib/management/mod.rs b/microsandbox-core/lib/management/mod.rs index c65d8d75..cfa8e8c8 100644 --- a/microsandbox-core/lib/management/mod.rs +++ b/microsandbox-core/lib/management/mod.rs @@ -22,6 +22,7 @@ pub mod config; pub mod db; pub mod home; +pub mod image; pub mod menv; pub mod orchestra; pub mod rootfs; diff --git a/microsandbox-core/lib/oci/auth.rs b/microsandbox-core/lib/oci/auth.rs new file mode 100644 index 00000000..1207e6a5 --- /dev/null +++ b/microsandbox-core/lib/oci/auth.rs @@ -0,0 +1,250 @@ +use oci_client::secrets::RegistryAuth; + +use microsandbox_utils::{StoredRegistryCredentials, env, load_stored_registry_credentials}; + +use crate::{MicrosandboxError, MicrosandboxResult, oci::Reference}; + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Normalize a registry host for consistent lookups. +/// +/// This ensures we store and resolve credentials under the same key. +pub fn normalize_registry_host(host: &str) -> String { + let mut normalized = host.trim().to_lowercase(); + + if let Some(stripped) = normalized.strip_prefix("https://") { + normalized = stripped.to_string(); + } else if let Some(stripped) = normalized.strip_prefix("http://") { + normalized = stripped.to_string(); + } + + normalized = normalized.trim_end_matches('/').to_string(); + + if normalized == "index.docker.io" { + "docker.io".to_string() + } else { + normalized + } +} + +/// Resolve the registry host for an image reference. +pub fn registry_host_for_reference(reference: &Reference) -> String { + let raw = reference.to_string(); + let mut parts = raw.split('/'); + let first = parts.next().unwrap_or(""); + + let host = if first.contains('.') || first.contains(':') || first == "localhost" { + first.to_string() + } else { + env::get_oci_registry() + }; + + normalize_registry_host(&host) +} + +/// Resolve registry auth for a given reference. +/// +/// Priority: +/// 1) Environment variables +/// 2) Stored credentials (msb login) +/// 3) Anonymous +pub fn resolve_registry_auth(reference: &Reference) -> MicrosandboxResult { + let registry = registry_host_for_reference(reference); + + let env_token = env::get_registry_token(); + let env_username = env::get_registry_username(); + let env_password = env::get_registry_password(); + + if env_token.is_some() && (env_username.is_some() || env_password.is_some()) { + return Err(MicrosandboxError::InvalidArgument( + "token cannot be combined with username/password".to_string(), + )); + } + + if let Some(token) = env_token { + return Ok(RegistryAuth::Bearer(token)); + } + + match (env_username, env_password) { + (Some(username), Some(password)) => { + return Ok(RegistryAuth::Basic(username, password)); + } + (Some(_), None) | (None, Some(_)) => { + tracing::warn!( + "registry credentials provided via env are incomplete; falling back to stored or anonymous" + ); + } + (None, None) => {} + } + + if let Some(stored) = load_stored_registry_credentials(®istry)? { + return Ok(match stored { + StoredRegistryCredentials::Basic { username, password } => { + RegistryAuth::Basic(username, password) + } + StoredRegistryCredentials::Token { token } => RegistryAuth::Bearer(token), + }); + } + + Ok(RegistryAuth::Anonymous) +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + use microsandbox_utils::{ + StoredRegistryCredentials, clear_registry_credentials, store_registry_credentials, + }; + use tempfile::TempDir; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + key: &'static str, + prev: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: impl Into) -> Self { + let prev = std::env::var_os(key); + let value: std::ffi::OsString = value.into(); + unsafe { std::env::set_var(key, &value) }; + Self { key, prev } + } + + fn remove(key: &'static str) -> Self { + let prev = std::env::var_os(key); + unsafe { std::env::remove_var(key) }; + Self { key, prev } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.prev.take() { + unsafe { std::env::set_var(self.key, value) }; + } else { + unsafe { std::env::remove_var(self.key) }; + } + } + } + + #[test] + fn normalize_registry_host_maps_index_docker_io() { + assert_eq!(normalize_registry_host("index.docker.io"), "docker.io"); + } + + #[test] + fn normalize_registry_host_strips_scheme_and_slash() { + assert_eq!(normalize_registry_host("https://Docker.IO/"), "docker.io"); + } + + #[test] + fn resolve_registry_auth_prefers_env_token() { + let _lock = ENV_LOCK.lock().unwrap(); + let _token = EnvGuard::set(env::MSB_REGISTRY_TOKEN_ENV_VAR, "env-token"); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/org/app:1.0".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Bearer(t) if t == "env-token")); + } + + #[test] + fn resolve_registry_auth_prefers_env_basic() { + let _lock = ENV_LOCK.lock().unwrap(); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::set(env::MSB_REGISTRY_USERNAME_ENV_VAR, "env-user"); + let _pass = EnvGuard::set(env::MSB_REGISTRY_PASSWORD_ENV_VAR, "env-pass"); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/org/app:1.0".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Basic(u, p) if u == "env-user" && p == "env-pass")); + } + + #[test] + fn resolve_registry_auth_falls_back_to_stored() { + let _lock = ENV_LOCK.lock().unwrap(); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/org/app:1.0".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Bearer(t) if t == "stored-token")); + } + + #[test] + fn resolve_registry_auth_returns_anonymous_when_missing() { + let _lock = ENV_LOCK.lock().unwrap(); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + clear_registry_credentials().expect("clear"); + + let reference: Reference = "ghcr.io/org/app:1.0".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Anonymous)); + } + + #[test] + fn resolve_registry_auth_errors_on_token_and_basic() { + let _lock = ENV_LOCK.lock().unwrap(); + let _token = EnvGuard::set(env::MSB_REGISTRY_TOKEN_ENV_VAR, "env-token"); + let _user = EnvGuard::set(env::MSB_REGISTRY_USERNAME_ENV_VAR, "env-user"); + let _pass = EnvGuard::set(env::MSB_REGISTRY_PASSWORD_ENV_VAR, "env-pass"); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + clear_registry_credentials().expect("clear"); + + let reference: Reference = "ghcr.io/org/app:1.0".parse().unwrap(); + let err = resolve_registry_auth(&reference).expect_err("expected error"); + assert!(matches!(err, MicrosandboxError::InvalidArgument(_))); + } +} diff --git a/microsandbox-core/lib/oci/image.rs b/microsandbox-core/lib/oci/image.rs index edd75b2a..01f72e03 100644 --- a/microsandbox-core/lib/oci/image.rs +++ b/microsandbox-core/lib/oci/image.rs @@ -33,7 +33,7 @@ use crate::{ MicrosandboxResult, management::db::{self}, - oci::{GlobalCache, LayerDependencies, LayerOps, Reference, Registry}, + oci::{GlobalCache, LayerDependencies, LayerOps, Reference, Registry, resolve_registry_auth}, }; use futures::future; #[cfg(feature = "cli")] @@ -147,7 +147,8 @@ impl Image { let mut platform = Platform::default(); platform.set_os(Os::Linux); - Registry::new(db.clone(), platform, layer_cache) + let auth = resolve_registry_auth(&image)?; + Registry::new(db.clone(), platform, layer_cache, auth) .await? .pull_image(&image) .await diff --git a/microsandbox-core/lib/oci/mocks.rs b/microsandbox-core/lib/oci/mocks.rs index 1ee4a629..d4e39490 100644 --- a/microsandbox-core/lib/oci/mocks.rs +++ b/microsandbox-core/lib/oci/mocks.rs @@ -23,8 +23,13 @@ pub(crate) async fn mock_registry_and_db() -> (Registry, Pool, platform: Platform, global_cache: O, + auth: RegistryAuth, ) -> MicrosandboxResult { let config = OciClientConfig { platform_resolver: Some(Box::new(move |manifests| { @@ -90,7 +91,7 @@ where Ok(Self { client: OciClient::new(config), - auth: RegistryAuth::Anonymous, + auth, db, global_cache, }) diff --git a/microsandbox-core/tests/registry_auth_integration.rs b/microsandbox-core/tests/registry_auth_integration.rs new file mode 100644 index 00000000..cb12ae36 --- /dev/null +++ b/microsandbox-core/tests/registry_auth_integration.rs @@ -0,0 +1,85 @@ +use std::sync::Mutex; + +use microsandbox_core::{management::image::resolve_registry_auth, oci::Reference}; +use microsandbox_utils::{ + StoredRegistryCredentials, clear_registry_credentials, env, store_registry_credentials, +}; +use tempfile::TempDir; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct EnvGuard { + key: &'static str, + prev: Option, +} + +impl EnvGuard { + fn set(key: &'static str, value: impl Into) -> Self { + let prev = std::env::var_os(key); + let value: std::ffi::OsString = value.into(); + unsafe { std::env::set_var(key, &value) }; + Self { key, prev } + } + + fn remove(key: &'static str) -> Self { + let prev = std::env::var_os(key); + unsafe { std::env::remove_var(key) }; + Self { key, prev } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.prev.take() { + unsafe { std::env::set_var(self.key, value) }; + } else { + unsafe { std::env::remove_var(self.key) }; + } + } +} + +#[test] +fn resolves_stored_credentials_when_env_missing() { + let _lock = ENV_LOCK.lock().unwrap(); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/aurial-rocks/python313:0.1.2".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, oci_client::secrets::RegistryAuth::Bearer(t) if t == "stored-token")); +} + +#[test] +fn env_overrides_stored_credentials() { + let _lock = ENV_LOCK.lock().unwrap(); + let _token = EnvGuard::set(env::MSB_REGISTRY_TOKEN_ENV_VAR, "env-token"); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/aurial-rocks/python313:0.1.2".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, oci_client::secrets::RegistryAuth::Bearer(t) if t == "env-token")); +} diff --git a/microsandbox-utils/Cargo.toml b/microsandbox-utils/Cargo.toml index e5ada7cd..d9110df6 100644 --- a/microsandbox-utils/Cargo.toml +++ b/microsandbox-utils/Cargo.toml @@ -14,6 +14,7 @@ path = "lib/lib.rs" [dependencies] anyhow.workspace = true async-trait.workspace = true +base64.workspace = true console.workspace = true dirs.workspace = true futures.workspace = true @@ -21,6 +22,8 @@ indicatif.workspace = true libc.workspace = true nix = { workspace = true, features = ["fs", "process", "signal", "term"] } pretty-error-debug.workspace = true +serde.workspace = true +serde_json.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/microsandbox-utils/lib/docker_config.rs b/microsandbox-utils/lib/docker_config.rs new file mode 100644 index 00000000..7a22a4f5 --- /dev/null +++ b/microsandbox-utils/lib/docker_config.rs @@ -0,0 +1,402 @@ +//! Docker config reader utilities for registry authentication. +//! +//! # Examples +//! ```no_run +//! use microsandbox_utils::load_docker_registry_credentials; +//! +//! let creds = load_docker_registry_credentials("ghcr.io") +//! .map_err(|err| microsandbox_utils::MicrosandboxUtilsError::custom(err))?; +//! if let Some(creds) = creds { +//! println!("loaded docker credentials: {:?}", creds); +//! } +//! # Ok::<(), microsandbox_utils::MicrosandboxUtilsError>(()) +//! ``` + +use std::{ + collections::HashMap, + io::Write, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use serde::Deserialize; + +//-------------------------------------------------------------------------------------------------- +// Constants +//-------------------------------------------------------------------------------------------------- + +const DOCKER_CONFIG_ENV_VAR: &str = "DOCKER_CONFIG"; +const DOCKER_CONFIG_FILENAME: &str = "config.json"; +const DOCKER_IO_LEGACY_KEY: &str = "https://index.docker.io/v1/"; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Credentials loaded from Docker config. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DockerAuthCredentials { + /// Basic auth using username + password. + Basic { + /// Registry username. + username: String, + /// Registry password. + password: String, + }, + /// Token-based auth (identity token). + Token { + /// Registry token. + token: String, + }, +} + +/// Errors that can occur while reading Docker config. +#[derive(Debug, thiserror::Error)] +pub enum DockerConfigError { + /// IO error while reading config file. + #[error("io error: {0}")] + Io(#[from] std::io::Error), + /// JSON parse error. + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + /// Base64 decode error. + #[error("base64 error: {0}")] + Base64(#[from] base64::DecodeError), + /// Invalid auth entry. + #[error("invalid auth entry: {0}")] + InvalidAuth(String), +} + +#[derive(Debug, Deserialize)] +struct DockerConfig { + auths: Option>, + #[allow(dead_code)] + #[serde(rename = "credsStore")] + creds_store: Option, + #[allow(dead_code)] + #[serde(rename = "credHelpers")] + cred_helpers: Option>, +} + +#[derive(Debug, Deserialize)] +struct DockerAuthEntry { + auth: Option, + identitytoken: Option, + username: Option, + password: Option, +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Loads credentials for a registry host from Docker config if present. +pub fn load_docker_registry_credentials( + host: &str, +) -> Result, DockerConfigError> { + let config_path = match docker_config_path() { + Some(path) => path, + None => return Ok(None), + }; + if !config_path.exists() { + return Ok(None); + } + + let config = read_config(&config_path)?; + if let Some(creds) = load_from_helpers(host, &config)? { + return Ok(Some(creds)); + } + + if let Some(auths) = config.auths { + for key in candidate_registry_keys(host) { + if let Some(entry) = auths.get(key) { + return parse_auth_entry(entry).map(Some); + } + } + } + + Ok(None) +} + +fn docker_config_path() -> Option { + if let Ok(path) = std::env::var(DOCKER_CONFIG_ENV_VAR) { + let path = PathBuf::from(path); + return Some(if path.is_dir() { + path.join(DOCKER_CONFIG_FILENAME) + } else { + path + }); + } + + let home = dirs::home_dir()?; + Some(home.join(".docker").join(DOCKER_CONFIG_FILENAME)) +} + +fn read_config(path: &Path) -> Result { + let contents = std::fs::read_to_string(path)?; + Ok(serde_json::from_str::(&contents)?) +} + +fn candidate_registry_keys(host: &str) -> Vec<&str> { + if host == "docker.io" { + vec![host, DOCKER_IO_LEGACY_KEY] + } else { + vec![host] + } +} + +fn parse_auth_entry(entry: &DockerAuthEntry) -> Result { + if let Some(token) = entry.identitytoken.as_ref() { + if token.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "identitytoken is empty".to_string(), + )); + } + return Ok(DockerAuthCredentials::Token { + token: token.to_string(), + }); + } + + if let (Some(username), Some(password)) = (entry.username.as_ref(), entry.password.as_ref()) { + if username.is_empty() || password.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "username/password is empty".to_string(), + )); + } + return Ok(DockerAuthCredentials::Basic { + username: username.to_string(), + password: password.to_string(), + }); + } + + if let Some(encoded) = entry.auth.as_ref() { + if encoded.is_empty() { + return Err(DockerConfigError::InvalidAuth("auth is empty".to_string())); + } + let decoded = BASE64_STANDARD.decode(encoded)?; + let decoded = String::from_utf8_lossy(&decoded); + let (username, password) = decoded + .split_once(':') + .ok_or_else(|| DockerConfigError::InvalidAuth("auth missing ':'".to_string()))?; + if username.is_empty() || password.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "auth username/password is empty".to_string(), + )); + } + return Ok(DockerAuthCredentials::Basic { + username: username.to_string(), + password: password.to_string(), + }); + } + + Err(DockerConfigError::InvalidAuth( + "no supported auth fields".to_string(), + )) +} + +fn load_from_helpers( + host: &str, + config: &DockerConfig, +) -> Result, DockerConfigError> { + let helper = match select_credential_helper(host, config) { + Some(helper) => helper, + None => return Ok(None), + }; + + for key in candidate_registry_keys(host) { + if let Some(creds) = run_credential_helper(&helper, key)? { + return Ok(Some(creds)); + } + } + + Ok(None) +} + +fn select_credential_helper(host: &str, config: &DockerConfig) -> Option { + if let Some(helpers) = config.cred_helpers.as_ref() + && let Some(helper) = helpers.get(host) + { + return Some(helper.to_string()); + } + + config.creds_store.as_ref().map(|v| v.to_string()) +} + +fn run_credential_helper( + helper: &str, + server_url: &str, +) -> Result, DockerConfigError> { + let helper_bin = format!("docker-credential-{}", helper); + let mut child = match Command::new(&helper_bin) + .arg("get") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(DockerConfigError::Io(err)), + }; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(server_url.as_bytes())?; + stdin.write_all(b"\n")?; + } + + let output = child.wait_with_output()?; + if !output.status.success() { + return Ok(None); + } + + let creds = parse_credential_helper_output(&output.stdout)?; + Ok(Some(creds)) +} + +fn parse_credential_helper_output(raw: &[u8]) -> Result { + #[derive(Deserialize)] + struct HelperOutput { + #[allow(dead_code)] + #[serde(rename = "ServerURL")] + server_url: Option, + #[serde(rename = "Username")] + username: String, + #[serde(rename = "Secret")] + secret: String, + } + + let output: HelperOutput = serde_json::from_slice(raw)?; + if output.secret.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "credential helper secret is empty".to_string(), + )); + } + + if output.username.is_empty() { + return Ok(DockerAuthCredentials::Token { + token: output.secret, + }); + } + + Ok(DockerAuthCredentials::Basic { + username: output.username, + password: output.secret, + }) +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + struct EnvGuard { + key: &'static str, + prev: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: String) -> Self { + let prev = std::env::var_os(key); + unsafe { std::env::set_var(key, value) }; + Self { key, prev } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.prev.take() { + unsafe { std::env::set_var(self.key, value) }; + } else { + unsafe { std::env::remove_var(self.key) }; + } + } + } + + fn write_config(temp_dir: &TempDir, contents: &str) -> PathBuf { + let path = temp_dir.path().join("config.json"); + fs::write(&path, contents).expect("write config"); + path + } + + #[test] + fn load_auth_from_basic_auth_field() { + let dir = TempDir::new().expect("temp dir"); + let encoded = BASE64_STANDARD.encode("user:pass"); + let config = format!( + r#"{{ + "auths": {{ + "registry.example.com": {{ "auth": "{}" }} + }} +}}"#, + encoded + ); + let path = write_config(&dir, &config); + let _guard = EnvGuard::set(DOCKER_CONFIG_ENV_VAR, path.to_string_lossy().to_string()); + + let creds = load_docker_registry_credentials("registry.example.com") + .expect("load creds") + .expect("creds"); + + assert_eq!( + creds, + DockerAuthCredentials::Basic { + username: "user".to_string(), + password: "pass".to_string() + } + ); + } + + #[test] + fn load_auth_from_identity_token() { + let dir = TempDir::new().expect("temp dir"); + let config = r#"{ + "auths": { + "registry.example.com": { "identitytoken": "token-123" } + } +}"#; + let path = write_config(&dir, config); + let _guard = EnvGuard::set(DOCKER_CONFIG_ENV_VAR, path.to_string_lossy().to_string()); + + let creds = load_docker_registry_credentials("registry.example.com") + .expect("load creds") + .expect("creds"); + + assert_eq!( + creds, + DockerAuthCredentials::Token { + token: "token-123".to_string() + } + ); + } + + #[test] + fn parse_helper_output_basic() { + let raw = br#"{"ServerURL":"ghcr.io","Username":"user","Secret":"pat"}"#; + let creds = parse_credential_helper_output(raw).expect("parse helper output"); + assert_eq!( + creds, + DockerAuthCredentials::Basic { + username: "user".to_string(), + password: "pat".to_string() + } + ); + } + + #[test] + fn parse_helper_output_token() { + let raw = br#"{"ServerURL":"ghcr.io","Username":"","Secret":"token"}"#; + let creds = parse_credential_helper_output(raw).expect("parse helper output"); + assert_eq!( + creds, + DockerAuthCredentials::Token { + token: "token".to_string() + } + ); + } +} diff --git a/microsandbox-utils/lib/env.rs b/microsandbox-utils/lib/env.rs index 1796f53d..4b3547c6 100644 --- a/microsandbox-utils/lib/env.rs +++ b/microsandbox-utils/lib/env.rs @@ -14,12 +14,30 @@ pub const MICROSANDBOX_HOME_ENV_VAR: &str = "MICROSANDBOX_HOME"; /// Environment variable for the OCI registry domain pub const OCI_REGISTRY_ENV_VAR: &str = "OCI_REGISTRY_DOMAIN"; +/// Environment variable for registry host (CLI fallback) +pub const MSB_REGISTRY_HOST_ENV_VAR: &str = "MSB_REGISTRY_HOST"; + +/// Environment variable for registry username +pub const MSB_REGISTRY_USERNAME_ENV_VAR: &str = "MSB_REGISTRY_USERNAME"; + +/// Environment variable for registry password +pub const MSB_REGISTRY_PASSWORD_ENV_VAR: &str = "MSB_REGISTRY_PASSWORD"; + +/// Environment variable for registry token +pub const MSB_REGISTRY_TOKEN_ENV_VAR: &str = "MSB_REGISTRY_TOKEN"; + /// Environment variable for the msbrun binary path pub const MSBRUN_EXE_ENV_VAR: &str = "MSBRUN_EXE"; /// Environment variable for the msbserver binary path pub const MSBSERVER_EXE_ENV_VAR: &str = "MSBSERVER_EXE"; +/// Environment variable for the minimum port in the sandbox port range +pub const MICROSANDBOX_PORT_MIN_ENV_VAR: &str = "MICROSANDBOX_PORT_MIN"; + +/// Environment variable for the maximum port in the sandbox port range +pub const MICROSANDBOX_PORT_MAX_ENV_VAR: &str = "MICROSANDBOX_PORT_MAX"; + //-------------------------------------------------------------------------------------------------- // Functions //-------------------------------------------------------------------------------------------------- @@ -45,3 +63,40 @@ pub fn get_oci_registry() -> String { DEFAULT_OCI_REGISTRY.to_string() } } + +/// Returns the registry host from environment, if set. +pub fn get_registry_host() -> Option { + std::env::var(MSB_REGISTRY_HOST_ENV_VAR).ok() +} + +/// Returns the registry username from environment, if set. +pub fn get_registry_username() -> Option { + std::env::var(MSB_REGISTRY_USERNAME_ENV_VAR).ok() +} + +/// Returns the registry password from environment, if set. +pub fn get_registry_password() -> Option { + std::env::var(MSB_REGISTRY_PASSWORD_ENV_VAR).ok() +} + +/// Returns the registry token from environment, if set. +pub fn get_registry_token() -> Option { + std::env::var(MSB_REGISTRY_TOKEN_ENV_VAR).ok() +} + +/// Returns the port range for sandbox port allocation. +/// If both MICROSANDBOX_PORT_MIN and MICROSANDBOX_PORT_MAX are set, +/// returns Some((min, max)). Otherwise, returns None for dynamic allocation. +pub fn get_sandbox_port_range() -> Option<(u16, u16)> { + let min = std::env::var(MICROSANDBOX_PORT_MIN_ENV_VAR) + .ok() + .and_then(|v| v.parse::().ok()); + let max = std::env::var(MICROSANDBOX_PORT_MAX_ENV_VAR) + .ok() + .and_then(|v| v.parse::().ok()); + + match (min, max) { + (Some(min_val), Some(max_val)) if min_val <= max_val => Some((min_val, max_val)), + _ => None, + } +} diff --git a/microsandbox-utils/lib/error.rs b/microsandbox-utils/lib/error.rs index de7c0656..f26e5929 100644 --- a/microsandbox-utils/lib/error.rs +++ b/microsandbox-utils/lib/error.rs @@ -37,6 +37,10 @@ pub enum MicrosandboxUtilsError { #[error("nix error: {0}")] NixError(#[from] nix::Error), + /// An error that occurred during a Serde JSON operation + #[error("serde json error: {0}")] + SerdeJson(#[from] serde_json::Error), + /// Custom error. #[error("Custom error: {0}")] Custom(#[from] AnyError), diff --git a/microsandbox-utils/lib/lib.rs b/microsandbox-utils/lib/lib.rs index afeba142..043ca1e6 100644 --- a/microsandbox-utils/lib/lib.rs +++ b/microsandbox-utils/lib/lib.rs @@ -4,10 +4,12 @@ #![allow(clippy::module_inception)] pub mod defaults; +pub mod docker_config; pub mod env; pub mod error; pub mod log; pub mod path; +pub mod registry_auth; pub mod runtime; pub mod seekable; pub mod term; @@ -17,10 +19,12 @@ pub mod term; //-------------------------------------------------------------------------------------------------- pub use defaults::*; +pub use docker_config::*; pub use env::*; pub use error::*; pub use log::*; pub use path::*; +pub use registry_auth::*; pub use runtime::*; pub use seekable::*; pub use term::*; diff --git a/microsandbox-utils/lib/registry_auth.rs b/microsandbox-utils/lib/registry_auth.rs new file mode 100644 index 00000000..06b04586 --- /dev/null +++ b/microsandbox-utils/lib/registry_auth.rs @@ -0,0 +1,149 @@ +//! Registry auth persistence helpers. +//! +//! # Examples +//! ```no_run +//! use microsandbox_utils::{store_registry_credentials, load_stored_registry_credentials, StoredRegistryCredentials}; +//! +//! store_registry_credentials( +//! "ghcr.io", +//! StoredRegistryCredentials::Token { +//! token: "token-123".to_string(), +//! }, +//! )?; +//! +//! let creds = load_stored_registry_credentials("ghcr.io")? +//! .expect("missing credentials"); +//! +//! match creds { +//! StoredRegistryCredentials::Token { token } => { +//! assert_eq!(token, "token-123"); +//! } +//! _ => unreachable!("expected token credentials"), +//! } +//! # Ok::<(), microsandbox_utils::MicrosandboxUtilsError>(()) +//! ``` + +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{MicrosandboxUtilsResult, env}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Stored credentials for a registry host. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum StoredRegistryCredentials { + /// Basic auth using username + password. + #[serde(rename = "basic")] + Basic { + /// Registry username. + username: String, + /// Registry password. + password: String, + }, + /// Bearer token. + #[serde(rename = "token")] + Token { + /// Registry token. + token: String, + }, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct RegistryAuthFile { + auths: HashMap, +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Load stored registry credentials for a host, if present. +pub fn load_stored_registry_credentials( + host: &str, +) -> MicrosandboxUtilsResult> { + let path = registry_auth_path(); + if !path.exists() { + return Ok(None); + } + + let data = fs::read_to_string(&path)?; + let auth_file: RegistryAuthFile = serde_json::from_str(&data)?; + Ok(auth_file.auths.get(host).cloned()) +} + +/// Store registry credentials for a host (overwrites existing entry). +pub fn store_registry_credentials( + host: &str, + credentials: StoredRegistryCredentials, +) -> MicrosandboxUtilsResult<()> { + let path = registry_auth_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut auth_file = if path.exists() { + let data = fs::read_to_string(&path)?; + serde_json::from_str::(&data)? + } else { + RegistryAuthFile::default() + }; + + auth_file.auths.insert(host.to_string(), credentials); + let json = serde_json::to_string_pretty(&auth_file)?; + fs::write(&path, json)?; + + set_permissions_restrictive(&path)?; + Ok(()) +} + +/// Remove stored registry credentials for a host. +pub fn remove_registry_credentials(host: &str) -> MicrosandboxUtilsResult { + let path = registry_auth_path(); + if !path.exists() { + return Ok(false); + } + + let data = fs::read_to_string(&path)?; + let mut auth_file: RegistryAuthFile = serde_json::from_str(&data)?; + let removed = auth_file.auths.remove(host).is_some(); + + let json = serde_json::to_string_pretty(&auth_file)?; + fs::write(&path, json)?; + set_permissions_restrictive(&path)?; + + Ok(removed) +} + +/// Remove all stored registry credentials. +pub fn clear_registry_credentials() -> MicrosandboxUtilsResult<()> { + let path = registry_auth_path(); + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) +} + +fn registry_auth_path() -> PathBuf { + env::get_microsandbox_home_path().join("registry_auth.json") +} + +fn set_permissions_restrictive(path: &Path) -> MicrosandboxUtilsResult<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = fs::metadata(path)?; + let mut perms = metadata.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + } + Ok(()) +}