//! FnConnect 远程连接解析(参考 feiniu-car-music `fn-api.js`)。 //! //! fnId → 网关 `https://5ddd.com/api/v1/fn/con`(authx md5 签名)→ 内网/公网/中继候选 → //! 探测可达性 → 得到可用的 base_url(含 mode=relay 的中继地址)。 use md5::{Digest, Md5}; use rand::RngCore; use serde_json::{json, Value}; /// 网关地址与签名常量(对齐 feiniu-car-music)。 const FN_CONNECT_URL: &str = "https://5ddd.com/api/v1/fn/con"; const FN_AUTHX_PREFIX: &str = "NDzZTVxnRKP8Z0jXg1VAMonaG8akvh"; const FN_API_KEY: &str = "zIGtkc3dqZnJpd29qZXJqa2w7c"; fn md5_hex(input: &str) -> String { let mut h = Md5::new(); h.update(input.as_bytes()); format!("{:x}", h.finalize()) } /// 从输入识别 fnId:`fnos.net/`、`.5ddd.com`、或裸 fnId。 pub fn extract_fn_id(input: &str) -> Option { let s = input.trim(); if s.is_empty() { return None; } if let Some(id) = s.split_once("fnos.net/").map(|(_, r)| r.split('/').next().unwrap_or("")) { if !id.is_empty() { return Some(id.trim().to_string()); } } if let Some(rest) = s.rsplit_once("/") { let last = rest.1; if last.ends_with(".5ddd.com") { return Some(last.trim_end_matches(".5ddd.com").to_string()); } } if s.ends_with(".5ddd.com") { return Some(s.trim_end_matches(".5ddd.com").to_string()); } // 裸 fnId if s.len() >= 3 && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') && !s.starts_with("http") { return Some(s.to_string()); } None } /// 计算网关 authx 签名。 fn fn_authx(method: &str, url: &str, data: &Value) -> String { let body = if method.eq_ignore_ascii_case("get") { String::new() } else { serde_json::to_string(data).unwrap_or_default() }; let mut nonce = String::new(); let mut rng = rand::thread_rng(); for _ in 0..6 { nonce.push(char::from(b'0' + (rng.next_u32() % 10) as u8)); } let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis().to_string()) .unwrap_or_default(); let raw = format!( "{FN_AUTHX_PREFIX}_{url}_{nonce}_{timestamp}_{}__{FN_API_KEY}", md5_hex(&body) ); format!("nonce={nonce}×tamp={timestamp}&sign={}", md5_hex(&raw)) } /// 从网关查询 fnId 的连接参数。 pub async fn query_fn_connect(fn_id: &str) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| e.to_string())?; let body = json!({ "fnId": fn_id }); let resp = client .post(FN_CONNECT_URL) .header("Content-Type", "application/json") .header("authx", fn_authx("post", "/api/v1/fn/con", &body)) .json(&body) .send() .await .map_err(|e| format!("FnConnect 网关不可达: {e}"))?; let b: Value = resp.json().await.map_err(|e| e.to_string())?; if b["code"].as_i64().unwrap_or(-1) != 0 { return Err(b["msg"].as_str().unwrap_or("FnConnect 网关返回错误").to_string()); } Ok(b["data"].clone()) } /// 构建候选 base_url 列表。返回 (url, is_relay)。 pub fn build_candidates(data: &Value) -> Vec<(String, bool)> { let mut out: Vec<(String, bool)> = Vec::new(); let port = &data["port"]; let http = port["httpPort"].as_u64().unwrap_or(5666); let https = port["httpsPort"].as_u64().unwrap_or(5667); let empty = vec![]; for ip in data["ipv4"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) { out.push((format!("http://{ip}:{http}"), false)); out.push((format!("https://{ip}:{https}"), false)); } for ip in data["publicIpv4"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) { out.push((format!("http://{ip}:{http}"), false)); out.push((format!("https://{ip}:{https}"), false)); } for ip in data["publicIpv6"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) { out.push((format!("http://[{ip}]:{http}"), false)); out.push((format!("https://[{ip}]:{https}"), false)); } let relays = data["fn"].as_array().unwrap_or(&empty); let relay_addrs: Vec = if relays.is_empty() { vec!["5ddd.com".to_string()] } else { relays .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }; for addr in relay_addrs { let domain = addr.split(':').next().unwrap_or(&addr).to_string(); out.push((format!("https://{domain}"), true)); } out } /// 探测某个 base_url 是否可用。 async fn probe(url: &str) -> bool { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(6)) .build() .unwrap_or_else(|_| reqwest::Client::new()); let full = format!("{}/music/api/v1/track/list?page=1&size=1", url.trim_end_matches('/')); match client.get(&full).send().await { Ok(resp) => resp.status().as_u16() < 500, Err(_) => false, } } /// 解析 fnId → 第一个可达的 base_url;返回 (base_url, is_relay)。 pub async fn resolve_base_url(fn_id: &str) -> Result<(String, bool), String> { let data = query_fn_connect(fn_id).await?; let candidates = build_candidates(&data); if candidates.is_empty() { return Err("FnConnect 未返回可用地址".into()); } for (url, relay) in &candidates { if probe(url).await { return Ok((url.clone(), *relay)); } } Err(format!("FnConnect 候选均不可达({} 个)", candidates.len())) }