diff --git a/rustfs/src/config/cli.rs b/rustfs/src/config/cli.rs index e9668572e..b23907ea1 100644 --- a/rustfs/src/config/cli.rs +++ b/rustfs/src/config/cli.rs @@ -949,6 +949,23 @@ pub enum ConnectLicenseCommands { Verify(ConnectLicenseArtifactOpts), /// Verify and display the installed license for one deployment and service Show(ConnectLicenseScopeOpts), + /// Check Connect and install an operator-approved replacement license + Renew(ConnectLicenseRenewOpts), +} + +/// Online renewal transport plus local trust and scope pins. +#[derive(Args, Clone)] +pub struct ConnectLicenseRenewOpts { + /// HTTPS Connect agent API base URL + #[arg(long, value_parser = NonEmptyStringValueParser::new())] + pub endpoint: String, + + /// PEM root CA file used only for this Connect endpoint + #[arg(long = "ca-file")] + pub ca_file: PathBuf, + + #[command(flatten)] + pub scope: ConnectLicenseScopeOpts, } /// Trust and scope pins shared by service-license commands. @@ -1356,7 +1373,10 @@ pub fn default_server_opts() -> ServerOpts { #[cfg(test)] mod tests { - use super::{Cli, Commands, ConnectCommands, ConnectInventoryCommands, InspectCommands, preprocess_args_for_legacy}; + use super::{ + Cli, Commands, ConnectCommands, ConnectInventoryCommands, ConnectLicenseCommands, InspectCommands, + preprocess_args_for_legacy, + }; use crate::version; use clap::error::ErrorKind; use clap::{CommandFactory, Parser}; @@ -1462,6 +1482,49 @@ mod tests { assert!(register.token_file.is_none()); } + #[test] + fn connect_license_renew_requires_transport_and_scope_pins() { + let cli = Cli::try_parse_from([ + "rustfs", + "connect", + "license", + "renew", + "--endpoint", + "https://connect.example/agent/", + "--ca-file", + "/etc/rustfs/connect-ca.pem", + "--state-dir", + "/var/lib/rustfs/connect", + "--public-key-file", + "/etc/rustfs/connect-license.pub", + "--key-id", + "aabbcc", + "--issuer", + "connect", + "--audience", + "rustfs", + "--organization", + "organizations/018cc251-f400-7000-8000-000000000003", + "--deployment", + "organizations/018cc251-f400-7000-8000-000000000003/clusters/018cc251-f400-7000-8000-000000000004", + "--service-code", + "SUPPORT", + ]) + .expect("connect license renew arguments should parse"); + + let Some(Commands::Connect(connect)) = cli.command else { + panic!("connect command expected"); + }; + let ConnectCommands::License(license) = connect.command else { + panic!("connect license command expected"); + }; + let ConnectLicenseCommands::Renew(renew) = license.command else { + panic!("connect license renew command expected"); + }; + assert_eq!(renew.endpoint, "https://connect.example/agent/"); + assert_eq!(renew.scope.service_code, "SUPPORT"); + } + #[test] fn connect_register_has_no_token_value_or_environment_option() { for forbidden in ["--token", "--registration-token", "--token-env"] { diff --git a/rustfs/src/config/mod.rs b/rustfs/src/config/mod.rs index a01b32f92..102ea5c85 100644 --- a/rustfs/src/config/mod.rs +++ b/rustfs/src/config/mod.rs @@ -56,7 +56,7 @@ pub use cli::{ ConnectClientPerformanceOperation, ConnectClientPerformanceOpts, ConnectDrivePerformanceOpts, ConnectPerformanceCommands, }; pub use cli::{ConnectEnvironmentInventoryOpts, ConnectInventoryCommands}; -pub use cli::{ConnectLicenseArtifactOpts, ConnectLicenseCommands, ConnectLicenseScopeOpts}; +pub use cli::{ConnectLicenseArtifactOpts, ConnectLicenseCommands, ConnectLicenseRenewOpts, ConnectLicenseScopeOpts}; pub use cli::{ConnectLogsMode, ConnectLogsOpts}; pub use cli::{ConnectObjectPerformanceOperation, ConnectObjectPerformanceOpts}; pub use cli::{ConnectProfileOpts, ConnectProfileTool, ConnectThreadProfileScope}; diff --git a/rustfs/src/connect/license_renewal.rs b/rustfs/src/connect/license_renewal.rs new file mode 100644 index 000000000..b797ee456 --- /dev/null +++ b/rustfs/src/connect/license_renewal.rs @@ -0,0 +1,636 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Device-side retrieval of operator-approved RustFS Connect service licenses. + +use std::fs; +use std::io::{self, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use uuid::Uuid; + +use super::config::HeartbeatConfig; +use super::license::{ + LICENSE_SCHEMA, LicenseArtifactError, LicenseClaims, LicenseReport, LicenseVerificationContext, apply_license_artifact, + inspect_installed_license, verify_license_artifact, +}; +use super::telemetry::{TelemetryDelivery, TelemetryError, TelemetryTransport, is_exact_utc_seconds}; + +const PROTOCOL_VERSION: &str = "v1"; +const DELIVERY_VERSION: u32 = 1; +const MAX_ATTEMPTS: usize = 3; + +#[cfg(unix)] +const ARTIFACT_FILE_MODE: u32 = 0o600; + +static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Result of checking the currently installed license for an approved renewal. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LicenseRenewalOutcome { + Requested, + Pending { replacement_license_uid: String }, + Installed(LicenseReport), +} + +/// An mTLS client for the read-only Connect license-renewal delivery surface. +pub struct LicenseRenewalClient { + transport: TelemetryTransport, + initial_backoff: Duration, + max_backoff: Duration, +} + +impl LicenseRenewalClient { + /// Reuses the registered device identity, explicit proxy, trust roots, and + /// bounded request timeout from the Connect heartbeat transport. + pub fn new(config: HeartbeatConfig) -> Result { + let initial_backoff = config.schedule.initial_backoff; + let max_backoff = config.schedule.max_backoff; + let transport = TelemetryTransport::new(config).map_err(transport_error)?; + Ok(Self { + transport, + initial_backoff, + max_backoff, + }) + } + + /// Checks the license currently installed in `state_directory` and installs + /// only an operator-approved, signed replacement for the same scope. + pub async fn renew_installed( + &self, + state_directory: &Path, + context: &LicenseVerificationContext, + ) -> Result { + let current = inspect_installed_license(state_directory, context)?; + let current = current.license.ok_or(LicenseRenewalError::InstalledLicense)?; + let status_request = LicenseRenewalRequest::new(¤t)?; + let status_body = self.post_with_retry("licenseRenewal:status", &status_request).await?; + let status = decode_status(&status_body, &status_request)?; + + match status.status { + RenewalStatus::Requested => Ok(LicenseRenewalOutcome::Requested), + RenewalStatus::Pending => Ok(LicenseRenewalOutcome::Pending { + replacement_license_uid: status.replacement_license_uid.ok_or(LicenseRenewalError::Response)?, + }), + RenewalStatus::Ready => { + let expected_replacement = status + .replacement_license_uid + .as_deref() + .ok_or(LicenseRenewalError::Response)?; + let expected_digest = status.artifact_sha256.as_deref().ok_or(LicenseRenewalError::Response)?; + let download_request = LicenseRenewalRequest::new(¤t)?; + let body = self.post_with_retry("licenseRenewal:download", &download_request).await?; + let artifact = + decode_artifact(&body, &download_request, expected_replacement, expected_digest, &status.expire_time)?; + let mut current_context = context.clone(); + current_context.now_unix = Utc::now().timestamp(); + install_downloaded_artifact(state_directory, ¤t_context, artifact) + } + } + } + + async fn post_with_retry(&self, operation: &str, body: &T) -> Result, LicenseRenewalError> { + let mut backoff = self.initial_backoff; + for attempt in 0..MAX_ATTEMPTS { + match self.transport.post(operation, body).await.map_err(transport_error)? { + TelemetryDelivery::Accepted { body, .. } => return Ok(body), + TelemetryDelivery::Retry { retry_after } if attempt + 1 < MAX_ATTEMPTS => { + let delay = retry_after.unwrap_or(backoff).clamp(self.initial_backoff, self.max_backoff); + tokio::time::sleep(delay).await; + backoff = backoff.saturating_mul(2).min(self.max_backoff); + } + TelemetryDelivery::Retry { .. } => return Err(LicenseRenewalError::RetryExhausted), + TelemetryDelivery::AuthenticationStopped { status, .. } => { + return Err(LicenseRenewalError::AuthenticationStopped { status }); + } + TelemetryDelivery::Rejected { status, .. } => return Err(LicenseRenewalError::Rejected { status }), + } + } + Err(LicenseRenewalError::RetryExhausted) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LicenseRenewalRequest { + protocol_version: &'static str, + current_license_uid: String, + service_code: String, + request_id: String, +} + +impl LicenseRenewalRequest { + fn new(current: &LicenseClaims) -> Result { + if !is_uuid_v7(¤t.license_uid) || !is_service_code(¤t.service_code) { + return Err(LicenseRenewalError::InstalledLicense); + } + Ok(Self { + protocol_version: PROTOCOL_VERSION, + current_license_uid: current.license_uid.clone(), + service_code: current.service_code.clone(), + request_id: Uuid::new_v4().to_string(), + }) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +enum RenewalStatus { + Requested, + Pending, + Ready, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct LicenseRenewalResponse { + protocol_version: String, + status: RenewalStatus, + source_license_uid: String, + replacement_license_uid: Option, + service_code: String, + schema: String, + version: u32, + expire_time: String, + artifact_sha256: Option, + manual_approval_required: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct DownloadedArtifact { + payload: String, + signature: String, +} + +struct DownloadedLicense { + artifact: DownloadedArtifact, + replacement_license_uid: String, + expire_time: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct LicenseArtifactResponse { + protocol_version: String, + status: RenewalStatus, + source_license_uid: String, + replacement_license_uid: Option, + service_code: String, + schema: String, + version: u32, + expire_time: String, + artifact_sha256: Option, + artifact: DownloadedArtifact, +} + +fn decode_status(body: &[u8], request: &LicenseRenewalRequest) -> Result { + let response: LicenseRenewalResponse = serde_json::from_slice(body).map_err(|_| LicenseRenewalError::Response)?; + validate_common( + &response.protocol_version, + &response.source_license_uid, + response.replacement_license_uid.as_deref(), + &response.service_code, + &response.schema, + response.version, + &response.expire_time, + response.artifact_sha256.as_deref(), + request, + )?; + let valid_state = match response.status { + RenewalStatus::Requested => { + response.replacement_license_uid.is_none() && response.artifact_sha256.is_none() && response.manual_approval_required + } + RenewalStatus::Pending => { + response.replacement_license_uid.is_some() && response.artifact_sha256.is_none() && response.manual_approval_required + } + RenewalStatus::Ready => { + response.replacement_license_uid.is_some() + && response.artifact_sha256.as_deref().is_some_and(is_sha256) + && !response.manual_approval_required + } + }; + if !valid_state { + return Err(LicenseRenewalError::Response); + } + Ok(response) +} + +fn decode_artifact( + body: &[u8], + request: &LicenseRenewalRequest, + expected_replacement: &str, + expected_digest: &str, + expected_expire_time: &str, +) -> Result { + let response: LicenseArtifactResponse = serde_json::from_slice(body).map_err(|_| LicenseRenewalError::Response)?; + validate_common( + &response.protocol_version, + &response.source_license_uid, + response.replacement_license_uid.as_deref(), + &response.service_code, + &response.schema, + response.version, + &response.expire_time, + response.artifact_sha256.as_deref(), + request, + )?; + let replacement = response.replacement_license_uid.as_deref(); + let digest = response.artifact_sha256.as_deref(); + if response.status != RenewalStatus::Ready + || replacement != Some(expected_replacement) + || digest != Some(expected_digest) + || response.expire_time != expected_expire_time + || !is_sha256(expected_digest) + { + return Err(LicenseRenewalError::Response); + } + let canonical = serde_json::to_vec(&response.artifact).map_err(|_| LicenseRenewalError::Response)?; + let actual_digest = hex_simd::encode_to_string(Sha256::digest(&canonical), hex_simd::AsciiCase::Lower); + if !constant_time_eq(actual_digest.as_bytes(), expected_digest.as_bytes()) { + return Err(LicenseRenewalError::DigestMismatch); + } + Ok(DownloadedLicense { + artifact: response.artifact, + replacement_license_uid: expected_replacement.to_owned(), + expire_time: response.expire_time, + }) +} + +#[allow(clippy::too_many_arguments)] +fn validate_common( + protocol_version: &str, + source_license_uid: &str, + replacement_license_uid: Option<&str>, + service_code: &str, + schema: &str, + version: u32, + expire_time: &str, + artifact_sha256: Option<&str>, + request: &LicenseRenewalRequest, +) -> Result<(), LicenseRenewalError> { + if protocol_version != PROTOCOL_VERSION + || source_license_uid != request.current_license_uid + || service_code != request.service_code + || schema != LICENSE_SCHEMA + || version != DELIVERY_VERSION + || !is_exact_utc_seconds(expire_time) + || replacement_license_uid.is_some_and(|uid| !is_uuid_v7(uid)) + || artifact_sha256.is_some_and(|digest| !is_sha256(digest)) + { + return Err(LicenseRenewalError::Response); + } + Ok(()) +} + +fn install_downloaded_artifact( + state_directory: &Path, + context: &LicenseVerificationContext, + download: DownloadedLicense, +) -> Result { + fs::create_dir_all(state_directory).map_err(LicenseRenewalError::InstallState)?; + let bytes = serde_json::to_vec(&download.artifact).map_err(|_| LicenseRenewalError::Response)?; + let path = stage_artifact(state_directory, &bytes)?; + let result = (|| { + let verified = verify_license_artifact(&path, state_directory, context)?; + let claims = verified.license.as_ref().ok_or(LicenseRenewalError::Response)?; + if claims.license_uid != download.replacement_license_uid || claims.expire_time != download.expire_time { + return Err(LicenseRenewalError::Response); + } + apply_license_artifact(&path, state_directory, context) + .map(LicenseRenewalOutcome::Installed) + .map_err(LicenseRenewalError::License) + })(); + let _ = fs::remove_file(path); + result +} + +fn stage_artifact(directory: &Path, bytes: &[u8]) -> Result { + loop { + let path = directory.join(format!( + ".license-renewal.{}.{}.tmp", + std::process::id(), + STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(ARTIFACT_FILE_MODE); + } + let mut file = match options.open(&path) { + Ok(file) => file, + Err(source) if source.kind() == io::ErrorKind::AlreadyExists => continue, + Err(source) => return Err(LicenseRenewalError::InstallState(source)), + }; + if let Err(source) = file.write_all(bytes).and_then(|()| file.sync_all()) { + let _ = fs::remove_file(path); + return Err(LicenseRenewalError::InstallState(source)); + } + return Ok(path); + } +} + +fn is_uuid_v7(value: &str) -> bool { + Uuid::parse_str(value).is_ok_and(|uuid| uuid.get_version_num() == 7 && uuid.to_string() == value) +} + +fn is_service_code(value: &str) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(b'A'..=b'Z')) + && value.len() <= 64 + && bytes.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + left.iter() + .zip(right) + .fold(0_u8, |difference, (left, right)| difference | (left ^ right)) + == 0 +} + +fn transport_error(error: TelemetryError) -> LicenseRenewalError { + match error { + TelemetryError::Endpoint => LicenseRenewalError::Endpoint, + TelemetryError::RootCertificate => LicenseRenewalError::RootCertificate, + TelemetryError::ProxyConfiguration => LicenseRenewalError::ProxyConfiguration, + TelemetryError::ProxyAuthentication => LicenseRenewalError::ProxyAuthentication, + TelemetryError::ProxyRejected => LicenseRenewalError::ProxyRejected, + TelemetryError::TlsPeer => LicenseRenewalError::TlsPeer, + TelemetryError::Schedule => LicenseRenewalError::Schedule, + TelemetryError::NotRegistered => LicenseRenewalError::NotRegistered, + TelemetryError::IdentityMissing => LicenseRenewalError::IdentityMissing, + TelemetryError::IdentityCertificate => LicenseRenewalError::IdentityCertificate, + TelemetryError::CredentialName => LicenseRenewalError::CredentialName, + TelemetryError::CredentialExpired => LicenseRenewalError::CredentialExpired, + TelemetryError::StateConflict => LicenseRenewalError::CredentialState, + TelemetryError::ResponseTooLarge => LicenseRenewalError::ResponseTooLarge, + TelemetryError::Url(_) + | TelemetryError::Transport(_) + | TelemetryError::Identity(_) + | TelemetryError::IdentityStore(_) + | TelemetryError::CredentialStore(_) + | TelemetryError::CredentialValidation(_) => LicenseRenewalError::Transport, + } +} + +#[derive(Debug, thiserror::Error)] +pub enum LicenseRenewalError { + #[error("Connect license endpoint must be an HTTPS base URL without credentials, query, or fragment")] + Endpoint, + #[error("Connect license root CA configuration is invalid")] + RootCertificate, + #[error("Connect license proxy configuration is invalid")] + ProxyConfiguration, + #[error("Connect proxy authentication failed; verify the configured proxy credential files")] + ProxyAuthentication, + #[error("Connect proxy connection failed; verify proxy availability, credentials, and the Connect endpoint")] + ProxyRejected, + #[error("Connect TLS peer certificate validation failed; verify the endpoint and configured root CA")] + TlsPeer, + #[error("Connect license retry schedule is invalid")] + Schedule, + #[error("RustFS is not registered with Connect")] + NotRegistered, + #[error("the Connect device private key is missing")] + IdentityMissing, + #[error("the stored Connect certificate and device private key cannot form a TLS identity")] + IdentityCertificate, + #[error("the stored Connect credential name is invalid")] + CredentialName, + #[error("the stored Connect device certificate is not currently valid")] + CredentialExpired, + #[error("the persisted Connect credential transition is invalid")] + CredentialState, + #[error("Connect license response exceeded 64 KiB")] + ResponseTooLarge, + #[error("Connect license transport failed")] + Transport, + #[error("Connect license request failed after three bounded attempts")] + RetryExhausted, + #[error("Connect rejected the device license request with HTTP {status}")] + AuthenticationStopped { status: u16 }, + #[error("Connect rejected the license request with HTTP {status}")] + Rejected { status: u16 }, + #[error("the installed service license cannot identify the renewal scope")] + InstalledLicense, + #[error("the Connect license response is invalid")] + Response, + #[error("the Connect license artifact digest does not match the approved digest")] + DigestMismatch, + #[error("the Connect license artifact staging file could not be written")] + InstallState(#[source] io::Error), + #[error(transparent)] + License(#[from] LicenseArtifactError), +} + +#[cfg(test)] +mod tests { + use super::*; + use base64_simd::URL_SAFE_NO_PAD; + use chrono::DateTime; + use serde::Deserialize; + + const SOURCE_UID: &str = "018cc251-f400-7000-8000-000000000005"; + const REPLACEMENT_UID: &str = "018cc251-f400-7000-8000-000000000002"; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Vector { + public_key: String, + key_id: String, + connect_artifact: String, + } + + fn vector() -> Vector { + serde_json::from_str(include_str!("../../tests/fixtures/connect-license-ed25519-vector.json")) + .expect("license vector must decode") + } + + fn request() -> LicenseRenewalRequest { + LicenseRenewalRequest { + protocol_version: PROTOCOL_VERSION, + current_license_uid: SOURCE_UID.to_owned(), + service_code: "SUPPORT".to_owned(), + request_id: "018cc251-f400-4000-8000-000000000001".to_owned(), + } + } + + fn artifact_response(digest: &str, artifact: &DownloadedArtifact) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "protocolVersion": "v1", + "status": "READY", + "sourceLicenseUid": SOURCE_UID, + "replacementLicenseUid": REPLACEMENT_UID, + "serviceCode": "SUPPORT", + "schema": LICENSE_SCHEMA, + "version": 1, + "expireTime": "2030-02-01T00:00:00Z", + "artifactSha256": digest, + "artifact": artifact, + })) + .expect("response must encode") + } + + #[test] + fn status_accepts_only_consistent_manual_states() { + let requested = serde_json::json!({ + "protocolVersion": "v1", + "status": "REQUESTED", + "sourceLicenseUid": SOURCE_UID, + "replacementLicenseUid": null, + "serviceCode": "SUPPORT", + "schema": LICENSE_SCHEMA, + "version": 1, + "expireTime": "2030-01-01T00:00:00Z", + "artifactSha256": null, + "manualApprovalRequired": true, + }); + assert_eq!( + decode_status(&serde_json::to_vec(&requested).expect("status"), &request()) + .expect("valid requested state") + .status, + RenewalStatus::Requested + ); + + let mut inconsistent = requested; + inconsistent["manualApprovalRequired"] = false.into(); + assert!(matches!( + decode_status(&serde_json::to_vec(&inconsistent).expect("status"), &request()), + Err(LicenseRenewalError::Response) + )); + } + + #[test] + fn artifact_digest_and_signed_scope_are_verified_before_install() { + let vector = vector(); + let artifact: DownloadedArtifact = serde_json::from_str(&vector.connect_artifact).expect("artifact"); + let bytes = serde_json::to_vec(&artifact).expect("canonical artifact"); + let digest = hex_simd::encode_to_string(Sha256::digest(bytes), hex_simd::AsciiCase::Lower); + let downloaded = decode_artifact( + &artifact_response(&digest, &artifact), + &request(), + REPLACEMENT_UID, + &digest, + "2030-02-01T00:00:00Z", + ) + .expect("matching response"); + let public_key = URL_SAFE_NO_PAD.decode_to_vec(vector.public_key).expect("public key"); + let context = LicenseVerificationContext::new( + public_key.try_into().expect("32-byte public key"), + vector.key_id, + "test-connect-issuer".to_owned(), + "test-rustfs-cluster".to_owned(), + "organizations/018cc251-f400-7000-8000-000000000003".to_owned(), + "organizations/018cc251-f400-7000-8000-000000000003/clusters/018cc251-f400-7000-8000-000000000004".to_owned(), + "SUPPORT".to_owned(), + DateTime::parse_from_rfc3339("2030-01-15T00:00:00Z") + .expect("time") + .timestamp(), + ) + .expect("verification context"); + let state = tempfile::tempdir().expect("state directory"); + + let installed = install_downloaded_artifact(state.path(), &context, downloaded).expect("install"); + let LicenseRenewalOutcome::Installed(report) = installed else { + panic!("expected installed outcome"); + }; + assert_eq!(report.license.expect("claims").license_uid, REPLACEMENT_UID); + assert!( + inspect_installed_license(state.path(), &context) + .expect("installed license") + .is_valid() + ); + } + + #[test] + fn digest_mismatch_and_wrong_audience_never_install() { + let vector = vector(); + let artifact: DownloadedArtifact = serde_json::from_str(&vector.connect_artifact).expect("artifact"); + let digest = "0".repeat(64); + assert!(matches!( + decode_artifact( + &artifact_response(&digest, &artifact), + &request(), + REPLACEMENT_UID, + &digest, + "2030-02-01T00:00:00Z", + ), + Err(LicenseRenewalError::DigestMismatch) + )); + + let public_key = URL_SAFE_NO_PAD.decode_to_vec(vector.public_key).expect("public key"); + let context = LicenseVerificationContext::new( + public_key.try_into().expect("32-byte public key"), + vector.key_id, + "test-connect-issuer".to_owned(), + "another-audience".to_owned(), + "organizations/018cc251-f400-7000-8000-000000000003".to_owned(), + "organizations/018cc251-f400-7000-8000-000000000003/clusters/018cc251-f400-7000-8000-000000000004".to_owned(), + "SUPPORT".to_owned(), + DateTime::parse_from_rfc3339("2030-01-15T00:00:00Z") + .expect("time") + .timestamp(), + ) + .expect("verification context"); + let state = tempfile::tempdir().expect("state directory"); + let result = install_downloaded_artifact( + state.path(), + &context, + DownloadedLicense { + artifact, + replacement_license_uid: REPLACEMENT_UID.to_owned(), + expire_time: "2030-02-01T00:00:00Z".to_owned(), + }, + ); + + assert!(matches!(result, Err(LicenseRenewalError::License(LicenseArtifactError { .. })))); + assert!(matches!( + inspect_installed_license(state.path(), &context), + Err(LicenseArtifactError { .. }) + )); + } + + #[test] + fn transport_errors_do_not_expose_remote_or_proxy_details() { + let errors = [ + LicenseRenewalError::ProxyAuthentication, + LicenseRenewalError::ProxyRejected, + LicenseRenewalError::TlsPeer, + LicenseRenewalError::Transport, + ]; + for error in errors { + let message = error.to_string(); + for secret in ["proxy.example", "proxy-user", "proxy-password", "remote response"] { + assert!(!message.contains(secret)); + } + } + } +} diff --git a/rustfs/src/connect/mod.rs b/rustfs/src/connect/mod.rs index 92c6a11c6..bec108bae 100644 --- a/rustfs/src/connect/mod.rs +++ b/rustfs/src/connect/mod.rs @@ -35,6 +35,7 @@ pub mod identity; pub mod identity_store; pub mod inventory; pub mod license; +pub mod license_renewal; pub mod offline; pub mod registration; pub mod registration_bootstrap; @@ -125,6 +126,7 @@ pub use license::{ LICENSE_DOMAIN_SEPARATION_TAG, LicenseArtifactError, LicenseArtifactStatus, LicenseClaims, LicenseReport, LicenseVerificationContext, apply_license_artifact, inspect_installed_license, verify_license_artifact, }; +pub use license_renewal::{LicenseRenewalClient, LicenseRenewalError, LicenseRenewalOutcome}; pub use offline::{EnrollmentError, OfflineEnrollment, OfflineKeyStore, VerifiedChallenge}; pub use registration::{RegistrationToken, TokenError}; pub use registration_bootstrap::{RegistrationBootstrapError, RegistrationBootstrapResult, register_from_protected_input}; diff --git a/rustfs/src/startup_entrypoint.rs b/rustfs/src/startup_entrypoint.rs index 031ceb16a..93430fee3 100644 --- a/rustfs/src/startup_entrypoint.rs +++ b/rustfs/src/startup_entrypoint.rs @@ -137,7 +137,7 @@ async fn async_main() -> Result<()> { println!("device={} cluster={}", registered.device_uid, registered.cluster_name); return Ok(()); } - CommandResult::ConnectLicense(command) => return execute_connect_license(command), + CommandResult::ConnectLicense(command) => return execute_connect_license(command).await, CommandResult::ConnectEnvironmentInventory(options) => return execute_connect_environment_inventory(options).await, CommandResult::ConnectClientPerformance(options) => return execute_connect_client_performance(options).await, CommandResult::ConnectDrivePerformance(options) => return execute_connect_drive_performance(options).await, @@ -1336,19 +1336,53 @@ fn enabled_build_features() -> Vec { features } -fn execute_connect_license(command: ConnectLicenseCommands) -> Result<()> { - use crate::connect::{apply_license_artifact, inspect_installed_license, verify_license_artifact}; +async fn execute_connect_license(command: ConnectLicenseCommands) -> Result<()> { + use crate::connect::{ + CredentialStore, HeartbeatConfig, IdentityStore, LicenseRenewalClient, LicenseRenewalOutcome, ProxyConfig, + apply_license_artifact, inspect_installed_license, verify_license_artifact, + }; let scope = match &command { ConnectLicenseCommands::Import(options) | ConnectLicenseCommands::Verify(options) => &options.scope, ConnectLicenseCommands::Show(options) => options, + ConnectLicenseCommands::Renew(options) => &options.scope, }; let context = license_context(scope); + if let ConnectLicenseCommands::Renew(options) = &command { + let context = context.map_err(Error::other)?; + let root_ca_pem = std::fs::read(&options.ca_file).map_err(Error::other)?; + let mut config = HeartbeatConfig::new( + &options.endpoint, + root_ca_pem, + IdentityStore::new(scope.state_dir.join("identity")), + CredentialStore::new(scope.state_dir.join("credential")), + scope.state_dir.join("heartbeat/state.json"), + ); + config.proxy = ProxyConfig::from_env().map_err(Error::other)?; + let outcome = LicenseRenewalClient::new(config) + .map_err(Error::other)? + .renew_installed(&scope.state_dir, &context) + .await + .map_err(Error::other)?; + match outcome { + LicenseRenewalOutcome::Requested => println!("{{\"status\":\"REQUESTED\"}}"), + LicenseRenewalOutcome::Pending { replacement_license_uid } => println!( + "{}", + serde_json::json!({ + "status": "PENDING", + "replacementLicenseUid": replacement_license_uid, + }) + ), + LicenseRenewalOutcome::Installed(report) => print_license_report(&report)?, + } + return Ok(()); + } let report = match context { Ok(context) => match &command { ConnectLicenseCommands::Import(options) => apply_license_artifact(&options.artifact, &scope.state_dir, &context), ConnectLicenseCommands::Verify(options) => verify_license_artifact(&options.artifact, &scope.state_dir, &context), ConnectLicenseCommands::Show(_) => inspect_installed_license(&scope.state_dir, &context), + ConnectLicenseCommands::Renew(_) => unreachable!("renewal is handled before local license commands"), } .unwrap_or_else(|error| { let installed = matches!(&command, ConnectLicenseCommands::Show(_))