@@ -48,7 +48,11 @@ impl ProxiFyreAdapter {
|
||||
|
||||
proxies.push(ProxiFyreProxy {
|
||||
app_names,
|
||||
socks5_proxy_endpoint: format!("{}:{}", target.host, target.port),
|
||||
socks5_proxy_endpoint: if target.host.contains(':') {
|
||||
format!("[{}]:{}", target.host, target.port)
|
||||
} else {
|
||||
format!("{}:{}", target.host, target.port)
|
||||
},
|
||||
supported_protocols: protocols_for_profile(profile),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer};
|
||||
use crate::process::command_no_window;
|
||||
use crate::process::run_fixed_process;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{env, fs, fs::OpenOptions, io::Write, path::Path};
|
||||
use std::{env, fs, path::Path, time::Duration};
|
||||
|
||||
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
|
||||
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
|
||||
@@ -41,31 +41,24 @@ impl SingBoxAdapter {
|
||||
where
|
||||
C: SingBoxConfigChecker + ?Sized,
|
||||
{
|
||||
let selected_server = request
|
||||
.config
|
||||
.selected_server_id
|
||||
.as_deref()
|
||||
.and_then(|id| {
|
||||
request
|
||||
.subscription_cache
|
||||
.servers
|
||||
.iter()
|
||||
.find(|server| server.id == id)
|
||||
})
|
||||
.or_else(|| {
|
||||
let tag = request.config.selected_server_tag.as_deref()?;
|
||||
request
|
||||
.subscription_cache
|
||||
.servers
|
||||
.iter()
|
||||
.find(|server| server.tag == tag)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedServer,
|
||||
"Сервер Local sing-box не выбран или отсутствует в текущей подписке",
|
||||
)
|
||||
})?;
|
||||
let selected_server = if let Some(id) = request.config.selected_server_id.as_deref() {
|
||||
request
|
||||
.subscription_cache
|
||||
.servers
|
||||
.iter()
|
||||
.find(|server| server.id == id)
|
||||
} else {
|
||||
let mut matches = request.subscription_cache.servers.iter().filter(|server| {
|
||||
Some(server.tag.as_str()) == request.config.selected_server_tag.as_deref()
|
||||
});
|
||||
matches.next().filter(|_| matches.next().is_none())
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedServer,
|
||||
"Сервер Local sing-box не выбран или отсутствует в текущей подписке",
|
||||
)
|
||||
})?;
|
||||
let vpn_outbound = selected_outbound(
|
||||
&request.subscription_cache.config,
|
||||
selected_server,
|
||||
@@ -213,70 +206,50 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||
uuid::Uuid::new_v4().hyphenated()
|
||||
));
|
||||
|
||||
{
|
||||
let mut config_file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&config_path)
|
||||
.map_err(|error| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!(
|
||||
"Не удалось создать временный конфиг sing-box '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let write_result = config_file.write_all(config_json.as_bytes());
|
||||
drop(config_file);
|
||||
if let Err(error) = write_result {
|
||||
let _ = fs::remove_file(&config_path);
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!(
|
||||
"Не удалось записать временный конфиг sing-box '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
));
|
||||
struct TemporaryConfig(std::path::PathBuf);
|
||||
impl Drop for TemporaryConfig {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
let output = command_no_window(binary_path)
|
||||
.arg("check")
|
||||
.arg("-c")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
let _ = fs::remove_file(&config_path);
|
||||
let _temporary = TemporaryConfig(config_path.clone());
|
||||
crate::safe_fs::write_restricted_atomic(&config_path, config_json.as_bytes()).map_err(
|
||||
|_| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!(
|
||||
"Не удалось выполнить '{} check': {error}",
|
||||
binary_path.display()
|
||||
),
|
||||
"Не удалось безопасно создать временный конфиг sing-box",
|
||||
)
|
||||
})?;
|
||||
let _ = fs::remove_file(&config_path);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let message = command_message(&stdout, &stderr);
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(SingBoxConfigError::new(
|
||||
},
|
||||
)?;
|
||||
// Checker output can contain credentials from the outbound. The bounded
|
||||
// native process runner discards both streams instead of exposing them.
|
||||
let status = run_fixed_process(
|
||||
binary_path,
|
||||
&[
|
||||
"check".into(),
|
||||
"-c".into(),
|
||||
config_path.as_os_str().to_owned(),
|
||||
],
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.map_err(|error| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!("Проверка sing-box не прошла: {message}"),
|
||||
));
|
||||
if error.kind() == std::io::ErrorKind::TimedOut {
|
||||
"Проверка sing-box превысила 30 секунд"
|
||||
} else {
|
||||
"Не удалось выполнить проверку sing-box"
|
||||
},
|
||||
)
|
||||
})?;
|
||||
if !status.success() {
|
||||
return Err(SingBoxConfigError::new(SingBoxConfigErrorKind::CheckFailed,
|
||||
"sing-box отклонил конфигурацию выбранного сервера. Обновите подписку или выберите другой сервер."));
|
||||
}
|
||||
|
||||
Ok(SingBoxCheckResult {
|
||||
checked: true,
|
||||
success: true,
|
||||
message: if message.is_empty() {
|
||||
"Проверка sing-box прошла успешно".to_string()
|
||||
} else {
|
||||
message
|
||||
},
|
||||
message: "Проверка sing-box прошла успешно".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -295,32 +268,39 @@ fn selected_outbound(
|
||||
"В cache подписки нет outbounds",
|
||||
)
|
||||
})?;
|
||||
let outbound = outbounds
|
||||
.iter()
|
||||
.find(|outbound| {
|
||||
let tag_matches = outbound
|
||||
let outbound = if selected_server.id.starts_with("pw-") {
|
||||
outbounds.iter().find(|outbound| {
|
||||
crate::subscription::outbound_server_id(outbound) == selected_server.id
|
||||
})
|
||||
} else {
|
||||
// Legacy endpoint IDs are readable only when they identify exactly one outbound.
|
||||
let mut matches = outbounds.iter().filter(|outbound| {
|
||||
outbound
|
||||
.get("tag")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|tag| tag.trim() == selected_server.tag);
|
||||
let server_matches = outbound
|
||||
.get("server")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|server| server.eq_ignore_ascii_case(&selected_server.server));
|
||||
let port_matches = outbound
|
||||
.get("server_port")
|
||||
.and_then(Value::as_u64)
|
||||
.is_some_and(|port| port == u64::from(selected_server.server_port));
|
||||
tag_matches && server_matches && port_matches
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||
format!(
|
||||
"Outbound не найден: {} ({}:{})",
|
||||
selected_server.tag, selected_server.server, selected_server.server_port
|
||||
),
|
||||
)
|
||||
})?;
|
||||
.is_some_and(|tag| {
|
||||
crate::models::decode_percent_encoded_utf8(tag).trim() == selected_server.tag
|
||||
})
|
||||
&& outbound.get("type").and_then(Value::as_str)
|
||||
== Some(selected_server.server_type.as_str())
|
||||
&& outbound
|
||||
.get("server")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|host| host.eq_ignore_ascii_case(&selected_server.server))
|
||||
&& outbound.get("server_port").and_then(Value::as_u64)
|
||||
== Some(u64::from(selected_server.server_port))
|
||||
});
|
||||
matches.next().filter(|_| matches.next().is_none())
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||
format!(
|
||||
"Outbound не найден: {} ({}:{})",
|
||||
selected_server.tag, selected_server.server, selected_server.server_port
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let outbound_type = outbound
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
@@ -359,15 +339,3 @@ fn selected_outbound(
|
||||
|
||||
Ok(outbound)
|
||||
}
|
||||
|
||||
fn command_message(stdout: &str, stderr: &str) -> String {
|
||||
let stdout = stdout.trim();
|
||||
let stderr = stderr.trim();
|
||||
|
||||
match (stdout.is_empty(), stderr.is_empty()) {
|
||||
(true, true) => String::new(),
|
||||
(false, true) => stdout.to_string(),
|
||||
(true, false) => stderr.to_string(),
|
||||
(false, false) => format!("{stdout}\n{stderr}"),
|
||||
}
|
||||
}
|
||||
|
||||
+5
-71
@@ -1,89 +1,23 @@
|
||||
//! Administrator-state detection and explicit UAC restart boundary.
|
||||
|
||||
use crate::command_dto::{AdminStatusResponse, CommandError};
|
||||
use crate::powershell::{
|
||||
escape_single as escape_powershell_single, is_elevated as is_running_elevated,
|
||||
output_message as powershell_output_message, run_command as run_powershell_command,
|
||||
};
|
||||
use std::env;
|
||||
use crate::command_dto::AdminStatusResponse;
|
||||
use crate::process::is_process_elevated;
|
||||
|
||||
pub fn admin_status() -> AdminStatusResponse {
|
||||
let is_windows = cfg!(windows);
|
||||
let is_elevated = is_running_elevated();
|
||||
let is_elevated = is_process_elevated();
|
||||
let message = if !is_windows {
|
||||
"Проверка прав администратора нужна только в Windows.".to_string()
|
||||
} else if is_elevated {
|
||||
"ProxyWarden уже запущен от имени администратора.".to_string()
|
||||
} else {
|
||||
"Для установки компонентов и управления службами можно перезапустить ProxyWarden от имени администратора один раз.".to_string()
|
||||
"Права администратора будут запрошены отдельно для выбранного действия.".to_string()
|
||||
};
|
||||
|
||||
AdminStatusResponse {
|
||||
is_windows,
|
||||
is_elevated,
|
||||
can_restart_elevated: is_windows && !is_elevated,
|
||||
can_restart_elevated: false,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn launch_app_as_admin() -> Result<(), CommandError> {
|
||||
if !cfg!(windows) {
|
||||
return Err(CommandError::new(
|
||||
"admin_restart_unsupported",
|
||||
"Перезапуск от имени администратора доступен только в Windows.",
|
||||
));
|
||||
}
|
||||
|
||||
if is_running_elevated() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().map_err(|error| {
|
||||
CommandError::new(
|
||||
"admin_restart_failed",
|
||||
format!("Не удалось определить путь текущего приложения: {error}"),
|
||||
)
|
||||
})?;
|
||||
let working_dir = env::current_dir().ok();
|
||||
let working_dir_arg = working_dir
|
||||
.as_ref()
|
||||
.map(|path| {
|
||||
format!(
|
||||
" -WorkingDirectory '{}'",
|
||||
escape_powershell_single(&path.display().to_string())
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let script = format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try {{
|
||||
Start-Process -FilePath '{}' -Verb RunAs{}
|
||||
exit 0
|
||||
}} catch {{
|
||||
Write-Error ($_ | Out-String)
|
||||
exit 1
|
||||
}}
|
||||
"#,
|
||||
escape_powershell_single(&exe_path.display().to_string()),
|
||||
working_dir_arg
|
||||
);
|
||||
let output = run_powershell_command(&script).map_err(|error| {
|
||||
CommandError::new(
|
||||
"admin_restart_failed",
|
||||
format!("Не удалось запросить права администратора: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(CommandError::new(
|
||||
"admin_restart_failed",
|
||||
powershell_output_message(
|
||||
&output,
|
||||
"Перезапуск от имени администратора отменен или не был запущен.",
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
+180
-251
@@ -22,7 +22,7 @@ use crate::safe_fs;
|
||||
use crate::storage::JsonStorage;
|
||||
use crate::validation::{normalize_profile, normalize_target, ValidationError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fs, path::Path};
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
|
||||
const LOCAL_SINGBOX_TARGET_ID: &str = "local-singbox";
|
||||
@@ -37,10 +37,12 @@ pub enum ApplyRouteMode {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyConfigurationInput {
|
||||
#[serde(default)]
|
||||
pub expected_revision: Option<String>,
|
||||
pub route_mode: ApplyRouteMode,
|
||||
pub profile: ProfileInput,
|
||||
pub external_target: Option<TargetInput>,
|
||||
#[serde(default = "default_true")]
|
||||
#[serde(default)]
|
||||
pub disable_other_profiles: bool,
|
||||
}
|
||||
|
||||
@@ -65,6 +67,7 @@ pub enum ApplyPhaseStatus {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyConfigurationResult {
|
||||
pub saved_state: Option<crate::command_dto::SavedStateResponse>,
|
||||
pub success: bool,
|
||||
pub changed: bool,
|
||||
pub partial_state: bool,
|
||||
@@ -128,6 +131,19 @@ pub fn apply_configuration(
|
||||
input: ApplyConfigurationInput,
|
||||
services: ApplyServices<'_>,
|
||||
) -> Result<ApplyConfigurationResult, ApplyFlowError> {
|
||||
let read_guard = crate::configuration_transaction::read_guard(storage)
|
||||
.map_err(|error| storage_error("configuration_locked", error))?;
|
||||
if let Some(expected) = &input.expected_revision {
|
||||
if crate::configuration_transaction::revision_locked(storage)
|
||||
.map_err(|e| storage_error("configuration_read_failed", e))?
|
||||
!= *expected
|
||||
{
|
||||
return Err(ApplyFlowError::failure(
|
||||
"configuration_changed",
|
||||
"Настройки изменились. Обновите сохранённое состояние перед применением.",
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut phases = Vec::new();
|
||||
let old_profiles = storage
|
||||
.read_profiles()
|
||||
@@ -142,6 +158,18 @@ pub fn apply_configuration(
|
||||
proxy_config,
|
||||
singbox_config,
|
||||
} = prepare_apply(storage, input, &services)?;
|
||||
let revision = crate::configuration_transaction::revision_locked(storage)
|
||||
.map_err(|error| storage_error("configuration_read_failed", error))?;
|
||||
drop(read_guard);
|
||||
if let (Some(generated), Some(detected)) = (&singbox_config, &services.detected_singbox) {
|
||||
services
|
||||
.checker
|
||||
.check_config(&detected.executable_path, &generated.contents)
|
||||
.map_err(|error| ApplyFlowError::failure("singbox_preflight_failed", error.message))?;
|
||||
}
|
||||
let transaction =
|
||||
crate::configuration_transaction::ConfigurationTransaction::begin(storage, Some(&revision))
|
||||
.map_err(|error| storage_error("configuration_changed", error))?;
|
||||
phases.push(phase(
|
||||
"preflight",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
@@ -156,132 +184,95 @@ pub fn apply_configuration(
|
||||
let singbox_path = singbox_config
|
||||
.as_ref()
|
||||
.map(|_| storage.paths().generated_dir.join(SINGBOX_OUTPUT_FILE));
|
||||
let old_proxy_contents = fs::read(&proxy_path).ok();
|
||||
let old_singbox_contents = singbox_path.as_ref().and_then(|path| fs::read(path).ok());
|
||||
let rollback_state = RollbackState {
|
||||
storage,
|
||||
old_profiles: &old_profiles,
|
||||
old_targets: &old_targets,
|
||||
proxy_path: &proxy_path,
|
||||
old_proxy_contents: old_proxy_contents.as_deref(),
|
||||
singbox_path: singbox_path.as_deref(),
|
||||
old_singbox_contents: old_singbox_contents.as_deref(),
|
||||
};
|
||||
|
||||
if let Err(error) = storage.write_targets(&targets) {
|
||||
let rollback = rollback_source(storage, &old_profiles, &old_targets);
|
||||
let staged = (|| {
|
||||
storage
|
||||
.write_targets(&targets)
|
||||
.map_err(|e| storage_error("targets_write_failed", e))?;
|
||||
storage
|
||||
.write_profiles(&profiles)
|
||||
.map_err(|e| storage_error("profiles_write_failed", e))?;
|
||||
phases.push(phase(
|
||||
"source-state",
|
||||
ApplyPhaseStatus::Failed,
|
||||
"Не удалось сохранить targets.",
|
||||
));
|
||||
phases.push(rollback_phase(&rollback));
|
||||
return Ok(failed_result(
|
||||
"targets_write_failed",
|
||||
format!("Не удалось сохранить цели: {error}"),
|
||||
rollback.is_err(),
|
||||
&proxy_path,
|
||||
singbox_path.as_deref(),
|
||||
phases,
|
||||
));
|
||||
}
|
||||
if let Err(error) = storage.write_profiles(&profiles) {
|
||||
let rollback = rollback_source(storage, &old_profiles, &old_targets);
|
||||
phases.push(phase(
|
||||
"source-state",
|
||||
ApplyPhaseStatus::Failed,
|
||||
"Не удалось сохранить profiles.",
|
||||
));
|
||||
phases.push(rollback_phase(&rollback));
|
||||
return Ok(failed_result(
|
||||
"profiles_write_failed",
|
||||
format!("Не удалось сохранить профили: {error}"),
|
||||
rollback.is_err(),
|
||||
&proxy_path,
|
||||
singbox_path.as_deref(),
|
||||
phases,
|
||||
));
|
||||
}
|
||||
phases.push(phase(
|
||||
"source-state",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"Profiles и targets сохранены.",
|
||||
));
|
||||
|
||||
if let (Some(generated), Some(path)) = (singbox_config.as_ref(), singbox_path.as_ref()) {
|
||||
if let Err(error) = safe_fs::write_with_backup(path, generated.contents.as_bytes()) {
|
||||
return Ok(rollback_after_failure(
|
||||
&rollback_state,
|
||||
"singbox_config_write_failed",
|
||||
format!("Не удалось записать generated sing-box config: {error}"),
|
||||
"singbox-config",
|
||||
phases,
|
||||
));
|
||||
}
|
||||
phases.push(phase(
|
||||
"singbox-config",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"Generated sing-box config записан; служба не перезапускалась.",
|
||||
"Profiles и targets сохранены.",
|
||||
));
|
||||
} else {
|
||||
phases.push(phase(
|
||||
"singbox-config",
|
||||
ApplyPhaseStatus::Skipped,
|
||||
"External SOCKS5 не использует Local sing-box.",
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(error) = safe_fs::write_with_backup(&proxy_path, proxy_config.contents.as_bytes()) {
|
||||
return Ok(rollback_after_failure(
|
||||
&rollback_state,
|
||||
"proxifyre_config_write_failed",
|
||||
format!("Не удалось записать generated ProxiFyre config: {error}"),
|
||||
"proxifyre-config",
|
||||
phases,
|
||||
));
|
||||
}
|
||||
phases.push(phase(
|
||||
"proxifyre-config",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"Generated ProxiFyre config записан.",
|
||||
));
|
||||
|
||||
let helper_result = match services.helper.apply_proxy_config(HelperApplyRequest {
|
||||
adapter_id: &proxy_config.adapter_id,
|
||||
config_path: &proxy_path,
|
||||
config_contents: &proxy_config.contents,
|
||||
}) {
|
||||
Ok(result) if result.success => result,
|
||||
Ok(result) => {
|
||||
return Ok(rollback_after_failure(
|
||||
&rollback_state,
|
||||
if let (Some(generated), Some(path)) = (&singbox_config, &singbox_path) {
|
||||
safe_fs::write_restricted_with_backup(path, generated.contents.as_bytes())
|
||||
.map_err(|e| storage_error("singbox_config_write_failed", e))?;
|
||||
}
|
||||
safe_fs::write_restricted_with_backup(&proxy_path, proxy_config.contents.as_bytes())
|
||||
.map_err(|e| storage_error("proxifyre_config_write_failed", e))?;
|
||||
let result = services
|
||||
.helper
|
||||
.apply_proxy_config(HelperApplyRequest {
|
||||
adapter_id: &proxy_config.adapter_id,
|
||||
config_path: &proxy_path,
|
||||
config_contents: &proxy_config.contents,
|
||||
})
|
||||
.map_err(|e| ApplyFlowError::failure(e.code, e.message))?;
|
||||
if !result.success {
|
||||
return Err(ApplyFlowError::failure(
|
||||
"proxifyre_apply_failed",
|
||||
result.message,
|
||||
"runtime-apply",
|
||||
phases,
|
||||
));
|
||||
}
|
||||
crate::route_state::record_prepared_locked(
|
||||
storage,
|
||||
crate::privileged_jobs::ManagedComponent::Proxifyre,
|
||||
)
|
||||
.map_err(|e| storage_error("prepared_state_write_failed", e))?;
|
||||
if singbox_config.is_some() {
|
||||
crate::route_state::record_prepared_locked(
|
||||
storage,
|
||||
crate::privileged_jobs::ManagedComponent::SingBox,
|
||||
)
|
||||
.map_err(|e| storage_error("prepared_state_write_failed", e))?;
|
||||
}
|
||||
Ok(result)
|
||||
})();
|
||||
let (helper_result, committed_revision, artifacts) = match staged {
|
||||
Ok(result) => {
|
||||
let artifacts = crate::route_state::read_status_locked(storage)
|
||||
.map_err(|e| storage_error("prepared_state_read_failed", e))?;
|
||||
let revision = transaction
|
||||
.commit_with_revision()
|
||||
.map_err(|error| storage_error("configuration_commit_failed", error))?;
|
||||
(result, revision, artifacts)
|
||||
}
|
||||
Err(error) => {
|
||||
return Ok(rollback_after_failure(
|
||||
&rollback_state,
|
||||
&error.code,
|
||||
error.message,
|
||||
"runtime-apply",
|
||||
let rollback = transaction.abort();
|
||||
phases.push(phase(
|
||||
"rollback",
|
||||
if rollback.is_ok() {
|
||||
ApplyPhaseStatus::RolledBack
|
||||
} else {
|
||||
ApplyPhaseStatus::Failed
|
||||
},
|
||||
if rollback.is_ok() {
|
||||
"Предыдущие настройки и конфиги восстановлены."
|
||||
} else {
|
||||
"Восстановление не завершено; новые операции заблокированы до recovery."
|
||||
},
|
||||
));
|
||||
return Ok(failed_result(
|
||||
if rollback.is_ok() {
|
||||
error.code()
|
||||
} else {
|
||||
"configuration_recovery_required"
|
||||
},
|
||||
error.to_string(),
|
||||
rollback.is_err(),
|
||||
&proxy_path,
|
||||
singbox_path.as_deref(),
|
||||
phases,
|
||||
));
|
||||
}
|
||||
};
|
||||
phases.push(phase(
|
||||
"runtime-apply",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"ProxiFyre config применён без управления службой.",
|
||||
));
|
||||
phases.push(phase(
|
||||
"service-control",
|
||||
ApplyPhaseStatus::Skipped,
|
||||
"Apply не запускает, не останавливает и не перезапускает службы.",
|
||||
"Apply не управляет службами.",
|
||||
));
|
||||
|
||||
let mut restart_required = Vec::new();
|
||||
if services.detected_proxyfier.is_some() {
|
||||
restart_required.push(ComponentId::Proxyfier);
|
||||
@@ -320,6 +311,19 @@ pub fn apply_configuration(
|
||||
}
|
||||
|
||||
Ok(ApplyConfigurationResult {
|
||||
saved_state: Some(crate::command_dto::SavedStateResponse {
|
||||
artifacts,
|
||||
revision: committed_revision,
|
||||
profiles: profiles
|
||||
.iter()
|
||||
.map(crate::command_dto::ProfileDto::from)
|
||||
.collect(),
|
||||
targets: targets
|
||||
.iter()
|
||||
.map(crate::command_dto::TargetDto::from)
|
||||
.collect(),
|
||||
generated_config_path: proxy_path.display().to_string(),
|
||||
}),
|
||||
success: true,
|
||||
changed: source_changed || helper_result.changed,
|
||||
partial_state: false,
|
||||
@@ -351,62 +355,72 @@ fn prepare_apply(
|
||||
));
|
||||
}
|
||||
let mut profile_input = input.profile;
|
||||
let mut profiles = storage
|
||||
.read_profiles()
|
||||
.map_err(|error| storage_error("profiles_read_failed", error))?;
|
||||
let mut targets = storage
|
||||
.read_targets()
|
||||
.map_err(|error| storage_error("targets_read_failed", error))?;
|
||||
let singbox_config = match input.route_mode {
|
||||
ApplyRouteMode::External => {
|
||||
let target_input = input.external_target.ok_or_else(|| {
|
||||
ApplyFlowError::failure(
|
||||
"external_target_missing",
|
||||
"Для external маршрута требуется SOCKS5 target.",
|
||||
)
|
||||
})?;
|
||||
let target = normalize_target(target_input).map_err(ApplyFlowError::validation)?;
|
||||
profile_input.target_id = target.id.clone();
|
||||
upsert_target(&mut targets, target);
|
||||
None
|
||||
}
|
||||
ApplyRouteMode::LocalSingbox => {
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.map_err(|error| storage_error("singbox_config_read_failed", error))?;
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(|error| storage_error("singbox_cache_read_failed", error))?
|
||||
.ok_or_else(|| {
|
||||
let clearing_profile = !profile_input.enabled && profile_input.items.is_empty();
|
||||
let singbox_config = if clearing_profile {
|
||||
None
|
||||
} else {
|
||||
match input.route_mode {
|
||||
ApplyRouteMode::External => {
|
||||
let target_input = input.external_target.ok_or_else(|| {
|
||||
ApplyFlowError::failure(
|
||||
"singbox_subscription_cache_missing",
|
||||
"Сначала загрузите подписку Local sing-box.",
|
||||
"external_target_missing",
|
||||
"Для external маршрута требуется SOCKS5 target.",
|
||||
)
|
||||
})?;
|
||||
profile_input.target_id = LOCAL_SINGBOX_TARGET_ID.to_string();
|
||||
upsert_target(&mut targets, local_singbox_target(&config));
|
||||
Some(
|
||||
services
|
||||
.singbox_adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(
|
||||
&config,
|
||||
&cache,
|
||||
services
|
||||
.detected_singbox
|
||||
.as_ref()
|
||||
.map(|detected| detected.executable_path.as_path()),
|
||||
),
|
||||
services.checker,
|
||||
)
|
||||
.map_err(|error| {
|
||||
ApplyFlowError::failure("singbox_preflight_failed", error.message)
|
||||
})?,
|
||||
)
|
||||
let mut target =
|
||||
normalize_target(target_input).map_err(ApplyFlowError::validation)?;
|
||||
let shared = profiles.iter().any(|existing| {
|
||||
Some(existing.id.as_str()) != profile_input.id.as_deref()
|
||||
&& existing.target_id == target.id
|
||||
});
|
||||
if shared
|
||||
&& targets
|
||||
.iter()
|
||||
.any(|existing| existing.id == target.id && existing != &target)
|
||||
{
|
||||
target.id = format!("target-{}", uuid::Uuid::new_v4());
|
||||
}
|
||||
profile_input.target_id = target.id.clone();
|
||||
upsert_target(&mut targets, target);
|
||||
None
|
||||
}
|
||||
ApplyRouteMode::LocalSingbox => {
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.map_err(|error| storage_error("singbox_config_read_failed", error))?;
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(|error| storage_error("singbox_cache_read_failed", error))?
|
||||
.ok_or_else(|| {
|
||||
ApplyFlowError::failure(
|
||||
"singbox_subscription_cache_missing",
|
||||
"Сначала загрузите подписку Local sing-box.",
|
||||
)
|
||||
})?;
|
||||
profile_input.target_id = LOCAL_SINGBOX_TARGET_ID.to_string();
|
||||
upsert_target(&mut targets, local_singbox_target(&config));
|
||||
Some(
|
||||
services
|
||||
.singbox_adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
services.checker,
|
||||
)
|
||||
.map_err(|error| {
|
||||
ApplyFlowError::failure("singbox_preflight_failed", error.message)
|
||||
})?,
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let profile = normalize_profile(profile_input).map_err(ApplyFlowError::validation)?;
|
||||
let mut profiles = storage
|
||||
.read_profiles()
|
||||
.map_err(|error| storage_error("profiles_read_failed", error))?;
|
||||
if input.disable_other_profiles {
|
||||
for existing in &mut profiles {
|
||||
if existing.id != profile.id {
|
||||
@@ -415,6 +429,14 @@ fn prepare_apply(
|
||||
}
|
||||
}
|
||||
upsert_profile(&mut profiles, profile);
|
||||
if !profiles.iter().any(|profile| profile.enabled)
|
||||
&& proxyfier_component_from_detection(services.detected_proxyfier.as_ref()).running
|
||||
{
|
||||
return Err(ApplyFlowError::failure(
|
||||
"stop_before_clearing_route",
|
||||
"Сначала явно остановите ProxiFyre, затем примените удаление последних правил.",
|
||||
));
|
||||
}
|
||||
|
||||
let components = vec![
|
||||
proxyfier_component_from_detection(services.detected_proxyfier.as_ref()),
|
||||
@@ -462,96 +484,6 @@ fn upsert_target(targets: &mut Vec<Target>, target: Target) {
|
||||
}
|
||||
}
|
||||
|
||||
struct RollbackState<'a> {
|
||||
storage: &'a JsonStorage,
|
||||
old_profiles: &'a [Profile],
|
||||
old_targets: &'a [Target],
|
||||
proxy_path: &'a Path,
|
||||
old_proxy_contents: Option<&'a [u8]>,
|
||||
singbox_path: Option<&'a Path>,
|
||||
old_singbox_contents: Option<&'a [u8]>,
|
||||
}
|
||||
|
||||
fn rollback_after_failure(
|
||||
state: &RollbackState<'_>,
|
||||
code: &str,
|
||||
message: String,
|
||||
failed_phase: &str,
|
||||
mut phases: Vec<ApplyPhase>,
|
||||
) -> ApplyConfigurationResult {
|
||||
phases.push(phase(failed_phase, ApplyPhaseStatus::Failed, &message));
|
||||
let source_rollback = rollback_source(state.storage, state.old_profiles, state.old_targets);
|
||||
let proxy_rollback = restore_generated(state.proxy_path, state.old_proxy_contents);
|
||||
let singbox_rollback = state
|
||||
.singbox_path
|
||||
.map(|path| restore_generated(path, state.old_singbox_contents))
|
||||
.unwrap_or(Ok(()));
|
||||
let rollback_ok = source_rollback.is_ok() && proxy_rollback.is_ok() && singbox_rollback.is_ok();
|
||||
phases.push(if rollback_ok {
|
||||
phase(
|
||||
"rollback",
|
||||
ApplyPhaseStatus::RolledBack,
|
||||
"Source state и generated artifacts восстановлены.",
|
||||
)
|
||||
} else {
|
||||
phase(
|
||||
"rollback",
|
||||
ApplyPhaseStatus::Failed,
|
||||
"Rollback завершился не полностью; проверьте файлы config/generated.",
|
||||
)
|
||||
});
|
||||
failed_result(
|
||||
code,
|
||||
message,
|
||||
!rollback_ok,
|
||||
state.proxy_path,
|
||||
state.singbox_path,
|
||||
phases,
|
||||
)
|
||||
}
|
||||
|
||||
fn rollback_source(
|
||||
storage: &JsonStorage,
|
||||
profiles: &[Profile],
|
||||
targets: &[Target],
|
||||
) -> Result<(), String> {
|
||||
let targets_result = storage
|
||||
.write_targets(targets)
|
||||
.map_err(|error| error.to_string());
|
||||
let profiles_result = storage
|
||||
.write_profiles(profiles)
|
||||
.map_err(|error| error.to_string());
|
||||
targets_result.and(profiles_result)
|
||||
}
|
||||
|
||||
fn restore_generated(path: &Path, previous: Option<&[u8]>) -> Result<(), String> {
|
||||
match previous {
|
||||
Some(contents) => {
|
||||
safe_fs::write_with_backup(path, contents).map_err(|error| error.to_string())
|
||||
}
|
||||
None => match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback_phase(result: &Result<(), String>) -> ApplyPhase {
|
||||
match result {
|
||||
Ok(()) => phase(
|
||||
"rollback",
|
||||
ApplyPhaseStatus::RolledBack,
|
||||
"Source state восстановлен.",
|
||||
),
|
||||
Err(error) => phase(
|
||||
"rollback",
|
||||
ApplyPhaseStatus::Failed,
|
||||
format!("Не удалось полностью восстановить source state: {error}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_result(
|
||||
code: &str,
|
||||
message: String,
|
||||
@@ -561,6 +493,7 @@ fn failed_result(
|
||||
phases: Vec<ApplyPhase>,
|
||||
) -> ApplyConfigurationResult {
|
||||
ApplyConfigurationResult {
|
||||
saved_state: None,
|
||||
success: false,
|
||||
changed: false,
|
||||
partial_state,
|
||||
@@ -588,7 +521,3 @@ fn phase(
|
||||
fn storage_error(code: &str, error: std::io::Error) -> ApplyFlowError {
|
||||
ApplyFlowError::failure(code, format!("Ошибка storage: {error}"))
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
+300
-11
@@ -4,6 +4,11 @@
|
||||
//! camelCase contract exposed to the React webview.
|
||||
|
||||
use crate::adapters::singbox::SingBoxCheckResult;
|
||||
use crate::component_catalog::ComponentId as CatalogComponentId;
|
||||
use crate::component_packages::{
|
||||
ComponentInstallSource, ComponentUpdateState, ComponentUpdateStatus, PackageSource,
|
||||
UpdateCheckTrust, UpdateFreshness,
|
||||
};
|
||||
use crate::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||
@@ -74,6 +79,8 @@ pub struct StatusResponse {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SavedStateResponse {
|
||||
pub artifacts: Vec<crate::route_state::ArtifactStatus>,
|
||||
pub revision: String,
|
||||
pub profiles: Vec<ProfileDto>,
|
||||
pub targets: Vec<TargetDto>,
|
||||
pub generated_config_path: String,
|
||||
@@ -83,6 +90,7 @@ pub struct SavedStateResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartupSnapshotResponse {
|
||||
pub admin_status: AdminStatusResponse,
|
||||
pub migration_status: StorageMigrationStatusDto,
|
||||
pub saved_state: SavedStateResponse,
|
||||
pub components: Vec<ComponentStatusDto>,
|
||||
pub proxifyre_setup_status: ProxiFyreSetupStatusDto,
|
||||
@@ -90,6 +98,18 @@ pub struct StartupSnapshotResponse {
|
||||
pub singbox_setup_status: SingBoxSetupStatusDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageMigrationStatusDto {
|
||||
pub storage_schema_version: u32,
|
||||
pub component_layout_version: Option<u32>,
|
||||
pub outcome: String,
|
||||
pub changed: bool,
|
||||
pub blocking: bool,
|
||||
pub notice_code: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxiFyreSetupStatusDto {
|
||||
@@ -108,22 +128,12 @@ pub struct ProxiFyreSetupItemDto {
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxiFyreSetupProgressDto {
|
||||
pub operation: String,
|
||||
pub status: String,
|
||||
pub active_step: Option<String>,
|
||||
pub percent: u8,
|
||||
pub message: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
pub type SingBoxSetupStatusDto = SingBoxSetupStatus;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalSingBoxStatusResponse {
|
||||
pub saved_state: SavedStateResponse,
|
||||
pub config: LocalSingBoxConfigDto,
|
||||
pub cache: Option<SubscriptionCacheDto>,
|
||||
pub component: ComponentStatusDto,
|
||||
@@ -356,6 +366,285 @@ pub struct ComponentStatusDto {
|
||||
pub actions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentLifecycleResponseDto {
|
||||
pub component: ComponentStatusDto,
|
||||
pub changed: bool,
|
||||
pub reboot_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ManagedPackageComponentDto {
|
||||
Proxifyre,
|
||||
SingBox,
|
||||
}
|
||||
|
||||
impl ManagedPackageComponentDto {
|
||||
pub(crate) const fn catalog_id(self) -> CatalogComponentId {
|
||||
match self {
|
||||
Self::Proxifyre => CatalogComponentId::Proxifyre,
|
||||
Self::SingBox => CatalogComponentId::SingBox,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn model_id(self) -> ComponentId {
|
||||
match self {
|
||||
Self::Proxifyre => ComponentId::Proxyfier,
|
||||
Self::SingBox => ComponentId::Singbox,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentUpdateFreshnessDto {
|
||||
NeverChecked,
|
||||
Fresh,
|
||||
Stale,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentUpdateStateDto {
|
||||
Current,
|
||||
UpdateAvailable,
|
||||
CheckStale,
|
||||
UnknownOffline,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentInstallSourceDto {
|
||||
Bundled,
|
||||
Cache,
|
||||
External,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentPackageSourceDto {
|
||||
Bundled,
|
||||
Cache,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentUpdateTrustDto {
|
||||
Trusted,
|
||||
MissingIndependentDigest,
|
||||
MalformedIndependentDigest,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentPackageRequestDto {
|
||||
pub component_id: ManagedPackageComponentDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentPackageStatusDto {
|
||||
pub component_id: ManagedPackageComponentDto,
|
||||
pub installed_version: Option<String>,
|
||||
pub bundled_version: String,
|
||||
pub available_offline_version: String,
|
||||
pub latest_known_version: Option<String>,
|
||||
pub last_checked_at: Option<u64>,
|
||||
pub freshness: ComponentUpdateFreshnessDto,
|
||||
pub update_state: ComponentUpdateStateDto,
|
||||
pub install_source: ComponentInstallSourceDto,
|
||||
pub offline_package_source: ComponentPackageSourceDto,
|
||||
pub can_install_offline: bool,
|
||||
pub offline_unavailable_reason: Option<String>,
|
||||
pub can_download: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentUpdateCheckResponseDto {
|
||||
pub trust: ComponentUpdateTrustDto,
|
||||
pub update_available: bool,
|
||||
pub status: ComponentPackageStatusDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentUpdateDownloadResponseDto {
|
||||
pub downloaded_version: String,
|
||||
pub source: ComponentPackageSourceDto,
|
||||
pub status: ComponentPackageStatusDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentUpdateResponseDto {
|
||||
pub component: ComponentStatusDto,
|
||||
pub package: ComponentPackageStatusDto,
|
||||
pub changed: bool,
|
||||
pub reboot_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentCutoverStateDto {
|
||||
NotNeeded,
|
||||
Ready,
|
||||
ManualMigrationRequired,
|
||||
InProgress,
|
||||
AwaitingNextStart,
|
||||
AwaitingRouteSmoke,
|
||||
CleanupReady,
|
||||
CleanupPending,
|
||||
Complete,
|
||||
RolledBack,
|
||||
RecoveryRequired,
|
||||
Blocked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentCutoverModeDto {
|
||||
ServiceSwitch,
|
||||
ManualOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentCutoverServiceStateDto {
|
||||
Running,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentCutoverStatusDto {
|
||||
pub component_id: ManagedPackageComponentDto,
|
||||
pub state: ComponentCutoverStateDto,
|
||||
pub mode: ComponentCutoverModeDto,
|
||||
pub legacy_version: Option<String>,
|
||||
pub current_version: Option<String>,
|
||||
pub bundled_version: Option<String>,
|
||||
pub original_service_state: Option<ComponentCutoverServiceStateDto>,
|
||||
pub legacy_path_label: Option<String>,
|
||||
pub current_path_label: Option<String>,
|
||||
pub steps: Vec<String>,
|
||||
pub next_start_verified: bool,
|
||||
pub route_smoke_confirmed: bool,
|
||||
pub can_cutover: bool,
|
||||
pub can_confirm_route_smoke: bool,
|
||||
pub can_cleanup: bool,
|
||||
pub disabled_code: Option<String>,
|
||||
pub disabled_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentCutoverRequestDto {
|
||||
pub component_id: ManagedPackageComponentDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmComponentRouteSmokeInputDto {
|
||||
pub component_id: ManagedPackageComponentDto,
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentCutoverResponseDto {
|
||||
pub status: ComponentCutoverStatusDto,
|
||||
pub changed: bool,
|
||||
pub reboot_required: bool,
|
||||
}
|
||||
|
||||
impl TryFrom<&ComponentUpdateStatus> for ComponentPackageStatusDto {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(status: &ComponentUpdateStatus) -> Result<Self, Self::Error> {
|
||||
let component_id = match status.component_id {
|
||||
CatalogComponentId::Proxifyre => ManagedPackageComponentDto::Proxifyre,
|
||||
CatalogComponentId::SingBox => ManagedPackageComponentDto::SingBox,
|
||||
CatalogComponentId::WindowsPacketFilter
|
||||
| CatalogComponentId::VcRuntime
|
||||
| CatalogComponentId::Winsw => return Err(()),
|
||||
};
|
||||
Ok(Self {
|
||||
component_id,
|
||||
installed_version: status.installed_version.clone(),
|
||||
bundled_version: status.bundled_version.clone(),
|
||||
available_offline_version: status.available_offline_version.clone(),
|
||||
latest_known_version: status.latest_known_version.clone(),
|
||||
last_checked_at: status.last_checked_at_unix,
|
||||
freshness: status.freshness.into(),
|
||||
update_state: status.update_state.into(),
|
||||
install_source: status.install_source.into(),
|
||||
offline_package_source: status.offline_package_source.into(),
|
||||
can_install_offline: status.can_install_offline,
|
||||
offline_unavailable_reason: status.offline_unavailable_reason.clone(),
|
||||
can_download: status.can_download,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpdateFreshness> for ComponentUpdateFreshnessDto {
|
||||
fn from(value: UpdateFreshness) -> Self {
|
||||
match value {
|
||||
UpdateFreshness::NeverChecked => Self::NeverChecked,
|
||||
UpdateFreshness::Fresh => Self::Fresh,
|
||||
UpdateFreshness::Stale => Self::Stale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ComponentUpdateState> for ComponentUpdateStateDto {
|
||||
fn from(value: ComponentUpdateState) -> Self {
|
||||
match value {
|
||||
ComponentUpdateState::Current => Self::Current,
|
||||
ComponentUpdateState::UpdateAvailable => Self::UpdateAvailable,
|
||||
ComponentUpdateState::CheckStale => Self::CheckStale,
|
||||
ComponentUpdateState::UnknownOffline => Self::UnknownOffline,
|
||||
ComponentUpdateState::Unsupported => Self::Unsupported,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ComponentInstallSource> for ComponentInstallSourceDto {
|
||||
fn from(value: ComponentInstallSource) -> Self {
|
||||
match value {
|
||||
ComponentInstallSource::Bundled => Self::Bundled,
|
||||
ComponentInstallSource::Cache => Self::Cache,
|
||||
ComponentInstallSource::External => Self::External,
|
||||
ComponentInstallSource::None => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PackageSource> for ComponentPackageSourceDto {
|
||||
fn from(value: PackageSource) -> Self {
|
||||
match value {
|
||||
PackageSource::Bundled => Self::Bundled,
|
||||
PackageSource::Cache => Self::Cache,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpdateCheckTrust> for ComponentUpdateTrustDto {
|
||||
fn from(value: UpdateCheckTrust) -> Self {
|
||||
match value {
|
||||
UpdateCheckTrust::Trusted => Self::Trusted,
|
||||
UpdateCheckTrust::MissingIndependentDigest => Self::MissingIndependentDigest,
|
||||
UpdateCheckTrust::MalformedIndependentDigest => Self::MalformedIndependentDigest,
|
||||
UpdateCheckTrust::Unsupported => Self::Unsupported,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActivityEntryDto {
|
||||
|
||||
+1663
-112
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,869 @@
|
||||
use crate::safe_fs::ensure_no_reparse_ancestors;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
pub const COMPONENT_CATALOG_SCHEMA_VERSION: u32 = 1;
|
||||
pub const COMPONENT_CATALOG_FILENAME: &str = "catalog.json";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ComponentCatalogError {
|
||||
#[error("component catalog JSON is invalid: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("component catalog is invalid: {0}")]
|
||||
Invalid(String),
|
||||
#[error("component bundle cannot be read: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TargetArch {
|
||||
X64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AssetArch {
|
||||
X64,
|
||||
Anycpu,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ComponentId {
|
||||
Proxifyre,
|
||||
WindowsPacketFilter,
|
||||
VcRuntime,
|
||||
SingBox,
|
||||
Winsw,
|
||||
}
|
||||
|
||||
impl ComponentId {
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Proxifyre,
|
||||
Self::WindowsPacketFilter,
|
||||
Self::VcRuntime,
|
||||
Self::SingBox,
|
||||
Self::Winsw,
|
||||
];
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Proxifyre => "proxifyre",
|
||||
Self::WindowsPacketFilter => "windows-packet-filter",
|
||||
Self::VcRuntime => "vc-runtime",
|
||||
Self::SingBox => "sing-box",
|
||||
Self::Winsw => "winsw",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum InstallRole {
|
||||
ProxifyreRuntime,
|
||||
PacketFilterDriver,
|
||||
VcRuntimePrerequisite,
|
||||
SingBoxRuntime,
|
||||
SingBoxServiceWrapper,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ComponentCatalog {
|
||||
pub schema_version: u32,
|
||||
pub target_arch: TargetArch,
|
||||
pub components: Vec<ComponentPackage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ComponentPackage {
|
||||
pub id: ComponentId,
|
||||
pub version: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub file_version: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub product_version: Option<String>,
|
||||
pub asset_path: String,
|
||||
pub asset_arch: AssetArch,
|
||||
pub effective_target: TargetArch,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub source_url: String,
|
||||
pub license: ComponentLicense,
|
||||
pub install_role: InstallRole,
|
||||
pub update_trust_policy: UpdateTrustPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ComponentLicense {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(
|
||||
tag = "type",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum UpdateTrustPolicy {
|
||||
GithubReleaseDigest {
|
||||
repository: String,
|
||||
tag_pattern: String,
|
||||
asset_pattern: String,
|
||||
require_stable: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
authenticode_publishers: Option<Vec<String>>,
|
||||
},
|
||||
BuildTimeOnlyAuthenticode {
|
||||
allowed_source_hosts: Vec<String>,
|
||||
asset_pattern: String,
|
||||
publishers: Vec<String>,
|
||||
},
|
||||
BundledOnlyNoIndependentProof {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn parse_catalog(bytes: &[u8]) -> Result<ComponentCatalog, ComponentCatalogError> {
|
||||
let catalog: ComponentCatalog = serde_json::from_slice(bytes)?;
|
||||
validate_catalog(&catalog)?;
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
pub fn validate_bundle(root: &Path) -> Result<ComponentCatalog, ComponentCatalogError> {
|
||||
ensure_no_reparse_ancestors(root)?;
|
||||
let catalog_path = root.join(COMPONENT_CATALOG_FILENAME);
|
||||
require_regular_file(&catalog_path, "catalog")?;
|
||||
let catalog = parse_catalog(&fs::read(&catalog_path)?)?;
|
||||
|
||||
let mut expected_files = HashSet::from([COMPONENT_CATALOG_FILENAME.to_string()]);
|
||||
for component in &catalog.components {
|
||||
if !expected_files.insert(component.asset_path.clone()) {
|
||||
return Err(invalid("two components reference the same asset path"));
|
||||
}
|
||||
expected_files.insert(component.license.path.clone());
|
||||
|
||||
let asset_path = root.join(relative_path(&component.asset_path));
|
||||
require_regular_file(&asset_path, "component asset")?;
|
||||
let metadata = fs::metadata(&asset_path)?;
|
||||
if metadata.len() != component.size {
|
||||
return Err(invalid(format!(
|
||||
"asset size does not match catalog for {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
if sha256_file(&asset_path)? != component.sha256 {
|
||||
return Err(invalid(format!(
|
||||
"asset SHA-256 does not match catalog for {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
|
||||
let license_path = root.join(relative_path(&component.license.path));
|
||||
require_regular_file(&license_path, "license")?;
|
||||
if fs::metadata(license_path)?.len() == 0 {
|
||||
return Err(invalid(format!(
|
||||
"license file is empty for {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let actual_files = collect_bundle_files(root)?;
|
||||
if actual_files != expected_files {
|
||||
let missing = expected_files.difference(&actual_files).count();
|
||||
let extra = actual_files.difference(&expected_files).count();
|
||||
return Err(invalid(format!(
|
||||
"bundle file set does not match catalog (missing: {missing}, extra: {extra})"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
pub fn parse_bundled_catalog_if_present(
|
||||
root: &Path,
|
||||
) -> Result<Option<ComponentCatalog>, ComponentCatalogError> {
|
||||
ensure_no_reparse_ancestors(root)?;
|
||||
match fs::symlink_metadata(root.join(COMPONENT_CATALOG_FILENAME)) {
|
||||
Ok(_) => validate_bundle(root).map(Some),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_catalog(catalog: &ComponentCatalog) -> Result<(), ComponentCatalogError> {
|
||||
if catalog.schema_version != COMPONENT_CATALOG_SCHEMA_VERSION {
|
||||
return Err(invalid("unsupported schemaVersion"));
|
||||
}
|
||||
if catalog.target_arch != TargetArch::X64 {
|
||||
return Err(invalid("targetArch must be x64"));
|
||||
}
|
||||
if catalog.components.len() != ComponentId::ALL.len() {
|
||||
return Err(invalid("catalog must contain exactly five components"));
|
||||
}
|
||||
|
||||
let mut component_ids = HashSet::new();
|
||||
let mut install_roles = HashSet::new();
|
||||
let mut asset_paths = HashSet::new();
|
||||
let mut license_paths = HashSet::new();
|
||||
for component in &catalog.components {
|
||||
if !component_ids.insert(component.id) {
|
||||
return Err(invalid("component IDs must be unique"));
|
||||
}
|
||||
if !install_roles.insert(component.install_role) {
|
||||
return Err(invalid("install roles must be unique"));
|
||||
}
|
||||
if !asset_paths.insert(component.asset_path.as_str()) {
|
||||
return Err(invalid("asset paths must be unique"));
|
||||
}
|
||||
if !license_paths.insert(component.license.path.as_str()) {
|
||||
return Err(invalid("license paths must be unique"));
|
||||
}
|
||||
validate_component(component)?;
|
||||
}
|
||||
|
||||
if ComponentId::ALL
|
||||
.iter()
|
||||
.any(|component_id| !component_ids.contains(component_id))
|
||||
{
|
||||
return Err(invalid("catalog is missing a required component"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_component(component: &ComponentPackage) -> Result<(), ComponentCatalogError> {
|
||||
let (expected_role, expected_arch) = expected_role_and_arch(component.id);
|
||||
if component.install_role != expected_role {
|
||||
return Err(invalid(format!(
|
||||
"installRole does not match component {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
if component.asset_arch != expected_arch || component.effective_target != TargetArch::X64 {
|
||||
return Err(invalid(format!(
|
||||
"asset architecture does not match component {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
if !is_stable_numeric_version(&component.version)
|
||||
|| component
|
||||
.file_version
|
||||
.as_deref()
|
||||
.is_some_and(|version| !is_stable_numeric_version(version))
|
||||
|| component
|
||||
.product_version
|
||||
.as_deref()
|
||||
.is_some_and(|version| !is_stable_product_version(version))
|
||||
{
|
||||
return Err(invalid(format!(
|
||||
"version metadata is invalid for {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
validate_relative_path(&component.asset_path, "assetPath")?;
|
||||
if component.asset_path.split('/').next() != Some(component.id.as_str()) {
|
||||
return Err(invalid(format!(
|
||||
"assetPath must be inside the {} directory",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
validate_relative_path(&component.license.path, "license.path")?;
|
||||
if component.license.path.split('/').next() != Some(component.id.as_str()) {
|
||||
return Err(invalid(format!(
|
||||
"license.path must be inside the {} directory",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
if component.asset_path == component.license.path {
|
||||
return Err(invalid("assetPath and license.path must be different"));
|
||||
}
|
||||
if !is_valid_sha256(&component.sha256) {
|
||||
return Err(invalid(format!(
|
||||
"SHA-256 is invalid for {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
if component.size == 0 {
|
||||
return Err(invalid(format!(
|
||||
"asset size must be positive for {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
if !is_valid_license_id(&component.license.id) {
|
||||
return Err(invalid(format!(
|
||||
"license ID is invalid for {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
validate_component_contract(component)?;
|
||||
|
||||
let source = validate_source_url(&component.source_url)?;
|
||||
let asset_name = component
|
||||
.asset_path
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.ok_or_else(|| invalid("assetPath has no filename"))?;
|
||||
if source
|
||||
.path_segments()
|
||||
.and_then(|mut segments| segments.next_back())
|
||||
!= Some(asset_name)
|
||||
{
|
||||
return Err(invalid(format!(
|
||||
"sourceUrl filename does not match assetPath for {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
validate_official_source(component, &source, asset_name)?;
|
||||
validate_trust_policy(&component.update_trust_policy, &source, asset_name)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_component_contract(component: &ComponentPackage) -> Result<(), ComponentCatalogError> {
|
||||
let expected_license = match component.id {
|
||||
ComponentId::Proxifyre => "AGPL-3.0-only",
|
||||
ComponentId::WindowsPacketFilter => "MIT",
|
||||
ComponentId::VcRuntime => "LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026",
|
||||
ComponentId::SingBox => "LicenseRef-Sing-Box-Project",
|
||||
ComponentId::Winsw => "MIT",
|
||||
};
|
||||
if component.license.id != expected_license {
|
||||
return Err(invalid(format!(
|
||||
"license ID does not match component {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
|
||||
let policy_matches_component = match (component.id, &component.update_trust_policy) {
|
||||
(
|
||||
ComponentId::Proxifyre,
|
||||
UpdateTrustPolicy::GithubReleaseDigest {
|
||||
repository,
|
||||
tag_pattern,
|
||||
asset_pattern,
|
||||
require_stable,
|
||||
authenticode_publishers,
|
||||
},
|
||||
) => {
|
||||
repository == "wiresock/proxifyre"
|
||||
&& tag_pattern == "v*"
|
||||
&& asset_pattern == "ProxiFyre-v*-x64-signed.zip"
|
||||
&& *require_stable
|
||||
&& authenticode_publishers
|
||||
.as_deref()
|
||||
.is_some_and(|publishers| {
|
||||
publishers.len() == 1 && publishers[0] == "The Anti-Cloud Corporation"
|
||||
})
|
||||
}
|
||||
(
|
||||
ComponentId::WindowsPacketFilter,
|
||||
UpdateTrustPolicy::GithubReleaseDigest {
|
||||
repository,
|
||||
tag_pattern,
|
||||
asset_pattern,
|
||||
require_stable,
|
||||
authenticode_publishers,
|
||||
},
|
||||
) => {
|
||||
repository == "wiresock/ndisapi"
|
||||
&& tag_pattern == "v*"
|
||||
&& asset_pattern == "Windows.Packet.Filter.*.x64.msi"
|
||||
&& *require_stable
|
||||
&& authenticode_publishers
|
||||
.as_deref()
|
||||
.is_some_and(|publishers| {
|
||||
publishers.len() == 1 && publishers[0] == "The Anti-Cloud Corporation"
|
||||
})
|
||||
}
|
||||
(
|
||||
ComponentId::SingBox,
|
||||
UpdateTrustPolicy::GithubReleaseDigest {
|
||||
repository,
|
||||
tag_pattern,
|
||||
asset_pattern,
|
||||
require_stable,
|
||||
authenticode_publishers,
|
||||
},
|
||||
) => {
|
||||
repository == "SagerNet/sing-box"
|
||||
&& tag_pattern == "v*"
|
||||
&& asset_pattern == "sing-box-*-windows-amd64.zip"
|
||||
&& *require_stable
|
||||
&& authenticode_publishers.is_none()
|
||||
}
|
||||
(
|
||||
ComponentId::VcRuntime,
|
||||
UpdateTrustPolicy::BuildTimeOnlyAuthenticode {
|
||||
allowed_source_hosts,
|
||||
asset_pattern,
|
||||
publishers,
|
||||
},
|
||||
) => {
|
||||
allowed_source_hosts.len() == 1
|
||||
&& allowed_source_hosts[0] == "aka.ms"
|
||||
&& asset_pattern == "VC_redist.x64.exe"
|
||||
&& publishers.len() == 1
|
||||
&& publishers[0] == "Microsoft Corporation"
|
||||
}
|
||||
(ComponentId::Winsw, UpdateTrustPolicy::BundledOnlyNoIndependentProof { .. }) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !policy_matches_component {
|
||||
return Err(invalid(format!(
|
||||
"trust policy does not match component {}",
|
||||
component.id.as_str()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_official_source(
|
||||
component: &ComponentPackage,
|
||||
source: &Url,
|
||||
asset_name: &str,
|
||||
) -> Result<(), ComponentCatalogError> {
|
||||
let expected_repository = match component.id {
|
||||
ComponentId::Proxifyre => Some("wiresock/proxifyre"),
|
||||
ComponentId::WindowsPacketFilter => Some("wiresock/ndisapi"),
|
||||
ComponentId::SingBox => Some("SagerNet/sing-box"),
|
||||
ComponentId::Winsw => Some("winsw/winsw"),
|
||||
ComponentId::VcRuntime => None,
|
||||
};
|
||||
|
||||
if let Some(expected_repository) = expected_repository {
|
||||
if source.host_str() != Some("github.com") {
|
||||
return Err(invalid(
|
||||
"component source is not its official GitHub repository",
|
||||
));
|
||||
}
|
||||
let segments = github_release_segments(source)?;
|
||||
if !segments[0..2]
|
||||
.join("/")
|
||||
.eq_ignore_ascii_case(expected_repository)
|
||||
|| segments[5] != asset_name
|
||||
|| segments[4].strip_prefix('v').unwrap_or(segments[4]) != component.version
|
||||
{
|
||||
return Err(invalid(
|
||||
"component source is not its pinned official release",
|
||||
));
|
||||
}
|
||||
} else if component.version != "14.51.36247.0"
|
||||
|| source.as_str() != "https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe"
|
||||
{
|
||||
return Err(invalid(
|
||||
"VC runtime must use the pinned Microsoft 14.51.36247.0 source",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const fn expected_role_and_arch(component_id: ComponentId) -> (InstallRole, AssetArch) {
|
||||
match component_id {
|
||||
ComponentId::Proxifyre => (InstallRole::ProxifyreRuntime, AssetArch::X64),
|
||||
ComponentId::WindowsPacketFilter => (InstallRole::PacketFilterDriver, AssetArch::X64),
|
||||
ComponentId::VcRuntime => (InstallRole::VcRuntimePrerequisite, AssetArch::X64),
|
||||
ComponentId::SingBox => (InstallRole::SingBoxRuntime, AssetArch::X64),
|
||||
ComponentId::Winsw => (InstallRole::SingBoxServiceWrapper, AssetArch::Anycpu),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_trust_policy(
|
||||
policy: &UpdateTrustPolicy,
|
||||
source: &Url,
|
||||
asset_name: &str,
|
||||
) -> Result<(), ComponentCatalogError> {
|
||||
match policy {
|
||||
UpdateTrustPolicy::GithubReleaseDigest {
|
||||
repository,
|
||||
tag_pattern,
|
||||
asset_pattern,
|
||||
require_stable,
|
||||
authenticode_publishers,
|
||||
} => {
|
||||
if !*require_stable {
|
||||
return Err(invalid(
|
||||
"GitHub release policy must require a stable release",
|
||||
));
|
||||
}
|
||||
validate_repository(repository)?;
|
||||
validate_pattern(tag_pattern, "tagPattern")?;
|
||||
validate_pattern(asset_pattern, "assetPattern")?;
|
||||
validate_optional_publishers(authenticode_publishers)?;
|
||||
if source.host_str() != Some("github.com") {
|
||||
return Err(invalid("GitHub release source must use github.com"));
|
||||
}
|
||||
|
||||
let segments = github_release_segments(source)?;
|
||||
if !segments[0..2].join("/").eq_ignore_ascii_case(repository)
|
||||
|| segments[5] != asset_name
|
||||
|| !pattern_matches(tag_pattern, segments[4])
|
||||
|| !pattern_matches(asset_pattern, asset_name)
|
||||
{
|
||||
return Err(invalid(
|
||||
"GitHub source URL does not match repository/tag/asset policy",
|
||||
));
|
||||
}
|
||||
}
|
||||
UpdateTrustPolicy::BuildTimeOnlyAuthenticode {
|
||||
allowed_source_hosts,
|
||||
asset_pattern,
|
||||
publishers,
|
||||
} => {
|
||||
validate_hosts(allowed_source_hosts)?;
|
||||
validate_pattern(asset_pattern, "assetPattern")?;
|
||||
validate_publishers(publishers)?;
|
||||
let source_host = source
|
||||
.host_str()
|
||||
.ok_or_else(|| invalid("sourceUrl has no host"))?;
|
||||
if !allowed_source_hosts
|
||||
.iter()
|
||||
.any(|host| host.eq_ignore_ascii_case(source_host))
|
||||
|| !pattern_matches(asset_pattern, asset_name)
|
||||
{
|
||||
return Err(invalid(
|
||||
"build-time Authenticode policy does not match source asset",
|
||||
));
|
||||
}
|
||||
}
|
||||
UpdateTrustPolicy::BundledOnlyNoIndependentProof { reason } => {
|
||||
if reason.trim().is_empty()
|
||||
|| reason.trim() != reason
|
||||
|| reason.chars().count() > 240
|
||||
|| reason.chars().any(char::is_control)
|
||||
{
|
||||
return Err(invalid("bundled-only policy must contain a safe reason"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn github_release_segments(source: &Url) -> Result<Vec<&str>, ComponentCatalogError> {
|
||||
let segments: Vec<_> = source
|
||||
.path_segments()
|
||||
.ok_or_else(|| invalid("GitHub source URL has no path"))?
|
||||
.collect();
|
||||
if segments.len() != 6 || segments[2] != "releases" || segments[3] != "download" {
|
||||
return Err(invalid("GitHub source URL is not a release asset URL"));
|
||||
}
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn validate_source_url(raw: &str) -> Result<Url, ComponentCatalogError> {
|
||||
let parsed = Url::parse(raw).map_err(|_| invalid("sourceUrl is not a valid URL"))?;
|
||||
if parsed.scheme() != "https"
|
||||
|| parsed.host_str().is_none()
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.port().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
return Err(invalid("sourceUrl must be a plain HTTPS official URL"));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn validate_repository(repository: &str) -> Result<(), ComponentCatalogError> {
|
||||
let mut segments = repository.split('/');
|
||||
let owner = segments.next().unwrap_or_default();
|
||||
let name = segments.next().unwrap_or_default();
|
||||
if segments.next().is_some()
|
||||
|| !is_safe_repository_segment(owner)
|
||||
|| !is_safe_repository_segment(name)
|
||||
|| name.ends_with(".git")
|
||||
{
|
||||
return Err(invalid("GitHub repository identity is invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_safe_repository_segment(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 100
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
|
||||
&& value != "."
|
||||
&& value != ".."
|
||||
}
|
||||
|
||||
fn validate_pattern(pattern: &str, field: &str) -> Result<(), ComponentCatalogError> {
|
||||
if pattern.is_empty()
|
||||
|| pattern.len() > 160
|
||||
|| pattern.matches('*').count() > 1
|
||||
|| pattern.contains(['/', '\\'])
|
||||
|| pattern.bytes().any(|byte| {
|
||||
!(byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*' | b'+'))
|
||||
})
|
||||
{
|
||||
return Err(invalid(format!("{field} is invalid")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pattern_matches(pattern: &str, value: &str) -> bool {
|
||||
match pattern.split_once('*') {
|
||||
Some((prefix, suffix)) => {
|
||||
value.len() >= prefix.len() + suffix.len()
|
||||
&& value.starts_with(prefix)
|
||||
&& value.ends_with(suffix)
|
||||
}
|
||||
None => pattern == value,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a discovered GitHub release asset against the immutable policy
|
||||
/// embedded in the bundled component catalog.
|
||||
pub fn validate_github_update_asset(
|
||||
component: &ComponentPackage,
|
||||
version: &str,
|
||||
asset_name: &str,
|
||||
source_url: &str,
|
||||
) -> Result<(), ComponentCatalogError> {
|
||||
let UpdateTrustPolicy::GithubReleaseDigest {
|
||||
repository,
|
||||
tag_pattern,
|
||||
asset_pattern,
|
||||
require_stable,
|
||||
..
|
||||
} = &component.update_trust_policy
|
||||
else {
|
||||
return Err(invalid("component does not allow GitHub runtime updates"));
|
||||
};
|
||||
|
||||
if !*require_stable || !is_stable_numeric_version(version) {
|
||||
return Err(invalid("update version is not stable"));
|
||||
}
|
||||
validate_relative_path(asset_name, "update asset name")?;
|
||||
if asset_name.contains('/') || !pattern_matches(asset_pattern, asset_name) {
|
||||
return Err(invalid("update asset name does not match policy"));
|
||||
}
|
||||
|
||||
let source = validate_source_url(source_url)?;
|
||||
if source.host_str() != Some("github.com") {
|
||||
return Err(invalid("update asset is not hosted by GitHub"));
|
||||
}
|
||||
let segments = github_release_segments(&source)?;
|
||||
let tag = segments[4];
|
||||
if !segments[0..2].join("/").eq_ignore_ascii_case(repository)
|
||||
|| segments[5] != asset_name
|
||||
|| !pattern_matches(tag_pattern, tag)
|
||||
|| tag.strip_prefix('v').unwrap_or(tag) != version
|
||||
{
|
||||
return Err(invalid(
|
||||
"update asset does not match the pinned repository policy",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_hosts(hosts: &[String]) -> Result<(), ComponentCatalogError> {
|
||||
let mut unique = HashSet::new();
|
||||
if hosts.is_empty()
|
||||
|| hosts.iter().any(|host| {
|
||||
host.is_empty()
|
||||
|| host.len() > 253
|
||||
|| host != &host.to_ascii_lowercase()
|
||||
|| host.starts_with('.')
|
||||
|| host.ends_with('.')
|
||||
|| !host
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.'))
|
||||
|| !unique.insert(host.as_str())
|
||||
})
|
||||
{
|
||||
return Err(invalid("allowedSourceHosts is invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_optional_publishers(
|
||||
publishers: &Option<Vec<String>>,
|
||||
) -> Result<(), ComponentCatalogError> {
|
||||
if let Some(publishers) = publishers {
|
||||
validate_publishers(publishers)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_publishers(publishers: &[String]) -> Result<(), ComponentCatalogError> {
|
||||
let mut unique = HashSet::new();
|
||||
if publishers.is_empty()
|
||||
|| publishers.iter().any(|publisher| {
|
||||
publisher.trim().is_empty()
|
||||
|| publisher.trim() != publisher
|
||||
|| publisher.chars().count() > 128
|
||||
|| publisher.chars().any(char::is_control)
|
||||
|| !unique.insert(publisher.as_str())
|
||||
})
|
||||
{
|
||||
return Err(invalid("Authenticode publishers are invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_relative_path(value: &str, field: &str) -> Result<(), ComponentCatalogError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 512
|
||||
|| value.contains('\\')
|
||||
|| value.starts_with('/')
|
||||
|| value.ends_with('/')
|
||||
|| value.split('/').any(|segment| {
|
||||
segment.is_empty()
|
||||
|| segment == "."
|
||||
|| segment == ".."
|
||||
|| segment.len() > 128
|
||||
|| segment.ends_with('.')
|
||||
|| is_windows_reserved_name(segment)
|
||||
|| !segment
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
|
||||
})
|
||||
{
|
||||
return Err(invalid(format!("{field} is not a safe relative path")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_windows_reserved_name(segment: &str) -> bool {
|
||||
let stem = segment.split('.').next().unwrap_or_default();
|
||||
let upper = stem.to_ascii_uppercase();
|
||||
matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| upper
|
||||
.strip_prefix("COM")
|
||||
.or_else(|| upper.strip_prefix("LPT"))
|
||||
.is_some_and(|suffix| suffix.len() == 1 && matches!(suffix.as_bytes()[0], b'1'..=b'9'))
|
||||
}
|
||||
|
||||
fn is_valid_sha256(value: &str) -> bool {
|
||||
value.len() == 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
|
||||
}
|
||||
|
||||
fn is_valid_license_id(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 96
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'+' | b'_'))
|
||||
}
|
||||
|
||||
fn is_stable_numeric_version(value: &str) -> bool {
|
||||
let segments: Vec<_> = value.split('.').collect();
|
||||
(2..=4).contains(&segments.len())
|
||||
&& segments.iter().all(|segment| {
|
||||
!segment.is_empty()
|
||||
&& segment.len() <= 10
|
||||
&& segment.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn is_stable_product_version(value: &str) -> bool {
|
||||
let Some((numeric, metadata)) = value.split_once('+') else {
|
||||
return is_stable_numeric_version(value);
|
||||
};
|
||||
is_stable_numeric_version(numeric)
|
||||
&& !metadata.is_empty()
|
||||
&& metadata.len() <= 128
|
||||
&& !metadata.contains('+')
|
||||
&& metadata.split('.').all(|segment| {
|
||||
!segment.is_empty()
|
||||
&& segment
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
})
|
||||
}
|
||||
|
||||
fn relative_path(value: &str) -> PathBuf {
|
||||
value.split('/').collect()
|
||||
}
|
||||
|
||||
fn require_regular_file(path: &Path, label: &str) -> Result<(), ComponentCatalogError> {
|
||||
ensure_no_reparse_ancestors(path)?;
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(invalid(format!("{label} must be a regular file")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_bundle_files(root: &Path) -> Result<HashSet<String>, ComponentCatalogError> {
|
||||
ensure_no_reparse_ancestors(root)?;
|
||||
let mut files = HashSet::new();
|
||||
let mut directories = vec![root.to_path_buf()];
|
||||
while let Some(directory) = directories.pop() {
|
||||
ensure_no_reparse_ancestors(&directory)?;
|
||||
for entry in fs::read_dir(directory)? {
|
||||
let entry = entry?;
|
||||
ensure_no_reparse_ancestors(&entry.path())?;
|
||||
let file_type = entry.file_type()?;
|
||||
if file_type.is_symlink() {
|
||||
return Err(invalid("bundle must not contain symbolic links"));
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
directories.push(entry.path());
|
||||
} else if file_type.is_file() {
|
||||
let relative = entry
|
||||
.path()
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| invalid("bundle entry escaped the root directory"))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
validate_relative_path(&relative, "bundle entry")?;
|
||||
files.insert(relative);
|
||||
} else {
|
||||
return Err(invalid("bundle contains a non-regular filesystem entry"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn sha256_file(path: &Path) -> Result<String, ComponentCatalogError> {
|
||||
ensure_no_reparse_ancestors(path)?;
|
||||
let mut file = File::open(path)?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let count = file.read(&mut buffer)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
Ok(hex_lower(&digest.finalize()))
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut output = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
output.push(HEX[(byte >> 4) as usize] as char);
|
||||
output.push(HEX[(byte & 0x0f) as usize] as char);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> ComponentCatalogError {
|
||||
ComponentCatalogError::Invalid(message.into())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1212
-240
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,924 @@
|
||||
//! Pure component ownership classification and lifecycle preflight.
|
||||
//!
|
||||
//! Detection gathers evidence; this module decides whether ProxyWarden may
|
||||
//! inspect or mutate a candidate. No component binary is executed here.
|
||||
|
||||
use crate::models::ComponentId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const OWNERSHIP_MISMATCH: &str = "ownership_mismatch";
|
||||
pub const COMPONENT_INCOMPLETE: &str = "component_incomplete";
|
||||
pub const FOREIGN_COMPONENT: &str = "foreign_component";
|
||||
pub const AMBIGUOUS_LEGACY: &str = "ambiguous_legacy";
|
||||
pub const COMPONENT_MISSING: &str = "component_missing";
|
||||
pub const LEGACY_IDENTITY_CHANGED: &str = "legacy_identity_changed";
|
||||
pub const MANUAL_MIGRATION_REQUIRED: &str = "manual_migration_required";
|
||||
|
||||
pub const LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT: &str = r"C:\Tools\ProxiFyre";
|
||||
pub const LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION: &str = "2.2.1";
|
||||
const LEGACY_PROXIFYRE_PRIMARY_SERVICE: &str = "ProxiFyreService";
|
||||
const LEGACY_PROXIFYRE_FIXED_VERSION: &str = "2.2.1.0";
|
||||
const SERVICE_WIN32_OWN_PROCESS: u32 = 0x0000_0010;
|
||||
const SERVICE_AUTO_START: u32 = 0x0000_0002;
|
||||
const SERVICE_ERROR_NORMAL: u32 = 0x0000_0001;
|
||||
const SERVICE_SID_TYPE_NONE: u32 = 0;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentClassification {
|
||||
ManagedCurrent,
|
||||
ManagedLegacy,
|
||||
Foreign,
|
||||
Incomplete,
|
||||
Missing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CandidateRole {
|
||||
Current,
|
||||
Legacy,
|
||||
ForeignByDefault,
|
||||
Foreign,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MarkerEvidence {
|
||||
Valid,
|
||||
Missing,
|
||||
Invalid,
|
||||
NotRequired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BinaryIdentityEvidence {
|
||||
KnownPackage,
|
||||
Unknown,
|
||||
Mismatch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ServiceEvidence {
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub path_name: Option<String>,
|
||||
pub executable_path: Option<PathBuf>,
|
||||
pub path_matches_candidate: bool,
|
||||
pub binary_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ComponentCandidateProbe {
|
||||
pub component_id: ComponentId,
|
||||
pub role: CandidateRole,
|
||||
pub root: PathBuf,
|
||||
pub root_exists: bool,
|
||||
pub has_reparse_point: bool,
|
||||
pub executable_path: Option<PathBuf>,
|
||||
pub missing_files: Vec<PathBuf>,
|
||||
pub marker: MarkerEvidence,
|
||||
pub marker_required: bool,
|
||||
pub binary_identity: BinaryIdentityEvidence,
|
||||
pub binary_version: Option<String>,
|
||||
pub service: Option<ServiceEvidence>,
|
||||
pub service_required: bool,
|
||||
pub legacy_identity_complete: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InventoryIssue {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl InventoryIssue {
|
||||
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ComponentCandidate {
|
||||
pub component_id: ComponentId,
|
||||
pub classification: ComponentClassification,
|
||||
pub role: CandidateRole,
|
||||
pub root: PathBuf,
|
||||
pub executable_path: Option<PathBuf>,
|
||||
pub binary_version: Option<String>,
|
||||
pub service: Option<ServiceEvidence>,
|
||||
pub marker: MarkerEvidence,
|
||||
pub issues: Vec<InventoryIssue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ComponentInventory {
|
||||
pub component_id: ComponentId,
|
||||
pub candidates: Vec<ComponentCandidate>,
|
||||
pub selected: Option<usize>,
|
||||
pub issues: Vec<InventoryIssue>,
|
||||
}
|
||||
|
||||
/// Immutable identity retained only by the disabled legacy compatibility
|
||||
/// helpers until Task 8 removes their implementation. Normal lifecycle routing
|
||||
/// no longer grants ManagedLegacy Start/Stop/Apply authority.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LegacyComponentIdentity {
|
||||
component_id: ComponentId,
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
/// Read-only evidence used by the durable cutover coordinator. This is
|
||||
/// intentionally separate from `ComponentClassification`: legacy discovery
|
||||
/// and compatibility helpers must not grant migration authority.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LegacyCutoverEvidence {
|
||||
pub proxifyre_manifest_matches: bool,
|
||||
pub proxifyre_scm_profile: Option<LegacyProxifyreScmProfile>,
|
||||
/// SHA-256 over the complete SCM restore snapshot (base config, every
|
||||
/// CONFIG2 value, security descriptor, and original stable state). The
|
||||
/// cutover coordinator computes it from the leased snapshot so fields
|
||||
/// outside the frozen safety profile remain bound to the sealed evidence.
|
||||
pub proxifyre_scm_snapshot_fingerprint: String,
|
||||
pub additional_matching_service: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LegacyProxifyreScmProfile {
|
||||
pub service_type: u32,
|
||||
pub start_type: u32,
|
||||
pub error_control: u32,
|
||||
pub account_name: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
pub dependencies: Vec<String>,
|
||||
pub load_order_group: Option<String>,
|
||||
pub has_failure_actions: bool,
|
||||
pub failure_actions_on_non_crash: bool,
|
||||
pub delayed_auto_start: bool,
|
||||
pub sid_type: u32,
|
||||
pub required_privileges: Vec<String>,
|
||||
pub has_triggers: bool,
|
||||
pub untrusted_mutation_rights: bool,
|
||||
}
|
||||
|
||||
impl LegacyProxifyreScmProfile {
|
||||
pub fn matches_frozen_2_2_1_profile(&self) -> bool {
|
||||
self.service_type == SERVICE_WIN32_OWN_PROCESS
|
||||
&& self.start_type == SERVICE_AUTO_START
|
||||
&& self.error_control == SERVICE_ERROR_NORMAL
|
||||
&& self.account_name.eq_ignore_ascii_case("LocalSystem")
|
||||
&& self.display_name == "ProxiFyre Service"
|
||||
&& self.description == "ProxiFyre - SOCKS5 ProxiFyre Service"
|
||||
&& self.dependencies.is_empty()
|
||||
&& self.load_order_group.as_deref().is_none_or(str::is_empty)
|
||||
&& !self.has_failure_actions
|
||||
&& !self.failure_actions_on_non_crash
|
||||
&& !self.delayed_auto_start
|
||||
&& self.sid_type == SERVICE_SID_TYPE_NONE
|
||||
&& self.required_privileges.is_empty()
|
||||
&& !self.has_triggers
|
||||
&& !self.untrusted_mutation_rights
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque strict-gate result. External callers can only obtain one through the
|
||||
/// matcher below; private fields prevent constructing an "approved" enum, and
|
||||
/// mutation entrypoints do not accept caller-supplied proofs.
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// use proxywarden_lib::component_inventory::LegacyCutoverProof;
|
||||
///
|
||||
/// let _forged = LegacyCutoverProof {
|
||||
/// identity_fingerprint: "forged".to_string(),
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LegacyCutoverProof {
|
||||
identity_fingerprint: String,
|
||||
}
|
||||
|
||||
impl LegacyCutoverProof {
|
||||
pub fn fingerprint(&self) -> &str {
|
||||
&self.identity_fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentInventory {
|
||||
pub fn missing(component_id: ComponentId) -> Self {
|
||||
Self {
|
||||
component_id,
|
||||
candidates: Vec::new(),
|
||||
selected: None,
|
||||
issues: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_candidate(&self) -> Option<&ComponentCandidate> {
|
||||
self.selected.and_then(|index| self.candidates.get(index))
|
||||
}
|
||||
|
||||
pub fn classification(&self) -> ComponentClassification {
|
||||
self.selected_candidate()
|
||||
.map(|candidate| candidate.classification)
|
||||
.unwrap_or(ComponentClassification::Missing)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InventoryAction {
|
||||
Install,
|
||||
Apply,
|
||||
CheckBinary,
|
||||
Start,
|
||||
Stop,
|
||||
ConfigureFirewall,
|
||||
Update,
|
||||
Uninstall,
|
||||
Cutover,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthorizedActionError<E> {
|
||||
Denied(InventoryIssue),
|
||||
Runner(E),
|
||||
}
|
||||
|
||||
pub fn classify_component_candidates(
|
||||
component_id: ComponentId,
|
||||
probes: Vec<ComponentCandidateProbe>,
|
||||
) -> ComponentInventory {
|
||||
let mut candidates: Vec<_> = probes.into_iter().map(classify_candidate).collect();
|
||||
candidates.retain(|candidate| candidate.classification != ComponentClassification::Missing);
|
||||
|
||||
let current = candidates
|
||||
.iter()
|
||||
.position(|candidate| candidate.role == CandidateRole::Current);
|
||||
let managed_legacy: Vec<_> = candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, candidate)| candidate.classification == ComponentClassification::ManagedLegacy)
|
||||
.map(|(index, _)| index)
|
||||
.collect();
|
||||
|
||||
let mut issues = Vec::new();
|
||||
let selected = if let Some(current) = current {
|
||||
Some(current)
|
||||
} else if managed_legacy.len() == 1 {
|
||||
managed_legacy.first().copied()
|
||||
} else if managed_legacy.len() > 1 {
|
||||
issues.push(InventoryIssue::new(
|
||||
AMBIGUOUS_LEGACY,
|
||||
"Найдено несколько подтвержденных старых установок; автоматический выбор заблокирован.",
|
||||
));
|
||||
None
|
||||
} else {
|
||||
candidates
|
||||
.iter()
|
||||
.position(|candidate| {
|
||||
candidate.classification == ComponentClassification::Foreign
|
||||
&& candidate
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.code == OWNERSHIP_MISMATCH)
|
||||
})
|
||||
.or_else(|| {
|
||||
candidates.iter().position(|candidate| {
|
||||
candidate.classification == ComponentClassification::Foreign
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
candidates.iter().position(|candidate| {
|
||||
candidate.classification == ComponentClassification::Incomplete
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
ComponentInventory {
|
||||
component_id,
|
||||
candidates,
|
||||
selected,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authorize_component_action(
|
||||
inventory: &ComponentInventory,
|
||||
action: InventoryAction,
|
||||
) -> Result<Option<&ComponentCandidate>, InventoryIssue> {
|
||||
if let Some(issue) = inventory.issues.first() {
|
||||
return Err(issue.clone());
|
||||
}
|
||||
|
||||
let Some(candidate) = inventory.selected_candidate() else {
|
||||
return if action == InventoryAction::Install {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(InventoryIssue::new(
|
||||
COMPONENT_MISSING,
|
||||
"Управляемый компонент не найден.",
|
||||
))
|
||||
};
|
||||
};
|
||||
|
||||
match candidate.classification {
|
||||
ComponentClassification::ManagedCurrent => match action {
|
||||
InventoryAction::Apply
|
||||
| InventoryAction::CheckBinary
|
||||
| InventoryAction::Start
|
||||
| InventoryAction::Stop
|
||||
| InventoryAction::ConfigureFirewall
|
||||
| InventoryAction::Update
|
||||
| InventoryAction::Uninstall => Ok(Some(candidate)),
|
||||
InventoryAction::Install | InventoryAction::Cutover => Err(InventoryIssue::new(
|
||||
"component_already_current",
|
||||
"Компонент уже находится в текущей управляемой папке.",
|
||||
)),
|
||||
},
|
||||
ComponentClassification::ManagedLegacy => match action {
|
||||
InventoryAction::CheckBinary => Ok(Some(candidate)),
|
||||
InventoryAction::Apply
|
||||
| InventoryAction::Install
|
||||
| InventoryAction::Start
|
||||
| InventoryAction::Stop
|
||||
| InventoryAction::ConfigureFirewall
|
||||
| InventoryAction::Update
|
||||
| InventoryAction::Uninstall
|
||||
| InventoryAction::Cutover => Err(InventoryIssue::new(
|
||||
"legacy_cutover_required",
|
||||
"Старая установка требует отдельного доказанного cutover-потока.",
|
||||
)),
|
||||
},
|
||||
ComponentClassification::Foreign => {
|
||||
Err(candidate.issues.first().cloned().unwrap_or_else(|| {
|
||||
InventoryIssue::new(
|
||||
FOREIGN_COMPONENT,
|
||||
"Найдена чужая установка; управление ею заблокировано.",
|
||||
)
|
||||
}))
|
||||
}
|
||||
ComponentClassification::Incomplete => {
|
||||
Err(candidate.issues.first().cloned().unwrap_or_else(|| {
|
||||
InventoryIssue::new(
|
||||
COMPONENT_INCOMPLETE,
|
||||
"Установка компонента неполна; опасные действия заблокированы.",
|
||||
)
|
||||
}))
|
||||
}
|
||||
ComponentClassification::Missing => {
|
||||
if action == InventoryAction::Install {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(InventoryIssue::new(
|
||||
COMPONENT_MISSING,
|
||||
"Управляемый компонент не найден.",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces read-only proof for the one supported automatic legacy cutover.
|
||||
///
|
||||
/// A `ManagedLegacy` candidate alone is discovery evidence, not mutation
|
||||
/// authority. The caller must independently match the leased ten-file package
|
||||
/// manifest and query the complete live SCM profile before calling this gate.
|
||||
pub fn prove_legacy_cutover(
|
||||
inventory: &ComponentInventory,
|
||||
evidence: &LegacyCutoverEvidence,
|
||||
) -> Result<LegacyCutoverProof, InventoryIssue> {
|
||||
let manual = || {
|
||||
Err(InventoryIssue::new(
|
||||
MANUAL_MIGRATION_REQUIRED,
|
||||
"Найдена старая установка, но ее identity недостаточна для автоматического переноса.",
|
||||
))
|
||||
};
|
||||
|
||||
if inventory.component_id != ComponentId::Proxyfier
|
||||
|| !inventory.issues.is_empty()
|
||||
|| inventory.candidates.len() != 1
|
||||
{
|
||||
return manual();
|
||||
}
|
||||
let Some(candidate) = inventory.selected_candidate() else {
|
||||
return manual();
|
||||
};
|
||||
if candidate.component_id != ComponentId::Proxyfier
|
||||
|| candidate.classification != ComponentClassification::ManagedLegacy
|
||||
|| candidate.role != CandidateRole::Legacy
|
||||
|| !candidate.issues.is_empty()
|
||||
|| normalized_identity_path(&candidate.root)
|
||||
!= normalized_identity_text(LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT)
|
||||
|| !candidate
|
||||
.binary_version
|
||||
.as_deref()
|
||||
.is_some_and(legacy_proxifyre_version_matches)
|
||||
|| !evidence.proxifyre_manifest_matches
|
||||
|| !is_sha256(&evidence.proxifyre_scm_snapshot_fingerprint)
|
||||
|| evidence.additional_matching_service
|
||||
|| !evidence
|
||||
.proxifyre_scm_profile
|
||||
.as_ref()
|
||||
.is_some_and(LegacyProxifyreScmProfile::matches_frozen_2_2_1_profile)
|
||||
{
|
||||
return manual();
|
||||
}
|
||||
|
||||
let expected_executable = candidate.root.join("ProxiFyre.exe");
|
||||
if candidate.executable_path.as_deref().is_none_or(|path| {
|
||||
normalized_identity_path(path) != normalized_identity_path(&expected_executable)
|
||||
}) {
|
||||
return manual();
|
||||
}
|
||||
let Some(service) = candidate.service.as_ref() else {
|
||||
return manual();
|
||||
};
|
||||
if !service
|
||||
.name
|
||||
.eq_ignore_ascii_case(LEGACY_PROXIFYRE_PRIMARY_SERVICE)
|
||||
|| !matches!(
|
||||
service.status.trim().to_ascii_lowercase().as_str(),
|
||||
"running" | "stopped"
|
||||
)
|
||||
|| service
|
||||
.binary_version
|
||||
.as_deref()
|
||||
.is_none_or(|version| !legacy_proxifyre_version_matches(version))
|
||||
|| service.executable_path.as_deref().is_none_or(|path| {
|
||||
normalized_identity_path(path) != normalized_identity_path(&expected_executable)
|
||||
})
|
||||
|| service.path_name.as_deref().is_none_or(|path_name| {
|
||||
!legacy_proxifyre_topshelf_path_matches(path_name, &expected_executable)
|
||||
})
|
||||
{
|
||||
return manual();
|
||||
}
|
||||
|
||||
let identity_fingerprint = json!({
|
||||
"domain": "proxywarden-legacy-cutover-proof-v1",
|
||||
"candidate": legacy_candidate_fingerprint(candidate, service),
|
||||
"evidence": evidence,
|
||||
});
|
||||
Ok(LegacyCutoverProof {
|
||||
identity_fingerprint: format!(
|
||||
"{:x}",
|
||||
Sha256::digest(identity_fingerprint.to_string().as_bytes())
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Cross-platform pure matcher for the historical Topshelf service command.
|
||||
/// It parses Windows quoting rules even when contract tests run on Linux.
|
||||
pub fn legacy_proxifyre_topshelf_path_matches(path_name: &str, expected_executable: &Path) -> bool {
|
||||
if normalized_identity_path(expected_executable)
|
||||
!= normalized_identity_text(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(arguments) = split_windows_command_line(path_name) else {
|
||||
return false;
|
||||
};
|
||||
if arguments.len() != 5
|
||||
|| normalized_identity_text(&arguments[0]) != normalized_identity_path(expected_executable)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut display_name = false;
|
||||
let mut service_name = false;
|
||||
for pair in arguments[1..].chunks_exact(2) {
|
||||
match (pair[0].to_ascii_lowercase().as_str(), pair[1].as_str()) {
|
||||
("-displayname", "ProxiFyre Service") if !display_name => display_name = true,
|
||||
("-servicename", LEGACY_PROXIFYRE_PRIMARY_SERVICE) if !service_name => {
|
||||
service_name = true;
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
display_name && service_name
|
||||
}
|
||||
|
||||
pub fn run_authorized_component_action<T, E>(
|
||||
inventory: &ComponentInventory,
|
||||
action: InventoryAction,
|
||||
runner: impl FnOnce(Option<&ComponentCandidate>) -> Result<T, E>,
|
||||
) -> Result<T, AuthorizedActionError<E>> {
|
||||
let candidate =
|
||||
authorize_component_action(inventory, action).map_err(AuthorizedActionError::Denied)?;
|
||||
runner(candidate).map_err(AuthorizedActionError::Runner)
|
||||
}
|
||||
|
||||
pub fn capture_legacy_component_identity(
|
||||
inventory: &ComponentInventory,
|
||||
) -> Result<LegacyComponentIdentity, InventoryIssue> {
|
||||
if !inventory.issues.is_empty() {
|
||||
return Err(legacy_identity_changed());
|
||||
}
|
||||
let candidate = inventory
|
||||
.selected_candidate()
|
||||
.filter(|candidate| candidate.classification == ComponentClassification::ManagedLegacy)
|
||||
.ok_or_else(legacy_identity_changed)?;
|
||||
let service = candidate
|
||||
.service
|
||||
.as_ref()
|
||||
.filter(|service| {
|
||||
!service.name.trim().is_empty()
|
||||
&& service
|
||||
.path_name
|
||||
.as_deref()
|
||||
.is_some_and(|path_name| !path_name.trim().is_empty())
|
||||
&& service.executable_path.is_some()
|
||||
&& service.path_matches_candidate
|
||||
})
|
||||
.ok_or_else(legacy_identity_changed)?;
|
||||
if candidate.component_id != inventory.component_id
|
||||
|| candidate.executable_path.is_none()
|
||||
|| !candidate.issues.is_empty()
|
||||
{
|
||||
return Err(legacy_identity_changed());
|
||||
}
|
||||
|
||||
Ok(LegacyComponentIdentity {
|
||||
component_id: inventory.component_id.clone(),
|
||||
fingerprint: legacy_candidate_fingerprint(candidate, service),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn revalidate_legacy_component<'a>(
|
||||
expected: &LegacyComponentIdentity,
|
||||
inventory: &'a ComponentInventory,
|
||||
action: InventoryAction,
|
||||
) -> Result<&'a ComponentCandidate, InventoryIssue> {
|
||||
if !matches!(action, InventoryAction::Start | InventoryAction::Stop)
|
||||
|| inventory.component_id != expected.component_id
|
||||
{
|
||||
return Err(legacy_identity_changed());
|
||||
}
|
||||
let candidate = authorize_component_action(inventory, action)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|candidate| candidate.classification == ComponentClassification::ManagedLegacy)
|
||||
.ok_or_else(legacy_identity_changed)?;
|
||||
let actual = capture_legacy_component_identity(inventory)?;
|
||||
if actual != *expected {
|
||||
return Err(legacy_identity_changed());
|
||||
}
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
pub fn run_revalidated_legacy_action<T, E>(
|
||||
expected: &LegacyComponentIdentity,
|
||||
inventory: &ComponentInventory,
|
||||
action: InventoryAction,
|
||||
runner: impl FnOnce(&ComponentCandidate) -> Result<T, E>,
|
||||
) -> Result<T, AuthorizedActionError<E>> {
|
||||
let candidate = revalidate_legacy_component(expected, inventory, action)
|
||||
.map_err(AuthorizedActionError::Denied)?;
|
||||
runner(candidate).map_err(AuthorizedActionError::Runner)
|
||||
}
|
||||
|
||||
fn legacy_candidate_fingerprint(
|
||||
candidate: &ComponentCandidate,
|
||||
service: &ServiceEvidence,
|
||||
) -> String {
|
||||
let value = json!({
|
||||
"component": component_identity_label(&candidate.component_id),
|
||||
"classification": "managed-legacy",
|
||||
"role": candidate_role_label(candidate.role),
|
||||
"root": normalized_identity_path(&candidate.root),
|
||||
"executable": candidate.executable_path.as_deref().map(normalized_identity_path),
|
||||
"binaryIdentity": "known-package",
|
||||
"binaryVersion": candidate.binary_version,
|
||||
"marker": marker_identity_label(candidate.marker),
|
||||
"service": {
|
||||
"name": service.name.to_ascii_lowercase(),
|
||||
"pathName": service.path_name.as_deref().map(normalized_identity_text),
|
||||
"executable": service.executable_path.as_deref().map(normalized_identity_path),
|
||||
"pathMatchesCandidate": service.path_matches_candidate,
|
||||
"binaryVersion": service.binary_version,
|
||||
},
|
||||
});
|
||||
format!("{:x}", Sha256::digest(value.to_string().as_bytes()))
|
||||
}
|
||||
|
||||
/// Canonical redacted identity used by normal startup, the privileged plan,
|
||||
/// and elevated next-start verification. Keeping this in the inventory owner
|
||||
/// prevents subtly different hashes from authorizing cleanup.
|
||||
pub fn component_inventory_fingerprint_for_cutover(inventory: &ComponentInventory) -> String {
|
||||
let mut candidates = inventory
|
||||
.candidates
|
||||
.iter()
|
||||
.map(inventory_candidate_fingerprint_value)
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort_by_key(Value::to_string);
|
||||
let mut issues = inventory
|
||||
.issues
|
||||
.iter()
|
||||
.map(|issue| issue.code.clone())
|
||||
.collect::<Vec<_>>();
|
||||
issues.sort();
|
||||
let value = json!({
|
||||
"component": component_identity_label(&inventory.component_id),
|
||||
"selected": inventory.selected_candidate().map(inventory_candidate_fingerprint_value),
|
||||
"candidates": candidates,
|
||||
"issues": issues,
|
||||
});
|
||||
format!("{:x}", Sha256::digest(value.to_string().as_bytes()))
|
||||
}
|
||||
|
||||
fn inventory_candidate_fingerprint_value(candidate: &ComponentCandidate) -> Value {
|
||||
let mut issues = candidate
|
||||
.issues
|
||||
.iter()
|
||||
.map(|issue| issue.code.clone())
|
||||
.collect::<Vec<_>>();
|
||||
issues.sort();
|
||||
json!({
|
||||
"component": component_identity_label(&candidate.component_id),
|
||||
"classification": component_classification_label(candidate.classification),
|
||||
"role": candidate_role_label(candidate.role),
|
||||
"root": normalized_inventory_path(&candidate.root),
|
||||
"executable": candidate.executable_path.as_deref().map(normalized_inventory_path),
|
||||
"binaryVersion": candidate.binary_version,
|
||||
"marker": marker_identity_label(candidate.marker),
|
||||
"service": candidate.service.as_ref().map(|service| json!({
|
||||
"name": service.name.to_ascii_lowercase(),
|
||||
"status": service.status.to_ascii_lowercase(),
|
||||
"pathName": service.path_name.as_deref().map(normalized_inventory_text),
|
||||
"executable": service.executable_path.as_deref().map(normalized_inventory_path),
|
||||
"pathMatches": service.path_matches_candidate,
|
||||
"binaryVersion": service.binary_version,
|
||||
})),
|
||||
"issues": issues,
|
||||
})
|
||||
}
|
||||
|
||||
fn component_classification_label(classification: ComponentClassification) -> &'static str {
|
||||
match classification {
|
||||
ComponentClassification::ManagedCurrent => "managed-current",
|
||||
ComponentClassification::ManagedLegacy => "managed-legacy",
|
||||
ComponentClassification::Foreign => "foreign",
|
||||
ComponentClassification::Incomplete => "incomplete",
|
||||
ComponentClassification::Missing => "missing",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_inventory_path(path: &Path) -> String {
|
||||
normalized_inventory_text(&path.to_string_lossy())
|
||||
}
|
||||
|
||||
fn normalized_inventory_text(value: &str) -> String {
|
||||
value.trim().replace('/', "\\").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn legacy_identity_changed() -> InventoryIssue {
|
||||
InventoryIssue::new(
|
||||
LEGACY_IDENTITY_CHANGED,
|
||||
"Старая управляемая установка изменилась после проверки; действие отменено.",
|
||||
)
|
||||
}
|
||||
|
||||
fn legacy_proxifyre_version_matches(version: &str) -> bool {
|
||||
matches!(
|
||||
version.trim(),
|
||||
LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION | LEGACY_PROXIFYRE_FIXED_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
fn is_sha256(value: &str) -> bool {
|
||||
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn normalized_identity_path(path: &Path) -> String {
|
||||
normalized_identity_text(&path.to_string_lossy())
|
||||
}
|
||||
|
||||
fn normalized_identity_text(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.replace('/', "\\")
|
||||
.trim_end_matches('\\')
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn split_windows_command_line(value: &str) -> Option<Vec<String>> {
|
||||
if value.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
let characters: Vec<char> = value.chars().collect();
|
||||
let mut index = 0;
|
||||
let mut arguments = Vec::new();
|
||||
while index < characters.len() {
|
||||
while index < characters.len() && characters[index].is_whitespace() {
|
||||
index += 1;
|
||||
}
|
||||
if index == characters.len() {
|
||||
break;
|
||||
}
|
||||
let mut argument = String::new();
|
||||
let mut quoted = false;
|
||||
while index < characters.len() {
|
||||
if characters[index] == '\\' {
|
||||
let start = index;
|
||||
while index < characters.len() && characters[index] == '\\' {
|
||||
index += 1;
|
||||
}
|
||||
let count = index - start;
|
||||
if index < characters.len() && characters[index] == '"' {
|
||||
argument.extend(std::iter::repeat_n('\\', count / 2));
|
||||
if count % 2 == 0 {
|
||||
quoted = !quoted;
|
||||
} else {
|
||||
argument.push('"');
|
||||
}
|
||||
index += 1;
|
||||
} else {
|
||||
argument.extend(std::iter::repeat_n('\\', count));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match characters[index] {
|
||||
'"' => quoted = !quoted,
|
||||
character if character.is_whitespace() && !quoted => break,
|
||||
character => argument.push(character),
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if quoted || argument.is_empty() {
|
||||
return None;
|
||||
}
|
||||
arguments.push(argument);
|
||||
while index < characters.len() && characters[index].is_whitespace() {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
(!arguments.is_empty()).then_some(arguments)
|
||||
}
|
||||
|
||||
fn component_identity_label(component: &ComponentId) -> &'static str {
|
||||
match component {
|
||||
ComponentId::ControlApp => "control-app",
|
||||
ComponentId::Proxyfier => "proxifyre",
|
||||
ComponentId::Singbox => "sing-box",
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_role_label(role: CandidateRole) -> &'static str {
|
||||
match role {
|
||||
CandidateRole::Current => "current",
|
||||
CandidateRole::Legacy => "legacy",
|
||||
CandidateRole::ForeignByDefault => "foreign-by-default",
|
||||
CandidateRole::Foreign => "foreign",
|
||||
}
|
||||
}
|
||||
|
||||
fn marker_identity_label(marker: MarkerEvidence) -> &'static str {
|
||||
match marker {
|
||||
MarkerEvidence::Valid => "valid",
|
||||
MarkerEvidence::Missing => "missing",
|
||||
MarkerEvidence::Invalid => "invalid",
|
||||
MarkerEvidence::NotRequired => "not-required",
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_candidate(probe: ComponentCandidateProbe) -> ComponentCandidate {
|
||||
let mut issues = Vec::new();
|
||||
let classification =
|
||||
if !probe.root_exists && probe.executable_path.is_none() && probe.service.is_none() {
|
||||
ComponentClassification::Missing
|
||||
} else if probe.has_reparse_point {
|
||||
issues.push(InventoryIssue::new(
|
||||
OWNERSHIP_MISMATCH,
|
||||
format!(
|
||||
"Путь компонента содержит reparse point и не может считаться управляемым: {}",
|
||||
probe.root.display()
|
||||
),
|
||||
));
|
||||
ComponentClassification::Foreign
|
||||
} else if probe.binary_identity == BinaryIdentityEvidence::Mismatch {
|
||||
issues.push(InventoryIssue::new(
|
||||
OWNERSHIP_MISMATCH,
|
||||
"Binary не совпадает с известным пакетом ProxyWarden.",
|
||||
));
|
||||
ComponentClassification::Foreign
|
||||
} else if probe
|
||||
.service
|
||||
.as_ref()
|
||||
.is_some_and(|service| !service.path_matches_candidate)
|
||||
{
|
||||
issues.push(InventoryIssue::new(
|
||||
OWNERSHIP_MISMATCH,
|
||||
"Имя службы совпало, но ее PathName указывает на другой binary.",
|
||||
));
|
||||
ComponentClassification::Foreign
|
||||
} else if probe.role == CandidateRole::Foreign {
|
||||
let (code, message) = if probe.service.is_some() {
|
||||
(
|
||||
OWNERSHIP_MISMATCH,
|
||||
"Служба с известным именем указывает в путь вне allowlist ProxyWarden.",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
FOREIGN_COMPONENT,
|
||||
"Путь не входит в allowlist управляемых установок ProxyWarden.",
|
||||
)
|
||||
};
|
||||
issues.push(InventoryIssue::new(code, message));
|
||||
ComponentClassification::Foreign
|
||||
} else if !probe.root_exists || !probe.missing_files.is_empty() {
|
||||
issues.push(InventoryIssue::new(
|
||||
COMPONENT_INCOMPLETE,
|
||||
missing_files_message(&probe.root, &probe.missing_files),
|
||||
));
|
||||
incomplete_classification(probe.role)
|
||||
} else if probe.marker_required && probe.marker != MarkerEvidence::Valid {
|
||||
let code = if probe.marker == MarkerEvidence::Invalid {
|
||||
OWNERSHIP_MISMATCH
|
||||
} else {
|
||||
COMPONENT_INCOMPLETE
|
||||
};
|
||||
issues.push(InventoryIssue::new(
|
||||
code,
|
||||
"Marker установки не подтверждает владение ProxyWarden.",
|
||||
));
|
||||
if probe.marker == MarkerEvidence::Invalid {
|
||||
ComponentClassification::Foreign
|
||||
} else {
|
||||
incomplete_classification(probe.role)
|
||||
}
|
||||
} else if probe.service_required && probe.service.is_none() {
|
||||
issues.push(InventoryIssue::new(
|
||||
COMPONENT_INCOMPLETE,
|
||||
"Ожидаемая Windows-служба отсутствует.",
|
||||
));
|
||||
incomplete_classification(probe.role)
|
||||
} else {
|
||||
match probe.role {
|
||||
CandidateRole::Current
|
||||
if probe.marker == MarkerEvidence::Valid || probe.legacy_identity_complete =>
|
||||
{
|
||||
ComponentClassification::ManagedCurrent
|
||||
}
|
||||
CandidateRole::Legacy | CandidateRole::ForeignByDefault
|
||||
if probe.legacy_identity_complete
|
||||
&& probe.binary_identity == BinaryIdentityEvidence::KnownPackage =>
|
||||
{
|
||||
ComponentClassification::ManagedLegacy
|
||||
}
|
||||
CandidateRole::ForeignByDefault => {
|
||||
issues.push(InventoryIssue::new(
|
||||
FOREIGN_COMPONENT,
|
||||
"Путь считается чужим без полной legacy identity ProxyWarden.",
|
||||
));
|
||||
ComponentClassification::Foreign
|
||||
}
|
||||
CandidateRole::Current | CandidateRole::Legacy => {
|
||||
issues.push(InventoryIssue::new(
|
||||
COMPONENT_INCOMPLETE,
|
||||
"Недостаточно evidence для подтверждения владения компонентом.",
|
||||
));
|
||||
ComponentClassification::Incomplete
|
||||
}
|
||||
CandidateRole::Foreign => ComponentClassification::Foreign,
|
||||
}
|
||||
};
|
||||
|
||||
ComponentCandidate {
|
||||
component_id: probe.component_id,
|
||||
classification,
|
||||
role: probe.role,
|
||||
root: probe.root,
|
||||
executable_path: probe.executable_path,
|
||||
binary_version: probe.binary_version,
|
||||
service: probe.service,
|
||||
marker: probe.marker,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
fn incomplete_classification(role: CandidateRole) -> ComponentClassification {
|
||||
if role == CandidateRole::ForeignByDefault {
|
||||
ComponentClassification::Foreign
|
||||
} else {
|
||||
ComponentClassification::Incomplete
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_files_message(root: &Path, missing_files: &[PathBuf]) -> String {
|
||||
if missing_files.is_empty() {
|
||||
return format!("Папка компонента отсутствует: {}", root.display());
|
||||
}
|
||||
|
||||
let names = missing_files
|
||||
.iter()
|
||||
.filter_map(|path| path.file_name().and_then(|name| name.to_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("Установка неполна; отсутствуют: {names}")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,32 @@
|
||||
//! Live component status resolution and read-only route/profile presentation.
|
||||
|
||||
use crate::command_dto::{CommandError, ResolvedAppDto};
|
||||
use crate::command_dto::ResolvedAppDto;
|
||||
use crate::component_detection::{
|
||||
detect_proxyfier_install, detect_singbox_install, proxyfier_component_from_detection,
|
||||
singbox_component_from_detection, DetectedProxyfier, DetectedSingBox,
|
||||
inventory_proxyfier, inventory_singbox, proxyfier_component_from_detection,
|
||||
proxyfier_component_from_inventory, singbox_component_from_detection,
|
||||
singbox_component_from_inventory, DetectedProxyfier, DetectedSingBox,
|
||||
};
|
||||
use crate::component_inventory::ComponentInventory;
|
||||
use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, ProfileItem, ProfileItemType, Target,
|
||||
};
|
||||
use crate::storage::JsonStorage;
|
||||
|
||||
pub(crate) fn components_or_defaults(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<Vec<ComponentStatus>, CommandError> {
|
||||
components_or_defaults_with_detection(
|
||||
storage,
|
||||
detect_proxyfier_install(),
|
||||
detect_singbox_install(),
|
||||
)
|
||||
pub(crate) fn live_components() -> Vec<ComponentStatus> {
|
||||
resolve_component_statuses_with_inventories(&inventory_proxyfier(), &inventory_singbox())
|
||||
}
|
||||
|
||||
pub(crate) fn components_or_defaults_with_detection(
|
||||
storage: &JsonStorage,
|
||||
pub(crate) fn components_with_detection(
|
||||
detected_proxyfier: Option<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Result<Vec<ComponentStatus>, CommandError> {
|
||||
let components = storage.read_components().map_err(storage_error)?;
|
||||
Ok(resolve_component_statuses(
|
||||
components,
|
||||
detected_proxyfier,
|
||||
detected_singbox,
|
||||
))
|
||||
) -> Vec<ComponentStatus> {
|
||||
resolve_component_statuses(detected_proxyfier, detected_singbox)
|
||||
}
|
||||
|
||||
pub fn resolve_component_statuses(
|
||||
stored_components: Vec<ComponentStatus>,
|
||||
detected_proxyfier: Option<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Vec<ComponentStatus> {
|
||||
let mut components = default_components();
|
||||
|
||||
for component in stored_components {
|
||||
upsert_component(&mut components, component);
|
||||
}
|
||||
|
||||
upsert_component(
|
||||
&mut components,
|
||||
proxyfier_component_from_detection(detected_proxyfier.as_ref()),
|
||||
@@ -56,6 +39,24 @@ pub fn resolve_component_statuses(
|
||||
components
|
||||
}
|
||||
|
||||
pub fn resolve_component_statuses_with_inventories(
|
||||
proxyfier_inventory: &ComponentInventory,
|
||||
singbox_inventory: &ComponentInventory,
|
||||
) -> Vec<ComponentStatus> {
|
||||
let mut components = default_components();
|
||||
|
||||
upsert_component(
|
||||
&mut components,
|
||||
proxyfier_component_from_inventory(proxyfier_inventory),
|
||||
);
|
||||
upsert_component(
|
||||
&mut components,
|
||||
singbox_component_from_inventory(singbox_inventory),
|
||||
);
|
||||
|
||||
components
|
||||
}
|
||||
|
||||
fn default_components() -> Vec<ComponentStatus> {
|
||||
vec![
|
||||
ComponentStatus {
|
||||
@@ -150,7 +151,3 @@ pub(crate) fn resolved_app(item: &ProfileItem, warnings: &mut Vec<String>) -> Re
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
CommandError::new("storage_error", error.to_string())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! One process-independent configuration lock and a fixed, recoverable commit.
|
||||
//! Only ProgramData source/generated files are included; this is never privileged authority.
|
||||
use crate::{safe_fs, storage::JsonStorage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub struct RootGuard {
|
||||
_file: File,
|
||||
}
|
||||
|
||||
pub fn acquire_root(storage: &JsonStorage) -> io::Result<RootGuard> {
|
||||
let dir = &storage.paths().migrations_dir;
|
||||
safe_fs::ensure_no_reparse_ancestors(dir)?;
|
||||
fs::create_dir_all(dir)?;
|
||||
safe_fs::protect_path_for_owner_admin_system(dir)?;
|
||||
let path = dir.join("storage-migration.lock");
|
||||
safe_fs::ensure_no_reparse_ancestors(&path)?;
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true).write(true).create(true).truncate(false);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
options.share_mode(0);
|
||||
}
|
||||
let file = options.open(&path)?;
|
||||
safe_fs::protect_path_for_owner_admin_system(&path)?;
|
||||
#[cfg(not(windows))]
|
||||
file.try_lock().map_err(io::Error::other)?;
|
||||
Ok(RootGuard { _file: file })
|
||||
}
|
||||
|
||||
pub fn read_guard(storage: &JsonStorage) -> io::Result<RootGuard> {
|
||||
let guard = acquire_root(storage)?;
|
||||
if migration_active(storage).try_exists()? {
|
||||
return Err(io::Error::other(
|
||||
"storage recovery required before reading configuration",
|
||||
));
|
||||
}
|
||||
recover_locked(storage)?;
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
fn migration_active(storage: &JsonStorage) -> PathBuf {
|
||||
storage
|
||||
.paths()
|
||||
.migrations_dir
|
||||
.join("active-storage-migration.json")
|
||||
}
|
||||
fn journal_path(storage: &JsonStorage) -> PathBuf {
|
||||
storage
|
||||
.paths()
|
||||
.migrations_dir
|
||||
.join("configuration-commit.json")
|
||||
}
|
||||
fn snapshot_path(storage: &JsonStorage, index: usize) -> PathBuf {
|
||||
storage
|
||||
.paths()
|
||||
.migrations_dir
|
||||
.join(format!("configuration-before-{index}.json"))
|
||||
}
|
||||
fn revision_path(storage: &JsonStorage) -> PathBuf {
|
||||
storage
|
||||
.paths()
|
||||
.state_dir
|
||||
.join("configuration-revision.json")
|
||||
}
|
||||
|
||||
fn tracked_paths(storage: &JsonStorage) -> Vec<PathBuf> {
|
||||
let paths = storage.paths();
|
||||
[
|
||||
paths.profiles_file.clone(),
|
||||
paths.targets_file.clone(),
|
||||
paths.local_singbox_file.clone(),
|
||||
paths.singbox_subscription_cache_file.clone(),
|
||||
paths.generated_dir.join("proxifyre-app-config.json"),
|
||||
paths.generated_dir.join("sing-box-config.json"),
|
||||
revision_path(storage),
|
||||
crate::route_state::prepared_path(storage),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|path| [path.clone(), safe_fs::backup_path(&path)])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn optional_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
|
||||
safe_fs::ensure_no_reparse_ancestors(path)?;
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
/// Read only while holding this module's root guard. Content protects against uncoordinated old writers too.
|
||||
pub fn revision_locked(storage: &JsonStorage) -> io::Result<String> {
|
||||
let mut hash = Sha256::new();
|
||||
for path in tracked_paths(storage).into_iter().step_by(2) {
|
||||
match optional_bytes(&path)? {
|
||||
Some(bytes) => {
|
||||
hash.update([1]);
|
||||
hash.update((bytes.len() as u64).to_le_bytes());
|
||||
hash.update(bytes);
|
||||
}
|
||||
None => hash.update([0]),
|
||||
}
|
||||
}
|
||||
Ok(format!("{:x}", hash.finalize()))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Intent {
|
||||
version: u8,
|
||||
committed: bool,
|
||||
before: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
/// Caller must hold the common root lock; migration calls this before inspecting source.
|
||||
pub fn recover_locked(storage: &JsonStorage) -> io::Result<()> {
|
||||
let Some(bytes) = optional_bytes(&journal_path(storage))? else {
|
||||
return Ok(());
|
||||
};
|
||||
if migration_active(storage).try_exists()? {
|
||||
return Err(io::Error::other(
|
||||
"conflicting storage intents require recovery",
|
||||
));
|
||||
}
|
||||
let intent: Intent = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| io::Error::other("invalid configuration intent"))?;
|
||||
let paths = tracked_paths(storage);
|
||||
if intent.version != 1 || intent.before.len() != paths.len() {
|
||||
return Err(io::Error::other("unsupported configuration intent"));
|
||||
}
|
||||
if !intent.committed {
|
||||
// Verify every snapshot before the first restoration, including absent destinations.
|
||||
let mut snapshots = Vec::new();
|
||||
for (index, expected) in intent.before.iter().enumerate() {
|
||||
safe_fs::ensure_no_reparse_ancestors(&paths[index])?;
|
||||
snapshots.push(match expected {
|
||||
Some(hash) => {
|
||||
let bytes = optional_bytes(&snapshot_path(storage, index))?
|
||||
.ok_or_else(|| io::Error::other("missing configuration snapshot"))?;
|
||||
if digest(&bytes) != *hash {
|
||||
return Err(io::Error::other("damaged configuration snapshot"));
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
None => None,
|
||||
});
|
||||
}
|
||||
for (path, bytes) in paths.iter().zip(snapshots) {
|
||||
match bytes {
|
||||
Some(bytes) => safe_fs::write_restricted_atomic(path, &bytes)?,
|
||||
None => remove_optional(path)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanup(storage, paths.len())
|
||||
}
|
||||
|
||||
fn remove_optional(path: &Path) -> io::Result<()> {
|
||||
safe_fs::ensure_no_reparse_ancestors(path)?;
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
fn cleanup(storage: &JsonStorage, count: usize) -> io::Result<()> {
|
||||
// Mark rollback complete before deleting any snapshot, so interrupted cleanup is retryable.
|
||||
let marker = Intent {
|
||||
version: 1,
|
||||
committed: true,
|
||||
before: vec![None; count],
|
||||
};
|
||||
safe_fs::write_restricted_atomic(&journal_path(storage), &serde_json::to_vec(&marker)?)?;
|
||||
for index in 0..count {
|
||||
remove_optional(&safe_fs::backup_path(&snapshot_path(storage, index)))?;
|
||||
remove_optional(&snapshot_path(storage, index))?;
|
||||
}
|
||||
remove_optional(&safe_fs::backup_path(&journal_path(storage)))?;
|
||||
remove_optional(&journal_path(storage))
|
||||
}
|
||||
|
||||
pub struct ConfigurationTransaction<'a> {
|
||||
storage: &'a JsonStorage,
|
||||
guard: Option<RootGuard>,
|
||||
intent: Intent,
|
||||
committed: bool,
|
||||
}
|
||||
impl<'a> ConfigurationTransaction<'a> {
|
||||
pub fn begin(storage: &'a JsonStorage, expected: Option<&str>) -> io::Result<Self> {
|
||||
let guard = read_guard(storage)?;
|
||||
if let Some(expected) = expected {
|
||||
if revision_locked(storage)? != expected {
|
||||
return Err(io::Error::other(
|
||||
"configuration changed; retry using current settings",
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut before = Vec::new();
|
||||
for (index, path) in tracked_paths(storage).iter().enumerate() {
|
||||
before.push(match optional_bytes(path)? {
|
||||
Some(bytes) => {
|
||||
safe_fs::write_restricted_atomic(&snapshot_path(storage, index), &bytes)?;
|
||||
Some(digest(&bytes))
|
||||
}
|
||||
None => None,
|
||||
});
|
||||
}
|
||||
let intent = Intent {
|
||||
version: 1,
|
||||
committed: false,
|
||||
before,
|
||||
};
|
||||
safe_fs::write_restricted_atomic(&journal_path(storage), &serde_json::to_vec(&intent)?)?;
|
||||
Ok(Self {
|
||||
storage,
|
||||
guard: Some(guard),
|
||||
intent,
|
||||
committed: false,
|
||||
})
|
||||
}
|
||||
pub fn commit(self) -> io::Result<()> {
|
||||
self.commit_with_revision().map(|_| ())
|
||||
}
|
||||
|
||||
pub fn commit_with_revision(mut self) -> io::Result<String> {
|
||||
// A fresh nonce records intent even if a later edit returns source to identical bytes.
|
||||
let prepared = safe_fs::write_restricted_atomic(
|
||||
&revision_path(self.storage),
|
||||
&serde_json::to_vec(&uuid::Uuid::new_v4().to_string())?,
|
||||
)
|
||||
.and_then(|()| revision_locked(self.storage));
|
||||
let revision = match prepared {
|
||||
Ok(revision) => revision,
|
||||
Err(error) => {
|
||||
self.committed = true;
|
||||
return match recover_locked(self.storage) {
|
||||
Ok(()) => Err(error),
|
||||
Err(_) => Err(io::Error::other(
|
||||
"configuration_recovery_required: восстановление сохранения не завершено",
|
||||
)),
|
||||
};
|
||||
}
|
||||
};
|
||||
self.intent.committed = true;
|
||||
let marker = serde_json::to_vec(&self.intent)?;
|
||||
if let Err(error) = safe_fs::write_restricted_atomic(&journal_path(self.storage), &marker) {
|
||||
// The atomic writer may fail its final ACL step after promotion.
|
||||
// Read back the exact marker under the same lock before deciding the outcome.
|
||||
match optional_bytes(&journal_path(self.storage)) {
|
||||
Ok(Some(bytes)) if bytes == marker => {}
|
||||
Ok(Some(_)) => {
|
||||
self.committed = true;
|
||||
return match recover_locked(self.storage) {
|
||||
Ok(()) => Err(error),
|
||||
Err(_) => Err(io::Error::other("configuration_recovery_required: восстановление сохранения не завершено")),
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
self.committed = true;
|
||||
return Err(io::Error::other("configuration_outcome_unknown: итог сохранения не подтверждён; обновите состояние перед повтором"));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.committed = true;
|
||||
let _ = cleanup(self.storage, self.intent.before.len());
|
||||
self.guard.take();
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
pub fn abort(mut self) -> io::Result<()> {
|
||||
let result = recover_locked(self.storage);
|
||||
// Do not silently retry and hide a failed explicit recovery in Drop.
|
||||
self.committed = true;
|
||||
result
|
||||
}
|
||||
}
|
||||
impl Drop for ConfigurationTransaction<'_> {
|
||||
fn drop(&mut self) {
|
||||
if !self.committed {
|
||||
let _ = recover_locked(self.storage);
|
||||
}
|
||||
// Failed recovery leaves the durable intent for the next guarded read, never fresh defaults.
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,35 @@
|
||||
//! Persisted profiles/targets, startup snapshot, ProxiFyre bootstrap import, and preview use cases.
|
||||
use crate::configuration_transaction::{read_guard, ConfigurationTransaction};
|
||||
// Persisted profiles/targets, startup preparation, and preview use cases.
|
||||
|
||||
use crate::adapters::proxifyre::{ProxiFyreConfig, ProxiFyreProxy};
|
||||
use crate::adapters::proxifyre::{ProxiFyreAdapter, PROXIFYRE_OUTPUT_FILE};
|
||||
use crate::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||
use crate::admin::admin_status;
|
||||
use crate::command_dto::*;
|
||||
use crate::component_detection::{
|
||||
default_proxifyre_install_dir, default_singbox_install_dir, detect_proxyfier_install,
|
||||
detect_singbox_install,
|
||||
default_proxifyre_install_dir, default_singbox_install_dir, detected_proxyfier_from_inventory,
|
||||
detected_singbox_from_inventory, inventory_proxyfier, inventory_singbox,
|
||||
};
|
||||
use crate::component_inventory::ComponentClassification;
|
||||
use crate::component_status::{
|
||||
components_or_defaults, resolve_component_statuses, resolved_app, route_line,
|
||||
live_components, resolve_component_statuses_with_inventories, resolved_app, route_line,
|
||||
};
|
||||
use crate::models::{
|
||||
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
|
||||
use crate::migration::{
|
||||
prepare_storage, reconcile_component_layout, record_component_cutover_startup_evidence,
|
||||
recover_incomplete_migration, with_component_layout,
|
||||
};
|
||||
use crate::proxifyre_runtime::build_proxifyre_setup_status_with_detection;
|
||||
use crate::safe_fs;
|
||||
use crate::singbox_service::build_singbox_setup_status_with_install_root;
|
||||
use crate::singbox_subscription::read_singbox_status_with_detection;
|
||||
use crate::storage::JsonStorage;
|
||||
use crate::validation::{normalize_profile, normalize_target, ValidationError};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const MAIN_PROFILE_ID: &str = "main-profile";
|
||||
const MAIN_TARGET_ID: &str = "main-proxy";
|
||||
|
||||
pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandError> {
|
||||
let _guard = read_guard(storage).map_err(storage_error)?;
|
||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let targets = storage.read_targets().map_err(storage_error)?;
|
||||
let components = components_or_defaults(storage)?;
|
||||
let components = live_components();
|
||||
let activity = storage.read_activity().map_err(storage_error)?;
|
||||
let active_profile_count = profiles.iter().filter(|profile| profile.enabled).count();
|
||||
let routed_app_count = profiles
|
||||
@@ -62,6 +64,7 @@ pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandErro
|
||||
}
|
||||
|
||||
pub fn read_profiles(storage: &JsonStorage) -> Result<Vec<ProfileDto>, CommandError> {
|
||||
let _guard = read_guard(storage).map_err(storage_error)?;
|
||||
storage
|
||||
.read_profiles()
|
||||
.map_err(storage_error)
|
||||
@@ -72,6 +75,7 @@ pub fn save_profile_to_storage(
|
||||
storage: &JsonStorage,
|
||||
input: ProfileInputDto,
|
||||
) -> Result<ProfileDto, CommandError> {
|
||||
let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
|
||||
let profile = normalize_profile(input.into()).map_err(validation_error)?;
|
||||
let mut profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
|
||||
@@ -84,10 +88,12 @@ pub fn save_profile_to_storage(
|
||||
}
|
||||
|
||||
storage.write_profiles(&profiles).map_err(storage_error)?;
|
||||
transaction.commit().map_err(storage_error)?;
|
||||
Ok(ProfileDto::from(&profile))
|
||||
}
|
||||
|
||||
pub fn read_targets(storage: &JsonStorage) -> Result<Vec<TargetDto>, CommandError> {
|
||||
let _guard = read_guard(storage).map_err(storage_error)?;
|
||||
storage
|
||||
.read_targets()
|
||||
.map_err(storage_error)
|
||||
@@ -98,6 +104,7 @@ pub fn save_target_to_storage(
|
||||
storage: &JsonStorage,
|
||||
input: TargetInputDto,
|
||||
) -> Result<TargetDto, CommandError> {
|
||||
let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
|
||||
let target = normalize_target(input.into()).map_err(validation_error)?;
|
||||
let mut targets = storage.read_targets().map_err(storage_error)?;
|
||||
|
||||
@@ -107,46 +114,155 @@ pub fn save_target_to_storage(
|
||||
}
|
||||
|
||||
storage.write_targets(&targets).map_err(storage_error)?;
|
||||
transaction.commit().map_err(storage_error)?;
|
||||
Ok(TargetDto::from(&target))
|
||||
}
|
||||
|
||||
pub fn read_components(storage: &JsonStorage) -> Result<Vec<ComponentStatusDto>, CommandError> {
|
||||
components_or_defaults(storage).map(|components| {
|
||||
components
|
||||
.iter()
|
||||
.map(ComponentStatusDto::from)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
pub fn read_live_components() -> Vec<ComponentStatusDto> {
|
||||
live_components()
|
||||
.iter()
|
||||
.map(ComponentStatusDto::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rebuilds only an existing, untrusted derived config from authoritative
|
||||
/// profiles/targets. A missing config still requires an explicit Apply action.
|
||||
pub fn ensure_proxifyre_generated_config_ready(storage: &JsonStorage) -> Result<(), CommandError> {
|
||||
let guard = read_guard(storage).map_err(storage_error)?;
|
||||
let path = storage.paths().generated_dir.join(PROXIFYRE_OUTPUT_FILE);
|
||||
if !path.try_exists().map_err(storage_error)? {
|
||||
return Err(CommandError::new(
|
||||
"generated_config_missing",
|
||||
"Сначала нажмите «Применить», чтобы создать конфигурацию ProxiFyre.",
|
||||
));
|
||||
}
|
||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
if !profiles
|
||||
.iter()
|
||||
.any(|profile| profile.enabled && !profile.items.is_empty())
|
||||
{
|
||||
return Err(CommandError::new(
|
||||
"route_has_no_apps",
|
||||
"Нет включённых правил. Добавьте приложения и примените конфигурацию перед запуском ProxiFyre.",
|
||||
));
|
||||
}
|
||||
if safe_fs::open_restricted_file_read_lease(&path).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let targets = storage.read_targets().map_err(storage_error)?;
|
||||
let components = live_components();
|
||||
let generated = ProxiFyreAdapter::default()
|
||||
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||
.map_err(|_| {
|
||||
CommandError::new(
|
||||
"generated_config_rebuild_failed",
|
||||
"Старую конфигурацию ProxiFyre нельзя использовать. Нажмите «Применить», чтобы пересоздать её.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let revision =
|
||||
crate::configuration_transaction::revision_locked(storage).map_err(storage_error)?;
|
||||
drop(guard);
|
||||
let transaction =
|
||||
ConfigurationTransaction::begin(storage, Some(&revision)).map_err(storage_error)?;
|
||||
remove_untrusted_generated_file(&path, true)?;
|
||||
remove_untrusted_generated_file(&safe_fs::backup_path(&path), false)?;
|
||||
safe_fs::write_restricted_atomic(&path, generated.contents.as_bytes())
|
||||
.map_err(|_| generated_config_rebuild_error())?;
|
||||
transaction.commit().map_err(storage_error)
|
||||
}
|
||||
|
||||
fn remove_untrusted_generated_file(
|
||||
path: &std::path::Path,
|
||||
required: bool,
|
||||
) -> Result<(), CommandError> {
|
||||
safe_fs::ensure_no_reparse_ancestors(path).map_err(|_| generated_config_rebuild_error())?;
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_file() => {
|
||||
fs::remove_file(path).map_err(|_| generated_config_rebuild_error())
|
||||
}
|
||||
Ok(_) => Err(generated_config_rebuild_error()),
|
||||
Err(error) if !required && error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(_) => Err(generated_config_rebuild_error()),
|
||||
}
|
||||
}
|
||||
|
||||
fn generated_config_rebuild_error() -> CommandError {
|
||||
CommandError::new(
|
||||
"generated_config_rebuild_failed",
|
||||
"Не удалось безопасно пересоздать старую конфигурацию ProxiFyre. Нажмите «Применить» и повторите запуск.",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn read_startup_snapshot(
|
||||
storage: &JsonStorage,
|
||||
startup_session_id: &str,
|
||||
) -> Result<StartupSnapshotResponse, CommandError> {
|
||||
// Resolve an interrupted storage transaction before any normal read or
|
||||
// component-dependent startup work.
|
||||
recover_incomplete_migration(storage)?;
|
||||
// Both detectors query Windows independently. Run them together so the
|
||||
// startup snapshot is bounded by the slower check instead of their sum.
|
||||
let proxyfier_detection = std::thread::spawn(detect_proxyfier_install);
|
||||
let detected_singbox = detect_singbox_install();
|
||||
let detected_proxyfier = proxyfier_detection.join().ok().flatten();
|
||||
let saved_state = read_saved_state_with_proxifyre_config(
|
||||
let proxyfier_inventory_task = std::thread::spawn(inventory_proxyfier);
|
||||
let singbox_inventory = inventory_singbox();
|
||||
let proxyfier_inventory = proxyfier_inventory_task.join().map_err(|_| {
|
||||
CommandError::new(
|
||||
"component_inventory_failed",
|
||||
"Не удалось проверить установку ProxiFyre.",
|
||||
)
|
||||
})?;
|
||||
let detected_proxyfier = detected_proxyfier_from_inventory(&proxyfier_inventory);
|
||||
let detected_singbox = detected_singbox_from_inventory(&singbox_inventory);
|
||||
let mut legacy_candidates = vec![storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join("proxifyre-app-config.json")];
|
||||
legacy_candidates.extend(
|
||||
proxyfier_inventory
|
||||
.candidates
|
||||
.iter()
|
||||
.filter(|candidate| {
|
||||
matches!(
|
||||
candidate.classification,
|
||||
ComponentClassification::ManagedCurrent
|
||||
| ComponentClassification::ManagedLegacy
|
||||
)
|
||||
})
|
||||
.map(|candidate| candidate.root.join("app-config.json")),
|
||||
);
|
||||
let migration_status = prepare_storage(storage, &legacy_candidates)?;
|
||||
if migration_status.blocking {
|
||||
return Err(CommandError::new(
|
||||
migration_status
|
||||
.notice_code
|
||||
.clone()
|
||||
.unwrap_or_else(|| "storage_migration_blocked".to_string()),
|
||||
migration_status.message,
|
||||
));
|
||||
}
|
||||
let component_layout_version =
|
||||
reconcile_component_layout(storage, &proxyfier_inventory, &singbox_inventory)?;
|
||||
// This is an untrusted UX carrier. A failed write must not block normal
|
||||
// startup; cleanup remains unavailable until an exact later observation.
|
||||
let _ = record_component_cutover_startup_evidence(
|
||||
storage,
|
||||
detected_proxyfier
|
||||
.as_ref()
|
||||
.and_then(|detected| detected.config_path.as_deref()),
|
||||
)?;
|
||||
let stored_components = storage.read_components().map_err(storage_error)?;
|
||||
let components = resolve_component_statuses(
|
||||
stored_components,
|
||||
detected_proxyfier.clone(),
|
||||
detected_singbox.clone(),
|
||||
)
|
||||
.iter()
|
||||
.map(ComponentStatusDto::from)
|
||||
.collect();
|
||||
startup_session_id,
|
||||
&proxyfier_inventory,
|
||||
);
|
||||
let migration_status = with_component_layout(migration_status, component_layout_version);
|
||||
|
||||
let components =
|
||||
resolve_component_statuses_with_inventories(&proxyfier_inventory, &singbox_inventory)
|
||||
.iter()
|
||||
.map(ComponentStatusDto::from)
|
||||
.collect();
|
||||
let proxifyre_setup_status = build_proxifyre_setup_status_with_detection(
|
||||
detected_proxyfier.as_ref(),
|
||||
&default_proxifyre_install_dir(),
|
||||
);
|
||||
let singbox_status = read_singbox_status_with_detection(storage, detected_singbox.as_ref())?;
|
||||
let saved_state = singbox_status.saved_state.clone();
|
||||
let singbox_setup_status = build_singbox_setup_status_with_install_root(
|
||||
detected_singbox.as_ref(),
|
||||
&default_singbox_install_dir(),
|
||||
@@ -154,6 +270,7 @@ pub fn read_startup_snapshot(
|
||||
|
||||
Ok(StartupSnapshotResponse {
|
||||
admin_status: admin_status(),
|
||||
migration_status,
|
||||
saved_state,
|
||||
components,
|
||||
proxifyre_setup_status,
|
||||
@@ -170,29 +287,20 @@ pub fn read_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, Com
|
||||
}
|
||||
|
||||
pub fn read_saved_state(storage: &JsonStorage) -> Result<SavedStateResponse, CommandError> {
|
||||
let detected_config_path = detect_proxyfier_install().and_then(|detected| detected.config_path);
|
||||
read_saved_state_with_proxifyre_config(storage, detected_config_path.as_deref())
|
||||
let _guard = read_guard(storage).map_err(storage_error)?;
|
||||
read_saved_state_locked(storage)
|
||||
}
|
||||
|
||||
pub fn read_saved_state_with_proxifyre_config(
|
||||
pub(crate) fn read_saved_state_locked(
|
||||
storage: &JsonStorage,
|
||||
proxifyre_config_path: Option<&Path>,
|
||||
) -> Result<SavedStateResponse, CommandError> {
|
||||
let mut profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let mut targets = storage.read_targets().map_err(storage_error)?;
|
||||
|
||||
if should_bootstrap_profiles(&profiles) {
|
||||
if let Some(imported) =
|
||||
proxifyre_config_path.and_then(import_saved_state_from_proxifyre_config)
|
||||
{
|
||||
profiles = imported.profiles;
|
||||
upsert_targets(&mut targets, imported.targets);
|
||||
storage.write_targets(&targets).map_err(storage_error)?;
|
||||
storage.write_profiles(&profiles).map_err(storage_error)?;
|
||||
}
|
||||
}
|
||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let targets = storage.read_targets().map_err(storage_error)?;
|
||||
|
||||
Ok(SavedStateResponse {
|
||||
artifacts: crate::route_state::read_status_locked(storage).map_err(storage_error)?,
|
||||
revision: crate::configuration_transaction::revision_locked(storage)
|
||||
.map_err(storage_error)?,
|
||||
profiles: profiles.iter().map(ProfileDto::from).collect(),
|
||||
targets: targets.iter().map(TargetDto::from).collect(),
|
||||
generated_config_path: storage
|
||||
@@ -204,198 +312,6 @@ pub fn read_saved_state_with_proxifyre_config(
|
||||
})
|
||||
}
|
||||
|
||||
struct ImportedSavedState {
|
||||
profiles: Vec<Profile>,
|
||||
targets: Vec<Target>,
|
||||
}
|
||||
|
||||
fn should_bootstrap_profiles(profiles: &[Profile]) -> bool {
|
||||
!profiles
|
||||
.iter()
|
||||
.any(|profile| profile.enabled && !profile.items.is_empty())
|
||||
}
|
||||
|
||||
fn import_saved_state_from_proxifyre_config(path: &Path) -> Option<ImportedSavedState> {
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
let config: ProxiFyreConfig = serde_json::from_str(&contents).ok()?;
|
||||
|
||||
let proxy_entries = config
|
||||
.proxies
|
||||
.iter()
|
||||
.filter_map(import_proxy_entry)
|
||||
.collect::<Vec<_>>();
|
||||
if proxy_entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let single_entry = proxy_entries.len() == 1;
|
||||
let mut profiles = Vec::with_capacity(proxy_entries.len());
|
||||
let mut targets = Vec::with_capacity(proxy_entries.len());
|
||||
|
||||
for (index, entry) in proxy_entries.into_iter().enumerate() {
|
||||
let ordinal = index + 1;
|
||||
let target_id = if single_entry {
|
||||
MAIN_TARGET_ID.to_string()
|
||||
} else {
|
||||
format!("proxifyre-import-target-{ordinal}")
|
||||
};
|
||||
let profile_id = if single_entry {
|
||||
MAIN_PROFILE_ID.to_string()
|
||||
} else {
|
||||
format!("proxifyre-import-profile-{ordinal}")
|
||||
};
|
||||
let profile_name = if single_entry {
|
||||
"Приложения через прокси".to_string()
|
||||
} else {
|
||||
format!("Импорт ProxiFyre {ordinal}")
|
||||
};
|
||||
|
||||
targets.push(Target {
|
||||
id: target_id.clone(),
|
||||
name: if single_entry {
|
||||
"Основной прокси".to_string()
|
||||
} else {
|
||||
format!("Прокси ProxiFyre {ordinal}")
|
||||
},
|
||||
kind: TargetKind::External,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: entry.host,
|
||||
port: entry.port,
|
||||
requires_component: None,
|
||||
});
|
||||
profiles.push(Profile {
|
||||
id: profile_id,
|
||||
name: profile_name,
|
||||
enabled: true,
|
||||
target_id,
|
||||
protocols: entry.protocols,
|
||||
items: entry.items,
|
||||
});
|
||||
}
|
||||
|
||||
Some(ImportedSavedState { profiles, targets })
|
||||
}
|
||||
|
||||
struct ImportedProxyEntry {
|
||||
items: Vec<ProfileItem>,
|
||||
protocols: Vec<Protocol>,
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option<ImportedProxyEntry> {
|
||||
let items = proxy
|
||||
.app_names
|
||||
.iter()
|
||||
.filter_map(|name| imported_profile_item(name))
|
||||
.collect::<Vec<_>>();
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (host, port) = parse_socks5_endpoint(&proxy.socks5_proxy_endpoint)?;
|
||||
|
||||
Some(ImportedProxyEntry {
|
||||
items,
|
||||
protocols: imported_protocols(&proxy.supported_protocols),
|
||||
host,
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
fn imported_profile_item(raw_value: &str) -> Option<ProfileItem> {
|
||||
let value = raw_value.trim().trim_matches('"');
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let looks_like_path = value.contains('\\') || value.contains('/');
|
||||
let item_type = if looks_like_path && value.to_ascii_lowercase().ends_with(".exe") {
|
||||
ProfileItemType::Exe
|
||||
} else if looks_like_path {
|
||||
ProfileItemType::Folder
|
||||
} else {
|
||||
ProfileItemType::Process
|
||||
};
|
||||
let value = match item_type {
|
||||
ProfileItemType::Process => {
|
||||
let base = value.rsplit(['\\', '/']).next().unwrap_or(value);
|
||||
if base.to_ascii_lowercase().ends_with(".exe") {
|
||||
base[..base.len() - 4].to_string()
|
||||
} else {
|
||||
base.to_string()
|
||||
}
|
||||
}
|
||||
ProfileItemType::Folder | ProfileItemType::Exe => value.to_string(),
|
||||
};
|
||||
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ProfileItem {
|
||||
recursive: matches!(item_type, ProfileItemType::Folder),
|
||||
item_type,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
fn imported_protocols(values: &[String]) -> Vec<Protocol> {
|
||||
let mut protocols = Vec::new();
|
||||
for value in values {
|
||||
let protocol = match value.trim().to_ascii_uppercase().as_str() {
|
||||
"TCP" => Protocol::Tcp,
|
||||
"UDP" => Protocol::Udp,
|
||||
_ => continue,
|
||||
};
|
||||
if !protocols.contains(&protocol) {
|
||||
protocols.push(protocol);
|
||||
}
|
||||
}
|
||||
|
||||
if protocols.is_empty() {
|
||||
vec![Protocol::Tcp, Protocol::Udp]
|
||||
} else {
|
||||
protocols
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_socks5_endpoint(endpoint: &str) -> Option<(String, u16)> {
|
||||
let endpoint = endpoint.trim();
|
||||
let endpoint = if endpoint
|
||||
.get(.."socks5://".len())
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("socks5://"))
|
||||
{
|
||||
&endpoint["socks5://".len()..]
|
||||
} else {
|
||||
endpoint
|
||||
};
|
||||
if endpoint.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(rest) = endpoint.strip_prefix('[') {
|
||||
let (host, rest) = rest.split_once(']')?;
|
||||
let port = rest.strip_prefix(':')?.parse::<u16>().ok()?;
|
||||
let host = host.trim();
|
||||
return (!host.is_empty()).then(|| (host.to_string(), port));
|
||||
}
|
||||
|
||||
let (host, port) = endpoint.rsplit_once(':')?;
|
||||
let host = host.trim();
|
||||
let port = port.trim().parse::<u16>().ok()?;
|
||||
(!host.is_empty()).then(|| (host.to_string(), port))
|
||||
}
|
||||
|
||||
fn upsert_targets(targets: &mut Vec<Target>, imported_targets: Vec<Target>) {
|
||||
for target in imported_targets {
|
||||
match targets.iter().position(|existing| existing.id == target.id) {
|
||||
Some(index) => targets[index] = target,
|
||||
None => targets.push(target),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_preview(
|
||||
input: ProfileInputDto,
|
||||
) -> Result<ResolveProfilePreviewResponse, CommandError> {
|
||||
@@ -431,3 +347,89 @@ fn validation_error(errors: Vec<ValidationError>) -> CommandError {
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{
|
||||
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn rebuilds_existing_untrusted_generated_config_from_source_of_truth() {
|
||||
let root = test_root("rebuild-generated");
|
||||
let storage = JsonStorage::new(&root);
|
||||
storage
|
||||
.write_profiles(&[test_profile()])
|
||||
.expect("write profiles");
|
||||
storage
|
||||
.write_targets(&[test_target()])
|
||||
.expect("write targets");
|
||||
let generated = storage.paths().generated_dir.join(PROXIFYRE_OUTPUT_FILE);
|
||||
fs::create_dir_all(generated.parent().expect("generated parent"))
|
||||
.expect("create generated parent");
|
||||
fs::write(&generated, b"untrusted legacy bytes").expect("write weak legacy config");
|
||||
fs::write(safe_fs::backup_path(&generated), b"untrusted backup")
|
||||
.expect("write weak legacy backup");
|
||||
|
||||
ensure_proxifyre_generated_config_ready(&storage).expect("rebuild generated config");
|
||||
|
||||
let contents = fs::read_to_string(&generated).expect("read rebuilt config");
|
||||
assert!(contents.contains("Discord.exe"));
|
||||
assert!(contents.contains("127.0.0.1:1080"));
|
||||
assert!(!contents.contains("untrusted legacy bytes"));
|
||||
assert!(!safe_fs::backup_path(&generated).exists());
|
||||
#[cfg(windows)]
|
||||
safe_fs::verify_path_protected_for_owner_admin_system(&generated)
|
||||
.expect("rebuilt config keeps the restricted ACL");
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_generated_config_still_requires_explicit_apply() {
|
||||
let root = test_root("missing-generated");
|
||||
let storage = JsonStorage::new(&root);
|
||||
|
||||
let error = ensure_proxifyre_generated_config_ready(&storage)
|
||||
.expect_err("missing config must not be created implicitly");
|
||||
|
||||
assert_eq!(error.code, "generated_config_missing");
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
fn test_profile() -> Profile {
|
||||
Profile {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
enabled: true,
|
||||
target_id: "external".to_string(),
|
||||
protocols: vec![Protocol::Tcp, Protocol::Udp],
|
||||
items: vec![ProfileItem {
|
||||
item_type: ProfileItemType::Process,
|
||||
value: "Discord.exe".to_string(),
|
||||
recursive: false,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn test_target() -> Target {
|
||||
Target {
|
||||
id: "external".to_string(),
|
||||
name: "External".to_string(),
|
||||
kind: TargetKind::External,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 1080,
|
||||
requires_component: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_root(label: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("proxywarden-{label}-{}", uuid::Uuid::new_v4()))
|
||||
}
|
||||
|
||||
fn cleanup(root: &Path) {
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn temp_script_path(prefix: &str) -> PathBuf {
|
||||
env::temp_dir().join(unique_file_name(prefix, "ps1"))
|
||||
}
|
||||
|
||||
pub fn artifact_path(artifact_dir: &Path, prefix: &str, extension: &str) -> PathBuf {
|
||||
artifact_dir.join(unique_file_name(prefix, extension))
|
||||
}
|
||||
|
||||
fn unique_file_name(prefix: &str, extension: &str) -> String {
|
||||
let extension = extension.trim_start_matches('.');
|
||||
format!(
|
||||
"{prefix}-{}.{}",
|
||||
uuid::Uuid::new_v4().hyphenated(),
|
||||
extension
|
||||
)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
use crate::models::ComponentId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum HelperAction {
|
||||
#[serde(rename = "install-control-app")]
|
||||
InstallControlApp,
|
||||
#[serde(rename = "install-proxyfier")]
|
||||
InstallProxyfier,
|
||||
#[serde(rename = "install-singbox")]
|
||||
InstallSingbox,
|
||||
#[serde(rename = "proxyfier.apply")]
|
||||
ProxyfierApply,
|
||||
#[serde(rename = "service.status")]
|
||||
ServiceStatus,
|
||||
#[serde(rename = "service.start")]
|
||||
ServiceStart,
|
||||
#[serde(rename = "service.stop")]
|
||||
ServiceStop,
|
||||
#[serde(rename = "service.restart")]
|
||||
ServiceRestart,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HelperRequest {
|
||||
pub action: HelperAction,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub component: Option<ComponentId>,
|
||||
#[serde(default)]
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HelperResponse {
|
||||
pub success: bool,
|
||||
pub action: HelperAction,
|
||||
pub changed: bool,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub details: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HelperCommandSpec {
|
||||
pub program: PathBuf,
|
||||
pub args: Vec<String>,
|
||||
pub stdin: String,
|
||||
pub requires_elevation: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HelperCommandOutput {
|
||||
pub status_code: i32,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HelperError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl HelperError {
|
||||
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait HelperCommandRunner {
|
||||
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructuredHelper<R> {
|
||||
helper_program: PathBuf,
|
||||
runner: R,
|
||||
}
|
||||
|
||||
impl<R> StructuredHelper<R>
|
||||
where
|
||||
R: HelperCommandRunner,
|
||||
{
|
||||
pub fn new(helper_program: impl Into<PathBuf>, runner: R) -> Self {
|
||||
Self {
|
||||
helper_program: helper_program.into(),
|
||||
runner,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runner(&self) -> &R {
|
||||
&self.runner
|
||||
}
|
||||
|
||||
pub fn execute(&self, request: &HelperRequest) -> Result<HelperResponse, HelperError> {
|
||||
let stdin = serde_json::to_string(request)
|
||||
.map_err(|error| HelperError::new("helper_request_encode", error.to_string()))?;
|
||||
let spec = HelperCommandSpec {
|
||||
program: self.helper_program.clone(),
|
||||
args: vec!["--json".to_string()],
|
||||
stdin,
|
||||
requires_elevation: helper_action_requires_elevation(&request.action),
|
||||
};
|
||||
let output = self.runner.run(&spec)?;
|
||||
|
||||
if output.status_code != 0 {
|
||||
return Err(HelperError::new(
|
||||
"helper_exit",
|
||||
format!(
|
||||
"Помощник завершился с кодом {}: {}",
|
||||
output.status_code, output.stderr
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
parse_helper_response(&output.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_helper_response(stdout: &str) -> Result<HelperResponse, HelperError> {
|
||||
serde_json::from_str(stdout).map_err(|error| {
|
||||
HelperError::new(
|
||||
"helper_response_decode",
|
||||
format!("Помощник вернул не JSON или некорректный JSON: {error}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn install_request(component: ComponentId) -> HelperRequest {
|
||||
let action = match component {
|
||||
ComponentId::ControlApp => HelperAction::InstallControlApp,
|
||||
ComponentId::Proxyfier => HelperAction::InstallProxyfier,
|
||||
ComponentId::Singbox => HelperAction::InstallSingbox,
|
||||
};
|
||||
|
||||
HelperRequest {
|
||||
action,
|
||||
component: Some(component),
|
||||
payload: json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn service_request(component: ComponentId, action: HelperAction) -> HelperRequest {
|
||||
HelperRequest {
|
||||
action,
|
||||
component: Some(component),
|
||||
payload: json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn proxifyre_apply_request(
|
||||
config_path: impl AsRef<Path>,
|
||||
service_name: impl Into<String>,
|
||||
) -> HelperRequest {
|
||||
HelperRequest {
|
||||
action: HelperAction::ProxyfierApply,
|
||||
component: Some(ComponentId::Proxyfier),
|
||||
payload: json!({
|
||||
"configPath": config_path.as_ref().display().to_string(),
|
||||
"serviceName": service_name.into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn helper_action_requires_elevation(action: &HelperAction) -> bool {
|
||||
matches!(
|
||||
action,
|
||||
HelperAction::InstallControlApp
|
||||
| HelperAction::InstallProxyfier
|
||||
| HelperAction::InstallSingbox
|
||||
| HelperAction::ProxyfierApply
|
||||
| HelperAction::ServiceStart
|
||||
| HelperAction::ServiceStop
|
||||
| HelperAction::ServiceRestart
|
||||
)
|
||||
}
|
||||
+125
-6
@@ -4,19 +4,25 @@ pub mod apply_flow;
|
||||
pub mod clock;
|
||||
pub mod command_dto;
|
||||
pub mod commands;
|
||||
pub mod component_catalog;
|
||||
pub mod component_cutover;
|
||||
pub mod component_detection;
|
||||
pub mod component_inventory;
|
||||
pub mod component_packages;
|
||||
pub mod component_status;
|
||||
pub mod configuration_transaction;
|
||||
pub mod configuration_use_case;
|
||||
pub mod elevated_scripts;
|
||||
pub mod helper;
|
||||
pub mod migration;
|
||||
pub mod models;
|
||||
mod powershell;
|
||||
pub mod nsis_runtime;
|
||||
pub mod privileged_jobs;
|
||||
pub mod privileged_runtime;
|
||||
pub mod process;
|
||||
pub mod proxifyre_ownership;
|
||||
pub mod proxifyre_runtime;
|
||||
pub mod proxifyre_scripts;
|
||||
pub mod proxy_apply;
|
||||
pub mod proxy_probe;
|
||||
pub mod route_state;
|
||||
pub mod safe_fs;
|
||||
pub mod singbox_config;
|
||||
pub mod singbox_runtime;
|
||||
@@ -26,6 +32,113 @@ pub mod storage;
|
||||
pub mod subscription;
|
||||
pub mod validation;
|
||||
|
||||
pub enum EarlyProcessMode {
|
||||
NotHandled,
|
||||
Exit(i32),
|
||||
}
|
||||
|
||||
/// Handles the fixed elevated-helper mode before Tauri or a webview is initialized.
|
||||
/// Ordinary startup returns before constructing any component/network runtime.
|
||||
pub fn run_early_process_mode<I>(arguments: I) -> EarlyProcessMode
|
||||
where
|
||||
I: IntoIterator<Item = std::ffi::OsString>,
|
||||
{
|
||||
let arguments = arguments.into_iter().collect::<Vec<_>>();
|
||||
match nsis_runtime::parse_nsis_early_arguments(arguments.clone()) {
|
||||
Ok(Some(mode)) => {
|
||||
return EarlyProcessMode::Exit(nsis_runtime::nsis_process_exit_code(
|
||||
nsis_runtime::run_system_nsis_mode(mode),
|
||||
));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(_) => return EarlyProcessMode::Exit(nsis_runtime::NSIS_EXIT_USAGE),
|
||||
}
|
||||
let job_id = match privileged_jobs::parse_early_helper_arguments(arguments) {
|
||||
Ok(Some(job_id)) => job_id,
|
||||
Ok(None) => return EarlyProcessMode::NotHandled,
|
||||
Err(_) => return EarlyProcessMode::Exit(64),
|
||||
};
|
||||
let runtime = match privileged_runtime::SystemPrivilegedRuntime::production() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(_) => return EarlyProcessMode::Exit(2),
|
||||
};
|
||||
run_recognized_early_job(&job_id, &runtime, &runtime)
|
||||
}
|
||||
|
||||
pub fn run_early_process_mode_with_runtime<I>(
|
||||
arguments: I,
|
||||
resolver: &dyn privileged_jobs::PrivilegedPlanResolver,
|
||||
runner: &dyn privileged_jobs::PrivilegedActionRunner,
|
||||
) -> EarlyProcessMode
|
||||
where
|
||||
I: IntoIterator<Item = std::ffi::OsString>,
|
||||
{
|
||||
let job_id = match privileged_jobs::parse_early_helper_arguments(arguments) {
|
||||
Ok(Some(job_id)) => job_id,
|
||||
Ok(None) => return EarlyProcessMode::NotHandled,
|
||||
Err(_) => return EarlyProcessMode::Exit(64),
|
||||
};
|
||||
run_recognized_early_job(&job_id, resolver, runner)
|
||||
}
|
||||
|
||||
fn run_recognized_early_job(
|
||||
job_id: &privileged_jobs::PrivilegedJobId,
|
||||
resolver: &dyn privileged_jobs::PrivilegedPlanResolver,
|
||||
runner: &dyn privileged_jobs::PrivilegedActionRunner,
|
||||
) -> EarlyProcessMode {
|
||||
let store = match privileged_jobs::PrivilegedJobStore::production() {
|
||||
Ok(store) => store,
|
||||
Err(_) => return EarlyProcessMode::Exit(2),
|
||||
};
|
||||
let result = privileged_jobs::execute_privileged_job(
|
||||
&store,
|
||||
job_id,
|
||||
&privileged_jobs::SystemEpochClock,
|
||||
&privileged_jobs::NativeElevationProbe,
|
||||
resolver,
|
||||
runner,
|
||||
);
|
||||
match result {
|
||||
Ok(result) if result.status == privileged_jobs::PrivilegedJobStatus::Succeeded => {
|
||||
EarlyProcessMode::Exit(0)
|
||||
}
|
||||
Ok(_) => EarlyProcessMode::Exit(1),
|
||||
Err(_) => EarlyProcessMode::Exit(2),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod early_process_mode_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ordinary_startup_returns_before_constructing_privileged_runtime() {
|
||||
assert!(matches!(
|
||||
run_early_process_mode(Vec::<std::ffi::OsString>::new()),
|
||||
EarlyProcessMode::NotHandled
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_helper_arguments_fail_before_runtime_construction() {
|
||||
assert!(matches!(
|
||||
run_early_process_mode([std::ffi::OsString::from("--elevated-helper")]),
|
||||
EarlyProcessMode::Exit(64)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_nsis_arguments_fail_before_runtime_construction() {
|
||||
assert!(matches!(
|
||||
run_early_process_mode([
|
||||
std::ffi::OsString::from(nsis_runtime::NSIS_VERIFY_UPGRADE_ARGUMENT),
|
||||
std::ffi::OsString::from("unexpected"),
|
||||
]),
|
||||
EarlyProcessMode::Exit(nsis_runtime::NSIS_EXIT_USAGE)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub mod adapters {
|
||||
pub mod proxifyre;
|
||||
pub mod proxy_router;
|
||||
@@ -42,10 +155,16 @@ pub fn run() {
|
||||
commands::get_saved_state,
|
||||
commands::get_components,
|
||||
commands::get_proxifyre_setup_status,
|
||||
commands::get_proxifyre_setup_progress,
|
||||
commands::get_singbox_status,
|
||||
commands::get_singbox_setup_status,
|
||||
commands::save_singbox_subscription,
|
||||
commands::get_component_package_statuses,
|
||||
commands::get_component_cutover_statuses,
|
||||
commands::check_component_update,
|
||||
commands::download_component_update,
|
||||
commands::update_component,
|
||||
commands::cutover_component,
|
||||
commands::confirm_component_route_smoke,
|
||||
commands::cleanup_component_quarantine,
|
||||
commands::fetch_singbox_subscription,
|
||||
commands::forget_singbox_subscription,
|
||||
commands::select_singbox_server,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
match proxywarden_lib::run_early_process_mode(std::env::args_os().skip(1)) {
|
||||
proxywarden_lib::EarlyProcessMode::NotHandled => {}
|
||||
proxywarden_lib::EarlyProcessMode::Exit(code) => std::process::exit(code),
|
||||
}
|
||||
proxywarden_lib::run();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+26
-23
@@ -1,6 +1,5 @@
|
||||
use percent_encoding::percent_decode_str;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
|
||||
@@ -160,12 +159,36 @@ pub struct LocalSingBoxConfig {
|
||||
pub listen_port: u16,
|
||||
#[serde(default = "default_local_singbox_service_name")]
|
||||
pub service_name: String,
|
||||
#[serde(default = "default_local_singbox_install_root")]
|
||||
#[serde(default = "default_local_singbox_install_root", skip_serializing)]
|
||||
pub install_root: String,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StorageMigrationOutcome {
|
||||
InitializedEmpty,
|
||||
AdoptedWithoutLegacyImport,
|
||||
ImportedLegacyConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct StorageMeta {
|
||||
pub storage_schema_version: u32,
|
||||
pub outcome: StorageMigrationOutcome,
|
||||
pub migration_id: String,
|
||||
pub completed_at_epoch_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ComponentLayoutMeta {
|
||||
pub component_layout_version: u32,
|
||||
pub verified_at_epoch_seconds: u64,
|
||||
}
|
||||
|
||||
impl LocalSingBoxConfig {
|
||||
pub fn subscription_display_url(&self) -> Option<String> {
|
||||
self.subscription_url
|
||||
@@ -213,27 +236,7 @@ impl SubscriptionCache {
|
||||
server.ensure_id();
|
||||
}
|
||||
|
||||
let Some(outbounds) = self
|
||||
.config
|
||||
.get_mut("outbounds")
|
||||
.and_then(Value::as_array_mut)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
for outbound in outbounds {
|
||||
let Some(decoded_tag) = outbound
|
||||
.get("tag")
|
||||
.and_then(Value::as_str)
|
||||
.map(decode_percent_encoded_utf8)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(object) = outbound.as_object_mut() {
|
||||
object.insert("tag".to_string(), Value::String(decoded_tag));
|
||||
}
|
||||
}
|
||||
// Outbound bytes define stable identity. Decode display labels only.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,993 @@
|
||||
use super::*;
|
||||
use crate::privileged_jobs::{
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests, write_nsis_interrupted_retirement_for_tests,
|
||||
write_nsis_partial_reboot_staging_for_tests, write_nsis_partial_retirement_staging_for_tests,
|
||||
write_nsis_terminal_pair_for_tests, NsisPrivilegedLifecycleGuard, NsisPrivilegedLifecycleState,
|
||||
PrivilegedJobsError,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeHost {
|
||||
elevated: bool,
|
||||
calls: RefCell<Vec<&'static str>>,
|
||||
verify_executable: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
lifecycle_state: VecDeque<Result<NsisLifecycleState, NsisRuntimeError>>,
|
||||
cutover: VecDeque<Result<NsisCutoverState, NsisRuntimeError>>,
|
||||
proxifyre: VecDeque<Result<NsisComponentState, NsisRuntimeError>>,
|
||||
singbox: VecDeque<Result<NsisComponentState, NsisRuntimeError>>,
|
||||
transients: VecDeque<Result<NsisTransientState, NsisRuntimeError>>,
|
||||
acquire: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
retire: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
stop_proxifyre: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
stop_singbox: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
retry_singbox_cleanup: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
uninstall_proxifyre: VecDeque<Result<bool, NsisRuntimeError>>,
|
||||
uninstall_singbox: VecDeque<Result<bool, NsisRuntimeError>>,
|
||||
reboot_under_lock: VecDeque<Result<bool, NsisRuntimeError>>,
|
||||
mark_reboot: VecDeque<Result<bool, NsisRuntimeError>>,
|
||||
clear_reboot: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
cleanup: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
}
|
||||
|
||||
impl FakeHost {
|
||||
fn ready(proxifyre: NsisComponentState, singbox: NsisComponentState) -> Self {
|
||||
Self {
|
||||
elevated: true,
|
||||
verify_executable: VecDeque::from([Ok(()), Ok(())]),
|
||||
lifecycle_state: VecDeque::from([Ok(NsisLifecycleState {
|
||||
retirement_pending: false,
|
||||
reboot_required: false,
|
||||
})]),
|
||||
cutover: VecDeque::from([Ok(NsisCutoverState::Absent), Ok(NsisCutoverState::Absent)]),
|
||||
proxifyre: VecDeque::from([Ok(proxifyre), Ok(proxifyre)]),
|
||||
singbox: VecDeque::from([Ok(singbox), Ok(singbox)]),
|
||||
transients: VecDeque::from([
|
||||
Ok(NsisTransientState {
|
||||
singbox_cleanup_pending: false,
|
||||
package_staging_pending: false,
|
||||
}),
|
||||
Ok(NsisTransientState {
|
||||
singbox_cleanup_pending: false,
|
||||
package_staging_pending: false,
|
||||
}),
|
||||
]),
|
||||
acquire: VecDeque::from([Ok(())]),
|
||||
retire: VecDeque::from([Ok(())]),
|
||||
stop_proxifyre: VecDeque::from([Ok(())]),
|
||||
stop_singbox: VecDeque::from([Ok(())]),
|
||||
retry_singbox_cleanup: VecDeque::from([Ok(())]),
|
||||
uninstall_proxifyre: VecDeque::from([Ok(false)]),
|
||||
uninstall_singbox: VecDeque::from([Ok(false)]),
|
||||
reboot_under_lock: VecDeque::from([Ok(false)]),
|
||||
mark_reboot: VecDeque::from([Ok(true), Ok(true)]),
|
||||
clear_reboot: VecDeque::from([Ok(()), Ok(())]),
|
||||
cleanup: VecDeque::from([Ok(())]),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&self, name: &'static str) {
|
||||
self.calls.borrow_mut().push(name);
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<&'static str> {
|
||||
self.calls.borrow().clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn next<T>(queue: &mut VecDeque<Result<T, NsisRuntimeError>>) -> Result<T, NsisRuntimeError> {
|
||||
queue.pop_front().expect("fake call was not planned")
|
||||
}
|
||||
|
||||
impl NsisRuntimeHost for FakeHost {
|
||||
fn is_elevated(&self) -> bool {
|
||||
self.call("elevated");
|
||||
self.elevated
|
||||
}
|
||||
|
||||
fn verify_current_executable(&mut self) -> Result<(), NsisRuntimeError> {
|
||||
self.call("verify-exe");
|
||||
next(&mut self.verify_executable)
|
||||
}
|
||||
|
||||
fn verify_lifecycle_state(&mut self) -> Result<NsisLifecycleState, NsisRuntimeError> {
|
||||
self.call("lifecycle-idle");
|
||||
next(&mut self.lifecycle_state)
|
||||
}
|
||||
|
||||
fn acquire_lifecycle_lock(&mut self) -> Result<(), NsisRuntimeError> {
|
||||
self.call("acquire");
|
||||
next(&mut self.acquire)
|
||||
}
|
||||
|
||||
fn inspect_cutover(&mut self) -> Result<NsisCutoverState, NsisRuntimeError> {
|
||||
self.call("cutover");
|
||||
next(&mut self.cutover)
|
||||
}
|
||||
|
||||
fn preflight_proxifyre(&mut self) -> Result<NsisComponentState, NsisRuntimeError> {
|
||||
self.call("proxifyre");
|
||||
next(&mut self.proxifyre)
|
||||
}
|
||||
|
||||
fn preflight_singbox(&mut self) -> Result<NsisComponentState, NsisRuntimeError> {
|
||||
self.call("singbox");
|
||||
next(&mut self.singbox)
|
||||
}
|
||||
|
||||
fn verify_transient_layout(&mut self) -> Result<NsisTransientState, NsisRuntimeError> {
|
||||
self.call("transients");
|
||||
next(&mut self.transients)
|
||||
}
|
||||
|
||||
fn retire_cutover(
|
||||
&mut self,
|
||||
_expected: &CutoverTerminalRetirementExpectation,
|
||||
) -> Result<(), NsisRuntimeError> {
|
||||
self.call("retire-cutover");
|
||||
next(&mut self.retire)
|
||||
}
|
||||
|
||||
fn stop_proxifyre(&mut self) -> Result<(), NsisRuntimeError> {
|
||||
self.call("stop-proxifyre");
|
||||
next(&mut self.stop_proxifyre)
|
||||
}
|
||||
|
||||
fn stop_singbox(&mut self) -> Result<(), NsisRuntimeError> {
|
||||
self.call("stop-singbox");
|
||||
next(&mut self.stop_singbox)
|
||||
}
|
||||
|
||||
fn retry_singbox_cleanup(&mut self) -> Result<(), NsisRuntimeError> {
|
||||
self.call("retry-singbox-cleanup");
|
||||
next(&mut self.retry_singbox_cleanup)
|
||||
}
|
||||
|
||||
fn uninstall_proxifyre(&mut self) -> Result<bool, NsisRuntimeError> {
|
||||
self.call("uninstall-proxifyre");
|
||||
next(&mut self.uninstall_proxifyre)
|
||||
}
|
||||
|
||||
fn uninstall_singbox(&mut self) -> Result<bool, NsisRuntimeError> {
|
||||
self.call("uninstall-singbox");
|
||||
next(&mut self.uninstall_singbox)
|
||||
}
|
||||
|
||||
fn reboot_required_under_lock(&mut self) -> Result<bool, NsisRuntimeError> {
|
||||
self.call("reboot-under-lock");
|
||||
next(&mut self.reboot_under_lock)
|
||||
}
|
||||
|
||||
fn mark_reboot_required(&mut self) -> Result<bool, NsisRuntimeError> {
|
||||
self.call("mark-reboot");
|
||||
next(&mut self.mark_reboot)
|
||||
}
|
||||
|
||||
fn clear_reboot_required(&mut self) -> Result<(), NsisRuntimeError> {
|
||||
self.call("clear-reboot");
|
||||
next(&mut self.clear_reboot)
|
||||
}
|
||||
|
||||
fn cleanup_transients(&mut self) -> Result<(), NsisRuntimeError> {
|
||||
self.call("cleanup");
|
||||
next(&mut self.cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_only_exact_single_nsis_flags() {
|
||||
assert_eq!(
|
||||
parse_nsis_early_arguments([OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT)])
|
||||
.expect("verify flag"),
|
||||
Some(NsisEarlyMode::VerifyUpgrade)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_nsis_early_arguments([OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT)])
|
||||
.expect("uninstall flag"),
|
||||
Some(NsisEarlyMode::UninstallManaged)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_nsis_early_arguments(Vec::<OsString>::new()).expect("ordinary launch"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_nsis_early_arguments([OsString::from("--elevated-helper")])
|
||||
.expect("other early mode"),
|
||||
None
|
||||
);
|
||||
|
||||
for invalid in [
|
||||
vec![OsString::from(format!("{}{}", "--nsis-", "unknown"))],
|
||||
vec![
|
||||
OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT),
|
||||
OsString::from("extra"),
|
||||
],
|
||||
vec![
|
||||
OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT),
|
||||
OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT),
|
||||
],
|
||||
vec![
|
||||
OsString::from("ordinary"),
|
||||
OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT),
|
||||
],
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_nsis_early_arguments(invalid),
|
||||
Err(NsisRuntimeError::InvalidArguments)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_staging_recovery_accepts_only_fixed_component_uuid_and_entry_shapes() {
|
||||
let uuid = "6f21e8c7-b63f-4c4c-9aa7-df96a7d0049d";
|
||||
assert_eq!(
|
||||
parse_package_staging_directory_name(&format!(".package-proxifyre-{uuid}")),
|
||||
Ok(PackageStagingComponent::Proxifyre)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_package_staging_directory_name(&format!(".package-windows-packet-filter-{uuid}")),
|
||||
Ok(PackageStagingComponent::WindowsPacketFilter)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_package_staging_directory_name(&format!(".package-sing-box-{uuid}")),
|
||||
Ok(PackageStagingComponent::SingBox)
|
||||
);
|
||||
for invalid in [
|
||||
".package-proxifyre-not-a-uuid",
|
||||
".package-vc-runtime-6f21e8c7-b63f-4c4c-9aa7-df96a7d0049d",
|
||||
".package-proxifyre-6F21E8C7-B63F-4C4C-9AA7-DF96A7D0049D",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_package_staging_directory_name(invalid),
|
||||
Err(NsisRuntimeError::TransientUnsafe)
|
||||
);
|
||||
}
|
||||
assert!(package_staging_entry_role(
|
||||
PackageStagingComponent::Proxifyre,
|
||||
"ProxiFyre-v2.5.1-x64-signed.zip"
|
||||
)
|
||||
.is_some());
|
||||
assert!(package_staging_entry_role(
|
||||
PackageStagingComponent::WindowsPacketFilter,
|
||||
"Windows.Packet.Filter.3.7.0.1.x64.msi"
|
||||
)
|
||||
.is_some());
|
||||
assert!(package_staging_entry_role(
|
||||
PackageStagingComponent::SingBox,
|
||||
"sing-box-1.14.0-windows-amd64.zip"
|
||||
)
|
||||
.is_some());
|
||||
assert!(package_staging_entry_role(PackageStagingComponent::SingBox, "foreign.zip").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elevation_failure_returns_before_runtime_or_filesystem_checks() {
|
||||
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
|
||||
host.elevated = false;
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Err(NsisRuntimeError::NotElevated)
|
||||
);
|
||||
assert_eq!(host.calls(), ["elevated"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_is_strictly_read_only() {
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedRunning,
|
||||
NsisComponentState::ManagedStopped,
|
||||
);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
|
||||
Ok(NsisRunOutcome::Success)
|
||||
);
|
||||
assert_eq!(
|
||||
host.calls(),
|
||||
[
|
||||
"elevated",
|
||||
"verify-exe",
|
||||
"lifecycle-idle",
|
||||
"cutover",
|
||||
"proxifyre",
|
||||
"singbox",
|
||||
"transients",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_blocks_terminal_cutover_without_retiring_it() {
|
||||
let expected = CutoverTerminalRetirementExpectation::EmptyInfrastructure;
|
||||
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
|
||||
host.cutover = VecDeque::from([Ok(NsisCutoverState::Retirable(expected))]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
|
||||
Err(NsisRuntimeError::CutoverBlocked)
|
||||
);
|
||||
assert!(!host.calls().contains(&"retire-cutover"));
|
||||
assert!(!host.calls().contains(&"acquire"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_blocks_pending_tombstone_without_retrying_it() {
|
||||
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
|
||||
host.transients = VecDeque::from([Ok(NsisTransientState {
|
||||
singbox_cleanup_pending: true,
|
||||
package_staging_pending: false,
|
||||
})]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
|
||||
Err(NsisRuntimeError::TransientUnsafe)
|
||||
);
|
||||
assert!(!host.calls().contains(&"retry-singbox-cleanup"));
|
||||
assert!(!host.calls().contains(&"acquire"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_blocks_interrupted_job_store_retirement_without_mutating_it() {
|
||||
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
|
||||
host.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState {
|
||||
retirement_pending: true,
|
||||
reboot_required: false,
|
||||
})]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
|
||||
Err(NsisRuntimeError::TransientUnsafe)
|
||||
);
|
||||
assert!(!host.calls().contains(&"acquire"));
|
||||
assert!(!host.calls().contains(&"cleanup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_uninstall_resumes_interrupted_job_store_retirement() {
|
||||
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
|
||||
host.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState {
|
||||
retirement_pending: true,
|
||||
reboot_required: false,
|
||||
})]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Ok(NsisRunOutcome::Success)
|
||||
);
|
||||
assert!(host.calls().contains(&"acquire"));
|
||||
assert!(host.calls().contains(&"cleanup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_blocks_stale_package_staging_but_uninstall_retires_it() {
|
||||
let pending = NsisTransientState {
|
||||
singbox_cleanup_pending: false,
|
||||
package_staging_pending: true,
|
||||
};
|
||||
let mut update = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
|
||||
update.transients = VecDeque::from([Ok(pending)]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut update, NsisEarlyMode::VerifyUpgrade),
|
||||
Err(NsisRuntimeError::TransientUnsafe)
|
||||
);
|
||||
assert!(!update.calls().contains(&"cleanup"));
|
||||
|
||||
let mut uninstall = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
|
||||
uninstall.transients = VecDeque::from([Ok(pending), Ok(pending)]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut uninstall, NsisEarlyMode::UninstallManaged),
|
||||
Ok(NsisRunOutcome::Success)
|
||||
);
|
||||
assert!(uninstall.calls().contains(&"cleanup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_first_component_still_preflights_second_and_causes_zero_mutation() {
|
||||
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
|
||||
host.proxifyre = VecDeque::from([Err(NsisRuntimeError::ComponentUnsafe)]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Err(NsisRuntimeError::ComponentUnsafe)
|
||||
);
|
||||
assert!(host.calls().contains(&"singbox"));
|
||||
assert!(host.calls().contains(&"transients"));
|
||||
assert!(!host.calls().contains(&"acquire"));
|
||||
assert!(!host.calls().contains(&"stop-proxifyre"));
|
||||
assert!(!host.calls().contains(&"uninstall-singbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn busy_lifecycle_still_runs_full_read_only_preflight_and_never_mutates() {
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedRunning,
|
||||
NsisComponentState::ManagedStopped,
|
||||
);
|
||||
host.lifecycle_state = VecDeque::from([Err(NsisRuntimeError::LifecycleBusy)]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Err(NsisRuntimeError::LifecycleBusy)
|
||||
);
|
||||
assert!(host.calls().contains(&"proxifyre"));
|
||||
assert!(host.calls().contains(&"singbox"));
|
||||
assert!(host.calls().contains(&"transients"));
|
||||
assert!(!host.calls().contains(&"acquire"));
|
||||
assert!(!host.calls().contains(&"stop-proxifyre"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_stops_both_running_services_before_uninstalling_either() {
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedRunning,
|
||||
NsisComponentState::ManagedRunning,
|
||||
);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Ok(NsisRunOutcome::Success)
|
||||
);
|
||||
let calls = host.calls();
|
||||
let stop_prox = calls
|
||||
.iter()
|
||||
.position(|call| *call == "stop-proxifyre")
|
||||
.unwrap();
|
||||
let stop_sing = calls
|
||||
.iter()
|
||||
.position(|call| *call == "stop-singbox")
|
||||
.unwrap();
|
||||
let uninstall_prox = calls
|
||||
.iter()
|
||||
.position(|call| *call == "uninstall-proxifyre")
|
||||
.unwrap();
|
||||
let uninstall_sing = calls
|
||||
.iter()
|
||||
.position(|call| *call == "uninstall-singbox")
|
||||
.unwrap();
|
||||
let cleanup = calls.iter().position(|call| *call == "cleanup").unwrap();
|
||||
assert!(stop_prox < uninstall_prox);
|
||||
assert!(stop_sing < uninstall_prox);
|
||||
assert!(uninstall_prox < uninstall_sing);
|
||||
assert!(uninstall_sing < cleanup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_components_are_noops_but_owned_transients_are_retired() {
|
||||
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Ok(NsisRunOutcome::Success)
|
||||
);
|
||||
let calls = host.calls();
|
||||
assert!(!calls.contains(&"stop-proxifyre"));
|
||||
assert!(!calls.contains(&"stop-singbox"));
|
||||
assert!(!calls.contains(&"uninstall-proxifyre"));
|
||||
assert!(!calls.contains(&"uninstall-singbox"));
|
||||
assert!(calls.contains(&"cleanup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_drift_after_lock_causes_zero_component_mutation() {
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedRunning,
|
||||
NsisComponentState::Missing,
|
||||
);
|
||||
host.proxifyre = VecDeque::from([
|
||||
Ok(NsisComponentState::ManagedRunning),
|
||||
Ok(NsisComponentState::ManagedStopped),
|
||||
]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Err(NsisRuntimeError::StateChanged)
|
||||
);
|
||||
assert!(host.calls().contains(&"acquire"));
|
||||
assert!(!host.calls().contains(&"stop-proxifyre"));
|
||||
assert!(!host.calls().contains(&"uninstall-proxifyre"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_cutover_is_exactly_retired_before_component_mutation() {
|
||||
let expected = CutoverTerminalRetirementExpectation::EmptyInfrastructure;
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedRunning,
|
||||
NsisComponentState::Missing,
|
||||
);
|
||||
host.cutover = VecDeque::from([
|
||||
Ok(NsisCutoverState::Retirable(expected.clone())),
|
||||
Ok(NsisCutoverState::Retirable(expected)),
|
||||
Ok(NsisCutoverState::Absent),
|
||||
]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Ok(NsisRunOutcome::Success)
|
||||
);
|
||||
let calls = host.calls();
|
||||
let retire = calls
|
||||
.iter()
|
||||
.position(|call| *call == "retire-cutover")
|
||||
.unwrap();
|
||||
let stop = calls
|
||||
.iter()
|
||||
.position(|call| *call == "stop-proxifyre")
|
||||
.unwrap();
|
||||
assert!(retire < stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_failure_prevents_all_uninstall_and_terminal_cleanup() {
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedRunning,
|
||||
NsisComponentState::ManagedRunning,
|
||||
);
|
||||
host.stop_proxifyre = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Err(NsisRuntimeError::OperationFailed)
|
||||
);
|
||||
let calls = host.calls();
|
||||
assert!(!calls.contains(&"stop-singbox"));
|
||||
assert!(!calls.contains(&"uninstall-proxifyre"));
|
||||
assert!(!calls.contains(&"cleanup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_reboot_requirement_maps_to_msi_3010() {
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedStopped,
|
||||
NsisComponentState::ManagedStopped,
|
||||
);
|
||||
host.uninstall_proxifyre = VecDeque::from([Ok(true)]);
|
||||
assert_eq!(
|
||||
nsis_process_exit_code(run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged)),
|
||||
NSIS_EXIT_REBOOT_REQUIRED
|
||||
);
|
||||
assert_eq!(
|
||||
nsis_process_exit_code(Err(NsisRuntimeError::InvalidArguments)),
|
||||
NSIS_EXIT_USAGE
|
||||
);
|
||||
let calls = host.calls();
|
||||
assert!(
|
||||
calls.iter().position(|call| *call == "mark-reboot")
|
||||
< calls.iter().position(|call| *call == "uninstall-proxifyre")
|
||||
);
|
||||
assert!(!calls.contains(&"clear-reboot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reboot_intent_is_write_ahead_and_cleared_only_after_proven_no_reboot() {
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedStopped,
|
||||
NsisComponentState::Missing,
|
||||
);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Ok(NsisRunOutcome::Success)
|
||||
);
|
||||
let calls = host.calls();
|
||||
let mark = calls
|
||||
.iter()
|
||||
.position(|call| *call == "mark-reboot")
|
||||
.unwrap();
|
||||
let uninstall = calls
|
||||
.iter()
|
||||
.position(|call| *call == "uninstall-proxifyre")
|
||||
.unwrap();
|
||||
let clear = calls
|
||||
.iter()
|
||||
.position(|call| *call == "clear-reboot")
|
||||
.unwrap();
|
||||
assert!(mark < uninstall);
|
||||
assert!(uninstall < clear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_uninstall_keeps_write_ahead_reboot_intent_for_retry() {
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedStopped,
|
||||
NsisComponentState::Missing,
|
||||
);
|
||||
host.uninstall_proxifyre = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Err(NsisRuntimeError::OperationFailed)
|
||||
);
|
||||
let calls = host.calls();
|
||||
assert!(
|
||||
calls.iter().position(|call| *call == "mark-reboot")
|
||||
< calls.iter().position(|call| *call == "uninstall-proxifyre")
|
||||
);
|
||||
assert!(!calls.contains(&"clear-reboot"));
|
||||
assert!(!calls.contains(&"cleanup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intent_published_while_waiting_for_lock_is_never_adopted_or_cleared() {
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedStopped,
|
||||
NsisComponentState::Missing,
|
||||
);
|
||||
// The read-only pre-lock probe saw no marker, but authoritative observation
|
||||
// under the acquired lock sees the earlier owner's durable fact.
|
||||
host.reboot_under_lock = VecDeque::from([Ok(true)]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Ok(NsisRunOutcome::RebootRequired)
|
||||
);
|
||||
let calls = host.calls();
|
||||
assert!(calls.contains(&"uninstall-proxifyre"));
|
||||
assert!(!calls.contains(&"mark-reboot"));
|
||||
assert!(!calls.contains(&"clear-reboot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_published_after_probe_is_delivered_even_when_components_are_missing() {
|
||||
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
|
||||
host.reboot_under_lock = VecDeque::from([Ok(true)]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Ok(NsisRunOutcome::RebootRequired)
|
||||
);
|
||||
let calls = host.calls();
|
||||
assert!(calls.contains(&"reboot-under-lock"));
|
||||
assert!(!calls.contains(&"uninstall-proxifyre"));
|
||||
assert!(!calls.contains(&"uninstall-singbox"));
|
||||
assert!(!calls.contains(&"clear-reboot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reboot_requirement_survives_a_later_failure_and_retry() {
|
||||
let mut first = FakeHost::ready(
|
||||
NsisComponentState::ManagedStopped,
|
||||
NsisComponentState::ManagedStopped,
|
||||
);
|
||||
first.uninstall_proxifyre = VecDeque::from([Ok(true)]);
|
||||
first.uninstall_singbox = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut first, NsisEarlyMode::UninstallManaged),
|
||||
Err(NsisRuntimeError::OperationFailed)
|
||||
);
|
||||
let calls = first.calls();
|
||||
assert!(
|
||||
calls.iter().position(|call| *call == "mark-reboot")
|
||||
< calls.iter().position(|call| *call == "uninstall-singbox")
|
||||
);
|
||||
assert!(!calls.contains(&"cleanup"));
|
||||
|
||||
let mut retry = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
|
||||
retry.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState {
|
||||
retirement_pending: false,
|
||||
reboot_required: true,
|
||||
})]);
|
||||
retry.reboot_under_lock = VecDeque::from([Ok(true)]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut retry, NsisEarlyMode::UninstallManaged),
|
||||
Ok(NsisRunOutcome::RebootRequired)
|
||||
);
|
||||
assert!(!retry.calls().contains(&"mark-reboot"));
|
||||
assert!(retry.calls().contains(&"cleanup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_shape_failure_is_observed_before_lock_and_component_mutation() {
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedRunning,
|
||||
NsisComponentState::ManagedStopped,
|
||||
);
|
||||
host.transients = VecDeque::from([Err(NsisRuntimeError::TransientUnsafe)]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Err(NsisRuntimeError::TransientUnsafe)
|
||||
);
|
||||
assert!(!host.calls().contains(&"acquire"));
|
||||
assert!(!host.calls().contains(&"stop-proxifyre"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_singbox_tombstone_is_retried_before_services_are_stopped() {
|
||||
let mut host = FakeHost::ready(
|
||||
NsisComponentState::ManagedRunning,
|
||||
NsisComponentState::Missing,
|
||||
);
|
||||
host.transients = VecDeque::from([
|
||||
Ok(NsisTransientState {
|
||||
singbox_cleanup_pending: true,
|
||||
package_staging_pending: false,
|
||||
}),
|
||||
Ok(NsisTransientState {
|
||||
singbox_cleanup_pending: true,
|
||||
package_staging_pending: false,
|
||||
}),
|
||||
Ok(NsisTransientState {
|
||||
singbox_cleanup_pending: false,
|
||||
package_staging_pending: false,
|
||||
}),
|
||||
]);
|
||||
assert_eq!(
|
||||
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
|
||||
Ok(NsisRunOutcome::Success)
|
||||
);
|
||||
let calls = host.calls();
|
||||
let retry = calls
|
||||
.iter()
|
||||
.position(|call| *call == "retry-singbox-cleanup")
|
||||
.unwrap();
|
||||
let stop = calls
|
||||
.iter()
|
||||
.position(|call| *call == "stop-proxifyre")
|
||||
.unwrap();
|
||||
assert!(retry < stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_separates_update_from_full_uninstall_without_powershell() {
|
||||
let hook = include_str!("../bundled/installer-hooks/proxywarden-hooks.nsh");
|
||||
assert!(hook.contains("$UpdateMode"));
|
||||
assert!(hook.contains(NSIS_VERIFY_UPGRADE_ARGUMENT));
|
||||
assert!(hook.contains(NSIS_UNINSTALL_MANAGED_ARGUMENT));
|
||||
assert!(hook.contains("CheckIfAppIsRunning"));
|
||||
assert!(hook.contains("3010"));
|
||||
assert!(hook.contains("SetRebootFlag true"));
|
||||
assert_eq!(hook.matches("ClearErrors").count(), 3);
|
||||
let launch_error_gate = hook.find("IfErrors").expect("launch-error gate");
|
||||
let last_exec = hook.rfind("ExecWait").expect("native helper launch");
|
||||
assert!(last_exec < launch_error_gate);
|
||||
for branch in hook.split("ExecWait").take(2) {
|
||||
assert!(branch.rfind("ClearErrors").is_some());
|
||||
}
|
||||
assert!(!hook.to_ascii_lowercase().contains("powershell"));
|
||||
let guard = hook.find("CheckIfAppIsRunning").expect("app guard");
|
||||
let destructive = hook
|
||||
.find(NSIS_UNINSTALL_MANAGED_ARGUMENT)
|
||||
.expect("destructive mode");
|
||||
assert!(guard < destructive);
|
||||
let reboot_observed = hook.find("SetRebootFlag true").expect("reboot flag");
|
||||
let marker_ack = hook
|
||||
.find("Delete \"$INSTDIR\\.proxywarden-nsis-reboot-required.json\"")
|
||||
.expect("exact reboot marker acknowledgement");
|
||||
let marker_error = hook[marker_ack..]
|
||||
.find("IfErrors")
|
||||
.map(|offset| marker_ack + offset)
|
||||
.expect("marker delete error gate");
|
||||
assert!(reboot_observed < marker_ack);
|
||||
assert!(marker_ack < marker_error);
|
||||
assert!(hook[marker_error..].contains("Abort"));
|
||||
}
|
||||
|
||||
#[cfg(all(windows, debug_assertions))]
|
||||
mod windows_store {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
struct TestRoot(PathBuf);
|
||||
|
||||
impl TestRoot {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"proxywarden-nsis-store-{}",
|
||||
uuid::Uuid::new_v4().hyphenated()
|
||||
));
|
||||
fs::create_dir(&path).expect("create temp app root");
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestRoot {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_idle_probe_does_not_create_store() {
|
||||
let root = TestRoot::new();
|
||||
assert_eq!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0).expect("idle missing store"),
|
||||
NsisPrivilegedLifecycleState {
|
||||
retirement_pending: false,
|
||||
reboot_required: false,
|
||||
}
|
||||
);
|
||||
assert!(!root.0.join(".proxywarden-privileged-jobs").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_terminal_pairs_and_lock_are_retired_nonrecursively() {
|
||||
let root = TestRoot::new();
|
||||
write_nsis_terminal_pair_for_tests(&root.0, true).expect("terminal pair");
|
||||
assert_eq!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0).expect("terminal idle store"),
|
||||
NsisPrivilegedLifecycleState {
|
||||
retirement_pending: false,
|
||||
reboot_required: false,
|
||||
}
|
||||
);
|
||||
let held = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
|
||||
.expect("exclusive lifecycle guard");
|
||||
assert!(matches!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0),
|
||||
Err(PrivilegedJobsError::LifecycleBusy)
|
||||
));
|
||||
drop(held);
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
|
||||
.expect("persisted idle lock is read-only verifiable");
|
||||
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
|
||||
.expect("reacquire lifecycle guard");
|
||||
match guard.retire_terminal_store() {
|
||||
Ok(()) => {}
|
||||
Err(PrivilegedJobsError::Io(error))
|
||||
if error.kind() == std::io::ErrorKind::PermissionDenied =>
|
||||
{
|
||||
// Stable identity leases capture SACL bytes. A normal
|
||||
// developer token cannot enable SeSecurityPrivilege; the
|
||||
// elevated NSIS path and elevated Windows gate exercise the
|
||||
// actual same-handle deletion.
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("exact retirement: {error}"),
|
||||
}
|
||||
assert!(!root.0.join(".proxywarden-privileged-jobs").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_terminal_retirement_is_detected_and_resumed() {
|
||||
let root = TestRoot::new();
|
||||
write_nsis_terminal_pair_for_tests(&root.0, true).expect("independent terminal pair");
|
||||
write_nsis_interrupted_retirement_for_tests(&root.0)
|
||||
.expect("interrupted retirement fixture");
|
||||
assert_eq!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
|
||||
.expect("durable retirement marker"),
|
||||
NsisPrivilegedLifecycleState {
|
||||
retirement_pending: true,
|
||||
reboot_required: false,
|
||||
}
|
||||
);
|
||||
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
|
||||
.expect("resume lifecycle guard");
|
||||
match guard.retire_terminal_store() {
|
||||
Ok(()) => {
|
||||
assert!(!root.0.join(".proxywarden-privileged-jobs").exists());
|
||||
}
|
||||
Err(PrivilegedJobsError::Io(error))
|
||||
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
|
||||
Err(error) => panic!("resumed retirement: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reboot_marker_survives_store_retirement_until_nsis_observes_3010() {
|
||||
let root = TestRoot::new();
|
||||
let guard =
|
||||
NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0).expect("lifecycle guard");
|
||||
assert!(guard.mark_reboot_required().expect("durable reboot marker"));
|
||||
drop(guard);
|
||||
assert!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
|
||||
.expect("reboot state")
|
||||
.reboot_required
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root.0.join(".proxywarden-privileged-jobs"))
|
||||
.expect("simulate completed store cleanup before process exit");
|
||||
assert_eq!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
|
||||
.expect("reboot survives store loss"),
|
||||
NsisPrivilegedLifecycleState {
|
||||
retirement_pending: false,
|
||||
reboot_required: true,
|
||||
}
|
||||
);
|
||||
|
||||
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
|
||||
.expect("recreate exact lifecycle store");
|
||||
match guard.retire_terminal_store() {
|
||||
Ok(()) => assert_eq!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
|
||||
.expect("reboot marker retained for outward 3010"),
|
||||
NsisPrivilegedLifecycleState {
|
||||
retirement_pending: false,
|
||||
reboot_required: true,
|
||||
}
|
||||
),
|
||||
Err(PrivilegedJobsError::Io(error))
|
||||
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
|
||||
Err(error) => panic!("reboot marker retirement: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proven_no_reboot_clears_only_the_exact_write_ahead_marker() {
|
||||
let root = TestRoot::new();
|
||||
let guard =
|
||||
NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0).expect("lifecycle guard");
|
||||
assert!(guard.mark_reboot_required().expect("write-ahead intent"));
|
||||
guard
|
||||
.clear_reboot_required()
|
||||
.expect("exact no-reboot acknowledgement");
|
||||
drop(guard);
|
||||
assert_eq!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
|
||||
.expect("marker cleared after proven no-reboot"),
|
||||
NsisPrivilegedLifecycleState {
|
||||
retirement_pending: false,
|
||||
reboot_required: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_retirement_staging_is_durable_and_resumed_pair_at_a_time() {
|
||||
let root = TestRoot::new();
|
||||
write_nsis_partial_retirement_staging_for_tests(&root.0)
|
||||
.expect("partial retirement staging");
|
||||
assert_eq!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
|
||||
.expect("staging is a durable retirement intent"),
|
||||
NsisPrivilegedLifecycleState {
|
||||
retirement_pending: true,
|
||||
reboot_required: false,
|
||||
}
|
||||
);
|
||||
|
||||
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
|
||||
.expect("resume staged retirement");
|
||||
match guard.retire_terminal_store() {
|
||||
Ok(()) => assert!(!root.0.join(".proxywarden-privileged-jobs").exists()),
|
||||
Err(PrivilegedJobsError::Io(error))
|
||||
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
|
||||
Err(error) => panic!("staged retirement recovery: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_reboot_staging_is_published_and_never_acknowledged_by_helper() {
|
||||
let root = TestRoot::new();
|
||||
write_nsis_partial_reboot_staging_for_tests(&root.0).expect("partial reboot staging");
|
||||
assert!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
|
||||
.expect("partial reboot intent")
|
||||
.reboot_required
|
||||
);
|
||||
|
||||
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
|
||||
.expect("recover reboot marker under lifecycle lock");
|
||||
assert!(!root
|
||||
.0
|
||||
.join(".proxywarden-nsis-reboot-required.pending")
|
||||
.exists());
|
||||
assert!(root
|
||||
.0
|
||||
.join(".proxywarden-nsis-reboot-required.json")
|
||||
.is_file());
|
||||
match guard.retire_terminal_store() {
|
||||
Ok(()) => assert!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
|
||||
.expect("reboot fact remains after store cleanup")
|
||||
.reboot_required
|
||||
),
|
||||
Err(PrivilegedJobsError::Io(error))
|
||||
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
|
||||
Err(error) => panic!("reboot staging recovery: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_or_unknown_records_block_without_deletion() {
|
||||
let running = TestRoot::new();
|
||||
write_nsis_terminal_pair_for_tests(&running.0, false).expect("running pair");
|
||||
assert!(matches!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&running.0),
|
||||
Err(PrivilegedJobsError::InvalidRecord)
|
||||
));
|
||||
assert!(running.0.join(".proxywarden-privileged-jobs").exists());
|
||||
|
||||
let unknown = TestRoot::new();
|
||||
write_nsis_terminal_pair_for_tests(&unknown.0, true).expect("terminal pair");
|
||||
let path = unknown
|
||||
.0
|
||||
.join(".proxywarden-privileged-jobs")
|
||||
.join("foreign.bin");
|
||||
fs::write(&path, b"foreign").expect("foreign entry");
|
||||
safe_fs::protect_path_for_owner_admin_system(&path).expect("seal fixture");
|
||||
assert!(matches!(
|
||||
verify_nsis_privileged_lifecycle_idle_for_tests(&unknown.0),
|
||||
Err(PrivilegedJobsError::InvalidRecord)
|
||||
));
|
||||
assert!(path.exists());
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
//! Shared PowerShell execution boundary for fixed ProxyWarden scripts.
|
||||
//!
|
||||
//! Callers remain responsible for generating static script templates and for
|
||||
//! validating every path or service identifier before invoking this module.
|
||||
|
||||
use crate::process::command_no_window;
|
||||
use std::{fs, path::Path, process::Output};
|
||||
|
||||
pub(crate) fn write_script(path: &Path, script: &str) -> std::io::Result<()> {
|
||||
let mut bytes = Vec::with_capacity(script.len() + 3);
|
||||
bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]);
|
||||
bytes.extend_from_slice(script.as_bytes());
|
||||
fs::write(path, bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn run_command(script: &str) -> std::io::Result<Output> {
|
||||
command_no_window("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
])
|
||||
.output()
|
||||
}
|
||||
|
||||
pub(crate) fn run_file(script_path: &Path) -> std::io::Result<Output> {
|
||||
command_no_window("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
])
|
||||
.arg(script_path)
|
||||
.output()
|
||||
}
|
||||
|
||||
pub(crate) fn is_elevated() -> bool {
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#;
|
||||
let Ok(output) = run_command(script) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
output.status.success()
|
||||
&& String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("true")
|
||||
}
|
||||
|
||||
pub(crate) fn output_message(output: &Output, fallback: &str) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if !stderr.is_empty() {
|
||||
return stderr;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
return stdout;
|
||||
}
|
||||
|
||||
fallback.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn package_failure_details(result_path: &Path, output: &Output) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Ok(contents) = fs::read_to_string(result_path) {
|
||||
let details = compact_error_text(&contents);
|
||||
if !details.is_empty() && !details.eq_ignore_ascii_case("ok") {
|
||||
parts.push(details);
|
||||
}
|
||||
}
|
||||
|
||||
let stdout = compact_error_text(&String::from_utf8_lossy(&output.stdout));
|
||||
if !stdout.is_empty() {
|
||||
parts.push(format!("stdout: {stdout}"));
|
||||
}
|
||||
|
||||
let stderr = compact_error_text(&String::from_utf8_lossy(&output.stderr));
|
||||
if !stderr.is_empty() {
|
||||
parts.push(format!("stderr: {stderr}"));
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
parts.push(
|
||||
"Лог elevated-скрипта не создан. Обычно это значит, что окно UAC было отменено или Windows не дала запустить elevated PowerShell."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn compact_error_text(value: &str) -> String {
|
||||
let text = value
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
const MAX_CHARS: usize = 1400;
|
||||
if text.chars().count() <= MAX_CHARS {
|
||||
return text;
|
||||
}
|
||||
|
||||
format!("{}...", text.chars().take(MAX_CHARS).collect::<String>())
|
||||
}
|
||||
|
||||
pub(crate) fn escape_single(value: &str) -> String {
|
||||
value.replace('\'', "''")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3941
-1
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,22 @@ struct ProxiFyreInstallMarker {
|
||||
packet_filter_installed_by_proxy_warden: bool,
|
||||
}
|
||||
|
||||
pub fn validate_proxifyre_marker_text(
|
||||
marker_text: &str,
|
||||
expected_install_dir: &Path,
|
||||
) -> Result<ManagedProxiFyreOwnership, String> {
|
||||
let marker = parse_marker(marker_text)?;
|
||||
validate_marker_identity(&marker)?;
|
||||
if !same_path(Path::new(&marker.install_root), expected_install_dir) {
|
||||
return Err("installRoot из marker не совпадает с управляемой папкой".to_string());
|
||||
}
|
||||
|
||||
Ok(ManagedProxiFyreOwnership {
|
||||
service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_string(),
|
||||
remove_packet_filter: marker.packet_filter_installed_by_proxy_warden,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify_managed_proxifyre_install(
|
||||
install_dir: &Path,
|
||||
executable_path: &Path,
|
||||
@@ -68,25 +84,13 @@ pub fn verify_managed_proxifyre_install(
|
||||
marker_path.display()
|
||||
)
|
||||
})?;
|
||||
let marker_text = marker_text.strip_prefix('\u{feff}').unwrap_or(&marker_text);
|
||||
let marker: ProxiFyreInstallMarker = serde_json::from_str(marker_text).map_err(|error| {
|
||||
let marker = parse_marker(&marker_text).map_err(|error| {
|
||||
format!(
|
||||
"marker установки {} содержит некорректный JSON: {error}",
|
||||
"marker установки {} содержит некорректные данные: {error}",
|
||||
marker_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !marker.manager.eq_ignore_ascii_case("ProxyWarden")
|
||||
|| !marker.component.eq_ignore_ascii_case("proxifyre")
|
||||
{
|
||||
return Err("marker установки не подтверждает владение ProxyWarden/ProxiFyre".to_string());
|
||||
}
|
||||
if !marker
|
||||
.service_name
|
||||
.eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME)
|
||||
{
|
||||
return Err("marker установки содержит неподдерживаемое имя службы".to_string());
|
||||
}
|
||||
validate_marker_identity(&marker)?;
|
||||
|
||||
let marker_root = canonical_path(Path::new(&marker.install_root), "installRoot из marker")?;
|
||||
if marker_root != install_dir {
|
||||
@@ -99,6 +103,39 @@ pub fn verify_managed_proxifyre_install(
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_marker(marker_text: &str) -> Result<ProxiFyreInstallMarker, String> {
|
||||
let marker_text = marker_text.strip_prefix('\u{feff}').unwrap_or(marker_text);
|
||||
serde_json::from_str(marker_text)
|
||||
.map_err(|error| format!("marker содержит некорректный JSON: {error}"))
|
||||
}
|
||||
|
||||
fn validate_marker_identity(marker: &ProxiFyreInstallMarker) -> Result<(), String> {
|
||||
if !marker.manager.eq_ignore_ascii_case("ProxyWarden")
|
||||
|| !marker.component.eq_ignore_ascii_case("proxifyre")
|
||||
{
|
||||
return Err("marker установки не подтверждает владение ProxyWarden/ProxiFyre".to_string());
|
||||
}
|
||||
if !marker
|
||||
.service_name
|
||||
.eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME)
|
||||
{
|
||||
return Err("marker установки содержит неподдерживаемое имя службы".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn same_path(left: &Path, right: &Path) -> bool {
|
||||
left.to_string_lossy()
|
||||
.replace('/', "\\")
|
||||
.trim_end_matches('\\')
|
||||
.eq_ignore_ascii_case(
|
||||
right
|
||||
.to_string_lossy()
|
||||
.replace('/', "\\")
|
||||
.trim_end_matches('\\'),
|
||||
)
|
||||
}
|
||||
|
||||
fn canonical_path(path: &Path, label: &str) -> Result<std::path::PathBuf, String> {
|
||||
fs::canonicalize(path)
|
||||
.map_err(|error| format!("не удалось проверить {label} '{}': {error}", path.display()))
|
||||
|
||||
+4569
-1022
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,921 @@
|
||||
use super::*;
|
||||
use crate::component_cutover::{
|
||||
CutoverOperation, EffectDisposition, LegacyServiceState, MutationDirection, MutationEffect,
|
||||
MutationRecord, StateFingerprint,
|
||||
};
|
||||
use crate::process::{
|
||||
FullServiceSnapshot, ServiceBaseConfigSnapshot, ServiceSecuritySnapshot, ServiceStableState,
|
||||
SERVICE_CONFIG2_KINDS,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Call {
|
||||
CaptureLegacy,
|
||||
QueryLegacy,
|
||||
QueryCurrent,
|
||||
QueryComplete,
|
||||
QueryLegacyPolicy(ServiceConfig2Kind),
|
||||
QueryCurrentPolicy(ServiceConfig2Kind),
|
||||
QueryCurrentSecurity,
|
||||
StopLegacy,
|
||||
DeleteLegacy,
|
||||
CreateCurrent,
|
||||
SetCurrentPolicy(ServiceConfig2Kind),
|
||||
SetCurrentSecurity,
|
||||
StartCurrent,
|
||||
StopCurrent,
|
||||
DeleteCurrent,
|
||||
CreateLegacy,
|
||||
RestoreLegacyPolicy(ServiceConfig2Kind),
|
||||
RestoreLegacySecurity,
|
||||
StartLegacy,
|
||||
}
|
||||
|
||||
struct FakeScm {
|
||||
calls: Vec<Call>,
|
||||
fail_on: Option<Call>,
|
||||
before: ServiceRestoreSnapshot,
|
||||
complete: CompleteServiceObservation,
|
||||
current_base: ServiceBaseConfigSnapshot,
|
||||
}
|
||||
|
||||
impl FakeScm {
|
||||
fn new() -> Self {
|
||||
let current_base = expected_current_proxifyre_service_base(
|
||||
&std::env::temp_dir().join("ProxyWarden-current-ProxiFyre.exe"),
|
||||
)
|
||||
.expect("current base fixture");
|
||||
Self {
|
||||
calls: Vec::new(),
|
||||
fail_on: None,
|
||||
before: before_state(),
|
||||
complete: CompleteServiceObservation::Missing,
|
||||
current_base,
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&mut self, call: Call) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.calls.push(call.clone());
|
||||
if self.fail_on.as_ref() == Some(&call) {
|
||||
Err(ProxifyreNativeHostError)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_policy() -> ServicePolicySnapshot {
|
||||
ServicePolicySnapshot {
|
||||
service: crate::process::ServiceSnapshot {
|
||||
exists: false,
|
||||
state: None,
|
||||
path_name: None,
|
||||
process_id: None,
|
||||
},
|
||||
path_matches: false,
|
||||
demand_start: false,
|
||||
failure_recovery_disabled: false,
|
||||
dacl_matches: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxifyreCutoverScm for FakeScm {
|
||||
fn capture_legacy_service(
|
||||
&mut self,
|
||||
) -> Result<ServiceRestoreSnapshot, ProxifyreNativeHostError> {
|
||||
self.record(Call::CaptureLegacy)?;
|
||||
Ok(self.before.clone())
|
||||
}
|
||||
|
||||
fn query_legacy_service(&mut self) -> Result<ServicePolicySnapshot, ProxifyreNativeHostError> {
|
||||
self.record(Call::QueryLegacy)?;
|
||||
Ok(Self::missing_policy())
|
||||
}
|
||||
|
||||
fn query_current_service(&mut self) -> Result<ServicePolicySnapshot, ProxifyreNativeHostError> {
|
||||
self.record(Call::QueryCurrent)?;
|
||||
Ok(Self::missing_policy())
|
||||
}
|
||||
|
||||
fn query_complete_service(
|
||||
&mut self,
|
||||
) -> Result<CompleteServiceObservation, ProxifyreNativeHostError> {
|
||||
self.record(Call::QueryComplete)?;
|
||||
Ok(self.complete.clone())
|
||||
}
|
||||
|
||||
fn expected_current_service_base(
|
||||
&self,
|
||||
) -> Result<ServiceBaseConfigSnapshot, ProxifyreNativeHostError> {
|
||||
Ok(self.current_base.clone())
|
||||
}
|
||||
|
||||
fn query_legacy_service_policy(
|
||||
&mut self,
|
||||
kind: ServiceConfig2Kind,
|
||||
) -> Result<Option<ServiceConfig2Snapshot>, ProxifyreNativeHostError> {
|
||||
self.record(Call::QueryLegacyPolicy(kind))?;
|
||||
Ok(self.before.config2(kind).cloned())
|
||||
}
|
||||
|
||||
fn query_current_service_policy(
|
||||
&mut self,
|
||||
kind: ServiceConfig2Kind,
|
||||
) -> Result<Option<ServiceConfig2Snapshot>, ProxifyreNativeHostError> {
|
||||
self.record(Call::QueryCurrentPolicy(kind))?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn current_service_security_matches(&mut self) -> Result<bool, ProxifyreNativeHostError> {
|
||||
self.record(Call::QueryCurrentSecurity)?;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn stop_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::StopLegacy)
|
||||
}
|
||||
|
||||
fn delete_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::DeleteLegacy)
|
||||
}
|
||||
|
||||
fn create_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::CreateCurrent)
|
||||
}
|
||||
|
||||
fn set_current_service_policy(
|
||||
&mut self,
|
||||
kind: ServiceConfig2Kind,
|
||||
) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::SetCurrentPolicy(kind))
|
||||
}
|
||||
|
||||
fn set_current_service_security(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::SetCurrentSecurity)
|
||||
}
|
||||
|
||||
fn start_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::StartCurrent)
|
||||
}
|
||||
|
||||
fn stop_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::StopCurrent)
|
||||
}
|
||||
|
||||
fn delete_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::DeleteCurrent)
|
||||
}
|
||||
|
||||
fn create_legacy_service(
|
||||
&mut self,
|
||||
_before: &ServiceRestoreSnapshot,
|
||||
) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::CreateLegacy)
|
||||
}
|
||||
|
||||
fn restore_legacy_service_policy(
|
||||
&mut self,
|
||||
snapshot: &ServiceConfig2Snapshot,
|
||||
) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::RestoreLegacyPolicy(snapshot.kind()))
|
||||
}
|
||||
|
||||
fn restore_legacy_service_security(
|
||||
&mut self,
|
||||
_before: &ServiceRestoreSnapshot,
|
||||
) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::RestoreLegacySecurity)
|
||||
}
|
||||
|
||||
fn start_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(Call::StartLegacy)
|
||||
}
|
||||
}
|
||||
|
||||
fn before_state() -> ServiceRestoreSnapshot {
|
||||
FullServiceSnapshot {
|
||||
service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_owned(),
|
||||
base: ServiceBaseConfigSnapshot {
|
||||
service_type: 0x10,
|
||||
start_type: 2,
|
||||
error_control: 1,
|
||||
binary_path_name: concat!(
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" "#,
|
||||
r#"-displayname "ProxiFyre Service" -servicename "ProxiFyreService""#
|
||||
)
|
||||
.to_owned(),
|
||||
load_order_group: None,
|
||||
tag_id: 0,
|
||||
dependencies: Vec::new(),
|
||||
service_start_name: "LocalSystem".to_owned(),
|
||||
display_name: "ProxiFyre Service".to_owned(),
|
||||
},
|
||||
config2: SERVICE_CONFIG2_KINDS
|
||||
.iter()
|
||||
.copied()
|
||||
.map(expected_current_proxifyre_service_policy)
|
||||
.collect(),
|
||||
security: ServiceSecuritySnapshot {
|
||||
self_relative_descriptor: vec![1, 2, 3],
|
||||
untrusted_mutation_rights: false,
|
||||
},
|
||||
original_state: ServiceStableState::Running,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_current_is_exactly_one_call_and_never_starts() {
|
||||
let mut host = FakeScm::new();
|
||||
let before = host.before.clone();
|
||||
|
||||
assert!(mutate_proxifyre_cutover_scm(
|
||||
&mut host,
|
||||
&CutoverOperation::CreateCurrentService,
|
||||
&before,
|
||||
)
|
||||
.expect("SCM mutation dispatch"));
|
||||
assert_eq!(host.calls, vec![Call::CreateCurrent]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collision_or_failure_stops_after_the_single_selected_mutation() {
|
||||
let mut host = FakeScm::new();
|
||||
host.fail_on = Some(Call::DeleteLegacy);
|
||||
let before = host.before.clone();
|
||||
|
||||
mutate_proxifyre_cutover_scm(&mut host, &CutoverOperation::DeleteLegacyService, &before)
|
||||
.expect_err("collision/failure must surface");
|
||||
assert_eq!(host.calls, vec![Call::DeleteLegacy]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_policy_selects_only_the_requested_captured_record() {
|
||||
let mut host = FakeScm::new();
|
||||
let before = host.before.clone();
|
||||
|
||||
assert!(mutate_proxifyre_cutover_scm(
|
||||
&mut host,
|
||||
&CutoverOperation::RestoreLegacyServicePolicy(ServiceConfig2Kind::Triggers),
|
||||
&before,
|
||||
)
|
||||
.expect("restore dispatch"));
|
||||
assert_eq!(
|
||||
host.calls,
|
||||
vec![Call::RestoreLegacyPolicy(ServiceConfig2Kind::Triggers)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_missing_policy_is_typed_absence_and_never_mutates() {
|
||||
let mut host = FakeScm::new();
|
||||
assert_eq!(
|
||||
host.query_current_service_policy(ServiceConfig2Kind::Description)
|
||||
.expect("read-only query"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
host.calls,
|
||||
vec![Call::QueryCurrentPolicy(ServiceConfig2Kind::Description)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_scm_operation_is_not_claimed_or_mutated() {
|
||||
let mut host = FakeScm::new();
|
||||
let before = host.before.clone();
|
||||
assert!(!mutate_proxifyre_cutover_scm(
|
||||
&mut host,
|
||||
&CutoverOperation::HardenLegacyRootSecurity,
|
||||
&before,
|
||||
)
|
||||
.expect("non-SCM dispatch"));
|
||||
assert!(host.calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_current_policy_covers_every_config2_kind() {
|
||||
for kind in SERVICE_CONFIG2_KINDS {
|
||||
assert_eq!(expected_current_proxifyre_service_policy(kind).kind(), kind);
|
||||
}
|
||||
assert!(matches!(
|
||||
expected_current_proxifyre_service_policy(ServiceConfig2Kind::Triggers),
|
||||
ServiceConfig2Snapshot::Triggers(ref triggers) if triggers.is_empty()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scm_observer_matches_typed_expected_fingerprint_for_every_scm_operation() {
|
||||
let before = before_state();
|
||||
let operations = vec![
|
||||
CutoverOperation::StopLegacyService,
|
||||
CutoverOperation::DeleteLegacyService,
|
||||
CutoverOperation::CreateCurrentService,
|
||||
CutoverOperation::SetCurrentServicePolicy(ServiceConfig2Kind::Description),
|
||||
CutoverOperation::SetCurrentServiceSecurity,
|
||||
CutoverOperation::StartCurrentService,
|
||||
CutoverOperation::StopCurrentService,
|
||||
CutoverOperation::DeleteCurrentService,
|
||||
CutoverOperation::CreateLegacyService,
|
||||
CutoverOperation::RestoreLegacyServicePolicy(ServiceConfig2Kind::Triggers),
|
||||
CutoverOperation::RestoreLegacyServiceSecurity,
|
||||
CutoverOperation::StartLegacyService,
|
||||
];
|
||||
|
||||
for operation in operations {
|
||||
let mut host = FakeScm::new();
|
||||
host.complete = satisfying_scm_observation(&operation, &before, &host.current_base);
|
||||
assert_eq!(
|
||||
observe_proxifyre_cutover_scm_state(&mut host, &operation, &before)
|
||||
.expect("typed complete SCM observation"),
|
||||
expected_proxifyre_cutover_scm_effect(&operation).expect("typed expected SCM effect"),
|
||||
"operation {operation:?}"
|
||||
);
|
||||
assert_eq!(host.calls, vec![Call::QueryComplete]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scm_unexpected_fingerprint_preserves_complete_drift_instead_of_boolean_bucket() {
|
||||
let before = before_state();
|
||||
let operation = CutoverOperation::CreateCurrentService;
|
||||
let mut first = FakeScm::new();
|
||||
let mut first_snapshot = before.clone();
|
||||
first_snapshot.base.display_name = "foreign-one".to_owned();
|
||||
first.complete = complete_service(first_snapshot, false);
|
||||
let first_fingerprint = observe_proxifyre_cutover_scm_state(&mut first, &operation, &before)
|
||||
.expect("first exact unexpected state");
|
||||
|
||||
let mut repeated = FakeScm::new();
|
||||
let mut repeated_snapshot = before.clone();
|
||||
repeated_snapshot.base.display_name = "foreign-one".to_owned();
|
||||
repeated.complete = complete_service(repeated_snapshot, false);
|
||||
let repeated_fingerprint =
|
||||
observe_proxifyre_cutover_scm_state(&mut repeated, &operation, &before)
|
||||
.expect("repeated exact unexpected state");
|
||||
|
||||
let mut second = FakeScm::new();
|
||||
let mut second_snapshot = before.clone();
|
||||
second_snapshot.base.display_name = "foreign-two".to_owned();
|
||||
second.complete = complete_service(second_snapshot, false);
|
||||
let second_fingerprint = observe_proxifyre_cutover_scm_state(&mut second, &operation, &before)
|
||||
.expect("second exact unexpected state");
|
||||
|
||||
assert_eq!(first_fingerprint, repeated_fingerprint);
|
||||
assert_ne!(first_fingerprint, second_fingerprint);
|
||||
assert_ne!(
|
||||
first_fingerprint,
|
||||
expected_proxifyre_cutover_scm_effect(&operation).expect("expected effect")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scm_expected_effect_rejects_untrusted_mutation_rights() {
|
||||
let before = before_state();
|
||||
let operation = CutoverOperation::CreateCurrentService;
|
||||
let mut host = FakeScm::new();
|
||||
let mut live = satisfying_scm_observation(&operation, &before, &host.current_base);
|
||||
let CompleteServiceObservation::Present { snapshot, .. } = &mut live else {
|
||||
panic!("current service fixture must be present");
|
||||
};
|
||||
snapshot.security.untrusted_mutation_rights = true;
|
||||
host.complete = live;
|
||||
|
||||
assert_ne!(
|
||||
observe_proxifyre_cutover_scm_state(&mut host, &operation, &before)
|
||||
.expect("exact unsafe SCM observation"),
|
||||
expected_proxifyre_cutover_scm_effect(&operation).expect("expected effect")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_current_effect_requires_the_complete_fresh_service_default_profile() {
|
||||
let before = before_state();
|
||||
let operation = CutoverOperation::CreateCurrentService;
|
||||
let mut exact = FakeScm::new();
|
||||
exact.complete = satisfying_scm_observation(&operation, &before, &exact.current_base);
|
||||
assert_eq!(
|
||||
observe_proxifyre_cutover_scm_state(&mut exact, &operation, &before)
|
||||
.expect("complete fresh-service defaults"),
|
||||
expected_proxifyre_cutover_scm_effect(&operation).expect("expected create effect")
|
||||
);
|
||||
|
||||
let mut drifted = FakeScm::new();
|
||||
let mut live = satisfying_scm_observation(&operation, &before, &drifted.current_base);
|
||||
let CompleteServiceObservation::Present { snapshot, .. } = &mut live else {
|
||||
panic!("current service fixture must be present");
|
||||
};
|
||||
let description = snapshot
|
||||
.config2
|
||||
.iter_mut()
|
||||
.find(|value| value.kind() == ServiceConfig2Kind::Description)
|
||||
.expect("complete default profile");
|
||||
*description = ServiceConfig2Snapshot::Description(Some("drift".to_owned()));
|
||||
drifted.complete = live;
|
||||
assert_ne!(
|
||||
observe_proxifyre_cutover_scm_state(&mut drifted, &operation, &before)
|
||||
.expect("drifted fresh-service defaults"),
|
||||
expected_proxifyre_cutover_scm_effect(&operation).expect("expected create effect")
|
||||
);
|
||||
}
|
||||
|
||||
fn complete_service(
|
||||
snapshot: ServiceRestoreSnapshot,
|
||||
current_dacl_matches: bool,
|
||||
) -> CompleteServiceObservation {
|
||||
CompleteServiceObservation::Present {
|
||||
snapshot: Box::new(snapshot),
|
||||
current_dacl_matches,
|
||||
}
|
||||
}
|
||||
|
||||
fn satisfying_scm_observation(
|
||||
operation: &CutoverOperation,
|
||||
before: &ServiceRestoreSnapshot,
|
||||
current_base: &ServiceBaseConfigSnapshot,
|
||||
) -> CompleteServiceObservation {
|
||||
if matches!(
|
||||
operation,
|
||||
CutoverOperation::DeleteLegacyService | CutoverOperation::DeleteCurrentService
|
||||
) {
|
||||
return CompleteServiceObservation::Missing;
|
||||
}
|
||||
|
||||
let current = matches!(
|
||||
operation,
|
||||
CutoverOperation::CreateCurrentService
|
||||
| CutoverOperation::SetCurrentServicePolicy(_)
|
||||
| CutoverOperation::SetCurrentServiceSecurity
|
||||
| CutoverOperation::StartCurrentService
|
||||
| CutoverOperation::StopCurrentService
|
||||
);
|
||||
let mut snapshot = before.clone();
|
||||
let mut current_dacl_matches = false;
|
||||
if current {
|
||||
snapshot.base = current_base.clone();
|
||||
snapshot.config2 = SERVICE_CONFIG2_KINDS
|
||||
.iter()
|
||||
.copied()
|
||||
.map(expected_current_proxifyre_service_policy)
|
||||
.collect();
|
||||
current_dacl_matches = matches!(
|
||||
operation,
|
||||
CutoverOperation::SetCurrentServiceSecurity
|
||||
| CutoverOperation::StartCurrentService
|
||||
| CutoverOperation::StopCurrentService
|
||||
);
|
||||
}
|
||||
snapshot.original_state = if matches!(
|
||||
operation,
|
||||
CutoverOperation::StartCurrentService | CutoverOperation::StartLegacyService
|
||||
) {
|
||||
ServiceStableState::Running
|
||||
} else {
|
||||
ServiceStableState::Stopped
|
||||
};
|
||||
complete_service(snapshot, current_dacl_matches)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn missing_primary_service_config2_probe_is_live_and_read_only() {
|
||||
let service = crate::process::query_known_service(KnownWindowsService::Proxifyre)
|
||||
.expect("read-only SCM probe");
|
||||
if service.exists {
|
||||
eprintln!("skipping missing-service assertion because ProxiFyreService exists");
|
||||
return;
|
||||
}
|
||||
let executable = std::env::current_exe().expect("current test executable");
|
||||
assert_eq!(
|
||||
query_service_config2_exact(
|
||||
PROXIFYRE_MANAGED_SERVICE_NAME,
|
||||
&executable,
|
||||
ServiceConfig2Kind::Description,
|
||||
)
|
||||
.expect("missing service query"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum CandidateCall {
|
||||
CreateRoot,
|
||||
WritePackage(PathBuf),
|
||||
WriteConfig,
|
||||
WriteMarker,
|
||||
WriteReceipt,
|
||||
}
|
||||
|
||||
struct FakeCandidateWriter {
|
||||
calls: Vec<CandidateCall>,
|
||||
fail_on: Option<CandidateCall>,
|
||||
fail_after_effect: Option<CandidateCall>,
|
||||
observation: ProxifyreCutoverCandidateObservation,
|
||||
}
|
||||
|
||||
impl Default for FakeCandidateWriter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
calls: Vec::new(),
|
||||
fail_on: None,
|
||||
fail_after_effect: None,
|
||||
observation: ProxifyreCutoverCandidateObservation::Absent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeCandidateWriter {
|
||||
fn record(&mut self, call: CandidateCall) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.calls.push(call.clone());
|
||||
if self.fail_on.as_ref() == Some(&call) {
|
||||
Err(ProxifyreNativeHostError)
|
||||
} else if self.fail_after_effect.as_ref() == Some(&call) {
|
||||
self.observation = expected_candidate_observation();
|
||||
Err(ProxifyreNativeHostError)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxifyreCutoverCandidateWriter for FakeCandidateWriter {
|
||||
fn observe_candidate(
|
||||
&mut self,
|
||||
operation: &CutoverOperation,
|
||||
) -> Result<ProxifyreCutoverCandidateObservation, ProxifyreNativeHostError> {
|
||||
if !matches!(
|
||||
operation,
|
||||
CutoverOperation::CreateCurrentCandidateRoot
|
||||
| CutoverOperation::WriteCurrentCandidatePackageEntry(_)
|
||||
| CutoverOperation::WriteCurrentCandidateConfig
|
||||
| CutoverOperation::WriteCurrentCandidateMarker
|
||||
| CutoverOperation::WriteCurrentCandidateReceipt
|
||||
) {
|
||||
return Err(ProxifyreNativeHostError);
|
||||
}
|
||||
Ok(self.observation.clone())
|
||||
}
|
||||
|
||||
fn create_candidate_root(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(CandidateCall::CreateRoot)
|
||||
}
|
||||
|
||||
fn write_candidate_package_entry(
|
||||
&mut self,
|
||||
relative_path: &Path,
|
||||
) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(CandidateCall::WritePackage(relative_path.to_path_buf()))
|
||||
}
|
||||
|
||||
fn write_candidate_config(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(CandidateCall::WriteConfig)
|
||||
}
|
||||
|
||||
fn write_candidate_marker(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(CandidateCall::WriteMarker)
|
||||
}
|
||||
|
||||
fn write_candidate_receipt(&mut self) -> Result<(), ProxifyreNativeHostError> {
|
||||
self.record(CandidateCall::WriteReceipt)
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_candidate_observation() -> ProxifyreCutoverCandidateObservation {
|
||||
let snapshot: SealedPathSnapshot = serde_json::from_value(serde_json::json!({
|
||||
"identity": {
|
||||
"volumeSerialNumber": 7,
|
||||
"fileId": 11,
|
||||
"kind": "regular_file",
|
||||
"size": 3
|
||||
},
|
||||
"security": {
|
||||
"selfRelative": [1, 2, 3],
|
||||
"sacl": "present"
|
||||
}
|
||||
}))
|
||||
.expect("sealed candidate fixture");
|
||||
ProxifyreCutoverCandidateObservation::Expected(snapshot)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_dispatch_selects_exactly_one_create_new_mutation() {
|
||||
let mut writer = FakeCandidateWriter::default();
|
||||
let relative_path = PathBuf::from("ProxiFyre.exe");
|
||||
|
||||
assert!(mutate_proxifyre_cutover_candidate(
|
||||
&mut writer,
|
||||
&CutoverOperation::WriteCurrentCandidatePackageEntry(relative_path.clone()),
|
||||
)
|
||||
.expect("candidate mutation dispatch"));
|
||||
assert_eq!(
|
||||
writer.calls,
|
||||
vec![CandidateCall::WritePackage(relative_path)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_collision_or_write_failure_is_not_hidden() {
|
||||
let mut writer = FakeCandidateWriter {
|
||||
fail_on: Some(CandidateCall::WriteReceipt),
|
||||
..FakeCandidateWriter::default()
|
||||
};
|
||||
|
||||
mutate_proxifyre_cutover_candidate(
|
||||
&mut writer,
|
||||
&CutoverOperation::WriteCurrentCandidateReceipt,
|
||||
)
|
||||
.expect_err("collision/failure must surface");
|
||||
assert_eq!(writer.calls, vec![CandidateCall::WriteReceipt]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_failed_create_or_write_distinguishes_no_effect_from_reacquired_exact_effect() {
|
||||
for (operation, call) in [
|
||||
(
|
||||
CutoverOperation::CreateCurrentCandidateRoot,
|
||||
CandidateCall::CreateRoot,
|
||||
),
|
||||
(
|
||||
CutoverOperation::WriteCurrentCandidateReceipt,
|
||||
CandidateCall::WriteReceipt,
|
||||
),
|
||||
] {
|
||||
let mut before_effect = FakeCandidateWriter {
|
||||
fail_on: Some(call.clone()),
|
||||
..FakeCandidateWriter::default()
|
||||
};
|
||||
mutate_proxifyre_cutover_candidate(&mut before_effect, &operation)
|
||||
.expect_err("failure before external effect");
|
||||
assert_eq!(
|
||||
before_effect
|
||||
.observe_candidate(&operation)
|
||||
.expect("observe absent target"),
|
||||
ProxifyreCutoverCandidateObservation::Absent
|
||||
);
|
||||
|
||||
let mut after_effect = FakeCandidateWriter {
|
||||
fail_after_effect: Some(call),
|
||||
..FakeCandidateWriter::default()
|
||||
};
|
||||
mutate_proxifyre_cutover_candidate(&mut after_effect, &operation)
|
||||
.expect_err("failure after external effect");
|
||||
let observed = after_effect
|
||||
.observe_candidate(&operation)
|
||||
.expect("reacquire exact target");
|
||||
assert!(matches!(
|
||||
observed,
|
||||
ProxifyreCutoverCandidateObservation::Expected(SealedPathSnapshot {
|
||||
identity: safe_fs::StableObjectIdentity {
|
||||
volume_serial_number: 7,
|
||||
file_id: 11,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_observer_keeps_unknown_distinct_from_absent_and_expected() {
|
||||
let operation = CutoverOperation::CreateCurrentCandidateRoot;
|
||||
let mut writer = FakeCandidateWriter {
|
||||
observation: ProxifyreCutoverCandidateObservation::Unknown,
|
||||
..FakeCandidateWriter::default()
|
||||
};
|
||||
assert_eq!(
|
||||
writer
|
||||
.observe_candidate(&operation)
|
||||
.expect("typed unknown observation"),
|
||||
ProxifyreCutoverCandidateObservation::Unknown
|
||||
);
|
||||
assert_ne!(
|
||||
ProxifyreCutoverCandidateObservation::Unknown,
|
||||
ProxifyreCutoverCandidateObservation::Absent
|
||||
);
|
||||
assert_ne!(
|
||||
ProxifyreCutoverCandidateObservation::Unknown,
|
||||
expected_candidate_observation()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_dispatch_does_not_claim_scm_or_legacy_filesystem_operations() {
|
||||
let mut writer = FakeCandidateWriter::default();
|
||||
|
||||
assert!(!mutate_proxifyre_cutover_candidate(
|
||||
&mut writer,
|
||||
&CutoverOperation::CreateCurrentService,
|
||||
)
|
||||
.expect("non-candidate operation"));
|
||||
assert!(writer.calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_candidate_handoff_accepts_only_unique_durable_forward_identity() {
|
||||
let operation = CutoverOperation::CreateCurrentCandidateRoot;
|
||||
let identity = safe_fs::StableObjectIdentity {
|
||||
volume_serial_number: 7,
|
||||
file_id: 11,
|
||||
kind: safe_fs::StableObjectKind::Directory,
|
||||
size: 0,
|
||||
};
|
||||
let fingerprint = StateFingerprint::digest("candidate-handoff-test", b"state");
|
||||
let durable = MutationRecord {
|
||||
sequence: 0,
|
||||
direction: MutationDirection::Forward,
|
||||
operation: operation.clone(),
|
||||
before_state: fingerprint.clone(),
|
||||
expected_effect: fingerprint.clone(),
|
||||
intent_written_at_epoch_seconds: 1,
|
||||
authority_evidence: None,
|
||||
effect: Some(MutationEffect {
|
||||
disposition: EffectDisposition::ExpectedEffect,
|
||||
observed: fingerprint,
|
||||
object_identity: Some(identity.clone()),
|
||||
observed_at_epoch_seconds: 2,
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
unique_forward_expected_effect_identity(std::slice::from_ref(&durable), &operation)
|
||||
.expect("unique durable identity"),
|
||||
Some(&identity)
|
||||
);
|
||||
|
||||
let mut pending = durable.clone();
|
||||
pending.effect = None;
|
||||
assert_eq!(
|
||||
unique_forward_expected_effect_identity(&[pending], &operation)
|
||||
.expect("pending intent is not durable effect"),
|
||||
None
|
||||
);
|
||||
|
||||
let mut missing_identity = durable.clone();
|
||||
missing_identity.effect.as_mut().unwrap().object_identity = None;
|
||||
assert!(unique_forward_expected_effect_identity(&[missing_identity], &operation).is_err());
|
||||
assert!(
|
||||
unique_forward_expected_effect_identity(&[durable.clone(), durable], &operation).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_candidate_freezes_complete_sorted_final_metadata() {
|
||||
let (plan, runtime, config, config_sha256) = candidate_inputs();
|
||||
let prepared = prepare_proxifyre_cutover_candidate(
|
||||
&plan,
|
||||
runtime,
|
||||
&config,
|
||||
&config_sha256,
|
||||
false,
|
||||
1_700_000_000,
|
||||
)
|
||||
.expect("prepare complete cutover candidate");
|
||||
|
||||
assert_eq!(
|
||||
prepared.snapshot().files.len(),
|
||||
CURRENT_PROXIFYRE_PACKAGE_FILES.len() + 3
|
||||
);
|
||||
assert!(valid_sha256(&prepared.snapshot().manifest_fingerprint));
|
||||
assert!(prepared.snapshot().files.windows(2).all(|pair| {
|
||||
candidate_relative_label(&pair[0].relative_path)
|
||||
< candidate_relative_label(&pair[1].relative_path)
|
||||
}));
|
||||
let config_spec = prepared
|
||||
.file_spec(Path::new("app-config.json"))
|
||||
.expect("config spec");
|
||||
assert_eq!(config_spec.role, CurrentCandidateFileRole::Config);
|
||||
assert_eq!(config_spec.sha256, config_sha256);
|
||||
|
||||
let marker: SystemProxifyreMarker = serde_json::from_slice(
|
||||
prepared
|
||||
.file_bytes(Path::new(PROXIFYRE_MARKER_FILE))
|
||||
.expect("marker bytes"),
|
||||
)
|
||||
.expect("marker JSON");
|
||||
assert!(marker.packet_filter_installed_by_proxy_warden);
|
||||
let receipt: InstallReceipt = serde_json::from_slice(
|
||||
prepared
|
||||
.file_bytes(Path::new(INSTALL_RECEIPT_FILENAME))
|
||||
.expect("receipt bytes"),
|
||||
)
|
||||
.expect("receipt JSON");
|
||||
assert_eq!(receipt.installed_at, 1_700_000_000);
|
||||
assert!(receipt
|
||||
.windows_packet_filter
|
||||
.as_ref()
|
||||
.is_some_and(|ownership| ownership.installed_by_proxy_warden));
|
||||
|
||||
let (_, repeated_runtime, _, _) = candidate_inputs_with_plan(&plan);
|
||||
let repeated = prepare_proxifyre_cutover_candidate(
|
||||
&plan,
|
||||
repeated_runtime,
|
||||
&config,
|
||||
&config_sha256,
|
||||
false,
|
||||
1_700_000_000,
|
||||
)
|
||||
.expect("repeat identical candidate");
|
||||
assert_eq!(
|
||||
prepared.snapshot().manifest_fingerprint,
|
||||
repeated.snapshot().manifest_fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_timestamp_and_preexisting_packet_filter_change_final_manifest() {
|
||||
let (plan, runtime, config, config_sha256) = candidate_inputs();
|
||||
let first = prepare_proxifyre_cutover_candidate(
|
||||
&plan,
|
||||
runtime,
|
||||
&config,
|
||||
&config_sha256,
|
||||
false,
|
||||
1_700_000_000,
|
||||
)
|
||||
.expect("first candidate");
|
||||
let (_, runtime, _, _) = candidate_inputs_with_plan(&plan);
|
||||
let second = prepare_proxifyre_cutover_candidate(
|
||||
&plan,
|
||||
runtime,
|
||||
&config,
|
||||
&config_sha256,
|
||||
true,
|
||||
1_700_000_001,
|
||||
)
|
||||
.expect("second candidate");
|
||||
|
||||
assert_ne!(
|
||||
first.snapshot().manifest_fingerprint,
|
||||
second.snapshot().manifest_fingerprint
|
||||
);
|
||||
let receipt: InstallReceipt = serde_json::from_slice(
|
||||
second
|
||||
.file_bytes(Path::new(INSTALL_RECEIPT_FILENAME))
|
||||
.expect("receipt bytes"),
|
||||
)
|
||||
.expect("receipt JSON");
|
||||
assert!(receipt.windows_packet_filter.is_none());
|
||||
}
|
||||
|
||||
fn candidate_inputs() -> (
|
||||
ProxifyreCutoverPlan,
|
||||
PreparedProxifyreRuntime,
|
||||
Vec<u8>,
|
||||
String,
|
||||
) {
|
||||
let app_root = std::env::temp_dir().join("proxywarden-cutover-contract");
|
||||
let config = br#"{"proxies":[],"applications":[]}"#.to_vec();
|
||||
let config_sha256 = format!("{:x}", Sha256::digest(&config));
|
||||
let package_sha256 = "a".repeat(64);
|
||||
let plan = ProxifyreCutoverPlan::new(
|
||||
&app_root,
|
||||
PathBuf::from(r"C:\Tools\ProxiFyre"),
|
||||
LegacyServiceState::Stopped,
|
||||
"2.2.1".to_owned(),
|
||||
package_sha256,
|
||||
config_sha256.clone(),
|
||||
"b".repeat(64),
|
||||
uuid::Uuid::new_v4().hyphenated().to_string(),
|
||||
);
|
||||
let (_, runtime, _, _) = candidate_inputs_with_plan(&plan);
|
||||
(plan, runtime, config, config_sha256)
|
||||
}
|
||||
|
||||
fn candidate_inputs_with_plan(
|
||||
plan: &ProxifyreCutoverPlan,
|
||||
) -> (
|
||||
ProxifyreCutoverPlan,
|
||||
PreparedProxifyreRuntime,
|
||||
Vec<u8>,
|
||||
String,
|
||||
) {
|
||||
let files: Vec<_> = CURRENT_PROXIFYRE_PACKAGE_FILES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, name)| {
|
||||
let bytes = vec![u8::try_from(index + 1).expect("small fixture index")];
|
||||
ProxifyreStagedFile {
|
||||
relative_path: (*name).to_owned(),
|
||||
sha256: format!("{:x}", Sha256::digest(&bytes)),
|
||||
size: bytes.len() as u64,
|
||||
bytes,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let runtime = PreparedProxifyreRuntime {
|
||||
proof: PrivilegedPackageProof {
|
||||
component_id: ComponentId::Proxifyre,
|
||||
version: plan.bundled_version.clone(),
|
||||
asset_name: "proxifyre.zip".to_owned(),
|
||||
sha256: plan.package_fingerprint.clone(),
|
||||
size: 123,
|
||||
source: PackageSource::Bundled,
|
||||
independent_proof: None,
|
||||
},
|
||||
installed_files: installed_file_inventory(&files),
|
||||
files,
|
||||
};
|
||||
let config = br#"{"proxies":[],"applications":[]}"#.to_vec();
|
||||
let config_sha256 = format!("{:x}", Sha256::digest(&config));
|
||||
(plan.clone(), runtime, config, config_sha256)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,687 +0,0 @@
|
||||
//! Static-template PowerShell generation for explicit ProxiFyre package actions.
|
||||
|
||||
use crate::component_detection::{default_proxifyre_install_dir, DetectedProxyfier};
|
||||
use crate::powershell::escape_single as escape_powershell_single;
|
||||
use crate::proxifyre_ownership::ManagedProxiFyreOwnership;
|
||||
use std::path::Path;
|
||||
|
||||
const PROXIFYRE_RELEASE_API_URL: &str =
|
||||
"https://api.github.com/repos/wiresock/proxifyre/releases/latest";
|
||||
const NDISAPI_RELEASE_API_URL: &str =
|
||||
"https://api.github.com/repos/wiresock/ndisapi/releases/latest";
|
||||
const PROXIFYRE_PINNED_RELEASE_TAG: &str = "v2.2.1";
|
||||
const NDISAPI_PINNED_RELEASE_TAG: &str = "v3.6.2";
|
||||
const NDISAPI_PINNED_INSTALLER_VERSION: &str = "3.6.2.1";
|
||||
const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe";
|
||||
const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe";
|
||||
pub const PROXIFYRE_FIREWALL_INBOUND_RULE: &str = "ProxyWarden.ProxiFyre.Inbound";
|
||||
pub const PROXIFYRE_FIREWALL_OUTBOUND_RULE: &str = "ProxyWarden.ProxiFyre.Outbound";
|
||||
|
||||
pub fn install_proxifyre_script(generated_config_path: &Path) -> String {
|
||||
install_proxifyre_script_with_bundle(generated_config_path, None)
|
||||
}
|
||||
|
||||
pub fn install_proxifyre_script_with_bundle(
|
||||
generated_config_path: &Path,
|
||||
bundled_asset_dir: Option<&Path>,
|
||||
) -> String {
|
||||
install_proxifyre_script_for_target(
|
||||
generated_config_path,
|
||||
bundled_asset_dir,
|
||||
&default_proxifyre_install_dir(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn install_proxifyre_script_for_target(
|
||||
generated_config_path: &Path,
|
||||
bundled_asset_dir: Option<&Path>,
|
||||
target_dir: &Path,
|
||||
) -> String {
|
||||
let mut script = String::new();
|
||||
script.push_str(&format!(
|
||||
"$targetDir = '{}'\n",
|
||||
escape_powershell_single(&target_dir.display().to_string())
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$generatedConfigPath = '{}'\n",
|
||||
escape_powershell_single(&generated_config_path.display().to_string())
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$bundledAssetDir = '{}'\n",
|
||||
escape_powershell_single(
|
||||
&bundled_asset_dir
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_default()
|
||||
)
|
||||
));
|
||||
script.push_str("$script:bundledAssetDir = [string]$bundledAssetDir\n");
|
||||
script.push_str(&format!(
|
||||
"$proxifyreReleaseApi = '{}'\n",
|
||||
escape_powershell_single(PROXIFYRE_RELEASE_API_URL)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$ndisapiReleaseApi = '{}'\n",
|
||||
escape_powershell_single(NDISAPI_RELEASE_API_URL)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$proxifyrePinnedReleaseTag = '{}'\n",
|
||||
escape_powershell_single(PROXIFYRE_PINNED_RELEASE_TAG)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$ndisapiPinnedReleaseTag = '{}'\n",
|
||||
escape_powershell_single(NDISAPI_PINNED_RELEASE_TAG)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$ndisapiPinnedInstallerVersion = '{}'\n",
|
||||
escape_powershell_single(NDISAPI_PINNED_INSTALLER_VERSION)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$vcRedistX64Url = '{}'\n",
|
||||
escape_powershell_single(VC_REDIST_X64_URL)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$vcRedistX86Url = '{}'\n",
|
||||
escape_powershell_single(VC_REDIST_X86_URL)
|
||||
));
|
||||
script.push_str(
|
||||
r#"
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
function Get-NativeArchitecture {
|
||||
$processor = Get-CimInstance Win32_Processor | Select-Object -First 1
|
||||
if ($null -ne $processor -and $processor.Architecture -eq 12) { return 'ARM64' }
|
||||
if ([Environment]::Is64BitOperatingSystem) { return 'x64' }
|
||||
return 'x86'
|
||||
}
|
||||
|
||||
function Get-SafeUriForLog([string]$uri) {
|
||||
try {
|
||||
$parsed = [Uri]$uri
|
||||
$port = if ($parsed.IsDefaultPort) { '' } else { ":$($parsed.Port)" }
|
||||
return "$($parsed.Scheme)://$($parsed.Host)$port$($parsed.AbsolutePath)"
|
||||
} catch {
|
||||
return '<invalid-url>'
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-ReleaseApi([string]$uri, [string]$label) {
|
||||
$safeUri = Get-SafeUriForLog $uri
|
||||
$headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/vnd.github+json' }
|
||||
$lastError = $null
|
||||
|
||||
foreach ($attempt in 1..3) {
|
||||
try {
|
||||
return Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 60 -MaximumRedirection 10
|
||||
} catch {
|
||||
$lastError = $_.Exception.Message
|
||||
if ($attempt -lt 3) {
|
||||
Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw "Не удалось получить metadata для $label ($safeUri): $lastError"
|
||||
}
|
||||
|
||||
function New-ReleaseAsset([string]$name, [string]$url) {
|
||||
[PSCustomObject]@{
|
||||
name = $name
|
||||
browser_download_url = $url
|
||||
digest = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ReleaseAsset([string]$apiUri, [string]$pattern, [string]$label, $fallbackAsset, [int]$fallbackPercent) {
|
||||
try {
|
||||
$release = Invoke-ReleaseApi $apiUri $label
|
||||
return Select-Asset $release.assets $pattern $label
|
||||
} catch {
|
||||
$fallbackUri = Get-SafeUriForLog $fallbackAsset.browser_download_url
|
||||
Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'running' $fallbackPercent "GitHub API недоступен для $label. Пробую прямую ссылку: $fallbackUri"
|
||||
return $fallbackAsset
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PinnedProxiFyreAsset([string]$arch) {
|
||||
$archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' }
|
||||
$name = "ProxiFyre-$proxifyrePinnedReleaseTag-$archLabel-signed.zip"
|
||||
$url = "https://github.com/wiresock/proxifyre/releases/download/$proxifyrePinnedReleaseTag/$name"
|
||||
return New-ReleaseAsset $name $url
|
||||
}
|
||||
|
||||
function Get-PinnedWindowsPacketFilterAsset([string]$arch) {
|
||||
$archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' }
|
||||
$name = "Windows.Packet.Filter.$ndisapiPinnedInstallerVersion.$archLabel.msi"
|
||||
$url = "https://github.com/wiresock/ndisapi/releases/download/$ndisapiPinnedReleaseTag/$name"
|
||||
return New-ReleaseAsset $name $url
|
||||
}
|
||||
|
||||
function Complete-Download([string]$partialPath, [string]$path, [string]$label) {
|
||||
if (-not (Test-Path -LiteralPath $partialPath)) {
|
||||
throw "${label}: файл не был создан."
|
||||
}
|
||||
|
||||
$item = Get-Item -LiteralPath $partialPath
|
||||
if ($item.Length -le 0) {
|
||||
throw "${label}: скачанный файл пустой."
|
||||
}
|
||||
|
||||
Move-Item -LiteralPath $partialPath -Destination $path -Force
|
||||
}
|
||||
|
||||
function Invoke-WebClientDownload([string]$uri, [string]$partialPath) {
|
||||
$client = New-Object System.Net.WebClient
|
||||
try {
|
||||
$client.Headers.Add('User-Agent', 'proxywarden')
|
||||
$client.Headers.Add('Accept', 'application/octet-stream,*/*')
|
||||
$client.DownloadFile($uri, $partialPath)
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-CurlDownload([string]$uri, [string]$partialPath) {
|
||||
$curl = Get-Command 'curl.exe' -ErrorAction SilentlyContinue
|
||||
if ($null -eq $curl) {
|
||||
throw 'curl.exe не найден.'
|
||||
}
|
||||
|
||||
$curlOutput = & $curl.Source --silent --show-error --fail --location --retry 2 --retry-delay 2 --connect-timeout 30 --max-time 180 --user-agent 'proxywarden' --output $partialPath --url $uri 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$curlMessage = ($curlOutput | Out-String).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($curlMessage)) {
|
||||
throw "curl.exe завершился с кодом $LASTEXITCODE."
|
||||
}
|
||||
|
||||
throw "curl.exe завершился с кодом ${LASTEXITCODE}: $curlMessage"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Download([string]$uri, [string]$path, [string]$label) {
|
||||
$safeUri = Get-SafeUriForLog $uri
|
||||
$partialPath = "$path.part"
|
||||
$headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/octet-stream,*/*' }
|
||||
$webRequestError = $null
|
||||
$webClientError = $null
|
||||
$curlError = $null
|
||||
|
||||
foreach ($attempt in 1..3) {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $partialPath -Headers $headers -TimeoutSec 180 -MaximumRedirection 10
|
||||
Complete-Download $partialPath $path $label
|
||||
return
|
||||
} catch {
|
||||
$webRequestError = $_.Exception.Message
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
if ($attempt -lt 3) {
|
||||
Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
Invoke-WebClientDownload $uri $partialPath
|
||||
Complete-Download $partialPath $path $label
|
||||
return
|
||||
} catch {
|
||||
$webClientError = $_.Exception.Message
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
try {
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
Invoke-CurlDownload $uri $partialPath
|
||||
Complete-Download $partialPath $path $label
|
||||
return
|
||||
} catch {
|
||||
$curlError = $_.Exception.Message
|
||||
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$errors = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($webRequestError)) { $errors += "Invoke-WebRequest: $webRequestError" }
|
||||
if (-not [string]::IsNullOrWhiteSpace($webClientError)) { $errors += "WebClient: $webClientError" }
|
||||
if (-not [string]::IsNullOrWhiteSpace($curlError)) { $errors += "curl.exe: $curlError" }
|
||||
$details = if ($errors.Count -gt 0) { $errors -join ' | ' } else { 'неизвестная ошибка' }
|
||||
|
||||
throw "Не удалось скачать $label ($safeUri): $details"
|
||||
}
|
||||
|
||||
function Select-Asset($assets, [string]$pattern, [string]$label) {
|
||||
$asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1
|
||||
if ($null -eq $asset) { throw "Не найден подходящий asset для $label ($pattern)." }
|
||||
return $asset
|
||||
}
|
||||
|
||||
function Verify-AssetHash([string]$path, $asset) {
|
||||
if ($asset.digest -match '^sha256:(.+)$') {
|
||||
$expected = $Matches[1].ToLowerInvariant()
|
||||
$actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected) {
|
||||
throw "SHA256 не совпал для $($asset.name). Ожидалось $expected, получилось $actual."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ExitCode($process, [string]$label) {
|
||||
if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) {
|
||||
throw "$label завершился с кодом $($process.ExitCode)."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-InstalledProgram([string]$pattern) {
|
||||
$paths = @(
|
||||
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||
)
|
||||
return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -match $pattern } |
|
||||
Select-Object -First 1
|
||||
}
|
||||
|
||||
function Test-VcRuntime([string]$arch) {
|
||||
$pattern = if ($arch -eq 'ARM64') {
|
||||
'Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)'
|
||||
} else {
|
||||
"Microsoft Visual C\+\+.*Redistributable.*\($arch\)"
|
||||
}
|
||||
|
||||
return $null -ne (Get-InstalledProgram $pattern)
|
||||
}
|
||||
|
||||
function Test-WindowsPacketFilter {
|
||||
return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI')
|
||||
}
|
||||
|
||||
function Get-LogTail([string]$path) {
|
||||
if (-not (Test-Path -LiteralPath $path)) { return '' }
|
||||
return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' '
|
||||
}
|
||||
|
||||
function Get-BundledAssetDir {
|
||||
$dir = [string]$script:bundledAssetDir
|
||||
if ([string]::IsNullOrWhiteSpace($dir)) { return $null }
|
||||
if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return $null }
|
||||
return $dir
|
||||
}
|
||||
|
||||
function Get-BundledAssetManifest {
|
||||
$assetDir = Get-BundledAssetDir
|
||||
if ($null -eq $assetDir) { return $null }
|
||||
$manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json')
|
||||
if (-not (Test-Path -LiteralPath $manifestPath)) { return $null }
|
||||
|
||||
try {
|
||||
return Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Не удалось прочитать manifest встроенных пакетов ProxiFyre: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
$script:bundledAssetManifest = Get-BundledAssetManifest
|
||||
|
||||
function Get-BundledAssetHash([string]$name) {
|
||||
if ($null -eq $script:bundledAssetManifest -or $null -eq $script:bundledAssetManifest.files) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$entry = $script:bundledAssetManifest.files |
|
||||
Where-Object { $_.name -eq $name } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $entry) { return $null }
|
||||
return [string]$entry.sha256
|
||||
}
|
||||
|
||||
function Verify-BundledAssetHash([string]$path, [string]$label) {
|
||||
$name = [IO.Path]::GetFileName($path)
|
||||
$expected = Get-BundledAssetHash $name
|
||||
if ([string]::IsNullOrWhiteSpace($expected)) {
|
||||
throw "Во встроенном manifest нет SHA256 для $label ($name)."
|
||||
}
|
||||
|
||||
$actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected.ToLowerInvariant()) {
|
||||
throw "SHA256 не совпал для встроенного $label ($name). Ожидалось $expected, получилось $actual."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BundledAsset([string]$pattern, [string]$label) {
|
||||
$assetDir = Get-BundledAssetDir
|
||||
if ($null -eq $assetDir) { return $null }
|
||||
|
||||
$asset = Get-ChildItem -LiteralPath $assetDir -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match $pattern } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $asset) { return $null }
|
||||
|
||||
Verify-BundledAssetHash $asset.FullName $label
|
||||
return $asset.FullName
|
||||
}
|
||||
|
||||
function Copy-BundledAsset([string]$sourcePath, [string]$targetPath, [string]$label) {
|
||||
Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Force
|
||||
$item = Get-Item -LiteralPath $targetPath
|
||||
if ($item.Length -le 0) {
|
||||
throw "${label}: встроенный файл пустой."
|
||||
}
|
||||
}
|
||||
|
||||
$arch = Get-NativeArchitecture
|
||||
$workDir = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-proxifyre-install'
|
||||
$extractDir = Join-Path $workDir 'proxifyre'
|
||||
Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $workDir, $extractDir, $targetDir | Out-Null
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 8 'Проверяю сетевой драйвер Windows Packet Filter.'
|
||||
$packetFilterAlreadyInstalled = Test-WindowsPacketFilter
|
||||
if (-not $packetFilterAlreadyInstalled) {
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 14 'Готовлю Windows Packet Filter.'
|
||||
$ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' }
|
||||
$bundledNdisPath = Get-BundledAsset $ndisPattern 'Windows Packet Filter'
|
||||
if ($null -ne $bundledNdisPath) {
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Использую встроенный Windows Packet Filter.'
|
||||
$ndisPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledNdisPath))
|
||||
Copy-BundledAsset $bundledNdisPath $ndisPath 'Windows Packet Filter'
|
||||
} else {
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Скачиваю Windows Packet Filter.'
|
||||
$ndisAsset = Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter' (Get-PinnedWindowsPacketFilterAsset $arch) 16
|
||||
$ndisPath = Join-Path $workDir $ndisAsset.name
|
||||
Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'
|
||||
Verify-AssetHash $ndisPath $ndisAsset
|
||||
}
|
||||
$ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log'
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 26 'Устанавливаю Windows Packet Filter.'
|
||||
$ndisProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $ndisPath, '/qn', '/norestart', '/L*v', $ndisLogPath) -Wait -PassThru -WindowStyle Hidden
|
||||
if ($ndisProcess.ExitCode -ne 0 -and $ndisProcess.ExitCode -ne 3010 -and -not (Test-WindowsPacketFilter)) {
|
||||
$ndisLogTail = Get-LogTail $ndisLogPath
|
||||
throw "Windows Packet Filter завершился с кодом $($ndisProcess.ExitCode). MSI log: $ndisLogPath $ndisLogTail"
|
||||
}
|
||||
}
|
||||
Write-ProxyWardenProgress 'install' 'packet-filter' 'succeeded' 36 'Сетевой драйвер готов.'
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 40 'Проверяю Microsoft Visual C++ Runtime.'
|
||||
if (-not (Test-VcRuntime $arch)) {
|
||||
$vcBundledPattern = if ($arch -eq 'x86') { '^vc_redist\.x86\.exe$' } else { '^vc_redist\.x64\.exe$' }
|
||||
$vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url }
|
||||
$bundledVcPath = Get-BundledAsset $vcBundledPattern 'Microsoft Visual C++ Runtime'
|
||||
$vcRedistPath = Join-Path $workDir 'vc_redist.exe'
|
||||
if ($null -ne $bundledVcPath) {
|
||||
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Использую встроенный Microsoft Visual C++ Runtime.'
|
||||
Copy-BundledAsset $bundledVcPath $vcRedistPath 'Microsoft Visual C++ Runtime'
|
||||
} else {
|
||||
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Скачиваю Microsoft Visual C++ Runtime.'
|
||||
Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'
|
||||
}
|
||||
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 54 'Устанавливаю Microsoft Visual C++ Runtime.'
|
||||
$vcProcess = Start-Process -FilePath $vcRedistPath -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru -WindowStyle Hidden
|
||||
if ($vcProcess.ExitCode -ne 0 -and $vcProcess.ExitCode -ne 3010 -and $vcProcess.ExitCode -ne 1638 -and -not (Test-VcRuntime $arch)) {
|
||||
throw "Visual C++ Runtime завершился с кодом $($vcProcess.ExitCode)."
|
||||
}
|
||||
}
|
||||
Write-ProxyWardenProgress 'install' 'vc-runtime' 'succeeded' 62 'Среда запуска готова.'
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 66 'Готовлю ProxiFyre.'
|
||||
$proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' }
|
||||
$bundledProxiFyrePath = Get-BundledAsset $proxifyrePattern 'ProxiFyre'
|
||||
if ($null -ne $bundledProxiFyrePath) {
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Использую встроенный ProxiFyre.'
|
||||
$proxifyreZipPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledProxiFyrePath))
|
||||
Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath 'ProxiFyre'
|
||||
} else {
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Скачиваю ProxiFyre.'
|
||||
$proxifyreAsset = Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre' (Get-PinnedProxiFyreAsset $arch) 68
|
||||
$proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name
|
||||
Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'
|
||||
Verify-AssetHash $proxifyreZipPath $proxifyreAsset
|
||||
}
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 76 'Распаковываю ProxiFyre.'
|
||||
Expand-Archive -LiteralPath $proxifyreZipPath -DestinationPath $extractDir -Force
|
||||
$proxifyreExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'ProxiFyre.exe' | Select-Object -First 1
|
||||
if ($null -eq $proxifyreExe) { throw 'В архиве ProxiFyre не найден ProxiFyre.exe.' }
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 82 'Копирую ProxiFyre в папку установки.'
|
||||
Copy-Item -Path (Join-Path $proxifyreExe.Directory.FullName '*') -Destination $targetDir -Recurse -Force
|
||||
|
||||
$configTarget = Join-Path $targetDir 'app-config.json'
|
||||
if (Test-Path -LiteralPath $generatedConfigPath) {
|
||||
Copy-Item -LiteralPath $generatedConfigPath -Destination $configTarget -Force
|
||||
} elseif (-not (Test-Path -LiteralPath $configTarget)) {
|
||||
$emptyConfig = '{"logLevel":"Info","bypassLan":true,"proxies":[]}'
|
||||
Set-Content -LiteralPath $configTarget -Value $emptyConfig -Encoding UTF8
|
||||
}
|
||||
|
||||
$markerPath = Join-Path $targetDir 'proxywarden-component.json'
|
||||
$markerJson = [ordered]@{
|
||||
manager = 'ProxyWarden'
|
||||
component = 'proxifyre'
|
||||
serviceName = 'ProxiFyreService'
|
||||
installedAt = (Get-Date).ToString('o')
|
||||
installRoot = $targetDir
|
||||
packetFilterInstalledByProxyWarden = (-not $packetFilterAlreadyInstalled)
|
||||
} | ConvertTo-Json -Depth 4
|
||||
[IO.File]::WriteAllText($markerPath, $markerJson, [Text.UTF8Encoding]::new($false))
|
||||
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 90 'Устанавливаю и запускаю службу ProxiFyre.'
|
||||
Push-Location $targetDir
|
||||
try {
|
||||
& .\ProxiFyre.exe stop | Out-Null
|
||||
& .\ProxiFyre.exe uninstall | Out-Null
|
||||
& .\ProxiFyre.exe install
|
||||
if ($LASTEXITCODE -ne 0) { throw "ProxiFyre.exe install завершился с кодом $LASTEXITCODE." }
|
||||
& .\ProxiFyre.exe start
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Start-Service -Name 'ProxiFyreService' -ErrorAction Stop
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-ProxyWardenProgress 'install' 'proxifyre' 'succeeded' 100 'ProxiFyre и сетевой драйвер готовы.'
|
||||
"#,
|
||||
);
|
||||
|
||||
script
|
||||
}
|
||||
|
||||
pub fn configure_proxifyre_firewall_script(executable_path: &Path) -> String {
|
||||
let executable_path = escape_powershell_single(&executable_path.display().to_string());
|
||||
format!(
|
||||
r#"
|
||||
$exePath = '{executable_path}'
|
||||
if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) {{
|
||||
throw "ProxiFyre.exe не найден по подтвержденному пути: $exePath"
|
||||
}}
|
||||
|
||||
$ruleSpecs = @(
|
||||
@{{ Name = '{PROXIFYRE_FIREWALL_INBOUND_RULE}'; DisplayName = 'ProxyWarden: ProxiFyre (входящие)'; Direction = 'Inbound' }},
|
||||
@{{ Name = '{PROXIFYRE_FIREWALL_OUTBOUND_RULE}'; DisplayName = 'ProxyWarden: ProxiFyre (исходящие)'; Direction = 'Outbound' }}
|
||||
)
|
||||
|
||||
foreach ($rule in $ruleSpecs) {{
|
||||
Get-NetFirewallRule -Name $rule.Name -ErrorAction SilentlyContinue |
|
||||
Remove-NetFirewallRule -ErrorAction Stop
|
||||
New-NetFirewallRule `
|
||||
-Name $rule.Name `
|
||||
-DisplayName $rule.DisplayName `
|
||||
-Group 'ProxyWarden' `
|
||||
-Program $exePath `
|
||||
-Direction $rule.Direction `
|
||||
-Action Allow `
|
||||
-Profile Any `
|
||||
-Enabled True `
|
||||
-ErrorAction Stop | Out-Null
|
||||
}}
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn uninstall_proxifyre_script(
|
||||
detected: Option<&DetectedProxyfier>,
|
||||
ownership: &ManagedProxiFyreOwnership,
|
||||
) -> String {
|
||||
let mut script = String::new();
|
||||
let install_dir = detected
|
||||
.map(|detected| detected.install_dir.display().to_string())
|
||||
.unwrap_or_default();
|
||||
let executable_path = detected
|
||||
.map(|detected| detected.executable_path.display().to_string())
|
||||
.unwrap_or_default();
|
||||
script.push_str(&format!(
|
||||
"$installDir = '{}'\n",
|
||||
escape_powershell_single(&install_dir)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$exePath = '{}'\n",
|
||||
escape_powershell_single(&executable_path)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$serviceName = '{}'\n",
|
||||
escape_powershell_single(&ownership.service_name)
|
||||
));
|
||||
script.push_str(&format!(
|
||||
"$removePacketFilter = ${}\n",
|
||||
if ownership.remove_packet_filter {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
}
|
||||
));
|
||||
script.push_str(
|
||||
r#"
|
||||
function Get-InstalledProgram([string]$pattern) {
|
||||
$paths = @(
|
||||
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||
)
|
||||
return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -match $pattern } |
|
||||
Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString
|
||||
}
|
||||
|
||||
function Test-WindowsPacketFilter {
|
||||
return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI')
|
||||
}
|
||||
|
||||
function Get-LogTail([string]$path) {
|
||||
if (-not (Test-Path -LiteralPath $path)) { return '' }
|
||||
return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' '
|
||||
}
|
||||
|
||||
function Resolve-MsiProductCode($program, [string]$label) {
|
||||
if ($null -eq $program) { return $null }
|
||||
if ($program.PSChildName -match '^\{[0-9A-Fa-f-]{36}\}$') {
|
||||
return $program.PSChildName
|
||||
}
|
||||
foreach ($candidate in @($program.QuietUninstallString, $program.UninstallString)) {
|
||||
if ($candidate -match '\{[0-9A-Fa-f-]{36}\}') {
|
||||
return $Matches[0]
|
||||
}
|
||||
}
|
||||
throw "Не удалось найти MSI product code для $label. Отказываюсь запускать произвольный UninstallString."
|
||||
}
|
||||
|
||||
function Uninstall-MsiProgram($program, [string]$label, [string]$logPath) {
|
||||
$productCode = Resolve-MsiProductCode $program $label
|
||||
if ([string]::IsNullOrWhiteSpace($productCode)) { return }
|
||||
$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/x', $productCode, '/qn', '/norestart', '/L*v', $logPath) -Wait -PassThru -WindowStyle Hidden
|
||||
if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) {
|
||||
$logTail = Get-LogTail $logPath
|
||||
throw "$label uninstall завершился с кодом $($process.ExitCode). MSI log: $logPath $logTail"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ServiceBinaryPath([string]$pathName) {
|
||||
if ([string]::IsNullOrWhiteSpace($pathName)) { return $null }
|
||||
$pathName = $pathName.Trim()
|
||||
if ($pathName.StartsWith('"')) {
|
||||
$closingQuote = $pathName.IndexOf('"', 1)
|
||||
if ($closingQuote -lt 2) { return $null }
|
||||
return $pathName.Substring(1, $closingQuote - 1)
|
||||
}
|
||||
return ($pathName -split '\s+', 2)[0]
|
||||
}
|
||||
|
||||
function Find-ManagedProxiFyreService {
|
||||
$escapedName = $serviceName.Replace("'", "''")
|
||||
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $record) { return $null }
|
||||
$binaryPath = Get-ServiceBinaryPath $record.PathName
|
||||
if (-not [string]::Equals($binaryPath, $exePath, [StringComparison]::OrdinalIgnoreCase)) { return $null }
|
||||
return Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Get-ServiceProcessId([string]$name) {
|
||||
$escapedName = $name.Replace("'", "''")
|
||||
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $record) { return 0 }
|
||||
return [int]$record.ProcessId
|
||||
}
|
||||
|
||||
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 10 'Останавливаю службу ProxiFyre.'
|
||||
$service = Find-ManagedProxiFyreService
|
||||
if ($null -ne $service -and $service.Status -ne 'Stopped') {
|
||||
try {
|
||||
if ($service.CanStop) { Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue }
|
||||
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||||
if ($null -ne $service) { $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$service = Find-ManagedProxiFyreService
|
||||
if ($null -ne $service -and $service.Status -ne 'Stopped') {
|
||||
$processId = Get-ServiceProcessId $service.Name
|
||||
if ($processId -gt 0) {
|
||||
taskkill.exe /PID $processId /F | Out-Null
|
||||
Start-Sleep -Milliseconds 700
|
||||
}
|
||||
}
|
||||
|
||||
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 34 'Удаляю службу и файлы ProxiFyre.'
|
||||
if (-not [string]::IsNullOrWhiteSpace($exePath) -and (Test-Path -LiteralPath $exePath)) {
|
||||
Push-Location (Split-Path -Parent $exePath)
|
||||
try {
|
||||
& $exePath uninstall | Out-Null
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
$service = Find-ManagedProxiFyreService
|
||||
if ($null -ne $service) {
|
||||
sc.exe delete $service.Name | Out-Null
|
||||
}
|
||||
|
||||
foreach ($firewallRuleName in @('ProxyWarden.ProxiFyre.Inbound', 'ProxyWarden.ProxiFyre.Outbound')) {
|
||||
Get-NetFirewallRule -Name $firewallRuleName -ErrorAction SilentlyContinue |
|
||||
Remove-NetFirewallRule -ErrorAction Stop
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($installDir) -and (Test-Path -LiteralPath $installDir)) {
|
||||
Remove-Item -LiteralPath $installDir -Recurse -Force
|
||||
}
|
||||
|
||||
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'succeeded' 58 'ProxiFyre удален.'
|
||||
|
||||
if ($removePacketFilter) {
|
||||
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 68 'Проверяю Windows Packet Filter.'
|
||||
$packetFilter = Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI'
|
||||
if ($null -ne $packetFilter) {
|
||||
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 78 'Удаляю Windows Packet Filter.'
|
||||
$driverLogPath = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-windows-packet-filter-uninstall.log'
|
||||
Uninstall-MsiProgram $packetFilter 'Windows Packet Filter' $driverLogPath
|
||||
}
|
||||
if (Test-WindowsPacketFilter) {
|
||||
throw 'Windows Packet Filter все еще найден после удаления. Возможно, Windows требует перезагрузку.'
|
||||
}
|
||||
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'succeeded' 100 'ProxiFyre и принадлежащий ProxyWarden Windows Packet Filter удалены.'
|
||||
} else {
|
||||
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'skipped' 100 'Windows Packet Filter оставлен: marker не подтверждает владение ProxyWarden.'
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
script
|
||||
}
|
||||
@@ -10,10 +10,15 @@ use crate::adapters::proxy_router::{
|
||||
use crate::clock::Clock;
|
||||
use crate::command_dto::{ActivityEntryDto, CommandError};
|
||||
use crate::component_detection::{
|
||||
detect_proxyfier_install, detect_proxyfier_install_with_host, detect_singbox_install,
|
||||
DetectedProxyfier, DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost,
|
||||
detect_proxyfier_install, detect_singbox_install, inventory_proxyfier_with_host,
|
||||
inventory_proxyfier_with_host_and_current_root, DetectedProxyfier, DetectedSingBox,
|
||||
ProxyfierDetectionHost, SystemProxyfierDetectionHost,
|
||||
};
|
||||
use crate::component_status::components_or_defaults_with_detection;
|
||||
use crate::component_inventory::{
|
||||
run_authorized_component_action, AuthorizedActionError, ComponentClassification,
|
||||
InventoryAction,
|
||||
};
|
||||
use crate::component_status::components_with_detection;
|
||||
use crate::models::{ActivityEntry, ActivityLevel};
|
||||
use crate::safe_fs;
|
||||
use crate::storage::JsonStorage;
|
||||
@@ -58,6 +63,7 @@ pub trait ProxyApplyHelper {
|
||||
|
||||
pub struct DetectedProxyApplyHelper<H = SystemProxyfierDetectionHost> {
|
||||
host: H,
|
||||
current_root: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
|
||||
@@ -68,7 +74,19 @@ impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
|
||||
|
||||
impl<H> From<H> for DetectedProxyApplyHelper<H> {
|
||||
fn from(host: H) -> Self {
|
||||
Self { host }
|
||||
Self {
|
||||
host,
|
||||
current_root: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> DetectedProxyApplyHelper<H> {
|
||||
pub fn with_current_root(host: H, current_root: std::path::PathBuf) -> Self {
|
||||
Self {
|
||||
host,
|
||||
current_root: Some(current_root),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,11 +98,36 @@ where
|
||||
&self,
|
||||
request: HelperApplyRequest<'_>,
|
||||
) -> Result<HelperApplyResult, CommandError> {
|
||||
let Some(detected) = detect_proxyfier_install_with_host(&self.host) else {
|
||||
let inventory = self.current_root.as_deref().map_or_else(
|
||||
|| inventory_proxyfier_with_host(&self.host),
|
||||
|current_root| inventory_proxyfier_with_host_and_current_root(&self.host, current_root),
|
||||
);
|
||||
if inventory.classification() == ComponentClassification::Missing {
|
||||
return staged_apply_result(request);
|
||||
};
|
||||
}
|
||||
if inventory.classification() == ComponentClassification::ManagedLegacy {
|
||||
return Err(CommandError::new(
|
||||
"legacy_cutover_required",
|
||||
"Старая установка ProxiFyre не изменена. Сначала выполните явный перенос компонента.",
|
||||
));
|
||||
}
|
||||
run_authorized_component_action(&inventory, InventoryAction::Apply, |_| {
|
||||
if inventory.classification() == ComponentClassification::ManagedCurrent {
|
||||
return staged_managed_current_result(request);
|
||||
}
|
||||
Err(CommandError::new(
|
||||
"ownership_mismatch",
|
||||
"Найденный ProxiFyre не прошел ownership-проверку.",
|
||||
))
|
||||
})
|
||||
.map_err(authorized_action_error)
|
||||
}
|
||||
}
|
||||
|
||||
apply_to_detected_proxyfier(request, &detected)
|
||||
fn authorized_action_error(error: AuthorizedActionError<CommandError>) -> CommandError {
|
||||
match error {
|
||||
AuthorizedActionError::Denied(issue) => CommandError::new(issue.code, issue.message),
|
||||
AuthorizedActionError::Runner(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,10 +155,12 @@ pub fn apply_profiles_with_services_and_detection(
|
||||
detected_proxyfier: Option<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||
let transaction =
|
||||
crate::configuration_transaction::ConfigurationTransaction::begin(storage, None)
|
||||
.map_err(storage_error)?;
|
||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let targets = storage.read_targets().map_err(storage_error)?;
|
||||
let components =
|
||||
components_or_defaults_with_detection(storage, detected_proxyfier, detected_singbox)?;
|
||||
let components = components_with_detection(detected_proxyfier, detected_singbox);
|
||||
let generated =
|
||||
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
|
||||
Ok(generated) => generated,
|
||||
@@ -139,10 +184,18 @@ pub fn apply_profiles_with_services_and_detection(
|
||||
config_contents: generated.contents.as_str(),
|
||||
})?;
|
||||
|
||||
crate::route_state::record_prepared_locked(
|
||||
storage,
|
||||
crate::privileged_jobs::ManagedComponent::Proxifyre,
|
||||
)
|
||||
.map_err(storage_error)?;
|
||||
if helper_result.success {
|
||||
transaction.commit().map_err(storage_error)?;
|
||||
} else {
|
||||
drop(transaction);
|
||||
}
|
||||
let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result);
|
||||
storage
|
||||
.append_activity(activity.clone())
|
||||
.map_err(storage_error)?;
|
||||
let _ = storage.append_activity(activity.clone());
|
||||
|
||||
Ok(ApplyProfilesResponse {
|
||||
success: helper_result.success,
|
||||
@@ -158,38 +211,7 @@ pub fn apply_profiles_with_services_and_detection(
|
||||
}
|
||||
|
||||
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
|
||||
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
|
||||
}
|
||||
|
||||
fn apply_to_detected_proxyfier(
|
||||
request: HelperApplyRequest<'_>,
|
||||
detected: &DetectedProxyfier,
|
||||
) -> Result<HelperApplyResult, CommandError> {
|
||||
let Some(config_path) = &detected.config_path else {
|
||||
return staged_apply_result(request);
|
||||
};
|
||||
|
||||
safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err(
|
||||
|error| {
|
||||
CommandError::new(
|
||||
"proxyfier_apply_failed",
|
||||
format!(
|
||||
"Не удалось безопасно записать конфиг ProxiFyre '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(HelperApplyResult {
|
||||
success: true,
|
||||
changed: true,
|
||||
action: "proxifyre.apply-detected-config".to_string(),
|
||||
message: format!(
|
||||
"Сгенерированный конфиг записан в найденную установку ProxiFyre: {}",
|
||||
config_path.display()
|
||||
),
|
||||
})
|
||||
safe_fs::write_restricted_with_backup(path, contents.as_bytes()).map_err(storage_error)
|
||||
}
|
||||
|
||||
fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result<HelperApplyResult, CommandError> {
|
||||
@@ -204,6 +226,20 @@ fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result<HelperApplyRes
|
||||
})
|
||||
}
|
||||
|
||||
fn staged_managed_current_result(
|
||||
request: HelperApplyRequest<'_>,
|
||||
) -> Result<HelperApplyResult, CommandError> {
|
||||
Ok(HelperApplyResult {
|
||||
success: true,
|
||||
changed: true,
|
||||
action: format!("{}.stage-managed-config", request.adapter_id),
|
||||
message: format!(
|
||||
"Сгенерированный конфиг подготовлен в {}; служба получит его при следующем явном запуске",
|
||||
request.config_path.display()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn activity_for_apply(
|
||||
clock: &impl Clock,
|
||||
generated: &ProxyRouterGeneratedConfig,
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Source/prepared/activation are separate facts. This module never controls services.
|
||||
use crate::{
|
||||
configuration_transaction,
|
||||
privileged_jobs::{ManagedComponent, PrivilegedJobStore},
|
||||
process::{self, KnownWindowsService, ServiceState},
|
||||
safe_fs,
|
||||
storage::JsonStorage,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, io, path::PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct PreparedArtifact {
|
||||
source_fingerprint: String,
|
||||
config_sha256: String,
|
||||
}
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct PreparedState {
|
||||
proxifyre: Option<PreparedArtifact>,
|
||||
singbox: Option<PreparedArtifact>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ActivationState {
|
||||
Unknown,
|
||||
Stopped,
|
||||
RestartRequired,
|
||||
Confirmed,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtifactStatus {
|
||||
pub component: String,
|
||||
pub source_matches_prepared: bool,
|
||||
pub generated_exists: bool,
|
||||
pub activation: ActivationState,
|
||||
}
|
||||
|
||||
pub fn prepared_path(storage: &JsonStorage) -> PathBuf {
|
||||
storage
|
||||
.paths()
|
||||
.state_dir
|
||||
.join("prepared-configuration.json")
|
||||
}
|
||||
fn generated_path(storage: &JsonStorage, component: ManagedComponent) -> PathBuf {
|
||||
storage.paths().generated_dir.join(match component {
|
||||
ManagedComponent::Proxifyre => "proxifyre-app-config.json",
|
||||
ManagedComponent::SingBox => "sing-box-config.json",
|
||||
})
|
||||
}
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
fn source_fingerprint(storage: &JsonStorage, component: ManagedComponent) -> io::Result<String> {
|
||||
let bytes = match component {
|
||||
ManagedComponent::Proxifyre => {
|
||||
serde_json::to_vec(&(storage.read_profiles()?, storage.read_targets()?))?
|
||||
}
|
||||
ManagedComponent::SingBox => serde_json::to_vec(&(
|
||||
storage.read_local_singbox_config()?,
|
||||
storage.read_singbox_subscription_cache()?,
|
||||
))?,
|
||||
};
|
||||
Ok(hash(&bytes))
|
||||
}
|
||||
fn read_prepared(storage: &JsonStorage) -> PreparedState {
|
||||
// Missing, old, or invalid derived metadata is unknown, never reconstructed from source.
|
||||
let path = prepared_path(storage);
|
||||
if safe_fs::ensure_no_reparse_ancestors(&path).is_err() {
|
||||
return PreparedState::default();
|
||||
}
|
||||
fs::read(path)
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Must run inside ConfigurationTransaction after all source and generated writes.
|
||||
pub fn record_prepared_locked(
|
||||
storage: &JsonStorage,
|
||||
component: ManagedComponent,
|
||||
) -> io::Result<()> {
|
||||
let path = generated_path(storage, component);
|
||||
safe_fs::ensure_no_reparse_ancestors(&path)?;
|
||||
let artifact = PreparedArtifact {
|
||||
source_fingerprint: source_fingerprint(storage, component)?,
|
||||
config_sha256: hash(&fs::read(path)?),
|
||||
};
|
||||
let mut state = read_prepared(storage);
|
||||
match component {
|
||||
ManagedComponent::Proxifyre => state.proxifyre = Some(artifact),
|
||||
ManagedComponent::SingBox => state.singbox = Some(artifact),
|
||||
};
|
||||
safe_fs::write_restricted_atomic(&prepared_path(storage), &serde_json::to_vec(&state)?)
|
||||
}
|
||||
|
||||
pub fn read_status_locked(storage: &JsonStorage) -> io::Result<Vec<ArtifactStatus>> {
|
||||
let prepared = read_prepared(storage);
|
||||
let store = PrivilegedJobStore::production().ok();
|
||||
[ManagedComponent::Proxifyre, ManagedComponent::SingBox]
|
||||
.into_iter()
|
||||
.map(|component| {
|
||||
let path = generated_path(storage, component);
|
||||
safe_fs::ensure_no_reparse_ancestors(&path)?;
|
||||
let generated = fs::read(path).ok().map(|bytes| hash(&bytes));
|
||||
let artifact = match component {
|
||||
ManagedComponent::Proxifyre => &prepared.proxifyre,
|
||||
ManagedComponent::SingBox => &prepared.singbox,
|
||||
};
|
||||
let source = source_fingerprint(storage, component)?;
|
||||
let source_matches_prepared = artifact.as_ref().is_some_and(|record| {
|
||||
record.source_fingerprint == source
|
||||
&& generated.as_ref() == Some(&record.config_sha256)
|
||||
});
|
||||
let service = match component {
|
||||
ManagedComponent::Proxifyre => KnownWindowsService::Proxifyre,
|
||||
ManagedComponent::SingBox => KnownWindowsService::SingBox,
|
||||
};
|
||||
let ack = store
|
||||
.as_ref()
|
||||
.and_then(|store| store.read_activation(component).ok().flatten());
|
||||
let current = process::running_service_instance(service).ok();
|
||||
let managed = match component {
|
||||
ManagedComponent::Proxifyre => crate::component_detection::inventory_proxyfier(),
|
||||
ManagedComponent::SingBox => crate::component_detection::inventory_singbox(),
|
||||
}
|
||||
.classification()
|
||||
== crate::component_inventory::ComponentClassification::ManagedCurrent;
|
||||
let stopped = process::query_known_service(service)
|
||||
.ok()
|
||||
.is_some_and(|state| !state.exists || state.state == Some(ServiceState::Stopped));
|
||||
let activation = classify_activation(
|
||||
source_matches_prepared,
|
||||
generated.as_deref(),
|
||||
ack.as_ref(),
|
||||
current,
|
||||
managed,
|
||||
stopped,
|
||||
);
|
||||
Ok(ArtifactStatus {
|
||||
component: match component {
|
||||
ManagedComponent::Proxifyre => "proxyfier",
|
||||
ManagedComponent::SingBox => "singbox",
|
||||
}
|
||||
.into(),
|
||||
source_matches_prepared,
|
||||
generated_exists: generated.is_some(),
|
||||
activation,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn read_status(storage: &JsonStorage) -> io::Result<Vec<ArtifactStatus>> {
|
||||
let _guard = configuration_transaction::read_guard(storage)?;
|
||||
read_status_locked(storage)
|
||||
}
|
||||
|
||||
fn classify_activation(
|
||||
prepared: bool,
|
||||
generated: Option<&str>,
|
||||
ack: Option<&crate::privileged_jobs::ActivationAcknowledgement>,
|
||||
current: Option<process::ServiceInstance>,
|
||||
managed: bool,
|
||||
stopped: bool,
|
||||
) -> ActivationState {
|
||||
if stopped {
|
||||
return ActivationState::Stopped;
|
||||
}
|
||||
match (ack, current) {
|
||||
(Some(ack), Some(current)) if managed && ack.instance == current => {
|
||||
if prepared && generated == Some(ack.config_sha256.as_str()) {
|
||||
ActivationState::Confirmed
|
||||
} else {
|
||||
ActivationState::RestartRequired
|
||||
}
|
||||
}
|
||||
_ => ActivationState::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn late_activation_never_confirms_new_preparation_or_a_reused_pid() {
|
||||
let instance = process::ServiceInstance {
|
||||
process_id: 42,
|
||||
created_at_filetime: 100,
|
||||
};
|
||||
let ack = crate::privileged_jobs::ActivationAcknowledgement {
|
||||
component: ManagedComponent::Proxifyre,
|
||||
config_sha256: "a".repeat(64),
|
||||
instance,
|
||||
};
|
||||
assert_eq!(
|
||||
classify_activation(
|
||||
true,
|
||||
Some(&ack.config_sha256),
|
||||
Some(&ack),
|
||||
Some(instance),
|
||||
true,
|
||||
false
|
||||
),
|
||||
ActivationState::Confirmed
|
||||
);
|
||||
assert_eq!(
|
||||
classify_activation(
|
||||
true,
|
||||
Some(&"b".repeat(64)),
|
||||
Some(&ack),
|
||||
Some(instance),
|
||||
true,
|
||||
false
|
||||
),
|
||||
ActivationState::RestartRequired
|
||||
);
|
||||
assert_eq!(
|
||||
classify_activation(
|
||||
false,
|
||||
Some(&ack.config_sha256),
|
||||
Some(&ack),
|
||||
Some(instance),
|
||||
true,
|
||||
false
|
||||
),
|
||||
ActivationState::RestartRequired
|
||||
);
|
||||
assert_eq!(
|
||||
classify_activation(
|
||||
true,
|
||||
Some(&ack.config_sha256),
|
||||
Some(&ack),
|
||||
Some(process::ServiceInstance {
|
||||
created_at_filetime: 101,
|
||||
..instance
|
||||
}),
|
||||
true,
|
||||
false
|
||||
),
|
||||
ActivationState::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
classify_activation(
|
||||
true,
|
||||
Some(&ack.config_sha256),
|
||||
Some(&ack),
|
||||
Some(instance),
|
||||
false,
|
||||
false
|
||||
),
|
||||
ActivationState::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
classify_activation(
|
||||
true,
|
||||
Some(&ack.config_sha256),
|
||||
None,
|
||||
Some(instance),
|
||||
true,
|
||||
false
|
||||
),
|
||||
ActivationState::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
classify_activation(true, Some(&ack.config_sha256), Some(&ack), None, true, true),
|
||||
ActivationState::Stopped
|
||||
);
|
||||
}
|
||||
}
|
||||
+4712
-20
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,12 @@ use crate::adapters::singbox::{
|
||||
};
|
||||
use crate::clock::Clock;
|
||||
use crate::command_dto::{ActivityEntryDto, CommandError, GenerateSingBoxConfigResponse};
|
||||
use crate::configuration_transaction::{read_guard, revision_locked, ConfigurationTransaction};
|
||||
use crate::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, Target,
|
||||
TargetKind,
|
||||
};
|
||||
use crate::safe_fs;
|
||||
use crate::singbox_subscription::read_required_singbox_cache;
|
||||
use crate::storage::JsonStorage;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -25,8 +25,19 @@ pub fn generate_singbox_config_with_services<C>(
|
||||
where
|
||||
C: SingBoxConfigChecker,
|
||||
{
|
||||
let guard = read_guard(storage).map_err(storage_error)?;
|
||||
let config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
let cache = read_required_singbox_cache(storage)?;
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?
|
||||
.ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"singbox_subscription_cache_missing",
|
||||
"Сначала загрузите подписку.",
|
||||
)
|
||||
})?;
|
||||
let revision = revision_locked(storage).map_err(storage_error)?;
|
||||
drop(guard);
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, binary_path),
|
||||
@@ -38,13 +49,19 @@ where
|
||||
.generated_dir
|
||||
.join(generated.output_file_name.as_str());
|
||||
|
||||
let transaction =
|
||||
ConfigurationTransaction::begin(storage, Some(&revision)).map_err(storage_error)?;
|
||||
write_generated_config(&generated_path, &generated.contents)?;
|
||||
ensure_local_singbox_target(storage, &config)?;
|
||||
|
||||
crate::route_state::record_prepared_locked(
|
||||
storage,
|
||||
crate::privileged_jobs::ManagedComponent::SingBox,
|
||||
)
|
||||
.map_err(storage_error)?;
|
||||
transaction.commit().map_err(storage_error)?;
|
||||
let activity = activity_for_singbox_generate(clock, &generated, &generated_path);
|
||||
storage
|
||||
.append_activity(activity.clone())
|
||||
.map_err(storage_error)?;
|
||||
let _ = storage.append_activity(activity.clone());
|
||||
|
||||
Ok(GenerateSingBoxConfigResponse {
|
||||
success: true,
|
||||
@@ -117,7 +134,7 @@ fn singbox_adapter_error(error: SingBoxConfigError) -> CommandError {
|
||||
}
|
||||
|
||||
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
|
||||
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
|
||||
safe_fs::write_restricted_with_backup(path, contents.as_bytes()).map_err(storage_error)
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
|
||||
+1314
-483
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+106
-167
@@ -1,32 +1,121 @@
|
||||
use crate::component_detection::DetectedSingBox;
|
||||
use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME};
|
||||
use crate::process::service_path_matches_exact;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const WINSW_WRAPPER_FILE: &str = "ProxyWardenSingBox.exe";
|
||||
pub const WINSW_SERVICE_XML_FILE: &str = "ProxyWardenSingBox.xml";
|
||||
pub const SINGBOX_RUNTIME_FILE: &str = "sing-box.exe";
|
||||
pub const SINGBOX_CRONET_FILE: &str = "libcronet.dll";
|
||||
pub const SINGBOX_LICENSE_FILE: &str = "LICENSE";
|
||||
pub const SINGBOX_RUNTIME_CONFIG_FILE: &str = "config.json";
|
||||
pub const SINGBOX_OWNERSHIP_MARKER_FILE: &str = "proxywarden-singbox.json";
|
||||
/// WinSW expands `%BASE%` to the sealed component root. The fixed two-parent
|
||||
/// hop lands at the verified Control App root while keeping wrapper output out
|
||||
/// of the immutable runtime inventory.
|
||||
pub const SINGBOX_SERVICE_LOG_DIR: &str = r"%BASE%\..\..\.proxywarden-service-logs\sing-box";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SingBoxServiceAction {
|
||||
Start,
|
||||
Stop,
|
||||
pub enum SingBoxNativeServiceState {
|
||||
Missing,
|
||||
Stopped,
|
||||
Running,
|
||||
Pending,
|
||||
}
|
||||
|
||||
impl SingBoxServiceAction {
|
||||
pub fn action_name(self) -> &'static str {
|
||||
match self {
|
||||
SingBoxServiceAction::Start => "start",
|
||||
SingBoxServiceAction::Stop => "stop",
|
||||
/// Fresh SCM state queried at the privileged boundary. `path_name` is the raw
|
||||
/// `QueryServiceConfigW` value; the policy compares it with the one fixed
|
||||
/// wrapper path and rejects arguments or another executable.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SingBoxNativeServiceSnapshot {
|
||||
pub state: SingBoxNativeServiceState,
|
||||
pub path_name: Option<String>,
|
||||
pub demand_start: bool,
|
||||
pub failure_recovery_disabled: bool,
|
||||
pub builtin_users_can_start: bool,
|
||||
}
|
||||
|
||||
impl SingBoxNativeServiceSnapshot {
|
||||
pub fn missing() -> Self {
|
||||
Self {
|
||||
state: SingBoxNativeServiceState::Missing,
|
||||
path_name: None,
|
||||
demand_start: false,
|
||||
failure_recovery_disabled: false,
|
||||
builtin_users_can_start: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
SingBoxServiceAction::Start => "запустить",
|
||||
SingBoxServiceAction::Stop => "остановить",
|
||||
}
|
||||
pub fn matches_managed_policy(&self, spec: &SingBoxServiceInstallSpec) -> bool {
|
||||
self.path_name
|
||||
.as_deref()
|
||||
.is_some_and(|path_name| service_path_matches_exact(path_name, &spec.wrapper_path))
|
||||
&& self.demand_start
|
||||
&& self.failure_recovery_disabled
|
||||
&& !self.builtin_users_can_start
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed native SCM creation contract. A host maps this directly to
|
||||
/// `CreateServiceW`/`ChangeServiceConfig2W`; there is no caller-supplied
|
||||
/// command line or service name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SingBoxServiceInstallSpec {
|
||||
pub service_name: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub wrapper_path: PathBuf,
|
||||
pub command_line: String,
|
||||
pub demand_start: bool,
|
||||
pub failure_recovery_disabled: bool,
|
||||
pub builtin_users_can_start: bool,
|
||||
}
|
||||
|
||||
impl SingBoxServiceInstallSpec {
|
||||
pub fn for_install_root(install_root: &Path) -> Option<Self> {
|
||||
if !install_root.is_absolute()
|
||||
|| install_root.file_name().and_then(|name| name.to_str()) != Some("sing-box")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let wrapper_path = install_root.join(WINSW_WRAPPER_FILE);
|
||||
let command_line = quote_windows_executable(&wrapper_path)?;
|
||||
Some(Self {
|
||||
service_name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME,
|
||||
display_name: "ProxyWarden Local sing-box",
|
||||
wrapper_path,
|
||||
command_line,
|
||||
demand_start: true,
|
||||
failure_recovery_disabled: true,
|
||||
builtin_users_can_start: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn singbox_service_xml() -> &'static str {
|
||||
concat!(
|
||||
"<service>\r\n",
|
||||
" <id>ProxyWardenSingBox</id>\r\n",
|
||||
" <name>ProxyWarden Local sing-box</name>\r\n",
|
||||
" <description>Local sing-box runtime managed by ProxyWarden</description>\r\n",
|
||||
" <executable>%BASE%\\sing-box.exe</executable>\r\n",
|
||||
" <arguments>run -c "%BASE%\\config.json"</arguments>\r\n",
|
||||
" <startmode>Manual</startmode>\r\n",
|
||||
" <onfailure action=\"none\" />\r\n",
|
||||
" <logpath>%BASE%\\..\\..\\.proxywarden-service-logs\\sing-box</logpath>\r\n",
|
||||
" <log mode=\"none\"/>\r\n",
|
||||
"</service>\r\n",
|
||||
)
|
||||
}
|
||||
|
||||
fn quote_windows_executable(path: &Path) -> Option<String> {
|
||||
let value = path.to_str()?;
|
||||
if value.is_empty() || value.contains(['\0', '"', '\r', '\n']) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("\"{value}\""))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SingBoxSetupStatus {
|
||||
@@ -45,16 +134,6 @@ pub struct SingBoxSetupItem {
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceCommandOutput {
|
||||
pub success: bool,
|
||||
pub code: String,
|
||||
pub service_name: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub process_id: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBoxSetupStatus {
|
||||
build_singbox_setup_status_with_install_root(
|
||||
detected,
|
||||
@@ -74,7 +153,7 @@ pub fn build_singbox_setup_status_with_install_root(
|
||||
id: "sing-box-binary".to_string(),
|
||||
name: "sing-box".to_string(),
|
||||
installed: true,
|
||||
version: Some("binary найден".to_string()),
|
||||
version: singbox.version.clone(),
|
||||
details: singbox.executable_path.display().to_string(),
|
||||
},
|
||||
_ => SingBoxSetupItem {
|
||||
@@ -92,7 +171,7 @@ pub fn build_singbox_setup_status_with_install_root(
|
||||
id: "winsw-wrapper".to_string(),
|
||||
name: "WinSW service wrapper".to_string(),
|
||||
installed: true,
|
||||
version: Some("wrapper найден".to_string()),
|
||||
version: singbox.wrapper_version.clone(),
|
||||
details: singbox.wrapper_path.display().to_string(),
|
||||
},
|
||||
_ => SingBoxSetupItem {
|
||||
@@ -110,14 +189,14 @@ pub fn build_singbox_setup_status_with_install_root(
|
||||
id: "windows-service".to_string(),
|
||||
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||
installed: true,
|
||||
version: Some("служба запущена".to_string()),
|
||||
version: None,
|
||||
details: format!("Служба {}", singbox.service_name),
|
||||
},
|
||||
Some(singbox) => SingBoxSetupItem {
|
||||
id: "windows-service".to_string(),
|
||||
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||
installed: true,
|
||||
version: Some("служба остановлена".to_string()),
|
||||
version: None,
|
||||
details: format!("Служба {}", singbox.service_name),
|
||||
},
|
||||
None => SingBoxSetupItem {
|
||||
@@ -138,143 +217,3 @@ pub fn build_singbox_setup_status_with_install_root(
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_service_command_output(stdout: &[u8]) -> Option<ServiceCommandOutput> {
|
||||
let stdout = String::from_utf8_lossy(stdout);
|
||||
let payload = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.find(|line| line.starts_with('{') && line.ends_with('}'))?;
|
||||
|
||||
serde_json::from_str(payload).ok()
|
||||
}
|
||||
|
||||
pub fn ensure_safe_singbox_install_dir(path: &Path) -> Result<(), String> {
|
||||
let normalized = path
|
||||
.display()
|
||||
.to_string()
|
||||
.replace('/', "\\")
|
||||
.to_ascii_lowercase();
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
let is_proxywarden_component = normalized.contains("\\proxywarden\\components\\");
|
||||
let is_legacy_proxywarden_child = normalized.ends_with("\\proxywarden\\sing-box");
|
||||
|
||||
if file_name == "sing-box" && (is_proxywarden_component || is_legacy_proxywarden_child) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Отказываюсь рекурсивно удалять Local sing-box с небезопасным путем: {}",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn service_control_script(
|
||||
action: SingBoxServiceAction,
|
||||
service_name: &str,
|
||||
config_source: Option<&Path>,
|
||||
config_target: Option<&Path>,
|
||||
) -> String {
|
||||
let action_name = action.action_name();
|
||||
let escaped_service_name = escape_powershell_single(service_name);
|
||||
let escaped_config_source = config_source
|
||||
.map(|path| escape_powershell_single(&path.display().to_string()))
|
||||
.unwrap_or_default();
|
||||
let escaped_config_target = config_target
|
||||
.map(|path| escape_powershell_single(&path.display().to_string()))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$serviceName = '{escaped_service_name}'
|
||||
$action = '{action_name}'
|
||||
$configSource = '{escaped_config_source}'
|
||||
$configTarget = '{escaped_config_target}'
|
||||
|
||||
function Get-ServiceProcessId([string]$name) {{
|
||||
$escapedName = $name.Replace("'", "''")
|
||||
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $record) {{ return 0 }}
|
||||
return [int]$record.ProcessId
|
||||
}}
|
||||
|
||||
function Get-ServiceStatus([string]$name) {{
|
||||
$current = Get-Service -Name $name -ErrorAction SilentlyContinue
|
||||
if ($null -eq $current) {{ return $null }}
|
||||
return $current.Status.ToString()
|
||||
}}
|
||||
|
||||
function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{
|
||||
[PSCustomObject]@{{
|
||||
success = $success
|
||||
code = $code
|
||||
serviceName = $serviceName
|
||||
status = $status
|
||||
processId = $processId
|
||||
}} | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
}}
|
||||
|
||||
function Sync-ServiceConfig {{
|
||||
if ($action -ne 'start' -or [string]::IsNullOrWhiteSpace($configSource)) {{ return }}
|
||||
if (-not (Test-Path -LiteralPath $configSource)) {{
|
||||
Write-ServiceResult $false 'config_source_missing' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
if ([string]::IsNullOrWhiteSpace($configTarget)) {{ return }}
|
||||
|
||||
try {{
|
||||
Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop
|
||||
}} catch {{
|
||||
Write-ServiceResult $false 'config_sync_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
}}
|
||||
|
||||
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||
if ($null -eq $service) {{
|
||||
Write-ServiceResult $false 'service_not_found' $null 0
|
||||
}}
|
||||
|
||||
if ($action -eq 'start') {{
|
||||
Sync-ServiceConfig
|
||||
|
||||
if ($service.Status -eq 'Running') {{
|
||||
Write-ServiceResult $true 'already_running' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
try {{
|
||||
Start-Service -Name $serviceName -ErrorAction Stop
|
||||
$service = Get-Service -Name $serviceName -ErrorAction Stop
|
||||
$service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15))
|
||||
}} catch {{
|
||||
Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
if ($service.Status -eq 'Stopped') {{
|
||||
Write-ServiceResult $true 'already_stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
try {{
|
||||
Stop-Service -Name $serviceName -Force -ErrorAction Stop
|
||||
$service = Get-Service -Name $serviceName -ErrorAction Stop
|
||||
$service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15))
|
||||
}} catch {{
|
||||
Write-ServiceResult $false 'stop_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
Write-ServiceResult ($service.Status -eq 'Stopped') 'stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_powershell_single(value: &str) -> String {
|
||||
value.replace('\'', "''")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::command_dto::*;
|
||||
use crate::component_detection::{
|
||||
detect_singbox_install, singbox_component_from_detection, DetectedSingBox,
|
||||
};
|
||||
use crate::configuration_transaction::{read_guard, revision_locked, ConfigurationTransaction};
|
||||
use crate::models::{
|
||||
ActivityEntry, ActivityLevel, LocalSingBoxConfig, SubscriptionCache, SubscriptionServer,
|
||||
};
|
||||
@@ -59,15 +60,26 @@ pub(crate) fn read_singbox_status_with_detection(
|
||||
storage: &JsonStorage,
|
||||
detected: Option<&DetectedSingBox>,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let _guard = read_guard(storage).map_err(storage_error)?;
|
||||
let config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?;
|
||||
status_from_source(storage, &config, cache.as_ref(), detected)
|
||||
}
|
||||
|
||||
fn status_from_source(
|
||||
storage: &JsonStorage,
|
||||
config: &LocalSingBoxConfig,
|
||||
cache: Option<&SubscriptionCache>,
|
||||
detected: Option<&DetectedSingBox>,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let component = singbox_component_from_detection(detected);
|
||||
|
||||
Ok(LocalSingBoxStatusResponse {
|
||||
config: LocalSingBoxConfigDto::from(&config),
|
||||
cache: cache.as_ref().map(SubscriptionCacheDto::from),
|
||||
saved_state: crate::configuration_use_case::read_saved_state_locked(storage)?,
|
||||
config: LocalSingBoxConfigDto::from(config),
|
||||
cache: cache.map(SubscriptionCacheDto::from),
|
||||
component: ComponentStatusDto::from(&component),
|
||||
generated_config_path: storage
|
||||
.paths()
|
||||
@@ -86,10 +98,18 @@ pub fn save_singbox_subscription_to_storage(
|
||||
input: SaveSingBoxSubscriptionInputDto,
|
||||
clock: &impl Clock,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
|
||||
let subscription_url = input.subscription_url.trim().to_string();
|
||||
validate_subscription_url(&subscription_url)?;
|
||||
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
if config.subscription_url.as_deref() != Some(&subscription_url) {
|
||||
storage
|
||||
.remove_singbox_subscription_cache()
|
||||
.map_err(storage_error)?;
|
||||
config.selected_server_id = None;
|
||||
config.selected_server_tag = None;
|
||||
}
|
||||
config.subscription_url = Some(subscription_url);
|
||||
ensure_device_hwid(&mut config);
|
||||
config.updated_at = Some(clock.now());
|
||||
@@ -97,7 +117,17 @@ pub fn save_singbox_subscription_to_storage(
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
|
||||
read_singbox_status(storage)
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?;
|
||||
let mut result = status_from_source(
|
||||
storage,
|
||||
&config,
|
||||
cache.as_ref(),
|
||||
detect_singbox_install().as_ref(),
|
||||
)?;
|
||||
result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn fetch_singbox_subscription_with_fetcher(
|
||||
@@ -105,7 +135,21 @@ pub fn fetch_singbox_subscription_with_fetcher(
|
||||
fetcher: &impl SubscriptionFetcher,
|
||||
clock: &impl Clock,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
fetch_singbox_subscription_candidate(storage, None, fetcher, clock)
|
||||
}
|
||||
|
||||
pub fn fetch_singbox_subscription_candidate(
|
||||
storage: &JsonStorage,
|
||||
candidate_url: Option<&str>,
|
||||
fetcher: &impl SubscriptionFetcher,
|
||||
clock: &impl Clock,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let guard = read_guard(storage).map_err(storage_error)?;
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
if let Some(candidate) = candidate_url {
|
||||
validate_subscription_url(candidate.trim())?;
|
||||
config.subscription_url = Some(candidate.trim().to_string());
|
||||
}
|
||||
let subscription_url = config
|
||||
.subscription_url
|
||||
.as_deref()
|
||||
@@ -119,28 +163,28 @@ pub fn fetch_singbox_subscription_with_fetcher(
|
||||
)
|
||||
})?;
|
||||
|
||||
let device_hwid_created = ensure_device_hwid(&mut config);
|
||||
if device_hwid_created {
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
}
|
||||
ensure_device_hwid(&mut config);
|
||||
let revision = revision_locked(storage).map_err(storage_error)?;
|
||||
drop(guard);
|
||||
|
||||
let identity =
|
||||
subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref());
|
||||
let cache = fetcher
|
||||
.fetch_subscription(&subscription_url, &identity)
|
||||
.map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?;
|
||||
let selected_server = config
|
||||
.selected_server_id
|
||||
.as_deref()
|
||||
.and_then(|id| cache.servers.iter().find(|server| server.id == id))
|
||||
.or_else(|| {
|
||||
let tag = config.selected_server_tag.as_deref()?;
|
||||
cache.servers.iter().find(|server| server.tag == tag)
|
||||
})
|
||||
.or_else(|| cache.servers.first());
|
||||
let transaction = ConfigurationTransaction::begin(storage, Some(&revision)).map_err(|_| {
|
||||
CommandError::new(
|
||||
"configuration_changed",
|
||||
"Настройки изменились во время загрузки. Повторите обновление подписки.",
|
||||
)
|
||||
})?;
|
||||
let selected_server = if let Some(id) = config.selected_server_id.as_deref() {
|
||||
cache.servers.iter().find(|server| server.id == id)
|
||||
} else if let Some(tag) = config.selected_server_tag.as_deref() {
|
||||
find_subscription_server(&cache, None, tag, None, None)
|
||||
} else {
|
||||
cache.servers.first()
|
||||
};
|
||||
|
||||
config.selected_server_id = selected_server.map(|server| server.id.clone());
|
||||
config.selected_server_tag = selected_server.map(|server| server.tag.clone());
|
||||
@@ -151,23 +195,35 @@ pub fn fetch_singbox_subscription_with_fetcher(
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
storage
|
||||
.append_activity(ActivityEntry {
|
||||
id: "singbox-subscription-fetched".to_string(),
|
||||
at: clock.now(),
|
||||
level: ActivityLevel::Success,
|
||||
title: "Подписка Local sing-box обновлена".to_string(),
|
||||
message: format!("Серверов найдено: {}", cache.servers.len()),
|
||||
})
|
||||
.map_err(storage_error)?;
|
||||
|
||||
read_singbox_status(storage)
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?;
|
||||
let mut result = status_from_source(
|
||||
storage,
|
||||
&config,
|
||||
cache.as_ref(),
|
||||
detect_singbox_install().as_ref(),
|
||||
)?;
|
||||
result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?;
|
||||
let _ = storage.append_activity(ActivityEntry {
|
||||
id: "singbox-subscription-fetched".to_string(),
|
||||
at: clock.now(),
|
||||
level: ActivityLevel::Success,
|
||||
title: "Подписка Local sing-box обновлена".to_string(),
|
||||
message: format!(
|
||||
"Серверов найдено: {}",
|
||||
result.cache.as_ref().map_or(0, |cache| cache.servers.len())
|
||||
),
|
||||
});
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn forget_singbox_subscription_in_storage(
|
||||
storage: &JsonStorage,
|
||||
clock: &impl Clock,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
config.subscription_url = None;
|
||||
config.selected_server_tag = None;
|
||||
@@ -176,11 +232,24 @@ pub fn forget_singbox_subscription_in_storage(
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
storage
|
||||
.discard_local_singbox_config_backup()
|
||||
.map_err(storage_error)?;
|
||||
storage
|
||||
.remove_singbox_subscription_cache()
|
||||
.map_err(storage_error)?;
|
||||
|
||||
read_singbox_status(storage)
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?;
|
||||
let mut result = status_from_source(
|
||||
storage,
|
||||
&config,
|
||||
cache.as_ref(),
|
||||
detect_singbox_install().as_ref(),
|
||||
)?;
|
||||
result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn select_singbox_server_in_storage(
|
||||
@@ -188,6 +257,7 @@ pub fn select_singbox_server_in_storage(
|
||||
input: SelectSingBoxServerInputDto,
|
||||
clock: &impl Clock,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
|
||||
let requested_tag = input.tag.trim().to_string();
|
||||
let requested_id = input
|
||||
.id
|
||||
@@ -233,7 +303,17 @@ pub fn select_singbox_server_in_storage(
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
|
||||
read_singbox_status(storage)
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?;
|
||||
let mut result = status_from_source(
|
||||
storage,
|
||||
&config,
|
||||
cache.as_ref(),
|
||||
detect_singbox_install().as_ref(),
|
||||
)?;
|
||||
result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn ping_singbox_server_in_storage(
|
||||
@@ -267,6 +347,7 @@ pub fn ping_all_singbox_servers_in_storage(
|
||||
pub(crate) fn read_required_singbox_cache(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<SubscriptionCache, CommandError> {
|
||||
let _guard = read_guard(storage).map_err(storage_error)?;
|
||||
storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?
|
||||
@@ -338,28 +419,26 @@ fn find_subscription_server<'a>(
|
||||
requested_server: Option<&str>,
|
||||
requested_port: Option<u16>,
|
||||
) -> Option<&'a SubscriptionServer> {
|
||||
requested_id
|
||||
.and_then(|id| cache.servers.iter().find(|server| server.id == id))
|
||||
.or_else(|| {
|
||||
cache
|
||||
.servers
|
||||
.iter()
|
||||
.find(|server| server.tag == requested_tag)
|
||||
})
|
||||
.or_else(|| {
|
||||
let requested = comparable_server_tag(requested_tag);
|
||||
cache
|
||||
.servers
|
||||
.iter()
|
||||
.find(|server| comparable_server_tag(&server.tag) == requested)
|
||||
})
|
||||
.or_else(|| {
|
||||
let server_name = requested_server?.trim();
|
||||
let server_port = requested_port?;
|
||||
cache.servers.iter().find(|server| {
|
||||
server.server.eq_ignore_ascii_case(server_name) && server.server_port == server_port
|
||||
})
|
||||
})
|
||||
if let Some(id) = requested_id {
|
||||
return cache.servers.iter().find(|server| server.id == id);
|
||||
}
|
||||
let tag = comparable_server_tag(requested_tag);
|
||||
let mut matches = cache.servers.iter().filter(|server| {
|
||||
comparable_server_tag(&server.tag) == tag
|
||||
&& requested_server.is_none_or(|host| server.server.eq_ignore_ascii_case(host.trim()))
|
||||
&& requested_port.is_none_or(|port| server.server_port == port)
|
||||
});
|
||||
if let Some(found) = matches.next() {
|
||||
return matches.next().is_none().then_some(found);
|
||||
}
|
||||
let host = requested_server?.trim();
|
||||
let port = requested_port?;
|
||||
let mut endpoints = cache
|
||||
.servers
|
||||
.iter()
|
||||
.filter(|server| server.server.eq_ignore_ascii_case(host) && server.server_port == port);
|
||||
let found = endpoints.next()?;
|
||||
endpoints.next().is_none().then_some(found)
|
||||
}
|
||||
|
||||
fn comparable_server_tag(value: &str) -> String {
|
||||
|
||||
+148
-25
@@ -1,6 +1,11 @@
|
||||
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
|
||||
use crate::component_cutover::{
|
||||
validate_component_cutover_observation, validate_component_cutover_user_evidence,
|
||||
ComponentCutoverObservation, ComponentCutoverUserEvidence,
|
||||
};
|
||||
use crate::models::{
|
||||
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
|
||||
ActivityEntry, ComponentLayoutMeta, LocalSingBoxConfig, Profile, StorageMeta,
|
||||
SubscriptionCache, Target, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT,
|
||||
};
|
||||
use crate::safe_fs;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
@@ -17,11 +22,19 @@ pub struct StoragePaths {
|
||||
pub root: PathBuf,
|
||||
pub config_dir: PathBuf,
|
||||
pub state_dir: PathBuf,
|
||||
pub packages_dir: PathBuf,
|
||||
pub generated_dir: PathBuf,
|
||||
pub profiles_file: PathBuf,
|
||||
pub targets_file: PathBuf,
|
||||
pub components_file: PathBuf,
|
||||
pub local_singbox_file: PathBuf,
|
||||
pub storage_meta_file: PathBuf,
|
||||
pub component_layout_file: PathBuf,
|
||||
pub component_updates_file: PathBuf,
|
||||
pub component_cutover_observation_file: PathBuf,
|
||||
pub component_cutover_user_evidence_file: PathBuf,
|
||||
pub migrations_dir: PathBuf,
|
||||
pub privileged_jobs_dir: PathBuf,
|
||||
pub singbox_subscription_cache_file: PathBuf,
|
||||
pub activity_file: PathBuf,
|
||||
}
|
||||
@@ -32,17 +45,30 @@ impl StoragePaths {
|
||||
let config_dir = root.join("config");
|
||||
let state_dir = root.join("state");
|
||||
let generated_dir = root.join("generated");
|
||||
let packages_dir = root.join("packages");
|
||||
|
||||
Self {
|
||||
root,
|
||||
profiles_file: config_dir.join("profiles.json"),
|
||||
targets_file: config_dir.join("targets.json"),
|
||||
// Legacy migration input only. Live component status is always
|
||||
// rebuilt from native inventory and never read from this file.
|
||||
components_file: config_dir.join("components.json"),
|
||||
local_singbox_file: config_dir.join("local-singbox.json"),
|
||||
storage_meta_file: config_dir.join("storage-meta.json"),
|
||||
component_layout_file: state_dir.join("component-layout.json"),
|
||||
component_updates_file: state_dir.join("component-updates.json"),
|
||||
component_cutover_observation_file: state_dir
|
||||
.join("component-cutover-observation.json"),
|
||||
component_cutover_user_evidence_file: state_dir
|
||||
.join("component-cutover-user-evidence.json"),
|
||||
migrations_dir: state_dir.join("migrations"),
|
||||
privileged_jobs_dir: state_dir.join("privileged-jobs"),
|
||||
singbox_subscription_cache_file: state_dir.join("singbox-subscription-cache.json"),
|
||||
activity_file: state_dir.join("activity.json"),
|
||||
config_dir,
|
||||
state_dir,
|
||||
packages_dir,
|
||||
generated_dir,
|
||||
}
|
||||
}
|
||||
@@ -92,14 +118,13 @@ impl JsonStorage {
|
||||
self.write_json(&self.paths.targets_file, targets)
|
||||
}
|
||||
|
||||
pub fn read_components(&self) -> io::Result<Vec<ComponentStatus>> {
|
||||
self.read_json_or_default(&self.paths.components_file)
|
||||
}
|
||||
|
||||
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
|
||||
let mut config: LocalSingBoxConfig =
|
||||
self.read_json_or_default(&self.paths.local_singbox_file)?;
|
||||
config.normalize_percent_encoded_tags();
|
||||
// The persisted pre-1.2 install_root is legacy discovery input only.
|
||||
// Runtime layout is owned by component inventory, not user storage.
|
||||
config.install_root = DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -107,6 +132,97 @@ impl JsonStorage {
|
||||
self.write_json(&self.paths.local_singbox_file, config)
|
||||
}
|
||||
|
||||
pub fn read_storage_meta(&self) -> io::Result<Option<StorageMeta>> {
|
||||
self.read_optional_json(&self.paths.storage_meta_file)
|
||||
}
|
||||
|
||||
pub fn write_storage_meta(&self, meta: &StorageMeta) -> io::Result<()> {
|
||||
self.write_json(&self.paths.storage_meta_file, meta)
|
||||
}
|
||||
|
||||
pub fn read_component_layout(&self) -> io::Result<Option<ComponentLayoutMeta>> {
|
||||
self.read_optional_json(&self.paths.component_layout_file)
|
||||
}
|
||||
|
||||
pub fn write_component_layout(&self, layout: &ComponentLayoutMeta) -> io::Result<()> {
|
||||
self.write_json(&self.paths.component_layout_file, layout)
|
||||
}
|
||||
|
||||
pub fn read_component_cutover_observation(
|
||||
&self,
|
||||
) -> io::Result<Option<ComponentCutoverObservation>> {
|
||||
let path = &self.paths.component_cutover_observation_file;
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => {
|
||||
let observation: ComponentCutoverObservation = parse_json(path, &contents)?;
|
||||
validate_component_cutover_observation(&observation).map_err(|_| {
|
||||
io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
"invalid component cutover observation",
|
||||
)
|
||||
})?;
|
||||
Ok(Some(observation))
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_component_cutover_observation(
|
||||
&self,
|
||||
observation: &ComponentCutoverObservation,
|
||||
) -> io::Result<()> {
|
||||
validate_component_cutover_observation(observation).map_err(|_| {
|
||||
io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"invalid component cutover observation",
|
||||
)
|
||||
})?;
|
||||
let contents = serde_json::to_vec_pretty(observation)
|
||||
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
|
||||
safe_fs::write_restricted_with_backup(
|
||||
&self.paths.component_cutover_observation_file,
|
||||
&contents,
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads the normal-process cutover evidence without corruption recovery or
|
||||
/// any other write. Elevated callers must still live-revalidate it.
|
||||
pub fn read_component_cutover_user_evidence(
|
||||
&self,
|
||||
) -> io::Result<Option<ComponentCutoverUserEvidence>> {
|
||||
let path = &self.paths.component_cutover_user_evidence_file;
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => {
|
||||
let evidence: ComponentCutoverUserEvidence = parse_json(path, &contents)?;
|
||||
validate_component_cutover_user_evidence(&evidence).map_err(|_| {
|
||||
io::Error::new(ErrorKind::InvalidData, "invalid component cutover evidence")
|
||||
})?;
|
||||
Ok(Some(evidence))
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_component_cutover_user_evidence(
|
||||
&self,
|
||||
evidence: &ComponentCutoverUserEvidence,
|
||||
) -> io::Result<()> {
|
||||
validate_component_cutover_user_evidence(evidence).map_err(|_| {
|
||||
io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"invalid component cutover evidence",
|
||||
)
|
||||
})?;
|
||||
let contents = serde_json::to_vec_pretty(evidence)
|
||||
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
|
||||
safe_fs::write_restricted_with_backup(
|
||||
&self.paths.component_cutover_user_evidence_file,
|
||||
&contents,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> {
|
||||
let mut cache = self
|
||||
.read_optional_json::<SubscriptionCache>(&self.paths.singbox_subscription_cache_file)?;
|
||||
@@ -121,6 +237,7 @@ impl JsonStorage {
|
||||
}
|
||||
|
||||
pub fn remove_singbox_subscription_cache(&self) -> io::Result<()> {
|
||||
remove_optional_file(&backup_path(&self.paths.singbox_subscription_cache_file))?;
|
||||
match fs::remove_file(&self.paths.singbox_subscription_cache_file) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
@@ -128,6 +245,10 @@ impl JsonStorage {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn discard_local_singbox_config_backup(&self) -> io::Result<()> {
|
||||
remove_optional_file(&backup_path(&self.paths.local_singbox_file))
|
||||
}
|
||||
|
||||
pub fn read_activity(&self) -> io::Result<Vec<ActivityEntry>> {
|
||||
let entries = self.read_json_or_default(&self.paths.activity_file)?;
|
||||
Ok(cap_activity(entries, self.activity_limit))
|
||||
@@ -148,7 +269,17 @@ impl JsonStorage {
|
||||
Ok(contents) => {
|
||||
parse_json(path, &contents).or_else(|error| recover_corrupt_json(path, error))
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
match fs::read_to_string(backup_path(path)) {
|
||||
Ok(contents) => {
|
||||
let value = parse_json(&backup_path(path), &contents)?;
|
||||
safe_fs::write_atomic_without_backup(path, contents.as_bytes())?;
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
@@ -206,23 +337,21 @@ fn recover_corrupt_json<T>(path: &Path, parse_error: io::Error) -> io::Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let corrupt_path = safe_fs::corrupt_path(path);
|
||||
move_corrupt_file(path, &corrupt_path)?;
|
||||
|
||||
let backup_path = backup_path(path);
|
||||
if backup_path.exists() {
|
||||
if backup_path.try_exists()? {
|
||||
let backup_contents = fs::read_to_string(&backup_path)?;
|
||||
match parse_json(&backup_path, &backup_contents) {
|
||||
Ok(value) => {
|
||||
fs::copy(&backup_path, path)?;
|
||||
let corrupt_path = safe_fs::corrupt_path(path);
|
||||
safe_fs::write_atomic_without_backup(&corrupt_path, &fs::read(path)?)?;
|
||||
safe_fs::write_atomic_without_backup(path, backup_contents.as_bytes())?;
|
||||
Ok(value)
|
||||
}
|
||||
Err(backup_error) => Err(io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Invalid JSON in '{}'; corrupt file moved to '{}'; backup '{}' could not be restored: {backup_error}; original error: {parse_error}",
|
||||
"Invalid JSON in '{}'; original preserved; backup '{}' could not be restored: {backup_error}; original error: {parse_error}",
|
||||
path.display(),
|
||||
corrupt_path.display(),
|
||||
backup_path.display()
|
||||
),
|
||||
)),
|
||||
@@ -231,24 +360,18 @@ where
|
||||
Err(io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Invalid JSON in '{}'; corrupt file moved to '{}'; no valid backup available: {parse_error}",
|
||||
"Invalid JSON in '{}'; original preserved; no valid backup available: {parse_error}",
|
||||
path.display(),
|
||||
corrupt_path.display()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn move_corrupt_file(path: &Path, corrupt_path: &Path) -> io::Result<()> {
|
||||
match fs::rename(path, corrupt_path) {
|
||||
fn remove_optional_file(path: &Path) -> io::Result<()> {
|
||||
safe_fs::ensure_no_reparse_ancestors(path)?;
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(rename_error) => {
|
||||
fs::copy(path, corrupt_path)?;
|
||||
fs::remove_file(path)?;
|
||||
if !corrupt_path.exists() {
|
||||
return Err(rename_error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,10 @@ pub fn fetch_subscription_with_identity_and_policy(
|
||||
}
|
||||
|
||||
let response = request.send().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription request failed: {error}"))
|
||||
SubscriptionError::new(format!(
|
||||
"Subscription request failed: {}",
|
||||
error.without_url()
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
if status.is_redirection() {
|
||||
@@ -209,8 +212,8 @@ pub fn fetch_subscription_with_identity_and_policy(
|
||||
.get("subscription-userinfo")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
let body = response.text().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription body read failed: {error}"))
|
||||
let body = response.text().map_err(|_error| {
|
||||
SubscriptionError::new("Subscription body read failed".to_string())
|
||||
})?;
|
||||
let parsed = parse_subscription_body(&body)?;
|
||||
|
||||
@@ -615,7 +618,7 @@ fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
|
||||
})
|
||||
}
|
||||
|
||||
fn outbound_server_id(outbound: &Value) -> String {
|
||||
pub(crate) fn outbound_server_id(outbound: &Value) -> String {
|
||||
let bytes = serde_json::to_vec(outbound).unwrap_or_default();
|
||||
let hash = bytes.iter().fold(0xcbf29ce484222325_u64, |hash, byte| {
|
||||
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
|
||||
|
||||
Reference in New Issue
Block a user