Expand README with architecture and setup details

This commit is contained in:
2026-07-08 09:58:26 +03:00
parent 81be7e186c
commit c5120669d2
109 changed files with 22311 additions and 0 deletions
+338
View File
@@ -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}"),
}
}