Refactor application structure and simplify implementation

This commit is contained in:
2026-07-22 00:08:09 +03:00
parent dbba3806cc
commit 90b2eb507c
74 changed files with 11362 additions and 6814 deletions
+4 -1
View File
@@ -205,7 +205,10 @@ fn app_names_for_profile(profile: &Profile) -> Vec<String> {
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
};
if !names.iter().any(|existing| existing == app_name) {
if !names
.iter()
.any(|existing: &String| existing.eq_ignore_ascii_case(app_name))
{
names.push(app_name.to_string());
}
}
+74 -39
View File
@@ -1,12 +1,8 @@
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer};
use crate::process::command_no_window;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
env, fs,
path::Path,
time::{SystemTime, UNIX_EPOCH},
};
use std::{env, fs, fs::OpenOptions, io::Write, path::Path};
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
@@ -43,23 +39,36 @@ impl SingBoxAdapter {
checker: &C,
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
where
C: SingBoxConfigChecker,
C: SingBoxConfigChecker + ?Sized,
{
let selected_server_tag = request
let selected_server = request
.config
.selected_server_tag
.selected_server_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.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 не выбран",
"Сервер Local sing-box не выбран или отсутствует в текущей подписке",
)
})?;
let vpn_outbound = selected_outbound(
&request.subscription_cache.config,
selected_server_tag,
selected_server,
&self.vpn_outbound_tag,
)?;
let generated_config = json!({
@@ -105,7 +114,7 @@ impl SingBoxAdapter {
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
contents,
selected_server_tag: selected_server_tag.to_string(),
selected_server_tag: selected_server.tag.clone(),
listen: request.config.listen_host.clone(),
listen_port: request.config.listen_port,
check,
@@ -200,20 +209,37 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
let config_path = env::temp_dir().join(format!(
"proxywarden-sing-box-{}-{}.json",
std::process::id(),
now_millis()
"proxywarden-sing-box-{}.json",
uuid::Uuid::new_v4().hyphenated()
));
fs::write(&config_path, config_json).map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось записать временный конфиг sing-box '{}': {error}",
config_path.display()
),
)
})?;
{
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()
),
));
}
}
let output = command_no_window(binary_path)
.arg("check")
@@ -257,7 +283,7 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
fn selected_outbound(
subscription_config: &Value,
selected_server_tag: &str,
selected_server: &SubscriptionServer,
vpn_outbound_tag: &str,
) -> Result<Value, SingBoxConfigError> {
let outbounds = subscription_config
@@ -272,15 +298,27 @@ fn selected_outbound(
let outbound = outbounds
.iter()
.find(|outbound| {
outbound
let tag_matches = outbound
.get("tag")
.and_then(Value::as_str)
.is_some_and(|tag| tag.trim() == selected_server_tag)
.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}"),
format!(
"Outbound не найден: {} ({}:{})",
selected_server.tag, selected_server.server, selected_server.server_port
),
)
})?;
let outbound_type = outbound
@@ -292,7 +330,8 @@ fn selected_outbound(
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
format!(
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'"
"Outbound '{}' имеет неподдерживаемый тип '{outbound_type}'",
selected_server.tag
),
));
}
@@ -301,7 +340,10 @@ fn selected_outbound(
let object = outbound.as_object_mut().ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"),
format!(
"Outbound '{}' должен быть JSON-объектом",
selected_server.tag
),
)
})?;
object.insert(
@@ -318,13 +360,6 @@ fn selected_outbound(
Ok(outbound)
}
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default()
}
fn command_message(stdout: &str, stderr: &str) -> String {
let stdout = stdout.trim();
let stderr = stderr.trim();
+89
View File
@@ -0,0 +1,89 @@
//! 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;
pub fn admin_status() -> AdminStatusResponse {
let is_windows = cfg!(windows);
let is_elevated = is_running_elevated();
let message = if !is_windows {
"Проверка прав администратора нужна только в Windows.".to_string()
} else if is_elevated {
"ProxyWarden уже запущен от имени администратора.".to_string()
} else {
"Для установки компонентов и управления службами можно перезапустить ProxyWarden от имени администратора один раз.".to_string()
};
AdminStatusResponse {
is_windows,
is_elevated,
can_restart_elevated: is_windows && !is_elevated,
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,
"Перезапуск от имени администратора отменен или не был запущен.",
),
))
}
+594
View File
@@ -0,0 +1,594 @@
//! Transactional configuration apply use case.
//!
//! The module validates and generates all artifacts before source writes,
//! performs no service lifecycle actions, and attempts rollback when a later
//! write or runtime apply fails.
use crate::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
use crate::adapters::singbox::{
SingBoxAdapter, SingBoxConfigChecker, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
};
use crate::clock::Clock;
use crate::component_detection::{
proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier,
DetectedSingBox,
};
use crate::models::{
ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, Profile, ProfileInput,
ProxyProtocol, Target, TargetInput, TargetKind,
};
use crate::proxy_apply::{HelperApplyRequest, ProxyApplyHelper};
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 thiserror::Error;
const LOCAL_SINGBOX_TARGET_ID: &str = "local-singbox";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ApplyRouteMode {
External,
LocalSingbox,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyConfigurationInput {
pub route_mode: ApplyRouteMode,
pub profile: ProfileInput,
pub external_target: Option<TargetInput>,
#[serde(default = "default_true")]
pub disable_other_profiles: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyPhase {
pub id: String,
pub status: ApplyPhaseStatus,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApplyPhaseStatus {
Succeeded,
Failed,
RolledBack,
Skipped,
Warning,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyConfigurationResult {
pub success: bool,
pub changed: bool,
pub partial_state: bool,
pub message: String,
pub error_code: Option<String>,
pub generated_config_path: String,
pub singbox_generated_config_path: Option<String>,
pub restart_required: Vec<ComponentId>,
pub phases: Vec<ApplyPhase>,
}
#[derive(Debug, Error)]
pub enum ApplyFlowError {
#[error("Проверьте поля конфигурации")]
Validation { details: Vec<ValidationError> },
#[error("{message}")]
Failure { code: String, message: String },
}
impl ApplyFlowError {
pub fn code(&self) -> &str {
match self {
Self::Validation { .. } => "validation_failed",
Self::Failure { code, .. } => code,
}
}
pub fn details(self) -> Vec<ValidationError> {
match self {
Self::Validation { details } => details,
Self::Failure { .. } => Vec::new(),
}
}
fn failure(code: impl Into<String>, message: impl Into<String>) -> Self {
Self::Failure {
code: code.into(),
message: message.into(),
}
}
fn validation(details: Vec<ValidationError>) -> Self {
Self::Validation { details }
}
}
pub struct ApplyServices<'a> {
pub proxy_adapter: &'a dyn ProxyRouterAdapter,
pub singbox_adapter: &'a SingBoxAdapter,
pub checker: &'a dyn SingBoxConfigChecker,
pub helper: &'a dyn ProxyApplyHelper,
pub clock: &'a dyn Clock,
pub detected_proxyfier: Option<DetectedProxyfier>,
pub detected_singbox: Option<DetectedSingBox>,
}
/// Applies one complete routing draft without starting, stopping, installing,
/// uninstalling, or restarting Windows services.
pub fn apply_configuration(
storage: &JsonStorage,
input: ApplyConfigurationInput,
services: ApplyServices<'_>,
) -> Result<ApplyConfigurationResult, ApplyFlowError> {
let mut phases = Vec::new();
let old_profiles = storage
.read_profiles()
.map_err(|error| storage_error("profiles_read_failed", error))?;
let old_targets = storage
.read_targets()
.map_err(|error| storage_error("targets_read_failed", error))?;
let PreparedApply {
profiles,
targets,
proxy_config,
singbox_config,
} = prepare_apply(storage, input, &services)?;
phases.push(phase(
"preflight",
ApplyPhaseStatus::Succeeded,
"Входные данные и оба generated config проверены до записи.",
));
let source_changed = profiles != old_profiles || targets != old_targets;
let proxy_path = storage
.paths()
.generated_dir
.join(&proxy_config.output_file_name);
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);
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 записан; служба не перезапускалась.",
));
} 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,
"proxifyre_apply_failed",
result.message,
"runtime-apply",
phases,
));
}
Err(error) => {
return Ok(rollback_after_failure(
&rollback_state,
&error.code,
error.message,
"runtime-apply",
phases,
));
}
};
phases.push(phase(
"runtime-apply",
ApplyPhaseStatus::Succeeded,
"ProxiFyre config применён без управления службой.",
));
phases.push(phase(
"service-control",
ApplyPhaseStatus::Skipped,
"Apply не запускает, не останавливает и не перезапускает службы.",
));
let mut restart_required = Vec::new();
if services.detected_proxyfier.is_some() {
restart_required.push(ComponentId::Proxyfier);
}
if singbox_config.is_some() && services.detected_singbox.is_some() {
restart_required.push(ComponentId::Singbox);
}
let message = if restart_required.is_empty() {
helper_result.message.clone()
} else {
"Конфигурация применена. Для загрузки новых файлов явно перезапустите отмеченные службы."
.to_string()
};
let activity = ActivityEntry {
id: "configuration-applied".to_string(),
at: services.clock.now(),
level: ActivityLevel::Success,
title: "Маршрут применён".to_string(),
message: format!(
"Профилей: {}, приложений: {}. Управление службами не выполнялось.",
proxy_config.enabled_profiles, proxy_config.routed_apps
),
};
if let Err(error) = storage.append_activity(activity) {
phases.push(phase(
"activity",
ApplyPhaseStatus::Warning,
format!("Маршрут применён, но запись activity не удалась: {error}"),
));
} else {
phases.push(phase(
"activity",
ApplyPhaseStatus::Succeeded,
"Activity обновлена.",
));
}
Ok(ApplyConfigurationResult {
success: true,
changed: source_changed || helper_result.changed,
partial_state: false,
message,
error_code: None,
generated_config_path: proxy_path.display().to_string(),
singbox_generated_config_path: singbox_path.map(|path| path.display().to_string()),
restart_required,
phases,
})
}
struct PreparedApply {
profiles: Vec<Profile>,
targets: Vec<Target>,
proxy_config: crate::adapters::proxy_router::ProxyRouterGeneratedConfig,
singbox_config: Option<crate::adapters::singbox::SingBoxGeneratedConfig>,
}
fn prepare_apply(
storage: &JsonStorage,
input: ApplyConfigurationInput,
services: &ApplyServices<'_>,
) -> Result<PreparedApply, ApplyFlowError> {
if services.detected_proxyfier.is_none() {
return Err(ApplyFlowError::failure(
"proxifyre_not_found",
"ProxiFyre не найден. Установите компонент отдельным явным действием перед apply.",
));
}
let mut profile_input = input.profile;
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(|| {
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,
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 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 {
existing.enabled = false;
}
}
}
upsert_profile(&mut profiles, profile);
let components = vec![
proxyfier_component_from_detection(services.detected_proxyfier.as_ref()),
singbox_component_from_detection(services.detected_singbox.as_ref()),
];
let proxy_config = services
.proxy_adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.map_err(|error| ApplyFlowError::failure("proxifyre_preflight_failed", error.message))?;
Ok(PreparedApply {
profiles,
targets,
proxy_config,
singbox_config,
})
}
fn local_singbox_target(config: &LocalSingBoxConfig) -> Target {
Target {
id: LOCAL_SINGBOX_TARGET_ID.to_string(),
name: "Локальный sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: config.listen_host.clone(),
port: config.listen_port,
requires_component: Some(ComponentId::Singbox),
}
}
fn upsert_profile(profiles: &mut Vec<Profile>, profile: Profile) {
match profiles
.iter()
.position(|existing| existing.id == profile.id)
{
Some(index) => profiles[index] = profile,
None => profiles.push(profile),
}
}
fn upsert_target(targets: &mut Vec<Target>, target: Target) {
match targets.iter().position(|existing| existing.id == target.id) {
Some(index) => targets[index] = target,
None => targets.push(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,
partial_state: bool,
proxy_path: &Path,
singbox_path: Option<&Path>,
phases: Vec<ApplyPhase>,
) -> ApplyConfigurationResult {
ApplyConfigurationResult {
success: false,
changed: false,
partial_state,
message,
error_code: Some(code.to_string()),
generated_config_path: proxy_path.display().to_string(),
singbox_generated_config_path: singbox_path.map(|path| path.display().to_string()),
restart_required: Vec::new(),
phases,
}
}
fn phase(
id: impl Into<String>,
status: ApplyPhaseStatus,
message: impl Into<String>,
) -> ApplyPhase {
ApplyPhase {
id: id.into(),
status,
message: message.into(),
}
}
fn storage_error(code: &str, error: std::io::Error) -> ApplyFlowError {
ApplyFlowError::failure(code, format!("Ошибка storage: {error}"))
}
fn default_true() -> bool {
true
}
+19
View File
@@ -0,0 +1,19 @@
//! Small injectable time boundary for deterministic activity records.
use std::time::{SystemTime, UNIX_EPOCH};
pub trait Clock {
fn now(&self) -> String;
}
pub struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
format!("unix:{seconds}")
}
}
+542
View File
@@ -0,0 +1,542 @@
//! Serialized Tauri command boundary types.
//!
//! System/domain truth stays in `models`; these DTOs only define the stable
//! camelCase contract exposed to the React webview.
use crate::adapters::singbox::SingBoxCheckResult;
use crate::models::{
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
};
use crate::singbox_service::SingBoxSetupStatus;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminStatusResponse {
pub is_windows: bool,
pub is_elevated: bool,
pub can_restart_elevated: bool,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ValidationIssue {
pub field: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandError {
pub code: String,
pub message: String,
#[serde(default)]
pub details: Vec<ValidationIssue>,
}
impl CommandError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
details: Vec::new(),
}
}
pub fn with_details(
code: impl Into<String>,
message: impl Into<String>,
details: Vec<ValidationIssue>,
) -> Self {
Self {
code: code.into(),
message: message.into(),
details,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusResponse {
pub route_line: String,
pub active_profile_count: usize,
pub routed_app_count: usize,
pub active_target: Option<TargetDto>,
pub components: Vec<ComponentStatusDto>,
pub recent_activity: Vec<ActivityEntryDto>,
pub generated_config_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedStateResponse {
pub profiles: Vec<ProfileDto>,
pub targets: Vec<TargetDto>,
pub generated_config_path: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartupSnapshotResponse {
pub admin_status: AdminStatusResponse,
pub saved_state: SavedStateResponse,
pub components: Vec<ComponentStatusDto>,
pub proxifyre_setup_status: ProxiFyreSetupStatusDto,
pub singbox_status: LocalSingBoxStatusResponse,
pub singbox_setup_status: SingBoxSetupStatusDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxiFyreSetupStatusDto {
pub ready: bool,
pub missing_count: usize,
pub items: Vec<ProxiFyreSetupItemDto>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxiFyreSetupItemDto {
pub id: String,
pub name: String,
pub installed: bool,
pub version: Option<String>,
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 config: LocalSingBoxConfigDto,
pub cache: Option<SubscriptionCacheDto>,
pub component: ComponentStatusDto,
pub generated_config_path: String,
pub lan_listen_host: Option<String>,
#[cfg(debug_assertions)]
pub subscription_identity: SubscriptionRequestIdentityDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSingBoxConfigDto {
pub subscription_display_url: Option<String>,
pub has_subscription: bool,
pub selected_server_tag: Option<String>,
pub selected_server_id: Option<String>,
pub listen_host: String,
pub listen_port: u16,
pub service_name: String,
pub install_root: String,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionCacheDto {
pub servers: Vec<SubscriptionServerDto>,
pub user_info: serde_json::Map<String, serde_json::Value>,
pub fetched_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionServerDto {
pub id: String,
pub tag: String,
#[serde(rename = "type")]
pub server_type: String,
pub server: String,
pub server_port: u16,
}
#[cfg(debug_assertions)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionRequestIdentityDto {
pub headers: Vec<SubscriptionRequestHeaderDto>,
}
#[cfg(debug_assertions)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionRequestHeaderDto {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveSingBoxSubscriptionInputDto {
pub subscription_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectSingBoxServerInputDto {
#[serde(default)]
pub id: Option<String>,
pub tag: String,
#[serde(default)]
pub server: Option<String>,
#[serde(default)]
pub server_port: Option<u16>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PingSingBoxServerInputDto {
#[serde(default)]
pub id: Option<String>,
pub tag: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PingProxyTargetInputDto {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PingServerResponse {
pub id: String,
pub tag: String,
pub server: String,
pub server_port: u16,
pub ok: bool,
pub latency: Option<u128>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyProbeResponse {
pub id: String,
pub name: String,
pub url: String,
pub ok: bool,
pub status: Option<u16>,
pub latency: Option<u128>,
pub ip: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyTargetCheckResponse {
pub tag: String,
pub server: String,
pub server_port: u16,
pub ok: bool,
pub latency: Option<u128>,
pub error: Option<String>,
pub probes: Vec<ProxyProbeResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GenerateSingBoxConfigResponse {
pub success: bool,
pub message: String,
pub adapter_id: String,
pub generated_config_path: String,
pub selected_server_tag: String,
pub listen_host: String,
pub listen_port: u16,
pub check: Option<SingBoxCheckResult>,
pub activity: ActivityEntryDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInputDto {
pub id: Option<String>,
pub name: String,
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub target_id: Option<String>,
#[serde(default)]
pub protocols: Option<Vec<String>>,
#[serde(default)]
pub items: Option<Vec<ProfileItemInputDto>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItemInputDto {
#[serde(rename = "type")]
pub item_type: String,
pub value: String,
#[serde(default)]
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetInputDto {
pub id: Option<String>,
pub name: String,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub protocol: Option<String>,
pub host: String,
pub port: u32,
#[serde(default)]
pub requires_component: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileDto {
pub id: String,
pub name: String,
pub enabled: bool,
pub target_id: String,
pub protocols: Vec<Protocol>,
pub items: Vec<ProfileItemDto>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItemDto {
#[serde(rename = "type")]
pub item_type: ProfileItemType,
pub value: String,
pub recursive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetDto {
pub id: String,
pub name: String,
pub kind: TargetKind,
pub protocol: ProxyProtocol,
pub host: String,
pub port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub requires_component: Option<ComponentId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentStatusDto {
pub id: ComponentId,
pub name: String,
pub state: ComponentState,
pub installed: bool,
pub running: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_status: Option<String>,
pub problems: Vec<String>,
pub actions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ActivityEntryDto {
pub id: String,
pub at: String,
pub level: ActivityLevel,
pub title: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveProfilePreviewResponse {
pub profile_id: String,
pub apps: Vec<ResolvedAppDto>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedAppDto {
pub source_type: ProfileItemType,
pub source_value: String,
pub app_name: String,
pub notes: Vec<String>,
}
impl From<ProfileInputDto> for ProfileInput {
fn from(input: ProfileInputDto) -> Self {
Self {
id: input.id,
name: input.name,
enabled: input.enabled.unwrap_or(true),
target_id: input
.target_id
.unwrap_or_else(|| "local-singbox".to_string()),
protocols: input
.protocols
.unwrap_or_else(|| vec!["TCP".to_string(), "UDP".to_string()]),
items: input
.items
.unwrap_or_default()
.into_iter()
.map(ProfileItemInput::from)
.collect(),
}
}
}
impl From<ProfileItemInputDto> for ProfileItemInput {
fn from(input: ProfileItemInputDto) -> Self {
Self {
item_type: input.item_type,
value: input.value,
recursive: input.recursive,
}
}
}
impl From<TargetInputDto> for TargetInput {
fn from(input: TargetInputDto) -> Self {
Self {
id: input.id,
name: input.name,
kind: input.kind.unwrap_or_else(|| "external".to_string()),
protocol: input.protocol.unwrap_or_else(|| "socks5".to_string()),
host: input.host,
port: input.port,
requires_component: input.requires_component,
}
}
}
impl From<&Profile> for ProfileDto {
fn from(profile: &Profile) -> Self {
Self {
id: profile.id.clone(),
name: profile.name.clone(),
enabled: profile.enabled,
target_id: profile.target_id.clone(),
protocols: profile.protocols.clone(),
items: profile.items.iter().map(ProfileItemDto::from).collect(),
}
}
}
impl From<&ProfileItem> for ProfileItemDto {
fn from(item: &ProfileItem) -> Self {
Self {
item_type: item.item_type.clone(),
value: item.value.clone(),
recursive: item.recursive,
}
}
}
impl From<&Target> for TargetDto {
fn from(target: &Target) -> Self {
Self {
id: target.id.clone(),
name: target.name.clone(),
kind: target.kind.clone(),
protocol: target.protocol.clone(),
host: target.host.clone(),
port: target.port,
requires_component: target.requires_component.clone(),
}
}
}
impl From<&ComponentStatus> for ComponentStatusDto {
fn from(component: &ComponentStatus) -> Self {
Self {
id: component.id.clone(),
name: component.name.clone(),
state: component.state.clone(),
installed: component.installed,
running: component.running,
version: component.version.clone(),
path: component.path.clone(),
service_name: component.service_name.clone(),
service_status: component.service_status.clone(),
problems: component.problems.clone(),
actions: component.actions.clone(),
}
}
}
impl From<&ActivityEntry> for ActivityEntryDto {
fn from(entry: &ActivityEntry) -> Self {
Self {
id: entry.id.clone(),
at: entry.at.clone(),
level: entry.level.clone(),
title: entry.title.clone(),
message: entry.message.clone(),
}
}
}
impl From<&LocalSingBoxConfig> for LocalSingBoxConfigDto {
fn from(config: &LocalSingBoxConfig) -> Self {
Self {
subscription_display_url: config.subscription_display_url(),
has_subscription: config
.subscription_url
.as_deref()
.is_some_and(|value| !value.trim().is_empty()),
selected_server_tag: config.selected_server_tag.clone(),
selected_server_id: config.selected_server_id.clone(),
listen_host: config.listen_host.clone(),
listen_port: config.listen_port,
service_name: config.service_name.clone(),
install_root: config.install_root.clone(),
updated_at: config.updated_at.clone(),
}
}
}
impl From<&SubscriptionCache> for SubscriptionCacheDto {
fn from(cache: &SubscriptionCache) -> Self {
Self {
servers: cache
.servers
.iter()
.map(SubscriptionServerDto::from)
.collect(),
user_info: cache.user_info.clone(),
fetched_at: cache.fetched_at.clone(),
}
}
}
impl From<&SubscriptionServer> for SubscriptionServerDto {
fn from(server: &SubscriptionServer) -> Self {
Self {
id: server.id.clone(),
tag: server.tag.clone(),
server_type: server.server_type.clone(),
server: server.server.clone(),
server_port: server.server_port,
}
}
}
+78 -4666
View File
File diff suppressed because it is too large Load Diff
+54 -23
View File
@@ -30,10 +30,12 @@ pub struct DetectedProxyfier {
pub service_status: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectedService {
pub name: String,
pub status: String,
pub path_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -63,6 +65,15 @@ pub trait ProxyfierDetectionHost {
fn service_status(&self, service_name: &str) -> Option<String>;
fn service_info(&self, service_name: &str) -> Option<DetectedService> {
self.service_status(service_name)
.map(|status| DetectedService {
name: service_name.to_string(),
status,
path_name: None,
})
}
fn service_running(&self, service_name: &str) -> bool {
self.service_status(service_name)
.is_some_and(|status| service_status_is_running(&status))
@@ -102,6 +113,15 @@ impl ProxyfierDetectionHost for SystemProxyfierDetectionHost {
powershell_text(&script).map(|status| status.to_ascii_lowercase())
}
fn service_info(&self, service_name: &str) -> Option<DetectedService> {
let script = format!(
"$s = Get-CimInstance Win32_Service -Filter \"Name='{}'\" -ErrorAction SilentlyContinue; if ($s) {{ [ordered]@{{ name = $s.Name; status = $s.State; pathName = $s.PathName }} | ConvertTo-Json -Compress }}",
escape_powershell_single(service_name)
);
let json = powershell_text(&script)?;
serde_json::from_str(&json).ok()
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
read_registry_install_entries()
}
@@ -148,16 +168,9 @@ pub fn default_singbox_install_dir() -> PathBuf {
pub fn detect_proxyfier_install_with_host(
host: &impl ProxyfierDetectionHost,
) -> Option<DetectedProxyfier> {
let detected_service = detect_proxifyre_service(host);
let proxifyre_running = detected_service
.as_ref()
.is_some_and(|service| service_status_is_running(&service.status));
proxyfier_candidates(host)
.into_iter()
.filter_map(|candidate| {
candidate.into_detected(host, proxifyre_running, detected_service.as_ref())
})
.filter_map(|candidate| candidate.into_detected(host))
.next()
}
@@ -332,23 +345,24 @@ struct ProxyfierCandidate {
}
impl ProxyfierCandidate {
fn into_detected(
self,
host: &impl ProxyfierDetectionHost,
proxifyre_running: bool,
detected_service: Option<&DetectedService>,
) -> Option<DetectedProxyfier> {
fn into_detected(self, host: &impl ProxyfierDetectionHost) -> Option<DetectedProxyfier> {
let executable_path = self.install_dir.join(executable_name(&self.engine));
let config_path = config_path(&self.engine, &self.install_dir);
if !host.path_exists(&executable_path) {
return None;
}
let detected_service = detect_proxifyre_service(host, &executable_path);
let proxifyre_running = detected_service
.as_ref()
.is_some_and(|service| service_status_is_running(&service.status));
Some(DetectedProxyfier {
service_name: detected_service
.map(|service| service.name.clone())
.or_else(|| service_name(&self.engine).map(str::to_string)),
service_status: detected_service.map(|service| service.status.clone()),
.as_ref()
.map(|service| service.name.clone()),
service_status: detected_service
.as_ref()
.map(|service| service.status.clone()),
engine: self.engine,
name: self.name,
install_dir: self.install_dir,
@@ -539,19 +553,36 @@ fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> {
}
}
fn detect_proxifyre_service(host: &impl ProxyfierDetectionHost) -> Option<DetectedService> {
fn detect_proxifyre_service(
host: &impl ProxyfierDetectionHost,
executable_path: &Path,
) -> Option<DetectedService> {
for name in ["ProxiFyreService", "ProxiFyre"] {
if let Some(status) = host.service_status(name) {
return Some(DetectedService {
name: name.to_string(),
status: normalize_service_status(&status),
if let Some(mut service) = host.service_info(name) {
let matches_executable = service.path_name.as_deref().is_some_and(|path_name| {
service_path_matches_executable(path_name, executable_path)
});
if matches_executable {
service.status = normalize_service_status(&service.status);
return Some(service);
}
}
}
None
}
pub fn service_path_matches_executable(path_name: &str, executable_path: &Path) -> bool {
let path_name = path_name.trim();
let candidate = if let Some(rest) = path_name.strip_prefix('"') {
rest.split_once('"').map(|(path, _)| path)
} else {
path_name.split_whitespace().next()
};
candidate.is_some_and(|candidate| same_path(Path::new(candidate), executable_path))
}
fn normalize_service_status(status: &str) -> String {
status.trim().to_ascii_lowercase()
}
+156
View File
@@ -0,0 +1,156 @@
//! Live component status resolution and read-only route/profile presentation.
use crate::command_dto::{CommandError, ResolvedAppDto};
use crate::component_detection::{
detect_proxyfier_install, detect_singbox_install, proxyfier_component_from_detection,
singbox_component_from_detection, DetectedProxyfier, DetectedSingBox,
};
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 components_or_defaults_with_detection(
storage: &JsonStorage,
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,
))
}
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()),
);
upsert_component(
&mut components,
singbox_component_from_detection(detected_singbox.as_ref()),
);
components
}
fn default_components() -> Vec<ComponentStatus> {
vec![
ComponentStatus {
id: ComponentId::ControlApp,
name: "Приложение управления".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: None,
path: None,
service_name: None,
service_status: None,
problems: Vec::new(),
actions: vec![
"Открыть журнал".to_string(),
"Скопировать диагностику".to_string(),
],
},
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
service_name: Some("ProxiFyreService".to_string()),
service_status: None,
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
actions: vec!["Установить ProxiFyre".to_string()],
},
ComponentStatus {
id: ComponentId::Singbox,
name: "Локальный sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
service_name: Some(crate::models::DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()),
service_status: None,
problems: Vec::new(),
actions: vec!["Установить локальный sing-box".to_string()],
},
]
}
fn upsert_component(components: &mut Vec<ComponentStatus>, component: ComponentStatus) {
match components
.iter()
.position(|existing| existing.id == component.id)
{
Some(index) => components[index] = component,
None => components.push(component),
}
}
pub(crate) fn route_line(active_target: Option<&Target>) -> String {
match active_target {
Some(target) if target.id == "local-singbox" => {
format!(
"Выбранные приложения -> ProxiFyre -> локальный sing-box {}:{} -> VPN",
target.host, target.port
)
}
Some(target) => format!(
"Выбранные приложения -> ProxiFyre -> внешний прокси {}:{}",
target.host, target.port
),
None => "Выбранные приложения -> ProxiFyre -> внешний прокси".to_string(),
}
}
pub(crate) fn resolved_app(item: &ProfileItem, warnings: &mut Vec<String>) -> ResolvedAppDto {
let mut notes = Vec::new();
match item.item_type {
ProfileItemType::Process => notes.push("Имя процесса используется напрямую".to_string()),
ProfileItemType::Folder => {
let note = "Сканирование папок отложено; ProxiFyre получает путь к папке";
notes.push(note.to_string());
warnings.push(note.to_string());
}
ProfileItemType::Exe => {
notes.push("Путь к EXE сохраняется для сопоставления в ProxiFyre".to_string())
}
}
ResolvedAppDto {
source_type: item.item_type.clone(),
source_value: item.value.clone(),
app_name: item.value.clone(),
notes,
}
}
fn storage_error(error: std::io::Error) -> CommandError {
CommandError::new("storage_error", error.to_string())
}
+430
View File
@@ -0,0 +1,430 @@
//! Persisted profiles/targets, startup snapshot, ProxiFyre bootstrap import, and preview use cases.
use crate::adapters::proxifyre::{ProxiFyreConfig, ProxiFyreProxy};
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,
};
use crate::component_status::{
components_or_defaults, resolve_component_statuses, resolved_app, route_line,
};
use crate::models::{
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
};
use crate::proxifyre_runtime::build_proxifyre_setup_status_with_detection;
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 profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?;
let components = components_or_defaults(storage)?;
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
.iter()
.filter(|profile| profile.enabled)
.map(|profile| profile.items.len())
.sum();
let active_target = profiles
.iter()
.find(|profile| profile.enabled)
.and_then(|profile| targets.iter().find(|target| target.id == profile.target_id));
let route_line = route_line(active_target);
Ok(StatusResponse {
route_line,
active_profile_count,
routed_app_count,
active_target: active_target.map(TargetDto::from),
components: components.iter().map(ComponentStatusDto::from).collect(),
recent_activity: activity
.iter()
.take(10)
.map(ActivityEntryDto::from)
.collect(),
generated_config_path: storage
.paths()
.generated_dir
.join("proxifyre-app-config.json")
.display()
.to_string(),
})
}
pub fn read_profiles(storage: &JsonStorage) -> Result<Vec<ProfileDto>, CommandError> {
storage
.read_profiles()
.map_err(storage_error)
.map(|profiles| profiles.iter().map(ProfileDto::from).collect())
}
pub fn save_profile_to_storage(
storage: &JsonStorage,
input: ProfileInputDto,
) -> Result<ProfileDto, CommandError> {
let profile = normalize_profile(input.into()).map_err(validation_error)?;
let mut profiles = storage.read_profiles().map_err(storage_error)?;
match profiles
.iter()
.position(|existing| existing.id == profile.id)
{
Some(index) => profiles[index] = profile.clone(),
None => profiles.push(profile.clone()),
}
storage.write_profiles(&profiles).map_err(storage_error)?;
Ok(ProfileDto::from(&profile))
}
pub fn read_targets(storage: &JsonStorage) -> Result<Vec<TargetDto>, CommandError> {
storage
.read_targets()
.map_err(storage_error)
.map(|targets| targets.iter().map(TargetDto::from).collect())
}
pub fn save_target_to_storage(
storage: &JsonStorage,
input: TargetInputDto,
) -> Result<TargetDto, CommandError> {
let target = normalize_target(input.into()).map_err(validation_error)?;
let mut targets = storage.read_targets().map_err(storage_error)?;
match targets.iter().position(|existing| existing.id == target.id) {
Some(index) => targets[index] = target.clone(),
None => targets.push(target.clone()),
}
storage.write_targets(&targets).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_startup_snapshot(
storage: &JsonStorage,
) -> Result<StartupSnapshotResponse, CommandError> {
let detected_proxyfier = detect_proxyfier_install();
let detected_singbox = detect_singbox_install();
let saved_state = read_saved_state_with_proxifyre_config(
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();
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 singbox_setup_status = build_singbox_setup_status_with_install_root(
detected_singbox.as_ref(),
&default_singbox_install_dir(),
);
Ok(StartupSnapshotResponse {
admin_status: admin_status(),
saved_state,
components,
proxifyre_setup_status,
singbox_status,
singbox_setup_status,
})
}
pub fn read_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, CommandError> {
storage
.read_activity()
.map_err(storage_error)
.map(|entries| entries.iter().map(ActivityEntryDto::from).collect())
}
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())
}
pub fn read_saved_state_with_proxifyre_config(
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)?;
}
}
Ok(SavedStateResponse {
profiles: profiles.iter().map(ProfileDto::from).collect(),
targets: targets.iter().map(TargetDto::from).collect(),
generated_config_path: storage
.paths()
.generated_dir
.join("proxifyre-app-config.json")
.display()
.to_string(),
})
}
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> {
let profile = normalize_profile(input.into()).map_err(validation_error)?;
let mut warnings = Vec::new();
let apps = profile
.items
.iter()
.map(|item| resolved_app(item, &mut warnings))
.collect();
Ok(ResolveProfilePreviewResponse {
profile_id: profile.id,
apps,
warnings,
})
}
fn storage_error(error: std::io::Error) -> CommandError {
CommandError::new("storage_error", error.to_string())
}
fn validation_error(errors: Vec<ValidationError>) -> CommandError {
CommandError::with_details(
"validation_error",
"Проверка введенных данных не прошла",
errors
.into_iter()
.map(|error| ValidationIssue {
field: error.field,
message: error.message,
})
.collect(),
)
}
+16 -10
View File
@@ -1,12 +1,27 @@
pub mod activity;
pub mod admin;
pub mod apply_flow;
pub mod clock;
pub mod command_dto;
pub mod commands;
pub mod component_detection;
pub mod component_status;
pub mod configuration_use_case;
pub mod elevated_scripts;
pub mod helper;
pub mod models;
mod powershell;
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 safe_fs;
pub mod singbox_config;
pub mod singbox_runtime;
pub mod singbox_service;
pub mod singbox_subscription;
pub mod storage;
pub mod subscription;
pub mod validation;
@@ -22,21 +37,14 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.manage(commands::CommandState::default())
.invoke_handler(tauri::generate_handler![
commands::get_status,
commands::get_admin_status,
commands::restart_as_admin,
commands::get_startup_snapshot,
commands::get_profiles,
commands::get_saved_state,
commands::save_profile,
commands::get_targets,
commands::save_target,
commands::get_components,
commands::get_proxifyre_setup_status,
commands::get_proxifyre_setup_progress,
commands::get_singbox_status,
commands::get_singbox_setup_status,
commands::resolve_profile_preview,
commands::save_singbox_subscription,
commands::fetch_singbox_subscription,
commands::forget_singbox_subscription,
@@ -45,9 +53,7 @@ pub fn run() {
commands::ping_all_singbox_servers,
commands::ping_proxy_target,
commands::generate_singbox_config,
commands::apply_profiles,
commands::get_logs,
commands::open_config_location,
commands::apply_configuration,
commands::start_proxifyre_service,
commands::stop_proxifyre_service,
commands::install_proxifyre,
+37
View File
@@ -57,6 +57,7 @@ pub enum ComponentState {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileItemInput {
#[serde(rename = "type")]
pub item_type: String,
@@ -66,6 +67,7 @@ pub struct ProfileItemInput {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInput {
pub id: Option<String>,
pub name: String,
@@ -98,6 +100,7 @@ pub struct Profile {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetInput {
pub id: Option<String>,
pub name: String,
@@ -149,6 +152,8 @@ pub struct LocalSingBoxConfig {
pub device_hwid: Option<String>,
#[serde(default)]
pub selected_server_tag: Option<String>,
#[serde(default)]
pub selected_server_id: Option<String>,
#[serde(default = "default_local_singbox_listen_host")]
pub listen_host: String,
#[serde(default = "default_local_singbox_listen_port")]
@@ -181,6 +186,7 @@ impl Default for LocalSingBoxConfig {
subscription_url: None,
device_hwid: None,
selected_server_tag: None,
selected_server_id: None,
listen_host: default_local_singbox_listen_host(),
listen_port: default_local_singbox_listen_port(),
service_name: default_local_singbox_service_name(),
@@ -204,6 +210,7 @@ impl SubscriptionCache {
pub fn normalize_percent_encoded_tags(&mut self) {
for server in &mut self.servers {
server.tag = decode_percent_encoded_utf8(&server.tag);
server.ensure_id();
}
let Some(outbounds) = self
@@ -232,6 +239,8 @@ impl SubscriptionCache {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscriptionServer {
#[serde(default)]
pub id: String,
pub tag: String,
#[serde(rename = "type")]
pub server_type: String,
@@ -239,6 +248,34 @@ pub struct SubscriptionServer {
pub server_port: u16,
}
impl SubscriptionServer {
pub fn ensure_id(&mut self) {
if self.id.trim().is_empty() {
self.id = subscription_server_id(
&self.server_type,
&self.tag,
&self.server,
self.server_port,
);
}
}
}
pub fn subscription_server_id(
server_type: &str,
tag: &str,
server: &str,
server_port: u16,
) -> String {
format!(
"{}|{}|{}|{}",
server_type.trim().to_ascii_lowercase(),
tag.trim(),
server.trim().to_ascii_lowercase(),
server_port
)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActivityEntry {
pub id: String,
+120
View File
@@ -0,0 +1,120 @@
//! 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('\'', "''")
}
+104
View File
@@ -0,0 +1,104 @@
//! Ownership proof for destructive ProxiFyre uninstall operations.
use serde::Deserialize;
use std::{fs, path::Path};
pub const PROXIFYRE_MARKER_FILE: &str = "proxywarden-component.json";
pub const PROXIFYRE_MANAGED_SERVICE_NAME: &str = "ProxiFyreService";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagedProxiFyreOwnership {
pub service_name: String,
pub remove_packet_filter: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProxiFyreInstallMarker {
manager: String,
component: String,
service_name: String,
install_root: String,
#[serde(default)]
packet_filter_installed_by_proxy_warden: bool,
}
pub fn verify_managed_proxifyre_install(
install_dir: &Path,
executable_path: &Path,
expected_install_dir: &Path,
) -> Result<ManagedProxiFyreOwnership, String> {
let install_dir = canonical_path(install_dir, "папку ProxiFyre")?;
let expected_install_dir = canonical_path(expected_install_dir, "ожидаемую папку ProxiFyre")?;
if install_dir != expected_install_dir {
return Err(format!(
"папка {} не является управляемой папкой {}",
install_dir.display(),
expected_install_dir.display()
));
}
let has_expected_shape = install_dir
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("ProxiFyre"))
&& install_dir
.parent()
.and_then(Path::file_name)
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("components"));
if !has_expected_shape {
return Err("управляемая папка должна оканчиваться на components\\ProxiFyre".to_string());
}
let executable_path = canonical_path(executable_path, "ProxiFyre.exe")?;
if executable_path.parent() != Some(install_dir.as_path())
|| !executable_path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("ProxiFyre.exe"))
{
return Err("обнаруженный ProxiFyre.exe находится вне управляемой папки".to_string());
}
let marker_path = install_dir.join(PROXIFYRE_MARKER_FILE);
let marker_text = fs::read_to_string(&marker_path).map_err(|error| {
format!(
"не удалось прочитать marker установки {}: {error}",
marker_path.display()
)
})?;
let marker: ProxiFyreInstallMarker = serde_json::from_str(&marker_text).map_err(|error| {
format!(
"marker установки {} содержит некорректный JSON: {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());
}
let marker_root = canonical_path(Path::new(&marker.install_root), "installRoot из marker")?;
if marker_root != 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,
})
}
fn canonical_path(path: &Path, label: &str) -> Result<std::path::PathBuf, String> {
fs::canonicalize(path)
.map_err(|error| format!("не удалось проверить {label} '{}': {error}", path.display()))
}
File diff suppressed because it is too large Load Diff
+647
View File
@@ -0,0 +1,647 @@
//! 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 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'
[ordered]@{
manager = 'ProxyWarden'
component = 'proxifyre'
serviceName = 'ProxiFyreService'
installedAt = (Get-Date).ToString('o')
installRoot = $targetDir
packetFilterInstalledByProxyWarden = (-not $packetFilterAlreadyInstalled)
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
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 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
}
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
}
+258
View File
@@ -0,0 +1,258 @@
//! ProxiFyre config apply helper boundary and testable legacy apply fixture.
//!
//! The current webview path uses `apply_flow`; the lower-level fixture remains
//! for adapter/storage integration tests and shares the same detected writer.
use crate::adapters::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
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,
};
use crate::component_status::components_or_defaults_with_detection;
use crate::models::{ActivityEntry, ActivityLevel};
use crate::safe_fs;
use crate::storage::JsonStorage;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyProfilesResponse {
pub success: bool,
pub changed: bool,
pub message: String,
pub adapter_id: String,
pub generated_config_path: String,
pub enabled_profiles: usize,
pub routed_apps: usize,
pub helper: HelperApplyResult,
pub activity: ActivityEntryDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperApplyResult {
pub success: bool,
pub changed: bool,
pub action: String,
pub message: String,
}
pub struct HelperApplyRequest<'a> {
pub adapter_id: &'a str,
pub config_path: &'a Path,
pub config_contents: &'a str,
}
pub trait ProxyApplyHelper {
fn apply_proxy_config(
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError>;
}
pub struct DetectedProxyApplyHelper<H = SystemProxyfierDetectionHost> {
host: H,
}
impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
pub fn system() -> Self {
SystemProxyfierDetectionHost.into()
}
}
impl<H> From<H> for DetectedProxyApplyHelper<H> {
fn from(host: H) -> Self {
Self { host }
}
}
impl<H> ProxyApplyHelper for DetectedProxyApplyHelper<H>
where
H: ProxyfierDetectionHost,
{
fn apply_proxy_config(
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
let Some(detected) = detect_proxyfier_install_with_host(&self.host) else {
return staged_apply_result(request);
};
apply_to_detected_proxyfier(request, &detected)
}
}
pub fn apply_profiles_with_services(
storage: &JsonStorage,
adapter: &impl ProxyRouterAdapter,
helper: &impl ProxyApplyHelper,
clock: &impl Clock,
) -> Result<ApplyProfilesResponse, CommandError> {
apply_profiles_with_services_and_detection(
storage,
adapter,
helper,
clock,
detect_proxyfier_install(),
detect_singbox_install(),
)
}
pub fn apply_profiles_with_services_and_detection(
storage: &JsonStorage,
adapter: &impl ProxyRouterAdapter,
helper: &impl ProxyApplyHelper,
clock: &impl Clock,
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Result<ApplyProfilesResponse, CommandError> {
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 generated =
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
Ok(generated) => generated,
Err(error) => {
let command_error = adapter_error(error);
let activity = activity_for_apply_error(clock, &command_error);
storage.append_activity(activity).map_err(storage_error)?;
return Err(command_error);
}
};
let generated_path = storage
.paths()
.generated_dir
.join(generated.output_file_name.as_str());
write_generated_config(&generated_path, &generated.contents)?;
let helper_result = helper.apply_proxy_config(HelperApplyRequest {
adapter_id: generated.adapter_id.as_str(),
config_path: &generated_path,
config_contents: generated.contents.as_str(),
})?;
let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result);
storage
.append_activity(activity.clone())
.map_err(storage_error)?;
Ok(ApplyProfilesResponse {
success: helper_result.success,
changed: helper_result.changed,
message: helper_result.message.clone(),
adapter_id: generated.adapter_id,
generated_config_path: generated_path.display().to_string(),
enabled_profiles: generated.enabled_profiles,
routed_apps: generated.routed_apps,
helper: helper_result,
activity: ActivityEntryDto::from(&activity),
})
}
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()
),
})
}
fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result<HelperApplyResult, CommandError> {
Ok(HelperApplyResult {
success: true,
changed: true,
action: format!("{}.stage-generated-config", request.adapter_id),
message: format!(
"Сгенерированный конфиг подготовлен в {}; совместимая установка ProxiFyre не найдена",
request.config_path.display()
),
})
}
fn activity_for_apply(
clock: &impl Clock,
generated: &ProxyRouterGeneratedConfig,
generated_path: &Path,
helper_result: &HelperApplyResult,
) -> ActivityEntry {
let level = if helper_result.success {
ActivityLevel::Success
} else {
ActivityLevel::Error
};
ActivityEntry {
id: format!("apply-{}", generated.adapter_id),
at: clock.now(),
level,
title: "Конфиг ProxiFyre создан".to_string(),
message: format!(
"Профилей: {}, приложений: {}, конфиг: {}",
generated.enabled_profiles,
generated.routed_apps,
generated_path.display()
),
}
}
fn activity_for_apply_error(clock: &impl Clock, error: &CommandError) -> ActivityEntry {
ActivityEntry {
id: format!("apply-error-{}", error.code),
at: clock.now(),
level: ActivityLevel::Error,
title: "Применение ProxiFyre заблокировано".to_string(),
message: error.message.clone(),
}
}
fn storage_error(error: std::io::Error) -> CommandError {
CommandError::new("storage_error", error.to_string())
}
fn adapter_error(error: ProxyRouterError) -> CommandError {
let code = match error.kind {
ProxyRouterErrorKind::EmptyProfileItems => "empty_profile_items",
ProxyRouterErrorKind::MissingTarget => "missing_target",
ProxyRouterErrorKind::MissingRequiredComponent => "missing_required_component",
ProxyRouterErrorKind::RequiredComponentNotRunning => "required_component_not_running",
ProxyRouterErrorKind::UnsupportedTargetProtocol => "unsupported_target_protocol",
ProxyRouterErrorKind::Serialization => "serialization_error",
};
CommandError::new(code, error.message)
}
+316
View File
@@ -0,0 +1,316 @@
//! TCP and outbound HTTP checks used to verify a configured SOCKS5 route.
//!
//! All functions are blocking. Tauri handlers must call them through
//! `spawn_blocking`; probe URLs are static and never come from webview input.
use crate::command_dto::{
CommandError, PingProxyTargetInputDto, PingServerResponse, ProxyProbeResponse,
ProxyTargetCheckResponse,
};
use std::net::{IpAddr, TcpStream, ToSocketAddrs};
use std::time::{Duration, Instant};
const PROXY_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
const PROXY_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
const PROXY_CHECK_USER_AGENT: &str = "proxywarden route-check";
const DEFAULT_PROXY_PROBES: &[ProxyProbeEndpoint] = &[
ProxyProbeEndpoint {
id: "cloudflare-trace",
name: "Cloudflare Trace",
url: "https://www.cloudflare.com/cdn-cgi/trace",
ip_source: ProbeIpSource::CloudflareTrace,
},
ProxyProbeEndpoint {
id: "cloudflare-speed",
name: "Cloudflare Speed",
url: "https://speed.cloudflare.com/meta",
ip_source: ProbeIpSource::JsonField("clientIp"),
},
ProxyProbeEndpoint {
id: "ipify",
name: "ipify",
url: "https://api.ipify.org?format=json",
ip_source: ProbeIpSource::JsonField("ip"),
},
];
#[derive(Debug, Clone, Copy)]
pub struct ProxyProbeEndpoint {
id: &'static str,
name: &'static str,
url: &'static str,
ip_source: ProbeIpSource,
}
#[derive(Debug, Clone, Copy)]
enum ProbeIpSource {
CloudflareTrace,
JsonField(&'static str),
}
pub fn ping_proxy_target_endpoint(
input: PingProxyTargetInputDto,
) -> Result<ProxyTargetCheckResponse, CommandError> {
ping_proxy_target_endpoint_with_probes(input, DEFAULT_PROXY_PROBES)
}
pub fn ping_proxy_target_endpoint_with_probes(
input: PingProxyTargetInputDto,
probes: &[ProxyProbeEndpoint],
) -> Result<ProxyTargetCheckResponse, CommandError> {
let host = input.host.trim();
if host.is_empty() {
return Err(CommandError::new(
"proxy_target_host_missing",
"Хост внешнего прокси не указан.",
));
}
let tcp = ping_endpoint("route-proxy", "route-proxy", host, input.port);
if !tcp.ok {
return Ok(ProxyTargetCheckResponse {
tag: "route-proxy".to_string(),
server: host.to_string(),
server_port: input.port,
ok: false,
latency: tcp.latency,
error: tcp.error,
probes: Vec::new(),
});
}
let probe_results = run_proxy_probes(host, input.port, probes);
let has_probe_success = probe_results.iter().any(|probe| probe.ok);
let ok = probe_results.is_empty() || has_probe_success;
let error = (!ok).then(|| {
"SOCKS5 порт доступен, но тестовые HTTP endpoints не ответили через прокси.".to_string()
});
Ok(ProxyTargetCheckResponse {
tag: "route-proxy".to_string(),
server: host.to_string(),
server_port: input.port,
ok,
latency: tcp.latency,
error,
probes: probe_results,
})
}
pub fn ping_endpoint(id: &str, tag: &str, server: &str, server_port: u16) -> PingServerResponse {
let started = Instant::now();
let addresses = match (server, server_port).to_socket_addrs() {
Ok(addresses) => addresses.collect::<Vec<_>>(),
Err(error) => {
return PingServerResponse {
id: id.to_string(),
tag: tag.to_string(),
server: server.to_string(),
server_port,
ok: false,
latency: None,
error: Some(format!("DNS/адрес недоступен: {error}")),
};
}
};
if addresses.is_empty() {
return PingServerResponse {
id: id.to_string(),
tag: tag.to_string(),
server: server.to_string(),
server_port,
ok: false,
latency: None,
error: Some("DNS не вернул адреса".to_string()),
};
}
let timeout = Duration::from_secs(2);
let mut last_error = None;
for address in addresses {
match TcpStream::connect_timeout(&address, timeout) {
Ok(_) => {
return PingServerResponse {
id: id.to_string(),
tag: tag.to_string(),
server: server.to_string(),
server_port,
ok: true,
latency: Some(started.elapsed().as_millis()),
error: None,
};
}
Err(error) => last_error = Some(error.to_string()),
}
}
PingServerResponse {
id: id.to_string(),
tag: tag.to_string(),
server: server.to_string(),
server_port,
ok: false,
latency: None,
error: last_error,
}
}
fn run_proxy_probes(
proxy_host: &str,
proxy_port: u16,
probes: &[ProxyProbeEndpoint],
) -> Vec<ProxyProbeResponse> {
if probes.is_empty() {
return Vec::new();
}
let proxy_url = socks5h_proxy_url(proxy_host, proxy_port);
let client = match reqwest::Proxy::all(&proxy_url).and_then(|proxy| {
reqwest::blocking::Client::builder()
.timeout(PROXY_CHECK_TIMEOUT)
.connect_timeout(PROXY_CHECK_CONNECT_TIMEOUT)
.proxy(proxy)
.build()
}) {
Ok(client) => client,
Err(error) => {
return probes
.iter()
.map(|probe| {
failed_probe(
*probe,
format!("Не удалось подготовить SOCKS5 проверку: {error}"),
)
})
.collect();
}
};
let handles = probes
.iter()
.copied()
.map(|probe| {
let client = client.clone();
std::thread::spawn(move || run_proxy_probe(&client, probe))
})
.collect::<Vec<_>>();
handles
.into_iter()
.zip(probes.iter().copied())
.map(|(handle, probe)| {
handle
.join()
.unwrap_or_else(|_| failed_probe(probe, "Проверка была прервана.".to_string()))
})
.collect()
}
fn run_proxy_probe(
client: &reqwest::blocking::Client,
probe: ProxyProbeEndpoint,
) -> ProxyProbeResponse {
let started = Instant::now();
let response = match client
.get(probe.url)
.header(reqwest::header::USER_AGENT, PROXY_CHECK_USER_AGENT)
.send()
{
Ok(response) => response,
Err(error) => return failed_probe(probe, format!("HTTP через SOCKS5 не прошел: {error}")),
};
let status = response.status();
let status_code = status.as_u16();
let body = match response.text() {
Ok(body) => body,
Err(error) => {
return failed_probe_with_status(
probe,
status_code,
format!("Ответ не прочитан: {error}"),
);
}
};
let latency = started.elapsed().as_millis();
if !status.is_success() {
return ProxyProbeResponse {
id: probe.id.to_string(),
name: probe.name.to_string(),
url: probe.url.to_string(),
ok: false,
status: Some(status_code),
latency: Some(latency),
ip: None,
error: Some(format!("HTTP {status_code}")),
};
}
ProxyProbeResponse {
id: probe.id.to_string(),
name: probe.name.to_string(),
url: probe.url.to_string(),
ok: true,
status: Some(status_code),
latency: Some(latency),
ip: extract_probe_ip(probe, &body),
error: None,
}
}
fn failed_probe(probe: ProxyProbeEndpoint, error: String) -> ProxyProbeResponse {
failed_probe_with_status(probe, 0, error)
}
fn failed_probe_with_status(
probe: ProxyProbeEndpoint,
status: u16,
error: String,
) -> ProxyProbeResponse {
ProxyProbeResponse {
id: probe.id.to_string(),
name: probe.name.to_string(),
url: probe.url.to_string(),
ok: false,
status: (status > 0).then_some(status),
latency: None,
ip: None,
error: Some(error),
}
}
fn socks5h_proxy_url(host: &str, port: u16) -> String {
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
if host.contains(':') {
format!("socks5h://[{host}]:{port}")
} else {
format!("socks5h://{host}:{port}")
}
}
fn extract_probe_ip(probe: ProxyProbeEndpoint, body: &str) -> Option<String> {
match probe.ip_source {
ProbeIpSource::CloudflareTrace => body
.lines()
.find_map(|line| line.strip_prefix("ip=").and_then(normalize_ip)),
ProbeIpSource::JsonField(field) => serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|value| {
value
.get(field)
.and_then(|field| field.as_str())
.and_then(normalize_ip)
}),
}
}
fn normalize_ip(value: &str) -> Option<String> {
let candidate = value.trim().trim_matches('"');
candidate
.parse::<IpAddr>()
.is_ok()
.then(|| candidate.to_string())
}
+125
View File
@@ -0,0 +1,125 @@
//! Local sing-box config generation and derived local-target persistence.
use crate::adapters::singbox::{
SingBoxAdapter, SingBoxConfigChecker, SingBoxConfigError, SingBoxConfigErrorKind,
SingBoxGeneratedConfig, SingBoxGenerationRequest,
};
use crate::clock::Clock;
use crate::command_dto::{ActivityEntryDto, CommandError, GenerateSingBoxConfigResponse};
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;
pub fn generate_singbox_config_with_services<C>(
storage: &JsonStorage,
adapter: &SingBoxAdapter,
checker: &C,
clock: &impl Clock,
binary_path: Option<&Path>,
) -> Result<GenerateSingBoxConfigResponse, CommandError>
where
C: SingBoxConfigChecker,
{
let config = storage.read_local_singbox_config().map_err(storage_error)?;
let cache = read_required_singbox_cache(storage)?;
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&config, &cache, binary_path),
checker,
)
.map_err(singbox_adapter_error)?;
let generated_path = storage
.paths()
.generated_dir
.join(generated.output_file_name.as_str());
write_generated_config(&generated_path, &generated.contents)?;
ensure_local_singbox_target(storage, &config)?;
let activity = activity_for_singbox_generate(clock, &generated, &generated_path);
storage
.append_activity(activity.clone())
.map_err(storage_error)?;
Ok(GenerateSingBoxConfigResponse {
success: true,
message: "Конфиг Local sing-box создан".to_string(),
adapter_id: generated.adapter_id,
generated_config_path: generated_path.display().to_string(),
selected_server_tag: generated.selected_server_tag,
listen_host: generated.listen,
listen_port: generated.listen_port,
check: generated.check,
activity: ActivityEntryDto::from(&activity),
})
}
fn ensure_local_singbox_target(
storage: &JsonStorage,
config: &LocalSingBoxConfig,
) -> Result<(), CommandError> {
let mut targets = storage.read_targets().map_err(storage_error)?;
let target = Target {
id: "local-singbox".to_string(),
name: "Локальный sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: config.listen_host.clone(),
port: config.listen_port,
requires_component: Some(ComponentId::Singbox),
};
match targets.iter().position(|existing| existing.id == target.id) {
Some(index) => targets[index] = target,
None => targets.push(target),
}
storage.write_targets(&targets).map_err(storage_error)
}
fn activity_for_singbox_generate(
clock: &impl Clock,
generated: &SingBoxGeneratedConfig,
generated_path: &Path,
) -> ActivityEntry {
ActivityEntry {
id: "singbox-config-generated".to_string(),
at: clock.now(),
level: ActivityLevel::Success,
title: "Конфиг Local sing-box создан".to_string(),
message: format!(
"Сервер: {}, listen: {}:{}, конфиг: {}",
generated.selected_server_tag,
generated.listen,
generated.listen_port,
generated_path.display()
),
}
}
fn singbox_adapter_error(error: SingBoxConfigError) -> CommandError {
let code = match error.kind {
SingBoxConfigErrorKind::MissingSelectedServer => "singbox_server_not_selected",
SingBoxConfigErrorKind::MissingSelectedOutbound => "singbox_selected_server_missing",
SingBoxConfigErrorKind::UnsupportedSelectedOutbound => {
"singbox_selected_server_unsupported"
}
SingBoxConfigErrorKind::Serialization => "serialization_error",
SingBoxConfigErrorKind::CheckFailed => "singbox_check_failed",
};
CommandError::new(code, error.message)
}
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
}
fn storage_error(error: std::io::Error) -> CommandError {
CommandError::new("storage_error", error.to_string())
}
+532
View File
@@ -0,0 +1,532 @@
//! Explicit Local sing-box service and package lifecycle orchestration.
//!
//! These operations may request UAC elevation. Apply configuration never calls
//! this module; install/start/stop/uninstall remain separate user actions.
use crate::command_dto::{CommandError, ComponentStatusDto};
use crate::component_detection::{detect_singbox_install, singbox_component_from_detection};
use crate::elevated_scripts;
use crate::powershell::{
escape_single as escape_powershell_single, is_elevated as is_running_elevated,
package_failure_details, run_command as run_powershell_command,
run_file as run_powershell_file, write_script as write_powershell_script,
};
use crate::process::command_no_window;
use crate::singbox_service::{
ensure_safe_singbox_install_dir,
parse_service_command_output as parse_singbox_service_command_output, service_control_script,
ServiceCommandOutput as SingBoxServiceCommandOutput, SingBoxServiceAction,
};
use crate::storage::{default_config_root, JsonStorage};
use std::fs;
use std::path::{Path, PathBuf};
pub(crate) fn control_singbox_service(
action: SingBoxServiceAction,
config_source: Option<&Path>,
) -> Result<ComponentStatusDto, CommandError> {
let Some(detected) = detect_singbox_install() else {
return Err(CommandError::new(
"singbox_not_found",
"Local sing-box не найден на компьютере.",
));
};
let config_target = config_source.map(|_| detected.install_dir.join("config.json"));
let script = service_control_script(
action,
&detected.service_name,
config_source,
config_target.as_deref(),
);
let output = command_no_window("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script.as_str(),
])
.output()
.map_err(|error| {
CommandError::new(
singbox_service_error_code(action),
format!(
"Не удалось {} службу Local sing-box: {error}",
action.label()
),
)
})?;
let result = parse_singbox_service_command_output(&output.stdout).ok_or_else(|| {
CommandError::new(
singbox_service_error_code(action),
singbox_service_script_failed_message(action, output.status.code()),
)
})?;
if result.success {
let refreshed = detect_singbox_install();
let component = singbox_component_from_detection(refreshed.as_ref());
return Ok(ComponentStatusDto::from(&component));
}
if matches!(
result.code.as_str(),
"start_failed" | "stop_failed" | "config_sync_failed"
) {
run_elevated_singbox_service_command(
action,
&detected.service_name,
config_source,
config_target.as_deref(),
&result,
)?;
let refreshed = detect_singbox_install();
let component = singbox_component_from_detection(refreshed.as_ref());
return Ok(ComponentStatusDto::from(&component));
}
Err(CommandError::new(
singbox_service_error_code(action),
singbox_service_command_failed_message(action, &result),
))
}
fn run_elevated_singbox_service_command(
action: SingBoxServiceAction,
service_name: &str,
config_source: Option<&Path>,
config_target: Option<&Path>,
direct_result: &SingBoxServiceCommandOutput,
) -> Result<(), CommandError> {
let script_path =
write_elevated_singbox_service_script(action, service_name, config_source, config_target)?;
let launch_script = format!(
"$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode",
escape_powershell_single(&script_path.display().to_string())
);
let output = if is_running_elevated() {
run_powershell_file(&script_path)
} else {
run_powershell_command(&launch_script)
};
let _ = fs::remove_file(&script_path);
match output {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(CommandError::new(
singbox_service_error_code(action),
elevated_singbox_service_failed_message(action, direct_result, output.status.code()),
)),
Err(error) => Err(CommandError::new(
singbox_service_error_code(action),
format!(
"Не удалось запросить права администратора, чтобы {} службу Local sing-box: {error}",
action.label()
),
)),
}
}
fn write_elevated_singbox_service_script(
action: SingBoxServiceAction,
service_name: &str,
config_source: Option<&Path>,
config_target: Option<&Path>,
) -> Result<PathBuf, CommandError> {
let script_path = elevated_scripts::temp_script_path("proxywarden-singbox-service");
let script =
elevated_singbox_service_script(action, service_name, config_source, config_target);
write_powershell_script(&script_path, &script).map_err(|error| {
CommandError::new(
singbox_service_error_code(action),
format!(
"Не удалось подготовить временный скрипт для управления Local sing-box '{}': {error}",
script_path.display()
),
)
})?;
Ok(script_path)
}
fn elevated_singbox_service_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 = 'SilentlyContinue'
$serviceName = '{escaped_service_name}'
$action = '{action_name}'
$configSource = '{escaped_config_source}'
$configTarget = '{escaped_config_target}'
if ($action -eq 'start') {{
if (-not [string]::IsNullOrWhiteSpace($configSource)) {{
if (-not (Test-Path -LiteralPath $configSource)) {{ exit 5 }}
if (-not [string]::IsNullOrWhiteSpace($configTarget)) {{
try {{
Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop
}} catch {{
exit 6
}}
}}
}}
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($null -eq $service) {{ exit 2 }}
if ($service.Status -eq 'Running') {{ exit 0 }}
Start-Service -Name $serviceName -ErrorAction SilentlyContinue
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($null -ne $service) {{
try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}}
if ($service.Status -eq 'Running') {{ exit 0 }}
}}
exit 3
}}
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($null -eq $service) {{ exit 2 }}
if ($service.Status -eq 'Stopped') {{ exit 0 }}
Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($null -ne $service) {{
try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15)) }} catch {{}}
if ($service.Status -eq 'Stopped') {{ exit 0 }}
}}
exit 4
"#
)
}
pub(crate) fn install_singbox_component(
storage: &JsonStorage,
install_dir: &Path,
) -> Result<ComponentStatusDto, CommandError> {
let generated_config_path = storage.paths().generated_dir.join("sing-box-config.json");
run_elevated_singbox_package_script(
SingBoxPackageAction::Install,
include_str!("../../scripts/install-singbox.ps1"),
vec![
"-InstallRoot".to_string(),
install_dir.display().to_string(),
"-ConfigSource".to_string(),
generated_config_path.display().to_string(),
],
&storage.paths().state_dir,
)?;
let refreshed = detect_singbox_install();
let Some(detected) = refreshed.as_ref() else {
return Err(CommandError::new(
SingBoxPackageAction::Install.error_code(),
"Установка Local sing-box завершилась, но приложение не найдено после проверки.",
));
};
Ok(ComponentStatusDto::from(&singbox_component_from_detection(
Some(detected),
)))
}
pub(crate) fn uninstall_singbox_component() -> Result<ComponentStatusDto, CommandError> {
let Some(detected) = detect_singbox_install() else {
let component = singbox_component_from_detection(None);
return Ok(ComponentStatusDto::from(&component));
};
ensure_safe_singbox_install_dir(&detected.install_dir).map_err(|message| {
CommandError::new(SingBoxPackageAction::Uninstall.error_code(), message)
})?;
let artifact_dir = default_config_root().join("state");
run_elevated_singbox_package_script(
SingBoxPackageAction::Uninstall,
include_str!("../../scripts/install-singbox.ps1"),
vec![
"-InstallRoot".to_string(),
detected.install_dir.display().to_string(),
"-ServiceName".to_string(),
detected.service_name,
"-Uninstall".to_string(),
],
&artifact_dir,
)?;
let refreshed = detect_singbox_install();
if refreshed.is_some() {
return Err(CommandError::new(
SingBoxPackageAction::Uninstall.error_code(),
"Удаление Local sing-box завершилось, но приложение все еще найдено на компьютере.",
));
}
let component = singbox_component_from_detection(None);
Ok(ComponentStatusDto::from(&component))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SingBoxPackageAction {
Install,
Uninstall,
}
impl SingBoxPackageAction {
fn error_code(self) -> &'static str {
match self {
SingBoxPackageAction::Install => "singbox_install_failed",
SingBoxPackageAction::Uninstall => "singbox_uninstall_failed",
}
}
fn label(self) -> &'static str {
match self {
SingBoxPackageAction::Install => "установить",
SingBoxPackageAction::Uninstall => "удалить",
}
}
fn file_label(self) -> &'static str {
match self {
SingBoxPackageAction::Install => "install",
SingBoxPackageAction::Uninstall => "uninstall",
}
}
}
fn run_elevated_singbox_package_script(
action: SingBoxPackageAction,
installer_body: &str,
installer_args: Vec<String>,
artifact_dir: &Path,
) -> Result<(), CommandError> {
fs::create_dir_all(artifact_dir).map_err(|error| {
CommandError::new(
action.error_code(),
format!(
"Не удалось создать папку для временных файлов Local sing-box '{}': {error}",
artifact_dir.display()
),
)
})?;
let prefix = format!("proxywarden-singbox-{}", action.file_label());
let installer_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1");
let runner_path =
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.runner"), "ps1");
let result_path =
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log");
write_powershell_script(&installer_path, installer_body).map_err(|error| {
CommandError::new(
action.error_code(),
format!(
"Не удалось подготовить установщик Local sing-box '{}': {error}",
installer_path.display()
),
)
})?;
write_powershell_script(
&runner_path,
&singbox_installer_runner_script(&installer_path, &result_path, &installer_args),
)
.map_err(|error| {
CommandError::new(
action.error_code(),
format!(
"Не удалось подготовить runner Local sing-box '{}': {error}",
runner_path.display()
),
)
})?;
let launch_script = format!(
r#"
$ErrorActionPreference = 'Stop'
$resultPath = '{}'
try {{
$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}')
if ($null -eq $p) {{
Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8
exit 1
}}
exit $p.ExitCode
}} catch {{
Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8
exit 1
}}
"#,
escape_powershell_single(&result_path.display().to_string()),
escape_powershell_single(&runner_path.display().to_string())
);
let output = if is_running_elevated() {
run_powershell_file(&runner_path)
} else {
run_powershell_command(&launch_script)
};
let _ = fs::remove_file(&installer_path);
let _ = fs::remove_file(&runner_path);
match output {
Ok(output) if output.status.success() => {
let _ = fs::remove_file(&result_path);
Ok(())
}
Ok(output) => {
let details = package_failure_details(&result_path, &output);
let _ = fs::remove_file(&result_path);
Err(CommandError::new(
action.error_code(),
format!(
"Не удалось {} Local sing-box. Код elevated-команды: {}. {details}",
action.label(),
output.status.code().unwrap_or(-1),
),
))
}
Err(error) => Err(CommandError::new(
action.error_code(),
format!(
"Не удалось запросить права администратора, чтобы {} Local sing-box: {error}",
action.label()
),
)),
}
}
pub fn singbox_installer_runner_script(
installer_path: &Path,
result_path: &Path,
installer_args: &[String],
) -> String {
let args = installer_args
.iter()
.map(|arg| format!("'{}'", escape_powershell_single(arg)))
.collect::<Vec<_>>()
.join(", ");
format!(
r#"
$ErrorActionPreference = 'Stop'
$installerPath = '{}'
$resultPath = '{}'
$stdoutPath = "$resultPath.stdout.log"
$stderrPath = "$resultPath.stderr.log"
$installerArgs = @({args})
try {{
$output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs 2>&1
$exitCode = $LASTEXITCODE
Set-Content -LiteralPath $stdoutPath -Value ($output | Out-String) -Encoding UTF8
if ($exitCode -ne 0) {{
$stdout = if (Test-Path -LiteralPath $stdoutPath) {{ Get-Content -LiteralPath $stdoutPath -Raw }} else {{ '' }}
$stderr = if (Test-Path -LiteralPath $stderrPath) {{ Get-Content -LiteralPath $stderrPath -Raw }} else {{ '' }}
throw "install-singbox.ps1 завершился с кодом $exitCode. stdout: $stdout stderr: $stderr"
}}
Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8
exit 0
}} catch {{
Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8
exit 1
}} finally {{
Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
}}
"#,
escape_powershell_single(&installer_path.display().to_string()),
escape_powershell_single(&result_path.display().to_string())
)
}
fn singbox_service_error_code(action: SingBoxServiceAction) -> &'static str {
match action {
SingBoxServiceAction::Start => "singbox_service_start_failed",
SingBoxServiceAction::Stop => "singbox_service_stop_failed",
}
}
fn singbox_service_script_failed_message(
action: SingBoxServiceAction,
exit_code: Option<i32>,
) -> String {
let exit_code = exit_code
.map(|code| format!(" Код выхода PowerShell: {code}."))
.unwrap_or_default();
format!(
"Не удалось {} службу Local sing-box: команда управления службой не вернула корректный результат.{exit_code}",
action.label()
)
}
fn singbox_service_command_failed_message(
action: SingBoxServiceAction,
result: &SingBoxServiceCommandOutput,
) -> String {
let service_name = result
.service_name
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("ProxyWardenSingBox");
let status = result
.status
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("неизвестен");
let pid = result
.process_id
.filter(|value| *value > 0)
.map(|value| format!(", PID: {value}"))
.unwrap_or_default();
match result.code.as_str() {
"service_not_found" => "Служба Local sing-box не найдена.".to_string(),
"config_source_missing" => {
"Сгенерированный конфиг Local sing-box не найден перед запуском службы.".to_string()
}
"config_sync_failed" => {
"Не удалось обновить config.json службы Local sing-box перед запуском. Попробуй запустить приложение от имени администратора.".to_string()
}
"start_failed" => format!(
"Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора."
),
"stop_failed" => format!(
"Не удалось остановить службу {service_name}. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc."
),
_ => format!(
"Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.",
action.label()
),
}
}
fn elevated_singbox_service_failed_message(
action: SingBoxServiceAction,
direct_result: &SingBoxServiceCommandOutput,
exit_code: Option<i32>,
) -> String {
let exit_code = exit_code
.map(|code| format!(" Код выхода elevated PowerShell: {code}."))
.unwrap_or_default();
format!(
"{} Попытка с правами администратора тоже не сработала.{exit_code}",
singbox_service_command_failed_message(action, direct_result)
)
}
+377
View File
@@ -0,0 +1,377 @@
//! Local sing-box subscription persistence, selection, status, and ping use cases.
use crate::clock::Clock;
use crate::command_dto::*;
use crate::component_detection::{
detect_singbox_install, singbox_component_from_detection, DetectedSingBox,
};
use crate::models::{
ActivityEntry, ActivityLevel, LocalSingBoxConfig, SubscriptionCache, SubscriptionServer,
};
use crate::proxy_probe::ping_endpoint;
use crate::storage::JsonStorage;
use crate::subscription;
use std::net::{IpAddr, UdpSocket};
pub trait SubscriptionFetcher {
fn fetch_subscription(
&self,
url: &str,
identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError>;
}
pub struct SystemSubscriptionFetcher;
impl SubscriptionFetcher for SystemSubscriptionFetcher {
fn fetch_subscription(
&self,
url: &str,
identity: &subscription::SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
subscription::fetch_subscription_with_identity(url, identity)
}
}
#[cfg(debug_assertions)]
fn subscription_request_identity_for_display() -> SubscriptionRequestIdentityDto {
let identity = subscription::SubscriptionFetchIdentity::default();
let headers = identity
.request_headers_without_device_hwid()
.into_iter()
.map(|(name, value)| SubscriptionRequestHeaderDto {
name: name.to_string(),
value,
})
.collect();
SubscriptionRequestIdentityDto { headers }
}
pub fn read_singbox_status(
storage: &JsonStorage,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let detected = detect_singbox_install();
read_singbox_status_with_detection(storage, detected.as_ref())
}
pub(crate) fn read_singbox_status_with_detection(
storage: &JsonStorage,
detected: Option<&DetectedSingBox>,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let config = storage.read_local_singbox_config().map_err(storage_error)?;
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?;
let component = singbox_component_from_detection(detected);
Ok(LocalSingBoxStatusResponse {
config: LocalSingBoxConfigDto::from(&config),
cache: cache.as_ref().map(SubscriptionCacheDto::from),
component: ComponentStatusDto::from(&component),
generated_config_path: storage
.paths()
.generated_dir
.join("sing-box-config.json")
.display()
.to_string(),
lan_listen_host: local_lan_ipv4(),
#[cfg(debug_assertions)]
subscription_identity: subscription_request_identity_for_display(),
})
}
pub fn save_singbox_subscription_to_storage(
storage: &JsonStorage,
input: SaveSingBoxSubscriptionInputDto,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
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)?;
config.subscription_url = Some(subscription_url);
ensure_device_hwid(&mut config);
config.updated_at = Some(clock.now());
storage
.write_local_singbox_config(&config)
.map_err(storage_error)?;
read_singbox_status(storage)
}
pub fn fetch_singbox_subscription_with_fetcher(
storage: &JsonStorage,
fetcher: &impl SubscriptionFetcher,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
let subscription_url = config
.subscription_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| {
CommandError::new(
"singbox_subscription_missing",
"Ссылка на подписку Local sing-box не сохранена.",
)
})?;
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)?;
}
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());
config.selected_server_id = selected_server.map(|server| server.id.clone());
config.selected_server_tag = selected_server.map(|server| server.tag.clone());
config.updated_at = Some(clock.now());
storage
.write_singbox_subscription_cache(&cache)
.map_err(storage_error)?;
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)
}
pub fn forget_singbox_subscription_in_storage(
storage: &JsonStorage,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
config.subscription_url = None;
config.selected_server_tag = None;
config.selected_server_id = None;
config.updated_at = Some(clock.now());
storage
.write_local_singbox_config(&config)
.map_err(storage_error)?;
storage
.remove_singbox_subscription_cache()
.map_err(storage_error)?;
read_singbox_status(storage)
}
pub fn select_singbox_server_in_storage(
storage: &JsonStorage,
input: SelectSingBoxServerInputDto,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let requested_tag = input.tag.trim().to_string();
let requested_id = input
.id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty());
if requested_tag.is_empty() {
return Err(CommandError::new(
"singbox_server_tag_missing",
"Сервер Local sing-box не выбран.",
));
}
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?
.ok_or_else(|| {
CommandError::new(
"singbox_subscription_cache_missing",
"Сначала нужно загрузить подписку Local sing-box.",
)
})?;
let Some(server) = find_subscription_server(
&cache,
requested_id,
&requested_tag,
input.server.as_deref(),
input.server_port,
) else {
return Err(CommandError::new(
"singbox_server_not_found",
format!("Сервер Local sing-box '{requested_tag}' не найден в текущей подписке."),
));
};
let selected_tag = server.tag.clone();
let selected_id = server.id.clone();
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
config.selected_server_tag = Some(selected_tag);
config.selected_server_id = Some(selected_id);
config.updated_at = Some(clock.now());
storage
.write_local_singbox_config(&config)
.map_err(storage_error)?;
read_singbox_status(storage)
}
pub fn ping_singbox_server_in_storage(
storage: &JsonStorage,
input: PingSingBoxServerInputDto,
) -> Result<PingServerResponse, CommandError> {
let tag = input.tag.trim();
let id = input
.id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty());
let cache = read_required_singbox_cache(storage)?;
let server = find_subscription_server(&cache, id, tag, None, None).ok_or_else(|| {
CommandError::new(
"singbox_server_not_found",
format!("Сервер Local sing-box '{tag}' не найден в текущей подписке."),
)
})?;
Ok(ping_subscription_server(server))
}
pub fn ping_all_singbox_servers_in_storage(
storage: &JsonStorage,
) -> Result<Vec<PingServerResponse>, CommandError> {
let cache = read_required_singbox_cache(storage)?;
Ok(cache.servers.iter().map(ping_subscription_server).collect())
}
pub(crate) fn read_required_singbox_cache(
storage: &JsonStorage,
) -> Result<SubscriptionCache, CommandError> {
storage
.read_singbox_subscription_cache()
.map_err(storage_error)?
.ok_or_else(|| {
CommandError::new(
"singbox_subscription_cache_missing",
"Сначала нужно загрузить подписку Local sing-box.",
)
})
}
fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError> {
if subscription_url.is_empty() {
return Err(CommandError::new(
"singbox_subscription_url_missing",
"Ссылка на подписку Local sing-box не указана.",
));
}
let parsed = url::Url::parse(subscription_url).map_err(|_| {
CommandError::new(
"singbox_subscription_url_invalid",
"Ссылка на подписку Local sing-box должна быть корректным URL.",
)
})?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(CommandError::new(
"singbox_subscription_url_invalid",
"Ссылка на подписку Local sing-box должна начинаться с http:// или https://.",
));
}
Ok(())
}
fn ensure_device_hwid(config: &mut LocalSingBoxConfig) -> bool {
if config
.device_hwid
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
return false;
}
config.device_hwid = Some(uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase());
true
}
fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse {
ping_endpoint(&server.id, &server.tag, &server.server, server.server_port)
}
fn local_lan_ipv4() -> Option<String> {
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
let IpAddr::V4(address) = socket.local_addr().ok()?.ip() else {
return None;
};
if address.is_loopback() || address.is_link_local() || address.is_unspecified() {
return None;
}
Some(address.to_string())
}
fn find_subscription_server<'a>(
cache: &'a SubscriptionCache,
requested_id: Option<&str>,
requested_tag: &str,
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
})
})
}
fn comparable_server_tag(value: &str) -> String {
value
.chars()
.filter(|ch| !matches!(ch, '\u{fe0e}' | '\u{fe0f}' | '\u{200d}'))
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn storage_error(error: std::io::Error) -> CommandError {
CommandError::new("storage_error", error.to_string())
}
+297 -59
View File
@@ -1,8 +1,7 @@
use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
use base64::{engine::general_purpose, Engine};
use reqwest::redirect;
use serde_json::{json, Map, Value};
use std::net::{IpAddr, Ipv6Addr};
use std::net::{IpAddr, Ipv6Addr, SocketAddr, ToSocketAddrs};
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use url::Url;
@@ -11,6 +10,7 @@ const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsock
const DEFAULT_APP_NAME: &str = "ProxyWarden";
const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const SUBSCRIPTION_MAX_REDIRECTS: usize = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionError {
@@ -155,69 +155,129 @@ pub fn fetch_subscription_with_identity_and_policy(
) -> Result<SubscriptionCache, SubscriptionError> {
let parsed_url =
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
validate_subscription_fetch_url(&parsed_url, policy)?;
let mut current_url = parsed_url;
let redirect_policy = redirect::Policy::custom(move |attempt| {
if validate_subscription_fetch_url(attempt.url(), policy).is_ok() {
attempt.follow()
} else {
attempt.stop()
for redirect_count in 0..=SUBSCRIPTION_MAX_REDIRECTS {
validate_subscription_fetch_url(&current_url, policy)?;
let client = subscription_client_for_url(&current_url, policy)?;
let mut request = client.get(current_url.clone());
for (name, value) in identity.request_headers_without_device_hwid() {
request = request.header(name, value);
}
});
let client = reqwest::blocking::Client::builder()
if let Some(device_hwid) = identity
.device_hwid
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request.header("x-hwid", device_hwid);
}
let response = request.send().map_err(|error| {
SubscriptionError::new(format!("Subscription request failed: {error}"))
})?;
let status = response.status();
if status.is_redirection() {
if redirect_count == SUBSCRIPTION_MAX_REDIRECTS {
return Err(SubscriptionError::new(
"Subscription request exceeded redirect limit",
));
}
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
SubscriptionError::new("Subscription redirect has no valid Location header")
})?;
current_url = current_url
.join(location)
.map_err(|_| SubscriptionError::new("Subscription redirect URL is invalid"))?;
continue;
}
if !status.is_success() {
return Err(SubscriptionError::new(format!(
"Subscription request failed: HTTP {}",
status.as_u16()
)));
}
let user_info = parse_user_info(
response
.headers()
.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 parsed = parse_subscription_body(&body)?;
return Ok(SubscriptionCache {
config: parsed.config,
servers: parsed.servers,
user_info,
fetched_at: now_timestamp(),
});
}
Err(SubscriptionError::new(
"Subscription request could not complete",
))
}
fn subscription_client_for_url(
parsed_url: &Url,
policy: SubscriptionFetchPolicy,
) -> Result<reqwest::blocking::Client, SubscriptionError> {
let mut builder = reqwest::blocking::Client::builder()
.connect_timeout(SUBSCRIPTION_CONNECT_TIMEOUT)
.timeout(SUBSCRIPTION_REQUEST_TIMEOUT)
.redirect(redirect_policy)
.build()
.map_err(|error| {
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
})?;
let mut request = client.get(parsed_url);
.redirect(reqwest::redirect::Policy::none());
for (name, value) in identity.request_headers_without_device_hwid() {
request = request.header(name, value);
if !policy.allow_unsafe_local_urls {
let host = parsed_url
.host_str()
.ok_or_else(|| SubscriptionError::new("Subscription URL has no host"))?;
if host.parse::<IpAddr>().is_err() {
let port = parsed_url
.port_or_known_default()
.ok_or_else(|| SubscriptionError::new("Subscription URL has no resolvable port"))?;
let addresses = (host, port)
.to_socket_addrs()
.map_err(|error| {
SubscriptionError::new(format!(
"Subscription host DNS resolution failed: {error}"
))
})?
.collect::<Vec<_>>();
validate_resolved_subscription_addresses(&addresses)?;
builder = builder.resolve_to_addrs(host, &addresses);
}
}
if let Some(device_hwid) = identity
.device_hwid
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request.header("x-hwid", device_hwid);
}
let response = request
.send()
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
let status = response.status();
if !status.is_success() {
return Err(SubscriptionError::new(format!(
"Subscription request failed: HTTP {}",
status.as_u16()
)));
}
let user_info = parse_user_info(
response
.headers()
.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 parsed = parse_subscription_body(&body)?;
Ok(SubscriptionCache {
config: parsed.config,
servers: parsed.servers,
user_info,
fetched_at: now_timestamp(),
builder.build().map_err(|error| {
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
})
}
pub fn validate_resolved_subscription_addresses(
addresses: &[SocketAddr],
) -> Result<(), SubscriptionError> {
if addresses.is_empty() {
return Err(SubscriptionError::new(
"Subscription host DNS resolution returned no addresses",
));
}
if addresses.iter().any(|address| is_unsafe_ip(address.ip())) {
return Err(SubscriptionError::new(
"Subscription host resolves to a local, private, link-local, multicast, or metadata address",
));
}
Ok(())
}
fn validate_subscription_fetch_url(
parsed_url: &Url,
policy: SubscriptionFetchPolicy,
@@ -286,23 +346,171 @@ fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
let links = decoded
.lines()
.map(str::trim)
.filter(|line| line.starts_with("vless://"))
.filter(|line| {
["vless://", "trojan://", "ss://", "vmess://"]
.iter()
.any(|scheme| line.starts_with(scheme))
})
.collect::<Vec<_>>();
if links.is_empty() {
return Err(SubscriptionError::new(
"Subscription does not contain JSON config or VLESS links",
"Subscription does not contain JSON config or supported VLESS, VMess, Trojan, or Shadowsocks links",
));
}
let outbounds = links
.into_iter()
.map(parse_vless_url)
.map(|link| {
if link.starts_with("vless://") {
parse_vless_url(link)
} else if link.starts_with("trojan://") {
parse_trojan_url(link)
} else if link.starts_with("ss://") {
parse_shadowsocks_url(link)
} else {
parse_vmess_url(link)
}
})
.collect::<Result<Vec<_>, _>>()?;
Ok(json!({ "outbounds": outbounds }))
}
fn parse_trojan_url(raw_url: &str) -> Result<Value, SubscriptionError> {
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Trojan URL"))?;
let password = parsed.username().trim().to_string();
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
let server_port = parsed.port_or_known_default().unwrap_or(443);
if password.is_empty() || server.is_empty() {
return Err(SubscriptionError::new(
"Trojan URL misses password, host or port",
));
}
let tag = parsed
.fragment()
.map(decode_percent_encoded_utf8)
.unwrap_or_else(|| "trojan-out".to_string());
let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone());
Ok(json!({
"type": "trojan",
"tag": tag,
"server": server,
"server_port": server_port,
"password": password,
"tls": {
"enabled": true,
"server_name": server_name
}
}))
}
fn parse_shadowsocks_url(raw_url: &str) -> Result<Value, SubscriptionError> {
let parsed =
Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Shadowsocks URL"))?;
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
let server_port = parsed.port().unwrap_or(8388);
let credentials = match parsed.password() {
Some(password) => format!("{}:{password}", parsed.username()),
None => decode_base64_text(parsed.username()).ok_or_else(|| {
SubscriptionError::new("Shadowsocks credentials are not valid base64")
})?,
};
let (method, password) = credentials
.split_once(':')
.ok_or_else(|| SubscriptionError::new("Shadowsocks URL misses method or password"))?;
if method.trim().is_empty() || password.is_empty() || server.is_empty() {
return Err(SubscriptionError::new(
"Shadowsocks URL misses method, password, host or port",
));
}
let tag = parsed
.fragment()
.map(decode_percent_encoded_utf8)
.unwrap_or_else(|| "shadowsocks-out".to_string());
Ok(json!({
"type": "shadowsocks",
"tag": tag,
"server": server,
"server_port": server_port,
"method": method,
"password": password
}))
}
fn parse_vmess_url(raw_url: &str) -> Result<Value, SubscriptionError> {
let payload = raw_url
.strip_prefix("vmess://")
.and_then(|value| value.split('#').next())
.ok_or_else(|| SubscriptionError::new("Invalid VMess URL"))?;
let decoded = decode_base64_text(payload)
.ok_or_else(|| SubscriptionError::new("VMess payload is not valid base64"))?;
let source: Value = serde_json::from_str(&decoded)
.map_err(|_| SubscriptionError::new("VMess payload is not valid JSON"))?;
let server = source
.get("add")
.and_then(Value::as_str)
.unwrap_or_default();
let server_port = source
.get("port")
.and_then(|value| value.as_u64().or_else(|| value.as_str()?.parse().ok()))
.and_then(|value| u16::try_from(value).ok())
.unwrap_or(443);
let uuid = source.get("id").and_then(Value::as_str).unwrap_or_default();
if server.is_empty() || uuid.is_empty() {
return Err(SubscriptionError::new(
"VMess payload misses host, port or uuid",
));
}
let tag = source
.get("ps")
.and_then(Value::as_str)
.map(decode_percent_encoded_utf8)
.unwrap_or_else(|| "vmess-out".to_string());
let security = source
.get("scy")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("auto");
let mut outbound = json!({
"type": "vmess",
"tag": tag,
"server": server,
"server_port": server_port,
"uuid": uuid,
"security": security
});
if source.get("tls").and_then(Value::as_str) == Some("tls") {
let server_name = source
.get("sni")
.or_else(|| source.get("host"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or(server);
outbound["tls"] = json!({ "enabled": true, "server_name": server_name });
}
if source.get("net").and_then(Value::as_str) == Some("ws") {
let path = source
.get("path")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("/");
let host = source
.get("host")
.and_then(Value::as_str)
.filter(|value| !value.is_empty());
outbound["transport"] = json!({
"type": "ws",
"path": path,
"headers": host.map(|host| json!({ "Host": host })).unwrap_or_else(|| json!({}))
});
}
Ok(outbound)
}
fn parse_vless_url(raw_url: &str) -> Result<Value, SubscriptionError> {
if !raw_url.starts_with("vless://") {
return Err(SubscriptionError::new("VLESS URL must start with vless://"));
@@ -399,6 +607,7 @@ fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
.unwrap_or_else(|| format!("{server_type}-{server}"));
Some(SubscriptionServer {
id: outbound_server_id(outbound),
tag,
server_type,
server,
@@ -406,6 +615,14 @@ fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
})
}
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)
});
format!("pw-{hash:016x}")
}
fn maybe_decode_base64(content: &str) -> String {
let compact = content.split_whitespace().collect::<String>();
if compact.is_empty()
@@ -419,7 +636,11 @@ fn maybe_decode_base64(content: &str) -> String {
for engine in [general_purpose::STANDARD, general_purpose::URL_SAFE] {
if let Ok(decoded) = engine.decode(compact.as_bytes()) {
if let Ok(decoded) = String::from_utf8(decoded) {
if decoded.contains("vless://") || decoded.contains('{') {
if ["vless://", "vmess://", "trojan://", "ss://"]
.iter()
.any(|scheme| decoded.contains(scheme))
|| decoded.contains('{')
{
return decoded;
}
}
@@ -429,6 +650,23 @@ fn maybe_decode_base64(content: &str) -> String {
content.to_string()
}
fn decode_base64_text(value: &str) -> Option<String> {
let value = value.trim();
for engine in [
general_purpose::STANDARD,
general_purpose::STANDARD_NO_PAD,
general_purpose::URL_SAFE,
general_purpose::URL_SAFE_NO_PAD,
] {
if let Ok(decoded) = engine.decode(value.as_bytes()) {
if let Ok(decoded) = String::from_utf8(decoded) {
return Some(decoded);
}
}
}
None
}
fn query_value(url: &Url, key: &str) -> Option<String> {
url.query_pairs()
.find(|(name, _)| name == key)
+67 -3
View File
@@ -25,11 +25,18 @@ fn clean(value: &str) -> String {
fn slug(value: &str, fallback: &str) -> String {
let mut output = String::new();
let mut previous_dash = false;
let mut has_non_ascii = false;
for ch in value.trim().to_lowercase().chars() {
if ch.is_ascii_alphanumeric() {
output.push(ch);
previous_dash = false;
} else if ch.is_alphanumeric() {
has_non_ascii = true;
if !previous_dash {
output.push('-');
previous_dash = true;
}
} else if !previous_dash {
output.push('-');
previous_dash = true;
@@ -37,13 +44,56 @@ fn slug(value: &str, fallback: &str) -> String {
}
let output = output.trim_matches('-').to_string();
if output.is_empty() {
fallback.to_string()
let base = if output.is_empty() { fallback } else { &output };
if has_non_ascii {
format!("{base}-{:016x}", stable_hash(value.trim().as_bytes()))
} else {
output
base.to_string()
}
}
fn stable_hash(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf29ce484222325, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
})
}
fn valid_proxy_host(value: &str) -> bool {
!value.is_empty()
&& !value.contains("://")
&& !value.chars().any(|ch| {
ch.is_whitespace() || ch.is_control() || matches!(ch, '/' | '\\' | '@' | '?' | '#')
})
&& url::Host::parse(value).is_ok()
}
fn valid_windows_item_path(value: &str, item_type: &ProfileItemType) -> bool {
if value
.chars()
.any(|ch| ch.is_control() || matches!(ch, '"' | '<' | '>' | '|' | '?' | '*'))
{
return false;
}
let bytes = value.as_bytes();
let absolute_drive = bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/');
let unc = value.starts_with(r"\\");
let environment_root = value.starts_with('%')
&& value[1..].find('%').is_some_and(|index| {
value
.as_bytes()
.get(index + 2)
.is_some_and(|ch| matches!(ch, b'\\' | b'/'))
});
let path_shape_valid = absolute_drive || unc || environment_root;
path_shape_valid
&& (!matches!(item_type, ProfileItemType::Exe)
|| value.to_ascii_lowercase().ends_with(".exe"))
}
fn process_name(value: &str) -> String {
let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim();
base.strip_suffix(".exe")
@@ -149,6 +199,15 @@ pub fn normalize_profile(input: ProfileInput) -> ValidationResult<Profile> {
errors.push(error("items.value", "Укажите значение элемента профиля"));
continue;
}
if matches!(item_type, ProfileItemType::Folder | ProfileItemType::Exe)
&& !valid_windows_item_path(&value, &item_type)
{
errors.push(error(
"items.value",
"Укажите абсолютный Windows-путь; для exe путь должен оканчиваться на .exe",
));
continue;
}
let recursive =
matches!(item_type, ProfileItemType::Folder) && raw_item.recursive.unwrap_or(true);
@@ -183,6 +242,11 @@ pub fn normalize_target(input: TargetInput) -> ValidationResult<Target> {
}
if host.is_empty() {
errors.push(error("host", "Укажите хост цели"));
} else if !valid_proxy_host(&host) {
errors.push(error(
"host",
"Укажите только IP-адрес или имя хоста без схемы, пути и учетных данных",
));
}
if input.port == 0 || input.port > u16::MAX as u32 {
errors.push(error("port", "Порт цели должен быть от 1 до 65535"));