Files
Thing/src-tauri/src/music/feiniu/fnconnect.rs
T
2026-09-12 16:16:57 +08:00

155 lines
5.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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/<id>`、`<id>.5ddd.com`、或裸 fnId。
pub fn extract_fn_id(input: &str) -> Option<String> {
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}&timestamp={timestamp}&sign={}", md5_hex(&raw))
}
/// 从网关查询 fnId 的连接参数。
pub async fn query_fn_connect(fn_id: &str) -> Result<Value, String> {
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<String> = 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()))
}