Clarify active Windows client architecture

This commit is contained in:
2026-07-07 21:19:41 +03:00
parent a0f41baa36
commit 59f2264a2e
55 changed files with 19554 additions and 8 deletions

View File

@@ -0,0 +1,23 @@
use crate::models::ActivityEntry;
pub const DEFAULT_ACTIVITY_LIMIT: usize = 200;
pub fn sort_activity_desc(mut entries: Vec<ActivityEntry>) -> Vec<ActivityEntry> {
entries.sort_by(|left, right| right.at.cmp(&left.at).then_with(|| right.id.cmp(&left.id)));
entries
}
pub fn cap_activity(entries: Vec<ActivityEntry>, limit: usize) -> Vec<ActivityEntry> {
let mut entries = sort_activity_desc(entries);
entries.truncate(limit);
entries
}
pub fn append_activity(
mut entries: Vec<ActivityEntry>,
entry: ActivityEntry,
limit: usize,
) -> Vec<ActivityEntry> {
entries.push(entry);
cap_activity(entries, limit)
}

View File

@@ -0,0 +1,242 @@
use crate::models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
ProxyProtocol, Target,
};
#[cfg(test)]
use crate::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
#[cfg(not(test))]
use crate::adapters::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
use serde::{Deserialize, Serialize};
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
pub const PROXIFYRE_OUTPUT_FILE: &str = "proxifyre-app-config.json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxiFyreAdapter {
log_level: String,
bypass_lan: bool,
}
impl ProxiFyreAdapter {
pub fn new(log_level: impl Into<String>, bypass_lan: bool) -> Self {
Self {
log_level: log_level.into(),
bypass_lan,
}
}
pub fn generate_proxifyre_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxiFyreConfig, ProxyRouterError> {
let mut proxies = Vec::new();
for profile in request.profiles.iter().filter(|profile| profile.enabled) {
let target = find_target(profile, request.targets)?;
ensure_target_supported(profile, target, request.components)?;
let app_names = app_names_for_profile(profile);
if app_names.is_empty() {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::EmptyProfileItems,
format!("В профиле '{}' нет приложений для маршрутизации", profile.id),
));
}
proxies.push(ProxiFyreProxy {
app_names,
socks5_proxy_endpoint: format!("{}:{}", target.host, target.port),
supported_protocols: protocols_for_profile(profile),
});
}
Ok(ProxiFyreConfig {
log_level: self.log_level.clone(),
bypass_lan: self.bypass_lan,
proxies,
})
}
}
impl Default for ProxiFyreAdapter {
fn default() -> Self {
Self::new("Info", true)
}
}
impl ProxyRouterAdapter for ProxiFyreAdapter {
fn id(&self) -> &'static str {
PROXIFYRE_ADAPTER_ID
}
fn output_file_name(&self) -> &'static str {
PROXIFYRE_OUTPUT_FILE
}
fn generate_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError> {
let config = self.generate_proxifyre_config(request)?;
let enabled_profiles = config.proxies.len();
let routed_apps = config
.proxies
.iter()
.map(|proxy| proxy.app_names.len())
.sum();
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
ProxyRouterError::new(
ProxyRouterErrorKind::Serialization,
format!("Не удалось сериализовать конфиг ProxiFyre: {error}"),
)
})?;
Ok(ProxyRouterGeneratedConfig {
adapter_id: self.id().to_string(),
output_file_name: self.output_file_name().to_string(),
contents,
enabled_profiles,
routed_apps,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxiFyreConfig {
#[serde(rename = "logLevel")]
pub log_level: String,
#[serde(rename = "bypassLan")]
pub bypass_lan: bool,
pub proxies: Vec<ProxiFyreProxy>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxiFyreProxy {
#[serde(rename = "appNames")]
pub app_names: Vec<String>,
#[serde(rename = "socks5ProxyEndpoint")]
pub socks5_proxy_endpoint: String,
#[serde(rename = "supportedProtocols")]
pub supported_protocols: Vec<String>,
}
fn find_target<'a>(
profile: &Profile,
targets: &'a [Target],
) -> Result<&'a Target, ProxyRouterError> {
targets
.iter()
.find(|target| target.id == profile.target_id)
.ok_or_else(|| {
ProxyRouterError::new(
ProxyRouterErrorKind::MissingTarget,
format!(
"Профиль '{}' ссылается на отсутствующую цель '{}'",
profile.id, profile.target_id
),
)
})
}
fn ensure_target_supported(
profile: &Profile,
target: &Target,
components: &[ComponentStatus],
) -> Result<(), ProxyRouterError> {
if target.protocol != ProxyProtocol::Socks5 {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::UnsupportedTargetProtocol,
format!(
"Цель '{}' использует HTTP, но ProxiFyre требует SOCKS5",
target.id
),
));
}
if let Some(required_component) = &target.requires_component {
let Some(status) = components
.iter()
.find(|component| &component.id == required_component)
else {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::MissingRequiredComponent,
format!(
"Цель '{}' профиля '{}' требует отсутствующий компонент '{}'",
target.id,
profile.id,
component_id_label(required_component)
),
));
};
if !component_is_running(status) {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::RequiredComponentNotRunning,
format!(
"Цель '{}' профиля '{}' требует запущенный компонент '{}'",
target.id,
profile.id,
component_id_label(required_component)
),
));
}
}
Ok(())
}
fn component_is_running(status: &ComponentStatus) -> bool {
status.installed && status.running && status.state == ComponentState::Running
}
fn app_names_for_profile(profile: &Profile) -> Vec<String> {
let mut names = Vec::new();
for item in &profile.items {
let value = item.value.trim();
if value.is_empty() {
continue;
}
let app_name = match item.item_type {
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
};
if !names.iter().any(|existing| existing == app_name) {
names.push(app_name.to_string());
}
}
names
}
fn protocols_for_profile(profile: &Profile) -> Vec<String> {
let mut protocols = Vec::new();
for protocol in &profile.protocols {
let value = match protocol {
Protocol::Tcp => "TCP",
Protocol::Udp => "UDP",
};
if !protocols.iter().any(|existing| existing == value) {
protocols.push(value.to_string());
}
}
protocols
}
fn component_id_label(component_id: &ComponentId) -> &'static str {
match component_id {
ComponentId::ControlApp => "control-app",
ComponentId::Proxyfier => "proxyfier",
ComponentId::Singbox => "singbox",
}
}

View File

@@ -0,0 +1,67 @@
use crate::models::{ComponentStatus, Profile, Target};
#[derive(Debug, Clone, Copy)]
pub struct ProxyRouterRequest<'a> {
pub profiles: &'a [Profile],
pub targets: &'a [Target],
pub components: &'a [ComponentStatus],
}
impl<'a> ProxyRouterRequest<'a> {
pub fn new(
profiles: &'a [Profile],
targets: &'a [Target],
components: &'a [ComponentStatus],
) -> Self {
Self {
profiles,
targets,
components,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyRouterGeneratedConfig {
pub adapter_id: String,
pub output_file_name: String,
pub contents: String,
pub enabled_profiles: usize,
pub routed_apps: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyRouterError {
pub kind: ProxyRouterErrorKind,
pub message: String,
}
impl ProxyRouterError {
pub fn new(kind: ProxyRouterErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyRouterErrorKind {
EmptyProfileItems,
MissingTarget,
MissingRequiredComponent,
RequiredComponentNotRunning,
UnsupportedTargetProtocol,
Serialization,
}
pub trait ProxyRouterAdapter {
fn id(&self) -> &'static str;
fn output_file_name(&self) -> &'static str;
fn generate_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError>;
}

View File

@@ -0,0 +1,358 @@
use crate::models::{
ComponentId, ComponentState, ComponentStatus, ProxyProtocol, Target, TargetKind,
};
use serde::{Deserialize, Serialize};
use std::{
env, fs,
path::Path,
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
pub const DEFAULT_MIXED_INBOUND_TAG: &str = "vpn-proxy-mixed-in";
pub const DEFAULT_DIRECT_OUTBOUND_TAG: &str = "direct";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxAdapter {
log_level: String,
inbound_tag: String,
outbound_tag: String,
}
impl SingBoxAdapter {
pub fn new(
log_level: impl Into<String>,
inbound_tag: impl Into<String>,
outbound_tag: impl Into<String>,
) -> Self {
Self {
log_level: log_level.into(),
inbound_tag: inbound_tag.into(),
outbound_tag: outbound_tag.into(),
}
}
pub fn generate_config<C>(
&self,
request: SingBoxGenerationRequest<'_>,
checker: &C,
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
where
C: SingBoxConfigChecker,
{
let target = find_local_singbox_target(request.targets)?;
ensure_local_singbox_target(target, request.components)?;
let config = SingBoxConfig {
log: SingBoxLog {
disabled: false,
level: self.log_level.clone(),
timestamp: true,
},
inbounds: vec![SingBoxInbound {
inbound_type: "mixed".to_string(),
tag: self.inbound_tag.clone(),
listen: target.host.clone(),
listen_port: target.port,
users: Vec::new(),
set_system_proxy: false,
}],
outbounds: vec![SingBoxOutbound {
outbound_type: "direct".to_string(),
tag: self.outbound_tag.clone(),
}],
route: SingBoxRoute {
final_outbound: self.outbound_tag.clone(),
},
};
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::Serialization,
format!("Не удалось сериализовать конфиг sing-box: {error}"),
)
})?;
let check = match request.binary_path {
Some(binary_path) => Some(checker.check_config(binary_path, &contents)?),
None => None,
};
Ok(SingBoxGeneratedConfig {
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
contents,
local_target_id: target.id.clone(),
listen: target.host.clone(),
listen_port: target.port,
check,
})
}
}
impl Default for SingBoxAdapter {
fn default() -> Self {
Self::new(
"info",
DEFAULT_MIXED_INBOUND_TAG,
DEFAULT_DIRECT_OUTBOUND_TAG,
)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SingBoxGenerationRequest<'a> {
pub targets: &'a [Target],
pub components: &'a [ComponentStatus],
pub binary_path: Option<&'a Path>,
}
impl<'a> SingBoxGenerationRequest<'a> {
pub fn new(
targets: &'a [Target],
components: &'a [ComponentStatus],
binary_path: Option<&'a Path>,
) -> Self {
Self {
targets,
components,
binary_path,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxGeneratedConfig {
pub adapter_id: String,
pub output_file_name: String,
pub contents: String,
pub local_target_id: String,
pub listen: String,
pub listen_port: u16,
pub check: Option<SingBoxCheckResult>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxCheckResult {
pub checked: bool,
pub success: bool,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxConfigError {
pub kind: SingBoxConfigErrorKind,
pub message: String,
}
impl SingBoxConfigError {
pub fn new(kind: SingBoxConfigErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SingBoxConfigErrorKind {
MissingLocalTarget,
MissingRequiredComponent,
RequiredComponentNotRunning,
UnsupportedTarget,
Serialization,
CheckFailed,
}
pub trait SingBoxConfigChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SingBoxCommandChecker;
impl SingBoxConfigChecker for SingBoxCommandChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
let config_path = env::temp_dir().join(format!(
"vpn-proxy-sing-box-{}-{}.json",
std::process::id(),
now_millis()
));
fs::write(&config_path, config_json).map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось записать временный конфиг sing-box '{}': {error}",
config_path.display()
),
)
})?;
let output = Command::new(binary_path)
.arg("check")
.arg("-c")
.arg(&config_path)
.output()
.map_err(|error| {
let _ = fs::remove_file(&config_path);
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!("Не удалось выполнить '{} check': {error}", binary_path.display()),
)
})?;
let _ = fs::remove_file(&config_path);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let message = command_message(&stdout, &stderr);
if !output.status.success() {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!("Проверка sing-box не прошла: {message}"),
));
}
Ok(SingBoxCheckResult {
checked: true,
success: true,
message: if message.is_empty() {
"Проверка sing-box прошла успешно".to_string()
} else {
message
},
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxConfig {
pub log: SingBoxLog,
pub inbounds: Vec<SingBoxInbound>,
pub outbounds: Vec<SingBoxOutbound>,
pub route: SingBoxRoute,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxLog {
pub disabled: bool,
pub level: String,
pub timestamp: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxInbound {
#[serde(rename = "type")]
pub inbound_type: String,
pub tag: String,
pub listen: String,
#[serde(rename = "listen_port")]
pub listen_port: u16,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub users: Vec<SingBoxUser>,
#[serde(rename = "set_system_proxy")]
pub set_system_proxy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxUser {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxOutbound {
#[serde(rename = "type")]
pub outbound_type: String,
pub tag: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxRoute {
#[serde(rename = "final")]
pub final_outbound: String,
}
fn find_local_singbox_target(targets: &[Target]) -> Result<&Target, SingBoxConfigError> {
targets
.iter()
.find(|target| {
target.kind == TargetKind::Local
&& target.requires_component.as_ref() == Some(&ComponentId::Singbox)
})
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingLocalTarget,
"Локальная цель, требующая sing-box, не настроена",
)
})
}
fn ensure_local_singbox_target(
target: &Target,
components: &[ComponentStatus],
) -> Result<(), SingBoxConfigError> {
if target.kind != TargetKind::Local
|| target.protocol != ProxyProtocol::Socks5
|| target.requires_component.as_ref() != Some(&ComponentId::Singbox)
{
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedTarget,
format!(
"Цель '{}' должна быть локальной SOCKS5-целью, требующей sing-box",
target.id
),
));
}
let Some(status) = components
.iter()
.find(|component| component.id == ComponentId::Singbox)
else {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingRequiredComponent,
format!("Локальная цель '{}' требует состояние компонента sing-box", target.id),
));
};
if !component_is_running(status) {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::RequiredComponentNotRunning,
format!("Локальная цель '{}' требует установленный и запущенный sing-box", target.id),
));
}
Ok(())
}
fn component_is_running(status: &ComponentStatus) -> bool {
status.installed && status.running && status.state == ComponentState::Running
}
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default()
}
fn command_message(stdout: &str, stderr: &str) -> String {
let stdout = stdout.trim();
let stderr = stderr.trim();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) => format!("{stdout}\n{stderr}"),
}
}

View File

@@ -0,0 +1,971 @@
#[cfg(not(test))]
use crate::adapters::proxifyre::ProxiFyreAdapter;
#[cfg(not(test))]
use crate::adapters::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
#[cfg(test)]
use crate::proxifyre::ProxiFyreAdapter;
#[cfg(test)]
use crate::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
use crate::component_detection::{
detect_proxyfier_install, detect_proxyfier_install_with_host,
proxyfier_component_from_detection, DetectedProxyfier, ProxyfierDetectionHost,
SystemProxyfierDetectionHost,
};
use crate::models::{
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile,
ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, Target,
TargetInput, TargetKind,
};
use crate::storage::{default_config_root, JsonStorage};
use crate::validation::{normalize_profile, normalize_target, ValidationError};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
#[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 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 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>,
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 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 StagedApplyHelper;
impl ProxyApplyHelper for StagedApplyHelper {
fn apply_proxy_config(
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
Ok(HelperApplyResult {
success: true,
changed: true,
action: format!("{}.stage-generated-config", request.adapter_id),
message: format!(
"Сгенерированный конфиг подготовлен в {}; интеграция привилегированного помощника еще не подключена",
request.config_path.display()
),
})
}
}
pub struct DetectedProxyApplyHelper<H = SystemProxyfierDetectionHost> {
host: H,
}
impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
pub fn system() -> Self {
Self {
host: SystemProxyfierDetectionHost,
}
}
}
impl<H> DetectedProxyApplyHelper<H> {
pub fn new(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 fn get_status(state: tauri::State<'_, CommandState>) -> Result<StatusResponse, CommandError> {
build_status(&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 fn get_components(
state: tauri::State<'_, CommandState>,
) -> Result<Vec<ComponentStatusDto>, CommandError> {
read_components(&state.storage())
}
#[tauri::command]
pub fn resolve_profile_preview(
input: ProfileInputDto,
) -> Result<ResolveProfilePreviewResponse, CommandError> {
resolve_preview(input)
}
#[tauri::command]
pub fn apply_profiles(
state: tauri::State<'_, CommandState>,
) -> Result<ApplyProfilesResponse, CommandError> {
let storage = state.storage();
let adapter = ProxiFyreAdapter::default();
let helper = DetectedProxyApplyHelper::system();
let clock = SystemClock;
apply_profiles_with_services(&storage, &adapter, &helper, &clock)
}
#[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())
}
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_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, CommandError> {
storage
.read_activity()
.map_err(storage_error)
.map(|entries| entries.iter().map(ActivityEntryDto::from).collect())
}
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> {
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 generated =
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
Ok(generated) => generated,
Err(error) => {
let command_error = adapter_error(error);
let activity = activity_for_apply_error(clock, &command_error);
storage.append_activity(activity).map_err(storage_error)?;
return Err(command_error);
}
};
let generated_path = storage
.paths()
.generated_dir
.join(generated.output_file_name.as_str());
write_generated_config(&generated_path, &generated.contents)?;
let helper_result = helper.apply_proxy_config(HelperApplyRequest {
adapter_id: generated.adapter_id.as_str(),
config_path: &generated_path,
config_contents: generated.contents.as_str(),
})?;
let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result);
storage
.append_activity(activity.clone())
.map_err(storage_error)?;
Ok(ApplyProfilesResponse {
success: helper_result.success,
changed: helper_result.changed,
message: helper_result.message.clone(),
adapter_id: generated.adapter_id,
generated_config_path: generated_path.display().to_string(),
enabled_profiles: generated.enabled_profiles,
routed_apps: generated.routed_apps,
helper: helper_result,
activity: ActivityEntryDto::from(&activity),
})
}
fn components_or_defaults(storage: &JsonStorage) -> Result<Vec<ComponentStatus>, CommandError> {
let components = storage.read_components().map_err(storage_error)?;
Ok(resolve_component_statuses(
components,
detect_proxyfier_install(),
))
}
pub fn resolve_component_statuses(
stored_components: Vec<ComponentStatus>,
detected_proxyfier: Option<DetectedProxyfier>,
) -> 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()),
);
}
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,
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,
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,
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> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(storage_error)?;
}
fs::write(path, contents).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())
}
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);
};
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
CommandError::new(
"proxyfier_apply_failed",
format!(
"Не удалось создать папку конфига ProxiFyre '{}': {error}",
parent.display()
),
)
})?;
}
if config_path.exists() {
let backup_path = config_path.with_file_name(format!(
"{}.bak",
config_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("app-config.json")
));
fs::copy(config_path, backup_path).map_err(|error| {
CommandError::new(
"proxyfier_apply_failed",
format!(
"Не удалось создать backup текущего конфига ProxiFyre '{}': {error}",
config_path.display()
),
)
})?;
}
fs::write(config_path, request.config_contents).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 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(),
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(),
}
}
}

View File

@@ -0,0 +1,414 @@
use crate::models::{ComponentId, ComponentState, ComponentStatus};
use serde::Deserialize;
use std::{
env,
path::{Path, PathBuf},
process::Command,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyfierEngine {
ProxiFyre,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedProxyfier {
pub engine: ProxyfierEngine,
pub name: String,
pub install_dir: PathBuf,
pub executable_path: PathBuf,
pub config_path: Option<PathBuf>,
pub running: bool,
pub service_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryInstallEntry {
pub display_name: String,
pub install_location: Option<PathBuf>,
pub display_icon: Option<PathBuf>,
}
pub trait ProxyfierDetectionHost {
fn env_var(&self, name: &str) -> Option<String>;
fn path_exists(&self, path: &Path) -> bool;
fn process_running(&self, process_name: &str) -> bool;
fn service_running(&self, service_name: &str) -> bool;
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemProxyfierDetectionHost;
impl ProxyfierDetectionHost for SystemProxyfierDetectionHost {
fn env_var(&self, name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.trim().is_empty())
}
fn path_exists(&self, path: &Path) -> bool {
path.exists()
}
fn process_running(&self, process_name: &str) -> bool {
let process_name = process_name.trim_end_matches(".exe");
let script = format!(
"if (Get-Process -Name '{}' -ErrorAction SilentlyContinue) {{ 'true' }} else {{ 'false' }}",
escape_powershell_single(process_name)
);
powershell_bool(&script)
}
fn service_running(&self, service_name: &str) -> bool {
let script = format!(
"$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s -and $s.Status -eq 'Running') {{ 'true' }} else {{ 'false' }}",
escape_powershell_single(service_name)
);
powershell_bool(&script)
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
read_registry_install_entries()
}
}
pub fn detect_proxyfier_install() -> Option<DetectedProxyfier> {
detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost)
}
pub fn detect_proxyfier_install_with_host(
host: &impl ProxyfierDetectionHost,
) -> Option<DetectedProxyfier> {
let proxifyre_running = host.process_running("ProxiFyre.exe")
|| host.service_running("ProxiFyreService")
|| host.service_running("ProxiFyre");
proxyfier_candidates(host)
.into_iter()
.filter_map(|candidate| candidate.into_detected(host, proxifyre_running))
.next()
}
pub fn proxyfier_component_from_detection(
detected: Option<&DetectedProxyfier>,
) -> ComponentStatus {
match detected {
Some(proxyfier) => detected_proxyfier_component(proxyfier),
None => missing_proxyfier_component(),
}
}
fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatus {
let state = if proxyfier.running {
ComponentState::Running
} else {
ComponentState::Installed
};
let actions = match proxyfier.engine {
ProxyfierEngine::ProxiFyre => {
if proxyfier.running {
vec![
"Применить сгенерированный конфиг".to_string(),
"Открыть папку конфига".to_string(),
"Перезапустить".to_string(),
]
} else {
vec![
"Применить сгенерированный конфиг".to_string(),
"Открыть папку конфига".to_string(),
"Запустить".to_string(),
]
}
}
};
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state,
installed: true,
running: proxyfier.running,
version: Some(match proxyfier.engine {
ProxyfierEngine::ProxiFyre => "ProxiFyre найден".to_string(),
}),
path: Some(proxyfier.install_dir.display().to_string()),
problems: Vec::new(),
actions,
}
}
fn missing_proxyfier_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
actions: vec!["Установить ProxiFyre".to_string()],
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProxyfierCandidate {
engine: ProxyfierEngine,
name: String,
install_dir: PathBuf,
}
impl ProxyfierCandidate {
fn into_detected(
self,
host: &impl ProxyfierDetectionHost,
proxifyre_running: bool,
) -> Option<DetectedProxyfier> {
let executable_path = self.install_dir.join(executable_name(&self.engine));
let config_path = config_path(&self.engine, &self.install_dir);
let exists = host.path_exists(&self.install_dir)
|| host.path_exists(&executable_path)
|| config_path
.as_ref()
.is_some_and(|path| host.path_exists(path));
if !exists {
return None;
}
Some(DetectedProxyfier {
service_name: service_name(&self.engine).map(str::to_string),
engine: self.engine,
name: self.name,
install_dir: self.install_dir,
executable_path,
config_path,
running: proxifyre_running,
})
}
}
fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec<ProxyfierCandidate> {
let mut candidates = Vec::new();
push_env_candidate(
&mut candidates,
host,
ProxyfierEngine::ProxiFyre,
"ProxiFyre",
"VPN_PROXY_PROXIFYRE_ROOT",
);
for entry in host.registry_install_entries() {
if let Some(engine) = engine_from_name(&entry.display_name) {
let install_dir = entry
.install_location
.or_else(|| entry.display_icon.and_then(|path| executable_parent(&path)));
if let Some(install_dir) = install_dir {
push_candidate(
&mut candidates,
ProxyfierCandidate {
name: entry.display_name,
engine,
install_dir,
},
);
}
}
}
for install_dir in common_install_dirs(host, "ProxiFyre") {
push_candidate(
&mut candidates,
ProxyfierCandidate {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir,
},
);
}
candidates
}
fn push_env_candidate(
candidates: &mut Vec<ProxyfierCandidate>,
host: &impl ProxyfierDetectionHost,
engine: ProxyfierEngine,
name: &str,
env_name: &str,
) {
if let Some(path) = host.env_var(env_name) {
push_candidate(
candidates,
ProxyfierCandidate {
engine,
name: name.to_string(),
install_dir: PathBuf::from(path),
},
);
}
}
fn push_candidate(candidates: &mut Vec<ProxyfierCandidate>, candidate: ProxyfierCandidate) {
if !candidates.iter().any(|existing| {
existing.engine == candidate.engine && same_path(&existing.install_dir, &candidate.install_dir)
}) {
candidates.push(candidate);
}
}
fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec<PathBuf> {
let mut dirs = vec![PathBuf::from(format!(r"C:\Tools\{folder_name}"))];
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
if let Some(root) = host.env_var(env_name) {
dirs.push(PathBuf::from(root).join(folder_name));
}
}
dirs
}
fn executable_name(engine: &ProxyfierEngine) -> &'static str {
match engine {
ProxyfierEngine::ProxiFyre => "ProxiFyre.exe",
}
}
fn config_path(engine: &ProxyfierEngine, install_dir: &Path) -> Option<PathBuf> {
match engine {
ProxyfierEngine::ProxiFyre => Some(install_dir.join("app-config.json")),
}
}
fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> {
match engine {
ProxyfierEngine::ProxiFyre => Some("ProxiFyreService"),
}
}
fn engine_from_name(name: &str) -> Option<ProxyfierEngine> {
let normalized = name.to_ascii_lowercase();
if normalized.contains("proxifyre") {
Some(ProxyfierEngine::ProxiFyre)
} else {
None
}
}
fn executable_parent(path: &Path) -> Option<PathBuf> {
path.parent().map(Path::to_path_buf)
}
fn same_path(left: &Path, right: &Path) -> bool {
left.to_string_lossy()
.eq_ignore_ascii_case(&right.to_string_lossy())
}
fn powershell_bool(script: &str) -> bool {
Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.is_some_and(|stdout| stdout.trim().eq_ignore_ascii_case("true"))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct RegistryInstallJson {
display_name: Option<String>,
install_location: Option<String>,
display_icon: Option<String>,
}
fn read_registry_install_entries() -> Vec<RegistryInstallEntry> {
let script = r#"
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$items = foreach ($path in $paths) {
Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
}
$items |
Where-Object { $_.DisplayName -match 'ProxiFyre' } |
Select-Object DisplayName,InstallLocation,DisplayIcon |
ConvertTo-Json -Compress
"#;
let Ok(output) = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let Ok(stdout) = String::from_utf8(output.stdout) else {
return Vec::new();
};
let stdout = stdout.trim();
if stdout.is_empty() {
return Vec::new();
}
parse_registry_json(stdout)
}
fn parse_registry_json(json: &str) -> Vec<RegistryInstallEntry> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return Vec::new();
};
match value {
serde_json::Value::Array(entries) => entries
.into_iter()
.filter_map(registry_entry_from_value)
.collect(),
entry => registry_entry_from_value(entry).into_iter().collect(),
}
}
fn registry_entry_from_value(value: serde_json::Value) -> Option<RegistryInstallEntry> {
let parsed = serde_json::from_value::<RegistryInstallJson>(value).ok()?;
let display_name = parsed.display_name?;
Some(RegistryInstallEntry {
display_name,
install_location: parsed
.install_location
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from),
display_icon: parsed
.display_icon
.and_then(|value| display_icon_path(&value)),
})
}
fn display_icon_path(value: &str) -> Option<PathBuf> {
let trimmed = value.trim().trim_matches('"');
if trimmed.is_empty() {
return None;
}
let without_icon_index = trimmed
.split_once(',')
.map(|(path, _)| path)
.unwrap_or(trimmed)
.trim()
.trim_matches('"');
Some(PathBuf::from(without_icon_index))
}
fn escape_powershell_single(value: &str) -> String {
value.replace('\'', "''")
}

View File

@@ -0,0 +1,184 @@
use crate::models::ComponentId;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HelperAction {
#[serde(rename = "install-control-app")]
InstallControlApp,
#[serde(rename = "install-proxyfier")]
InstallProxyfier,
#[serde(rename = "install-singbox")]
InstallSingbox,
#[serde(rename = "proxyfier.apply")]
ProxyfierApply,
#[serde(rename = "service.status")]
ServiceStatus,
#[serde(rename = "service.start")]
ServiceStart,
#[serde(rename = "service.stop")]
ServiceStop,
#[serde(rename = "service.restart")]
ServiceRestart,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperRequest {
pub action: HelperAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub component: Option<ComponentId>,
#[serde(default)]
pub payload: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperResponse {
pub success: bool,
pub action: HelperAction,
pub changed: bool,
pub message: String,
#[serde(default)]
pub details: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandSpec {
pub program: PathBuf,
pub args: Vec<String>,
pub stdin: String,
pub requires_elevation: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandOutput {
pub status_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperError {
pub code: String,
pub message: String,
}
impl HelperError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
pub trait HelperCommandRunner {
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError>;
}
#[derive(Debug, Clone)]
pub struct StructuredHelper<R> {
helper_program: PathBuf,
runner: R,
}
impl<R> StructuredHelper<R>
where
R: HelperCommandRunner,
{
pub fn new(helper_program: impl Into<PathBuf>, runner: R) -> Self {
Self {
helper_program: helper_program.into(),
runner,
}
}
pub fn runner(&self) -> &R {
&self.runner
}
pub fn execute(&self, request: &HelperRequest) -> Result<HelperResponse, HelperError> {
let stdin = serde_json::to_string(request)
.map_err(|error| HelperError::new("helper_request_encode", error.to_string()))?;
let spec = HelperCommandSpec {
program: self.helper_program.clone(),
args: vec!["--json".to_string()],
stdin,
requires_elevation: helper_action_requires_elevation(&request.action),
};
let output = self.runner.run(&spec)?;
if output.status_code != 0 {
return Err(HelperError::new(
"helper_exit",
format!(
"Помощник завершился с кодом {}: {}",
output.status_code, output.stderr
),
));
}
parse_helper_response(&output.stdout)
}
}
pub fn parse_helper_response(stdout: &str) -> Result<HelperResponse, HelperError> {
serde_json::from_str(stdout).map_err(|error| {
HelperError::new(
"helper_response_decode",
format!("Помощник вернул не JSON или некорректный JSON: {error}"),
)
})
}
pub fn install_request(component: ComponentId) -> HelperRequest {
let action = match component {
ComponentId::ControlApp => HelperAction::InstallControlApp,
ComponentId::Proxyfier => HelperAction::InstallProxyfier,
ComponentId::Singbox => HelperAction::InstallSingbox,
};
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn service_request(component: ComponentId, action: HelperAction) -> HelperRequest {
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn proxifyre_apply_request(
config_path: impl AsRef<Path>,
service_name: impl Into<String>,
) -> HelperRequest {
HelperRequest {
action: HelperAction::ProxyfierApply,
component: Some(ComponentId::Proxyfier),
payload: json!({
"configPath": config_path.as_ref().display().to_string(),
"serviceName": service_name.into(),
}),
}
}
pub fn helper_action_requires_elevation(action: &HelperAction) -> bool {
matches!(
action,
HelperAction::InstallControlApp
| HelperAction::InstallProxyfier
| HelperAction::InstallSingbox
| HelperAction::ProxyfierApply
| HelperAction::ServiceStart
| HelperAction::ServiceStop
| HelperAction::ServiceRestart
)
}

View File

@@ -0,0 +1,5 @@
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("не удалось запустить клиент VPN Proxy для Windows");
}

View File

@@ -0,0 +1,42 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod activity;
mod component_detection;
mod commands;
mod models;
mod storage;
mod validation;
mod adapters {
pub mod proxifyre;
pub mod proxy_router;
}
#[cfg(test)]
pub(crate) mod proxifyre {
pub use crate::adapters::proxifyre::*;
}
#[cfg(test)]
pub(crate) mod proxy_router {
pub use crate::adapters::proxy_router::*;
}
fn main() {
tauri::Builder::default()
.manage(commands::CommandState::default())
.invoke_handler(tauri::generate_handler![
commands::get_status,
commands::get_profiles,
commands::save_profile,
commands::get_targets,
commands::save_target,
commands::get_components,
commands::resolve_profile_preview,
commands::apply_profiles,
commands::get_logs,
commands::open_config_location
])
.run(tauri::generate_context!())
.expect("не удалось запустить клиент VPN Proxy для Windows");
}

View File

@@ -0,0 +1,167 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Protocol {
Tcp,
Udp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ProfileItemType {
Process,
Folder,
Exe,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TargetKind {
Local,
External,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxyProtocol {
Socks5,
Http,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ComponentId {
ControlApp,
Proxyfier,
Singbox,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ComponentState {
Installed,
Missing,
Stopped,
Running,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItemInput {
#[serde(rename = "type")]
pub item_type: String,
pub value: String,
#[serde(default)]
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileInput {
pub id: Option<String>,
pub name: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_target_id")]
pub target_id: String,
#[serde(default = "default_protocols")]
pub protocols: Vec<String>,
#[serde(default)]
pub items: Vec<ProfileItemInput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItem {
#[serde(rename = "type")]
pub item_type: ProfileItemType,
pub value: String,
pub recursive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Profile {
pub id: String,
pub name: String,
pub enabled: bool,
pub target_id: String,
pub protocols: Vec<Protocol>,
pub items: Vec<ProfileItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TargetInput {
pub id: Option<String>,
pub name: String,
#[serde(default = "default_target_kind")]
pub kind: String,
#[serde(default = "default_proxy_protocol")]
pub protocol: String,
pub host: String,
pub port: u32,
#[serde(default)]
pub requires_component: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Target {
pub id: String,
pub name: String,
pub kind: TargetKind,
pub protocol: ProxyProtocol,
pub host: String,
pub port: u16,
pub requires_component: Option<ComponentId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentStatus {
pub id: ComponentId,
pub name: String,
pub state: ComponentState,
pub installed: bool,
pub running: bool,
pub version: Option<String>,
pub path: Option<String>,
#[serde(default)]
pub problems: Vec<String>,
#[serde(default)]
pub actions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActivityEntry {
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 = "lowercase")]
pub enum ActivityLevel {
Info,
Warning,
Error,
Success,
}
fn default_enabled() -> bool {
true
}
fn default_target_id() -> String {
"local-singbox".to_string()
}
fn default_protocols() -> Vec<String> {
vec!["TCP".to_string(), "UDP".to_string()]
}
fn default_target_kind() -> String {
"external".to_string()
}
fn default_proxy_protocol() -> String {
"socks5".to_string()
}

View File

@@ -0,0 +1,187 @@
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
use crate::models::{ActivityEntry, ComponentStatus, Profile, Target};
use serde::{de::DeserializeOwned, Serialize};
use std::fs;
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};
pub fn default_config_root() -> PathBuf {
PathBuf::from(r"C:\ProgramData\VpnProxy")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoragePaths {
pub root: PathBuf,
pub config_dir: PathBuf,
pub state_dir: PathBuf,
pub generated_dir: PathBuf,
pub profiles_file: PathBuf,
pub targets_file: PathBuf,
pub components_file: PathBuf,
pub activity_file: PathBuf,
}
impl StoragePaths {
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let config_dir = root.join("config");
let state_dir = root.join("state");
let generated_dir = root.join("generated");
Self {
root,
profiles_file: config_dir.join("profiles.json"),
targets_file: config_dir.join("targets.json"),
components_file: config_dir.join("components.json"),
activity_file: state_dir.join("activity.json"),
config_dir,
state_dir,
generated_dir,
}
}
}
impl Default for StoragePaths {
fn default() -> Self {
Self::new(default_config_root())
}
}
#[derive(Debug, Clone)]
pub struct JsonStorage {
paths: StoragePaths,
activity_limit: usize,
}
impl JsonStorage {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self::with_activity_limit(root, DEFAULT_ACTIVITY_LIMIT)
}
pub fn with_activity_limit(root: impl Into<PathBuf>, activity_limit: usize) -> Self {
Self {
paths: StoragePaths::new(root),
activity_limit,
}
}
pub fn paths(&self) -> &StoragePaths {
&self.paths
}
pub fn ensure_dirs(&self) -> io::Result<()> {
fs::create_dir_all(&self.paths.config_dir)?;
fs::create_dir_all(&self.paths.state_dir)?;
fs::create_dir_all(&self.paths.generated_dir)?;
Ok(())
}
pub fn read_profiles(&self) -> io::Result<Vec<Profile>> {
self.read_json_or_default(&self.paths.profiles_file)
}
pub fn write_profiles(&self, profiles: &[Profile]) -> io::Result<()> {
self.write_json(&self.paths.profiles_file, profiles)
}
pub fn read_targets(&self) -> io::Result<Vec<Target>> {
self.read_json_or_default(&self.paths.targets_file)
}
pub fn write_targets(&self, targets: &[Target]) -> io::Result<()> {
self.write_json(&self.paths.targets_file, targets)
}
pub fn read_components(&self) -> io::Result<Vec<ComponentStatus>> {
self.read_json_or_default(&self.paths.components_file)
}
pub fn write_components(&self, components: &[ComponentStatus]) -> io::Result<()> {
self.write_json(&self.paths.components_file, components)
}
pub fn read_activity(&self) -> io::Result<Vec<ActivityEntry>> {
let entries = self.read_json_or_default(&self.paths.activity_file)?;
Ok(cap_activity(entries, self.activity_limit))
}
pub fn write_activity(&self, entries: &[ActivityEntry]) -> io::Result<()> {
let entries = cap_activity(entries.to_vec(), self.activity_limit);
self.write_json(&self.paths.activity_file, &entries)
}
pub fn append_activity(&self, entry: ActivityEntry) -> io::Result<Vec<ActivityEntry>> {
let entries = self.read_activity()?;
let entries = append_activity(entries, entry, self.activity_limit);
self.write_json(&self.paths.activity_file, &entries)?;
Ok(entries)
}
fn read_json_or_default<T>(&self, path: &Path) -> io::Result<T>
where
T: DeserializeOwned + Default,
{
match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(value) => Ok(value),
Err(_) => Ok(T::default()),
},
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
Err(error) => Err(error),
}
}
fn write_json<T>(&self, path: &Path, value: &T) -> io::Result<()>
where
T: Serialize + ?Sized,
{
let contents = serde_json::to_vec_pretty(value)
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
write_atomic(path, &contents)
}
}
impl Default for JsonStorage {
fn default() -> Self {
Self::new(default_config_root())
}
}
pub fn backup_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "bak")
}
fn temp_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "tmp")
}
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("storage.json");
path.with_file_name(format!("{file_name}.{suffix}"))
}
fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp_path = temp_path(path);
fs::write(&temp_path, contents)?;
if path.exists() {
fs::copy(path, backup_path(path))?;
fs::remove_file(path)?;
}
match fs::rename(&temp_path, path) {
Ok(()) => Ok(()),
Err(error) => {
let _ = fs::remove_file(&temp_path);
Err(error)
}
}
}

View File

@@ -0,0 +1,222 @@
use crate::models::{
ComponentId, Profile, ProfileInput, ProfileItem, ProfileItemType, Protocol, ProxyProtocol,
Target, TargetInput, TargetKind,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
pub type ValidationResult<T> = Result<T, Vec<ValidationError>>;
fn error(field: impl Into<String>, message: impl Into<String>) -> ValidationError {
ValidationError {
field: field.into(),
message: message.into(),
}
}
fn clean(value: &str) -> String {
value.trim().to_string()
}
fn slug(value: &str, fallback: &str) -> String {
let mut output = String::new();
let mut previous_dash = false;
for ch in value.trim().to_lowercase().chars() {
if ch.is_ascii_alphanumeric() {
output.push(ch);
previous_dash = false;
} else if !previous_dash {
output.push('-');
previous_dash = true;
}
}
let output = output.trim_matches('-').to_string();
if output.is_empty() {
fallback.to_string()
} else {
output
}
}
fn process_name(value: &str) -> String {
let base = value
.trim()
.rsplit(['\\', '/'])
.next()
.unwrap_or("")
.trim();
base.strip_suffix(".exe")
.or_else(|| base.strip_suffix(".EXE"))
.unwrap_or(base)
.trim()
.to_string()
}
pub fn parse_protocol(value: &str) -> Result<Protocol, ValidationError> {
match value.trim().to_ascii_uppercase().as_str() {
"TCP" => Ok(Protocol::Tcp),
"UDP" => Ok(Protocol::Udp),
_ => Err(error("protocols", format!("Неподдерживаемый протокол: {value}"))),
}
}
pub fn parse_profile_item_type(value: &str) -> Result<ProfileItemType, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"process" => Ok(ProfileItemType::Process),
"folder" => Ok(ProfileItemType::Folder),
"exe" => Ok(ProfileItemType::Exe),
_ => Err(error("items.type", format!("Неподдерживаемый тип элемента: {value}"))),
}
}
pub fn parse_target_kind(value: &str) -> Result<TargetKind, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"local" => Ok(TargetKind::Local),
"external" => Ok(TargetKind::External),
_ => Err(error("kind", format!("Неподдерживаемый тип цели: {value}"))),
}
}
pub fn parse_proxy_protocol(value: &str) -> Result<ProxyProtocol, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"socks5" => Ok(ProxyProtocol::Socks5),
"http" => Ok(ProxyProtocol::Http),
_ => Err(error(
"protocol",
format!("Неподдерживаемый протокол прокси: {value}"),
)),
}
}
pub fn parse_component_id(value: &str) -> Result<ComponentId, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"control-app" | "controlapp" => Ok(ComponentId::ControlApp),
"proxyfier" => Ok(ComponentId::Proxyfier),
"singbox" | "sing-box" => Ok(ComponentId::Singbox),
_ => Err(error(
"requires_component",
format!("Неподдерживаемый компонент: {value}"),
)),
}
}
pub fn normalize_profile(input: ProfileInput) -> ValidationResult<Profile> {
let mut errors = Vec::new();
let name = clean(&input.name);
if name.is_empty() {
errors.push(error("name", "Укажите название профиля"));
}
let target_id = clean(&input.target_id);
if target_id.is_empty() {
errors.push(error("target_id", "Укажите цель профиля"));
}
let mut protocols = Vec::new();
for value in input.protocols {
match parse_protocol(&value) {
Ok(protocol) if !protocols.contains(&protocol) => protocols.push(protocol),
Ok(_) => {}
Err(err) => errors.push(err),
}
}
if protocols.is_empty() {
errors.push(error("protocols", "Выберите хотя бы один протокол"));
}
let mut items = Vec::new();
for raw_item in input.items {
let item_type = match parse_profile_item_type(&raw_item.item_type) {
Ok(item_type) => item_type,
Err(err) => {
errors.push(err);
continue;
}
};
let value = match item_type {
ProfileItemType::Process => process_name(&raw_item.value),
ProfileItemType::Folder | ProfileItemType::Exe => clean(&raw_item.value),
};
if value.is_empty() {
errors.push(error("items.value", "Укажите значение элемента профиля"));
continue;
}
let recursive = matches!(item_type, ProfileItemType::Folder)
&& raw_item.recursive.unwrap_or(true);
items.push(ProfileItem {
item_type,
value,
recursive,
});
}
if !errors.is_empty() {
return Err(errors);
}
Ok(Profile {
id: slug(input.id.as_deref().unwrap_or(&name), "profile"),
name,
enabled: input.enabled,
target_id,
protocols,
items,
})
}
pub fn normalize_target(input: TargetInput) -> ValidationResult<Target> {
let mut errors = Vec::new();
let name = clean(&input.name);
let host = clean(&input.host);
if name.is_empty() {
errors.push(error("name", "Укажите название цели"));
}
if host.is_empty() {
errors.push(error("host", "Укажите хост цели"));
}
if input.port == 0 || input.port > u16::MAX as u32 {
errors.push(error("port", "Порт цели должен быть от 1 до 65535"));
}
let kind = parse_target_kind(&input.kind).unwrap_or_else(|err| {
errors.push(err);
TargetKind::External
});
let protocol = parse_proxy_protocol(&input.protocol).unwrap_or_else(|err| {
errors.push(err);
ProxyProtocol::Socks5
});
let requires_component = match input.requires_component {
Some(value) if !value.trim().is_empty() => match parse_component_id(&value) {
Ok(component) => Some(component),
Err(err) => {
errors.push(err);
None
}
},
_ => None,
};
if !errors.is_empty() {
return Err(errors);
}
Ok(Target {
id: slug(input.id.as_deref().unwrap_or(&name), "target"),
name,
kind,
protocol,
host,
port: input.port as u16,
requires_component,
})
}