Remote: per-IP login lock, egress peers and timeouts narrowed; brain-mirror link covered by tests
This commit is contained in:
parent
80fa0cb11f
commit
4fa5fab870
|
|
@ -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<crate::attach::Attachment> = 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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -43,11 +43,12 @@ pub fn start() -> Result<u16, String> {
|
|||
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<std::net::SocketAddr> = 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()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,7 +142,8 @@ pub struct Hub {
|
|||
cmds: Mutex<Vec<Cmd>>,
|
||||
repaint: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
|
||||
settings: Mutex<Settings>,
|
||||
fails: Mutex<(u32, Option<Instant>)>,
|
||||
/// Wrong logins, counted per source (one noisy LAN device must not lock out the owner).
|
||||
fails: Mutex<HashMap<String, (u32, Instant)>>,
|
||||
relay_status: Mutex<String>,
|
||||
sync: Mutex<Vec<SyncReq>>,
|
||||
}
|
||||
|
|
@ -162,7 +163,7 @@ pub struct SyncReq {
|
|||
|
||||
pub fn hub() -> &'static Hub {
|
||||
static H: OnceLock<Hub> = 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 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::<String>();
|
||||
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::<std::net::IpAddr>() {
|
||||
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<Settings, String> {
|
||||
// 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<usize, String> {
|
|||
|
||||
/// Download and open the newest backup of this account (for a new computer).
|
||||
pub fn restore(url: &str, user: &str, password: &str) -> Result<Vec<u8>, 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]
|
||||
|
|
|
|||
|
|
@ -238,6 +238,10 @@ fn keep(mut ws: WebSocket<Stream>, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue