2929 lines
114 KiB
Rust
2929 lines
114 KiB
Rust
//! Concrete privileged lifecycle coordinator.
|
|
//!
|
|
//! The normal process resolves a local, stateless plan. The elevated process
|
|
//! rebuilds that plan while owning all verified package/config leases and lets
|
|
//! the runner consume the prepared action exactly once.
|
|
|
|
use crate::adapters::proxifyre::PROXIFYRE_OUTPUT_FILE;
|
|
use crate::adapters::singbox::{
|
|
DEFAULT_BLOCK_OUTBOUND_TAG, DEFAULT_DIRECT_OUTBOUND_TAG, DEFAULT_MIXED_INBOUND_TAG,
|
|
DEFAULT_VPN_OUTBOUND_TAG, SINGBOX_OUTPUT_FILE,
|
|
};
|
|
use crate::component_catalog::ComponentId as CatalogComponentId;
|
|
use crate::component_cutover::{
|
|
begin_proxifyre_cutover, candidate_operation_state_fingerprint, cleanup_proxifyre_quarantine,
|
|
component_cutover_user_evidence_fingerprint, confirm_proxifyre_cleanup,
|
|
cutover_terminal_record_fingerprint, cutover_transaction_fingerprint,
|
|
legacy_service_snapshot_fingerprint, mark_proxifyre_next_start_verified,
|
|
read_existing_sealed_cutover_journal, read_sealed_cutover_status, recover_proxifyre_cutover,
|
|
retire_existing_proxifyre_cutover_terminal_state, retire_proxifyre_cutover_terminal_state,
|
|
validate_component_cutover_user_evidence, verify_cutover_external_mutation_status,
|
|
ComponentCutoverObservation, ComponentCutoverUserEvidence, CutoverDisplayState, CutoverError,
|
|
CutoverExecutionMode, CutoverExternalMutationStatus, CutoverHost, CutoverHostError,
|
|
CutoverJournal, CutoverJournalStore, CutoverOperation, CutoverPhase, CutoverRunOutcome,
|
|
CutoverTerminalRetirementExpectation, LegacyBeforeState, LegacyServiceState,
|
|
MutationAuthorityEvidence, PrerequisiteBeforeState, ProxifyreCutoverPlan,
|
|
ProxifyreLegacyIdentity, SealedCutoverStore, StateFingerprint,
|
|
SystemProxifyreCutoverFilesystem,
|
|
};
|
|
use crate::component_detection::{
|
|
has_additional_matching_legacy_proxifyre_service, inventory_proxyfier, inventory_singbox,
|
|
};
|
|
use crate::component_inventory::{
|
|
component_inventory_fingerprint_for_cutover, CandidateRole, ComponentClassification,
|
|
ComponentInventory, LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT, LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION,
|
|
};
|
|
use crate::component_packages::{
|
|
ComponentPackageService, GithubReleaseDigestProof, NativePackageSignatureVerifier,
|
|
NativePrivilegedBundleVerifier, PackageSignatureVerifier, PrivilegedBundleVerifier,
|
|
PrivilegedCachedUpdatePlan, PrivilegedPackageLease, PrivilegedPackageProof,
|
|
ReqwestUpdateTransport, UpdateTransport,
|
|
};
|
|
use crate::privileged_jobs::{
|
|
cutover_recovery_probe_fingerprint, InstalledPackageSource, ManagedComponent,
|
|
PlannedAssetFingerprint, PlannedGithubOriginProof, PlannedPackageFingerprint, PrivilegedAction,
|
|
PrivilegedActionRunner, PrivilegedCutoverContext, PrivilegedCutoverMode,
|
|
PrivilegedMutationResult, PrivilegedPlanResolver, PrivilegedResultCode,
|
|
PrivilegedRunnerFailure, ResolvedActionPlan,
|
|
};
|
|
use crate::proxifyre_runtime::{
|
|
configure_proxifyre_firewall_native, expected_proxifyre_cutover_scm_effect,
|
|
install_proxifyre_native, map_cutover_packet_filter_install_exit, map_proxifyre_installer_exit,
|
|
mutate_proxifyre_cutover_candidate, mutate_proxifyre_cutover_scm,
|
|
observe_proxifyre_cutover_scm_state, preflight_start_proxifyre_native,
|
|
prepare_proxifyre_cutover_candidate, prepare_proxifyre_cutover_package, stop_proxifyre_native,
|
|
uninstall_cutover_transaction_packet_filter, uninstall_proxifyre_native,
|
|
update_proxifyre_native, PreparedProxifyreCutoverCandidate,
|
|
ProxifyreCutoverCandidateObservation, ProxifyreCutoverCandidateWriter, ProxifyreCutoverScm,
|
|
ProxifyreInstallerCommand, ProxifyreInstallerKind, ProxifyreNativeError, ProxifyreNativeHost,
|
|
ProxifyreNativeHostError, ProxifyreNativeOwnership, ProxifyreNativeServiceStatus,
|
|
ProxifyreNativeStartDisposition, RuntimeConfigVerification,
|
|
SystemProxifyreCutoverCandidateWriter, SystemProxifyreCutoverScm, SystemProxifyreNativeHost,
|
|
};
|
|
use crate::safe_fs;
|
|
use crate::singbox_runtime::{
|
|
install_singbox_native, preflight_start_singbox_native, stop_singbox_native,
|
|
uninstall_singbox_native, update_singbox_native, SingBoxNativeError, SingBoxNativeOutcome,
|
|
SingBoxStartPreflight, SystemSingBoxNativeHost,
|
|
};
|
|
use crate::storage::{JsonStorage, StoragePaths};
|
|
use serde::Deserialize;
|
|
use serde_json::{json, Map, Value};
|
|
use sha2::{Digest, Sha256};
|
|
use std::fs::File;
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use uuid::Uuid;
|
|
|
|
const MAX_GENERATED_CONFIG_BYTES: u64 = 16 * 1024 * 1024;
|
|
const PACKAGE_STAGING_DIRECTORY: &str = ".proxywarden-package-staging";
|
|
|
|
type SharedBundleVerifier = Arc<dyn PrivilegedBundleVerifier + Send + Sync>;
|
|
type SharedUpdateTransport = Arc<dyn UpdateTransport + Send + Sync>;
|
|
type SharedSignatureVerifier = Arc<dyn PackageSignatureVerifier + Send + Sync>;
|
|
|
|
/// Stateless resolver used by the issuing, non-elevated process. It has no
|
|
/// transport field, so fresh/install/start planning cannot accidentally use
|
|
/// the network.
|
|
pub struct LocalPrivilegedPlanResolver {
|
|
packages: Option<ComponentPackageService>,
|
|
storage_paths: StoragePaths,
|
|
bundle_verifier: SharedBundleVerifier,
|
|
startup_session_id: String,
|
|
app_root: Option<PathBuf>,
|
|
}
|
|
|
|
impl LocalPrivilegedPlanResolver {
|
|
pub fn production(startup_session_id: &str) -> Result<Self, PrivilegedRunnerFailure> {
|
|
let startup_session_id = canonical_uuid_v4(startup_session_id)
|
|
.ok_or(PrivilegedRunnerFailure::PreconditionFailed)?;
|
|
let storage_paths = StoragePaths::default();
|
|
let app_root = installed_app_root()?;
|
|
let packages = ComponentPackageService::open(
|
|
app_root.join("bundled").join("components"),
|
|
&storage_paths,
|
|
)
|
|
.ok();
|
|
Ok(Self {
|
|
packages,
|
|
storage_paths,
|
|
bundle_verifier: Arc::new(NativePrivilegedBundleVerifier),
|
|
startup_session_id,
|
|
app_root: Some(app_root),
|
|
})
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[doc(hidden)]
|
|
pub fn from_parts_for_tests(
|
|
packages: Option<ComponentPackageService>,
|
|
storage_paths: StoragePaths,
|
|
bundle_verifier: SharedBundleVerifier,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
storage_paths,
|
|
bundle_verifier,
|
|
startup_session_id: Uuid::new_v4().hyphenated().to_string(),
|
|
app_root: None,
|
|
}
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[doc(hidden)]
|
|
pub fn from_parts_for_tests_with_startup_session(
|
|
packages: Option<ComponentPackageService>,
|
|
storage_paths: StoragePaths,
|
|
bundle_verifier: SharedBundleVerifier,
|
|
startup_session_id: &str,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
storage_paths,
|
|
bundle_verifier,
|
|
startup_session_id: canonical_uuid_v4(startup_session_id)
|
|
.expect("test startup session must be a canonical UUID v4"),
|
|
app_root: None,
|
|
}
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[doc(hidden)]
|
|
pub fn from_parts_for_tests_with_cutover_app_root(
|
|
packages: Option<ComponentPackageService>,
|
|
storage_paths: StoragePaths,
|
|
bundle_verifier: SharedBundleVerifier,
|
|
startup_session_id: &str,
|
|
app_root: PathBuf,
|
|
) -> Self {
|
|
Self {
|
|
packages,
|
|
storage_paths,
|
|
bundle_verifier,
|
|
startup_session_id: canonical_uuid_v4(startup_session_id)
|
|
.expect("test startup session must be a canonical UUID v4"),
|
|
app_root: Some(app_root),
|
|
}
|
|
}
|
|
|
|
fn resolve_package(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
) -> Result<Option<PlannedPackageFingerprint>, PrivilegedRunnerFailure> {
|
|
let bundled_ids: &[CatalogComponentId] = match action {
|
|
PrivilegedAction::InstallProxifyre | PrivilegedAction::CutoverProxifyre => &[
|
|
CatalogComponentId::Proxifyre,
|
|
CatalogComponentId::WindowsPacketFilter,
|
|
CatalogComponentId::VcRuntime,
|
|
],
|
|
PrivilegedAction::InstallSingBox => {
|
|
&[CatalogComponentId::SingBox, CatalogComponentId::Winsw]
|
|
}
|
|
PrivilegedAction::UpdateProxifyre => {
|
|
return self
|
|
.packages
|
|
.as_ref()
|
|
.ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)?
|
|
.plan_cached_update(CatalogComponentId::Proxifyre)
|
|
.map(|plan| Some(planned_cached_package(&plan)))
|
|
.map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed);
|
|
}
|
|
PrivilegedAction::UpdateSingBox => {
|
|
return self
|
|
.packages
|
|
.as_ref()
|
|
.ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)?
|
|
.plan_cached_update(CatalogComponentId::SingBox)
|
|
.map(|plan| Some(planned_cached_package(&plan)))
|
|
.map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed);
|
|
}
|
|
_ => return Ok(None),
|
|
};
|
|
|
|
let packages = self
|
|
.packages
|
|
.as_ref()
|
|
.ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)?;
|
|
let leases = bundled_ids
|
|
.iter()
|
|
.map(|component_id| {
|
|
packages.lease_bundled_for_privileged_install(
|
|
*component_id,
|
|
self.bundle_verifier.as_ref(),
|
|
)
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?;
|
|
Ok(Some(planned_leased_package(
|
|
InstalledPackageSource::Bundled,
|
|
&leases.iter().collect::<Vec<_>>(),
|
|
)?))
|
|
}
|
|
}
|
|
|
|
impl PrivilegedPlanResolver for LocalPrivilegedPlanResolver {
|
|
fn resolve(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
) -> Result<ResolvedActionPlan, PrivilegedRunnerFailure> {
|
|
if matches!(
|
|
action,
|
|
PrivilegedAction::CutoverProxifyre | PrivilegedAction::CleanupProxifyreQuarantine
|
|
) {
|
|
return self.resolve_cutover_action(action);
|
|
}
|
|
let package = self.resolve_package(action)?;
|
|
let configuration_fingerprint = resolve_local_configuration(action, &self.storage_paths)?;
|
|
let inventory_fingerprint =
|
|
component_inventory_fingerprint_for_cutover(&live_inventory(action));
|
|
Ok(ResolvedActionPlan::new(
|
|
action,
|
|
package,
|
|
inventory_fingerprint,
|
|
configuration_fingerprint,
|
|
None,
|
|
))
|
|
}
|
|
}
|
|
|
|
impl LocalPrivilegedPlanResolver {
|
|
fn resolve_cutover_action(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
) -> Result<ResolvedActionPlan, PrivilegedRunnerFailure> {
|
|
self.resolve_cutover_action_with_inventory(action, live_inventory(action))
|
|
}
|
|
|
|
fn resolve_cutover_action_with_inventory(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
inventory: ComponentInventory,
|
|
) -> Result<ResolvedActionPlan, PrivilegedRunnerFailure> {
|
|
let inventory_fingerprint = component_inventory_fingerprint_for_cutover(&inventory);
|
|
let external_status = self
|
|
.app_root
|
|
.as_deref()
|
|
.map(verify_cutover_external_mutation_status);
|
|
let cutover_infrastructure_absent =
|
|
external_status == Some(CutoverExternalMutationStatus::Absent);
|
|
let observation = (!cutover_infrastructure_absent)
|
|
.then(|| read_local_cutover_observation(&self.storage_paths))
|
|
.flatten();
|
|
match action {
|
|
PrivilegedAction::CutoverProxifyre => {
|
|
if external_status
|
|
.is_some_and(|status| status != CutoverExternalMutationStatus::Absent)
|
|
{
|
|
return Ok(ResolvedActionPlan::new(
|
|
action,
|
|
None,
|
|
inventory_fingerprint.clone(),
|
|
no_config_fingerprint(action),
|
|
Some(recovery_probe_context(&inventory_fingerprint)),
|
|
));
|
|
}
|
|
if let Some(observation) = observation.as_ref().and_then(valid_cutover_observation)
|
|
{
|
|
return Ok(ResolvedActionPlan::new(
|
|
action,
|
|
None,
|
|
inventory_fingerprint,
|
|
no_config_fingerprint(action),
|
|
Some(recovery_context(observation)?),
|
|
));
|
|
}
|
|
let original_state = validate_new_cutover_discovery(&inventory).ok();
|
|
let package = self.resolve_package(action)?;
|
|
let configuration_fingerprint = match original_state {
|
|
Some(_) => {
|
|
GeneratedConfigLease::open(
|
|
&self.storage_paths.generated_dir.join(PROXIFYRE_OUTPUT_FILE),
|
|
ManagedComponent::Proxifyre,
|
|
)?
|
|
.sha256
|
|
}
|
|
None => no_config_fingerprint(action),
|
|
};
|
|
Ok(ResolvedActionPlan::new(
|
|
action,
|
|
package,
|
|
inventory_fingerprint.clone(),
|
|
configuration_fingerprint,
|
|
Some(new_cutover_context(
|
|
&inventory_fingerprint,
|
|
original_state,
|
|
&self.startup_session_id,
|
|
)),
|
|
))
|
|
}
|
|
PrivilegedAction::CleanupProxifyreQuarantine => {
|
|
let observation = observation
|
|
.as_ref()
|
|
.and_then(valid_cutover_observation)
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
let user_evidence = read_local_cutover_user_evidence(&self.storage_paths)?;
|
|
if user_evidence.startup_session_id != self.startup_session_id
|
|
|| user_evidence.current_inventory_fingerprint != inventory_fingerprint
|
|
{
|
|
return Err(PrivilegedRunnerFailure::CutoverStateConflict);
|
|
}
|
|
Ok(ResolvedActionPlan::new(
|
|
action,
|
|
None,
|
|
inventory_fingerprint,
|
|
no_config_fingerprint(action),
|
|
Some(cleanup_context(observation, &user_evidence)?),
|
|
))
|
|
}
|
|
_ => Err(PrivilegedRunnerFailure::PreconditionFailed),
|
|
}
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[doc(hidden)]
|
|
pub fn resolve_cutover_action_with_inventory_for_tests(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
inventory: ComponentInventory,
|
|
) -> Result<ResolvedActionPlan, PrivilegedRunnerFailure> {
|
|
self.resolve_cutover_action_with_inventory(action, inventory)
|
|
}
|
|
}
|
|
|
|
/// Elevated resolver and one-shot runner. The pending value owns every live
|
|
/// lease; a mismatch consumes and drops it before any native host is created.
|
|
pub struct SystemPrivilegedRuntime {
|
|
app_root: PathBuf,
|
|
packages: Option<ComponentPackageService>,
|
|
storage_paths: StoragePaths,
|
|
bundle_verifier: SharedBundleVerifier,
|
|
update_transport: Option<SharedUpdateTransport>,
|
|
signature_verifier: SharedSignatureVerifier,
|
|
package_staging_parent: PathBuf,
|
|
verify_staging_app_root: bool,
|
|
enforce_cutover_journal: bool,
|
|
pending: Mutex<Option<PreparedAction>>,
|
|
}
|
|
|
|
impl SystemPrivilegedRuntime {
|
|
pub fn production() -> Result<Self, PrivilegedRunnerFailure> {
|
|
let storage_paths = StoragePaths::default();
|
|
let app_root = installed_app_root()?;
|
|
let packages = ComponentPackageService::open(
|
|
app_root.join("bundled").join("components"),
|
|
&storage_paths,
|
|
)
|
|
.ok();
|
|
let update_transport = ReqwestUpdateTransport::new()
|
|
.ok()
|
|
.map(|transport| Arc::new(transport) as SharedUpdateTransport);
|
|
Ok(Self {
|
|
app_root: app_root.clone(),
|
|
packages,
|
|
storage_paths,
|
|
bundle_verifier: Arc::new(NativePrivilegedBundleVerifier),
|
|
update_transport,
|
|
signature_verifier: Arc::new(NativePackageSignatureVerifier),
|
|
package_staging_parent: app_root.join(PACKAGE_STAGING_DIRECTORY),
|
|
verify_staging_app_root: true,
|
|
enforce_cutover_journal: true,
|
|
pending: Mutex::new(None),
|
|
})
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[doc(hidden)]
|
|
pub fn from_parts_for_tests(
|
|
packages: Option<ComponentPackageService>,
|
|
storage_paths: StoragePaths,
|
|
package_staging_parent: PathBuf,
|
|
bundle_verifier: SharedBundleVerifier,
|
|
update_transport: Option<SharedUpdateTransport>,
|
|
signature_verifier: SharedSignatureVerifier,
|
|
) -> Self {
|
|
let app_root = package_staging_parent
|
|
.parent()
|
|
.map(Path::to_path_buf)
|
|
.unwrap_or_else(|| package_staging_parent.clone());
|
|
Self {
|
|
app_root,
|
|
packages,
|
|
storage_paths,
|
|
bundle_verifier,
|
|
update_transport,
|
|
signature_verifier,
|
|
package_staging_parent,
|
|
verify_staging_app_root: false,
|
|
enforce_cutover_journal: false,
|
|
pending: Mutex::new(None),
|
|
}
|
|
}
|
|
|
|
fn prepare(&self, action: PrivilegedAction) -> Result<PreparedInput, PrivilegedRunnerFailure> {
|
|
match action {
|
|
PrivilegedAction::InstallProxifyre => Ok(PreparedInput::InstallProxifyre {
|
|
proxifyre: Box::new(self.lease_bundled(CatalogComponentId::Proxifyre)?),
|
|
packet_filter: Box::new(
|
|
self.lease_bundled(CatalogComponentId::WindowsPacketFilter)?,
|
|
),
|
|
vc_runtime: Box::new(self.lease_bundled(CatalogComponentId::VcRuntime)?),
|
|
}),
|
|
PrivilegedAction::InstallSingBox => Ok(PreparedInput::InstallSingBox {
|
|
runtime: Box::new(self.lease_bundled(CatalogComponentId::SingBox)?),
|
|
wrapper: Box::new(self.lease_bundled(CatalogComponentId::Winsw)?),
|
|
}),
|
|
PrivilegedAction::UpdateProxifyre => Ok(PreparedInput::UpdateProxifyre(Box::new(
|
|
self.lease_cached(CatalogComponentId::Proxifyre)?,
|
|
))),
|
|
PrivilegedAction::UpdateSingBox => Ok(PreparedInput::UpdateSingBox(Box::new(
|
|
self.lease_cached(CatalogComponentId::SingBox)?,
|
|
))),
|
|
PrivilegedAction::StartProxifyre => {
|
|
Ok(PreparedInput::StartProxifyre(GeneratedConfigLease::open(
|
|
&self.storage_paths.generated_dir.join(PROXIFYRE_OUTPUT_FILE),
|
|
ManagedComponent::Proxifyre,
|
|
)?))
|
|
}
|
|
PrivilegedAction::StartSingBox => {
|
|
Ok(PreparedInput::StartSingBox(GeneratedConfigLease::open(
|
|
&self.storage_paths.generated_dir.join(SINGBOX_OUTPUT_FILE),
|
|
ManagedComponent::SingBox,
|
|
)?))
|
|
}
|
|
PrivilegedAction::StopProxifyre
|
|
| PrivilegedAction::ConfigureProxifyreFirewall
|
|
| PrivilegedAction::UninstallProxifyre
|
|
| PrivilegedAction::CutoverProxifyre
|
|
| PrivilegedAction::CleanupProxifyreQuarantine
|
|
| PrivilegedAction::StopSingBox
|
|
| PrivilegedAction::UninstallSingBox => Ok(PreparedInput::None),
|
|
}
|
|
}
|
|
|
|
fn lease_bundled(
|
|
&self,
|
|
component_id: CatalogComponentId,
|
|
) -> Result<PrivilegedPackageLease, PrivilegedRunnerFailure> {
|
|
self.packages
|
|
.as_ref()
|
|
.ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)?
|
|
.lease_bundled_for_privileged_install(component_id, self.bundle_verifier.as_ref())
|
|
.map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)
|
|
}
|
|
|
|
fn lease_cached(
|
|
&self,
|
|
component_id: CatalogComponentId,
|
|
) -> Result<PrivilegedPackageLease, PrivilegedRunnerFailure> {
|
|
let packages = self
|
|
.packages
|
|
.as_ref()
|
|
.ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)?;
|
|
let update_transport = self
|
|
.update_transport
|
|
.as_ref()
|
|
.ok_or(PrivilegedRunnerFailure::RunnerUnavailable)?;
|
|
let plan = packages
|
|
.plan_cached_update(component_id)
|
|
.map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?;
|
|
let app_root = self
|
|
.package_staging_parent
|
|
.parent()
|
|
.filter(|_| {
|
|
self.package_staging_parent
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
== Some(PACKAGE_STAGING_DIRECTORY)
|
|
})
|
|
.ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)?;
|
|
#[cfg(debug_assertions)]
|
|
if !self.verify_staging_app_root {
|
|
std::fs::create_dir_all(&self.package_staging_parent)
|
|
.map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?;
|
|
safe_fs::protect_path_for_owner_admin_system(&self.package_staging_parent)
|
|
.map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?;
|
|
return packages
|
|
.lease_cached_update_with_owner_protected_staging_for_tests(
|
|
&plan,
|
|
&self.package_staging_parent,
|
|
update_transport.as_ref(),
|
|
self.signature_verifier.as_ref(),
|
|
)
|
|
.map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed);
|
|
}
|
|
if self.verify_staging_app_root {
|
|
safe_fs::verify_path_under_trusted_program_files(app_root)
|
|
.map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?;
|
|
}
|
|
safe_fs::create_directory_admin_owned_user_read_only(&self.package_staging_parent)
|
|
.map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?;
|
|
packages
|
|
.lease_cached_update_for_privileged_install(
|
|
&plan,
|
|
&self.package_staging_parent,
|
|
update_transport.as_ref(),
|
|
self.signature_verifier.as_ref(),
|
|
)
|
|
.map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)
|
|
}
|
|
|
|
fn sealed_cutover_store(&self) -> Result<SealedCutoverStore, PrivilegedRunnerFailure> {
|
|
if !self.enforce_cutover_journal {
|
|
return Err(PrivilegedRunnerFailure::RunnerUnavailable);
|
|
}
|
|
let storage = JsonStorage::new(self.storage_paths.root.clone());
|
|
SealedCutoverStore::prepare(&self.app_root, &storage)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)
|
|
}
|
|
|
|
fn ensure_ordinary_proxifyre_action_allowed(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
) -> Result<Option<String>, PrivilegedRunnerFailure> {
|
|
if action.component() != ManagedComponent::Proxifyre
|
|
|| matches!(
|
|
action,
|
|
PrivilegedAction::CutoverProxifyre | PrivilegedAction::CleanupProxifyreQuarantine
|
|
)
|
|
|| !self.enforce_cutover_journal
|
|
{
|
|
return Ok(None);
|
|
}
|
|
match verify_cutover_external_mutation_status(&self.app_root) {
|
|
CutoverExternalMutationStatus::Absent => return Ok(None),
|
|
CutoverExternalMutationStatus::Active => {}
|
|
CutoverExternalMutationStatus::TerminalRetirementPending
|
|
| CutoverExternalMutationStatus::RolledBackRetained
|
|
| CutoverExternalMutationStatus::RecoveryRequired => {
|
|
return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired);
|
|
}
|
|
}
|
|
let journal = read_existing_sealed_cutover_journal(&self.app_root)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?
|
|
.ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?;
|
|
if let Some(config_fingerprint) = stopped_cutover_route_lifecycle_exception(
|
|
action,
|
|
journal.phase,
|
|
journal.execution_mode,
|
|
journal.plan.original_service_state,
|
|
&journal.plan.config_fingerprint,
|
|
) {
|
|
return Ok(Some(config_fingerprint));
|
|
}
|
|
Err(PrivilegedRunnerFailure::CutoverRecoveryRequired)
|
|
}
|
|
|
|
fn prepare_cutover_action(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
requested_plan: Option<&ResolvedActionPlan>,
|
|
) -> Result<(ResolvedActionPlan, PreparedInput), PrivilegedRunnerFailure> {
|
|
let inventory = live_inventory(action);
|
|
let inventory_fingerprint = component_inventory_fingerprint_for_cutover(&inventory);
|
|
let recovery_probe = recovery_probe_context(&inventory_fingerprint);
|
|
let requested_recovery_probe = requested_plan
|
|
.and_then(|plan| plan.cutover_context.as_ref())
|
|
.is_some_and(|context| context == &recovery_probe);
|
|
let external_status = verify_cutover_external_mutation_status(&self.app_root);
|
|
let existing_journal = match external_status {
|
|
CutoverExternalMutationStatus::Active => Some(
|
|
read_existing_sealed_cutover_journal(&self.app_root)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?
|
|
.ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?,
|
|
),
|
|
CutoverExternalMutationStatus::Absent => {
|
|
if action != PrivilegedAction::CutoverProxifyre {
|
|
return Err(PrivilegedRunnerFailure::CutoverStateConflict);
|
|
}
|
|
None
|
|
}
|
|
CutoverExternalMutationStatus::RecoveryRequired => {
|
|
if action != PrivilegedAction::CutoverProxifyre {
|
|
return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired);
|
|
}
|
|
let journal = read_existing_sealed_cutover_journal(&self.app_root)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?
|
|
.ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?;
|
|
if journal.phase != CutoverPhase::RecoveryRequired {
|
|
return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired);
|
|
}
|
|
Some(journal)
|
|
}
|
|
CutoverExternalMutationStatus::TerminalRetirementPending
|
|
| CutoverExternalMutationStatus::RolledBackRetained => {
|
|
if action != PrivilegedAction::CutoverProxifyre {
|
|
return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired);
|
|
}
|
|
match read_existing_sealed_cutover_journal(&self.app_root)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?
|
|
{
|
|
Some(journal) => {
|
|
if journal.execution_mode != CutoverExecutionMode::Finished
|
|
|| !matches!(
|
|
journal.phase,
|
|
CutoverPhase::ComponentComplete | CutoverPhase::RolledBack
|
|
)
|
|
|| (external_status
|
|
== CutoverExternalMutationStatus::RolledBackRetained
|
|
&& journal.phase != CutoverPhase::RolledBack)
|
|
{
|
|
return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired);
|
|
}
|
|
let expected_terminal_record_fingerprint =
|
|
cutover_terminal_record_fingerprint(&journal)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?;
|
|
let plan = ResolvedActionPlan::new(
|
|
action,
|
|
None,
|
|
inventory_fingerprint,
|
|
no_config_fingerprint(action),
|
|
Some(if requested_recovery_probe {
|
|
recovery_probe
|
|
} else {
|
|
recovery_context_from_journal(&journal)?
|
|
}),
|
|
);
|
|
return Ok((
|
|
plan,
|
|
PreparedInput::Cutover(Box::new(PreparedSystemCutover {
|
|
mode: PreparedCutoverMode::Retirement,
|
|
store: None,
|
|
new: None,
|
|
retirement: Some(PreparedTerminalCutoverRetirement {
|
|
app_root: self.app_root.clone(),
|
|
expected_phase: Some(journal.phase),
|
|
expectation: CutoverTerminalRetirementExpectation::Journal {
|
|
cutover_id: journal.plan.cutover_id.clone(),
|
|
phase: journal.phase,
|
|
terminal_record_fingerprint:
|
|
expected_terminal_record_fingerprint,
|
|
},
|
|
}),
|
|
host: None,
|
|
user_evidence: None,
|
|
})),
|
|
));
|
|
}
|
|
None => {
|
|
let plan = ResolvedActionPlan::new(
|
|
action,
|
|
None,
|
|
inventory_fingerprint,
|
|
no_config_fingerprint(action),
|
|
Some(recovery_probe),
|
|
);
|
|
return Ok((
|
|
plan,
|
|
PreparedInput::Cutover(Box::new(PreparedSystemCutover {
|
|
mode: PreparedCutoverMode::Retirement,
|
|
store: None,
|
|
new: None,
|
|
retirement: Some(PreparedTerminalCutoverRetirement {
|
|
app_root: self.app_root.clone(),
|
|
expected_phase: None,
|
|
expectation:
|
|
CutoverTerminalRetirementExpectation::EmptyInfrastructure,
|
|
}),
|
|
host: None,
|
|
user_evidence: None,
|
|
})),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
if let Some(sealed_journal) = existing_journal {
|
|
let store = self.sealed_cutover_store()?;
|
|
let sealed_observation = read_sealed_cutover_status(&store)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?
|
|
.ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?;
|
|
let journal = store
|
|
.load()
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?
|
|
.ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?;
|
|
let observation = sealed_observation;
|
|
if journal != sealed_journal || observation.cutover_id != journal.plan.cutover_id {
|
|
return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired);
|
|
}
|
|
let (mode, context, user_evidence) = match action {
|
|
PrivilegedAction::CutoverProxifyre => (
|
|
PreparedCutoverMode::Recovery,
|
|
if requested_recovery_probe {
|
|
recovery_probe
|
|
} else {
|
|
recovery_context(&observation)?
|
|
},
|
|
None,
|
|
),
|
|
PrivilegedAction::CleanupProxifyreQuarantine => {
|
|
let evidence = read_local_cutover_user_evidence(&self.storage_paths)?;
|
|
validate_elevated_cleanup_evidence(
|
|
&journal,
|
|
&inventory_fingerprint,
|
|
&evidence,
|
|
unix_now_epoch_seconds()?,
|
|
)?;
|
|
let context = cleanup_context(&observation, &evidence)?;
|
|
(PreparedCutoverMode::Cleanup, context, Some(evidence))
|
|
}
|
|
_ => return Err(PrivilegedRunnerFailure::CutoverStateConflict),
|
|
};
|
|
let plan = ResolvedActionPlan::new(
|
|
action,
|
|
None,
|
|
inventory_fingerprint,
|
|
no_config_fingerprint(action),
|
|
Some(context),
|
|
);
|
|
let host = SystemCutoverHost::recover(&journal.plan)?;
|
|
return Ok((
|
|
plan,
|
|
PreparedInput::Cutover(Box::new(PreparedSystemCutover {
|
|
mode,
|
|
store: Some(store),
|
|
new: None,
|
|
retirement: None,
|
|
host: Some(host),
|
|
user_evidence,
|
|
})),
|
|
));
|
|
}
|
|
let initiating_startup_session_id = requested_plan
|
|
.and_then(|plan| plan.cutover_context.as_ref())
|
|
.filter(|context| {
|
|
context.mode == PrivilegedCutoverMode::New
|
|
&& context.cutover_id.is_none()
|
|
&& context.user_evidence.is_none()
|
|
})
|
|
.and_then(|context| context.initiating_startup_session_id.as_deref())
|
|
.and_then(canonical_uuid_v4)
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
let original_state = validate_new_cutover_discovery(&inventory)?;
|
|
let proxifyre = Box::new(self.lease_bundled(CatalogComponentId::Proxifyre)?);
|
|
let packet_filter = Box::new(self.lease_bundled(CatalogComponentId::WindowsPacketFilter)?);
|
|
let vc_runtime = Box::new(self.lease_bundled(CatalogComponentId::VcRuntime)?);
|
|
let config = GeneratedConfigLease::open(
|
|
&self.storage_paths.generated_dir.join(PROXIFYRE_OUTPUT_FILE),
|
|
ManagedComponent::Proxifyre,
|
|
)?;
|
|
let package = planned_leased_package(
|
|
InstalledPackageSource::Bundled,
|
|
&[
|
|
proxifyre.as_ref(),
|
|
packet_filter.as_ref(),
|
|
vc_runtime.as_ref(),
|
|
],
|
|
)?;
|
|
let context = new_cutover_context(
|
|
&inventory_fingerprint,
|
|
Some(original_state),
|
|
&initiating_startup_session_id,
|
|
);
|
|
let plan = ResolvedActionPlan::new(
|
|
action,
|
|
Some(package),
|
|
inventory_fingerprint,
|
|
config.sha256.clone(),
|
|
Some(context),
|
|
);
|
|
let core_plan = ProxifyreCutoverPlan::new(
|
|
&self.app_root,
|
|
PathBuf::from(LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT),
|
|
original_state,
|
|
proxifyre.proof().version.clone(),
|
|
proxifyre.proof().sha256.clone(),
|
|
config.sha256.clone(),
|
|
plan.operation_fingerprint.clone(),
|
|
initiating_startup_session_id,
|
|
);
|
|
let mut native = SystemProxifyreNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverIdentityRejected)?;
|
|
let native_before = native
|
|
.inspect()
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverIdentityRejected)?;
|
|
let runtime = prepare_proxifyre_cutover_package(&proxifyre).map_err(map_proxifyre_error)?;
|
|
let candidate = prepare_proxifyre_cutover_candidate(
|
|
&core_plan,
|
|
runtime,
|
|
&config.bytes,
|
|
&config.sha256,
|
|
native_before.packet_filter_present,
|
|
unix_now_epoch_seconds()?,
|
|
)
|
|
.map_err(map_proxifyre_error)?;
|
|
Ok((
|
|
plan,
|
|
PreparedInput::Cutover(Box::new(PreparedSystemCutover {
|
|
mode: PreparedCutoverMode::New,
|
|
store: None,
|
|
new: Some(PreparedNewSystemCutover {
|
|
app_root: self.app_root.clone(),
|
|
storage_root: self.storage_paths.root.clone(),
|
|
plan: core_plan,
|
|
candidate,
|
|
native,
|
|
proxifyre,
|
|
packet_filter,
|
|
vc_runtime,
|
|
config,
|
|
packet_filter_present: native_before.packet_filter_present,
|
|
vc_runtime_present: native_before.vc_runtime_present,
|
|
}),
|
|
retirement: None,
|
|
host: None,
|
|
user_evidence: None,
|
|
})),
|
|
))
|
|
}
|
|
|
|
fn resolve_internal(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
requested_plan: Option<&ResolvedActionPlan>,
|
|
) -> Result<ResolvedActionPlan, PrivilegedRunnerFailure> {
|
|
let mut pending = self
|
|
.pending
|
|
.lock()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
pending.take();
|
|
|
|
if matches!(
|
|
action,
|
|
PrivilegedAction::CutoverProxifyre | PrivilegedAction::CleanupProxifyreQuarantine
|
|
) {
|
|
let (plan, input) = self.prepare_cutover_action(action, requested_plan)?;
|
|
*pending = Some(PreparedAction {
|
|
action,
|
|
plan: plan.clone(),
|
|
input,
|
|
});
|
|
return Ok(plan);
|
|
}
|
|
|
|
let sealed_config_fingerprint = self.ensure_ordinary_proxifyre_action_allowed(action)?;
|
|
let input = self.prepare(action)?;
|
|
if action == PrivilegedAction::StartProxifyre
|
|
&& sealed_config_fingerprint
|
|
.as_deref()
|
|
.is_some_and(|expected| input.configuration_fingerprint(action) != expected)
|
|
{
|
|
return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired);
|
|
}
|
|
let plan = ResolvedActionPlan::new(
|
|
action,
|
|
input.package_fingerprint()?,
|
|
component_inventory_fingerprint_for_cutover(&live_inventory(action)),
|
|
input.configuration_fingerprint(action),
|
|
None,
|
|
);
|
|
*pending = Some(PreparedAction {
|
|
action,
|
|
plan: plan.clone(),
|
|
input,
|
|
});
|
|
Ok(plan)
|
|
}
|
|
}
|
|
|
|
fn stopped_cutover_route_lifecycle_exception(
|
|
action: PrivilegedAction,
|
|
phase: CutoverPhase,
|
|
execution_mode: CutoverExecutionMode,
|
|
original_service_state: LegacyServiceState,
|
|
config_fingerprint: &str,
|
|
) -> Option<String> {
|
|
(phase == CutoverPhase::LegacyQuarantined
|
|
&& execution_mode == CutoverExecutionMode::AwaitingUser
|
|
&& original_service_state == LegacyServiceState::Stopped
|
|
&& matches!(
|
|
action,
|
|
PrivilegedAction::StartProxifyre | PrivilegedAction::StopProxifyre
|
|
))
|
|
.then(|| config_fingerprint.to_string())
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[doc(hidden)]
|
|
pub fn stopped_cutover_route_lifecycle_exception_for_tests(
|
|
action: PrivilegedAction,
|
|
phase: CutoverPhase,
|
|
execution_mode: CutoverExecutionMode,
|
|
original_service_state: LegacyServiceState,
|
|
config_fingerprint: &str,
|
|
) -> Option<String> {
|
|
stopped_cutover_route_lifecycle_exception(
|
|
action,
|
|
phase,
|
|
execution_mode,
|
|
original_service_state,
|
|
config_fingerprint,
|
|
)
|
|
}
|
|
|
|
impl PrivilegedPlanResolver for SystemPrivilegedRuntime {
|
|
fn resolve(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
) -> Result<ResolvedActionPlan, PrivilegedRunnerFailure> {
|
|
self.resolve_internal(action, None)
|
|
}
|
|
|
|
fn resolve_elevated(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
requested_plan: &ResolvedActionPlan,
|
|
) -> Result<ResolvedActionPlan, PrivilegedRunnerFailure> {
|
|
self.resolve_internal(action, Some(requested_plan))
|
|
}
|
|
}
|
|
|
|
impl PrivilegedActionRunner for SystemPrivilegedRuntime {
|
|
fn run(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
plan: &ResolvedActionPlan,
|
|
) -> Result<PrivilegedMutationResult, PrivilegedRunnerFailure> {
|
|
let prepared = self
|
|
.pending
|
|
.lock()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?
|
|
.take()
|
|
.ok_or(PrivilegedRunnerFailure::PreconditionFailed)?;
|
|
if prepared.action != action || &prepared.plan != plan {
|
|
return Err(PrivilegedRunnerFailure::PreconditionFailed);
|
|
}
|
|
execute_prepared(action, prepared.input)
|
|
}
|
|
}
|
|
|
|
struct PreparedAction {
|
|
action: PrivilegedAction,
|
|
plan: ResolvedActionPlan,
|
|
input: PreparedInput,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum PreparedCutoverMode {
|
|
New,
|
|
Recovery,
|
|
Cleanup,
|
|
Retirement,
|
|
}
|
|
|
|
struct PreparedNewSystemCutover {
|
|
app_root: PathBuf,
|
|
storage_root: PathBuf,
|
|
plan: ProxifyreCutoverPlan,
|
|
candidate: PreparedProxifyreCutoverCandidate,
|
|
native: SystemProxifyreNativeHost,
|
|
proxifyre: Box<PrivilegedPackageLease>,
|
|
packet_filter: Box<PrivilegedPackageLease>,
|
|
vc_runtime: Box<PrivilegedPackageLease>,
|
|
config: GeneratedConfigLease,
|
|
packet_filter_present: bool,
|
|
vc_runtime_present: bool,
|
|
}
|
|
|
|
struct PreparedTerminalCutoverRetirement {
|
|
app_root: PathBuf,
|
|
expected_phase: Option<CutoverPhase>,
|
|
expectation: CutoverTerminalRetirementExpectation,
|
|
}
|
|
|
|
struct PreparedSystemCutover {
|
|
mode: PreparedCutoverMode,
|
|
store: Option<SealedCutoverStore>,
|
|
new: Option<PreparedNewSystemCutover>,
|
|
retirement: Option<PreparedTerminalCutoverRetirement>,
|
|
host: Option<SystemCutoverHost>,
|
|
user_evidence: Option<ComponentCutoverUserEvidence>,
|
|
}
|
|
|
|
impl PreparedSystemCutover {
|
|
fn execute(mut self) -> Result<PrivilegedMutationResult, PrivilegedRunnerFailure> {
|
|
let now = unix_now_epoch_seconds()?;
|
|
let (outcome, reboot_required) = match self.mode {
|
|
PreparedCutoverMode::New => {
|
|
let prepared = self
|
|
.new
|
|
.take()
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
execute_new_system_cutover(prepared, now)?
|
|
}
|
|
PreparedCutoverMode::Recovery => {
|
|
let store = self
|
|
.store
|
|
.take()
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
let mut host = self
|
|
.host
|
|
.take()
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
let outcome = recover_proxifyre_cutover(&store, &mut host, now)
|
|
.map_err(|error| map_cutover_error(self.mode, error))?;
|
|
(outcome, host.reboot_required)
|
|
}
|
|
PreparedCutoverMode::Cleanup => {
|
|
let store = self
|
|
.store
|
|
.take()
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
let mut host = self
|
|
.host
|
|
.take()
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
let evidence = self
|
|
.user_evidence
|
|
.as_ref()
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
let journal = store
|
|
.load()
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
match journal.phase {
|
|
CutoverPhase::LegacyQuarantined => {
|
|
mark_proxifyre_next_start_verified(&store, &mut host, evidence, now)
|
|
.map_err(|error| map_cutover_error(self.mode, error))?;
|
|
confirm_proxifyre_cleanup(&store, evidence, now)
|
|
.map_err(|error| map_cutover_error(self.mode, error))?;
|
|
}
|
|
CutoverPhase::NextStartVerified => {
|
|
confirm_proxifyre_cleanup(&store, evidence, now)
|
|
.map_err(|error| map_cutover_error(self.mode, error))?;
|
|
}
|
|
CutoverPhase::CleanupConfirmed => {}
|
|
_ => return Err(PrivilegedRunnerFailure::CutoverStateConflict),
|
|
}
|
|
let outcome = cleanup_proxifyre_quarantine(&store, &mut host, now)
|
|
.map_err(|error| map_cutover_error(self.mode, error))?;
|
|
(outcome, host.reboot_required)
|
|
}
|
|
PreparedCutoverMode::Retirement => {
|
|
let prepared = self
|
|
.retirement
|
|
.take()
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
execute_terminal_cutover_retirement(prepared)?
|
|
}
|
|
};
|
|
let (changed, result_code) = match outcome {
|
|
CutoverRunOutcome::AwaitingNextStart => {
|
|
(true, PrivilegedResultCode::CutoverAwaitingNextStart)
|
|
}
|
|
CutoverRunOutcome::QuarantinePending => {
|
|
(true, PrivilegedResultCode::CutoverQuarantinePending)
|
|
}
|
|
CutoverRunOutcome::RolledBack => (false, PrivilegedResultCode::CutoverRolledBack),
|
|
CutoverRunOutcome::CleanupPending => {
|
|
(true, PrivilegedResultCode::CutoverCleanupPending)
|
|
}
|
|
CutoverRunOutcome::Complete => (true, PrivilegedResultCode::CutoverComplete),
|
|
};
|
|
Ok(PrivilegedMutationResult {
|
|
activation: None,
|
|
changed,
|
|
reboot_required,
|
|
result_code: Some(result_code),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn execute_terminal_cutover_retirement(
|
|
prepared: PreparedTerminalCutoverRetirement,
|
|
) -> Result<(CutoverRunOutcome, bool), PrivilegedRunnerFailure> {
|
|
if prepared.expected_phase.is_some_and(|phase| {
|
|
!matches!(
|
|
phase,
|
|
CutoverPhase::ComponentComplete | CutoverPhase::RolledBack
|
|
)
|
|
}) {
|
|
return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired);
|
|
}
|
|
retire_existing_proxifyre_cutover_terminal_state(&prepared.app_root, &prepared.expectation)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?;
|
|
let outcome = if prepared.expected_phase == Some(CutoverPhase::ComponentComplete) {
|
|
CutoverRunOutcome::Complete
|
|
} else {
|
|
CutoverRunOutcome::RolledBack
|
|
};
|
|
Ok((outcome, false))
|
|
}
|
|
|
|
fn execute_new_system_cutover(
|
|
prepared: PreparedNewSystemCutover,
|
|
now: u64,
|
|
) -> Result<(CutoverRunOutcome, bool), PrivilegedRunnerFailure> {
|
|
let storage = JsonStorage::new(prepared.storage_root);
|
|
let store = SealedCutoverStore::prepare(&prepared.app_root, &storage)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?;
|
|
let mut host = match SystemCutoverHost::new(
|
|
&prepared.plan,
|
|
prepared.candidate,
|
|
prepared.native,
|
|
prepared.proxifyre,
|
|
prepared.packet_filter,
|
|
prepared.vc_runtime,
|
|
prepared.config,
|
|
prepared.packet_filter_present,
|
|
prepared.vc_runtime_present,
|
|
) {
|
|
Ok(host) => host,
|
|
Err(error) => {
|
|
retire_empty_new_cutover_store(&store)?;
|
|
return Err(error);
|
|
}
|
|
};
|
|
let outcome = match begin_proxifyre_cutover(&store, &mut host, prepared.plan, now) {
|
|
Ok(outcome) => outcome,
|
|
Err(error) => {
|
|
drop(host);
|
|
preserve_journal_or_retire_empty_new_cutover(&store)?;
|
|
return Err(map_cutover_error(PreparedCutoverMode::New, error));
|
|
}
|
|
};
|
|
Ok((outcome, host.reboot_required))
|
|
}
|
|
|
|
fn preserve_journal_or_retire_empty_new_cutover(
|
|
store: &SealedCutoverStore,
|
|
) -> Result<(), PrivilegedRunnerFailure> {
|
|
match store.load() {
|
|
Ok(Some(_)) => Ok(()),
|
|
Ok(None) => retire_empty_new_cutover_store(store),
|
|
Err(_) => Err(PrivilegedRunnerFailure::CutoverRecoveryRequired),
|
|
}
|
|
}
|
|
|
|
fn retire_empty_new_cutover_store(
|
|
store: &SealedCutoverStore,
|
|
) -> Result<(), PrivilegedRunnerFailure> {
|
|
retire_proxifyre_cutover_terminal_state(store)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)
|
|
}
|
|
|
|
fn unix_now_epoch_seconds() -> Result<u64, PrivilegedRunnerFailure> {
|
|
let value = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_secs())
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
(value > 0)
|
|
.then_some(value)
|
|
.ok_or(PrivilegedRunnerFailure::OperationFailed)
|
|
}
|
|
|
|
fn map_cutover_error(mode: PreparedCutoverMode, error: CutoverError) -> PrivilegedRunnerFailure {
|
|
match error {
|
|
CutoverError::AlreadyExists | CutoverError::Missing | CutoverError::StateConflict => {
|
|
PrivilegedRunnerFailure::CutoverStateConflict
|
|
}
|
|
CutoverError::RecoveryRequired | CutoverError::Interrupted => {
|
|
PrivilegedRunnerFailure::CutoverRecoveryRequired
|
|
}
|
|
CutoverError::InvalidPlan | CutoverError::Host(CutoverHostError::PreconditionFailed)
|
|
if mode == PreparedCutoverMode::New =>
|
|
{
|
|
PrivilegedRunnerFailure::CutoverIdentityRejected
|
|
}
|
|
CutoverError::InvalidPlan | CutoverError::Host(CutoverHostError::PreconditionFailed) => {
|
|
PrivilegedRunnerFailure::CutoverRecoveryRequired
|
|
}
|
|
CutoverError::Host(_) | CutoverError::Storage(_) if mode != PreparedCutoverMode::New => {
|
|
PrivilegedRunnerFailure::CutoverRecoveryRequired
|
|
}
|
|
CutoverError::Host(_) | CutoverError::Storage(_) => {
|
|
PrivilegedRunnerFailure::OperationFailed
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SystemCutoverHost {
|
|
filesystem: SystemProxifyreCutoverFilesystem,
|
|
scm: SystemProxifyreCutoverScm,
|
|
candidate: Option<SystemProxifyreCutoverCandidateWriter>,
|
|
native: SystemProxifyreNativeHost,
|
|
_proxifyre_lease: Option<Box<PrivilegedPackageLease>>,
|
|
packet_filter_lease: Option<Box<PrivilegedPackageLease>>,
|
|
vc_runtime_lease: Option<Box<PrivilegedPackageLease>>,
|
|
_config_lease: Option<GeneratedConfigLease>,
|
|
captured_packet_filter_present: Option<bool>,
|
|
captured_vc_runtime_present: Option<bool>,
|
|
last_observed_identity: Option<safe_fs::StableObjectIdentity>,
|
|
reboot_required: bool,
|
|
}
|
|
|
|
impl SystemCutoverHost {
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn new(
|
|
plan: &ProxifyreCutoverPlan,
|
|
candidate: PreparedProxifyreCutoverCandidate,
|
|
native: SystemProxifyreNativeHost,
|
|
proxifyre_lease: Box<PrivilegedPackageLease>,
|
|
packet_filter_lease: Box<PrivilegedPackageLease>,
|
|
vc_runtime_lease: Box<PrivilegedPackageLease>,
|
|
config_lease: GeneratedConfigLease,
|
|
packet_filter_present: bool,
|
|
vc_runtime_present: bool,
|
|
) -> Result<Self, PrivilegedRunnerFailure> {
|
|
let filesystem = SystemProxifyreCutoverFilesystem::open(plan)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverIdentityRejected)?;
|
|
let scm = SystemProxifyreCutoverScm::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverIdentityRejected)?;
|
|
let candidate = SystemProxifyreCutoverCandidateWriter::begin(candidate)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverIdentityRejected)?;
|
|
Ok(Self {
|
|
filesystem,
|
|
scm,
|
|
candidate: Some(candidate),
|
|
native,
|
|
_proxifyre_lease: Some(proxifyre_lease),
|
|
packet_filter_lease: Some(packet_filter_lease),
|
|
vc_runtime_lease: Some(vc_runtime_lease),
|
|
_config_lease: Some(config_lease),
|
|
captured_packet_filter_present: Some(packet_filter_present),
|
|
captured_vc_runtime_present: Some(vc_runtime_present),
|
|
last_observed_identity: None,
|
|
reboot_required: false,
|
|
})
|
|
}
|
|
|
|
fn recover(plan: &ProxifyreCutoverPlan) -> Result<Self, PrivilegedRunnerFailure> {
|
|
Ok(Self {
|
|
filesystem: SystemProxifyreCutoverFilesystem::open(plan)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?,
|
|
scm: SystemProxifyreCutoverScm::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?,
|
|
candidate: None,
|
|
native: SystemProxifyreNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?,
|
|
_proxifyre_lease: None,
|
|
packet_filter_lease: None,
|
|
vc_runtime_lease: None,
|
|
_config_lease: None,
|
|
captured_packet_filter_present: None,
|
|
captured_vc_runtime_present: None,
|
|
last_observed_identity: None,
|
|
reboot_required: false,
|
|
})
|
|
}
|
|
|
|
fn handoff_complete_candidate(
|
|
&mut self,
|
|
journal: &CutoverJournal,
|
|
) -> Result<(), CutoverHostError> {
|
|
let Some(candidate) = self.candidate.take() else {
|
|
return Ok(());
|
|
};
|
|
let (root, entries) = candidate
|
|
.into_complete_candidate_leases()
|
|
.map_err(|_| CutoverHostError::PreconditionFailed)?;
|
|
self.filesystem
|
|
.adopt_current_candidate(journal, root, entries)
|
|
}
|
|
|
|
fn handoff_partial_candidate_for_rollback(
|
|
&mut self,
|
|
journal: &CutoverJournal,
|
|
) -> Result<(), CutoverHostError> {
|
|
let Some(candidate) = self.candidate.take() else {
|
|
return Ok(());
|
|
};
|
|
let (root, entries) = candidate
|
|
.into_partial_candidate_leases_for_rollback(journal)
|
|
.map_err(|_| CutoverHostError::PreconditionFailed)?;
|
|
self.filesystem
|
|
.adopt_partial_current_candidate_for_rollback(journal, root, entries)
|
|
}
|
|
|
|
fn candidate_writer(&self) -> Result<&SystemProxifyreCutoverCandidateWriter, CutoverHostError> {
|
|
self.candidate
|
|
.as_ref()
|
|
.ok_or(CutoverHostError::ObservationFailed)
|
|
}
|
|
|
|
fn candidate_writer_mut(
|
|
&mut self,
|
|
) -> Result<&mut SystemProxifyreCutoverCandidateWriter, CutoverHostError> {
|
|
self.candidate
|
|
.as_mut()
|
|
.ok_or(CutoverHostError::MutationFailed)
|
|
}
|
|
|
|
fn observe_prerequisite(
|
|
&mut self,
|
|
operation: &CutoverOperation,
|
|
) -> Result<StateFingerprint, CutoverHostError> {
|
|
let snapshot = self
|
|
.native
|
|
.inspect()
|
|
.map_err(|_| CutoverHostError::ObservationFailed)?;
|
|
let matches = match operation {
|
|
CutoverOperation::InstallWindowsPacketFilterPrerequisite => {
|
|
snapshot.packet_filter_present
|
|
}
|
|
CutoverOperation::InstallVcRuntimePrerequisite => snapshot.vc_runtime_present,
|
|
CutoverOperation::UninstallTransactionWindowsPacketFilter => {
|
|
!snapshot.packet_filter_present
|
|
}
|
|
_ => return Err(CutoverHostError::Unsupported),
|
|
};
|
|
cutover_delegate_fingerprint(
|
|
"proxywarden:privileged-cutover-prerequisite-state:v1",
|
|
operation,
|
|
if matches { "expected" } else { "unexpected" },
|
|
)
|
|
}
|
|
|
|
fn observe_scm(
|
|
&mut self,
|
|
operation: &CutoverOperation,
|
|
journal: &CutoverJournal,
|
|
) -> Result<StateFingerprint, CutoverHostError> {
|
|
observe_proxifyre_cutover_scm_state(&mut self.scm, operation, &journal.before_state.service)
|
|
.map_err(|_| CutoverHostError::ObservationFailed)
|
|
}
|
|
|
|
fn mutate_prerequisite(
|
|
&mut self,
|
|
operation: &CutoverOperation,
|
|
journal: &CutoverJournal,
|
|
) -> Result<Option<MutationAuthorityEvidence>, CutoverHostError> {
|
|
let (effect, authority_evidence) = match operation {
|
|
CutoverOperation::InstallWindowsPacketFilterPrerequisite => {
|
|
if journal
|
|
.before_state
|
|
.prerequisites
|
|
.windows_packet_filter_present
|
|
{
|
|
return Err(CutoverHostError::MutationFailed);
|
|
}
|
|
let lease = self
|
|
.packet_filter_lease
|
|
.as_ref()
|
|
.ok_or(CutoverHostError::MutationFailed)?;
|
|
let code = self
|
|
.native
|
|
.run_installer(ProxifyreInstallerCommand::InstallPacketFilter(
|
|
lease.asset_path(),
|
|
))
|
|
.map_err(|_| CutoverHostError::MutationFailed)?;
|
|
(
|
|
map_cutover_packet_filter_install_exit(code)
|
|
.map_err(|_| CutoverHostError::MutationFailed)?,
|
|
Some(MutationAuthorityEvidence::WindowsPacketFilterInstalledByTransaction),
|
|
)
|
|
}
|
|
CutoverOperation::InstallVcRuntimePrerequisite => {
|
|
if journal.before_state.prerequisites.vc_runtime_present {
|
|
return Err(CutoverHostError::MutationFailed);
|
|
}
|
|
let lease = self
|
|
.vc_runtime_lease
|
|
.as_ref()
|
|
.ok_or(CutoverHostError::MutationFailed)?;
|
|
let code = self
|
|
.native
|
|
.run_installer(ProxifyreInstallerCommand::InstallVcRuntime(
|
|
lease.asset_path(),
|
|
))
|
|
.map_err(|_| CutoverHostError::MutationFailed)?;
|
|
(
|
|
map_proxifyre_installer_exit(ProxifyreInstallerKind::VcRuntimeInstall, code)
|
|
.map_err(|_| CutoverHostError::MutationFailed)?,
|
|
None,
|
|
)
|
|
}
|
|
CutoverOperation::UninstallTransactionWindowsPacketFilter => (
|
|
uninstall_cutover_transaction_packet_filter(journal)
|
|
.map_err(|_| CutoverHostError::MutationFailed)?,
|
|
None,
|
|
),
|
|
_ => return Err(CutoverHostError::Unsupported),
|
|
};
|
|
self.reboot_required |= effect.reboot_required;
|
|
Ok(authority_evidence)
|
|
}
|
|
}
|
|
|
|
impl CutoverHost for SystemCutoverHost {
|
|
fn capture_and_lease(
|
|
&mut self,
|
|
plan: &ProxifyreCutoverPlan,
|
|
) -> Result<LegacyBeforeState, CutoverHostError> {
|
|
if self.candidate.is_none()
|
|
|| self
|
|
.scm
|
|
.query_current_service()
|
|
.map_err(|_| CutoverHostError::PreconditionFailed)?
|
|
.path_matches
|
|
{
|
|
return Err(CutoverHostError::PreconditionFailed);
|
|
}
|
|
let service = self
|
|
.scm
|
|
.capture_legacy_service()
|
|
.map_err(|_| CutoverHostError::PreconditionFailed)?;
|
|
let filesystem = self.filesystem.capture_legacy_filesystem()?;
|
|
let current_components_parent = self.filesystem.capture_current_components_parent()?;
|
|
let native = self
|
|
.native
|
|
.inspect()
|
|
.map_err(|_| CutoverHostError::PreconditionFailed)?;
|
|
if self.captured_packet_filter_present != Some(native.packet_filter_present)
|
|
|| self.captured_vc_runtime_present != Some(native.vc_runtime_present)
|
|
{
|
|
return Err(CutoverHostError::PreconditionFailed);
|
|
}
|
|
let current_candidate = self.candidate_writer()?.prepared().snapshot().clone();
|
|
let identity_fingerprint = legacy_service_snapshot_fingerprint(&service);
|
|
Ok(LegacyBeforeState {
|
|
identity: ProxifyreLegacyIdentity::V2_2_1ToolsPrimaryService,
|
|
identity_fingerprint,
|
|
service,
|
|
filesystem,
|
|
current_components_parent,
|
|
current_candidate,
|
|
prerequisites: PrerequisiteBeforeState {
|
|
windows_packet_filter_present: native.packet_filter_present,
|
|
vc_runtime_present: native.vc_runtime_present,
|
|
},
|
|
package_fingerprint: plan.package_fingerprint.clone(),
|
|
config_fingerprint: plan.config_fingerprint.clone(),
|
|
additional_matching_service: has_additional_matching_legacy_proxifyre_service(
|
|
&plan.legacy_root,
|
|
),
|
|
})
|
|
}
|
|
|
|
fn reacquire_leases(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError> {
|
|
self.filesystem.reacquire_leases(journal)
|
|
}
|
|
|
|
fn observe(
|
|
&mut self,
|
|
operation: &CutoverOperation,
|
|
journal: &CutoverJournal,
|
|
) -> Result<StateFingerprint, CutoverHostError> {
|
|
self.last_observed_identity = None;
|
|
if *operation == CutoverOperation::PromoteCurrentCandidate && self.candidate.is_some() {
|
|
self.handoff_complete_candidate(journal)?;
|
|
}
|
|
if *operation == CutoverOperation::TombstoneCurrentCandidate && self.candidate.is_some() {
|
|
self.handoff_partial_candidate_for_rollback(journal)?;
|
|
}
|
|
if SystemProxifyreCutoverFilesystem::handles_operation(operation) {
|
|
let observed = self.filesystem.observe_operation(operation, journal)?;
|
|
self.last_observed_identity = self.filesystem.observed_object_identity();
|
|
return Ok(observed);
|
|
}
|
|
if candidate_operation(operation) {
|
|
let has_live_candidate = self.candidate.is_some();
|
|
let candidate = &mut self.candidate;
|
|
let filesystem = &mut self.filesystem;
|
|
let (observed, identity) = dispatch_candidate_observation(
|
|
has_live_candidate,
|
|
|| observe_live_candidate(candidate.as_mut(), operation),
|
|
|| observe_recovered_candidate(filesystem, operation, journal),
|
|
)?;
|
|
self.last_observed_identity = identity;
|
|
return Ok(observed);
|
|
}
|
|
if prerequisite_operation(operation) {
|
|
return self.observe_prerequisite(operation);
|
|
}
|
|
if scm_operation(operation) {
|
|
return self.observe_scm(operation, journal);
|
|
}
|
|
Err(CutoverHostError::Unsupported)
|
|
}
|
|
|
|
fn observed_object_identity(
|
|
&mut self,
|
|
_operation: &CutoverOperation,
|
|
_journal: &CutoverJournal,
|
|
) -> Result<Option<safe_fs::StableObjectIdentity>, CutoverHostError> {
|
|
Ok(self.last_observed_identity.clone())
|
|
}
|
|
|
|
fn expected_effect(
|
|
&mut self,
|
|
operation: &CutoverOperation,
|
|
journal: &CutoverJournal,
|
|
) -> Result<StateFingerprint, CutoverHostError> {
|
|
if SystemProxifyreCutoverFilesystem::handles_operation(operation) {
|
|
return self
|
|
.filesystem
|
|
.expected_operation_effect(operation, journal);
|
|
}
|
|
if scm_operation(operation) {
|
|
return expected_proxifyre_cutover_scm_effect(operation)
|
|
.map_err(|_| CutoverHostError::ObservationFailed);
|
|
}
|
|
if candidate_operation(operation) {
|
|
return candidate_operation_state_fingerprint(operation, true);
|
|
}
|
|
if prerequisite_operation(operation) {
|
|
return cutover_delegate_fingerprint(
|
|
"proxywarden:privileged-cutover-prerequisite-state:v1",
|
|
operation,
|
|
"expected",
|
|
);
|
|
}
|
|
Err(CutoverHostError::Unsupported)
|
|
}
|
|
|
|
fn mutate(
|
|
&mut self,
|
|
operation: &CutoverOperation,
|
|
journal: &CutoverJournal,
|
|
) -> Result<Option<MutationAuthorityEvidence>, CutoverHostError> {
|
|
if SystemProxifyreCutoverFilesystem::handles_operation(operation) {
|
|
self.filesystem.mutate_operation(operation, journal)?;
|
|
return Ok(None);
|
|
}
|
|
if candidate_operation(operation) {
|
|
mutate_proxifyre_cutover_candidate(self.candidate_writer_mut()?, operation)
|
|
.map_err(|_| CutoverHostError::MutationFailed)
|
|
.and_then(|handled| handled.then_some(()).ok_or(CutoverHostError::Unsupported))?;
|
|
return Ok(None);
|
|
}
|
|
if prerequisite_operation(operation) {
|
|
return self.mutate_prerequisite(operation, journal);
|
|
}
|
|
if scm_operation(operation) {
|
|
match operation {
|
|
CutoverOperation::StartCurrentService => self
|
|
.filesystem
|
|
.verify_current_runtime_read_leases(journal)?,
|
|
CutoverOperation::StartLegacyService => self
|
|
.filesystem
|
|
.prepare_legacy_service_start_leases(journal)?,
|
|
_ => {}
|
|
}
|
|
mutate_proxifyre_cutover_scm(&mut self.scm, operation, &journal.before_state.service)
|
|
.map_err(|_| CutoverHostError::MutationFailed)
|
|
.and_then(|handled| handled.then_some(()).ok_or(CutoverHostError::Unsupported))?;
|
|
return Ok(None);
|
|
}
|
|
Err(CutoverHostError::Unsupported)
|
|
}
|
|
|
|
fn verify_current(
|
|
&mut self,
|
|
journal: &CutoverJournal,
|
|
) -> Result<StateFingerprint, CutoverHostError> {
|
|
let snapshot = self
|
|
.native
|
|
.inspect()
|
|
.map_err(|_| CutoverHostError::ObservationFailed)?;
|
|
let expected_service = match journal.plan.original_service_state {
|
|
LegacyServiceState::Running => ProxifyreNativeServiceStatus::Running,
|
|
LegacyServiceState::Stopped => ProxifyreNativeServiceStatus::Stopped,
|
|
};
|
|
if snapshot.ownership != ProxifyreNativeOwnership::Managed
|
|
|| snapshot.service_status != expected_service
|
|
|| snapshot.installed_version.as_deref() != Some(&journal.plan.bundled_version)
|
|
|| !snapshot.install_root_trusted
|
|
|| !snapshot.install_root_reparse_free
|
|
|| !snapshot.receipt_valid
|
|
|| !snapshot.receipt_files_match
|
|
|| !snapshot.service_path_matches
|
|
|| !snapshot.demand_start
|
|
|| !snapshot.failure_recovery_disabled
|
|
|| !snapshot.builtin_users_start_denied
|
|
|| !snapshot.vc_runtime_present
|
|
|| !snapshot.packet_filter_present
|
|
{
|
|
return Err(CutoverHostError::VerificationFailed);
|
|
}
|
|
classify_cutover_runtime_config(self.native.verify_runtime_config(
|
|
&self.native.install_root().join("app-config.json"),
|
|
&journal.plan.config_fingerprint,
|
|
))?;
|
|
let value = json!({
|
|
"package": journal.plan.package_fingerprint,
|
|
"config": journal.plan.config_fingerprint,
|
|
"candidate": journal.before_state.current_candidate.manifest_fingerprint,
|
|
"service": match expected_service {
|
|
ProxifyreNativeServiceStatus::Running => "running",
|
|
_ => "stopped",
|
|
},
|
|
});
|
|
Ok(StateFingerprint::digest(
|
|
"proxywarden:privileged-cutover-current-verified:v1",
|
|
value.to_string().as_bytes(),
|
|
))
|
|
}
|
|
|
|
fn verify_current_inventory(
|
|
&mut self,
|
|
_journal: &CutoverJournal,
|
|
) -> Result<StateFingerprint, CutoverHostError> {
|
|
StateFingerprint::from_sha256(component_inventory_fingerprint_for_cutover(
|
|
&inventory_proxyfier(),
|
|
))
|
|
.map_err(|_| CutoverHostError::VerificationFailed)
|
|
}
|
|
|
|
fn verify_rollback(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError> {
|
|
self.filesystem.verify_rollback_restored(journal)?;
|
|
let legacy = self
|
|
.scm
|
|
.capture_legacy_service()
|
|
.map_err(|_| CutoverHostError::ObservationFailed)?;
|
|
let current = self
|
|
.scm
|
|
.query_current_service()
|
|
.map_err(|_| CutoverHostError::ObservationFailed)?;
|
|
let native = self
|
|
.native
|
|
.inspect()
|
|
.map_err(|_| CutoverHostError::ObservationFailed)?;
|
|
let vc_installed_by_transaction = journal.mutations.iter().any(|record| {
|
|
record.direction == crate::component_cutover::MutationDirection::Forward
|
|
&& record.operation == CutoverOperation::InstallVcRuntimePrerequisite
|
|
&& record.effect.as_ref().is_some_and(|effect| {
|
|
effect.disposition
|
|
== crate::component_cutover::EffectDisposition::ExpectedEffect
|
|
})
|
|
});
|
|
let expected_vc_runtime =
|
|
journal.before_state.prerequisites.vc_runtime_present || vc_installed_by_transaction;
|
|
if !legacy_service_owns_shared_name(
|
|
legacy == journal.before_state.service,
|
|
current.path_matches,
|
|
) || native.packet_filter_present
|
|
!= journal
|
|
.before_state
|
|
.prerequisites
|
|
.windows_packet_filter_present
|
|
|| native.vc_runtime_present != expected_vc_runtime
|
|
{
|
|
return Err(CutoverHostError::VerificationFailed);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_cleanup(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError> {
|
|
self.filesystem.verify_cleanup_ready(journal)?;
|
|
let legacy = self
|
|
.scm
|
|
.query_legacy_service()
|
|
.map_err(|_| CutoverHostError::ObservationFailed)?;
|
|
let current = self
|
|
.native
|
|
.inspect()
|
|
.map_err(|_| CutoverHostError::ObservationFailed)?;
|
|
if !current_service_owns_shared_name(legacy.path_matches, current.service_path_matches)
|
|
|| current.ownership != ProxifyreNativeOwnership::Managed
|
|
|| !current.receipt_valid
|
|
|| !current.receipt_files_match
|
|
{
|
|
return Err(CutoverHostError::VerificationFailed);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn classify_cutover_runtime_config(
|
|
result: Result<RuntimeConfigVerification, ProxifyreNativeHostError>,
|
|
) -> Result<(), CutoverHostError> {
|
|
match result {
|
|
Ok(RuntimeConfigVerification::Match) => Ok(()),
|
|
Ok(RuntimeConfigVerification::Mismatch) => Err(CutoverHostError::VerificationFailed),
|
|
Err(_) => Err(CutoverHostError::ObservationFailed),
|
|
}
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[doc(hidden)]
|
|
pub fn classify_cutover_runtime_config_for_tests(
|
|
result: Result<RuntimeConfigVerification, ProxifyreNativeHostError>,
|
|
) -> Result<(), CutoverHostError> {
|
|
classify_cutover_runtime_config(result)
|
|
}
|
|
|
|
type CandidateObservationResult =
|
|
Result<(StateFingerprint, Option<safe_fs::StableObjectIdentity>), CutoverHostError>;
|
|
|
|
fn dispatch_candidate_observation(
|
|
has_live_candidate: bool,
|
|
observe_live: impl FnOnce() -> CandidateObservationResult,
|
|
observe_recovered: impl FnOnce() -> CandidateObservationResult,
|
|
) -> CandidateObservationResult {
|
|
if has_live_candidate {
|
|
observe_live()
|
|
} else {
|
|
observe_recovered()
|
|
}
|
|
}
|
|
|
|
fn observe_live_candidate(
|
|
candidate: Option<&mut SystemProxifyreCutoverCandidateWriter>,
|
|
operation: &CutoverOperation,
|
|
) -> CandidateObservationResult {
|
|
let observation = candidate
|
|
.ok_or(CutoverHostError::ObservationFailed)?
|
|
.observe_candidate(operation)
|
|
.map_err(|_| CutoverHostError::ObservationFailed)?;
|
|
let (state, identity) = match observation {
|
|
ProxifyreCutoverCandidateObservation::Absent => ("absent", None),
|
|
ProxifyreCutoverCandidateObservation::Expected(snapshot) => {
|
|
("expected", Some(snapshot.identity))
|
|
}
|
|
ProxifyreCutoverCandidateObservation::Unknown => {
|
|
return Err(CutoverHostError::ObservationFailed)
|
|
}
|
|
};
|
|
Ok((
|
|
cutover_delegate_fingerprint(
|
|
"proxywarden:privileged-cutover-candidate-state:v1",
|
|
operation,
|
|
state,
|
|
)?,
|
|
identity,
|
|
))
|
|
}
|
|
|
|
fn observe_recovered_candidate(
|
|
filesystem: &mut SystemProxifyreCutoverFilesystem,
|
|
operation: &CutoverOperation,
|
|
journal: &CutoverJournal,
|
|
) -> CandidateObservationResult {
|
|
let observed = filesystem.observe_recovered_candidate_operation(operation, journal)?;
|
|
Ok((observed, filesystem.observed_object_identity()))
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[doc(hidden)]
|
|
pub fn dispatch_candidate_observation_for_tests(
|
|
has_live_candidate: bool,
|
|
observe_live: impl FnOnce() -> CandidateObservationResult,
|
|
observe_recovered: impl FnOnce() -> CandidateObservationResult,
|
|
) -> CandidateObservationResult {
|
|
dispatch_candidate_observation(has_live_candidate, observe_live, observe_recovered)
|
|
}
|
|
|
|
fn legacy_service_owns_shared_name(legacy_path_matches: bool, current_path_matches: bool) -> bool {
|
|
legacy_path_matches && !current_path_matches
|
|
}
|
|
|
|
fn current_service_owns_shared_name(legacy_path_matches: bool, current_path_matches: bool) -> bool {
|
|
!legacy_path_matches && current_path_matches
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[doc(hidden)]
|
|
pub fn shared_service_name_path_ownership_for_tests(
|
|
legacy_path_matches: bool,
|
|
current_path_matches: bool,
|
|
) -> (bool, bool) {
|
|
(
|
|
legacy_service_owns_shared_name(legacy_path_matches, current_path_matches),
|
|
current_service_owns_shared_name(legacy_path_matches, current_path_matches),
|
|
)
|
|
}
|
|
|
|
fn candidate_operation(operation: &CutoverOperation) -> bool {
|
|
matches!(
|
|
operation,
|
|
CutoverOperation::CreateCurrentCandidateRoot
|
|
| CutoverOperation::WriteCurrentCandidatePackageEntry(_)
|
|
| CutoverOperation::WriteCurrentCandidateConfig
|
|
| CutoverOperation::WriteCurrentCandidateMarker
|
|
| CutoverOperation::WriteCurrentCandidateReceipt
|
|
)
|
|
}
|
|
|
|
fn prerequisite_operation(operation: &CutoverOperation) -> bool {
|
|
matches!(
|
|
operation,
|
|
CutoverOperation::InstallWindowsPacketFilterPrerequisite
|
|
| CutoverOperation::InstallVcRuntimePrerequisite
|
|
| CutoverOperation::UninstallTransactionWindowsPacketFilter
|
|
)
|
|
}
|
|
|
|
fn scm_operation(operation: &CutoverOperation) -> bool {
|
|
matches!(
|
|
operation,
|
|
CutoverOperation::StopLegacyService
|
|
| CutoverOperation::DeleteLegacyService
|
|
| CutoverOperation::CreateCurrentService
|
|
| CutoverOperation::SetCurrentServicePolicy(_)
|
|
| CutoverOperation::SetCurrentServiceSecurity
|
|
| CutoverOperation::StartCurrentService
|
|
| CutoverOperation::StopCurrentService
|
|
| CutoverOperation::DeleteCurrentService
|
|
| CutoverOperation::CreateLegacyService
|
|
| CutoverOperation::RestoreLegacyServicePolicy(_)
|
|
| CutoverOperation::RestoreLegacyServiceSecurity
|
|
| CutoverOperation::StartLegacyService
|
|
)
|
|
}
|
|
|
|
fn cutover_delegate_fingerprint(
|
|
domain: &str,
|
|
operation: &CutoverOperation,
|
|
state: &str,
|
|
) -> Result<StateFingerprint, CutoverHostError> {
|
|
let value =
|
|
serde_json::to_vec(&(operation, state)).map_err(|_| CutoverHostError::ObservationFailed)?;
|
|
Ok(StateFingerprint::digest(domain, &value))
|
|
}
|
|
|
|
enum PreparedInput {
|
|
None,
|
|
Cutover(Box<PreparedSystemCutover>),
|
|
InstallProxifyre {
|
|
proxifyre: Box<PrivilegedPackageLease>,
|
|
packet_filter: Box<PrivilegedPackageLease>,
|
|
vc_runtime: Box<PrivilegedPackageLease>,
|
|
},
|
|
UpdateProxifyre(Box<PrivilegedPackageLease>),
|
|
StartProxifyre(GeneratedConfigLease),
|
|
InstallSingBox {
|
|
runtime: Box<PrivilegedPackageLease>,
|
|
wrapper: Box<PrivilegedPackageLease>,
|
|
},
|
|
UpdateSingBox(Box<PrivilegedPackageLease>),
|
|
StartSingBox(GeneratedConfigLease),
|
|
}
|
|
|
|
impl PreparedInput {
|
|
fn package_fingerprint(
|
|
&self,
|
|
) -> Result<Option<PlannedPackageFingerprint>, PrivilegedRunnerFailure> {
|
|
let (source, leases): (InstalledPackageSource, Vec<&PrivilegedPackageLease>) = match self {
|
|
Self::InstallProxifyre {
|
|
proxifyre,
|
|
packet_filter,
|
|
vc_runtime,
|
|
} => (
|
|
InstalledPackageSource::Bundled,
|
|
vec![
|
|
proxifyre.as_ref(),
|
|
packet_filter.as_ref(),
|
|
vc_runtime.as_ref(),
|
|
],
|
|
),
|
|
Self::UpdateProxifyre(runtime) | Self::UpdateSingBox(runtime) => {
|
|
(InstalledPackageSource::Cache, vec![runtime.as_ref()])
|
|
}
|
|
Self::InstallSingBox { runtime, wrapper } => (
|
|
InstalledPackageSource::Bundled,
|
|
vec![runtime.as_ref(), wrapper.as_ref()],
|
|
),
|
|
Self::None | Self::Cutover(_) | Self::StartProxifyre(_) | Self::StartSingBox(_) => {
|
|
return Ok(None)
|
|
}
|
|
};
|
|
planned_leased_package(source, &leases).map(Some)
|
|
}
|
|
|
|
fn configuration_fingerprint(&self, action: PrivilegedAction) -> String {
|
|
match self {
|
|
Self::StartProxifyre(config) | Self::StartSingBox(config) => config.sha256.clone(),
|
|
_ => no_config_fingerprint(action),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct GeneratedConfigLease {
|
|
_file: File,
|
|
bytes: Vec<u8>,
|
|
sha256: String,
|
|
}
|
|
|
|
impl GeneratedConfigLease {
|
|
fn open(path: &Path, component: ManagedComponent) -> Result<Self, PrivilegedRunnerFailure> {
|
|
let mut file = safe_fs::open_restricted_file_read_lease(path)
|
|
.map_err(|_| PrivilegedRunnerFailure::PreconditionFailed)?;
|
|
let mut bytes = Vec::new();
|
|
file.by_ref()
|
|
.take(MAX_GENERATED_CONFIG_BYTES + 1)
|
|
.read_to_end(&mut bytes)
|
|
.map_err(|_| PrivilegedRunnerFailure::PreconditionFailed)?;
|
|
if bytes.is_empty()
|
|
|| bytes.len() as u64 > MAX_GENERATED_CONFIG_BYTES
|
|
|| validate_generated_config(component, &bytes).is_err()
|
|
{
|
|
return Err(PrivilegedRunnerFailure::PreconditionFailed);
|
|
}
|
|
file.seek(SeekFrom::Start(0))
|
|
.map_err(|_| PrivilegedRunnerFailure::PreconditionFailed)?;
|
|
let sha256 = format!("{:x}", Sha256::digest(&bytes));
|
|
Ok(Self {
|
|
_file: file,
|
|
bytes,
|
|
sha256,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn execute_prepared(
|
|
action: PrivilegedAction,
|
|
input: PreparedInput,
|
|
) -> Result<PrivilegedMutationResult, PrivilegedRunnerFailure> {
|
|
match (action, input) {
|
|
(
|
|
PrivilegedAction::CutoverProxifyre | PrivilegedAction::CleanupProxifyreQuarantine,
|
|
PreparedInput::Cutover(cutover),
|
|
) => cutover.execute(),
|
|
(
|
|
PrivilegedAction::InstallProxifyre,
|
|
PreparedInput::InstallProxifyre {
|
|
proxifyre,
|
|
packet_filter,
|
|
vc_runtime,
|
|
},
|
|
) => {
|
|
let mut host = SystemProxifyreNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
install_proxifyre_native(&mut host, &proxifyre, &packet_filter, &vc_runtime)
|
|
.map(proxifyre_result)
|
|
.map_err(map_proxifyre_error)
|
|
}
|
|
(PrivilegedAction::UpdateProxifyre, PreparedInput::UpdateProxifyre(runtime)) => {
|
|
let mut host = SystemProxifyreNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
update_proxifyre_native(&mut host, &runtime)
|
|
.map(proxifyre_result)
|
|
.map_err(map_proxifyre_error)
|
|
}
|
|
(PrivilegedAction::StartProxifyre, PreparedInput::StartProxifyre(config)) => {
|
|
let mut host = SystemProxifyreNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
match preflight_start_proxifyre_native(&mut host).map_err(map_proxifyre_error)? {
|
|
ProxifyreNativeStartDisposition::AlreadyRunning => Ok(PrivilegedMutationResult {
|
|
activation: None,
|
|
changed: false,
|
|
reboot_required: false,
|
|
result_code: None,
|
|
}),
|
|
ProxifyreNativeStartDisposition::ReadyForConfigPromotion => {
|
|
let path = host
|
|
.promote_runtime_config_bytes(&config.bytes, &config.sha256)
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
let mut activation = None;
|
|
let outcome = crate::proxifyre_runtime::start_proxifyre_native_observed(
|
|
&mut host,
|
|
&path,
|
|
&config.sha256,
|
|
|| {
|
|
activation =
|
|
observe_activation(ManagedComponent::Proxifyre, &config.sha256);
|
|
},
|
|
)
|
|
.map_err(map_proxifyre_error)?;
|
|
let mut result = proxifyre_result(outcome);
|
|
result.activation = activation;
|
|
Ok(result)
|
|
}
|
|
}
|
|
}
|
|
(PrivilegedAction::StopProxifyre, PreparedInput::None) => {
|
|
let mut host = SystemProxifyreNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
stop_proxifyre_native(&mut host)
|
|
.map(proxifyre_result)
|
|
.map_err(map_proxifyre_error)
|
|
}
|
|
(PrivilegedAction::ConfigureProxifyreFirewall, PreparedInput::None) => {
|
|
let mut host = SystemProxifyreNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
configure_proxifyre_firewall_native(&mut host)
|
|
.map(proxifyre_result)
|
|
.map_err(map_proxifyre_error)
|
|
}
|
|
(PrivilegedAction::UninstallProxifyre, PreparedInput::None) => {
|
|
let mut host = SystemProxifyreNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
uninstall_proxifyre_native(&mut host)
|
|
.map(proxifyre_result)
|
|
.map_err(map_proxifyre_error)
|
|
}
|
|
(PrivilegedAction::InstallSingBox, PreparedInput::InstallSingBox { runtime, wrapper }) => {
|
|
let mut host = SystemSingBoxNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
install_singbox_native(&mut host, &runtime, &wrapper)
|
|
.map(singbox_result)
|
|
.map_err(map_singbox_error)
|
|
}
|
|
(PrivilegedAction::UpdateSingBox, PreparedInput::UpdateSingBox(runtime)) => {
|
|
let mut host = SystemSingBoxNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
update_singbox_native(&mut host, &runtime)
|
|
.map(singbox_result)
|
|
.map_err(map_singbox_error)
|
|
}
|
|
(PrivilegedAction::StartSingBox, PreparedInput::StartSingBox(config)) => {
|
|
let mut host = SystemSingBoxNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
match preflight_start_singbox_native(&mut host).map_err(map_singbox_error)? {
|
|
SingBoxStartPreflight::AlreadyRunning => Ok(PrivilegedMutationResult {
|
|
activation: None,
|
|
changed: false,
|
|
reboot_required: false,
|
|
result_code: None,
|
|
}),
|
|
SingBoxStartPreflight::Ready => {
|
|
let path = host
|
|
.promote_runtime_config_bytes(&config.bytes, &config.sha256)
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
let mut activation = None;
|
|
let outcome = crate::singbox_runtime::start_singbox_native_observed(
|
|
&mut host,
|
|
&path,
|
|
&config.sha256,
|
|
|| {
|
|
activation =
|
|
observe_activation(ManagedComponent::SingBox, &config.sha256);
|
|
},
|
|
)
|
|
.map_err(map_singbox_error)?;
|
|
let mut result = singbox_result(outcome);
|
|
result.activation = activation;
|
|
Ok(result)
|
|
}
|
|
}
|
|
}
|
|
(PrivilegedAction::StopSingBox, PreparedInput::None) => {
|
|
let mut host = SystemSingBoxNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
stop_singbox_native(&mut host)
|
|
.map(singbox_result)
|
|
.map_err(map_singbox_error)
|
|
}
|
|
(PrivilegedAction::UninstallSingBox, PreparedInput::None) => {
|
|
let mut host = SystemSingBoxNativeHost::from_current_exe()
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
uninstall_singbox_native(&mut host)
|
|
.map(singbox_result)
|
|
.map_err(map_singbox_error)
|
|
}
|
|
_ => Err(PrivilegedRunnerFailure::PreconditionFailed),
|
|
}
|
|
}
|
|
|
|
fn observe_activation(
|
|
component: ManagedComponent,
|
|
hash: &str,
|
|
) -> Option<crate::privileged_jobs::ActivationAcknowledgement> {
|
|
let service = match component {
|
|
ManagedComponent::Proxifyre => crate::process::KnownWindowsService::Proxifyre,
|
|
ManagedComponent::SingBox => crate::process::KnownWindowsService::SingBox,
|
|
};
|
|
Some(crate::privileged_jobs::ActivationAcknowledgement {
|
|
component,
|
|
config_sha256: hash.to_string(),
|
|
instance: crate::process::running_service_instance(service).ok()?,
|
|
})
|
|
}
|
|
|
|
fn proxifyre_result(
|
|
outcome: crate::proxifyre_runtime::ProxifyreNativeOutcome,
|
|
) -> PrivilegedMutationResult {
|
|
PrivilegedMutationResult {
|
|
activation: None,
|
|
changed: outcome.changed,
|
|
reboot_required: outcome.reboot_required,
|
|
result_code: None,
|
|
}
|
|
}
|
|
|
|
fn singbox_result(outcome: SingBoxNativeOutcome) -> PrivilegedMutationResult {
|
|
let changed = matches!(
|
|
outcome,
|
|
SingBoxNativeOutcome::Installed
|
|
| SingBoxNativeOutcome::Updated
|
|
| SingBoxNativeOutcome::Started
|
|
| SingBoxNativeOutcome::Stopped
|
|
| SingBoxNativeOutcome::Uninstalled
|
|
);
|
|
PrivilegedMutationResult {
|
|
activation: None,
|
|
changed,
|
|
reboot_required: false,
|
|
result_code: None,
|
|
}
|
|
}
|
|
|
|
fn map_proxifyre_error(error: ProxifyreNativeError) -> PrivilegedRunnerFailure {
|
|
match error {
|
|
ProxifyreNativeError::InvalidPackage | ProxifyreNativeError::InvalidArchive => {
|
|
PrivilegedRunnerFailure::PackageVerificationFailed
|
|
}
|
|
ProxifyreNativeError::ServiceCollision => PrivilegedRunnerFailure::ServiceCollision,
|
|
ProxifyreNativeError::InvalidReceipt | ProxifyreNativeError::OwnershipMismatch => {
|
|
PrivilegedRunnerFailure::OwnershipMismatch
|
|
}
|
|
ProxifyreNativeError::ServiceRunning
|
|
| ProxifyreNativeError::ServiceNotStopped
|
|
| ProxifyreNativeError::ServiceContract
|
|
| ProxifyreNativeError::DowngradeRejected
|
|
| ProxifyreNativeError::RuntimeConfigInvalid => PrivilegedRunnerFailure::PreconditionFailed,
|
|
ProxifyreNativeError::InstallerExit { .. }
|
|
| ProxifyreNativeError::HostStep(_)
|
|
| ProxifyreNativeError::RollbackFailed => PrivilegedRunnerFailure::OperationFailed,
|
|
}
|
|
}
|
|
|
|
fn map_singbox_error(error: SingBoxNativeError) -> PrivilegedRunnerFailure {
|
|
match error {
|
|
SingBoxNativeError::InvalidPackage | SingBoxNativeError::InvalidArchive => {
|
|
PrivilegedRunnerFailure::PackageVerificationFailed
|
|
}
|
|
SingBoxNativeError::ServiceCollision => PrivilegedRunnerFailure::ServiceCollision,
|
|
SingBoxNativeError::InvalidReceipt | SingBoxNativeError::OwnershipMismatch => {
|
|
PrivilegedRunnerFailure::OwnershipMismatch
|
|
}
|
|
SingBoxNativeError::ServiceRunning
|
|
| SingBoxNativeError::ServiceNotStopped
|
|
| SingBoxNativeError::ServiceContract
|
|
| SingBoxNativeError::DowngradeRejected
|
|
| SingBoxNativeError::RuntimeConfigInvalid => PrivilegedRunnerFailure::PreconditionFailed,
|
|
SingBoxNativeError::CleanupPending => PrivilegedRunnerFailure::CleanupPending,
|
|
SingBoxNativeError::HostStep(_) | SingBoxNativeError::RollbackFailed => {
|
|
PrivilegedRunnerFailure::OperationFailed
|
|
}
|
|
}
|
|
}
|
|
|
|
fn installed_app_root() -> Result<PathBuf, PrivilegedRunnerFailure> {
|
|
let executable =
|
|
std::env::current_exe().map_err(|_| PrivilegedRunnerFailure::RunnerUnavailable)?;
|
|
safe_fs::verify_path_under_trusted_program_files(&executable)
|
|
.map_err(|_| PrivilegedRunnerFailure::RunnerUnavailable)?;
|
|
executable
|
|
.parent()
|
|
.map(Path::to_path_buf)
|
|
.ok_or(PrivilegedRunnerFailure::RunnerUnavailable)
|
|
}
|
|
|
|
pub(crate) fn installed_cutover_external_status() -> Option<CutoverExternalMutationStatus> {
|
|
installed_app_root()
|
|
.ok()
|
|
.map(|app_root| verify_cutover_external_mutation_status(&app_root))
|
|
}
|
|
|
|
fn read_local_cutover_observation(
|
|
storage_paths: &StoragePaths,
|
|
) -> Option<ComponentCutoverObservation> {
|
|
JsonStorage::new(storage_paths.root.clone())
|
|
.read_component_cutover_observation()
|
|
.ok()
|
|
.flatten()
|
|
}
|
|
|
|
fn read_local_cutover_user_evidence(
|
|
storage_paths: &StoragePaths,
|
|
) -> Result<ComponentCutoverUserEvidence, PrivilegedRunnerFailure> {
|
|
JsonStorage::new(storage_paths.root.clone())
|
|
.read_component_cutover_user_evidence()
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverStateConflict)?
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)
|
|
}
|
|
|
|
fn valid_cutover_observation(
|
|
observation: &ComponentCutoverObservation,
|
|
) -> Option<&ComponentCutoverObservation> {
|
|
let parsed = Uuid::parse_str(&observation.cutover_id).ok()?;
|
|
(observation.component == "proxifyre"
|
|
&& parsed.get_version() == Some(uuid::Version::Random)
|
|
&& parsed.hyphenated().to_string() == observation.cutover_id
|
|
&& is_sha256(&observation.operation_fingerprint)
|
|
&& is_sha256(&observation.transaction_fingerprint))
|
|
.then_some(observation)
|
|
}
|
|
|
|
fn new_cutover_context(
|
|
inventory_fingerprint: &str,
|
|
original_state: Option<LegacyServiceState>,
|
|
initiating_startup_session_id: &str,
|
|
) -> PrivilegedCutoverContext {
|
|
let state = match original_state {
|
|
Some(LegacyServiceState::Running) => "running",
|
|
Some(LegacyServiceState::Stopped) => "stopped",
|
|
None => "sealed-recovery-probe",
|
|
};
|
|
PrivilegedCutoverContext {
|
|
mode: PrivilegedCutoverMode::New,
|
|
cutover_id: None,
|
|
initiating_startup_session_id: Some(initiating_startup_session_id.to_string()),
|
|
evidence_fingerprint: cutover_context_fingerprint(
|
|
"proxywarden:privileged-cutover-new-evidence:v1",
|
|
&[
|
|
"proxifyre-2.2.1-tools-primary-topshelf",
|
|
LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT,
|
|
LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION,
|
|
state,
|
|
inventory_fingerprint,
|
|
initiating_startup_session_id,
|
|
],
|
|
),
|
|
user_evidence: None,
|
|
}
|
|
}
|
|
|
|
fn recovery_context(
|
|
observation: &ComponentCutoverObservation,
|
|
) -> Result<PrivilegedCutoverContext, PrivilegedRunnerFailure> {
|
|
valid_cutover_observation(observation)
|
|
.ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?;
|
|
Ok(PrivilegedCutoverContext {
|
|
mode: PrivilegedCutoverMode::Recovery,
|
|
cutover_id: Some(observation.cutover_id.clone()),
|
|
initiating_startup_session_id: None,
|
|
evidence_fingerprint: observation.transaction_fingerprint.clone(),
|
|
user_evidence: None,
|
|
})
|
|
}
|
|
|
|
fn recovery_context_from_journal(
|
|
journal: &CutoverJournal,
|
|
) -> Result<PrivilegedCutoverContext, PrivilegedRunnerFailure> {
|
|
Ok(PrivilegedCutoverContext {
|
|
mode: PrivilegedCutoverMode::Recovery,
|
|
cutover_id: Some(journal.plan.cutover_id.clone()),
|
|
initiating_startup_session_id: None,
|
|
evidence_fingerprint: cutover_transaction_fingerprint(journal)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?,
|
|
user_evidence: None,
|
|
})
|
|
}
|
|
|
|
fn recovery_probe_context(inventory_fingerprint: &str) -> PrivilegedCutoverContext {
|
|
PrivilegedCutoverContext {
|
|
mode: PrivilegedCutoverMode::Recovery,
|
|
cutover_id: None,
|
|
initiating_startup_session_id: None,
|
|
evidence_fingerprint: cutover_recovery_probe_fingerprint(inventory_fingerprint),
|
|
user_evidence: None,
|
|
}
|
|
}
|
|
|
|
fn cleanup_context(
|
|
observation: &ComponentCutoverObservation,
|
|
user_evidence: &ComponentCutoverUserEvidence,
|
|
) -> Result<PrivilegedCutoverContext, PrivilegedRunnerFailure> {
|
|
valid_cutover_observation(observation)
|
|
.filter(|value| {
|
|
matches!(
|
|
value.state,
|
|
CutoverDisplayState::AwaitingNextStart
|
|
| CutoverDisplayState::AwaitingRouteSmoke
|
|
| CutoverDisplayState::CleanupReady
|
|
| CutoverDisplayState::CleanupPending
|
|
)
|
|
})
|
|
.ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
validate_component_cutover_user_evidence(user_evidence)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
if user_evidence.cutover_id != observation.cutover_id
|
|
|| !user_evidence.route_smoke_confirmed
|
|
|| user_evidence.confirmed_at_epoch_seconds.is_none()
|
|
{
|
|
return Err(PrivilegedRunnerFailure::CutoverStateConflict);
|
|
}
|
|
let user_evidence_fingerprint = component_cutover_user_evidence_fingerprint(user_evidence)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
Ok(PrivilegedCutoverContext {
|
|
mode: PrivilegedCutoverMode::Cleanup,
|
|
cutover_id: Some(observation.cutover_id.clone()),
|
|
initiating_startup_session_id: None,
|
|
evidence_fingerprint: cutover_context_fingerprint(
|
|
"proxywarden:privileged-cutover-pending-evidence:v1",
|
|
&[
|
|
&observation.transaction_fingerprint,
|
|
&user_evidence_fingerprint,
|
|
],
|
|
),
|
|
user_evidence: Some(user_evidence.clone()),
|
|
})
|
|
}
|
|
|
|
fn validate_elevated_cleanup_evidence(
|
|
journal: &CutoverJournal,
|
|
live_inventory_fingerprint: &str,
|
|
evidence: &ComponentCutoverUserEvidence,
|
|
now_epoch_seconds: u64,
|
|
) -> Result<(), PrivilegedRunnerFailure> {
|
|
validate_component_cutover_user_evidence(evidence)
|
|
.map_err(|_| PrivilegedRunnerFailure::CutoverStateConflict)?;
|
|
if evidence.cutover_id != journal.plan.cutover_id
|
|
|| evidence.startup_session_id == journal.plan.created_startup_session_id
|
|
|| evidence.current_inventory_fingerprint != live_inventory_fingerprint
|
|
|| !evidence.route_smoke_confirmed
|
|
|| evidence.observed_at_epoch_seconds > now_epoch_seconds
|
|
|| evidence.confirmed_at_epoch_seconds > Some(now_epoch_seconds)
|
|
|| journal.next_start.as_ref().is_some_and(|sealed| {
|
|
sealed.startup_session_id != evidence.startup_session_id
|
|
|| sealed.current_inventory_fingerprint != evidence.current_inventory_fingerprint
|
|
|| sealed.observed_at_epoch_seconds != evidence.observed_at_epoch_seconds
|
|
})
|
|
|| journal.route_smoke.as_ref().is_some_and(|sealed| {
|
|
!sealed.confirmed_by_user
|
|
|| Some(sealed.confirmed_at_epoch_seconds) != evidence.confirmed_at_epoch_seconds
|
|
})
|
|
{
|
|
return Err(PrivilegedRunnerFailure::CutoverStateConflict);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_new_cutover_discovery(
|
|
inventory: &ComponentInventory,
|
|
) -> Result<LegacyServiceState, PrivilegedRunnerFailure> {
|
|
if !inventory.issues.is_empty()
|
|
|| inventory.candidates.len() != 1
|
|
|| has_additional_matching_legacy_proxifyre_service(Path::new(
|
|
LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT,
|
|
))
|
|
{
|
|
return Err(PrivilegedRunnerFailure::CutoverIdentityRejected);
|
|
}
|
|
let candidate = inventory
|
|
.selected_candidate()
|
|
.filter(|candidate| {
|
|
candidate.classification == ComponentClassification::ManagedLegacy
|
|
&& candidate.role == CandidateRole::Legacy
|
|
&& candidate.issues.is_empty()
|
|
&& normalized_path(&candidate.root)
|
|
== normalized_text_path(LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT)
|
|
&& candidate.binary_version.as_deref()
|
|
== Some(LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION)
|
|
&& candidate.executable_path.as_deref().is_some_and(|path| {
|
|
normalized_path(path)
|
|
== normalized_text_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
|
})
|
|
})
|
|
.ok_or(PrivilegedRunnerFailure::CutoverIdentityRejected)?;
|
|
let service = candidate
|
|
.service
|
|
.as_ref()
|
|
.filter(|service| {
|
|
service.name.eq_ignore_ascii_case("ProxiFyreService")
|
|
&& service.path_matches_candidate
|
|
&& service.binary_version.as_deref() == Some(LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION)
|
|
})
|
|
.ok_or(PrivilegedRunnerFailure::CutoverIdentityRejected)?;
|
|
match service.status.trim().to_ascii_lowercase().as_str() {
|
|
"running" => Ok(LegacyServiceState::Running),
|
|
"stopped" => Ok(LegacyServiceState::Stopped),
|
|
_ => Err(PrivilegedRunnerFailure::CutoverIdentityRejected),
|
|
}
|
|
}
|
|
|
|
fn cutover_context_fingerprint(domain: &str, fields: &[&str]) -> String {
|
|
let mut digest = Sha256::new();
|
|
digest.update(domain.as_bytes());
|
|
digest.update([0]);
|
|
for field in fields {
|
|
digest.update((field.len() as u64).to_le_bytes());
|
|
digest.update(field.as_bytes());
|
|
}
|
|
format!("{:x}", digest.finalize())
|
|
}
|
|
|
|
fn is_sha256(value: &str) -> bool {
|
|
value.len() == 64
|
|
&& value
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
|
}
|
|
|
|
fn canonical_uuid_v4(value: &str) -> Option<String> {
|
|
let parsed = Uuid::parse_str(value).ok()?;
|
|
(parsed.get_version() == Some(uuid::Version::Random)
|
|
&& parsed.hyphenated().to_string() == value)
|
|
.then(|| value.to_string())
|
|
}
|
|
|
|
fn resolve_local_configuration(
|
|
action: PrivilegedAction,
|
|
storage_paths: &StoragePaths,
|
|
) -> Result<String, PrivilegedRunnerFailure> {
|
|
let (path, component) = match action {
|
|
PrivilegedAction::StartProxifyre => (
|
|
storage_paths.generated_dir.join(PROXIFYRE_OUTPUT_FILE),
|
|
ManagedComponent::Proxifyre,
|
|
),
|
|
PrivilegedAction::StartSingBox => (
|
|
storage_paths.generated_dir.join(SINGBOX_OUTPUT_FILE),
|
|
ManagedComponent::SingBox,
|
|
),
|
|
_ => return Ok(no_config_fingerprint(action)),
|
|
};
|
|
Ok(GeneratedConfigLease::open(&path, component)?.sha256)
|
|
}
|
|
|
|
fn no_config_fingerprint(action: PrivilegedAction) -> String {
|
|
let action = match action {
|
|
PrivilegedAction::InstallProxifyre => "proxifyre.install",
|
|
PrivilegedAction::UpdateProxifyre => "proxifyre.update",
|
|
PrivilegedAction::StartProxifyre => "proxifyre.start",
|
|
PrivilegedAction::StopProxifyre => "proxifyre.stop",
|
|
PrivilegedAction::ConfigureProxifyreFirewall => "proxifyre.configure-firewall",
|
|
PrivilegedAction::UninstallProxifyre => "proxifyre.uninstall",
|
|
PrivilegedAction::CutoverProxifyre => "proxifyre.cutover",
|
|
PrivilegedAction::CleanupProxifyreQuarantine => "proxifyre.cleanup-quarantine",
|
|
PrivilegedAction::InstallSingBox => "sing-box.install",
|
|
PrivilegedAction::UpdateSingBox => "sing-box.update",
|
|
PrivilegedAction::StartSingBox => "sing-box.start",
|
|
PrivilegedAction::StopSingBox => "sing-box.stop",
|
|
PrivilegedAction::UninstallSingBox => "sing-box.uninstall",
|
|
};
|
|
format!(
|
|
"{:x}",
|
|
Sha256::digest(format!("proxywarden:privileged:no-config:v1:{action}").as_bytes())
|
|
)
|
|
}
|
|
|
|
fn planned_leased_package(
|
|
source: InstalledPackageSource,
|
|
leases: &[&PrivilegedPackageLease],
|
|
) -> Result<PlannedPackageFingerprint, PrivilegedRunnerFailure> {
|
|
let first = leases
|
|
.first()
|
|
.ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)?;
|
|
let origin_proof = match source {
|
|
InstalledPackageSource::Bundled => None,
|
|
InstalledPackageSource::Cache => {
|
|
first.proof().independent_proof.as_ref().map(planned_origin)
|
|
}
|
|
};
|
|
if source == InstalledPackageSource::Cache && (leases.len() != 1 || origin_proof.is_none()) {
|
|
return Err(PrivilegedRunnerFailure::PackageVerificationFailed);
|
|
}
|
|
Ok(PlannedPackageFingerprint {
|
|
source,
|
|
version: first.proof().version.clone(),
|
|
assets: leases
|
|
.iter()
|
|
.map(|lease| planned_asset(lease.proof()))
|
|
.collect(),
|
|
origin_proof,
|
|
})
|
|
}
|
|
|
|
fn planned_cached_package(plan: &PrivilegedCachedUpdatePlan) -> PlannedPackageFingerprint {
|
|
PlannedPackageFingerprint {
|
|
source: InstalledPackageSource::Cache,
|
|
version: plan.version.clone(),
|
|
assets: vec![PlannedAssetFingerprint {
|
|
component_id: plan.component_id,
|
|
version: plan.version.clone(),
|
|
name: plan.independent_proof.asset_name.clone(),
|
|
sha256: plan.independent_proof.sha256_from_api.clone(),
|
|
size: plan.independent_proof.size,
|
|
}],
|
|
origin_proof: Some(planned_origin(&plan.independent_proof)),
|
|
}
|
|
}
|
|
|
|
fn planned_asset(proof: &PrivilegedPackageProof) -> PlannedAssetFingerprint {
|
|
PlannedAssetFingerprint {
|
|
component_id: proof.component_id,
|
|
version: proof.version.clone(),
|
|
name: proof.asset_name.clone(),
|
|
sha256: proof.sha256.clone(),
|
|
size: proof.size,
|
|
}
|
|
}
|
|
|
|
fn planned_origin(proof: &GithubReleaseDigestProof) -> PlannedGithubOriginProof {
|
|
PlannedGithubOriginProof {
|
|
repository: proof.repository.clone(),
|
|
stable_tag: proof.stable_tag.clone(),
|
|
release_id: proof.release_id,
|
|
asset_id: proof.asset_id,
|
|
asset_name: proof.asset_name.clone(),
|
|
size: proof.size,
|
|
sha256_from_api: proof.sha256_from_api.clone(),
|
|
}
|
|
}
|
|
|
|
fn live_inventory(action: PrivilegedAction) -> ComponentInventory {
|
|
match action.component() {
|
|
ManagedComponent::Proxifyre => inventory_proxyfier(),
|
|
ManagedComponent::SingBox => inventory_singbox(),
|
|
}
|
|
}
|
|
|
|
fn normalized_path(path: &Path) -> String {
|
|
normalized_text_path(&path.to_string_lossy())
|
|
}
|
|
|
|
fn normalized_text_path(value: &str) -> String {
|
|
value.trim().replace('/', "\\").to_ascii_lowercase()
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[doc(hidden)]
|
|
pub fn validate_generated_config_bytes_for_tests(
|
|
component: ManagedComponent,
|
|
bytes: &[u8],
|
|
) -> bool {
|
|
validate_generated_config(component, bytes).is_ok()
|
|
}
|
|
|
|
fn validate_generated_config(component: ManagedComponent, bytes: &[u8]) -> Result<(), ()> {
|
|
match component {
|
|
ManagedComponent::Proxifyre => validate_proxifyre_config(bytes),
|
|
ManagedComponent::SingBox => validate_singbox_config(bytes),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct StrictProxifyreConfig {
|
|
log_level: String,
|
|
bypass_lan: bool,
|
|
proxies: Vec<StrictProxifyreProxy>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct StrictProxifyreProxy {
|
|
app_names: Vec<String>,
|
|
socks5_proxy_endpoint: String,
|
|
supported_protocols: Vec<String>,
|
|
}
|
|
|
|
fn validate_proxifyre_config(bytes: &[u8]) -> Result<(), ()> {
|
|
let config: StrictProxifyreConfig = serde_json::from_slice(bytes).map_err(|_| ())?;
|
|
if config.log_level != "Info" || !config.bypass_lan {
|
|
return Err(());
|
|
}
|
|
for proxy in config.proxies {
|
|
if proxy.app_names.is_empty()
|
|
|| proxy.app_names.iter().any(|name| !safe_nonempty_text(name))
|
|
|| !strict_plain_endpoint(&proxy.socks5_proxy_endpoint)
|
|
|| proxy.supported_protocols.is_empty()
|
|
{
|
|
return Err(());
|
|
}
|
|
let protocol_count = proxy.supported_protocols.len();
|
|
let mut protocols = proxy.supported_protocols;
|
|
protocols.sort();
|
|
protocols.dedup();
|
|
if protocols
|
|
.iter()
|
|
.any(|value| value != "TCP" && value != "UDP")
|
|
|| protocols.len() != protocol_count
|
|
|| protocols.len() > 2
|
|
{
|
|
return Err(());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn strict_plain_endpoint(value: &str) -> bool {
|
|
if value.contains(['/', '@', '?', '#', '\\']) || value.chars().any(char::is_whitespace) {
|
|
return false;
|
|
}
|
|
let Some((host, port)) = value.rsplit_once(':') else {
|
|
return false;
|
|
};
|
|
let valid_host = if let Some(ipv6) = host
|
|
.strip_prefix('[')
|
|
.and_then(|host| host.strip_suffix(']'))
|
|
{
|
|
ipv6.parse::<std::net::Ipv6Addr>().is_ok()
|
|
} else {
|
|
!host.contains([':', '[', ']']) && safe_nonempty_text(host)
|
|
};
|
|
valid_host
|
|
&& port.bytes().all(|byte| byte.is_ascii_digit())
|
|
&& port.parse::<u16>().is_ok_and(|parsed_port| parsed_port > 0)
|
|
}
|
|
|
|
#[test]
|
|
fn strict_proxy_endpoint_accepts_bracketed_ipv6_without_relaxing_url_rejection() {
|
|
assert!(strict_plain_endpoint("[2001:db8::1]:1080"));
|
|
assert!(strict_plain_endpoint("proxy.example.test:1080"));
|
|
for endpoint in [
|
|
"2001:db8::1:1080",
|
|
"[wrong]:1080",
|
|
"host:0",
|
|
"host:65536",
|
|
"host:1080/path",
|
|
"user@host:1080",
|
|
"host:1080?x",
|
|
"host :1080",
|
|
] {
|
|
assert!(!strict_plain_endpoint(endpoint), "{endpoint}");
|
|
}
|
|
}
|
|
|
|
fn validate_singbox_config(bytes: &[u8]) -> Result<(), ()> {
|
|
let value: Value = serde_json::from_slice(bytes).map_err(|_| ())?;
|
|
let root = exact_object(&value, &["log", "inbounds", "outbounds", "route"])?;
|
|
validate_singbox_log(root.get("log").ok_or(())?)?;
|
|
validate_singbox_inbounds(root.get("inbounds").ok_or(())?)?;
|
|
validate_singbox_outbounds(root.get("outbounds").ok_or(())?)?;
|
|
validate_singbox_route(root.get("route").ok_or(())?)
|
|
}
|
|
|
|
fn validate_singbox_log(value: &Value) -> Result<(), ()> {
|
|
let log = exact_object(value, &["disabled", "level", "timestamp"])?;
|
|
if log.get("disabled").and_then(Value::as_bool) != Some(false)
|
|
|| log.get("level").and_then(Value::as_str) != Some("info")
|
|
|| log.get("timestamp").and_then(Value::as_bool) != Some(true)
|
|
{
|
|
return Err(());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_singbox_inbounds(value: &Value) -> Result<(), ()> {
|
|
let inbounds = value.as_array().ok_or(())?;
|
|
if inbounds.len() != 1 {
|
|
return Err(());
|
|
}
|
|
let inbound = exact_object(
|
|
&inbounds[0],
|
|
&[
|
|
"type",
|
|
"tag",
|
|
"listen",
|
|
"listen_port",
|
|
"users",
|
|
"set_system_proxy",
|
|
],
|
|
)?;
|
|
let listen = inbound.get("listen").and_then(Value::as_str).ok_or(())?;
|
|
if inbound.get("type").and_then(Value::as_str) != Some("mixed")
|
|
|| inbound.get("tag").and_then(Value::as_str) != Some(DEFAULT_MIXED_INBOUND_TAG)
|
|
|| !matches!(listen, "127.0.0.1" | "::1" | "localhost")
|
|
|| !valid_json_port(inbound.get("listen_port").ok_or(())?)
|
|
|| !inbound
|
|
.get("users")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(Vec::is_empty)
|
|
|| inbound.get("set_system_proxy").and_then(Value::as_bool) != Some(false)
|
|
{
|
|
return Err(());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_singbox_outbounds(value: &Value) -> Result<(), ()> {
|
|
let outbounds = value.as_array().ok_or(())?;
|
|
if outbounds.len() != 3 {
|
|
return Err(());
|
|
}
|
|
validate_vpn_outbound(&outbounds[0])?;
|
|
validate_fixed_outbound(&outbounds[1], "direct", DEFAULT_DIRECT_OUTBOUND_TAG)?;
|
|
validate_fixed_outbound(&outbounds[2], "block", DEFAULT_BLOCK_OUTBOUND_TAG)
|
|
}
|
|
|
|
fn validate_fixed_outbound(value: &Value, kind: &str, tag: &str) -> Result<(), ()> {
|
|
let outbound = exact_object(value, &["type", "tag"])?;
|
|
if outbound.get("type").and_then(Value::as_str) != Some(kind)
|
|
|| outbound.get("tag").and_then(Value::as_str) != Some(tag)
|
|
{
|
|
return Err(());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_vpn_outbound(value: &Value) -> Result<(), ()> {
|
|
let outbound = value.as_object().ok_or(())?;
|
|
let kind = outbound.get("type").and_then(Value::as_str).ok_or(())?;
|
|
let allowed = match kind {
|
|
"vless" => &[
|
|
"type",
|
|
"tag",
|
|
"server",
|
|
"server_port",
|
|
"uuid",
|
|
"flow",
|
|
"tls",
|
|
"transport",
|
|
"packet_encoding",
|
|
][..],
|
|
"vmess" => &[
|
|
"type",
|
|
"tag",
|
|
"server",
|
|
"server_port",
|
|
"uuid",
|
|
"security",
|
|
"tls",
|
|
"transport",
|
|
][..],
|
|
"trojan" => &[
|
|
"type",
|
|
"tag",
|
|
"server",
|
|
"server_port",
|
|
"password",
|
|
"tls",
|
|
"transport",
|
|
][..],
|
|
"shadowsocks" => &["type", "tag", "server", "server_port", "method", "password"][..],
|
|
"hysteria2" => &[
|
|
"type",
|
|
"tag",
|
|
"server",
|
|
"server_port",
|
|
"password",
|
|
"up_mbps",
|
|
"down_mbps",
|
|
"obfs",
|
|
"tls",
|
|
][..],
|
|
_ => return Err(()),
|
|
};
|
|
require_allowed_keys(outbound, allowed)?;
|
|
if outbound.get("tag").and_then(Value::as_str) != Some(DEFAULT_VPN_OUTBOUND_TAG)
|
|
|| !outbound
|
|
.get("server")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(safe_nonempty_text)
|
|
|| !outbound.get("server_port").is_some_and(valid_json_port)
|
|
{
|
|
return Err(());
|
|
}
|
|
|
|
match kind {
|
|
"vless" => {
|
|
require_text(outbound, "uuid")?;
|
|
if outbound.get("flow").is_some_and(|value| {
|
|
value
|
|
.as_str()
|
|
.is_none_or(|text| text.chars().any(|character| character.is_control()))
|
|
}) {
|
|
return Err(());
|
|
}
|
|
if outbound
|
|
.get("packet_encoding")
|
|
.is_some_and(|value| value.as_str() != Some("xudp"))
|
|
{
|
|
return Err(());
|
|
}
|
|
}
|
|
"vmess" => {
|
|
require_text(outbound, "uuid")?;
|
|
require_text(outbound, "security")?;
|
|
}
|
|
"trojan" | "hysteria2" => require_text(outbound, "password")?,
|
|
"shadowsocks" => {
|
|
require_text(outbound, "method")?;
|
|
require_text(outbound, "password")?;
|
|
}
|
|
_ => return Err(()),
|
|
}
|
|
if let Some(tls) = outbound.get("tls") {
|
|
validate_tls(tls)?;
|
|
}
|
|
if let Some(transport) = outbound.get("transport") {
|
|
validate_transport(transport)?;
|
|
}
|
|
if let Some(obfs) = outbound.get("obfs") {
|
|
let obfs = exact_object(obfs, &["type", "password"])?;
|
|
if obfs.get("type").and_then(Value::as_str) != Some("salamander") {
|
|
return Err(());
|
|
}
|
|
require_text(obfs, "password")?;
|
|
}
|
|
for key in ["up_mbps", "down_mbps"] {
|
|
if outbound
|
|
.get(key)
|
|
.is_some_and(|value| value.as_u64().is_none_or(|number| number == 0))
|
|
{
|
|
return Err(());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_tls(value: &Value) -> Result<(), ()> {
|
|
let tls = value.as_object().ok_or(())?;
|
|
require_allowed_keys(
|
|
tls,
|
|
&[
|
|
"enabled",
|
|
"server_name",
|
|
"insecure",
|
|
"alpn",
|
|
"utls",
|
|
"reality",
|
|
],
|
|
)?;
|
|
if tls.get("enabled").and_then(Value::as_bool) != Some(true)
|
|
|| tls
|
|
.get("server_name")
|
|
.is_some_and(|value| !value.as_str().is_some_and(safe_nonempty_text))
|
|
|| tls.get("insecure").is_some_and(|value| !value.is_boolean())
|
|
|| tls.get("alpn").is_some_and(|value| {
|
|
value.as_array().is_none_or(|items| {
|
|
items.is_empty()
|
|
|| items
|
|
.iter()
|
|
.any(|item| !item.as_str().is_some_and(safe_nonempty_text))
|
|
})
|
|
})
|
|
{
|
|
return Err(());
|
|
}
|
|
if let Some(utls) = tls.get("utls") {
|
|
let utls = exact_object(utls, &["enabled", "fingerprint"])?;
|
|
if utls.get("enabled").and_then(Value::as_bool) != Some(true) {
|
|
return Err(());
|
|
}
|
|
require_text(utls, "fingerprint")?;
|
|
}
|
|
if let Some(reality) = tls.get("reality") {
|
|
let reality = exact_object(reality, &["enabled", "public_key", "short_id"])?;
|
|
if reality.get("enabled").and_then(Value::as_bool) != Some(true) {
|
|
return Err(());
|
|
}
|
|
require_text(reality, "public_key")?;
|
|
require_text(reality, "short_id")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_transport(value: &Value) -> Result<(), ()> {
|
|
let transport = value.as_object().ok_or(())?;
|
|
require_allowed_keys(transport, &["type", "path", "headers"])?;
|
|
if transport.get("type").and_then(Value::as_str) != Some("ws")
|
|
|| transport
|
|
.get("path")
|
|
.is_some_and(|value| !value.as_str().is_some_and(safe_nonempty_text))
|
|
{
|
|
return Err(());
|
|
}
|
|
if let Some(headers) = transport.get("headers") {
|
|
let headers = headers.as_object().ok_or(())?;
|
|
require_allowed_keys(headers, &["Host"])?;
|
|
if headers
|
|
.get("Host")
|
|
.is_some_and(|value| !value.as_str().is_some_and(safe_nonempty_text))
|
|
{
|
|
return Err(());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_singbox_route(value: &Value) -> Result<(), ()> {
|
|
let route = exact_object(value, &["rules", "final"])?;
|
|
let rules = route.get("rules").and_then(Value::as_array).ok_or(())?;
|
|
if rules.len() != 1
|
|
|| route.get("final").and_then(Value::as_str) != Some(DEFAULT_VPN_OUTBOUND_TAG)
|
|
{
|
|
return Err(());
|
|
}
|
|
let rule = exact_object(&rules[0], &["ip_is_private", "outbound"])?;
|
|
if rule.get("ip_is_private").and_then(Value::as_bool) != Some(true)
|
|
|| rule.get("outbound").and_then(Value::as_str) != Some(DEFAULT_DIRECT_OUTBOUND_TAG)
|
|
{
|
|
return Err(());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn exact_object<'a>(value: &'a Value, keys: &[&str]) -> Result<&'a Map<String, Value>, ()> {
|
|
let object = value.as_object().ok_or(())?;
|
|
if object.len() != keys.len() || keys.iter().any(|key| !object.contains_key(*key)) {
|
|
return Err(());
|
|
}
|
|
Ok(object)
|
|
}
|
|
|
|
fn require_allowed_keys(object: &Map<String, Value>, keys: &[&str]) -> Result<(), ()> {
|
|
if object.keys().any(|key| !keys.contains(&key.as_str())) {
|
|
return Err(());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn require_text(object: &Map<String, Value>, key: &str) -> Result<(), ()> {
|
|
if object
|
|
.get(key)
|
|
.and_then(Value::as_str)
|
|
.is_some_and(safe_nonempty_text)
|
|
{
|
|
Ok(())
|
|
} else {
|
|
Err(())
|
|
}
|
|
}
|
|
|
|
fn safe_nonempty_text(value: &str) -> bool {
|
|
!value.trim().is_empty() && !value.chars().any(|character| character.is_control())
|
|
}
|
|
|
|
fn valid_json_port(value: &Value) -> bool {
|
|
value
|
|
.as_u64()
|
|
.is_some_and(|port| (1..=u64::from(u16::MAX)).contains(&port))
|
|
}
|