4963 lines
164 KiB
Rust
4963 lines
164 KiB
Rust
use crate::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
|
||
use crate::adapters::proxy_router::{
|
||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||
ProxyRouterRequest,
|
||
};
|
||
use crate::adapters::singbox::{
|
||
SingBoxAdapter, SingBoxCheckResult, SingBoxCommandChecker, SingBoxConfigChecker,
|
||
SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGeneratedConfig, SingBoxGenerationRequest,
|
||
};
|
||
use crate::component_detection::{
|
||
default_proxifyre_install_dir, default_singbox_install_dir, detect_proxyfier_install,
|
||
detect_proxyfier_install_with_host, detect_singbox_install, proxifyre_install_dir_from_app_dir,
|
||
proxyfier_component_from_detection, singbox_component_from_detection,
|
||
singbox_install_dir_from_app_dir, DetectedProxyfier, DetectedSingBox, ProxyfierDetectionHost,
|
||
SystemProxyfierDetectionHost,
|
||
};
|
||
use crate::elevated_scripts;
|
||
use crate::models::{
|
||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
|
||
};
|
||
use crate::process::command_no_window;
|
||
use crate::safe_fs;
|
||
use crate::singbox_service::{
|
||
build_singbox_setup_status_with_install_root, ensure_safe_singbox_install_dir,
|
||
parse_service_command_output as parse_singbox_service_command_output, service_control_script,
|
||
ServiceCommandOutput as SingBoxServiceCommandOutput, SingBoxServiceAction, SingBoxSetupStatus,
|
||
};
|
||
use crate::storage::{default_config_root, JsonStorage};
|
||
use crate::subscription;
|
||
use crate::validation::{normalize_profile, normalize_target, ValidationError};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::env;
|
||
use std::fs;
|
||
use std::net::{IpAddr, TcpStream, ToSocketAddrs, UdpSocket};
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Command, Output};
|
||
use std::time::{Duration, Instant};
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
use tauri::Manager;
|
||
|
||
const MAIN_PROFILE_ID: &str = "main-profile";
|
||
const MAIN_TARGET_ID: &str = "main-proxy";
|
||
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";
|
||
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),
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct CommandState {
|
||
root: PathBuf,
|
||
}
|
||
|
||
impl CommandState {
|
||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||
Self { root: root.into() }
|
||
}
|
||
|
||
pub fn storage(&self) -> JsonStorage {
|
||
JsonStorage::new(self.root.clone())
|
||
}
|
||
}
|
||
|
||
impl Default for CommandState {
|
||
fn default() -> Self {
|
||
Self::new(default_config_root())
|
||
}
|
||
}
|
||
|
||
#[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 {
|
||
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||
Self {
|
||
code: code.into(),
|
||
message: message.into(),
|
||
details: Vec::new(),
|
||
}
|
||
}
|
||
|
||
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 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 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 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 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 {
|
||
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 {
|
||
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 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>,
|
||
}
|
||
|
||
#[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 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 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}")
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_status(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<StatusResponse, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || build_status(&storage))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_admin_status() -> AdminStatusResponse {
|
||
admin_status()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn restart_as_admin(app: tauri::AppHandle) -> Result<(), CommandError> {
|
||
launch_app_as_admin()?;
|
||
app.exit(0);
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_startup_snapshot(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<StartupSnapshotResponse, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || read_startup_snapshot(&storage))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_saved_state(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<SavedStateResponse, CommandError> {
|
||
read_saved_state(&state.storage())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_profiles(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<Vec<ProfileDto>, CommandError> {
|
||
read_profiles(&state.storage())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn save_profile(
|
||
state: tauri::State<'_, CommandState>,
|
||
input: ProfileInputDto,
|
||
) -> Result<ProfileDto, CommandError> {
|
||
save_profile_to_storage(&state.storage(), input)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_targets(state: tauri::State<'_, CommandState>) -> Result<Vec<TargetDto>, CommandError> {
|
||
read_targets(&state.storage())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn save_target(
|
||
state: tauri::State<'_, CommandState>,
|
||
input: TargetInputDto,
|
||
) -> Result<TargetDto, CommandError> {
|
||
save_target_to_storage(&state.storage(), input)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_components(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<Vec<ComponentStatusDto>, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || read_components(&storage))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_proxifyre_setup_status(
|
||
app: tauri::AppHandle,
|
||
) -> Result<ProxiFyreSetupStatusDto, CommandError> {
|
||
let install_dir = proxifyre_install_dir_for_app(&app)?;
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
build_proxifyre_setup_status_for_install_dir(&install_dir)
|
||
})
|
||
.await
|
||
.map_err(background_task_error)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_proxifyre_setup_progress(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<ProxiFyreSetupProgressDto, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || read_proxifyre_setup_progress(&storage))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_singbox_status(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || read_singbox_status(&storage))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_singbox_setup_status(
|
||
app: tauri::AppHandle,
|
||
) -> Result<SingBoxSetupStatusDto, CommandError> {
|
||
let install_dir = singbox_install_dir_for_app(&app)?;
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
build_singbox_setup_status_with_install_root(
|
||
detect_singbox_install().as_ref(),
|
||
&install_dir,
|
||
)
|
||
})
|
||
.await
|
||
.map_err(background_task_error)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn resolve_profile_preview(
|
||
input: ProfileInputDto,
|
||
) -> Result<ResolveProfilePreviewResponse, CommandError> {
|
||
resolve_preview(input)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn save_singbox_subscription(
|
||
state: tauri::State<'_, CommandState>,
|
||
input: SaveSingBoxSubscriptionInputDto,
|
||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||
save_singbox_subscription_to_storage(&state.storage(), input, &SystemClock)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn fetch_singbox_subscription(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
fetch_singbox_subscription_with_fetcher(&storage, &SystemSubscriptionFetcher, &SystemClock)
|
||
})
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn forget_singbox_subscription(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||
forget_singbox_subscription_in_storage(&state.storage(), &SystemClock)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn select_singbox_server(
|
||
state: tauri::State<'_, CommandState>,
|
||
input: SelectSingBoxServerInputDto,
|
||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||
select_singbox_server_in_storage(&state.storage(), input, &SystemClock)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn ping_singbox_server(
|
||
state: tauri::State<'_, CommandState>,
|
||
input: PingSingBoxServerInputDto,
|
||
) -> Result<PingServerResponse, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || ping_singbox_server_in_storage(&storage, input))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn ping_all_singbox_servers(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<Vec<PingServerResponse>, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || ping_all_singbox_servers_in_storage(&storage))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn ping_proxy_target(
|
||
input: PingProxyTargetInputDto,
|
||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||
tauri::async_runtime::spawn_blocking(move || ping_proxy_target_endpoint(input))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn generate_singbox_config(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<GenerateSingBoxConfigResponse, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
let detected = detect_singbox_install();
|
||
let binary_path = detected
|
||
.as_ref()
|
||
.map(|detected| detected.executable_path.as_path());
|
||
generate_singbox_config_with_services(
|
||
&storage,
|
||
&SingBoxAdapter::default(),
|
||
&SingBoxCommandChecker,
|
||
&SystemClock,
|
||
binary_path,
|
||
)
|
||
})
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn apply_profiles(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<ApplyProfilesResponse, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
let adapter = ProxiFyreAdapter::default();
|
||
let helper = DetectedProxyApplyHelper::system();
|
||
let clock = SystemClock;
|
||
|
||
apply_profiles_with_services(&storage, &adapter, &helper, &clock)
|
||
})
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_logs(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<Vec<ActivityEntryDto>, CommandError> {
|
||
read_activity(&state.storage())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn open_config_location(state: tauri::State<'_, CommandState>) -> Result<String, CommandError> {
|
||
let storage = state.storage();
|
||
let generated_path = storage
|
||
.paths()
|
||
.generated_dir
|
||
.join("proxifyre-app-config.json");
|
||
let config_path = detect_proxyfier_install()
|
||
.and_then(|detected| detected.config_path)
|
||
.filter(|path| path.exists())
|
||
.or_else(|| generated_path.exists().then_some(generated_path.clone()));
|
||
|
||
let Some(config_path) = config_path else {
|
||
return open_folder(&storage.paths().generated_dir);
|
||
};
|
||
|
||
open_file_or_select(&config_path)?;
|
||
Ok(config_path.display().to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn start_proxifyre_service() -> Result<ComponentStatusDto, CommandError> {
|
||
tauri::async_runtime::spawn_blocking(|| control_proxifyre_service(ServiceControlAction::Start))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn stop_proxifyre_service() -> Result<ComponentStatusDto, CommandError> {
|
||
tauri::async_runtime::spawn_blocking(|| control_proxifyre_service(ServiceControlAction::Stop))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn install_proxifyre(
|
||
app: tauri::AppHandle,
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<ComponentStatusDto, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || install_proxifyre_component(&storage, &app))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn uninstall_proxifyre() -> Result<ComponentStatusDto, CommandError> {
|
||
tauri::async_runtime::spawn_blocking(uninstall_proxifyre_component)
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn start_singbox_service(
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<ComponentStatusDto, CommandError> {
|
||
let storage = state.storage();
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
let detected = detect_singbox_install();
|
||
let binary_path = detected
|
||
.as_ref()
|
||
.map(|detected| detected.executable_path.as_path());
|
||
let generated = generate_singbox_config_with_services(
|
||
&storage,
|
||
&SingBoxAdapter::default(),
|
||
&SingBoxCommandChecker,
|
||
&SystemClock,
|
||
binary_path,
|
||
)?;
|
||
let generated_path = PathBuf::from(generated.generated_config_path);
|
||
control_singbox_service(SingBoxServiceAction::Start, Some(generated_path.as_path()))
|
||
})
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn stop_singbox_service() -> Result<ComponentStatusDto, CommandError> {
|
||
tauri::async_runtime::spawn_blocking(|| {
|
||
control_singbox_service(SingBoxServiceAction::Stop, None)
|
||
})
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn install_singbox(
|
||
app: tauri::AppHandle,
|
||
state: tauri::State<'_, CommandState>,
|
||
) -> Result<ComponentStatusDto, CommandError> {
|
||
let storage = state.storage();
|
||
let install_dir = singbox_install_dir_for_app(&app)?;
|
||
tauri::async_runtime::spawn_blocking(move || install_singbox_component(&storage, &install_dir))
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn uninstall_singbox() -> Result<ComponentStatusDto, CommandError> {
|
||
tauri::async_runtime::spawn_blocking(uninstall_singbox_component)
|
||
.await
|
||
.map_err(background_task_error)?
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
"Перезапуск от имени администратора отменен или не был запущен.",
|
||
),
|
||
))
|
||
}
|
||
|
||
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,
|
||
})
|
||
}
|
||
|
||
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),
|
||
})
|
||
}
|
||
|
||
pub fn read_singbox_status(
|
||
storage: &JsonStorage,
|
||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||
let detected = detect_singbox_install();
|
||
read_singbox_status_with_detection(storage, detected.as_ref())
|
||
}
|
||
|
||
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_tag = config
|
||
.selected_server_tag
|
||
.as_deref()
|
||
.filter(|tag| cache.servers.iter().any(|server| server.tag == *tag))
|
||
.map(str::to_string)
|
||
.or_else(|| cache.servers.first().map(|server| server.tag.clone()));
|
||
|
||
config.selected_server_tag = selected_tag;
|
||
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.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();
|
||
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_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 mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||
config.selected_server_tag = Some(selected_tag);
|
||
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 cache = read_required_singbox_cache(storage)?;
|
||
let server = find_subscription_server(&cache, 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 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", 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 = if ok {
|
||
None
|
||
} else {
|
||
Some(
|
||
"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 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 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.tag, &server.server, server.server_port)
|
||
}
|
||
|
||
fn ping_endpoint(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 {
|
||
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 {
|
||
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 {
|
||
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 {
|
||
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}")),
|
||
};
|
||
}
|
||
|
||
let ip = extract_probe_ip(probe, &body);
|
||
|
||
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,
|
||
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('"');
|
||
if candidate.parse::<IpAddr>().is_ok() {
|
||
Some(candidate.to_string())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
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_tag: &str,
|
||
requested_server: Option<&str>,
|
||
requested_port: Option<u16>,
|
||
) -> Option<&'a SubscriptionServer> {
|
||
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 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 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
|
||
"#
|
||
)
|
||
}
|
||
|
||
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),
|
||
)))
|
||
}
|
||
|
||
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)
|
||
)
|
||
}
|
||
|
||
fn components_or_defaults(storage: &JsonStorage) -> Result<Vec<ComponentStatus>, CommandError> {
|
||
components_or_defaults_with_detection(
|
||
storage,
|
||
detect_proxyfier_install(),
|
||
detect_singbox_install(),
|
||
)
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
if detected_proxyfier.is_some() {
|
||
upsert_component(
|
||
&mut components,
|
||
proxyfier_component_from_detection(detected_proxyfier.as_ref()),
|
||
);
|
||
}
|
||
if detected_singbox.is_some() {
|
||
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),
|
||
}
|
||
}
|
||
|
||
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(),
|
||
}
|
||
}
|
||
|
||
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 write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
|
||
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
|
||
}
|
||
|
||
fn open_file_or_select(path: &Path) -> Result<(), CommandError> {
|
||
let status = Command::new("notepad.exe")
|
||
.arg(path)
|
||
.spawn()
|
||
.map_err(|error| {
|
||
CommandError::new(
|
||
"open_config_failed",
|
||
format!("Не удалось открыть конфиг '{}': {error}", path.display()),
|
||
)
|
||
})?;
|
||
|
||
drop(status);
|
||
Ok(())
|
||
}
|
||
|
||
fn open_folder(path: &Path) -> Result<String, CommandError> {
|
||
fs::create_dir_all(path).map_err(storage_error)?;
|
||
Command::new("explorer.exe")
|
||
.arg(path)
|
||
.spawn()
|
||
.map_err(|error| {
|
||
CommandError::new(
|
||
"open_config_failed",
|
||
format!("Не удалось открыть папку '{}': {error}", path.display()),
|
||
)
|
||
})?;
|
||
|
||
Ok(path.display().to_string())
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum ServiceControlAction {
|
||
Start,
|
||
Stop,
|
||
}
|
||
|
||
impl ServiceControlAction {
|
||
fn error_code(self) -> &'static str {
|
||
match self {
|
||
ServiceControlAction::Start => "proxifyre_service_start_failed",
|
||
ServiceControlAction::Stop => "proxifyre_service_stop_failed",
|
||
}
|
||
}
|
||
|
||
fn label(self) -> &'static str {
|
||
match self {
|
||
ServiceControlAction::Start => "запустить",
|
||
ServiceControlAction::Stop => "остановить",
|
||
}
|
||
}
|
||
}
|
||
|
||
fn control_proxifyre_service(
|
||
action: ServiceControlAction,
|
||
) -> Result<ComponentStatusDto, CommandError> {
|
||
let Some(detected) = detect_proxyfier_install() else {
|
||
return Err(CommandError::new(
|
||
"proxifyre_not_found",
|
||
"ProxiFyre не найден на компьютере.",
|
||
));
|
||
};
|
||
|
||
run_proxifyre_service_command(action, &service_name_candidates(&detected))?;
|
||
let refreshed = detect_proxyfier_install();
|
||
let component = proxyfier_component_from_detection(refreshed.as_ref());
|
||
|
||
Ok(ComponentStatusDto::from(&component))
|
||
}
|
||
|
||
fn service_name_candidates(detected: &DetectedProxyfier) -> Vec<String> {
|
||
let mut names = Vec::new();
|
||
|
||
if let Some(service_name) = &detected.service_name {
|
||
names.push(service_name.clone());
|
||
}
|
||
for service_name in ["ProxiFyreService", "ProxiFyre"] {
|
||
if !names
|
||
.iter()
|
||
.any(|existing| existing.eq_ignore_ascii_case(service_name))
|
||
{
|
||
names.push(service_name.to_string());
|
||
}
|
||
}
|
||
|
||
names
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct ServiceCommandOutput {
|
||
success: bool,
|
||
code: String,
|
||
service_name: Option<String>,
|
||
status: Option<String>,
|
||
process_id: Option<u32>,
|
||
}
|
||
|
||
fn run_proxifyre_service_command(
|
||
action: ServiceControlAction,
|
||
service_names: &[String],
|
||
) -> Result<(), CommandError> {
|
||
let names = service_names
|
||
.iter()
|
||
.map(|name| format!("'{}'", escape_powershell_single(name)))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
let action_name = match action {
|
||
ServiceControlAction::Start => "start",
|
||
ServiceControlAction::Stop => "stop",
|
||
};
|
||
let script = format!(
|
||
r#"
|
||
$ErrorActionPreference = 'Stop'
|
||
$names = @({names})
|
||
$action = '{action_name}'
|
||
$service = $null
|
||
|
||
function Find-ProxiFyreService {{
|
||
foreach ($name in $names) {{
|
||
$candidate = Get-Service -Name $name -ErrorAction SilentlyContinue
|
||
if ($null -ne $candidate) {{ return $candidate }}
|
||
}}
|
||
|
||
return Get-Service |
|
||
Where-Object {{ $_.Name -match 'ProxiFyre|Proxifyre' -or $_.DisplayName -match 'ProxiFyre|Proxifyre' }} |
|
||
Select-Object -First 1
|
||
}}
|
||
|
||
function Get-ServiceProcessId([string]$name) {{
|
||
$escapedName = $name.Replace("'", "''")
|
||
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
|
||
if ($null -eq $record) {{ return 0 }}
|
||
return [int]$record.ProcessId
|
||
}}
|
||
|
||
function Get-ServiceStatus([string]$name) {{
|
||
$current = Get-Service -Name $name -ErrorAction SilentlyContinue
|
||
if ($null -eq $current) {{ return $null }}
|
||
return $current.Status.ToString()
|
||
}}
|
||
|
||
function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{
|
||
[PSCustomObject]@{{
|
||
success = $success
|
||
code = $code
|
||
serviceName = if ($null -ne $service) {{ $service.Name }} else {{ $null }}
|
||
status = $status
|
||
processId = $processId
|
||
}} | ConvertTo-Json -Compress
|
||
exit 0
|
||
}}
|
||
|
||
$service = Find-ProxiFyreService
|
||
if ($null -eq $service) {{
|
||
Write-ServiceResult $false 'service_not_found' $null 0
|
||
}}
|
||
|
||
$status = $service.Status.ToString()
|
||
$processId = Get-ServiceProcessId $service.Name
|
||
|
||
if ($action -eq 'start') {{
|
||
if ($status -eq 'Running') {{
|
||
Write-ServiceResult $true 'already_running' $status $processId
|
||
}}
|
||
|
||
try {{
|
||
Start-Service -Name $service.Name -ErrorAction Stop
|
||
$service = Get-Service -Name $service.Name
|
||
$service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15))
|
||
}} catch {{
|
||
Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $service.Name) (Get-ServiceProcessId $service.Name)
|
||
}}
|
||
|
||
Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $service.Name)
|
||
}}
|
||
|
||
if ($status -eq 'Stopped') {{
|
||
Write-ServiceResult $true 'already_stopped' $status $processId
|
||
}}
|
||
|
||
try {{
|
||
if ($service.CanStop) {{
|
||
Stop-Service -Name $service.Name -Force -ErrorAction Stop
|
||
}}
|
||
}} catch {{}}
|
||
|
||
try {{
|
||
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||
if ($null -ne $service -and $service.Status -ne 'Stopped') {{
|
||
$null = & sc.exe stop $service.Name 2>$null
|
||
}}
|
||
}} catch {{}}
|
||
|
||
try {{
|
||
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||
if ($null -ne $service -and $service.Status -ne 'Stopped') {{
|
||
$service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8))
|
||
}}
|
||
}} catch {{}}
|
||
|
||
$status = Get-ServiceStatus $service.Name
|
||
$processId = Get-ServiceProcessId $service.Name
|
||
if ($status -ne 'Stopped' -and $processId -gt 0) {{
|
||
try {{
|
||
$null = & taskkill.exe /PID $processId /F 2>$null
|
||
Start-Sleep -Milliseconds 700
|
||
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||
if ($null -ne $service) {{
|
||
$service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8))
|
||
}}
|
||
}} catch {{}}
|
||
}}
|
||
|
||
$status = Get-ServiceStatus $service.Name
|
||
$processId = Get-ServiceProcessId $service.Name
|
||
if ($status -eq 'Stopped') {{
|
||
Write-ServiceResult $true 'stopped' $status $processId
|
||
}}
|
||
|
||
Write-ServiceResult $false 'stop_failed' $status $processId
|
||
"#
|
||
);
|
||
|
||
let output = command_no_window("powershell")
|
||
.args([
|
||
"-NoProfile",
|
||
"-NonInteractive",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-Command",
|
||
script.as_str(),
|
||
])
|
||
.output()
|
||
.map_err(|error| {
|
||
CommandError::new(
|
||
action.error_code(),
|
||
format!("Не удалось {} службу ProxiFyre: {error}", action.label()),
|
||
)
|
||
})?;
|
||
|
||
let result = parse_service_command_output(&output.stdout).ok_or_else(|| {
|
||
CommandError::new(
|
||
action.error_code(),
|
||
service_script_failed_message(action, output.status.code()),
|
||
)
|
||
})?;
|
||
|
||
if result.success {
|
||
return Ok(());
|
||
}
|
||
|
||
if matches!(result.code.as_str(), "start_failed" | "stop_failed") {
|
||
run_elevated_proxifyre_service_command(action, service_names, &result)?;
|
||
return Ok(());
|
||
}
|
||
|
||
Err(CommandError::new(
|
||
action.error_code(),
|
||
service_command_failed_message(action, &result),
|
||
))
|
||
}
|
||
|
||
fn run_elevated_proxifyre_service_command(
|
||
action: ServiceControlAction,
|
||
service_names: &[String],
|
||
direct_result: &ServiceCommandOutput,
|
||
) -> Result<(), CommandError> {
|
||
let script_path = write_elevated_service_script(action, service_names)?;
|
||
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(
|
||
action.error_code(),
|
||
elevated_service_failed_message(action, direct_result, output.status.code()),
|
||
)),
|
||
Err(error) => Err(CommandError::new(
|
||
action.error_code(),
|
||
format!(
|
||
"Не удалось запросить права администратора, чтобы {} службу ProxiFyre: {error}",
|
||
action.label()
|
||
),
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn write_elevated_service_script(
|
||
action: ServiceControlAction,
|
||
service_names: &[String],
|
||
) -> Result<PathBuf, CommandError> {
|
||
let script_path = elevated_scripts::temp_script_path("proxywarden-proxifyre-service");
|
||
let script = elevated_service_script(action, service_names);
|
||
|
||
write_powershell_script(&script_path, &script).map_err(|error| {
|
||
CommandError::new(
|
||
action.error_code(),
|
||
format!(
|
||
"Не удалось подготовить временный скрипт для управления ProxiFyre '{}': {error}",
|
||
script_path.display()
|
||
),
|
||
)
|
||
})?;
|
||
|
||
Ok(script_path)
|
||
}
|
||
|
||
fn elevated_service_script(action: ServiceControlAction, service_names: &[String]) -> String {
|
||
let names = service_names
|
||
.iter()
|
||
.map(|name| format!("'{}'", escape_powershell_single(name)))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
let action_name = match action {
|
||
ServiceControlAction::Start => "start",
|
||
ServiceControlAction::Stop => "stop",
|
||
};
|
||
|
||
format!(
|
||
r#"
|
||
$ErrorActionPreference = 'SilentlyContinue'
|
||
$names = @({names})
|
||
$action = '{action_name}'
|
||
$service = $null
|
||
|
||
foreach ($name in $names) {{
|
||
$service = Get-Service -Name $name -ErrorAction SilentlyContinue
|
||
if ($null -ne $service) {{ break }}
|
||
}}
|
||
|
||
if ($null -eq $service) {{
|
||
$service = Get-Service |
|
||
Where-Object {{ $_.Name -match 'ProxiFyre|Proxifyre' -or $_.DisplayName -match 'ProxiFyre|Proxifyre' }} |
|
||
Select-Object -First 1
|
||
}}
|
||
|
||
if ($null -eq $service) {{ exit 2 }}
|
||
|
||
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
|
||
}}
|
||
|
||
if ($action -eq 'start') {{
|
||
if ($service.Status -eq 'Running') {{ exit 0 }}
|
||
Start-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||
if ($null -ne $service) {{
|
||
try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}}
|
||
if ($service.Status -eq 'Running') {{ exit 0 }}
|
||
}}
|
||
exit 3
|
||
}}
|
||
|
||
if ($service.Status -eq 'Stopped') {{ exit 0 }}
|
||
|
||
if ($service.CanStop) {{
|
||
Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue
|
||
}}
|
||
|
||
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||
if ($null -ne $service -and $service.Status -ne 'Stopped') {{
|
||
$null = & sc.exe stop $service.Name 2>$null
|
||
}}
|
||
|
||
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||
if ($null -ne $service -and $service.Status -ne 'Stopped') {{
|
||
try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} catch {{}}
|
||
}}
|
||
|
||
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||
if ($null -ne $service -and $service.Status -ne 'Stopped') {{
|
||
$processId = Get-ServiceProcessId $service.Name
|
||
if ($processId -gt 0) {{
|
||
$null = & taskkill.exe /PID $processId /F 2>$null
|
||
Start-Sleep -Milliseconds 700
|
||
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||
if ($null -ne $service) {{
|
||
try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} catch {{}}
|
||
}}
|
||
}}
|
||
}}
|
||
|
||
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
|
||
if ($null -eq $service -or $service.Status -eq 'Stopped') {{ exit 0 }}
|
||
exit 4
|
||
"#
|
||
)
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum ProxiFyrePackageAction {
|
||
Install,
|
||
Uninstall,
|
||
}
|
||
|
||
impl ProxiFyrePackageAction {
|
||
fn error_code(self) -> &'static str {
|
||
match self {
|
||
ProxiFyrePackageAction::Install => "proxifyre_install_failed",
|
||
ProxiFyrePackageAction::Uninstall => "proxifyre_uninstall_failed",
|
||
}
|
||
}
|
||
|
||
fn label(self) -> &'static str {
|
||
match self {
|
||
ProxiFyrePackageAction::Install => "установить",
|
||
ProxiFyrePackageAction::Uninstall => "удалить",
|
||
}
|
||
}
|
||
|
||
fn file_label(self) -> &'static str {
|
||
match self {
|
||
ProxiFyrePackageAction::Install => "install",
|
||
ProxiFyrePackageAction::Uninstall => "uninstall",
|
||
}
|
||
}
|
||
|
||
fn operation(self) -> &'static str {
|
||
match self {
|
||
ProxiFyrePackageAction::Install => "install",
|
||
ProxiFyrePackageAction::Uninstall => "uninstall",
|
||
}
|
||
}
|
||
|
||
fn start_message(self) -> &'static str {
|
||
match self {
|
||
ProxiFyrePackageAction::Install => "Готовлю установку ProxiFyre.",
|
||
ProxiFyrePackageAction::Uninstall => "Готовлю удаление ProxiFyre и сетевого драйвера.",
|
||
}
|
||
}
|
||
|
||
fn success_message(self) -> &'static str {
|
||
match self {
|
||
ProxiFyrePackageAction::Install => "ProxiFyre и сетевой драйвер готовы.",
|
||
ProxiFyrePackageAction::Uninstall => "ProxiFyre и сетевой драйвер удалены.",
|
||
}
|
||
}
|
||
}
|
||
|
||
fn install_proxifyre_component(
|
||
storage: &JsonStorage,
|
||
app: &tauri::AppHandle,
|
||
) -> Result<ComponentStatusDto, CommandError> {
|
||
let generated_config_path = storage
|
||
.paths()
|
||
.generated_dir
|
||
.join("proxifyre-app-config.json");
|
||
let bundled_asset_dir = bundled_proxifyre_asset_dir(app);
|
||
let install_dir = proxifyre_install_dir_for_app(app)?;
|
||
let script = install_proxifyre_script_for_target(
|
||
&generated_config_path,
|
||
bundled_asset_dir.as_deref(),
|
||
&install_dir,
|
||
);
|
||
|
||
run_elevated_package_script(
|
||
ProxiFyrePackageAction::Install,
|
||
script,
|
||
&storage.paths().state_dir,
|
||
)?;
|
||
|
||
if detect_windows_packet_filter().is_none() {
|
||
return Err(CommandError::new(
|
||
ProxiFyrePackageAction::Install.error_code(),
|
||
"Установка ProxiFyre завершилась, но Windows Packet Filter не найден после проверки.",
|
||
));
|
||
}
|
||
|
||
let refreshed = detect_proxyfier_install();
|
||
let Some(detected) = refreshed.as_ref() else {
|
||
return Err(CommandError::new(
|
||
ProxiFyrePackageAction::Install.error_code(),
|
||
"Установка ProxiFyre завершилась, но приложение не найдено после проверки.",
|
||
));
|
||
};
|
||
|
||
Ok(ComponentStatusDto::from(
|
||
&proxyfier_component_from_detection(Some(detected)),
|
||
))
|
||
}
|
||
|
||
fn bundled_proxifyre_asset_dir(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||
let mut candidates = Vec::new();
|
||
if let Ok(resource_dir) = app.path().resource_dir() {
|
||
candidates.push(resource_dir.join("bundled").join("proxifyre"));
|
||
}
|
||
candidates.push(
|
||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||
.join("bundled")
|
||
.join("proxifyre"),
|
||
);
|
||
|
||
candidates.into_iter().find(|path| path.is_dir())
|
||
}
|
||
|
||
fn app_install_dir(app: &tauri::AppHandle) -> Result<PathBuf, CommandError> {
|
||
if let Ok(exe_path) = env::current_exe() {
|
||
if let Some(parent) = exe_path.parent() {
|
||
return Ok(parent.to_path_buf());
|
||
}
|
||
}
|
||
|
||
app.path().resource_dir().map_err(|error| {
|
||
CommandError::new(
|
||
"app_install_dir_unavailable",
|
||
format!("Не удалось определить папку установки ProxyWarden: {error}"),
|
||
)
|
||
})
|
||
}
|
||
|
||
fn proxifyre_install_dir_for_app(app: &tauri::AppHandle) -> Result<PathBuf, CommandError> {
|
||
Ok(proxifyre_install_dir_from_app_dir(&app_install_dir(app)?))
|
||
}
|
||
|
||
fn singbox_install_dir_for_app(app: &tauri::AppHandle) -> Result<PathBuf, CommandError> {
|
||
Ok(singbox_install_dir_from_app_dir(&app_install_dir(app)?))
|
||
}
|
||
|
||
fn uninstall_proxifyre_component() -> Result<ComponentStatusDto, CommandError> {
|
||
let detected = detect_proxyfier_install();
|
||
let packet_filter = detect_windows_packet_filter();
|
||
if detected.is_none() && packet_filter.is_none() {
|
||
let component = proxyfier_component_from_detection(None);
|
||
return Ok(ComponentStatusDto::from(&component));
|
||
}
|
||
|
||
if let Some(detected) = detected.as_ref() {
|
||
ensure_safe_proxifyre_install_dir(&detected.install_dir)?;
|
||
}
|
||
let script = uninstall_proxifyre_script(detected.as_ref());
|
||
|
||
let artifact_dir = default_config_root().join("state");
|
||
run_elevated_package_script(ProxiFyrePackageAction::Uninstall, script, &artifact_dir)?;
|
||
|
||
let refreshed = detect_proxyfier_install();
|
||
if refreshed.is_some() {
|
||
return Err(CommandError::new(
|
||
ProxiFyrePackageAction::Uninstall.error_code(),
|
||
"Удаление ProxiFyre завершилось, но приложение все еще найдено на компьютере.",
|
||
));
|
||
}
|
||
if detect_windows_packet_filter().is_some() {
|
||
return Err(CommandError::new(
|
||
ProxiFyrePackageAction::Uninstall.error_code(),
|
||
"Удаление ProxiFyre завершилось, но Windows Packet Filter все еще найден на компьютере.",
|
||
));
|
||
}
|
||
|
||
let component = proxyfier_component_from_detection(None);
|
||
Ok(ComponentStatusDto::from(&component))
|
||
}
|
||
|
||
fn build_proxifyre_setup_status_for_install_dir(install_dir: &Path) -> ProxiFyreSetupStatusDto {
|
||
let proxifyre = detect_proxyfier_install();
|
||
build_proxifyre_setup_status_with_detection(proxifyre.as_ref(), install_dir)
|
||
}
|
||
|
||
fn build_proxifyre_setup_status_with_detection(
|
||
proxifyre: Option<&DetectedProxyfier>,
|
||
default_install_dir: &Path,
|
||
) -> ProxiFyreSetupStatusDto {
|
||
let vc_runtime = detect_vc_runtime();
|
||
let packet_filter = detect_windows_packet_filter();
|
||
|
||
let vc_runtime_item = setup_item_from_program(
|
||
"vc-runtime",
|
||
&format!("Microsoft Visual C++ Runtime ({})", runtime_arch_label()),
|
||
vc_runtime,
|
||
"Нужен для запуска ProxiFyre.exe. Установщик скачает официальный vc_redist от Microsoft.",
|
||
);
|
||
let packet_filter_item = setup_item_from_program(
|
||
"packet-filter",
|
||
"Windows Packet Filter",
|
||
packet_filter,
|
||
"Сетевой драйвер NT Kernel/WireSock, через который ProxiFyre перехватывает трафик приложений.",
|
||
);
|
||
let proxifyre_item = match proxifyre {
|
||
Some(detected) => ProxiFyreSetupItemDto {
|
||
id: "proxifyre".to_string(),
|
||
name: "ProxiFyre".to_string(),
|
||
installed: true,
|
||
version: Some(proxifyre_service_setup_version(detected)),
|
||
details: detected.install_dir.display().to_string(),
|
||
},
|
||
None => ProxiFyreSetupItemDto {
|
||
id: "proxifyre".to_string(),
|
||
name: "ProxiFyre".to_string(),
|
||
installed: false,
|
||
version: None,
|
||
details: format!(
|
||
"Будет установлен рядом с ProxyWarden в {}.",
|
||
default_install_dir.display()
|
||
),
|
||
},
|
||
};
|
||
|
||
let items = vec![vc_runtime_item, packet_filter_item, proxifyre_item];
|
||
let missing_count = items.iter().filter(|item| !item.installed).count();
|
||
|
||
ProxiFyreSetupStatusDto {
|
||
ready: missing_count == 0,
|
||
missing_count,
|
||
items,
|
||
}
|
||
}
|
||
|
||
fn proxifyre_service_setup_version(detected: &DetectedProxyfier) -> String {
|
||
match detected.service_status.as_deref() {
|
||
Some(status) if status.eq_ignore_ascii_case("running") => "служба запущена".to_string(),
|
||
Some(_) => "служба остановлена".to_string(),
|
||
None => "служба не установлена".to_string(),
|
||
}
|
||
}
|
||
|
||
fn proxifyre_progress_path(state_dir: &Path) -> PathBuf {
|
||
state_dir.join("proxifyre-setup-progress.json")
|
||
}
|
||
|
||
fn idle_proxifyre_setup_progress() -> ProxiFyreSetupProgressDto {
|
||
ProxiFyreSetupProgressDto {
|
||
operation: "idle".to_string(),
|
||
status: "idle".to_string(),
|
||
active_step: None,
|
||
percent: 0,
|
||
message: "Ожидаю действия пользователя.".to_string(),
|
||
updated_at: None,
|
||
}
|
||
}
|
||
|
||
fn read_proxifyre_setup_progress(
|
||
storage: &JsonStorage,
|
||
) -> Result<ProxiFyreSetupProgressDto, CommandError> {
|
||
let path = proxifyre_progress_path(&storage.paths().state_dir);
|
||
if !path.exists() {
|
||
return Ok(idle_proxifyre_setup_progress());
|
||
}
|
||
|
||
let contents = fs::read_to_string(&path).map_err(|error| {
|
||
CommandError::new(
|
||
"proxifyre_setup_progress_read_failed",
|
||
format!(
|
||
"Не удалось прочитать прогресс установки ProxiFyre '{}': {error}",
|
||
path.display()
|
||
),
|
||
)
|
||
})?;
|
||
|
||
serde_json::from_str(&contents).map_err(|error| {
|
||
CommandError::new(
|
||
"proxifyre_setup_progress_parse_failed",
|
||
format!(
|
||
"Не удалось разобрать прогресс установки ProxiFyre '{}': {error}",
|
||
path.display()
|
||
),
|
||
)
|
||
})
|
||
}
|
||
|
||
fn write_proxifyre_setup_progress(
|
||
path: &Path,
|
||
operation: &str,
|
||
active_step: Option<&str>,
|
||
status: &str,
|
||
percent: u8,
|
||
message: &str,
|
||
) -> Result<(), CommandError> {
|
||
let progress = ProxiFyreSetupProgressDto {
|
||
operation: operation.to_string(),
|
||
status: status.to_string(),
|
||
active_step: active_step.map(str::to_string),
|
||
percent: percent.min(100),
|
||
message: message.to_string(),
|
||
updated_at: Some(SystemClock.now()),
|
||
};
|
||
let bytes = serde_json::to_vec_pretty(&progress).map_err(|error| {
|
||
CommandError::new(
|
||
"proxifyre_setup_progress_write_failed",
|
||
format!("Не удалось подготовить прогресс установки ProxiFyre: {error}"),
|
||
)
|
||
})?;
|
||
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).map_err(|error| {
|
||
CommandError::new(
|
||
"proxifyre_setup_progress_write_failed",
|
||
format!(
|
||
"Не удалось создать папку прогресса установки ProxiFyre '{}': {error}",
|
||
parent.display()
|
||
),
|
||
)
|
||
})?;
|
||
}
|
||
|
||
let temp_path = safe_fs::temp_path(path);
|
||
fs::write(&temp_path, bytes).map_err(|error| {
|
||
CommandError::new(
|
||
"proxifyre_setup_progress_write_failed",
|
||
format!(
|
||
"Не удалось записать прогресс установки ProxiFyre '{}': {error}",
|
||
temp_path.display()
|
||
),
|
||
)
|
||
})?;
|
||
fs::rename(&temp_path, path).map_err(|error| {
|
||
let _ = fs::remove_file(&temp_path);
|
||
CommandError::new(
|
||
"proxifyre_setup_progress_write_failed",
|
||
format!(
|
||
"Не удалось обновить прогресс установки ProxiFyre '{}': {error}",
|
||
path.display()
|
||
),
|
||
)
|
||
})
|
||
}
|
||
|
||
fn setup_item_from_program(
|
||
id: &str,
|
||
name: &str,
|
||
program: Option<InstalledProgram>,
|
||
missing_details: &str,
|
||
) -> ProxiFyreSetupItemDto {
|
||
match program {
|
||
Some(program) => ProxiFyreSetupItemDto {
|
||
id: id.to_string(),
|
||
name: name.to_string(),
|
||
installed: true,
|
||
version: program.display_version,
|
||
details: program.display_name,
|
||
},
|
||
None => ProxiFyreSetupItemDto {
|
||
id: id.to_string(),
|
||
name: name.to_string(),
|
||
installed: false,
|
||
version: None,
|
||
details: missing_details.to_string(),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct InstalledProgram {
|
||
display_name: String,
|
||
display_version: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "PascalCase")]
|
||
struct InstalledProgramJson {
|
||
display_name: Option<String>,
|
||
display_version: Option<String>,
|
||
}
|
||
|
||
fn detect_vc_runtime() -> Option<InstalledProgram> {
|
||
installed_program(&vc_runtime_registry_pattern())
|
||
}
|
||
|
||
fn detect_windows_packet_filter() -> Option<InstalledProgram> {
|
||
installed_program("Windows Packet Filter|WinpkFilter|NDISAPI")
|
||
}
|
||
|
||
fn installed_program(pattern: &str) -> Option<InstalledProgram> {
|
||
let script = format!(
|
||
r#"
|
||
$paths = @(
|
||
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||
)
|
||
$program = Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue |
|
||
Where-Object {{ $_.DisplayName -match '{}' }} |
|
||
Select-Object -First 1 DisplayName, DisplayVersion
|
||
if ($null -ne $program) {{
|
||
$program | ConvertTo-Json -Compress
|
||
}}
|
||
"#,
|
||
escape_powershell_single(pattern)
|
||
);
|
||
|
||
let output = command_no_window("powershell")
|
||
.args([
|
||
"-NoProfile",
|
||
"-NonInteractive",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-Command",
|
||
script.as_str(),
|
||
])
|
||
.output()
|
||
.ok()?;
|
||
if !output.status.success() {
|
||
return None;
|
||
}
|
||
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
let payload = stdout.trim();
|
||
if payload.is_empty() || payload.eq_ignore_ascii_case("null") {
|
||
return None;
|
||
}
|
||
|
||
let parsed: InstalledProgramJson = serde_json::from_str(payload).ok()?;
|
||
let display_name = parsed.display_name?.trim().to_string();
|
||
if display_name.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(InstalledProgram {
|
||
display_name,
|
||
display_version: parsed
|
||
.display_version
|
||
.map(|version| version.trim().to_string())
|
||
.filter(|version| !version.is_empty()),
|
||
})
|
||
}
|
||
|
||
fn vc_runtime_registry_pattern() -> String {
|
||
let arch = runtime_arch_label();
|
||
if arch == "ARM64" {
|
||
return r"Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)".to_string();
|
||
}
|
||
|
||
format!(r"Microsoft Visual C\+\+.*Redistributable.*\({arch}\)")
|
||
}
|
||
|
||
fn runtime_arch_label() -> &'static str {
|
||
if cfg!(target_arch = "aarch64") {
|
||
"ARM64"
|
||
} else if cfg!(target_arch = "x86") {
|
||
"x86"
|
||
} else {
|
||
"x64"
|
||
}
|
||
}
|
||
|
||
fn run_elevated_package_script(
|
||
action: ProxiFyrePackageAction,
|
||
body: String,
|
||
artifact_dir: &Path,
|
||
) -> Result<(), CommandError> {
|
||
fs::create_dir_all(artifact_dir).map_err(|error| {
|
||
CommandError::new(
|
||
action.error_code(),
|
||
format!(
|
||
"Не удалось создать папку для временных файлов ProxiFyre '{}': {error}",
|
||
artifact_dir.display()
|
||
),
|
||
)
|
||
})?;
|
||
let prefix = format!("proxywarden-proxifyre-{}", action.file_label());
|
||
let script_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1");
|
||
let result_path =
|
||
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log");
|
||
let progress_path = proxifyre_progress_path(artifact_dir);
|
||
let _ = write_proxifyre_setup_progress(
|
||
&progress_path,
|
||
action.operation(),
|
||
None,
|
||
"running",
|
||
1,
|
||
action.start_message(),
|
||
);
|
||
let script =
|
||
wrap_elevated_package_script_for_action(&body, &result_path, Some(&progress_path), action);
|
||
|
||
write_powershell_script(&script_path, &script).map_err(|error| {
|
||
CommandError::new(
|
||
action.error_code(),
|
||
format!(
|
||
"Не удалось подготовить временный скрипт, чтобы {} ProxiFyre '{}': {error}",
|
||
action.label(),
|
||
script_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(&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() => {
|
||
let _ = fs::remove_file(&result_path);
|
||
let _ = write_proxifyre_setup_progress(
|
||
&progress_path,
|
||
action.operation(),
|
||
None,
|
||
"succeeded",
|
||
100,
|
||
action.success_message(),
|
||
);
|
||
Ok(())
|
||
}
|
||
Ok(output) => {
|
||
let details = package_failure_details(&result_path, &output);
|
||
let _ = fs::remove_file(&result_path);
|
||
let _ = write_proxifyre_setup_progress(
|
||
&progress_path,
|
||
action.operation(),
|
||
None,
|
||
"failed",
|
||
100,
|
||
&details,
|
||
);
|
||
Err(CommandError::new(
|
||
action.error_code(),
|
||
format!(
|
||
"Не удалось {} ProxiFyre. Код elevated-команды: {}. {details}",
|
||
action.label(),
|
||
output.status.code().unwrap_or(-1),
|
||
),
|
||
))
|
||
}
|
||
Err(error) => {
|
||
let message = format!(
|
||
"Не удалось запросить права администратора, чтобы {} ProxiFyre: {error}",
|
||
action.label()
|
||
);
|
||
let _ = write_proxifyre_setup_progress(
|
||
&progress_path,
|
||
action.operation(),
|
||
None,
|
||
"failed",
|
||
100,
|
||
&message,
|
||
);
|
||
Err(CommandError::new(action.error_code(), message))
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String {
|
||
wrap_elevated_package_script_for_action(
|
||
body,
|
||
result_path,
|
||
None,
|
||
ProxiFyrePackageAction::Install,
|
||
)
|
||
}
|
||
|
||
fn wrap_elevated_package_script_for_action(
|
||
body: &str,
|
||
result_path: &Path,
|
||
progress_path: Option<&Path>,
|
||
action: ProxiFyrePackageAction,
|
||
) -> String {
|
||
let mut script = String::new();
|
||
script.push_str("$ErrorActionPreference = 'Stop'\n");
|
||
script.push_str(&format!(
|
||
"$resultPath = '{}'\n",
|
||
escape_powershell_single(&result_path.display().to_string())
|
||
));
|
||
script.push_str(&format!(
|
||
"$script:progressOperation = '{}'\n",
|
||
escape_powershell_single(action.operation())
|
||
));
|
||
script.push_str("$script:progressActiveStep = $null\n");
|
||
if let Some(progress_path) = progress_path {
|
||
script.push_str(&format!(
|
||
"$progressPath = '{}'\n",
|
||
escape_powershell_single(&progress_path.display().to_string())
|
||
));
|
||
script.push_str(
|
||
r#"
|
||
function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) {
|
||
$script:progressOperation = $operation
|
||
$script:progressActiveStep = if ([string]::IsNullOrWhiteSpace($activeStep)) { $null } else { $activeStep }
|
||
$payload = [ordered]@{
|
||
operation = $operation
|
||
status = $status
|
||
activeStep = $script:progressActiveStep
|
||
percent = [Math]::Max(0, [Math]::Min(100, $percent))
|
||
message = $message
|
||
updatedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||
} | ConvertTo-Json -Compress
|
||
$progressTempPath = "$progressPath.tmp"
|
||
Set-Content -LiteralPath $progressTempPath -Value $payload -Encoding UTF8
|
||
Move-Item -LiteralPath $progressTempPath -Destination $progressPath -Force
|
||
}
|
||
"#,
|
||
);
|
||
} else {
|
||
script.push_str(
|
||
r#"
|
||
function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) {}
|
||
"#,
|
||
);
|
||
}
|
||
script.push_str("try {\n");
|
||
script.push_str(body);
|
||
script.push_str(
|
||
r#"
|
||
Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8
|
||
exit 0
|
||
} catch {
|
||
$message = ($_ | Out-String)
|
||
Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'failed' 100 $message
|
||
Set-Content -LiteralPath $resultPath -Value $message -Encoding UTF8
|
||
exit 1
|
||
}
|
||
"#,
|
||
);
|
||
|
||
script
|
||
}
|
||
|
||
fn write_powershell_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)
|
||
}
|
||
|
||
fn run_powershell_command(script: &str) -> std::io::Result<Output> {
|
||
command_no_window("powershell")
|
||
.args([
|
||
"-NoProfile",
|
||
"-NonInteractive",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-Command",
|
||
script,
|
||
])
|
||
.output()
|
||
}
|
||
|
||
fn run_powershell_file(script_path: &Path) -> std::io::Result<Output> {
|
||
command_no_window("powershell")
|
||
.args([
|
||
"-NoProfile",
|
||
"-NonInteractive",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-File",
|
||
])
|
||
.arg(script_path)
|
||
.output()
|
||
}
|
||
|
||
fn is_running_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_powershell_command(script) else {
|
||
return false;
|
||
};
|
||
|
||
output.status.success()
|
||
&& String::from_utf8_lossy(&output.stdout)
|
||
.trim()
|
||
.eq_ignore_ascii_case("true")
|
||
}
|
||
|
||
fn powershell_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 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>) -> 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(
|
||
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 Find-ProxiFyreService {
|
||
foreach ($name in @('ProxiFyreService', 'ProxiFyre')) {
|
||
$candidate = Get-Service -Name $name -ErrorAction SilentlyContinue
|
||
if ($null -ne $candidate) { return $candidate }
|
||
}
|
||
|
||
return Get-Service |
|
||
Where-Object { $_.Name -match 'ProxiFyre|Proxifyre' -or $_.DisplayName -match 'ProxiFyre|Proxifyre' } |
|
||
Select-Object -First 1
|
||
}
|
||
|
||
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-ProxiFyreService
|
||
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-ProxiFyreService
|
||
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-ProxiFyreService
|
||
if ($null -ne $service) {
|
||
sc.exe delete $service.Name | Out-Null
|
||
}
|
||
|
||
Get-Process -Name 'ProxiFyre' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||
|
||
if (-not [string]::IsNullOrWhiteSpace($installDir) -and (Test-Path -LiteralPath $installDir)) {
|
||
Remove-Item -LiteralPath $installDir -Recurse -Force
|
||
}
|
||
|
||
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'succeeded' 58 'ProxiFyre удален.'
|
||
|
||
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 и Windows Packet Filter удалены.'
|
||
"#,
|
||
);
|
||
|
||
script
|
||
}
|
||
|
||
fn ensure_safe_proxifyre_install_dir(path: &Path) -> Result<(), CommandError> {
|
||
let normalized = path
|
||
.display()
|
||
.to_string()
|
||
.replace('/', "\\")
|
||
.to_ascii_lowercase();
|
||
let name = path
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.map(|value| value.to_ascii_lowercase())
|
||
.unwrap_or_default();
|
||
let marker_path = path.join("proxywarden-component.json");
|
||
let is_proxywarden_component =
|
||
name == "proxifyre" && normalized.contains("\\proxywarden\\components\\");
|
||
let is_legacy_proxywarden_child =
|
||
name == "proxifyre" && normalized.ends_with("\\proxywarden\\proxifyre");
|
||
let is_legacy_tools_proxifyre = normalized == r"c:\tools\proxifyre";
|
||
let has_proxywarden_marker = marker_path.exists();
|
||
|
||
if path.parent().is_some()
|
||
&& (is_proxywarden_component
|
||
|| is_legacy_proxywarden_child
|
||
|| is_legacy_tools_proxifyre
|
||
|| has_proxywarden_marker)
|
||
{
|
||
return Ok(());
|
||
}
|
||
|
||
Err(CommandError::new(
|
||
ProxiFyrePackageAction::Uninstall.error_code(),
|
||
format!(
|
||
"Отказываюсь рекурсивно удалять папку ProxiFyre с небезопасным путем: {}",
|
||
path.display()
|
||
),
|
||
))
|
||
}
|
||
|
||
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 background_task_error(error: impl std::fmt::Display) -> CommandError {
|
||
CommandError::new(
|
||
"background_task_failed",
|
||
format!("Фоновая проверка не завершилась: {error}"),
|
||
)
|
||
}
|
||
|
||
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 = String::from_utf8_lossy(&output.stdout);
|
||
let stdout = compact_error_text(&stdout);
|
||
if !stdout.is_empty() {
|
||
parts.push(format!("stdout: {stdout}"));
|
||
}
|
||
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
let stderr = compact_error_text(&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(" ");
|
||
|
||
let max_chars = 1400;
|
||
if text.chars().count() <= max_chars {
|
||
return text;
|
||
}
|
||
|
||
let truncated = text.chars().take(max_chars).collect::<String>();
|
||
format!("{truncated}...")
|
||
}
|
||
|
||
fn parse_service_command_output(stdout: &[u8]) -> Option<ServiceCommandOutput> {
|
||
let stdout = String::from_utf8_lossy(stdout);
|
||
let payload = stdout
|
||
.lines()
|
||
.rev()
|
||
.map(str::trim)
|
||
.find(|line| line.starts_with('{') && line.ends_with('}'))?;
|
||
|
||
serde_json::from_str(payload).ok()
|
||
}
|
||
|
||
fn service_script_failed_message(action: ServiceControlAction, exit_code: Option<i32>) -> String {
|
||
let exit_code = exit_code
|
||
.map(|code| format!(" Код выхода PowerShell: {code}."))
|
||
.unwrap_or_default();
|
||
|
||
format!(
|
||
"Не удалось {} службу ProxiFyre: команда управления службой не вернула корректный результат.{exit_code}",
|
||
action.label()
|
||
)
|
||
}
|
||
|
||
fn service_command_failed_message(
|
||
action: ServiceControlAction,
|
||
result: &ServiceCommandOutput,
|
||
) -> String {
|
||
let service_name = result
|
||
.service_name
|
||
.as_deref()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or("ProxiFyre");
|
||
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" => "Служба ProxiFyre не найдена.".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_service_failed_message(
|
||
action: ServiceControlAction,
|
||
direct_result: &ServiceCommandOutput,
|
||
exit_code: Option<i32>,
|
||
) -> String {
|
||
let service_name = direct_result
|
||
.service_name
|
||
.as_deref()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or("ProxiFyre");
|
||
let status = direct_result
|
||
.status
|
||
.as_deref()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or("неизвестен");
|
||
let pid = direct_result
|
||
.process_id
|
||
.filter(|value| *value > 0)
|
||
.map(|value| format!(", PID: {value}"))
|
||
.unwrap_or_default();
|
||
let exit_code = exit_code
|
||
.map(|code| format!(" Код elevated-команды: {code}."))
|
||
.unwrap_or_default();
|
||
|
||
format!(
|
||
"Не удалось {} службу {service_name} даже после запроса прав администратора. До запроса UAC статус был: {status}{pid}.{exit_code} Если появлялось окно UAC, проверь, что оно было подтверждено.",
|
||
action.label()
|
||
)
|
||
}
|
||
|
||
fn escape_powershell_single(value: &str) -> String {
|
||
value.replace('\'', "''")
|
||
}
|
||
|
||
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(),
|
||
)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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(),
|
||
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 {
|
||
tag: server.tag.clone(),
|
||
server_type: server.server_type.clone(),
|
||
server: server.server.clone(),
|
||
server_port: server.server_port,
|
||
}
|
||
}
|
||
}
|