Refactor proxy routing and session management

This commit is contained in:
2026-07-08 00:09:38 +03:00
parent c5bdb10445
commit b45dd2ae05
26 changed files with 5193 additions and 307 deletions

View File

@@ -1,7 +1,6 @@
use crate::models::{
ComponentId, ComponentState, ComponentStatus, ProxyProtocol, Target, TargetKind,
};
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
env, fs,
path::Path,
@@ -12,25 +11,29 @@ use std::{
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_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,
outbound_tag: String,
vpn_outbound_tag: String,
}
impl SingBoxAdapter {
pub fn new(
log_level: impl Into<String>,
inbound_tag: impl Into<String>,
outbound_tag: impl Into<String>,
vpn_outbound_tag: impl Into<String>,
) -> Self {
Self {
log_level: log_level.into(),
inbound_tag: inbound_tag.into(),
outbound_tag: outbound_tag.into(),
vpn_outbound_tag: vpn_outbound_tag.into(),
}
}
@@ -42,32 +45,52 @@ impl SingBoxAdapter {
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,
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: 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| {
"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}"),
@@ -82,9 +105,9 @@ impl SingBoxAdapter {
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,
selected_server_tag: selected_server_tag.to_string(),
listen: request.config.listen_host.clone(),
listen_port: request.config.listen_port,
check,
})
}
@@ -92,30 +115,26 @@ impl SingBoxAdapter {
impl Default for SingBoxAdapter {
fn default() -> Self {
Self::new(
"info",
DEFAULT_MIXED_INBOUND_TAG,
DEFAULT_DIRECT_OUTBOUND_TAG,
)
Self::new("info", DEFAULT_MIXED_INBOUND_TAG, DEFAULT_VPN_OUTBOUND_TAG)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SingBoxGenerationRequest<'a> {
pub targets: &'a [Target],
pub components: &'a [ComponentStatus],
pub config: &'a LocalSingBoxConfig,
pub subscription_cache: &'a SubscriptionCache,
pub binary_path: Option<&'a Path>,
}
impl<'a> SingBoxGenerationRequest<'a> {
pub fn new(
targets: &'a [Target],
components: &'a [ComponentStatus],
config: &'a LocalSingBoxConfig,
subscription_cache: &'a SubscriptionCache,
binary_path: Option<&'a Path>,
) -> Self {
Self {
targets,
components,
config,
subscription_cache,
binary_path,
}
}
@@ -126,7 +145,7 @@ pub struct SingBoxGeneratedConfig {
pub adapter_id: String,
pub output_file_name: String,
pub contents: String,
pub local_target_id: String,
pub selected_server_tag: String,
pub listen: String,
pub listen_port: u16,
pub check: Option<SingBoxCheckResult>,
@@ -156,10 +175,9 @@ impl SingBoxConfigError {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SingBoxConfigErrorKind {
MissingLocalTarget,
MissingRequiredComponent,
RequiredComponentNotRunning,
UnsupportedTarget,
MissingSelectedServer,
MissingSelectedOutbound,
UnsupportedSelectedOutbound,
Serialization,
CheckFailed,
}
@@ -237,114 +255,67 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
}
}
#[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
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(|target| {
target.kind == TargetKind::Local
&& target.requires_component.as_ref() == Some(&ComponentId::Singbox)
.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::MissingLocalTarget,
"Локальная цель, требующая sing-box, не настроена",
SingBoxConfigErrorKind::MissingSelectedOutbound,
format!("Outbound не найден: {selected_server_tag}"),
)
})
}
})?;
let outbound_type = outbound
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
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)
{
if !SUPPORTED_PROXY_TYPES.contains(&outbound_type) {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedTarget,
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
format!(
"Цель '{}' должна быть локальной SOCKS5-целью, требующей sing-box",
target.id
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'"
),
));
}
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
),
));
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(())
}
fn component_is_running(status: &ComponentStatus) -> bool {
status.installed && status.running && status.state == ComponentState::Running
Ok(outbound)
}
fn now_millis() -> u128 {

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
use crate::models::{ComponentId, ComponentState, ComponentStatus};
use crate::models::{
ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT,
DEFAULT_LOCAL_SINGBOX_SERVICE_NAME,
};
use serde::Deserialize;
use std::{
env,
@@ -22,6 +25,17 @@ pub struct DetectedProxyfier {
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,
@@ -101,6 +115,29 @@ pub fn proxyfier_component_from_detection(detected: Option<&DetectedProxyfier>)
}
}
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
@@ -154,6 +191,58 @@ fn missing_proxyfier_component() -> ComponentStatus {
}
}
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,
@@ -272,6 +361,68 @@ fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) ->
dirs
}
fn singbox_candidates(host: &impl ProxyfierDetectionHost) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(path) = host.env_var("VPN_PROXY_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\VpnProxy\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("VpnProxy").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("VpnProxySingBox.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",

View File

@@ -4,12 +4,15 @@ 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)]
@@ -22,6 +25,11 @@ 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())
@@ -35,14 +43,27 @@ fn main() {
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::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::uninstall_proxifyre,
commands::start_singbox_service,
commands::stop_singbox_service,
commands::install_singbox,
commands::uninstall_singbox
])
.run(tauri::generate_context!())
.expect("не удалось запустить клиент VPN Proxy для Windows");

View File

@@ -1,5 +1,10 @@
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 = "VpnProxySingBox";
pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = r"C:\Program Files\VpnProxy\sing-box";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Protocol {
@@ -128,6 +133,65 @@ pub struct ComponentStatus {
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,
@@ -165,3 +229,45 @@ fn default_target_kind() -> 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}...")
}
}
}
}

View File

@@ -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 = "VpnProxySingBox.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("\\vpnproxy\\") || normalized.contains("\\vpn-proxy\\"))
{
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('\'', "''")
}

View File

@@ -1,5 +1,7 @@
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
use crate::models::{ActivityEntry, ComponentStatus, Profile, Target};
use crate::models::{
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
};
use serde::{de::DeserializeOwned, Serialize};
use std::fs;
use std::io::{self, ErrorKind};
@@ -18,6 +20,8 @@ pub struct StoragePaths {
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,
}
@@ -33,6 +37,8 @@ impl StoragePaths {
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,
@@ -100,6 +106,30 @@ impl JsonStorage {
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))
@@ -139,6 +169,17 @@ impl JsonStorage {
.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 {

View File

@@ -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", "vpn-proxy-windows-client")
.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}")
}