342 lines
11 KiB
Rust
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)
|
|
}
|