mirror of
https://github.com/perfect-panel/ppanel-web.git
synced 2026-08-30 22:41:56 -04:00
Initial
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
//! Adapter module — ports Go `server/adapter` package to Rust.
|
||||
//!
|
||||
//! Provides:
|
||||
//! - [`Proxy`] – per-proxy configuration struct (mirrors Go `Proxy`)
|
||||
//! - [`User`] – subscriber info (mirrors Go `User`)
|
||||
//! - [`ClientConfig`] – template-rendering config
|
||||
//! - [`Client`] – renders a Go-template with a sprig subset
|
||||
//! - [`Adapter`] – converts `Node`+`Server` entities into `Vec<Proxy>`
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use chrono::TimeZone as _;
|
||||
|
||||
use crate::model::entity::node::{Node, Protocol as NodeProtocol, Server};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Proxy
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Full proxy configuration, mirroring the Go `Proxy` struct in `adapter/client.go`.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct Proxy {
|
||||
pub sort: i32,
|
||||
pub name: String,
|
||||
pub server: String,
|
||||
pub port: i32,
|
||||
#[serde(rename = "Type")]
|
||||
pub type_: String,
|
||||
pub tags: Vec<String>,
|
||||
|
||||
// Security
|
||||
pub security: Option<String>,
|
||||
pub sni: Option<String>,
|
||||
pub allow_insecure: bool,
|
||||
pub fingerprint: Option<String>,
|
||||
pub reality_server_addr: Option<String>,
|
||||
pub reality_server_port: i32,
|
||||
pub reality_private_key: Option<String>,
|
||||
pub reality_public_key: Option<String>,
|
||||
pub reality_short_id: Option<String>,
|
||||
|
||||
// Transport
|
||||
pub transport: Option<String>,
|
||||
pub host: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub service_name: Option<String>,
|
||||
|
||||
// Shadowsocks
|
||||
pub method: Option<String>,
|
||||
pub server_key: Option<String>,
|
||||
pub uot: bool,
|
||||
pub uot_version: i32,
|
||||
|
||||
// Vmess/Vless/Trojan
|
||||
pub flow: Option<String>,
|
||||
|
||||
// Hysteria2
|
||||
pub hop_ports: Option<String>,
|
||||
pub hop_interval: i32,
|
||||
pub obfs_password: Option<String>,
|
||||
pub up_mbps: i32,
|
||||
pub down_mbps: i32,
|
||||
|
||||
// TUIC
|
||||
pub disable_sni: bool,
|
||||
pub reduce_rtt: bool,
|
||||
pub udp_relay_mode: Option<String>,
|
||||
pub congestion_controller: Option<String>,
|
||||
|
||||
// AnyTLS
|
||||
pub padding_scheme: Option<String>,
|
||||
|
||||
// Mieru
|
||||
pub multiplex: Option<String>,
|
||||
|
||||
// Vless xhttp
|
||||
pub xhttp_mode: Option<String>,
|
||||
pub xhttp_extra: Option<String>,
|
||||
|
||||
// Encryption
|
||||
pub encryption: Option<String>,
|
||||
pub encryption_mode: Option<String>,
|
||||
pub encryption_rtt: Option<String>,
|
||||
pub encryption_ticket: Option<String>,
|
||||
pub encryption_server_padding: Option<String>,
|
||||
pub encryption_private_key: Option<String>,
|
||||
pub encryption_client_padding: Option<String>,
|
||||
pub encryption_password: Option<String>,
|
||||
|
||||
// ECH
|
||||
pub ech_enable: bool,
|
||||
pub ech_server_name: Option<String>,
|
||||
|
||||
// Misc
|
||||
pub ratio: f64,
|
||||
pub cert_mode: Option<String>,
|
||||
pub cert_dns_provider: Option<String>,
|
||||
pub cert_dns_env: Option<String>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// User
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Subscriber / user info passed to templates (mirrors Go `User`).
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct User {
|
||||
pub password: String,
|
||||
/// Unix timestamp (seconds).
|
||||
pub expired_at: i64,
|
||||
pub download: i64,
|
||||
pub upload: i64,
|
||||
pub traffic: i64,
|
||||
pub subscribe_url: String,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ClientConfig
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for a [`Client`] instance.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClientConfig {
|
||||
pub site_name: String,
|
||||
pub subscribe_name: String,
|
||||
/// Output format, e.g. `"base64"`, `"yaml"`, `"json"`.
|
||||
pub output_format: String,
|
||||
pub params: HashMap<String, String>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Client
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Renders a Go-compatible template with a sprig-subset function map.
|
||||
pub struct Client {
|
||||
pub config: ClientConfig,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Render `template` against `proxies` and `user`.
|
||||
///
|
||||
/// Mirrors Go `(*Client).Build()`.
|
||||
pub fn build(&self, template: &str, proxies: &[Proxy], user: &User) -> anyhow::Result<String> {
|
||||
let mut tmpl = gtmpl::Template::default();
|
||||
|
||||
// Register sprig-subset functions.
|
||||
tmpl.add_func("toJson", sprig_to_json);
|
||||
tmpl.add_func("b64enc", sprig_b64enc);
|
||||
tmpl.add_func("date", sprig_date);
|
||||
|
||||
tmpl.parse(template)
|
||||
.map_err(|e| anyhow::anyhow!("template parse error: {e}"))?;
|
||||
|
||||
// Serialize each proxy to a serde_json::Value, then lift to gtmpl::Value.
|
||||
let proxy_values: Vec<gtmpl::Value> = proxies
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let json = serde_json::to_value(p)
|
||||
.context("serialize Proxy to JSON")?;
|
||||
Ok(json_to_gtmpl(json))
|
||||
})
|
||||
.collect::<anyhow::Result<_>>()?;
|
||||
|
||||
let user_value = json_to_gtmpl(
|
||||
serde_json::to_value(user).context("serialize User to JSON")?,
|
||||
);
|
||||
|
||||
let params_value = {
|
||||
let map: HashMap<String, gtmpl::Value> = self
|
||||
.config
|
||||
.params
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), gtmpl::Value::String(v.clone())))
|
||||
.collect();
|
||||
gtmpl::Value::Map(map)
|
||||
};
|
||||
|
||||
let mut ctx: HashMap<String, gtmpl::Value> = HashMap::new();
|
||||
ctx.insert(
|
||||
"SiteName".into(),
|
||||
gtmpl::Value::String(self.config.site_name.clone()),
|
||||
);
|
||||
ctx.insert(
|
||||
"SubscribeName".into(),
|
||||
gtmpl::Value::String(self.config.subscribe_name.clone()),
|
||||
);
|
||||
ctx.insert(
|
||||
"OutputFormat".into(),
|
||||
gtmpl::Value::String(self.config.output_format.clone()),
|
||||
);
|
||||
ctx.insert("Proxies".into(), gtmpl::Value::Array(proxy_values));
|
||||
ctx.insert("UserInfo".into(), user_value);
|
||||
ctx.insert("Params".into(), params_value);
|
||||
|
||||
let rendered = tmpl
|
||||
.render(>mpl::Context::from(gtmpl::Value::Map(ctx)))
|
||||
.map_err(|e| anyhow::anyhow!("template render error: {e}"))?;
|
||||
|
||||
if self.config.output_format == "base64" {
|
||||
return Ok(B64.encode(rendered.as_bytes()));
|
||||
}
|
||||
|
||||
Ok(rendered)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// JSON ↔ gtmpl::Value conversion
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn json_to_gtmpl(v: serde_json::Value) -> gtmpl::Value {
|
||||
match v {
|
||||
serde_json::Value::Null => gtmpl::Value::Nil,
|
||||
serde_json::Value::Bool(b) => gtmpl::Value::Bool(b),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
gtmpl::Value::Number(gtmpl_value::Number::from(i))
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
gtmpl::Value::Number(gtmpl_value::Number::from(f))
|
||||
} else {
|
||||
gtmpl::Value::Number(gtmpl_value::Number::from(0_i64))
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => gtmpl::Value::String(s),
|
||||
serde_json::Value::Array(arr) => {
|
||||
gtmpl::Value::Array(arr.into_iter().map(json_to_gtmpl).collect())
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
let m: HashMap<String, gtmpl::Value> =
|
||||
map.into_iter().map(|(k, v)| (k, json_to_gtmpl(v))).collect();
|
||||
gtmpl::Value::Map(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sprig-subset template functions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `toJson` — serialises first argument to a JSON string.
|
||||
fn sprig_to_json(args: &[gtmpl::Value]) -> Result<gtmpl::Value, gtmpl_value::FuncError> {
|
||||
let v = args
|
||||
.first()
|
||||
.ok_or_else(|| gtmpl_value::FuncError::AtLeastXArgs("toJson".into(), 1))?;
|
||||
|
||||
// Convert gtmpl::Value back through serde to produce JSON.
|
||||
let json_val = gtmpl_value_to_json(v.clone());
|
||||
let s = serde_json::to_string(&json_val).unwrap_or_else(|_| "null".into());
|
||||
Ok(gtmpl::Value::String(s))
|
||||
}
|
||||
|
||||
/// `b64enc` — base64-encodes first argument as a UTF-8 string.
|
||||
fn sprig_b64enc(args: &[gtmpl::Value]) -> Result<gtmpl::Value, gtmpl_value::FuncError> {
|
||||
let v = args
|
||||
.first()
|
||||
.ok_or_else(|| gtmpl_value::FuncError::AtLeastXArgs("b64enc".into(), 1))?;
|
||||
let s = match v {
|
||||
gtmpl::Value::String(s) => s.clone(),
|
||||
other => format!("{other:?}"),
|
||||
};
|
||||
Ok(gtmpl::Value::String(B64.encode(s.as_bytes())))
|
||||
}
|
||||
|
||||
/// `date` — formats a Unix timestamp using a Go-style layout string.
|
||||
///
|
||||
/// Signature: `date <layout-string> <unix-timestamp>`
|
||||
fn sprig_date(args: &[gtmpl::Value]) -> Result<gtmpl::Value, gtmpl_value::FuncError> {
|
||||
if args.len() < 2 {
|
||||
return Err(gtmpl_value::FuncError::AtLeastXArgs("date".into(), 2));
|
||||
}
|
||||
let layout = match &args[0] {
|
||||
gtmpl::Value::String(s) => s.as_str(),
|
||||
_ => {
|
||||
return Err(gtmpl_value::FuncError::Generic(
|
||||
"date: first arg must be a string layout".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let ts: i64 = match &args[1] {
|
||||
gtmpl::Value::Number(n) => n
|
||||
.as_i64()
|
||||
.unwrap_or_else(|| n.as_f64().map(|f| f as i64).unwrap_or(0)),
|
||||
_ => {
|
||||
return Err(gtmpl_value::FuncError::Generic(
|
||||
"date: second arg must be a number (Unix timestamp)".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let dt = chrono::Utc
|
||||
.timestamp_opt(ts, 0)
|
||||
.single()
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
|
||||
// Map common Go reference-time tokens to chrono format specifiers.
|
||||
let chrono_fmt = go_layout_to_chrono(layout);
|
||||
Ok(gtmpl::Value::String(dt.format(&chrono_fmt).to_string()))
|
||||
}
|
||||
|
||||
/// Translate a Go time-layout string to a chrono format string.
|
||||
///
|
||||
/// Only the most common reference-time tokens are mapped.
|
||||
fn go_layout_to_chrono(layout: &str) -> String {
|
||||
layout
|
||||
.replace("2006", "%Y")
|
||||
.replace("01", "%m")
|
||||
.replace("02", "%d")
|
||||
.replace("15", "%H")
|
||||
.replace("04", "%M")
|
||||
.replace("05", "%S")
|
||||
.replace("Jan", "%b")
|
||||
.replace("Monday", "%A")
|
||||
.replace("Mon", "%a")
|
||||
}
|
||||
|
||||
/// Convert a `gtmpl::Value` to a `serde_json::Value` (best-effort).
|
||||
fn gtmpl_value_to_json(v: gtmpl::Value) -> serde_json::Value {
|
||||
match v {
|
||||
gtmpl::Value::Nil | gtmpl::Value::NoValue => serde_json::Value::Null,
|
||||
gtmpl::Value::Bool(b) => serde_json::Value::Bool(b),
|
||||
gtmpl::Value::String(s) => serde_json::Value::String(s),
|
||||
gtmpl::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
serde_json::Value::Number(i.into())
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
serde_json::Number::from_f64(f)
|
||||
.map(serde_json::Value::Number)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
}
|
||||
}
|
||||
gtmpl::Value::Array(arr) => {
|
||||
serde_json::Value::Array(arr.into_iter().map(gtmpl_value_to_json).collect())
|
||||
}
|
||||
gtmpl::Value::Map(map) | gtmpl::Value::Object(map) => {
|
||||
let obj: serde_json::Map<String, serde_json::Value> = map
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, gtmpl_value_to_json(v)))
|
||||
.collect();
|
||||
serde_json::Value::Object(obj)
|
||||
}
|
||||
// Functions have no meaningful JSON representation.
|
||||
gtmpl::Value::Function(_) => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Adapter
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Converts node+server entities into a sorted list of [`Proxy`] values.
|
||||
pub struct Adapter;
|
||||
|
||||
impl Adapter {
|
||||
/// Build a `Vec<Proxy>` from `(Node, Server)` pairs.
|
||||
///
|
||||
/// Mirrors Go `(*Adapter).Proxies()`.
|
||||
pub fn proxies(pairs: &[(Node, Server)]) -> Vec<Proxy> {
|
||||
let mut out: Vec<Proxy> = Vec::new();
|
||||
|
||||
for (node, server) in pairs {
|
||||
// Deserialise the JSON protocols array stored in `server.protocols`.
|
||||
let protocols: Vec<NodeProtocol> = match serde_json::from_str(&server.protocols) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
server_id = server.id,
|
||||
error = %e,
|
||||
"failed to parse server protocols JSON"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Find the protocol entry whose `type_` matches `node.protocol`.
|
||||
let proto = match protocols.iter().find(|p| p.type_ == node.protocol) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
node_id = node.id,
|
||||
protocol = %node.protocol,
|
||||
"no matching protocol entry in server.protocols"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let tags: Vec<String> = if node.tags.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
node.tags.split(',').map(str::trim).map(String::from).collect()
|
||||
};
|
||||
|
||||
out.push(Proxy {
|
||||
sort: node.sort,
|
||||
name: node.name.clone(),
|
||||
server: node.address.clone(),
|
||||
port: node.port,
|
||||
type_: node.protocol.clone(),
|
||||
tags,
|
||||
security: proto.security.clone(),
|
||||
sni: proto.sni.clone(),
|
||||
allow_insecure: proto.allow_insecure,
|
||||
fingerprint: proto.fingerprint.clone(),
|
||||
reality_server_addr: proto.reality_server_addr.clone(),
|
||||
reality_server_port: proto.reality_server_port,
|
||||
reality_private_key: proto.reality_private_key.clone(),
|
||||
reality_public_key: proto.reality_public_key.clone(),
|
||||
reality_short_id: proto.reality_short_id.clone(),
|
||||
transport: proto.transport.clone(),
|
||||
host: proto.host.clone(),
|
||||
path: proto.path.clone(),
|
||||
service_name: proto.service_name.clone(),
|
||||
method: proto.cipher.clone(),
|
||||
server_key: proto.server_key.clone(),
|
||||
uot: proto.uot,
|
||||
uot_version: proto.uot_version,
|
||||
flow: proto.flow.clone(),
|
||||
hop_ports: proto.hop_ports.clone(),
|
||||
hop_interval: proto.hop_interval,
|
||||
obfs_password: proto.obfs_password.clone(),
|
||||
up_mbps: proto.up_mbps,
|
||||
down_mbps: proto.down_mbps,
|
||||
disable_sni: proto.disable_sni,
|
||||
reduce_rtt: proto.reduce_rtt,
|
||||
udp_relay_mode: proto.udp_relay_mode.clone(),
|
||||
congestion_controller: proto.congestion_controller.clone(),
|
||||
padding_scheme: proto.padding_scheme.clone(),
|
||||
multiplex: proto.multiplex.clone(),
|
||||
xhttp_mode: proto.xhttp_mode.clone(),
|
||||
xhttp_extra: proto.xhttp_extra.clone(),
|
||||
encryption: proto.encryption.clone(),
|
||||
encryption_mode: proto.encryption_mode.clone(),
|
||||
encryption_rtt: proto.encryption_rtt.clone(),
|
||||
encryption_ticket: proto.encryption_ticket.clone(),
|
||||
encryption_server_padding: proto.encryption_server_padding.clone(),
|
||||
encryption_private_key: proto.encryption_private_key.clone(),
|
||||
encryption_client_padding: proto.encryption_client_padding.clone(),
|
||||
encryption_password: proto.encryption_password.clone(),
|
||||
ech_enable: proto.ech_enable,
|
||||
ech_server_name: proto.ech_server_name.clone(),
|
||||
ratio: proto.ratio,
|
||||
cert_mode: proto.cert_mode.clone(),
|
||||
cert_dns_provider: proto.cert_dns_provider.clone(),
|
||||
cert_dns_env: proto.cert_dns_env.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by `node.sort` ascending (mirrors Go slice sort in original code).
|
||||
out.sort_by_key(|p| p.sort);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_proxy_default() {
|
||||
let _ = Proxy::default();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_b64enc() {
|
||||
let args = vec![gtmpl::Value::String("hello".into())];
|
||||
let result = sprig_b64enc(&args).expect("b64enc should succeed");
|
||||
assert_eq!(result, gtmpl::Value::String("aGVsbG8=".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_build_simple() {
|
||||
let config = ClientConfig {
|
||||
site_name: "TestSite".into(),
|
||||
subscribe_name: "TestSub".into(),
|
||||
output_format: "text".into(),
|
||||
params: HashMap::new(),
|
||||
};
|
||||
let client = Client { config };
|
||||
|
||||
let proxies = vec![Proxy::default(), Proxy::default()];
|
||||
let user = User::default();
|
||||
|
||||
// Go template: render the count of proxies.
|
||||
let out = client
|
||||
.build("{{ len .Proxies }}", &proxies, &user)
|
||||
.expect("build should succeed");
|
||||
assert_eq!(out.trim(), "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_build_base64() {
|
||||
let config = ClientConfig {
|
||||
output_format: "base64".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let client = Client { config };
|
||||
let out = client
|
||||
.build("hello", &[], &User::default())
|
||||
.expect("build should succeed");
|
||||
// base64("hello") == "aGVsbG8="
|
||||
assert_eq!(out, "aGVsbG8=");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user