Files
ProxyWarden/src-tauri/src/adapters/singbox.rs
T
dokril efda8eb98f
CI / Windows baseline (push) Canceled after 0s
Release v2.0.0
2026-09-10 20:59:52 +03:00

342 lines
11 KiB
Rust

use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer};
use crate::process::run_fixed_process;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{env, fs, path::Path, time::Duration};
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 + ?Sized,
{
let selected_server = if let Some(id) = request.config.selected_server_id.as_deref() {
request
.subscription_cache
.servers
.iter()
.find(|server| server.id == id)
} else {
let mut matches = request.subscription_cache.servers.iter().filter(|server| {
Some(server.tag.as_str()) == request.config.selected_server_tag.as_deref()
});
matches.next().filter(|_| matches.next().is_none())
}
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedServer,
"Сервер Local sing-box не выбран или отсутствует в текущей подписке",
)
})?;
let vpn_outbound = selected_outbound(
&request.subscription_cache.config,
selected_server,
&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.clone(),
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",
uuid::Uuid::new_v4().hyphenated()
));
struct TemporaryConfig(std::path::PathBuf);
impl Drop for TemporaryConfig {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
let _temporary = TemporaryConfig(config_path.clone());
crate::safe_fs::write_restricted_atomic(&config_path, config_json.as_bytes()).map_err(
|_| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
"Не удалось безопасно создать временный конфиг sing-box",
)
},
)?;
// Checker output can contain credentials from the outbound. The bounded
// native process runner discards both streams instead of exposing them.
let status = run_fixed_process(
binary_path,
&[
"check".into(),
"-c".into(),
config_path.as_os_str().to_owned(),
],
Duration::from_secs(30),
)
.map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
if error.kind() == std::io::ErrorKind::TimedOut {
"Проверка sing-box превысила 30 секунд"
} else {
"Не удалось выполнить проверку sing-box"
},
)
})?;
if !status.success() {
return Err(SingBoxConfigError::new(SingBoxConfigErrorKind::CheckFailed,
"sing-box отклонил конфигурацию выбранного сервера. Обновите подписку или выберите другой сервер."));
}
Ok(SingBoxCheckResult {
checked: true,
success: true,
message: "Проверка sing-box прошла успешно".to_string(),
})
}
}
fn selected_outbound(
subscription_config: &Value,
selected_server: &SubscriptionServer,
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 = if selected_server.id.starts_with("pw-") {
outbounds.iter().find(|outbound| {
crate::subscription::outbound_server_id(outbound) == selected_server.id
})
} else {
// Legacy endpoint IDs are readable only when they identify exactly one outbound.
let mut matches = outbounds.iter().filter(|outbound| {
outbound
.get("tag")
.and_then(Value::as_str)
.is_some_and(|tag| {
crate::models::decode_percent_encoded_utf8(tag).trim() == selected_server.tag
})
&& outbound.get("type").and_then(Value::as_str)
== Some(selected_server.server_type.as_str())
&& outbound
.get("server")
.and_then(Value::as_str)
.is_some_and(|host| host.eq_ignore_ascii_case(&selected_server.server))
&& outbound.get("server_port").and_then(Value::as_u64)
== Some(u64::from(selected_server.server_port))
});
matches.next().filter(|_| matches.next().is_none())
}
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedOutbound,
format!(
"Outbound не найден: {} ({}:{})",
selected_server.tag, selected_server.server, selected_server.server_port
),
)
})?;
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 '{}' имеет неподдерживаемый тип '{outbound_type}'",
selected_server.tag
),
));
}
let mut outbound = outbound.clone();
let object = outbound.as_object_mut().ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
format!(
"Outbound '{}' должен быть JSON-объектом",
selected_server.tag
),
)
})?;
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)
}