155 lines
5.6 KiB
Rust
155 lines
5.6 KiB
Rust
//! 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}×tamp={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()))
|
||
}
|