feat(connect): add device license renewal client (#7771)

This commit is contained in:
Chris
2026-09-14 02:21:53 +08:00
committed by GitHub
parent d99460ced0
commit ca20e06405
5 changed files with 740 additions and 5 deletions
+64 -1
View File
@@ -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"] {
+1 -1
View File
@@ -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};
+636
View File
@@ -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<Self, LicenseRenewalError> {
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<LicenseRenewalOutcome, LicenseRenewalError> {
let current = inspect_installed_license(state_directory, context)?;
let current = current.license.ok_or(LicenseRenewalError::InstalledLicense)?;
let status_request = LicenseRenewalRequest::new(&current)?;
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(&current)?;
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, &current_context, artifact)
}
}
}
async fn post_with_retry<T: Serialize>(&self, operation: &str, body: &T) -> Result<Vec<u8>, 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<Self, LicenseRenewalError> {
if !is_uuid_v7(&current.license_uid) || !is_service_code(&current.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<String>,
service_code: String,
schema: String,
version: u32,
expire_time: String,
artifact_sha256: Option<String>,
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<String>,
service_code: String,
schema: String,
version: u32,
expire_time: String,
artifact_sha256: Option<String>,
artifact: DownloadedArtifact,
}
fn decode_status(body: &[u8], request: &LicenseRenewalRequest) -> Result<LicenseRenewalResponse, LicenseRenewalError> {
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<DownloadedLicense, LicenseRenewalError> {
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<LicenseRenewalOutcome, LicenseRenewalError> {
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<PathBuf, LicenseRenewalError> {
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<u8> {
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));
}
}
}
}
+2
View File
@@ -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};
+37 -3
View File
@@ -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<String> {
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(_))