// 手機遙控: use AnyBot from a phone (or any browser). // // Two ways in, one API: // • 區域網路/Tailscale: this AnyBot answers on port 8793 itself (password login). // • 帳號(薄後端): this AnyBot keeps an outgoing connection to the relay (relay/ in this repo, // run on a small server). The phone signs in to the relay with the account, and the relay hands // each request to this AnyBot. No open port, works from anywhere, and the relay also keeps the // account, the device list and an encrypted backup of AnyBot's data. // // The original keeps its brain in its cloud and reaches devices from there (its device tools wait // 30 s for the device). Here the brain stays in AnyBot — on this Mac, or on an always-on server // started with `anybot --serve` — and the relay only passes requests along. use crate::httpd::{self, Req, Resp}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex, OnceLock}; use std::time::{Duration, Instant}; pub const LAN_PORT: u16 = 8793; /// How long the phone's "anything new?" request waits. pub const LONG_POLL_SECS: u64 = 20; /// Wrong passwords allowed before logins slow down. const MAX_FAILS: u32 = 5; #[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq)] pub struct Device { pub name: String, pub token_hash: String, pub created: i64, #[serde(default)] pub last_seen: i64, } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct Settings { /// Answer phones on the local network (and Tailscale) on LAN_PORT. #[serde(default)] pub lan: bool, /// The phone password, stored as salt$hash. #[serde(default)] pub password: String, /// Phones and browsers signed in directly. #[serde(default)] pub devices: Vec, /// The phone may see and operate this Mac's own screen (not just the bot's computer). #[serde(default)] pub mac_from_phone: bool, /// Other AnyBots in the same account may operate this Mac (裝置連結). Each action still follows /// this Mac's rules and asks the person on the brain's side. #[serde(default)] pub device_link: bool, /// The relay (thin backend), e.g. https://anybot.example.com #[serde(default)] pub relay_url: String, #[serde(default)] pub relay_user: String, /// This AnyBot's own key at the relay (a "host"). #[serde(default)] pub host_id: String, #[serde(default)] pub host_token: String, /// What this AnyBot is called on the phone. #[serde(default = "default_host_name")] pub host_name: String, /// Key for the encrypted backup (made from the account password; never sent). #[serde(default)] pub backup_key: String, #[serde(default)] pub auto_backup: bool, #[serde(default)] pub last_backup: i64, /// 大腦: the AnyBot of this account whose bots, chats and settings this one shows (its relay /// host id). Empty = this AnyBot is its own brain. When set, this one is a screen and hands. #[serde(default, skip_serializing_if = "String::is_empty")] pub brain_id: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub brain_name: String, /// 手腳: only this brain (host id) may use this computer (empty = any AnyBot of the account). #[serde(default, skip_serializing_if = "String::is_empty")] pub hands_for: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub hands_for_name: String, } impl Default for Settings { fn default() -> Self { Settings { lan: false, password: String::new(), devices: vec![], mac_from_phone: false, device_link: false, relay_url: String::new(), relay_user: String::new(), host_id: String::new(), host_token: String::new(), host_name: default_host_name(), backup_key: String::new(), auto_backup: true, last_backup: 0, brain_id: String::new(), brain_name: String::new(), hands_for: String::new(), hands_for_name: String::new() } } } pub fn default_host_name() -> String { // Asked of the system once (it runs a program). static N: OnceLock = OnceLock::new(); N.get_or_init(system_host_name).clone() } fn system_host_name() -> String { if !cfg!(target_os = "macos") { let pretty = std::process::Command::new("hostnamectl").arg("--pretty").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); let name = if pretty.is_empty() { std::fs::read_to_string("/etc/hostname").unwrap_or_default().trim().to_string() } else { pretty }; if !name.is_empty() { return name; } } let out = std::process::Command::new("scutil").args(["--get", "ComputerName"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); if !out.is_empty() { return out; } std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "AnyBot".into()) } /// What the phone asks AnyBot to do; the app carries these out on its own thread. #[derive(Clone, Debug, PartialEq)] pub enum Cmd { /// `from`: the account computer the person typed it on (empty: the phone, or this one). Send { bot: u64, text: String, attachments: Vec, from: String }, Stop { bot: u64 }, Answer { bot: u64, answer: String }, Read { bot: u64 }, NewThread { bot: u64 }, /// The time zone of the phone the person uses (a brain on a server only knows UTC). PersonTimezone(String), /// The phone changes a bot's model or its computer (empty = unchanged). SetBot { bot: u64, provider: String, model: String, computer: String }, /// Answer the last message again (重新產生) / reply to what is there (重新傳送). Regenerate { bot: u64 }, Reply { bot: u64 }, /// Bring back an earlier conversation (過去的對話). RestoreThread { bot: u64, index: usize }, /// 清除聊天記錄 from a screen of this brain (the chat lives here). Clear { bot: u64 }, /// A new phone signed in (to remember), or one was signed out. DeviceAdded(Device), DeviceSeen(String), } /// What the phone can see, refreshed by the app when something changes. #[derive(Default, Clone)] pub struct View { pub host: String, pub bots: Vec, pub approvals: Vec, pub messages: HashMap>, /// 頁面, for the phone's page view. pub pages: Vec, /// The models the phone can switch a bot to: each ready provider with its known models. pub models: Vec, /// The sidebar's 分區 in order (None = 未分區), empty ones too, as the desktop shows them. pub sections: Vec>, pub version: u64, } pub struct Hub { view: Mutex, changed: Condvar, cmds: Mutex>, repaint: Mutex>>, settings: Mutex, /// Wrong logins, counted per source (one noisy LAN device must not lock out the owner). fails: Mutex>, relay_status: Mutex, sync: Mutex>, } /// 大腦: another AnyBot of this account (a screen) asks for what changed, or sends its edits. pub enum SyncKind { /// 手機: something the desktop does with a bot, a page, a routine or a setting, or one of the /// views it shows (a bot's details, 設定), done on the app's thread; `role` is who asks. Phone { action: String, body: Value, role: String }, Pull { client: String, since: u64 }, Edit(Value), /// A provider's model list, fetched with the brain's own key (screens don't have it). Models(String), } pub struct SyncReq { pub kind: SyncKind, pub reply: std::sync::mpsc::Sender, } pub fn hub() -> &'static Hub { static H: OnceLock = OnceLock::new(); H.get_or_init(|| Hub { view: Mutex::new(View::default()), changed: Condvar::new(), cmds: Mutex::new(vec![]), repaint: Mutex::new(None), settings: Mutex::new(Settings::default()), fails: Mutex::new(HashMap::new()), relay_status: Mutex::new(String::new()), sync: Mutex::new(vec![]) }) } static VERSION: AtomicU64 = AtomicU64::new(1); impl Hub { pub fn publish(&self, mut v: View) { v.version = VERSION.fetch_add(1, Ordering::SeqCst) + 1; *self.view.lock().unwrap() = v; self.changed.notify_all(); } pub fn take_sync(&self) -> Vec { std::mem::take(&mut *self.sync.lock().unwrap()) } /// Hand a sync request to the app's thread and wait for its answer. fn ask_sync(&self, kind: SyncKind) -> Result { self.ask_sync_for(kind, 10) } fn ask_sync_for(&self, kind: SyncKind, secs: u64) -> Result { let (tx, rx) = std::sync::mpsc::channel(); self.sync.lock().unwrap().push(SyncReq { kind, reply: tx }); if let Some(r) = self.repaint.lock().unwrap().as_ref() { r(); } rx.recv_timeout(Duration::from_secs(secs)).map_err(|_| "大腦太忙,沒有及時回應".to_string()) } pub fn version(&self) -> u64 { self.view.lock().unwrap().version } pub fn take_cmds(&self) -> Vec { std::mem::take(&mut *self.cmds.lock().unwrap()) } fn push(&self, c: Cmd) { self.cmds.lock().unwrap().push(c); if let Some(r) = self.repaint.lock().unwrap().as_ref() { r(); } } pub fn set_repaint(&self, f: Arc) { *self.repaint.lock().unwrap() = Some(f); } pub fn set_settings(&self, s: &Settings) { *self.settings.lock().unwrap() = s.clone(); } pub fn settings(&self) -> Settings { self.settings.lock().unwrap().clone() } pub fn relay_status(&self) -> String { self.relay_status.lock().unwrap().clone() } fn set_relay_status(&self, s: &str) { *self.relay_status.lock().unwrap() = s.to_string(); } /// The view once it is newer than `since` (or after the wait). fn wait_newer(&self, since: u64, secs: u64) -> View { let g = self.view.lock().unwrap(); let (g, _) = self.changed.wait_timeout_while(g, Duration::from_secs(secs), |v| v.version <= since).unwrap(); g.clone() } } // ---------- shaping the chat for the phone ---------- pub fn tool_label(name: &str) -> &'static str { if crate::appctl::is_app_tool(name) { crate::appctl::label(name) } else if crate::macapps::is_mac_tool(name) { crate::macapps::label(name) } else if crate::computer::is_computer_tool(name) { crate::computer::label(name) } else if crate::web::is_web_tool(name) { crate::web::label(name) } else { crate::tools::label(name) } } fn tool_detail(name: &str, args: &str) -> String { let d = if crate::appctl::is_app_tool(name) { crate::appctl::describe(name, args) } else if crate::computer::is_computer_tool(name) { crate::computer::describe(name, args) } else if crate::web::is_web_tool(name) { crate::web::describe(name, args) } else { crate::tools::describe(name, args) }; d.chars().take(120).collect() } /// One message as the phone shows it (tool results are folded away like in the window). pub fn message_json(m: &crate::model::Message, names: &HashMap, pages: &[crate::pages::Page]) -> Option { use crate::model::{Role, ToolStatus}; if m.role == Role::Tool { return None; } let calls: Vec = m .tool_calls .iter() .map(|c| { let st = match c.status { ToolStatus::Waiting => "waiting", ToolStatus::Running => "running", ToolStatus::Done => "done", ToolStatus::Denied => "denied", ToolStatus::Failed => "failed", }; let page = (c.name == "write_page").then(|| serde_json::from_str::(&c.args).ok().and_then(|a| a["id"].as_u64())).flatten().and_then(|id| pages.iter().find(|p| p.id == id)); if c.name == "show_card" { json!({"card": serde_json::from_str::(&c.args).unwrap_or(Value::Null), "status": st}) } else if let Some(p) = page { json!({"page": crate::pages::card_json(p, crate::model::now_ms()), "status": st}) } else { json!({"label": tool_label(&c.name), "detail": tool_detail(&c.name, &c.args), "status": st}) } }) .collect(); Some(json!({ "role": match m.role { Role::User => "user", _ => "assistant" }, "text": m.text, "via": m.via, "error": m.error, "summary": m.summary, "wake": m.wake, "author": if m.author == 0 { String::new() } else { names.get(&m.author).cloned().unwrap_or_default() }, "reactions": m.reactions, "attachments": m.attachments.iter().map(|a| json!({"name": a.name, "image": a.image, "path": a.path})).collect::>(), "at": m.at, "tools": calls, })) } /// Newest messages kept in the phone's view of each chat. pub const PHONE_TAIL: usize = 120; // ---------- the API (both the local server and the relay call this) ---------- fn devices_ok(token: &str) -> Option { if token.is_empty() { return None; } let h = httpd::sha256_hex(token); hub().settings.lock().unwrap().devices.iter().find(|d| d.token_hash == h).map(|d| d.token_hash.clone()) } /// `via_relay`: the relay already checked the account, so no phone password is needed. pub fn handle(req: Req, via_relay: bool) -> Resp { let parts = req.parts(); let path: Vec<&str> = parts.to_vec(); match (req.method.as_str(), path.as_slice()) { ("GET", []) | ("GET", ["index.html"]) => return page(), ("GET", ["screen"]) => return Resp::bytes("text/html; charset=utf-8", SCREEN_PAGE.as_bytes().to_vec()).with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:"), ("GET", ["novnc.js"]) => return Resp::bytes("text/javascript; charset=utf-8", NOVNC.as_bytes().to_vec()).with_header("Cache-Control", "public, max-age=86400"), ("GET", ["manifest.webmanifest"]) => return Resp::bytes("application/manifest+json", MANIFEST.as_bytes().to_vec()), ("GET", ["icon.svg"]) => return Resp::bytes("image/svg+xml", ICON.as_bytes().to_vec()), ("GET", [name]) if ICON_PNGS.iter().any(|(n, _)| n == name) => return Resp::bytes("image/png", ICON_PNGS.iter().find(|(n, _)| n == name).map(|(_, b)| b.to_vec()).unwrap_or_default()), ("GET", ["api", "hello"]) => { let s = hub().settings(); return Resp::json(&json!({"app": "anybot", "host": s.host_name, "relay": via_relay, "needs_password": !via_relay})); } ("POST", ["api", "login"]) if !via_relay => return login(&req), _ => {} } if !via_relay { match devices_ok(&req.bearer()) { Some(h) => hub().push(Cmd::DeviceSeen(h)), None => return Resp::error(401, "請先登入"), } } let s = hub().settings(); // Direct phones are the owner's. Through the relay, the relay says who is asking. let role = if via_relay { Some(req.header("x-anybot-role")).filter(|r| !r.is_empty()).unwrap_or("owner").to_string() } else { "owner".to_string() }; if !role_allows(&role, &req.method, &path) { return Resp::error(403, "你在這台 AnyBot 的角色不能做這件事(請擁有者處理)"); } match (req.method.as_str(), path.as_slice()) { ("GET", ["api", "state"]) => { let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0); // The phone says where the person is (once per change, not every poll). if let Some(tz) = req.query.get("tz").filter(|t| t.parse::().is_ok()) { static TOLD: Mutex = Mutex::new(String::new()); let mut told = TOLD.lock().unwrap_or_else(|e| e.into_inner()); if *told != *tz { *told = tz.clone(); hub().push(Cmd::PersonTimezone(tz.clone())); } } let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS }); // 頁面 for the phone's home: the newest first, each with its bot. let now = crate::model::now_ms(); let mut pages: Vec<&crate::pages::Page> = v.pages.iter().collect(); pages.sort_by_key(|p| std::cmp::Reverse(p.updated)); let bot_name = |id: u64| v.bots.iter().find(|b| b["id"].as_u64() == Some(id)).and_then(|b| b["name"].as_str()).unwrap_or("").to_string(); let pages: Vec = pages.into_iter().take(12).map(|p| { let mut c = crate::pages::card_json(p, now); c["bot"] = json!(bot_name(p.bot)); c }).collect(); Resp::json(&json!({ "version": v.version, "host": v.host, "bots": v.bots, "approvals": v.approvals, "box": crate::cloudbox::run_state().label(), "box_available": crate::cloudbox::available(), "mac": s.mac_from_phone && cfg!(target_os = "macos") && role != "member", "role": role, "pages": pages, "models": if role == "member" { vec![] } else { v.models.clone() }, "sections": v.sections, })) } // 頁面: the whole page (or an older version with ?rev=N), and its charts drawn as SVG. ("GET", ["api", "pages", id]) => { let id: u64 = id.parse().unwrap_or(0); let v = hub().view.lock().unwrap(); let Some(cur) = v.pages.iter().find(|p| p.id == id) else { return Resp::error(404, "這個頁面已經刪除了") }; let shown = req.query.get("rev").and_then(|r| r.parse().ok()).and_then(|r| crate::pages::version_of(cur, r)).unwrap_or_else(|| cur.clone()); let bot = v.bots.iter().find(|b| b["id"].as_u64() == Some(cur.bot)).and_then(|b| b["name"].as_str()).unwrap_or("").to_string(); let mut out = crate::pages::page_json(&shown, &bot, crate::model::now_ms()); out["versions"] = crate::pages::page_json(cur, &bot, 0)["versions"].clone(); out["latest"] = json!(cur.revision); Resp::json(&out) } ("GET", ["api", "pages", id, "chart", n]) => { let id: u64 = id.parse().unwrap_or(0); let n: usize = n.parse().unwrap_or(0); let body = { let v = hub().view.lock().unwrap(); let Some(cur) = v.pages.iter().find(|p| p.id == id) else { return Resp::error(404, "這個頁面已經刪除了") }; req.query.get("rev").and_then(|r| r.parse().ok()).and_then(|r| crate::pages::version_of(cur, r)).unwrap_or_else(|| cur.clone()).body }; let Some(src) = crate::pages::mermaid_blocks(&body).into_iter().nth(n) else { return Resp::error(404, "找不到這張圖") }; match crate::diagram::svg(&src, req.query.get("dark").map(|d| d == "1").unwrap_or(false)) { Ok(svg) => Resp::bytes("image/svg+xml", svg.into_bytes()), Err(e) => Resp::error(422, &e), } } ("GET", ["api", "bots", id]) => { let id: u64 = id.parse().unwrap_or(0); let v = hub().view.lock().unwrap(); match v.messages.get(&id) { Some(m) => Resp::json(&json!({"version": v.version, "messages": m})), None => Resp::error(404, "找不到這個 Bot"), } } // What the desktop shows beside the chat and in 設定, made when asked (never keys, tokens // or the vault's secrets), and everything else it can do (api/act/). ("GET", ["api", "bots", id, "detail"]) => phone_act("detail", json!({"bot": id.parse::().unwrap_or(0)}), &role), ("GET", ["api", "settings"]) => phone_act("settings", json!({}), &role), ("GET", ["api", "box", "thumb"]) => box_thumb(), ("POST", ["api", "act", action]) => phone_act(action, req.json(), &role), ("POST", ["api", "bots", id, action]) => { let bot: u64 = id.parse().unwrap_or(0); let b = req.json(); let cmd = match *action { "send" => { let text = b["text"].as_str().unwrap_or("").trim().to_string(); let attachments: Vec = b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(); if text.is_empty() && attachments.is_empty() { return Resp::error(400, "訊息是空的"); } // 團隊: the bot knows who is talking when it isn't the owner. let who = req.header("x-anybot-user"); let text = if role != "owner" && !who.is_empty() { format!("【{who}】{text}") } else { text }; Cmd::Send { bot, text, attachments, from: String::new() } } "stop" => Cmd::Stop { bot }, "answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() }, "read" => Cmd::Read { bot }, "new" => Cmd::NewThread { bot }, "regenerate" => Cmd::Regenerate { bot }, "restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize }, "set" => Cmd::SetBot { bot, provider: b["provider"].as_str().unwrap_or("").to_string(), model: b["model"].as_str().unwrap_or("").trim().to_string(), computer: b["computer"].as_str().unwrap_or("").trim().to_string(), }, "upload" => return upload(bot, &req), _ => return Resp::error(404, "沒有這個動作"), }; hub().push(cmd); Resp::json(&json!({"ok": true})) } ("GET", ["api", "screen", which]) => screen(which, &s), ("POST", ["api", "screen", which]) => screen_input(which, &req.json(), &s), ("POST", ["api", "device", "run"]) => device_run(&req.json(), &s, via_relay, req.header("x-anybot-from")), ("GET", ["api", "sync"]) => { let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0); let client = req.query.get("client").cloned().unwrap_or_default(); if since > 0 { let _ = hub().wait_newer(since, LONG_POLL_SECS); } match hub().ask_sync(SyncKind::Pull { client, since }) { Ok(v) => Resp::json(&v), Err(e) => Resp::error(503, &e), } } ("POST", ["api", "sync", "edit"]) => match hub().ask_sync(SyncKind::Edit(req.json())) { Ok(v) if v["error"].is_string() => Resp::error(400, v["error"].as_str().unwrap_or("")), Ok(v) => Resp::json(&v), Err(e) => Resp::error(503, &e), }, ("POST", ["api", "sync", "upload", id]) => upload(id.parse().unwrap_or(0), &req), // A screen of this brain shows the bots' own computer, which lives here: a small picture // (people watch it live through VNC, see vnc.rs). ("GET", ["api", "sync", "screen"]) => match crate::cloudbox::thumbnail(320) { Some(img) => match crate::computer::encode_jpeg(&image::DynamicImage::ImageRgba8(img).to_rgb8()) { Some(j) => Resp::bytes("image/jpeg", j), None => Resp::error(500, "畫面編碼失敗"), }, None => Resp::error(404, "還沒有畫面"), }, // 接管 / 我完成了 from a screen: the bot's steps wait (or not) for the person. ("POST", ["api", "sync", "screen"]) => { let v = req.json(); match v["kind"].as_str().unwrap_or("") { "hold" => { crate::cloudbox::HELD_BY_PERSON.store(v["on"].as_bool().unwrap_or(false), Ordering::SeqCst); Resp::json(&json!({"ok": true})) } "wake" => match crate::cloudbox::ensure_running() { Ok(()) => Resp::json(&json!({"ok": true})), Err(e) => Resp::error(500, &e), }, _ => Resp::error(400, "不認得的操作"), } } ("POST", ["api", "sync", "models"]) => match hub().ask_sync_for(SyncKind::Models(req.json()["provider"].as_str().unwrap_or("").to_string()), 45) { Ok(v) => Resp::json(&v), Err(e) => Resp::error(503, &e), }, // A file shown in a chat (only what AnyBot keeps as attachments). // A file in a chat (a picture the bot showed, a file it sent, one the person attached), // for the phone to show or download. Only from the chats' own file folder. ("GET", ["api", "file"]) => match chat_file(&req.query.get("path").cloned().unwrap_or_default()) { Some((f, b)) => Resp::bytes(content_type(&f), b), None => Resp::error(404, "找不到這個檔案"), }, ("GET", ["api", "sync", "file"]) => { let want = std::path::PathBuf::from(req.query.get("path").cloned().unwrap_or_default()); let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok()); match (want.canonicalize().ok(), root) { (Some(f), Some(root)) if f.starts_with(&root) && f.is_file() => match std::fs::read(&f) { Ok(b) => Resp::bytes("application/octet-stream", b), Err(_) => Resp::error(404, "大腦上找不到這個檔案"), }, _ => Resp::error(404, "大腦上找不到這個檔案"), } } ("POST", ["api", "sync", "act"]) => { let b = req.json(); let bot = b["bot"].as_u64().unwrap_or(0); let cmd = match b["action"].as_str().unwrap_or("") { "send" => Cmd::Send { bot, text: b["text"].as_str().unwrap_or("").trim().to_string(), attachments: b["attachments"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(), // The relay names the computer that asked: the one the person is using. from: req.header("x-anybot-from").to_string(), }, "stop" => Cmd::Stop { bot }, "answer" => Cmd::Answer { bot, answer: b["answer"].as_str().unwrap_or("reject").to_string() }, "read" => Cmd::Read { bot }, "new" => Cmd::NewThread { bot }, "regenerate" => Cmd::Regenerate { bot }, "reply" => Cmd::Reply { bot }, "restore" => Cmd::RestoreThread { bot, index: b["index"].as_u64().unwrap_or(0) as usize }, "clear" => Cmd::Clear { bot }, _ => return Resp::error(404, "沒有這個動作"), }; hub().push(cmd); Resp::json(&json!({"ok": true})) } ("POST", ["api", "logout"]) if !via_relay => { let h = httpd::sha256_hex(&req.bearer()); hub().push(Cmd::DeviceSeen(format!("-{h}"))); Resp::json(&json!({"ok": true})) } _ => Resp::error(404, "沒有這個網址"), } } /// 團隊角色: what each role may do. "member" chats and attaches files; "admin" also approves, /// stops, starts new chats and uses the screens; only "device" (another AnyBot of the owner's /// account) may run tools here. /// A phone action carried out on the app's thread (where the state is), with its answer. fn phone_act(action: &str, body: Value, role: &str) -> Resp { match hub().ask_sync_for(SyncKind::Phone { action: action.to_string(), body, role: role.to_string() }, 20) { Ok(v) if v.get("error").is_some() => Resp::error(400, v["error"].as_str().unwrap_or("做不到")), Ok(v) => Resp::json(&v), Err(e) => Resp::error(503, &e), } } /// The bots' computer's screen, small (the phone's 電腦 card and a hand-off), as JPEG. fn box_thumb() -> Resp { let Some(img) = crate::cloudbox::thumbnail(480) else { return Resp::error(404, "Bot 的電腦沒有開著") }; let mut out = std::io::Cursor::new(vec![]); match image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 72).encode_image(&image::DynamicImage::ImageRgba8(img).to_rgb8()) { Ok(()) => Resp::bytes("image/jpeg", out.into_inner()), Err(e) => Resp::error(500, &e.to_string()), } } pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool { let device = matches!(path, ["api", "device", "run"]); let sync = matches!(path, ["api", "sync", ..]); match role { "device" => device, // 大腦: the owner's other AnyBots showing this one's bots. "mirror" => sync, "owner" | "admin" => !device && !sync, "member" => matches!((method, path), ("GET", ["api", "state"]) | ("GET", ["api", "bots", _]) | ("GET", ["api", "pages", ..]) | ("GET", ["api", "file"]) | ("POST", ["api", "bots", _, "send" | "read" | "upload"])), _ => false, } } /// A chat file by its path, only when it really is inside the chats' file folder. fn chat_file(want: &str) -> Option<(std::path::PathBuf, Vec)> { let root = crate::model::State::path().parent()?.join("attachments").canonicalize().ok()?; let f = std::path::PathBuf::from(want).canonicalize().ok()?; if !f.starts_with(&root) || !f.is_file() { return None; } let b = std::fs::read(&f).ok()?; Some((f, b)) } fn content_type(f: &std::path::Path) -> &'static str { match f.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() { Some("jpg" | "jpeg") => "image/jpeg", Some("png") => "image/png", Some("gif") => "image/gif", Some("webp") => "image/webp", Some("pdf") => "application/pdf", Some("txt" | "md" | "csv") => "text/plain; charset=utf-8", _ => "application/octet-stream", } } fn login(req: &Req) -> Resp { // One counter per source address, cleaned as it is touched. let ip = req.peer.split(':').next().unwrap_or(&req.peer).to_string(); { let mut f = hub().fails.lock().unwrap(); f.retain(|_, (_, t)| t.elapsed() < Duration::from_secs(60)); if let Some((n, t)) = f.get(&ip) { if *n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) { return Resp::error(429, "密碼錯太多次,請一分鐘後再試"); } } } let b = req.json(); let pw = b["password"].as_str().unwrap_or(""); let stored = hub().settings().password; if stored.is_empty() || !httpd::check_password(pw, &stored) { { let mut f = hub().fails.lock().unwrap(); let e = f.entry(ip.clone()).or_insert((0, Instant::now())); if e.1.elapsed() >= Duration::from_secs(60) { *e = (0, Instant::now()); } e.0 += 1; e.1 = Instant::now(); } // Slow guessing down a little more. std::thread::sleep(Duration::from_millis(600)); return Resp::error(401, if stored.is_empty() { "這台 AnyBot 還沒設定手機密碼" } else { "密碼不對" }); } hub().fails.lock().unwrap().remove(&ip); let token = httpd::random_hex(32); let name = b["device"].as_str().filter(|d| !d.trim().is_empty()).unwrap_or("手機").chars().take(40).collect::(); let d = Device { name, token_hash: httpd::sha256_hex(&token), created: chrono::Utc::now().timestamp(), last_seen: chrono::Utc::now().timestamp() }; // Known at once (the app saves it a moment later). hub().settings.lock().unwrap().devices.push(d.clone()); hub().push(Cmd::DeviceAdded(d)); Resp::json(&json!({"token": token})) } fn upload(bot: u64, req: &Req) -> Resp { let name = req.query.get("name").cloned().unwrap_or_else(|| "手機上傳".into()); let name: String = name.chars().filter(|c| !matches!(c, '/' | '\\' | '\0')).take(80).collect(); if req.body.is_empty() { return Resp::error(400, "檔案是空的"); } let dir = std::env::temp_dir().join(format!("anybot-phone-{}", httpd::random_hex(6))); let _ = std::fs::create_dir_all(&dir); let tmp = dir.join(if name.is_empty() { "upload".to_string() } else { name }); if let Err(e) = std::fs::write(&tmp, &req.body) { return Resp::error(500, &e.to_string()); } let r = crate::attach::store(bot, &tmp); let _ = std::fs::remove_dir_all(&dir); match r { Ok(a) => Resp::json(&json!({"path": a.path, "name": a.name, "image": a.image})), Err(e) => Resp::error(400, &e), } } /// A frame of the bot's computer ("box") or this Mac ("mac") as JPEG. /// This Mac's screen for the phone (when allowed). The bots' computer is watched through VNC /// (the page /screen, vnc.rs). fn screen(which: &str, s: &Settings) -> Resp { match which { "mac" if s.mac_from_phone => { match crate::computer::plain_picture() { Ok((w, h, jpeg)) => Resp::bytes("image/jpeg", jpeg).with_header("X-Size", &format!("{w}x{h}")), Err(e) => Resp::error(503, &e), } } "mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作(AnyBot 設定 › 手機與帳號)"), _ => Resp::error(404, "沒有這個畫面"), } } /// The phone taps, types or scrolls on this Mac's screen, as the person, in the pixels of the /// picture it was sent (shot_size). The bots' computer takes the person's input through VNC. fn screen_input(which: &str, v: &Value, s: &Settings) -> Resp { let (x, y) = (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)); let down = v["direction"].as_str().unwrap_or("down") != "up"; let amount = v["amount"].as_i64().unwrap_or(3).clamp(1, 20); match which { "mac" if s.mac_from_phone => { let (name, args) = match v["kind"].as_str().unwrap_or("") { "click" => ("computer_use_click", json!({"x": x, "y": y, "button": v["button"].as_str().unwrap_or("left"), "count": v["count"].as_u64().unwrap_or(1)})), "type" => ("computer_use_typing", json!({"value": v["text"]})), "key" => ("computer_use_press_key", json!({"key": v["key"]})), "scroll" => ("computer_use_scroll", json!({"x": x, "y": y, "direction": if down { "down" } else { "up" }, "amount": amount})), _ => return Resp::error(400, "不認得的操作"), }; match crate::computer::person_act(name, &args) { Ok(()) => Resp::json(&json!({"ok": true})), Err(e) => Resp::error(500, &e), } } "mac" => Resp::error(403, "這台 Mac 沒有開放從手機操作"), _ => Resp::error(404, "沒有這個畫面"), } } /// 裝置連結: another AnyBot in this account (e.g. the one on the server) runs a screen tool on /// this Mac. Only through the relay, and only when this Mac allows it. fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp { if !via_relay || !s.device_link { return Resp::error(403, "這台電腦沒有開放給帳號裡的其他 AnyBot 操作"); } if !s.hands_for.is_empty() && from != s.hands_for { return Resp::error(403, &format!("這台電腦只讓大腦「{}」使用", s.hands_for_name)); } let tool = v["tool"].as_str().unwrap_or(""); let args = v["args"].as_str().unwrap_or("{}"); let who = v["who"].as_str().unwrap_or("遠端 AnyBot"); // The brain's model request to a model only this computer's network reaches. if tool == MODEL_FETCH { return model_fetch_here(args); } if crate::computer::is_computer_tool(tool) { let (text, ok, img) = crate::computer::run_by(tool, args, who); return Resp::json(&json!({"text": text, "ok": ok, "image": img})); } // The AnyBot browser here (its logins are this computer's). if crate::web::is_web_tool(tool) && tool != "web_fetch" { let mut v: Value = serde_json::from_str(args).unwrap_or(json!({})); if let Some(o) = v.as_object_mut() { o.remove("computer"); } let (text, ok, img) = crate::web::run_full(tool, &v.to_string(), &std::sync::atomic::AtomicBool::new(false)); return Resp::json(&json!({"text": text, "ok": ok, "image": img})); } if crate::macapps::is_mac_tool(tool) && cfg!(target_os = "macos") { let (text, ok) = crate::macapps::run(tool, args); return Resp::json(&json!({"text": text, "ok": ok})); } // send_file from this computer: the file itself goes to the brain, which puts it in the chat. if tool == FETCH_FILE { return fetch_file_here(args); } if matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") { // The brain passes the bot's work folder inside the tool's args (empty = this home). let dir = crate::tools::args_of(args).get("workdir").and_then(Value::as_str).unwrap_or("").trim().to_string(); let mut workdir = crate::tools::expand_home(if dir.is_empty() { "~" } else { &dir }); if !workdir.is_absolute() { workdir = dirs::home_dir().unwrap_or(workdir); } let t = crate::tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: crate::tools::Place::Local { workdir } }; let ids = crate::tools::RunIds { conversation: "device".into(), request: httpd::random_hex(6) }; let (text, ok) = crate::tools::run_on(tool, args, &t, &std::sync::atomic::AtomicBool::new(false), &ids); return Resp::json(&json!({"text": text, "ok": ok})); } Resp::error(400, "這個工具不能遠端使用") } /// A file on this computer, for the brain's send_file (the brain has no other way to it). pub const FETCH_FILE: &str = "fetch_file"; /// Largest file sent that way: the relay carries 30 MB, and base64 adds a third. const FETCH_FILE_MAX: u64 = 20 * 1024 * 1024; fn fetch_file_here(args: &str) -> Resp { let v = crate::tools::args_of(args); let path = v["path"].as_str().unwrap_or("").trim().to_string(); let dir = v["workdir"].as_str().unwrap_or("").trim().to_string(); let base = crate::tools::expand_home(if dir.is_empty() { "~" } else { &dir }); let full = crate::tools::resolve(&base, &path); // Same rule as reading a file: keys, the app's own data and other secrets never leave. if crate::tools::is_ignored(&full) || crate::tools::is_ignored(std::path::Path::new(&path)) { return Resp::json(&json!({"text": "這個檔案是機密的,不能傳到聊天。", "ok": false})); } match std::fs::metadata(&full) { Ok(m) if m.is_dir() => Resp::json(&json!({"text": format!("「{path}」是資料夾,請先壓縮成一個檔案。"), "ok": false})), Ok(m) if m.len() > FETCH_FILE_MAX => Resp::json(&json!({"text": format!("「{path}」太大了(最多 20 MB)。"), "ok": false})), Ok(_) => match std::fs::read(&full) { Ok(b) => { use base64::Engine; let name = full.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "檔案".into()); Resp::json(&json!({"ok": true, "text": "", "name": name, "data": base64::engine::general_purpose::STANDARD.encode(&b)})) } Err(e) => Resp::json(&json!({"text": format!("讀不到「{path}」:{e}"), "ok": false})), }, Err(_) => Resp::json(&json!({"text": format!("這台電腦上找不到「{path}」。"), "ok": false})), } } // ---------- a model on the person's network, for a brain on a server ---------- // A brain in the cloud can't reach a model at a private address (a Qwen server at work, Ollama at // home). Its hands can: the brain hands them the request and gets the whole answer back. const MODEL_FETCH: &str = "model_fetch"; /// Private, link-local or loopback address (by name: localhost and *.local). fn private_host(host: &str) -> bool { let h = host.trim_matches(['[', ']']); if h == "localhost" || h.ends_with(".local") { return true; } match h.parse::() { // Not link-local: a cloud VM's metadata service (169.254.169.254) must never be // reachable through the hands, only real model servers on the person's network. Ok(std::net::IpAddr::V4(a)) => (a.is_private() || a.is_loopback()) && !a.is_link_local(), Ok(std::net::IpAddr::V6(a)) => (a.is_loopback() || (a.segments()[0] & 0xfe00) == 0xfc00) && !a.is_unicast_link_local(), Err(_) => false, } } fn host_port(url: &str) -> Option<(String, u16)> { let (scheme, rest) = url.split_once("://")?; let auth = rest.split('/').next()?; let auth = auth.rsplit('@').next()?; let (h, p) = match auth.rsplit_once(':') { Some((h, p)) if !h.ends_with(']') || auth.starts_with('[') => (h.to_string(), p.parse().ok()?), _ => (auth.to_string(), if scheme == "https" { 443 } else { 80 }), }; Some((h, p)) } /// The hands to send a model request through: the model is at a private address this brain /// can't open a connection to, and the person's computer lends this brain its hands. pub fn model_hands_for(url: &str) -> Option { let s = hub().settings(); // The person's own computer (the one they type on) is the one on their network; the first // hands by id was a desktop in the server room that can't reach a model at their office. let hands = my_hands(&s.host_id); let person = person_host(); let hand = hands.iter().find(|h| **h == person).or_else(|| hands.first()).cloned()?; let (h, p) = host_port(url)?; if !private_host(&h) || h == "localhost" || h.starts_with("127.") { return None; } static SEEN: Mutex>> = Mutex::new(None); let key = format!("{h}:{p}"); if let Some((reach, at)) = SEEN.lock().unwrap().get_or_insert_with(HashMap::new).get(&key) { if at.elapsed().as_secs() < 300 { return if *reach { None } else { Some(hand) }; } } use std::net::ToSocketAddrs; let reach = (h.as_str(), p).to_socket_addrs().ok().and_then(|mut a| a.next()).map(|a| std::net::TcpStream::connect_timeout(&a, std::time::Duration::from_millis(1500)).is_ok()).unwrap_or(false); SEEN.lock().unwrap().get_or_insert_with(HashMap::new).insert(key, (reach, std::time::Instant::now())); if reach { None } else { Some(hand) } } /// Send a model request (POST, JSON) through the hands; gives back its status and whole body. pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body: &str) -> Result<(u16, Vec), String> { let args = json!({"url": url, "headers": headers, "body": body}).to_string(); let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string(); let name = peer_name(hand); let whom = if name.is_empty() { "使用者的電腦".to_string() } else { format!("「{name}」") }; let r = crate::tunnel::call(hand, "POST", "/api/device/run", &[], req.as_bytes(), 600).ok_or_else(|| format!("模型在只有{whom}連得到的網路上,但大腦現在和帳號伺服器沒有連線"))?; let a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?; let v = answer_value(a.status, &a.body, &whom)?; if let Some(e) = v["error"].as_str() { return Err(format!("{whom}也連不到模型:{e}")); } Ok((v["status"].as_u64().unwrap_or(502) as u16, v["text"].as_str().unwrap_or("").as_bytes().to_vec())) } /// On the hands: make the brain's model request, only to an address on this computer's network. fn model_fetch_here(args: &str) -> Resp { let v: Value = serde_json::from_str(args).unwrap_or(Value::Null); let url = v["url"].as_str().unwrap_or(""); if !host_port(url).map(|(h, _)| private_host(&h)).unwrap_or(false) { return Resp::error(403, "只幫大腦連這台電腦網路裡的模型"); } let mut r = agent(600).post(url).header("Content-Type", "application/json"); for h in v["headers"].as_array().into_iter().flatten() { if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) { r = r.header(k, val); } } match r.config().http_status_as_error(false).build().send(v["body"].as_str().unwrap_or("").as_bytes()) { Ok(mut resp) => { let status = resp.status().as_u16(); let text = resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_string().unwrap_or_default(); Resp::json(&json!({"status": status, "text": text})) } Err(e) => Resp::json(&json!({"error": why_unreachable(&e)})), } } // ---------- the local server ---------- static LAN_GEN: AtomicU64 = AtomicU64::new(0); static LAN_BOUND: Mutex> = Mutex::new(None); pub fn lan_port() -> Option { *LAN_BOUND.lock().unwrap() } /// Start (or stop) answering phones on the local network. pub fn set_lan(on: bool) -> Result { let gen = LAN_GEN.fetch_add(1, Ordering::SeqCst) + 1; *LAN_BOUND.lock().unwrap() = None; if !on { return Ok(0); } let port_env: Option = std::env::var("ANYBOT_PHONE_PORT").ok().and_then(|p| p.parse().ok()); let start = port_env.unwrap_or(LAN_PORT); // The old listener notices the new generation within 50 ms; give it the port back. let listener = (0..40) .find_map(|_| { let l = (start..start + 10).find_map(|p| std::net::TcpListener::bind(("0.0.0.0", p)).ok()); if l.is_none() { std::thread::sleep(Duration::from_millis(60)); } l }) .ok_or("找不到可用的連接埠")?; let port = listener.local_addr().map_err(|e| e.to_string())?.port(); *LAN_BOUND.lock().unwrap() = Some(port); std::thread::spawn(move || httpd::serve_upgrade(listener, Arc::new(|r| handle(r, false)), Some(Arc::new(lan_vnc)), Arc::new(move || LAN_GEN.load(Ordering::SeqCst) == gen))); Ok(port) } /// The phone page on the local network watching the bots' computer: /api/vnc, signed in with /// the phone's password like every other request here; the VNC stream goes straight to the box. fn lan_vnc(req: Req, mut c: std::net::TcpStream) { use tungstenite::{protocol::Role, Message, WebSocket}; if req.path != "/api/vnc" { return httpd::write_response(&mut c, &Resp::error(404, "沒有這個網址")); } if devices_ok(&req.bearer()).is_none() { return httpd::write_response(&mut c, &Resp::error(401, "請先登入")); } if !httpd::accept_websocket(&req, &mut c) { return; } let _ = c.set_read_timeout(Some(Duration::from_millis(2))); let mut ws = WebSocket::from_raw_socket(c, Role::Server, None); let (tx, rx) = std::sync::mpsc::channel::>(); let send = move |b: &[u8]| tx.send(b.to_vec()).is_ok(); let Ok(mut session) = crate::vnc::open(send.clone()) else { return }; if !send(crate::vnc::GREETING) { return; } 'live: loop { match ws.read() { Ok(Message::Binary(b)) => { if !session.feed(&b, &send) { break; } } Ok(Message::Text(t)) => crate::vnc::person_typed(&t), Ok(Message::Close(_)) => break, Ok(_) => {} Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {} Err(_) => break, } loop { match rx.try_recv() { // An empty send: the box's side ended. Ok(b) if b.is_empty() => break 'live, Ok(b) => { if ws.write(Message::Binary(b.into())).is_err() { break 'live; } } Err(_) => break, } } match ws.flush() { Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {} Err(_) => break, Ok(()) => {} } } let _ = ws.close(None); } /// Addresses a phone can open (local network and Tailscale). pub fn addresses(port: u16) -> Vec { // macOS: ifconfig ("inet 192.168.1.5 netmask…"); Linux: `ip -4 -o addr` ("… inet 192.168.1.5/24 …"). let mut out = std::process::Command::new("ifconfig").output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default(); if out.trim().is_empty() { out = std::process::Command::new("ip").args(["-4", "-o", "addr"]).output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default(); } let mut v: Vec = out .lines() .filter_map(|l| l.split("inet ").nth(1)) .filter_map(|l| l.split_whitespace().next()) .map(|ip| ip.split('/').next().unwrap_or(ip)) .filter(|ip| !ip.starts_with("127.") && !ip.starts_with("169.254.")) .map(|ip| format!("http://{ip}:{port}")) .collect(); v.dedup(); v } // ---------- the relay connection (this AnyBot as a "host") ---------- static RELAY_GEN: AtomicU64 = AtomicU64::new(0); /// One agent per timeout, kept: its connections stay open between requests (no new TCP and TLS /// handshake each time). fn agent(secs: u64) -> ureq::Agent { static AGENTS: OnceLock>> = OnceLock::new(); AGENTS .get_or_init(Mutex::default) .lock() .unwrap() .entry(secs) .or_insert_with(|| ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).http_status_as_error(false).build().into()) .clone() } /// Why a request never got an answer, in plain words (never an error code). pub fn why_unreachable(e: &ureq::Error) -> String { use std::io::ErrorKind as K; use ureq::Error as E; let text = e.to_string().to_lowercase(); // A TLS failure often comes wrapped as an I/O error: look at the words first. if text.contains("certificate") || text.contains("tls") || text.contains("handshake") { return "和伺服器的安全連線失敗:對方的憑證不對(網址可能連到了別的服務,或憑證過期)".to_string(); } let w = match e { E::HostNotFound => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了", E::ConnectionFailed => "伺服器沒有回應:可能沒開機,或網路把連線擋住了", E::Timeout(_) => "等太久沒有回應:網路很慢或斷斷續續", E::Io(io) => match io.kind() { K::ConnectionRefused => "伺服器沒有開(連線被拒絕)", K::ConnectionReset | K::ConnectionAborted | K::BrokenPipe | K::UnexpectedEof => "連線到一半斷掉了", K::TimedOut => "等太久沒有回應:網路很慢或斷斷續續", K::NetworkUnreachable | K::HostUnreachable | K::NetworkDown | K::AddrNotAvailable => "這台電腦沒有連上網路", _ => "網路連線出了問題", }, _ if text.contains("dns") || text.contains("resolve") => "找不到伺服器:這台電腦可能沒連上網路,或伺服器網址打錯了", _ => "網路連線出了問題", }; w.to_string() } /// An answer that wasn't a yes, in plain words. `whom` names the other side (大腦, 那台電腦…). pub fn why_refused(st: u16, v: &Value, whom: &str) -> String { if let Some(e) = v["error"].as_str().filter(|e| !e.is_empty()) { return e.replace("那台 AnyBot ", whom).replace("那台 AnyBot", whom); } match st { 401 | 403 => "這台電腦的帳號登入失效了,請到「設定 › 手機與帳號」重新登入".into(), 404 => format!("{whom}不在這個帳號裡了(可能被移除,或兩邊 AnyBot 版本不同)"), 413 => "檔案太大,帳號伺服器不收".into(), 502 | 503 => "帳號伺服器暫時無法使用(可能正在重新啟動),稍後會自動再試".into(), 504 => format!("{whom}沒有及時回應(可能太忙或網路很慢)"), 500..=599 => "帳號伺服器出了問題,稍後會自動再試".into(), _ => format!("{whom}的回應看不懂,請把兩邊的 AnyBot 都更新到最新版"), } } pub fn relay_post(url: &str, bearer: &str, body: &Value) -> Result { let mut r = agent(30).post(url).header("Content-Type", "application/json"); if !bearer.is_empty() { r = r.header("Authorization", &format!("Bearer {bearer}")); } let mut resp = r.send(body.to_string().as_bytes()).map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; let st = resp.status().as_u16(); let text = resp.body_mut().read_to_string().unwrap_or_default(); let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); if !(200..300).contains(&st) { return Err(why_refused(st, &v, "帳號伺服器")); } Ok(v) } fn base(url: &str) -> String { url.trim().trim_end_matches('/').to_string() } /// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts. /// Returns the settings to keep. pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &str, create: bool, invite: &str) -> Result { // The relay signs people in by the trimmed, lowered name; the backup key must be derived // from exactly the same string, or two computers signing in as "Dan" and "dan" get keys // that cannot open each other's backups. let user = user.trim().to_lowercase(); let user = user.as_str(); let host_name = base_settings.host_name.as_str(); let url = base(url); // Plain http only inside one machine or one Docker network (e.g. http://relay:8080). let host = url.strip_prefix("http://").map(|r| r.split(['/', ':']).next().unwrap_or("")).unwrap_or(""); // Tailscale (100.64.0.0/10 and *.ts.net) is already encrypted end to end. let tailscale = host.ends_with(".ts.net") || host.split('.').collect::>().as_slice().first().map(|a| *a == "100").unwrap_or(false) && host.split('.').nth(1).and_then(|b| b.parse::().ok()).map(|b| (64..128).contains(&b)).unwrap_or(false); let internal = host == "127.0.0.1" || host == "localhost" || tailscale || (!host.is_empty() && !host.contains('.')); if !url.starts_with("https://") && !internal { return Err("帳號伺服器要用 https:// 開頭的網址(這台電腦或 Tailscale 位址可以用 http://)".into()); } if create { if let Err(e) = relay_post(&format!("{url}/relay/signup"), "", &json!({"user": user, "password": password, "invite": invite})) { // The account may be there already (made earlier, or on the phone): just sign in with it. if relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": host_name})).is_err() { return Err(if e.contains("不開放註冊") || e.contains("邀請碼") { "這台帳號伺服器已經有帳號了,不能再註冊新的。請取消勾選「建立新帳號」,用已經有的帳號登入。忘記密碼的話,在帳號伺服器那台電腦執行:anybot-relay --data 資料夾 --reset-password 帳號".into() } else { e }); } } } let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": host_name}))?; let session = v["token"].as_str().ok_or("帳號伺服器沒有給登入憑證")?.to_string(); let h = relay_post(&format!("{url}/relay/hosts"), &session, &json!({"name": host_name}))?; // The desktop doesn't need to stay signed in as a person; only the host key is kept. let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({})); let mut s = base_settings.clone(); s.relay_url = url; s.relay_user = user.to_string(); s.host_id = h["id"].as_str().unwrap_or("").to_string(); s.host_token = h["token"].as_str().unwrap_or("").to_string(); s.backup_key = httpd::derive_key(password, user).iter().map(|b| format!("{b:02x}")).collect(); if s.host_id.is_empty() || s.host_token.is_empty() { return Err("帳號伺服器沒有給這台電腦的金鑰".into()); } Ok(s) } /// Keep the outgoing connection to the relay: wait for the phone's requests and answer them. pub fn relay_restart(s: &Settings) { let gen = RELAY_GEN.fetch_add(1, Ordering::SeqCst) + 1; if s.relay_url.is_empty() || s.host_token.is_empty() { hub().set_relay_status(""); return; } let (url, token) = (s.relay_url.clone(), s.host_token.clone()); hub().set_relay_status("連線中…"); std::thread::spawn(move || { // 長連線 first; an older account server without it gets asked over and over instead. if crate::tunnel::run(&url, &token, &|| alive(gen), &|m| hub().set_relay_status(m)) || !alive(gen) { return; } // A few workers, so a slow request (a long-poll) doesn't block the others. for _ in 0..4 { let (url, token) = (url.clone(), token.clone()); std::thread::spawn(move || relay_worker(&url, &token, gen)); } }); } fn alive(gen: u64) -> bool { RELAY_GEN.load(Ordering::SeqCst) == gen } fn relay_worker(url: &str, token: &str, gen: u64) { let mut backoff = 1u64; while alive(gen) { let r = agent(40).get(&format!("{url}/relay/host/next")).header("Authorization", &format!("Bearer {token}")).call(); let mut resp = match r { Ok(r) => r, Err(e) => { hub().set_relay_status(&format!("連不上帳號伺服器:{}", why_unreachable(&e))); std::thread::sleep(Duration::from_secs(backoff)); backoff = (backoff * 2).min(30); continue; } }; let st = resp.status().as_u16(); if st == 401 { hub().set_relay_status("這台電腦的金鑰失效了,請重新登入帳號"); return; } if st == 204 { hub().set_relay_status("已連線"); backoff = 1; continue; } let text = resp.body_mut().read_to_string().unwrap_or_default(); let Ok(job) = serde_json::from_str::(&text) else { hub().set_relay_status(&format!("連不上帳號伺服器:{}", why_refused(st, &Value::Null, "帳號伺服器"))); std::thread::sleep(Duration::from_secs(backoff)); backoff = (backoff * 2).min(30); continue; }; hub().set_relay_status("已連線"); backoff = 1; let id = job["id"].as_str().unwrap_or("").to_string(); let req = req_from_json(&job); // Answer on a separate thread so this worker can pick up the next request at once. let (url, token) = (url.to_string(), token.to_string()); std::thread::spawn(move || { let resp = handle(req, true); let _ = agent(60).post(&format!("{url}/relay/host/reply/{id}")).header("Authorization", &format!("Bearer {token}")).header("Content-Type", "application/json").send(resp_to_json(&resp).to_string().as_bytes()); }); } } pub fn req_from_json(job: &Value) -> Req { use base64::Engine; Req { method: job["method"].as_str().unwrap_or("GET").to_string(), path: job["path"].as_str().unwrap_or("/").to_string(), query: job["query"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())).collect()).unwrap_or_default(), // Who asked, as the relay vouches for it (團隊: owner / admin / member; "device" for 裝置連結). headers: [("x-anybot-user", job["user"].as_str().unwrap_or("")), ("x-anybot-role", job["role"].as_str().unwrap_or("owner")), ("x-anybot-from", job["from"].as_str().unwrap_or(""))].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), body: { let b = base64::engine::general_purpose::STANDARD.decode(job["body"].as_str().unwrap_or("")).unwrap_or_default(); if job["z"].as_bool().unwrap_or(false) { crate::tunnel::unzip(&b).unwrap_or_default() } else { b } }, peer: "relay".into(), } } pub fn resp_to_json(r: &Resp) -> Value { use base64::Engine; json!({"status": r.status, "ctype": r.ctype, "body": base64::engine::general_purpose::STANDARD.encode(&r.body), "headers": r.headers}) } /// Upload an encrypted copy of AnyBot's data to the relay. pub fn backup_now(s: &Settings, state_json: &[u8]) -> Result { let key = key_of(&s.backup_key).ok_or("還沒登入帳號")?; let sealed = httpd::seal(&key, state_json); let mut resp = agent(120) .put(&format!("{}/relay/backup", s.relay_url)) .header("Authorization", &format!("Bearer {}", s.host_token)) .header("Content-Type", "application/octet-stream") .send(&sealed[..]) .map_err(|e| format!("備份上傳失敗:{e}"))?; let st = resp.status().as_u16(); if !(200..300).contains(&st) { return Err(format!("備份上傳失敗:{}", resp.body_mut().read_to_string().unwrap_or_default())); } Ok(sealed.len()) } /// Download and open the newest backup of this account (for a new computer). pub fn restore(url: &str, user: &str, password: &str) -> Result, String> { // Same name shape the relay (and relay_join) use for the backup key. let user = user.trim().to_lowercase(); let user = user.as_str(); let url = base(url); let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": "還原備份"}))?; let session = v["token"].as_str().unwrap_or("").to_string(); let mut resp = agent(120).get(&format!("{url}/relay/backup")).header("Authorization", &format!("Bearer {session}")).call().map_err(|e| e.to_string())?; let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({})); if resp.status().as_u16() == 404 { return Err("這個帳號還沒有備份".into()); } let sealed = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())?; httpd::open(&httpd::derive_key(password, user), &sealed).ok_or_else(|| "備份打不開(密碼不對,或備份壞了)".to_string()) } fn key_of(hex: &str) -> Option<[u8; 32]> { if hex.len() != 64 { return None; } let mut k = [0u8; 32]; for i in 0..32 { k[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?; } Some(k) } /// 裝置連結: run a tool on another AnyBot of this account (through the relay). pub fn run_on_device(host_id: &str, tool: &str, args: &str, who: &str) -> Result { let s = hub().settings(); if s.relay_url.is_empty() { return Err("沒有登入帳號,連不到其他裝置".into()); } let body = json!({"tool": tool, "args": args, "who": who}).to_string(); if let Some(r) = crate::tunnel::call(host_id, "POST", "/api/device/run", &[], body.as_bytes(), 50) { return r.map_err(|e| format!("連不到那台電腦:{e}")).and_then(|a| answer_value(a.status, &a.body, "那台電腦")); } let url = format!("{}/relay/hosts/{host_id}/api/device/run", s.relay_url); let mut resp = agent(45) .post(&url) .header("Authorization", &format!("Bearer {}", s.host_token)) .header("Content-Type", "application/json") .send(json!({"tool": tool, "args": args, "who": who}).to_string().as_bytes()) .map_err(|e| format!("連不到帳號伺服器,所以也連不到那台電腦:{}", why_unreachable(&e)))?; let st = resp.status().as_u16(); let text = resp.body_mut().with_config().limit(40 * 1024 * 1024).read_to_string().unwrap_or_default(); let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); if !(200..300).contains(&st) { return Err(why_refused(st, &v, "那台電腦")); } Ok(v) } // ---------- 大腦: this AnyBot as a screen of another ---------- fn brain_url(s: &Settings, rest: &str) -> Result { if s.relay_url.is_empty() || s.host_token.is_empty() { return Err("沒有登入帳號".into()); } if s.brain_id.is_empty() { return Err("沒有選大腦".into()); } Ok(format!("{}/relay/hosts/{}/api/sync{rest}", s.relay_url, s.brain_id)) } /// A JSON answer, or what went wrong in words. fn answer_value(st: u16, bytes: &[u8], whom: &str) -> Result { let v: Value = serde_json::from_slice(bytes).unwrap_or(Value::Null); if !(200..300).contains(&st) { return Err(why_refused(st, &v, whom)); } if v.is_null() { return Err(format!("{whom}的回應看不懂,請把兩邊的 AnyBot 都更新到最新版")); } Ok(v) } /// Ask the brain: over the lasting connection when it is open, else an ordinary request. fn to_brain(s: &Settings, method: &str, rest: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Result { let (st, bytes) = to_brain_raw(s, method, rest, query, body, secs)?; answer_value(st, &bytes, "大腦") } fn to_brain_raw(s: &Settings, method: &str, rest: &str, query: &[(&str, String)], body: &[u8], secs: u64) -> Result<(u16, Vec), String> { if s.brain_id.is_empty() { return Err("沒有選大腦".into()); } if let Some(r) = crate::tunnel::call(&s.brain_id, method, &format!("/api/sync{rest}"), query, body, secs) { return r.map(|a| (a.status, a.body)); } let q: Vec = query.iter().map(|(k, v)| format!("{k}={}", httpd::url_encode(v))).collect(); let url = brain_url(s, &format!("{rest}{}{}", if q.is_empty() { "" } else { "?" }, q.join("&")))?; let auth = format!("Bearer {}", s.host_token); let r = if method == "GET" { agent(secs).get(&url).header("Authorization", &auth).call() } else { agent(secs).post(&url).header("Authorization", &auth).header("Content-Type", "application/octet-stream").send(body) }; let mut resp = r.map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; let st = resp.status().as_u16(); let bytes = resp.body_mut().with_config().limit(200 * 1024 * 1024).read_to_vec().map_err(|_| "和大腦的連線到一半斷掉了".to_string())?; Ok((st, bytes)) } /// What changed at the brain since `since` (waits up to LONG_POLL_SECS when nothing has). pub fn sync_pull(s: &Settings, since: u64, client: &str) -> Result { to_brain(s, "GET", "", &[("since", since.to_string()), ("client", client.to_string())], &[], LONG_POLL_SECS + 25) } /// Send edits ("edit") or a chat action ("act") to the brain. pub fn sync_post(s: &Settings, what: &str, body: &Value) -> Result { to_brain(s, "POST", &format!("/{what}"), &[], body.to_string().as_bytes(), 60) } /// A file for the next message, kept at the brain; gives back its path there. pub fn sync_upload(s: &Settings, bot: u64, name: &str, bytes: &[u8]) -> Result { let v = to_brain(s, "POST", &format!("/upload/{bot}"), &[("name", name.to_string())], bytes, 120)?; v["path"].as_str().map(String::from).ok_or("大腦沒有收下檔案".into()) } /// The brain's bots' computer as a JPEG (`thumb`: the small picture that never wakes it). pub fn sync_screen(s: &Settings, thumb: bool) -> Result, String> { let (st, bytes) = to_brain_raw(s, "GET", "/screen", &[("thumb", if thumb { "1" } else { "0" }.to_string())], &[], 30)?; if !(200..300).contains(&st) { return Err(why_refused(st, &serde_json::from_slice(&bytes).unwrap_or(Value::Null), "大腦")); } Ok(bytes) } /// A link for this account's browser to watch the brain's computer (/screen on the account /// server; one-time, it works for ten minutes). pub fn screen_ticket(s: &Settings) -> Result { if s.relay_url.is_empty() || s.brain_id.is_empty() { return Err("這台沒有連到帳號的大腦".into()); } let url = format!("{}/relay/screen-ticket", s.relay_url.trim_end_matches('/')); let mut r = agent(15) .post(&url) .header("Authorization", &format!("Bearer {}", s.host_token)) .header("Content-Type", "application/json") .send(json!({"host": s.brain_id}).to_string().as_bytes()) .map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; let v: Value = r.body_mut().read_json().map_err(|_| "帳號伺服器的回答看不懂".to_string())?; v["ticket"].as_str().map(String::from).ok_or_else(|| "帳號伺服器沒有給看畫面的連結(它可能是舊版,請更新)".into()) } /// A file the brain keeps (an attachment shown in a chat), as bytes. pub fn sync_file(s: &Settings, path: &str) -> Result, String> { let (st, bytes) = to_brain_raw(s, "GET", "/file", &[("path", path.to_string())], &[], 120)?; if !(200..300).contains(&st) { return Err(why_refused(st, &serde_json::from_slice(&bytes).unwrap_or(Value::Null), "大腦")); } Ok(bytes) } /// A notification on the person's phones (through the relay; phones that turned them on). pub fn notify(title: &str, body: &str, bot: u64) { let s = hub().settings(); if s.relay_url.is_empty() || s.host_token.is_empty() { return; } let (title, body) = (title.to_string(), body.to_string()); std::thread::spawn(move || { let _ = relay_post(&format!("{}/relay/host/notify", s.relay_url), &s.host_token, &json!({"title": title, "body": body, "bot": bot})); }); } /// 發布網站: upload a folder (or one page) to the account server; returns the public address. pub fn publish_site(name: &str, local: &std::path::Path) -> Result { use base64::Engine; let s = hub().settings(); if s.relay_url.is_empty() || s.host_token.is_empty() { return Err("要先在 設定 › 手機與帳號 登入帳號,才能發布網站".into()); } let mut files = vec![]; let mut total = 0u64; if local.is_file() { let bytes = std::fs::read(local).map_err(|e| e.to_string())?; let is_html = local.extension().map(|e| e == "html" || e == "htm").unwrap_or(false); let n = if is_html { "index.html".to_string() } else { local.file_name().unwrap_or_default().to_string_lossy().to_string() }; if bytes.len() > 25 * 1024 * 1024 { return Err("網頁太大了(最多 25 MB)".into()); } files.push(json!({"path": n, "data": base64::engine::general_purpose::STANDARD.encode(bytes)})); } else { let mut stack = vec![local.to_path_buf()]; while let Some(d) = stack.pop() { for e in std::fs::read_dir(&d).map_err(|e| format!("讀不到資料夾:{e}"))?.flatten() { let p = e.path(); let fname = e.file_name().to_string_lossy().to_string(); if fname.starts_with('.') || fname == "node_modules" { continue; } if p.is_dir() { stack.push(p); } else if let Ok(bytes) = std::fs::read(&p) { total += bytes.len() as u64; if files.len() >= 500 || total > 25 * 1024 * 1024 { return Err("網站太大了(最多 500 個檔案、25 MB)".into()); } let rel = p.strip_prefix(local).unwrap_or(&p).to_string_lossy().replace('\\', "/"); files.push(json!({"path": rel, "data": base64::engine::general_purpose::STANDARD.encode(bytes)})); } } } } if !files.iter().any(|f| f["path"] == "index.html") { return Err("找不到 index.html(網站的首頁)".into()); } let v = relay_post(&format!("{}/relay/host/sites/{name}", s.relay_url), &s.host_token, &json!({"files": files}))?; let url = v["url"].as_str().filter(|u| !u.is_empty()).map(String::from).unwrap_or_else(|| format!("{}{}", s.relay_url, v["path"].as_str().unwrap_or(""))); Ok(url) } /// 團隊: who else this AnyBot is shared with (user, role). pub fn members(s: &Settings) -> Result, String> { let mut r = agent(10).get(&format!("{}/relay/host/members", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call().map_err(|e| e.to_string())?; let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); Ok(v["members"].as_array().map(|a| a.iter().map(|m| (m["user"].as_str().unwrap_or("").to_string(), m["role"].as_str().unwrap_or("").to_string())).collect()).unwrap_or_default()) } /// Share with another account ("member" / "admin"), or stop sharing ("remove"). pub fn set_member(s: &Settings, user: &str, role: &str) -> Result<(), String> { relay_post(&format!("{}/relay/host/members", s.relay_url), &s.host_token, &json!({"user": user, "role": role})).map(|_| ()) } /// The other AnyBots signed in to this account (for 裝置連結). /// 加一台電腦: a code (ten minutes, once) another computer uses to join this account, as hands for /// `for_host` (empty = any AnyBot of the account). pub fn pair_code(s: &Settings, for_host: &str) -> Result { let v = relay_post(&format!("{}/relay/pair", s.relay_url), &s.host_token, &json!({"for": for_host}))?; v["code"].as_str().map(String::from).ok_or("帳號伺服器沒有給配對碼".into()) } /// Join the account with a 配對碼 (no password). Gives the settings to keep. pub fn pair_claim(base_settings: &Settings, url: &str, code: &str) -> Result { let url = check_server(url)?; let v = relay_post(&format!("{url}/relay/pair/claim"), "", &json!({"code": code, "name": base_settings.host_name}))?; let mut s = base_settings.clone(); s.relay_url = url; s.relay_user = v["user"].as_str().unwrap_or("").to_string(); s.host_id = v["id"].as_str().unwrap_or("").to_string(); s.host_token = v["token"].as_str().unwrap_or("").to_string(); // No password here, so no key for backups (hands keep nothing to back up). s.backup_key.clear(); s.hands_for = v["for"].as_str().unwrap_or("").to_string(); s.hands_for_name = v["for_name"].as_str().unwrap_or("").to_string(); if s.host_id.is_empty() || s.host_token.is_empty() { return Err("帳號伺服器沒有給這台電腦的金鑰".into()); } Ok(s) } /// The account's AnyBots as the account server sees them (for choosing a brain). pub fn peers(s: &Settings) -> Result, String> { let mut r = agent(15).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call().map_err(|e| format!("連不到帳號伺服器:{}", why_unreachable(&e)))?; let st = r.status().as_u16(); let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); if !(200..300).contains(&st) { return Err(why_refused(st, &v, "帳號伺服器")); } Ok(v["hosts"].as_array().cloned().unwrap_or_default()) } /// Right after signing in: where this account's bots already are. Another AnyBot of the account /// that is on now and thinks for itself (not hands, not showing another) is the brain to sync /// with; with none, the account's newest backup (if there is one) is what to take in. pub struct AccountFind { pub brain: Option<(String, String)>, pub backup: Option>, } pub fn find_account_bots(s: &Settings, password: &str) -> AccountFind { let brain = peers(s).ok().and_then(|hosts| pick_brain(&hosts, &s.host_id)); let backup = if brain.is_none() { restore(&s.relay_url, &s.relay_user, password).ok() } else { None }; AccountFind { brain, backup } } /// The brain among the account's AnyBots (relay `hosts` rows), never this one. pub fn pick_brain(hosts: &[Value], me: &str) -> Option<(String, String)> { hosts .iter() .filter(|h| h["online"].as_bool() == Some(true) && h["hands"].as_bool() != Some(true) && h["uses"].as_str().unwrap_or("").is_empty()) .filter_map(|h| Some((h["id"].as_str()?.to_string(), h["name"].as_str().unwrap_or("AnyBot").to_string()))) .find(|(id, _)| !id.is_empty() && id != me) } /// Is this an AnyBot account server? (Gives the address to use.) pub fn check_server(url: &str) -> Result { let mut u = url.trim().trim_end_matches('/').to_string(); if u.is_empty() { return Err("請輸入帳號伺服器的網址".into()); } if !u.contains("://") { let host = u.split(['/', ':']).next().unwrap_or(""); let plain = host == "localhost" || host.parse::().is_ok() || !host.contains('.'); u = format!("{}{u}", if plain { "http://" } else { "https://" }); } let mut r = agent(15).get(&format!("{u}/api/hello")).call().map_err(|e| format!("連不到 {u}:{}", why_unreachable(&e)))?; let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); if v["app"] != "anybot-relay" { return Err(format!("{u} 有回應,但不是 AnyBot 帳號伺服器(網址打錯了?)")); } Ok(u) } /// AnyBots of the account that lend their hands (not offered as a brain). pub fn is_hands(id: &str) -> bool { hands_ids().lock().unwrap().contains(id) } fn hands_ids() -> &'static Mutex> { static H: OnceLock>> = OnceLock::new(); H.get_or_init(Mutex::default) } /// The account's computers as last seen: id → (name, the brain it uses, lends its hands). type PeerRows = Mutex>; fn peer_rows() -> &'static PeerRows { static P: OnceLock = OnceLock::new(); P.get_or_init(Mutex::default) } /// Computers of the account that lend their hands to this brain (`me`), by id. pub fn my_hands(me: &str) -> Vec { if me.is_empty() { return vec![]; } let mut v: Vec = peer_rows().lock().unwrap().iter().filter(|(_, (_, uses, hands))| *hands && uses == me).map(|(id, _)| id.clone()).collect(); v.sort(); v } fn person_cell() -> &'static Mutex { static P: OnceLock> = OnceLock::new(); P.get_or_init(Mutex::default) } /// The account computer the person last typed on (they are sitting at it). Kept beside the data, /// so a brain that restarts (an update) still knows it. pub fn set_person_host(id: &str) { let mut cur = person_cell().lock().unwrap(); if *cur != id { *cur = id.to_string(); let _ = std::fs::write(person_file(), id); } } fn person_file() -> std::path::PathBuf { crate::model::State::path().with_file_name("person-computer.txt") } /// At start: the computer the person used last time. pub fn load_person_host() -> Option { let id = std::fs::read_to_string(person_file()).ok()?.trim().to_string(); (!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric())).then(|| { *person_cell().lock().unwrap() = id.clone(); id }) } pub fn person_host() -> String { person_cell().lock().unwrap().clone() } /// The brain an account computer shows (empty: it thinks for itself), as last seen. pub fn peer_uses(id: &str) -> String { peer_rows().lock().unwrap().get(id).map(|r| r.1.clone()).unwrap_or_default() } /// A computer's own name as the account server knows it. pub fn peer_name(id: &str) -> String { peer_rows().lock().unwrap().get(id).map(|r| r.0.clone()).unwrap_or_default() } pub fn devices_online(s: &Settings) -> Vec<(String, String, bool)> { if s.relay_url.is_empty() { return vec![]; } let Ok(mut r) = agent(10).get(&format!("{}/relay/host/peers", s.relay_url)).header("Authorization", &format!("Bearer {}", s.host_token)).call() else { return vec![] }; let v: Value = serde_json::from_str(&r.body_mut().read_to_string().unwrap_or_default()).unwrap_or(Value::Null); *peer_rows().lock().unwrap() = v["hosts"] .as_array() .into_iter() .flatten() .filter_map(|h| Some((h["id"].as_str()?.to_string(), (h["name"].as_str().unwrap_or("").to_string(), h["uses"].as_str().unwrap_or("").to_string(), h["hands"].as_bool() == Some(true))))) .collect(); *hands_ids().lock().unwrap() = v["hosts"].as_array().into_iter().flatten().filter(|h| h["hands"].as_bool() == Some(true) || !h["uses"].as_str().unwrap_or("").is_empty()).filter_map(|h| h["id"].as_str().map(String::from)).collect(); v["hosts"].as_array().map(|a| a.iter().map(|h| (h["id"].as_str().unwrap_or("").to_string(), h["name"].as_str().unwrap_or("").to_string(), h["online"].as_bool().unwrap_or(false))).collect()).unwrap_or_default() } // ---------- the phone page ---------- const PAGE: &str = include_str!("../mobile/index.html"); /// The bots' computer, watched and used in a browser (noVNC; see vnc.rs). const SCREEN_PAGE: &str = include_str!("../mobile/screen.html"); const NOVNC: &str = include_str!("../mobile/novnc.js"); pub const MANIFEST: &str = r##"{"name":"AnyBot","short_name":"AnyBot","start_url":"./","display":"standalone","background_color":"#121212","theme_color":"#121212","icons":[{"src":"icon.svg","sizes":"any","type":"image/svg+xml"},{"src":"icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}"##; /// The app's icon, the same drawing everywhere (icons/anybot.svg is the Mac/Linux one). pub const ICON: &str = include_str!("../mobile/icon.svg"); /// PNG sizes for the iPhone home screen (it ignores SVG) and Android. pub const ICON_PNGS: [(&str, &[u8]); 3] = [("icon-180.png", include_bytes!("../mobile/icon-180.png")), ("icon-192.png", include_bytes!("../mobile/icon-192.png")), ("icon-512.png", include_bytes!("../mobile/icon-512.png"))]; pub fn page() -> Resp { Resp::bytes("text/html; charset=utf-8", PAGE.as_bytes().to_vec()).with_header("Content-Security-Policy", "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'") } #[cfg(test)] mod tests { use super::*; #[test] fn model_requests_go_only_to_private_addresses() { assert!(private_host("10.184.10.252") && private_host("192.168.1.5") && private_host("localhost") && private_host("nas.local")); assert!(!private_host("api.x.ai") && !private_host("8.8.8.8")); // A cloud VM's metadata service answers on link-local: the hands must never reach it. assert!(!private_host("169.254.169.254") && !private_host("169.254.0.1")); assert_eq!(host_port("http://10.184.10.252:8000/v1"), Some(("10.184.10.252".into(), 8000))); assert_eq!(host_port("https://api.x.ai/v1"), Some(("api.x.ai".into(), 443))); assert_eq!(model_fetch_here(&json!({"url": "https://api.x.ai/v1/chat/completions"}).to_string()).status, 403); assert_eq!(model_fetch_here(&json!({"url": "http://169.254.169.254/latest/meta-data/"}).to_string()).status, 403); } #[test] fn lan_login_lock_is_per_device() { // One noisy device guessing passwords must not lock the owner's phone out (a single // global counter used to do exactly that). let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() }; s.host_name = "測試 Mac".into(); hub().set_settings(&s); let bad = |ip: &str| handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"nope"}"#.to_vec(), peer: ip.into(), ..Default::default() }, false); for _ in 0..MAX_FAILS { assert_eq!(bad("192.168.1.66:5000").status, 401); } // The noisy device is now locked out… assert_eq!(bad("192.168.1.66:5000").status, 429); // …while every other device on the LAN can still sign in. let ok = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"pw-123"}"#.to_vec(), peer: "192.168.1.77:5001".into(), ..Default::default() }, false); assert_eq!(ok.status, 200); } #[test] fn roles_gate_the_sync_and_hands_apis() { // 大腦 sync (mirror) and 裝置連結 (device) are separate doors on the hands machine: // a chat admin must not reach either, and a brain may not run tools through the sync door. assert!(role_allows("device", "POST", &["api", "device", "run"])); assert!(!role_allows("device", "GET", &["api", "sync"])); assert!(role_allows("mirror", "GET", &["api", "sync"])); assert!(role_allows("mirror", "POST", &["api", "sync", "edit"])); assert!(!role_allows("mirror", "POST", &["api", "device", "run"])); assert!(role_allows("admin", "POST", &["api", "bots", "1", "send"])); assert!(!role_allows("admin", "POST", &["api", "device", "run"])); assert!(!role_allows("admin", "GET", &["api", "sync"])); assert!(role_allows("member", "POST", &["api", "bots", "1", "send"])); assert!(!role_allows("member", "POST", &["api", "bots", "1", "stop"])); // A hands machine takes tool orders only through the relay, only when the person opened // it (裝置連結), and only from the brain it was told to serve. let open_for_one = Settings { device_link: true, hands_for: "brain1".into(), hands_for_name: "雲端大腦".into(), ..Default::default() }; assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, false, "brain1").status, 403); assert_eq!(device_run(&json!({"tool": "x"}), &Settings { device_link: false, hands_for: "brain1".into(), ..Default::default() }, true, "brain1").status, 403); assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, true, "other-brain").status, 403); // The chosen brain reaches past the gates (and gets "this tool can't run remotely" for x). assert_eq!(device_run(&json!({"tool": "x"}), &open_for_one, true, "brain1").status, 400); } #[test] fn phone_login_and_api() { let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() }; s.host_name = "測試 Mac".into(); hub().set_settings(&s); // Not signed in: refused. let r = handle(Req { method: "GET".into(), path: "/api/state".into(), ..Default::default() }, false); assert_eq!(r.status, 401); let bad = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"nope"}"#.to_vec(), ..Default::default() }, false); assert_eq!(bad.status, 401); let ok = handle(Req { method: "POST".into(), path: "/api/login".into(), body: br#"{"password":"pw-123","device":"iPhone"}"#.to_vec(), ..Default::default() }, false); assert_eq!(ok.status, 200); let token = serde_json::from_slice::(&ok.body).unwrap()["token"].as_str().unwrap().to_string(); hub().publish(View { host: "測試 Mac".into(), bots: vec![json!({"id": 1, "name": "小助理"})], ..Default::default() }); let mut req = Req { method: "GET".into(), path: "/api/state".into(), ..Default::default() }; req.headers.insert("authorization".into(), format!("Bearer {token}")); let st = handle(req.clone(), false); assert_eq!(st.status, 200, "{}", String::from_utf8_lossy(&st.body)); let v: Value = serde_json::from_slice(&st.body).unwrap(); assert_eq!(v["bots"][0]["name"], "小助理"); // Sending queues a command for the app. let mut send = Req { method: "POST".into(), path: "/api/bots/1/send".into(), body: r#"{"text":"你好"}"#.as_bytes().to_vec(), ..Default::default() }; send.headers = req.headers.clone(); assert_eq!(handle(send, false).status, 200); let cmds = hub().take_cmds(); assert!(cmds.iter().any(|c| matches!(c, Cmd::DeviceAdded(d) if d.name == "iPhone"))); assert!(cmds.contains(&Cmd::Send { bot: 1, text: "你好".into(), attachments: vec![], from: String::new() })); // The Mac's own screen stays closed unless allowed. let mut mac = Req { method: "GET".into(), path: "/api/screen/mac".into(), ..Default::default() }; mac.headers = req.headers.clone(); assert_eq!(handle(mac, false).status, 403); // Device link needs the relay and the switch. assert_eq!(handle(Req { method: "POST".into(), path: "/api/device/run".into(), body: br#"{"tool":"shell"}"#.to_vec(), ..Default::default() }, true).status, 403); } #[test] fn the_brain_is_an_online_one_that_thinks_for_itself() { let hosts = vec![ json!({"id": "me", "name": "這台", "online": true, "hands": false, "uses": ""}), json!({"id": "h", "name": "伺服器手腳", "online": true, "hands": true, "uses": ""}), json!({"id": "s", "name": "筆電", "online": true, "hands": false, "uses": "b"}), json!({"id": "old", "name": "舊的", "online": false, "hands": false, "uses": ""}), json!({"id": "b", "name": "家裡的 Mac", "online": true, "hands": false, "uses": ""}), ]; assert_eq!(pick_brain(&hosts, "me"), Some(("b".into(), "家裡的 Mac".into()))); assert_eq!(pick_brain(&hosts[..4], "me"), None); } #[test] fn team_roles() { assert!(role_allows("member", "POST", &["api", "bots", "3", "send"])); assert!(role_allows("member", "GET", &["api", "state"])); for (m, p) in [("POST", &["api", "bots", "3", "answer"][..]), ("POST", &["api", "bots", "3", "stop"][..]), ("GET", &["api", "screen", "box"][..]), ("POST", &["api", "device", "run"][..])] { assert!(!role_allows("member", m, p), "{p:?}"); } assert!(role_allows("admin", "POST", &["api", "bots", "3", "answer"])); assert!(!role_allows("admin", "POST", &["api", "device", "run"])); assert!(role_allows("device", "POST", &["api", "device", "run"]) && !role_allows("device", "GET", &["api", "state"])); assert!(!role_allows("stranger", "GET", &["api", "state"])); // The relay's word reaches handle() as headers. let req = req_from_json(&json!({"method": "POST", "path": "/api/bots/1/answer", "user": "amy", "role": "member", "body": ""})); assert_eq!(handle(req, true).status, 403); } #[test] fn relay_messages_round_trip() { let r = Resp::bytes("image/jpeg", vec![1, 2, 3]).with_header("X-Size", "2x2"); let j = resp_to_json(&r); assert_eq!(j["status"], 200); let req = req_from_json(&json!({"method": "POST", "path": "/api/bots/2/send", "query": {"since": "4"}, "body": "eyJ0ZXh0IjoiaGkifQ=="})); assert_eq!(req.json()["text"], "hi"); assert_eq!(req.query["since"], "4"); } } /// Where the one-step installer lives (it also installs AnyBot on a computer that has nothing yet). pub const INSTALL_URL: &str = "https://code.30cm.net/daniel.w/AnyBot/raw/branch/main/install.sh"; /// The line 「加一台電腦」 shows: pasted into any Linux or Mac terminal, it installs AnyBot when it /// isn't there yet and joins this computer as hands (`anybot setup 網址 配對碼`). pub fn pair_line(relay_url: &str, code: &str) -> String { format!("curl -fsSL {INSTALL_URL} | sh -s -- hands {} {code}", relay_url.trim_end_matches('/')) } /// 登入後同步 against a real account server: ANYBOT_RELAY_URL=http://127.0.0.1:PORT #[cfg(test)] mod account_sync_live { use super::*; #[test] #[ignore] fn a_new_computer_gets_the_accounts_bots_after_signing_in() { let url = std::env::var("ANYBOT_RELAY_URL").expect("ANYBOT_RELAY_URL"); let (user, pw) = (format!("t{}", crate::httpd::random_hex(4)), "sync-pass-123"); // The first computer signs up and backs up its bots. let a = relay_join(&Settings { host_name: "第一台".into(), ..Default::default() }, &url, &user, pw, true, "").unwrap(); let mut st = crate::model::State::samples(); st.remote = Default::default(); backup_now(&a, &serde_json::to_vec(&st).unwrap()).unwrap(); // A new computer signs in: no other brain is on, so the backup is what it takes in. let b = relay_join(&Settings { host_name: "新電腦".into(), ..Default::default() }, &url, &user, pw, false, "").unwrap(); let found = find_account_bots(&b, pw); assert!(found.brain.is_none()); let got: crate::model::State = serde_json::from_slice(&found.backup.expect("backup")).unwrap(); let mut here = crate::model::State::first_run(); assert_eq!(here.take_in_bots(&got), 5); } }