Expand README with architecture and setup details
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::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",
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
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 = "proxywarden-mixed-in";
|
||||
pub const DEFAULT_VPN_OUTBOUND_TAG: &str = "vpn";
|
||||
pub const DEFAULT_DIRECT_OUTBOUND_TAG: &str = "direct";
|
||||
pub const DEFAULT_BLOCK_OUTBOUND_TAG: &str = "block";
|
||||
|
||||
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SingBoxAdapter {
|
||||
log_level: String,
|
||||
inbound_tag: String,
|
||||
vpn_outbound_tag: String,
|
||||
}
|
||||
|
||||
impl SingBoxAdapter {
|
||||
pub fn new(
|
||||
log_level: impl Into<String>,
|
||||
inbound_tag: impl Into<String>,
|
||||
vpn_outbound_tag: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
log_level: log_level.into(),
|
||||
inbound_tag: inbound_tag.into(),
|
||||
vpn_outbound_tag: vpn_outbound_tag.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_config<C>(
|
||||
&self,
|
||||
request: SingBoxGenerationRequest<'_>,
|
||||
checker: &C,
|
||||
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
|
||||
where
|
||||
C: SingBoxConfigChecker,
|
||||
{
|
||||
let selected_server_tag = request
|
||||
.config
|
||||
.selected_server_tag
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedServer,
|
||||
"Сервер Local sing-box не выбран",
|
||||
)
|
||||
})?;
|
||||
let vpn_outbound = selected_outbound(
|
||||
&request.subscription_cache.config,
|
||||
selected_server_tag,
|
||||
&self.vpn_outbound_tag,
|
||||
)?;
|
||||
let generated_config = json!({
|
||||
"log": {
|
||||
"disabled": false,
|
||||
"level": self.log_level,
|
||||
"timestamp": true
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "mixed",
|
||||
"tag": self.inbound_tag,
|
||||
"listen": request.config.listen_host,
|
||||
"listen_port": request.config.listen_port,
|
||||
"users": [],
|
||||
"set_system_proxy": false
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
vpn_outbound,
|
||||
{ "type": "direct", "tag": DEFAULT_DIRECT_OUTBOUND_TAG },
|
||||
{ "type": "block", "tag": DEFAULT_BLOCK_OUTBOUND_TAG }
|
||||
],
|
||||
"route": {
|
||||
"rules": [
|
||||
{ "ip_is_private": true, "outbound": DEFAULT_DIRECT_OUTBOUND_TAG }
|
||||
],
|
||||
"final": self.vpn_outbound_tag
|
||||
}
|
||||
});
|
||||
let contents = serde_json::to_string_pretty(&generated_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,
|
||||
selected_server_tag: selected_server_tag.to_string(),
|
||||
listen: request.config.listen_host.clone(),
|
||||
listen_port: request.config.listen_port,
|
||||
check,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SingBoxAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new("info", DEFAULT_MIXED_INBOUND_TAG, DEFAULT_VPN_OUTBOUND_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SingBoxGenerationRequest<'a> {
|
||||
pub config: &'a LocalSingBoxConfig,
|
||||
pub subscription_cache: &'a SubscriptionCache,
|
||||
pub binary_path: Option<&'a Path>,
|
||||
}
|
||||
|
||||
impl<'a> SingBoxGenerationRequest<'a> {
|
||||
pub fn new(
|
||||
config: &'a LocalSingBoxConfig,
|
||||
subscription_cache: &'a SubscriptionCache,
|
||||
binary_path: Option<&'a Path>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
subscription_cache,
|
||||
binary_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SingBoxGeneratedConfig {
|
||||
pub adapter_id: String,
|
||||
pub output_file_name: String,
|
||||
pub contents: String,
|
||||
pub selected_server_tag: 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 {
|
||||
MissingSelectedServer,
|
||||
MissingSelectedOutbound,
|
||||
UnsupportedSelectedOutbound,
|
||||
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!(
|
||||
"proxywarden-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
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_outbound(
|
||||
subscription_config: &Value,
|
||||
selected_server_tag: &str,
|
||||
vpn_outbound_tag: &str,
|
||||
) -> Result<Value, SingBoxConfigError> {
|
||||
let outbounds = subscription_config
|
||||
.get("outbounds")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||
"В cache подписки нет outbounds",
|
||||
)
|
||||
})?;
|
||||
let outbound = outbounds
|
||||
.iter()
|
||||
.find(|outbound| {
|
||||
outbound
|
||||
.get("tag")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|tag| tag.trim() == selected_server_tag)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||
format!("Outbound не найден: {selected_server_tag}"),
|
||||
)
|
||||
})?;
|
||||
let outbound_type = outbound
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
if !SUPPORTED_PROXY_TYPES.contains(&outbound_type) {
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!(
|
||||
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let mut outbound = outbound.clone();
|
||||
let object = outbound.as_object_mut().ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"),
|
||||
)
|
||||
})?;
|
||||
object.insert(
|
||||
"tag".to_string(),
|
||||
Value::String(vpn_outbound_tag.to_string()),
|
||||
);
|
||||
if outbound_type == "vless" && !object.contains_key("packet_encoding") {
|
||||
object.insert(
|
||||
"packet_encoding".to_string(),
|
||||
Value::String("xudp".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(outbound)
|
||||
}
|
||||
|
||||
fn now_millis() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn command_message(stdout: &str, stderr: &str) -> String {
|
||||
let stdout = stdout.trim();
|
||||
let stderr = stderr.trim();
|
||||
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,564 @@
|
||||
use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT,
|
||||
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME,
|
||||
};
|
||||
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 DetectedSingBox {
|
||||
pub install_dir: PathBuf,
|
||||
pub executable_path: PathBuf,
|
||||
pub wrapper_path: PathBuf,
|
||||
pub binary_exists: bool,
|
||||
pub wrapper_exists: bool,
|
||||
pub running: bool,
|
||||
pub service_name: 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(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect_singbox_install() -> Option<DetectedSingBox> {
|
||||
detect_singbox_install_with_host(&SystemProxyfierDetectionHost)
|
||||
}
|
||||
|
||||
pub fn detect_singbox_install_with_host(
|
||||
host: &impl ProxyfierDetectionHost,
|
||||
) -> Option<DetectedSingBox> {
|
||||
let running = host.process_running("sing-box.exe")
|
||||
|| host.service_running(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME);
|
||||
|
||||
singbox_candidates(host)
|
||||
.into_iter()
|
||||
.filter_map(|install_dir| detected_singbox_from_dir(host, install_dir, running))
|
||||
.next()
|
||||
}
|
||||
|
||||
pub fn singbox_component_from_detection(detected: Option<&DetectedSingBox>) -> ComponentStatus {
|
||||
match detected {
|
||||
Some(singbox) => detected_singbox_component(singbox),
|
||||
None => missing_singbox_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()],
|
||||
}
|
||||
}
|
||||
|
||||
fn detected_singbox_component(singbox: &DetectedSingBox) -> ComponentStatus {
|
||||
let state = if singbox.running {
|
||||
ComponentState::Running
|
||||
} else {
|
||||
ComponentState::Stopped
|
||||
};
|
||||
let actions = if singbox.running {
|
||||
vec![
|
||||
"Сгенерировать конфиг".to_string(),
|
||||
"Остановить".to_string(),
|
||||
"Открыть папку".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"Сгенерировать конфиг".to_string(),
|
||||
"Запустить".to_string(),
|
||||
"Открыть папку".to_string(),
|
||||
]
|
||||
};
|
||||
let problems = if singbox.running {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec!["Служба Local sing-box остановлена".to_string()]
|
||||
};
|
||||
|
||||
ComponentStatus {
|
||||
id: ComponentId::Singbox,
|
||||
name: "Local sing-box".to_string(),
|
||||
state,
|
||||
installed: true,
|
||||
running: singbox.running,
|
||||
version: Some("sing-box найден".to_string()),
|
||||
path: Some(singbox.executable_path.display().to_string()),
|
||||
problems,
|
||||
actions,
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_singbox_component() -> ComponentStatus {
|
||||
ComponentStatus {
|
||||
id: ComponentId::Singbox,
|
||||
name: "Local sing-box".to_string(),
|
||||
state: ComponentState::Missing,
|
||||
installed: false,
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
problems: Vec::new(),
|
||||
actions: vec!["Установить Local sing-box".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",
|
||||
"PROXYWARDEN_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 singbox_candidates(host: &impl ProxyfierDetectionHost) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Some(path) = host.env_var("PROXYWARDEN_SINGBOX_ROOT") {
|
||||
push_path_candidate(&mut candidates, PathBuf::from(path));
|
||||
}
|
||||
push_path_candidate(
|
||||
&mut candidates,
|
||||
PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT),
|
||||
);
|
||||
push_path_candidate(
|
||||
&mut candidates,
|
||||
PathBuf::from(r"C:\Tools\ProxyWarden\sing-box"),
|
||||
);
|
||||
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
|
||||
if let Some(root) = host.env_var(env_name) {
|
||||
push_path_candidate(
|
||||
&mut candidates,
|
||||
PathBuf::from(&root).join("ProxyWarden").join("sing-box"),
|
||||
);
|
||||
push_path_candidate(&mut candidates, PathBuf::from(root).join("sing-box"));
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn push_path_candidate(candidates: &mut Vec<PathBuf>, candidate: PathBuf) {
|
||||
if !candidates
|
||||
.iter()
|
||||
.any(|existing| same_path(existing, &candidate))
|
||||
{
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
fn detected_singbox_from_dir(
|
||||
host: &impl ProxyfierDetectionHost,
|
||||
install_dir: PathBuf,
|
||||
running: bool,
|
||||
) -> Option<DetectedSingBox> {
|
||||
let executable_path = install_dir.join("sing-box.exe");
|
||||
let wrapper_path = install_dir.join("ProxyWardenSingBox.exe");
|
||||
let binary_exists = host.path_exists(&executable_path);
|
||||
let wrapper_exists = host.path_exists(&wrapper_path);
|
||||
let exists = host.path_exists(&install_dir) || binary_exists || wrapper_exists;
|
||||
|
||||
if !exists {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(DetectedSingBox {
|
||||
install_dir,
|
||||
executable_path,
|
||||
wrapper_path,
|
||||
binary_exists,
|
||||
wrapper_exists,
|
||||
running,
|
||||
service_name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
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('\'', "''")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("не удалось запустить клиент ProxyWarden");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod activity;
|
||||
mod commands;
|
||||
mod component_detection;
|
||||
mod models;
|
||||
mod singbox_service;
|
||||
mod storage;
|
||||
mod subscription;
|
||||
mod validation;
|
||||
|
||||
mod adapters {
|
||||
pub mod proxifyre;
|
||||
pub mod proxy_router;
|
||||
pub mod singbox;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod proxifyre {
|
||||
pub use crate::adapters::proxifyre::*;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod proxy_router {
|
||||
pub use crate::adapters::proxy_router::*;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod singbox {
|
||||
pub use crate::adapters::singbox::*;
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(commands::CommandState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
commands::get_profiles,
|
||||
commands::get_saved_state,
|
||||
commands::save_profile,
|
||||
commands::get_targets,
|
||||
commands::save_target,
|
||||
commands::get_components,
|
||||
commands::get_proxifyre_setup_status,
|
||||
commands::get_singbox_status,
|
||||
commands::get_singbox_setup_status,
|
||||
commands::resolve_profile_preview,
|
||||
commands::save_singbox_subscription,
|
||||
commands::fetch_singbox_subscription,
|
||||
commands::forget_singbox_subscription,
|
||||
commands::select_singbox_server,
|
||||
commands::ping_singbox_server,
|
||||
commands::ping_all_singbox_servers,
|
||||
commands::ping_proxy_target,
|
||||
commands::generate_singbox_config,
|
||||
commands::apply_profiles,
|
||||
commands::get_logs,
|
||||
commands::open_config_location,
|
||||
commands::start_proxifyre_service,
|
||||
commands::stop_proxifyre_service,
|
||||
commands::install_proxifyre,
|
||||
commands::uninstall_proxifyre,
|
||||
commands::start_singbox_service,
|
||||
commands::stop_singbox_service,
|
||||
commands::install_singbox,
|
||||
commands::uninstall_singbox
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("не удалось запустить клиент ProxyWarden");
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
|
||||
pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "ProxyWardenSingBox";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = r"C:\Program Files\ProxyWarden\sing-box";
|
||||
|
||||
#[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 LocalSingBoxConfig {
|
||||
#[serde(default)]
|
||||
pub subscription_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_tag: Option<String>,
|
||||
#[serde(default = "default_local_singbox_listen_host")]
|
||||
pub listen_host: String,
|
||||
#[serde(default = "default_local_singbox_listen_port")]
|
||||
pub listen_port: u16,
|
||||
#[serde(default = "default_local_singbox_service_name")]
|
||||
pub service_name: String,
|
||||
#[serde(default = "default_local_singbox_install_root")]
|
||||
pub install_root: String,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
impl LocalSingBoxConfig {
|
||||
pub fn subscription_display_url(&self) -> Option<String> {
|
||||
self.subscription_url
|
||||
.as_deref()
|
||||
.map(redact_subscription_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalSingBoxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
subscription_url: None,
|
||||
selected_server_tag: None,
|
||||
listen_host: default_local_singbox_listen_host(),
|
||||
listen_port: default_local_singbox_listen_port(),
|
||||
service_name: default_local_singbox_service_name(),
|
||||
install_root: default_local_singbox_install_root(),
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SubscriptionCache {
|
||||
pub config: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub servers: Vec<SubscriptionServer>,
|
||||
#[serde(default)]
|
||||
pub user_info: serde_json::Map<String, serde_json::Value>,
|
||||
pub fetched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SubscriptionServer {
|
||||
pub tag: String,
|
||||
#[serde(rename = "type")]
|
||||
pub server_type: String,
|
||||
pub server: String,
|
||||
pub server_port: u16,
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
|
||||
fn default_local_singbox_listen_host() -> String {
|
||||
DEFAULT_LOCAL_SINGBOX_LISTEN_HOST.to_string()
|
||||
}
|
||||
|
||||
fn default_local_singbox_listen_port() -> u16 {
|
||||
DEFAULT_LOCAL_SINGBOX_LISTEN_PORT
|
||||
}
|
||||
|
||||
fn default_local_singbox_service_name() -> String {
|
||||
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()
|
||||
}
|
||||
|
||||
fn default_local_singbox_install_root() -> String {
|
||||
DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string()
|
||||
}
|
||||
|
||||
pub fn redact_subscription_url(raw_url: &str) -> String {
|
||||
let trimmed = raw_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
match trimmed.split_once("://") {
|
||||
Some((scheme, rest)) => {
|
||||
let host = rest
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("subscription");
|
||||
format!("{scheme}://{host}/...")
|
||||
}
|
||||
None => {
|
||||
let visible = trimmed.chars().take(18).collect::<String>();
|
||||
if trimmed.chars().count() <= 18 {
|
||||
"***".to_string()
|
||||
} else {
|
||||
format!("{visible}...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use crate::component_detection::DetectedSingBox;
|
||||
use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
pub const SINGBOX_RELEASE_API_URL: &str =
|
||||
"https://api.github.com/repos/SagerNet/sing-box/releases/latest";
|
||||
pub const WINSW_RELEASE_API_URL: &str = "https://api.github.com/repos/winsw/winsw/releases/latest";
|
||||
pub const WINSW_WRAPPER_FILE: &str = "ProxyWardenSingBox.exe";
|
||||
pub const SINGBOX_BINARY_FILE: &str = "sing-box.exe";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SingBoxServiceAction {
|
||||
Start,
|
||||
Stop,
|
||||
}
|
||||
|
||||
impl SingBoxServiceAction {
|
||||
pub fn action_name(self) -> &'static str {
|
||||
match self {
|
||||
SingBoxServiceAction::Start => "start",
|
||||
SingBoxServiceAction::Stop => "stop",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
SingBoxServiceAction::Start => "запустить",
|
||||
SingBoxServiceAction::Stop => "остановить",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SingBoxSetupStatus {
|
||||
pub ready: bool,
|
||||
pub missing_count: usize,
|
||||
pub items: Vec<SingBoxSetupItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SingBoxSetupItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub installed: bool,
|
||||
pub version: Option<String>,
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceCommandOutput {
|
||||
pub success: bool,
|
||||
pub code: String,
|
||||
pub service_name: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub process_id: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBoxSetupStatus {
|
||||
let install_root = detected
|
||||
.map(|singbox| singbox.install_dir.display().to_string())
|
||||
.unwrap_or_else(|| DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string());
|
||||
let binary_item = match detected {
|
||||
Some(singbox) if singbox.binary_exists => SingBoxSetupItem {
|
||||
id: "sing-box-binary".to_string(),
|
||||
name: "sing-box".to_string(),
|
||||
installed: true,
|
||||
version: Some("binary найден".to_string()),
|
||||
details: singbox.executable_path.display().to_string(),
|
||||
},
|
||||
_ => SingBoxSetupItem {
|
||||
id: "sing-box-binary".to_string(),
|
||||
name: "sing-box".to_string(),
|
||||
installed: false,
|
||||
version: None,
|
||||
details: format!(
|
||||
"Будет скачан из GitHub releases SagerNet/sing-box и установлен в {install_root}."
|
||||
),
|
||||
},
|
||||
};
|
||||
let wrapper_item = match detected {
|
||||
Some(singbox) if singbox.wrapper_exists => SingBoxSetupItem {
|
||||
id: "winsw-wrapper".to_string(),
|
||||
name: "WinSW service wrapper".to_string(),
|
||||
installed: true,
|
||||
version: Some("wrapper найден".to_string()),
|
||||
details: singbox.wrapper_path.display().to_string(),
|
||||
},
|
||||
_ => SingBoxSetupItem {
|
||||
id: "winsw-wrapper".to_string(),
|
||||
name: "WinSW service wrapper".to_string(),
|
||||
installed: false,
|
||||
version: None,
|
||||
details: format!(
|
||||
"Будет скачан из GitHub releases winsw/winsw как {WINSW_WRAPPER_FILE}."
|
||||
),
|
||||
},
|
||||
};
|
||||
let service_item = match detected {
|
||||
Some(singbox) if singbox.running => SingBoxSetupItem {
|
||||
id: "windows-service".to_string(),
|
||||
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||
installed: true,
|
||||
version: Some("служба запущена".to_string()),
|
||||
details: format!("Служба {}", singbox.service_name),
|
||||
},
|
||||
Some(singbox) => SingBoxSetupItem {
|
||||
id: "windows-service".to_string(),
|
||||
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||
installed: true,
|
||||
version: Some("служба остановлена".to_string()),
|
||||
details: format!("Служба {}", singbox.service_name),
|
||||
},
|
||||
None => SingBoxSetupItem {
|
||||
id: "windows-service".to_string(),
|
||||
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
|
||||
installed: false,
|
||||
version: None,
|
||||
details: "Будет создана Windows-служба Local sing-box.".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let items = vec![binary_item, wrapper_item, service_item];
|
||||
let missing_count = items.iter().filter(|item| !item.installed).count();
|
||||
|
||||
SingBoxSetupStatus {
|
||||
ready: missing_count == 0,
|
||||
missing_count,
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_service_command_output(stdout: &[u8]) -> Option<ServiceCommandOutput> {
|
||||
let stdout = String::from_utf8_lossy(stdout);
|
||||
let payload = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.find(|line| line.starts_with('{') && line.ends_with('}'))?;
|
||||
|
||||
serde_json::from_str(payload).ok()
|
||||
}
|
||||
|
||||
pub fn ensure_safe_singbox_install_dir(path: &Path) -> Result<(), String> {
|
||||
let normalized = path
|
||||
.display()
|
||||
.to_string()
|
||||
.replace('/', "\\")
|
||||
.to_ascii_lowercase();
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if file_name == "sing-box"
|
||||
&& (normalized.contains("\\proxywarden\\") || normalized.contains("\\proxywarden\\"))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Отказываюсь рекурсивно удалять Local sing-box с небезопасным путем: {}",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn service_control_script(
|
||||
action: SingBoxServiceAction,
|
||||
service_name: &str,
|
||||
config_source: Option<&Path>,
|
||||
config_target: Option<&Path>,
|
||||
) -> String {
|
||||
let action_name = action.action_name();
|
||||
let escaped_service_name = escape_powershell_single(service_name);
|
||||
let escaped_config_source = config_source
|
||||
.map(|path| escape_powershell_single(&path.display().to_string()))
|
||||
.unwrap_or_default();
|
||||
let escaped_config_target = config_target
|
||||
.map(|path| escape_powershell_single(&path.display().to_string()))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$serviceName = '{escaped_service_name}'
|
||||
$action = '{action_name}'
|
||||
$configSource = '{escaped_config_source}'
|
||||
$configTarget = '{escaped_config_target}'
|
||||
|
||||
function Get-ServiceProcessId([string]$name) {{
|
||||
$escapedName = $name.Replace("'", "''")
|
||||
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $record) {{ return 0 }}
|
||||
return [int]$record.ProcessId
|
||||
}}
|
||||
|
||||
function Get-ServiceStatus([string]$name) {{
|
||||
$current = Get-Service -Name $name -ErrorAction SilentlyContinue
|
||||
if ($null -eq $current) {{ return $null }}
|
||||
return $current.Status.ToString()
|
||||
}}
|
||||
|
||||
function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{
|
||||
[PSCustomObject]@{{
|
||||
success = $success
|
||||
code = $code
|
||||
serviceName = $serviceName
|
||||
status = $status
|
||||
processId = $processId
|
||||
}} | ConvertTo-Json -Compress
|
||||
exit 0
|
||||
}}
|
||||
|
||||
function Sync-ServiceConfig {{
|
||||
if ($action -ne 'start' -or [string]::IsNullOrWhiteSpace($configSource)) {{ return }}
|
||||
if (-not (Test-Path -LiteralPath $configSource)) {{
|
||||
Write-ServiceResult $false 'config_source_missing' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
if ([string]::IsNullOrWhiteSpace($configTarget)) {{ return }}
|
||||
|
||||
try {{
|
||||
Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop
|
||||
}} catch {{
|
||||
Write-ServiceResult $false 'config_sync_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
}}
|
||||
|
||||
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||
if ($null -eq $service) {{
|
||||
Write-ServiceResult $false 'service_not_found' $null 0
|
||||
}}
|
||||
|
||||
if ($action -eq 'start') {{
|
||||
Sync-ServiceConfig
|
||||
|
||||
if ($service.Status -eq 'Running') {{
|
||||
Write-ServiceResult $true 'already_running' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
try {{
|
||||
Start-Service -Name $serviceName -ErrorAction Stop
|
||||
$service = Get-Service -Name $serviceName -ErrorAction Stop
|
||||
$service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15))
|
||||
}} catch {{
|
||||
Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
if ($service.Status -eq 'Stopped') {{
|
||||
Write-ServiceResult $true 'already_stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
try {{
|
||||
Stop-Service -Name $serviceName -Force -ErrorAction Stop
|
||||
$service = Get-Service -Name $serviceName -ErrorAction Stop
|
||||
$service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15))
|
||||
}} catch {{
|
||||
Write-ServiceResult $false 'stop_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
|
||||
}}
|
||||
|
||||
Write-ServiceResult ($service.Status -eq 'Stopped') 'stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_powershell_single(value: &str) -> String {
|
||||
value.replace('\'', "''")
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
|
||||
use crate::models::{
|
||||
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, 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\ProxyWarden")
|
||||
}
|
||||
|
||||
#[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 local_singbox_file: PathBuf,
|
||||
pub singbox_subscription_cache_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"),
|
||||
local_singbox_file: config_dir.join("local-singbox.json"),
|
||||
singbox_subscription_cache_file: state_dir.join("singbox-subscription-cache.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_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
|
||||
self.read_json_or_default(&self.paths.local_singbox_file)
|
||||
}
|
||||
|
||||
pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> {
|
||||
self.write_json(&self.paths.local_singbox_file, config)
|
||||
}
|
||||
|
||||
pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> {
|
||||
self.read_optional_json(&self.paths.singbox_subscription_cache_file)
|
||||
}
|
||||
|
||||
pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> {
|
||||
self.write_json(&self.paths.singbox_subscription_cache_file, cache)
|
||||
}
|
||||
|
||||
pub fn remove_singbox_subscription_cache(&self) -> io::Result<()> {
|
||||
match fs::remove_file(&self.paths.singbox_subscription_cache_file) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn read_optional_json<T>(&self, path: &Path) -> io::Result<Option<T>>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => Ok(serde_json::from_str(&contents).ok()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use crate::models::{SubscriptionCache, SubscriptionServer};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
|
||||
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl SubscriptionError {
|
||||
fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SubscriptionError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SubscriptionError {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ParsedSubscription {
|
||||
pub config: Value,
|
||||
pub servers: Vec<SubscriptionServer>,
|
||||
}
|
||||
|
||||
pub fn parse_subscription_body(body: &str) -> Result<ParsedSubscription, SubscriptionError> {
|
||||
let config = match serde_json::from_str::<Value>(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => parse_link_subscription(body)?,
|
||||
};
|
||||
let servers = servers_from_config(&config)?;
|
||||
|
||||
Ok(ParsedSubscription { config, servers })
|
||||
}
|
||||
|
||||
pub fn parse_user_info(header_value: Option<&str>) -> Map<String, Value> {
|
||||
let mut result = Map::new();
|
||||
let Some(header_value) = header_value else {
|
||||
return result;
|
||||
};
|
||||
|
||||
for part in header_value.split(';') {
|
||||
let Some((key, value)) = part.trim().split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(parsed) = value.trim().parse::<i64>() {
|
||||
result.insert(key.to_string(), Value::Number(parsed.into()));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
let parsed_url =
|
||||
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
||||
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription URL must use http or https",
|
||||
));
|
||||
}
|
||||
|
||||
let response = reqwest::blocking::Client::new()
|
||||
.get(parsed_url)
|
||||
.header("user-agent", "singbox")
|
||||
.header("x-device-os", std::env::consts::OS)
|
||||
.header("x-device-model", "proxywarden")
|
||||
.send()
|
||||
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(SubscriptionError::new(format!(
|
||||
"Subscription request failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
let user_info = parse_user_info(
|
||||
response
|
||||
.headers()
|
||||
.get("subscription-userinfo")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
);
|
||||
let body = response.text().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription body read failed: {error}"))
|
||||
})?;
|
||||
let parsed = parse_subscription_body(&body)?;
|
||||
|
||||
Ok(SubscriptionCache {
|
||||
config: parsed.config,
|
||||
servers: parsed.servers,
|
||||
user_info,
|
||||
fetched_at: now_timestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
|
||||
let decoded = maybe_decode_base64(body);
|
||||
let links = decoded
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("vless://"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if links.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription does not contain JSON config or VLESS links",
|
||||
));
|
||||
}
|
||||
|
||||
let outbounds = links
|
||||
.into_iter()
|
||||
.map(parse_vless_url)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json!({ "outbounds": outbounds }))
|
||||
}
|
||||
|
||||
fn parse_vless_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
||||
if !raw_url.starts_with("vless://") {
|
||||
return Err(SubscriptionError::new("VLESS URL must start with vless://"));
|
||||
}
|
||||
|
||||
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?;
|
||||
let tag = parsed.fragment().unwrap_or("vless-out").to_string();
|
||||
let uuid = parsed.username().trim().to_string();
|
||||
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
||||
let server_port = parsed.port_or_known_default().unwrap_or(443);
|
||||
let public_key = query_value(&parsed, "pbk").unwrap_or_default();
|
||||
let short_id = query_value(&parsed, "sid").unwrap_or_default();
|
||||
let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone());
|
||||
let fingerprint = query_value(&parsed, "fp").unwrap_or_else(|| "chrome".to_string());
|
||||
let flow = query_value(&parsed, "flow").unwrap_or_default();
|
||||
|
||||
if uuid.is_empty() || server.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"VLESS URL misses uuid, host or port",
|
||||
));
|
||||
}
|
||||
|
||||
if public_key.is_empty() || short_id.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"VLESS REALITY parameters pbk and sid are required",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"type": "vless",
|
||||
"tag": tag,
|
||||
"server": server,
|
||||
"server_port": server_port,
|
||||
"uuid": uuid,
|
||||
"flow": flow,
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": server_name,
|
||||
"utls": {
|
||||
"enabled": true,
|
||||
"fingerprint": fingerprint
|
||||
},
|
||||
"reality": {
|
||||
"enabled": true,
|
||||
"public_key": public_key,
|
||||
"short_id": short_id
|
||||
}
|
||||
},
|
||||
"packet_encoding": "xudp"
|
||||
}))
|
||||
}
|
||||
|
||||
fn servers_from_config(config: &Value) -> Result<Vec<SubscriptionServer>, SubscriptionError> {
|
||||
let servers = config
|
||||
.get("outbounds")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(server_from_outbound)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if servers.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"No supported proxy outbounds found in subscription",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
|
||||
let server_type = outbound.get("type")?.as_str()?.to_string();
|
||||
if !SUPPORTED_PROXY_TYPES.contains(&server_type.as_str()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let server = outbound
|
||||
.get("server")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let server_port = outbound
|
||||
.get("server_port")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.unwrap_or(443);
|
||||
let tag = outbound
|
||||
.get("tag")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("{server_type}-{server}"));
|
||||
|
||||
Some(SubscriptionServer {
|
||||
tag,
|
||||
server_type,
|
||||
server,
|
||||
server_port,
|
||||
})
|
||||
}
|
||||
|
||||
fn maybe_decode_base64(content: &str) -> String {
|
||||
let compact = content.split_whitespace().collect::<String>();
|
||||
if compact.is_empty()
|
||||
|| !compact
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '-' | '_'))
|
||||
{
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
for engine in [general_purpose::STANDARD, general_purpose::URL_SAFE] {
|
||||
if let Ok(decoded) = engine.decode(compact.as_bytes()) {
|
||||
if let Ok(decoded) = String::from_utf8(decoded) {
|
||||
if decoded.contains("vless://") || decoded.contains('{') {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content.to_string()
|
||||
}
|
||||
|
||||
fn query_value(url: &Url, key: &str) -> Option<String> {
|
||||
url.query_pairs()
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.into_owned())
|
||||
}
|
||||
|
||||
fn now_timestamp() -> String {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("unix:{seconds}")
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user