//! Экспорт профиля в share-ссылку (обратное к [`crate::import`]). //! //! Протоколы со ссылками (hysteria2/vless/vmess/ss/trojan/tuic) → соответствующий //! URL; socks/http → JSON sing-box outbound; AmneziaWG → `vpn://`. use crate::models::{Outbound, TlsOpts, Transport}; use base64::{engine::general_purpose, Engine}; use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; use std::io::Write; // кодируем всё, кроме безопасных символов const ESCAPE: &AsciiSet = &CONTROLS .add(b' ') .add(b'"') .add(b'#') .add(b'<') .add(b'>') .add(b'?') .add(b'`') .add(b'{') .add(b'}') .add(b'/') .add(b':') .add(b'@') .add(b'&') .add(b'=') .add(b'+') .add(b'%'); fn enc(s: &str) -> String { utf8_percent_encode(s, ESCAPE).to_string() } fn query(pairs: &[(&str, String)]) -> String { let parts: Vec = pairs .iter() .filter(|(_, v)| !v.is_empty()) .map(|(k, v)| format!("{k}={}", enc(v))) .collect(); if parts.is_empty() { String::new() } else { format!("?{}", parts.join("&")) } } fn tls_query(tls: &TlsOpts, default_security: &str) -> Vec<(&'static str, String)> { let mut q: Vec<(&'static str, String)> = Vec::new(); if !tls.enabled { q.push(("security", "none".into())); return q; } let security = if tls.reality_public_key.is_some() { "reality" } else { default_security }; q.push(("security", security.into())); if let Some(s) = &tls.sni { q.push(("sni", s.clone())); } if let Some(fp) = &tls.fingerprint { q.push(("fp", fp.clone())); } if let Some(pbk) = &tls.reality_public_key { q.push(("pbk", pbk.clone())); } if let Some(sid) = &tls.reality_short_id { q.push(("sid", sid.clone())); } if !tls.alpn.is_empty() { q.push(("alpn", tls.alpn.join(","))); } if tls.insecure { q.push(("insecure", "1".into())); } q } fn transport_query(transport: &Option) -> Vec<(&'static str, String)> { match transport { None => vec![("type", "tcp".into())], Some(Transport::Ws { path, host }) => { let mut q = vec![("type", "ws".into())]; if let Some(p) = path { q.push(("path", p.clone())); } if let Some(h) = host { q.push(("host", h.clone())); } q } Some(Transport::Grpc { service_name }) => { let mut q = vec![("type", "grpc".into())]; if let Some(s) = service_name { q.push(("serviceName", s.clone())); } q } } } /// Упаковывает AmneziaWG-конфиг в совместимый с нашим импортом контейнер /// `vpn://base64url(qCompress(JSON))`. В `.conf` уже находятся endpoint, /// ключи и все параметры обфускации, поэтому ссылка полностью обратима. fn amnezia_protocol_version(config: &str) -> u8 { const V3_KEYS: &[&str] = &[ "HeaderProtectionKey", "ContentPaddingAddition", "RekeyAfterTime", "RekeyTimeout", "RejectAfterTime", "KeepaliveTimeout", "MaxHandshakeAttempts", "RandomTrailers", "DisableCookies", ]; let has_v3_key = config.lines().any(|line| { let Some((key, _)) = line.split_once('=') else { return false; }; V3_KEYS .iter() .any(|candidate| key.trim().eq_ignore_ascii_case(candidate)) }); if has_v3_key { 3 } else { 2 } } fn amnezia_link(name: &str, config: &str, server: &str, port: u16) -> String { let protocol_version = amnezia_protocol_version(config); let container_name = format!("amnezia-awg{protocol_version}"); let last_config = serde_json::json!({ "config": config }).to_string(); let container = serde_json::json!({ "defaultContainer": container_name, "description": name, "hostName": server, "dns1": "1.1.1.1", "dns2": "1.0.0.1", "containers": [{ "container": container_name, "awg": { "last_config": last_config, "port": port.to_string(), "protocol_version": protocol_version } }] }) .to_string(); let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); encoder .write_all(container.as_bytes()) .expect("zlib write to Vec cannot fail"); let compressed = encoder.finish().expect("zlib finish to Vec cannot fail"); let mut packed = (container.len() as u32).to_be_bytes().to_vec(); packed.extend_from_slice(&compressed); format!("vpn://{}", general_purpose::URL_SAFE_NO_PAD.encode(packed)) } /// JSON-представление профиля — sing-box outbound-объект (для вставки в конфиг /// или импорта как JSON). AmneziaWG отдаём его `.conf` — sing-box outbound'а у /// него нет (движок отдельный). pub fn to_json(outbound: &Outbound) -> String { match outbound { Outbound::AmneziaWg { config, .. } => config.clone(), _ => serde_json::to_string_pretty(&crate::config::outbound_to_singbox(outbound)) .unwrap_or_default(), } } /// Строит share-ссылку/представление профиля. pub fn to_share(name: &str, outbound: &Outbound) -> String { match outbound { Outbound::Shadowsocks { server, port, method, password, } => { let userinfo = general_purpose::STANDARD_NO_PAD.encode(format!("{method}:{password}")); format!("ss://{userinfo}@{server}:{port}#{}", enc(name)) } Outbound::Trojan { server, port, password, tls, transport, } => { let mut q = tls_query(tls, "tls"); q.extend(transport_query(transport)); format!( "trojan://{}@{server}:{port}{}#{}", enc(password), query(&q), enc(name) ) } Outbound::Vless { server, port, uuid, flow, tls, transport, } => { let mut q: Vec<(&str, String)> = vec![("encryption", "none".into())]; if let Some(f) = flow { q.push(("flow", f.clone())); } q.extend(tls_query(tls, "tls")); q.extend(transport_query(transport)); format!("vless://{uuid}@{server}:{port}{}#{}", query(&q), enc(name)) } Outbound::Tuic { server, port, uuid, password, congestion_control, tls, } => { let mut q = tls_query(tls, "tls"); if let Some(cc) = congestion_control { q.push(("congestion_control", cc.clone())); } format!( "tuic://{uuid}:{}@{server}:{port}{}#{}", enc(password), query(&q), enc(name) ) } Outbound::Hysteria2 { server, port, password, sni, insecure, obfs_password, up_mbps, down_mbps, } => { let mut q: Vec<(&str, String)> = Vec::new(); if let Some(s) = sni { q.push(("sni", s.clone())); } if *insecure { q.push(("insecure", "1".into())); } if let Some(op) = obfs_password { q.push(("obfs", "salamander".into())); q.push(("obfs-password", op.clone())); } if let Some(up) = up_mbps { q.push(("up", up.to_string())); } if let Some(down) = down_mbps { q.push(("down", down.to_string())); } format!( "hysteria2://{}@{server}:{port}{}#{}", enc(password), query(&q), enc(name) ) } Outbound::Vmess { server, port, uuid, alter_id, security, tls, transport, } => { let (net, host, path) = match transport { None => ("tcp", String::new(), String::new()), Some(Transport::Ws { path, host }) => ( "ws", host.clone().unwrap_or_default(), path.clone().unwrap_or_default(), ), Some(Transport::Grpc { service_name }) => ( "grpc", String::new(), service_name.clone().unwrap_or_default(), ), }; let json = serde_json::json!({ "v": "2", "ps": name, "add": server, "port": port.to_string(), "id": uuid, "aid": alter_id.to_string(), "scy": security.clone().unwrap_or_else(|| "auto".into()), "net": net, "host": host, "path": path, "tls": if tls.enabled { "tls" } else { "" }, "sni": tls.sni.clone().unwrap_or_default() }); format!( "vmess://{}", general_purpose::STANDARD_NO_PAD.encode(json.to_string()) ) } // socks/http без общепринятой share-ссылки → JSON sing-box outbound Outbound::Socks { .. } | Outbound::Http { .. } => { serde_json::to_string_pretty(&crate::config::outbound_to_singbox(outbound)) .unwrap_or_default() } Outbound::AmneziaWg { config, server, port, } => amnezia_link(name, config, server, *port), } } #[cfg(test)] mod tests { use super::*; use crate::import; use std::io::Read; fn unpack_amnezia_link(link: &str) -> serde_json::Value { let packed = general_purpose::URL_SAFE_NO_PAD .decode(link.strip_prefix("vpn://").unwrap()) .unwrap(); let mut decoder = flate2::read::ZlibDecoder::new(&packed[4..]); let mut json = String::new(); decoder.read_to_string(&mut json).unwrap(); serde_json::from_str(&json).unwrap() } fn roundtrip(name: &str, ob: Outbound) { let link = to_share(name, &ob); let (parsed_name, parsed) = import::parse(&link).unwrap(); assert_eq!(parsed_name, name, "link: {link}"); assert_eq!(parsed, ob, "link: {link}"); } #[test] fn roundtrip_hysteria2() { roundtrip( "HY2", Outbound::Hysteria2 { server: "ex.com".into(), port: 443, password: "pw".into(), sni: Some("sni.com".into()), insecure: true, obfs_password: Some("obf".into()), up_mbps: Some(100), down_mbps: Some(200), }, ); } #[test] fn roundtrip_vless_reality() { roundtrip( "VL", Outbound::Vless { server: "1.2.3.4".into(), port: 443, uuid: "11111111-1111-1111-1111-111111111111".into(), flow: Some("xtls-rprx-vision".into()), tls: TlsOpts { enabled: true, sni: Some("www.example.com".into()), insecure: false, alpn: vec![], fingerprint: Some("chrome".into()), reality_public_key: Some("PUBKEY".into()), reality_short_id: Some("ab12".into()), }, transport: Some(Transport::Ws { path: Some("/p".into()), host: Some("h.example".into()), }), }, ); } #[test] fn roundtrip_ss_trojan_tuic() { roundtrip( "SS", Outbound::Shadowsocks { server: "1.2.3.4".into(), port: 8388, method: "aes-256-gcm".into(), password: "pw".into(), }, ); roundtrip( "TR", Outbound::Trojan { server: "ex.com".into(), port: 443, password: "secret".into(), tls: TlsOpts { enabled: true, sni: Some("ex.com".into()), ..Default::default() }, transport: None, }, ); roundtrip( "TU", Outbound::Tuic { server: "ex.com".into(), port: 443, uuid: "uid".into(), password: "pw".into(), congestion_control: Some("bbr".into()), tls: TlsOpts { enabled: true, sni: Some("ex.com".into()), ..Default::default() }, }, ); } #[test] fn roundtrip_vmess_ws() { roundtrip( "VM", Outbound::Vmess { server: "1.2.3.4".into(), port: 443, uuid: "uid".into(), alter_id: 0, security: Some("auto".into()), tls: TlsOpts { enabled: true, sni: Some("sni.com".into()), ..Default::default() }, transport: Some(Transport::Ws { path: Some("/p".into()), host: Some("host.com".into()), }), }, ); } #[test] fn roundtrip_amneziawg_vpn_link() { let outbound = Outbound::AmneziaWg { config: "[Interface]\nAddress = 10.8.1.7/32\nPrivateKey = KEY\nJc = 5\nI1 = \nI2 =\nHeaderProtectionKey = HEADER_KEY\nContentPaddingAddition = 16-64\nRandomTrailers = on\n[Peer]\nPublicKey = PUB\nEndpoint = 1.2.3.4:34196\nAllowedIPs = 0.0.0.0/0, ::/0\n".into(), server: "1.2.3.4".into(), port: 34196, }; let link = to_share("FR-AWG", &outbound); assert!(link.starts_with("vpn://")); let container = unpack_amnezia_link(&link); assert_eq!(container["defaultContainer"], "amnezia-awg3"); assert_eq!(container["containers"][0]["container"], "amnezia-awg3"); assert_eq!(container["containers"][0]["awg"]["protocol_version"], 3); roundtrip("FR-AWG", outbound); } #[test] fn legacy_amneziawg_export_stays_v2() { let config = "[Interface]\nPrivateKey = KEY\nJc = 5\nS3 = 19\n[Peer]\nPublicKey = PUB\nEndpoint = 1.2.3.4:34196\n"; let container = unpack_amnezia_link(&amnezia_link("AWG", config, "1.2.3.4", 34196)); assert_eq!(container["defaultContainer"], "amnezia-awg2"); assert_eq!(container["containers"][0]["awg"]["protocol_version"], 2); } }