Ctrl K
ComunidadGuías…

src-tauri/src/export.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Экспорт профиля в 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<String> = 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<Transport>) -> 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 = <b 0x01>\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);
    }
}