use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use tracing::{info, warn};
use crate::client::{HubPairingClient, HubPairingCredentials, HubPairingOptions};
use crate::ilink::types::{BaseInfo, GetUpdatesRequest, GetUpdatesResponse};
pub fn default_local_credential_path() -> PathBuf {
crate::paths::default_bridge_credentials_path()
}
fn credential_path(cred_file: Option<&str>) -> PathBuf {
cred_file
.map(PathBuf::from)
.unwrap_or_else(default_local_credential_path)
}
enum LocalCredState {
Missing,
Valid(HubPairingCredentials),
ExistsUnusable,
}
async fn local_credential_state(path: &Path) -> Result<LocalCredState> {
match tokio::fs::read_to_string(path).await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalCredState::Missing),
Err(e) => Err(e.into()),
Ok(data) => match serde_json::from_str::<HubPairingCredentials>(&data) {
Ok(c) if !c.token.trim().is_empty() => Ok(LocalCredState::Valid(c)),
Ok(_) => Ok(LocalCredState::ExistsUnusable),
Err(_) => Ok(LocalCredState::ExistsUnusable),
},
}
}
async fn write_credentials(
path: &Path,
hub_url: &str,
vtoken: &str,
client_name: &str,
) -> Result<()> {
let saved_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format!("{}Z", d.as_secs()))
.unwrap_or_default();
let creds = HubPairingCredentials {
token: vtoken.to_string(),
base_url: hub_url.trim().trim_end_matches('/').to_string(),
account_id: "ilink-hub@hub.local".to_string(),
user_id: "hub-client".to_string(),
saved_at: Some(saved_at),
client_name: Some(client_name.to_string()),
};
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
tokio::fs::create_dir_all(parent).await?;
}
let data = serde_json::to_string_pretty(&creds)?;
tokio::fs::write(path, format!("{data}\n"))
.await
.with_context(|| format!("write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.await
.with_context(|| format!("chmod 0600 {}", path.display()))?;
}
Ok(())
}
pub fn hub_response_token_rejected(status: reqwest::StatusCode, ret: Option<i32>) -> bool {
status == reqwest::StatusCode::UNAUTHORIZED || ret == Some(401)
}
pub async fn validate_hub_token(hub_url: &str, token: &str) -> Result<()> {
let hub = hub_url.trim().trim_end_matches('/');
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.build()
.context("build HTTP client for token validation")?;
let url = format!("{hub}/ilink/bot/getupdates");
let body = GetUpdatesRequest {
get_updates_buf: String::new(),
base_info: Some(BaseInfo::default()),
timeout: Some(0),
};
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {}", token.trim()))
.json(&body)
.send()
.await
.with_context(|| format!("probe Hub token via {url}"))?;
let status = resp.status();
let out: GetUpdatesResponse = resp.json().await.context("parse getupdates probe")?;
if hub_response_token_rejected(status, out.ret) {
let detail = out.errmsg.unwrap_or_else(|| "token rejected".into());
anyhow::bail!("{detail}");
}
if let Some(ret) = out.ret {
if ret != 0 {
anyhow::bail!(
"getupdates probe failed: ret={ret} errmsg={}",
out.errmsg.as_deref().unwrap_or("unknown")
);
}
}
Ok(())
}
async fn register_via_hub_http(
hub_url: &str,
name: &str,
label: &str,
description: Option<&str>,
) -> Result<String> {
let client = reqwest::Client::new();
let url = format!("{}/hub/register", hub_url.trim().trim_end_matches('/'));
let mut req = client.post(&url).json(&serde_json::json!({
"name": name,
"label": label,
"persona_name": name,
"persona_emoji": "🤖",
"description": description,
}));
if let Ok(admin) = std::env::var("ILINK_ADMIN_TOKEN") {
let admin = admin.trim();
if !admin.is_empty() {
req = req.header("Authorization", format!("Bearer {admin}"));
}
}
let resp = req
.send()
.await
.with_context(|| {
format!(
"无法连接 Hub({url})。`ilink-hub-bridge` 不要求本机安装或运行 `ilink-hub`,\
只需 `WEIXIN_BASE_URL` 指向**已启动且可达**的 Hub(本机或远程均可)。\
若 Hub 未就绪:先启动 Hub 或改 URL;也可改用环境变量 `WEIXIN_TOKEN`,或在 Hub 可用时使用 `ilink-hub-bridge --pair` 扫码配对。"
)
})?;
let resp: serde_json::Value = resp.json().await.context("parse /hub/register response")?;
if resp["ret"] == 0 {
let v = resp["vtoken"]
.as_str()
.filter(|s| !s.is_empty())
.context("register response missing vtoken")?;
return Ok(v.to_string());
}
let msg = resp["errmsg"].as_str().unwrap_or("unknown");
if resp["ret"] == 401 {
let admin_present = std::env::var("ILINK_ADMIN_TOKEN")
.ok()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
let hint = if admin_present {
"已设置 ILINK_ADMIN_TOKEN,但 Hub 仍返回 401:请确认它与 Hub 端 ILINK_ADMIN_TOKEN 完全一致。"
} else {
"Hub 启用了 admin 鉴权,但本进程未设置 ILINK_ADMIN_TOKEN。请在环境中设置与 Hub 一致的 \
ILINK_ADMIN_TOKEN(manager 模式下设置在 manager 进程的环境里,会自动透传给子 bridge)。\
切勿复用其他后端的凭证/token 绕过——共享 vtoken 会导致多个 bridge 抢占同一消息队列。"
};
anyhow::bail!("POST /hub/register 被拒绝 (401 Unauthorized)。{hint}");
}
anyhow::bail!(
"POST /hub/register failed: ret={} errmsg={}",
resp["ret"],
msg
)
}
fn auto_client_name(
explicit: Option<&str>,
saved: Option<&str>,
config_path: Option<&Path>,
) -> String {
if let Some(n) = explicit.map(str::trim).filter(|s| !s.is_empty()) {
return n.to_string();
}
if let Some(n) = saved.map(str::trim).filter(|s| !s.is_empty()) {
return n.to_string();
}
default_auto_client_name(config_path)
}
pub fn default_auto_client_name(config_path: Option<&Path>) -> String {
let host = sanitize_client_name_segment(&local_hostname());
match config_path
.and_then(|p| p.file_stem())
.and_then(|s| s.to_str())
{
Some(stem) if !stem.is_empty() => {
let stem = sanitize_client_name_segment(stem);
format!("local-{host}-{stem}")
}
_ => format!("local-{host}"),
}
}
fn local_hostname() -> String {
if let Ok(h) = std::env::var("HOSTNAME").or_else(|_| std::env::var("COMPUTERNAME")) {
let h = h.trim();
if !h.is_empty() {
return h.to_string();
}
}
#[cfg(unix)]
{
use std::ffi::CStr;
extern "C" {
fn gethostname(
name: *mut libc_types::CChar,
namelen: libc_types::SizeT,
) -> libc_types::CInt;
}
mod libc_types {
pub type CChar = std::os::raw::c_char;
pub type SizeT = usize;
pub type CInt = i32;
}
let mut buf = [0 as libc_types::CChar; 256];
let ret = unsafe { gethostname(buf.as_mut_ptr(), buf.len()) };
if ret == 0 {
buf[buf.len() - 1] = 0;
let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) };
if let Ok(s) = cstr.to_str() {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
}
for path in &["/proc/sys/kernel/hostname", "/etc/hostname"] {
if let Ok(contents) = std::fs::read_to_string(path) {
let h = contents.trim();
if !h.is_empty() {
return h.to_string();
}
}
}
}
"host".to_string()
}
fn sanitize_client_name_segment(s: &str) -> String {
let mut out: String = s
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
while out.contains("--") {
out = out.replace("--", "-");
}
out.trim_matches('-').chars().take(32).collect()
}
async fn auto_register_and_save(
path: &Path,
hub: &str,
register_client_name: Option<&str>,
saved_client_name: Option<&str>,
config_path: Option<&Path>,
description: Option<&str>,
) -> Result<(String, String)> {
let hub = hub.trim().trim_end_matches('/').to_string();
let name = auto_client_name(register_client_name, saved_client_name, config_path);
let label = local_hostname();
let vtoken = register_via_hub_http(&hub, &name, &label, description).await.with_context(|| {
"自动调用 /hub/register 失败。若返回体为业务错误:检查 `ILINK_ADMIN_TOKEN` 是否与 Hub 一致;\
或改用 `WEIXIN_TOKEN` / `--pair`。"
})?;
write_credentials(path, &hub, &vtoken, &name).await?;
info!(
path = %path.display(),
client = %name,
"registered downstream via /hub/register and saved credentials"
);
println!(
"已向 Hub 自动注册客户端「{name}」(通用 /hub/register)。\
同名客户端重启后会复用该名称,不会重复堆积。若微信里有多于一个后端,请发送:/use {name}"
);
Ok((hub, vtoken))
}
#[allow(clippy::too_many_arguments)]
pub async fn resolve_hub_connection(
hub_url: &str,
explicit_token: Option<&str>,
cred_file: Option<&str>,
force_pair: bool,
register_client_name: Option<&str>,
force_register: bool,
config_path: Option<&Path>,
description: Option<&str>,
) -> Result<(String, String)> {
let hub = hub_url.trim().trim_end_matches('/').to_string();
if let Some(tok) = explicit_token.map(str::trim).filter(|s| !s.is_empty()) {
validate_hub_token(&hub, tok).await.with_context(|| {
"WEIXIN_TOKEN / --token 未被当前 Hub 接受(未注册或已失效)。\
请用 `ilink-hub register` 或 `ilink-hub-bridge --force-register` 重新注册。"
})?;
return Ok((hub, tok.to_string()));
}
let path = credential_path(cred_file.map(|s| s.trim()).filter(|s| !s.is_empty()));
if force_pair {
let mut opts = HubPairingOptions::new(&hub);
opts.cred_path = Some(path.to_string_lossy().into_owned());
opts.force = true;
let client = HubPairingClient::new(opts)?;
let creds = client.pair().await.context("Hub QR pairing")?;
let base = creds.base_url.trim().trim_end_matches('/').to_string();
return Ok((base, creds.token));
}
match local_credential_state(&path).await? {
LocalCredState::Valid(creds) => {
if let Err(e) = validate_hub_token(&hub, &creds.token).await {
warn!(
error = %e,
path = %path.display(),
"saved bridge token rejected by Hub; removing credentials and re-registering"
);
let saved_name = creds.client_name.as_deref();
let _ = tokio::fs::remove_file(&path).await;
return auto_register_and_save(
&path,
&hub,
register_client_name,
saved_name,
config_path,
description,
)
.await;
}
let stored_base = creds.base_url.trim().trim_end_matches('/');
if stored_base != hub.trim_end_matches('/') {
let name = creds.client_name.as_deref().unwrap_or("");
let _ = write_credentials(&path, &hub, &creds.token, name).await;
}
Ok((hub, creds.token))
}
LocalCredState::Missing => {
auto_register_and_save(
&path,
&hub,
register_client_name,
None,
config_path,
description,
)
.await
}
LocalCredState::ExistsUnusable => {
if force_register {
let _ = tokio::fs::remove_file(&path).await;
auto_register_and_save(
&path,
&hub,
register_client_name,
None,
config_path,
description,
)
.await
} else {
anyhow::bail!(
"凭证文件 {} 已存在但无法使用(内容损坏或 token 为空)。\
为避免静默覆盖扫码配对等已有文件,已停止自动注册。\
请删除该文件、设置 WEIXIN_TOKEN、使用 `--pair`,或加上 `--force-register` 删除该文件后重新自动注册。",
path.display()
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn hub_response_token_rejected_detects_401() {
assert!(hub_response_token_rejected(
reqwest::StatusCode::UNAUTHORIZED,
None
));
assert!(hub_response_token_rejected(
reqwest::StatusCode::OK,
Some(401)
));
assert!(!hub_response_token_rejected(
reqwest::StatusCode::OK,
Some(0)
));
}
#[test]
fn default_auto_client_name_uses_config_stem() {
let name = default_auto_client_name(Some(Path::new("/Users/me/ilink-claude.yaml")));
assert!(name.starts_with("local-"));
assert!(name.ends_with("-ilink-claude"));
}
#[test]
fn sanitize_client_name_segment_replaces_invalid_chars() {
assert_eq!(sanitize_client_name_segment("My Mac.local"), "My-Mac-local");
}
#[test]
fn auto_client_name_prefers_explicit_then_saved() {
assert_eq!(
auto_client_name(Some("custom"), Some("saved"), None),
"custom"
);
assert_eq!(auto_client_name(None, Some("saved"), None), "saved");
}
#[test]
fn adversarial_local_hostname_never_panics() {
let hostname = local_hostname();
assert!(!hostname.is_empty(), "local_hostname must return non-empty");
assert!(
hostname
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '.'),
"hostname must contain only valid chars"
);
}
#[test]
fn adversarial_gethostname_buffer_null_terminated() {
use std::ffi::CStr;
let mut buf = [b'x' as i8; 256];
buf[buf.len() - 1] = 0;
let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) };
let s = cstr.to_str().expect("valid UTF-8");
assert_eq!(s.len(), 255);
assert!(s.chars().all(|c| c == 'x'));
}
}