diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index f6791e9..4c2c7a0 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -262,14 +262,20 @@ impl App { if self.state.bot(bot).is_none() { return; } + // Only files the app itself stored in its attachments folder may ride along; + // resolve first, so "../../" paths cannot escape it (the phone can name any path). + let root = crate::model::State::path().parent().map(|p| p.join("attachments")).and_then(|p| p.canonicalize().ok()); let atts: Vec = attachments .iter() - .filter(|p| std::path::Path::new(p).starts_with(crate::model::State::path().parent().unwrap_or(std::path::Path::new("/")))) .filter_map(|p| { - let meta = std::fs::metadata(p).ok()?; - let file = std::path::Path::new(p).file_name()?.to_string_lossy().to_string(); + let f = std::path::Path::new(p).canonicalize().ok()?; + if !root.as_ref().map(|r| f.starts_with(r) && f.is_file()).unwrap_or(false) { + return None; + } + let meta = std::fs::metadata(&f).ok()?; + let file = f.file_name()?.to_string_lossy().to_string(); let name = file.split_once('-').map(|x| x.1.to_string()).unwrap_or(file); - Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: p.clone(), bytes: meta.len(), on_box: String::new() }) + Some(crate::attach::Attachment { image: crate::attach::is_image_name(&name), name, path: f.display().to_string(), bytes: meta.len(), on_box: String::new() }) }) .collect(); if let Some(l) = self.streams.get(&bot) { @@ -439,6 +445,14 @@ impl App { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.add(egui::Button::new(RichText::new(t("登出帳號")).size(12.5).color(p.danger)).frame(false)).clicked() { let keep_name = self.state.remote.host_name.clone(); + // Say goodbye to the account server: the machine's registration is + // removed, so its key dies with the sign-out. + let (url, token) = (self.state.remote.relay_url.clone(), self.state.remote.host_token.clone()); + if !url.is_empty() && !token.is_empty() { + std::thread::spawn(move || { + let _ = remote::relay_post(&format!("{url}/relay/host/remove"), &token, &serde_json::json!({})); + }); + } let s = &mut self.state.remote; s.host_id.clear(); s.host_token.clear(); diff --git a/src/appctl.rs b/src/appctl.rs index 2fcb7e5..5d5a1d8 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -545,7 +545,7 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome "list_computers" => { let mut lines = vec![ format!("{}:你自己的電腦(獨立的資料夾),一律可用", model::BOT_COMPUTER_NAME), - format!("{}:這台 Mac,{}", model::THIS_COMPUTER_NAME, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }), + format!("{}:{},{}", model::THIS_COMPUTER_NAME, if cfg!(target_os = "macos") { "這台 Mac" } else { "這台電腦" }, if state.local_execution { "已允許" } else { "使用者還沒在設定裡允許(設定 → 電腦 → 在這台電腦上執行)" }), ]; lines.extend(state.remote_computers.iter().map(|c| format!("{}:透過 SSH 連到 {}", c.name, c.ssh_target))); ok(lines.join("\n")) @@ -554,13 +554,20 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome let mut out = Outcome { text: String::new(), ok: true, wake: vec![], theme_changed: false, page: None }; let mut changed = vec![]; if let Some(t) = s("theme") { - state.theme = match t.as_str() { - "dark" => Theme::Dark, - "system" => Theme::System, - _ => Theme::Light, + // An unknown name used to mean Light with no word about it. + let (th, known) = match t.as_str() { + "dark" => (Theme::Dark, true), + "system" => (Theme::System, true), + "light" => (Theme::Light, true), + _ => (state.theme, false), }; - out.theme_changed = true; - changed.push(format!("主題 → {t}")); + if known { + state.theme = th; + out.theme_changed = true; + changed.push(format!("主題 → {t}")); + } else { + out.text.push_str(&format!("主題「{t}」不存在(可用 dark、light、system)。\n")); + } } if let Some(a) = s("accent") { state.accent = if a == "bot_color" { Accent::BotColor } else { Accent::Black }; diff --git a/src/egress.rs b/src/egress.rs index d261100..7c110e1 100644 --- a/src/egress.rs +++ b/src/egress.rs @@ -43,11 +43,12 @@ pub fn start() -> Result { Ok(port) } -/// Who may use the proxy: this machine, and Docker's own networks (172.16.0.0/12, where the -/// bridge and compose networks live) — never the rest of the local network. +/// Who may use the proxy: this machine, and Docker's default bridge (172.17.0.0/16, where the +/// boxes run). The rest of 172.16.0.0/12 is often the corporate LAN — an open proxy there is +/// an open door, so it is not served. pub fn allowed_peer(ip: std::net::IpAddr) -> bool { match ip { - std::net::IpAddr::V4(v) => v.is_loopback() || (v.octets()[0] == 172 && (16..=31).contains(&v.octets()[1])), + std::net::IpAddr::V4(v) => v.is_loopback() || (v.octets()[0] == 172 && v.octets()[1] == 17), std::net::IpAddr::V6(v) => v.is_loopback(), } } @@ -122,15 +123,27 @@ fn handle(mut client: TcpStream) -> Result<(), String> { return Err(e); } }; - let mut server = match TcpStream::connect((r.host.as_str(), r.port)) { - Ok(s) => s, - Err(e) => { + // Resolve and connect with a real timeout: a plain TcpStream::connect can hang ~2 minutes + // on a dead address, and every queued box request piles up behind it. + let addrs: Vec = match std::net::ToSocketAddrs::to_socket_addrs(&(r.host.as_str(), r.port)) { + Ok(a) => a.collect(), + Err(_) => { let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"); - return Err(e.to_string()); + return Err(format!("連不到 {}", r.host)); + } + }; + let mut server = match addrs.into_iter().find_map(|a| TcpStream::connect_timeout(&a, std::time::Duration::from_secs(10)).ok()) { + Some(s) => s, + None => { + let _ = client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"); + return Err(format!("連不到 {}", r.host)); } }; CONNECTIONS.fetch_add(1, Ordering::Relaxed); - client.set_read_timeout(None).ok(); + // A dead peer must not pin the copy loops (and their threads) forever: an idle tunnel ends. + let idle = std::time::Duration::from_secs(30 * 60); + client.set_read_timeout(Some(idle)).ok(); + server.set_read_timeout(Some(idle)).ok(); if r.tunnel { client.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").map_err(|e| e.to_string())?; } else { @@ -189,8 +202,12 @@ mod tests { #[test] fn only_this_machine_and_containers() { assert!(super::allowed_peer("127.0.0.1".parse().unwrap())); + // Docker's default bridge (where the boxes run) is served… assert!(super::allowed_peer("172.17.0.2".parse().unwrap())); - assert!(super::allowed_peer("172.31.255.1".parse().unwrap())); + // …but the rest of 172.16/12 (often the corporate LAN) is not: an open proxy there + // is an open door. + assert!(!super::allowed_peer("172.16.0.1".parse().unwrap())); + assert!(!super::allowed_peer("172.31.255.1".parse().unwrap())); assert!(!super::allowed_peer("172.32.0.1".parse().unwrap())); assert!(!super::allowed_peer("192.168.1.20".parse().unwrap())); } diff --git a/src/remote.rs b/src/remote.rs index 37c9ea3..c4213dc 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -142,7 +142,8 @@ pub struct Hub { cmds: Mutex>, repaint: Mutex>>, settings: Mutex, - fails: Mutex<(u32, Option)>, + /// Wrong logins, counted per source (one noisy LAN device must not lock out the owner). + fails: Mutex>, relay_status: Mutex, sync: Mutex>, } @@ -162,7 +163,7 @@ pub struct SyncReq { 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((0, None)), relay_status: Mutex::new(String::new()), sync: Mutex::new(vec![]) }) + 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); @@ -487,28 +488,35 @@ pub fn role_allows(role: &str, method: &str, path: &[&str]) -> bool { } 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(); - if let (n, Some(t)) = *f { - if n >= MAX_FAILS && t.elapsed() < Duration::from_secs(60) { + 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, "密碼錯太多次,請一分鐘後再試"); } - if t.elapsed() >= Duration::from_secs(60) { - *f = (0, None); - } } } 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(); - *f = (f.0 + 1, Some(Instant::now())); + { + 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() = (0, None); + 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() }; @@ -662,8 +670,10 @@ fn private_host(host: &str) -> bool { return true; } match h.parse::() { - 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, + // 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, } } @@ -873,6 +883,11 @@ fn base(url: &str) -> 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). @@ -1025,6 +1040,9 @@ pub fn backup_now(s: &Settings, state_json: &[u8]) -> Result { /// 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(); @@ -1371,9 +1389,55 @@ mod tests { 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] diff --git a/src/tunnel.rs b/src/tunnel.rs index a2b2310..633e1e7 100644 --- a/src/tunnel.rs +++ b/src/tunnel.rs @@ -238,6 +238,10 @@ fn keep(mut ws: WebSocket, serial: u64, alive: &dyn Fn() -> bool) { Ok(Message::Binary(b)) => { heard = Instant::now(); if let Some((head, body)) = wire::unpack(&b) { + // The relay says this machine was unregistered (signed out): stop for good. + if head["t"] == "bye" { + break; + } on_frame(head, body, &tx, &calls); } }