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, inbound_tag: impl Into, vpn_outbound_tag: impl Into, ) -> Self { Self { log_level: log_level.into(), inbound_tag: inbound_tag.into(), vpn_outbound_tag: vpn_outbound_tag.into(), } } pub fn generate_config( &self, request: SingBoxGenerationRequest<'_>, checker: &C, ) -> Result 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, } #[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) -> 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; } #[derive(Debug, Clone, Copy, Default)] pub struct SingBoxCommandChecker; impl SingBoxConfigChecker for SingBoxCommandChecker { fn check_config( &self, binary_path: &Path, config_json: &str, ) -> Result { 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 { 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) }