//! 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` 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, // Security pub security: Option, pub sni: Option, pub allow_insecure: bool, pub fingerprint: Option, pub reality_server_addr: Option, pub reality_server_port: i32, pub reality_private_key: Option, pub reality_public_key: Option, pub reality_short_id: Option, // Transport pub transport: Option, pub host: Option, pub path: Option, pub service_name: Option, // Shadowsocks pub method: Option, pub server_key: Option, pub uot: bool, pub uot_version: i32, // Vmess/Vless/Trojan pub flow: Option, // Hysteria2 pub hop_ports: Option, pub hop_interval: i32, pub obfs_password: Option, pub up_mbps: i32, pub down_mbps: i32, // TUIC pub disable_sni: bool, pub reduce_rtt: bool, pub udp_relay_mode: Option, pub congestion_controller: Option, // AnyTLS pub padding_scheme: Option, // Mieru pub multiplex: Option, // Vless xhttp pub xhttp_mode: Option, pub xhttp_extra: Option, // Encryption pub encryption: Option, pub encryption_mode: Option, pub encryption_rtt: Option, pub encryption_ticket: Option, pub encryption_server_padding: Option, pub encryption_private_key: Option, pub encryption_client_padding: Option, pub encryption_password: Option, // ECH pub ech_enable: bool, pub ech_server_name: Option, // Misc pub ratio: f64, pub cert_mode: Option, pub cert_dns_provider: Option, pub cert_dns_env: Option, } // ───────────────────────────────────────────────────────────────────────────── // 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, } // ───────────────────────────────────────────────────────────────────────────── // 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 { 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 = proxies .iter() .map(|p| { let json = serde_json::to_value(p) .context("serialize Proxy to JSON")?; Ok(json_to_gtmpl(json)) }) .collect::>()?; let user_value = json_to_gtmpl( serde_json::to_value(user).context("serialize User to JSON")?, ); let params_value = { let map: HashMap = self .config .params .iter() .map(|(k, v)| (k.clone(), gtmpl::Value::String(v.clone()))) .collect(); gtmpl::Value::Map(map) }; let mut ctx: HashMap = 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 = 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 { 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 { 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 ` fn sprig_date(args: &[gtmpl::Value]) -> Result { 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 = 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` from `(Node, Server)` pairs. /// /// Mirrors Go `(*Adapter).Proxies()`. pub fn proxies(pairs: &[(Node, Server)]) -> Vec { let mut out: Vec = Vec::new(); for (node, server) in pairs { // Deserialise the JSON protocols array stored in `server.protocols`. let protocols: Vec = 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 = 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="); } }