Expand README with architecture and setup details
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
use crate::models::{
|
||||
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
|
||||
pub const PROXIFYRE_OUTPUT_FILE: &str = "proxifyre-app-config.json";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProxiFyreAdapter {
|
||||
log_level: String,
|
||||
bypass_lan: bool,
|
||||
}
|
||||
|
||||
impl ProxiFyreAdapter {
|
||||
pub fn new(log_level: impl Into<String>, bypass_lan: bool) -> Self {
|
||||
Self {
|
||||
log_level: log_level.into(),
|
||||
bypass_lan,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_proxifyre_config(
|
||||
&self,
|
||||
request: ProxyRouterRequest<'_>,
|
||||
) -> Result<ProxiFyreConfig, ProxyRouterError> {
|
||||
let mut proxies = Vec::new();
|
||||
|
||||
for profile in request.profiles.iter().filter(|profile| profile.enabled) {
|
||||
let target = find_target(profile, request.targets)?;
|
||||
ensure_target_supported(profile, target, request.components)?;
|
||||
|
||||
let app_names = app_names_for_profile(profile);
|
||||
if app_names.is_empty() {
|
||||
return Err(ProxyRouterError::new(
|
||||
ProxyRouterErrorKind::EmptyProfileItems,
|
||||
format!(
|
||||
"В профиле '{}' нет приложений для маршрутизации",
|
||||
profile.id
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
proxies.push(ProxiFyreProxy {
|
||||
app_names,
|
||||
socks5_proxy_endpoint: format!("{}:{}", target.host, target.port),
|
||||
supported_protocols: protocols_for_profile(profile),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ProxiFyreConfig {
|
||||
log_level: self.log_level.clone(),
|
||||
bypass_lan: self.bypass_lan,
|
||||
proxies,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProxiFyreAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new("Info", true)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyRouterAdapter for ProxiFyreAdapter {
|
||||
fn id(&self) -> &'static str {
|
||||
PROXIFYRE_ADAPTER_ID
|
||||
}
|
||||
|
||||
fn output_file_name(&self) -> &'static str {
|
||||
PROXIFYRE_OUTPUT_FILE
|
||||
}
|
||||
|
||||
fn generate_config(
|
||||
&self,
|
||||
request: ProxyRouterRequest<'_>,
|
||||
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError> {
|
||||
let config = self.generate_proxifyre_config(request)?;
|
||||
let enabled_profiles = config.proxies.len();
|
||||
let routed_apps = config
|
||||
.proxies
|
||||
.iter()
|
||||
.map(|proxy| proxy.app_names.len())
|
||||
.sum();
|
||||
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
|
||||
ProxyRouterError::new(
|
||||
ProxyRouterErrorKind::Serialization,
|
||||
format!("Не удалось сериализовать конфиг ProxiFyre: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(ProxyRouterGeneratedConfig {
|
||||
adapter_id: self.id().to_string(),
|
||||
output_file_name: self.output_file_name().to_string(),
|
||||
contents,
|
||||
enabled_profiles,
|
||||
routed_apps,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProxiFyreConfig {
|
||||
#[serde(rename = "logLevel")]
|
||||
pub log_level: String,
|
||||
#[serde(rename = "bypassLan")]
|
||||
pub bypass_lan: bool,
|
||||
pub proxies: Vec<ProxiFyreProxy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProxiFyreProxy {
|
||||
#[serde(rename = "appNames")]
|
||||
pub app_names: Vec<String>,
|
||||
#[serde(rename = "socks5ProxyEndpoint")]
|
||||
pub socks5_proxy_endpoint: String,
|
||||
#[serde(rename = "supportedProtocols")]
|
||||
pub supported_protocols: Vec<String>,
|
||||
}
|
||||
|
||||
fn find_target<'a>(
|
||||
profile: &Profile,
|
||||
targets: &'a [Target],
|
||||
) -> Result<&'a Target, ProxyRouterError> {
|
||||
targets
|
||||
.iter()
|
||||
.find(|target| target.id == profile.target_id)
|
||||
.ok_or_else(|| {
|
||||
ProxyRouterError::new(
|
||||
ProxyRouterErrorKind::MissingTarget,
|
||||
format!(
|
||||
"Профиль '{}' ссылается на отсутствующую цель '{}'",
|
||||
profile.id, profile.target_id
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_target_supported(
|
||||
profile: &Profile,
|
||||
target: &Target,
|
||||
components: &[ComponentStatus],
|
||||
) -> Result<(), ProxyRouterError> {
|
||||
if target.protocol != ProxyProtocol::Socks5 {
|
||||
return Err(ProxyRouterError::new(
|
||||
ProxyRouterErrorKind::UnsupportedTargetProtocol,
|
||||
format!(
|
||||
"Цель '{}' использует HTTP, но ProxiFyre требует SOCKS5",
|
||||
target.id
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(required_component) = &target.requires_component {
|
||||
let Some(status) = components
|
||||
.iter()
|
||||
.find(|component| &component.id == required_component)
|
||||
else {
|
||||
return Err(ProxyRouterError::new(
|
||||
ProxyRouterErrorKind::MissingRequiredComponent,
|
||||
format!(
|
||||
"Цель '{}' профиля '{}' требует отсутствующий компонент '{}'",
|
||||
target.id,
|
||||
profile.id,
|
||||
component_id_label(required_component)
|
||||
),
|
||||
));
|
||||
};
|
||||
|
||||
if !component_is_running(status) {
|
||||
return Err(ProxyRouterError::new(
|
||||
ProxyRouterErrorKind::RequiredComponentNotRunning,
|
||||
format!(
|
||||
"Цель '{}' профиля '{}' требует запущенный компонент '{}'",
|
||||
target.id,
|
||||
profile.id,
|
||||
component_id_label(required_component)
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn component_is_running(status: &ComponentStatus) -> bool {
|
||||
status.installed && status.running && status.state == ComponentState::Running
|
||||
}
|
||||
|
||||
fn app_names_for_profile(profile: &Profile) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
|
||||
for item in &profile.items {
|
||||
let value = item.value.trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let app_name = match item.item_type {
|
||||
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
|
||||
};
|
||||
|
||||
if !names.iter().any(|existing| existing == app_name) {
|
||||
names.push(app_name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
names
|
||||
}
|
||||
|
||||
fn protocols_for_profile(profile: &Profile) -> Vec<String> {
|
||||
let mut protocols = Vec::new();
|
||||
|
||||
for protocol in &profile.protocols {
|
||||
let value = match protocol {
|
||||
Protocol::Tcp => "TCP",
|
||||
Protocol::Udp => "UDP",
|
||||
};
|
||||
|
||||
if !protocols.iter().any(|existing| existing == value) {
|
||||
protocols.push(value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
protocols
|
||||
}
|
||||
|
||||
fn component_id_label(component_id: &ComponentId) -> &'static str {
|
||||
match component_id {
|
||||
ComponentId::ControlApp => "control-app",
|
||||
ComponentId::Proxyfier => "proxyfier",
|
||||
ComponentId::Singbox => "singbox",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use crate::models::{ComponentStatus, Profile, Target};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProxyRouterRequest<'a> {
|
||||
pub profiles: &'a [Profile],
|
||||
pub targets: &'a [Target],
|
||||
pub components: &'a [ComponentStatus],
|
||||
}
|
||||
|
||||
impl<'a> ProxyRouterRequest<'a> {
|
||||
pub fn new(
|
||||
profiles: &'a [Profile],
|
||||
targets: &'a [Target],
|
||||
components: &'a [ComponentStatus],
|
||||
) -> Self {
|
||||
Self {
|
||||
profiles,
|
||||
targets,
|
||||
components,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProxyRouterGeneratedConfig {
|
||||
pub adapter_id: String,
|
||||
pub output_file_name: String,
|
||||
pub contents: String,
|
||||
pub enabled_profiles: usize,
|
||||
pub routed_apps: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProxyRouterError {
|
||||
pub kind: ProxyRouterErrorKind,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ProxyRouterError {
|
||||
pub fn new(kind: ProxyRouterErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProxyRouterErrorKind {
|
||||
EmptyProfileItems,
|
||||
MissingTarget,
|
||||
MissingRequiredComponent,
|
||||
RequiredComponentNotRunning,
|
||||
UnsupportedTargetProtocol,
|
||||
Serialization,
|
||||
}
|
||||
|
||||
pub trait ProxyRouterAdapter {
|
||||
fn id(&self) -> &'static str;
|
||||
|
||||
fn output_file_name(&self) -> &'static str;
|
||||
|
||||
fn generate_config(
|
||||
&self,
|
||||
request: ProxyRouterRequest<'_>,
|
||||
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError>;
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user