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() {
|
if self.state.bot(bot).is_none() {
|
||||||
return;
|
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
|
let atts: Vec<crate::attach::Attachment> = attachments
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| std::path::Path::new(p).starts_with(crate::model::State::path().parent().unwrap_or(std::path::Path::new("/"))))
|
|
||||||
.filter_map(|p| {
|
.filter_map(|p| {
|
||||||
let meta = std::fs::metadata(p).ok()?;
|
let f = std::path::Path::new(p).canonicalize().ok()?;
|
||||||
let file = std::path::Path::new(p).file_name()?.to_string_lossy().to_string();
|
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);
|
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();
|
.collect();
|
||||||
if let Some(l) = self.streams.get(&bot) {
|
if let Some(l) = self.streams.get(&bot) {
|
||||||
|
|
@ -439,6 +445,14 @@ impl App {
|
||||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
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() {
|
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();
|
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;
|
let s = &mut self.state.remote;
|
||||||
s.host_id.clear();
|
s.host_id.clear();
|
||||||
s.host_token.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" => {
|
"list_computers" => {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
format!("{}:你自己的電腦(獨立的資料夾),一律可用", model::BOT_COMPUTER_NAME),
|
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)));
|
lines.extend(state.remote_computers.iter().map(|c| format!("{}:透過 SSH 連到 {}", c.name, c.ssh_target)));
|
||||||
ok(lines.join("\n"))
|
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 out = Outcome { text: String::new(), ok: true, wake: vec![], theme_changed: false, page: None };
|
||||||
let mut changed = vec![];
|
let mut changed = vec![];
|
||||||
if let Some(t) = s("theme") {
|
if let Some(t) = s("theme") {
|
||||||
state.theme = match t.as_str() {
|
// An unknown name used to mean Light with no word about it.
|
||||||
"dark" => Theme::Dark,
|
let (th, known) = match t.as_str() {
|
||||||
"system" => Theme::System,
|
"dark" => (Theme::Dark, true),
|
||||||
_ => Theme::Light,
|
"system" => (Theme::System, true),
|
||||||
|
"light" => (Theme::Light, true),
|
||||||
|
_ => (state.theme, false),
|
||||||
};
|
};
|
||||||
|
if known {
|
||||||
|
state.theme = th;
|
||||||
out.theme_changed = true;
|
out.theme_changed = true;
|
||||||
changed.push(format!("主題 → {t}"));
|
changed.push(format!("主題 → {t}"));
|
||||||
|
} else {
|
||||||
|
out.text.push_str(&format!("主題「{t}」不存在(可用 dark、light、system)。\n"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(a) = s("accent") {
|
if let Some(a) = s("accent") {
|
||||||
state.accent = if a == "bot_color" { Accent::BotColor } else { Accent::Black };
|
state.accent = if a == "bot_color" { Accent::BotColor } else { Accent::Black };
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,12 @@ pub fn start() -> Result<u16, String> {
|
||||||
Ok(port)
|
Ok(port)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Who may use the proxy: this machine, and Docker's own networks (172.16.0.0/12, where the
|
/// Who may use the proxy: this machine, and Docker's default bridge (172.17.0.0/16, where the
|
||||||
/// bridge and compose networks live) — never the rest of the local network.
|
/// 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 {
|
pub fn allowed_peer(ip: std::net::IpAddr) -> bool {
|
||||||
match ip {
|
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(),
|
std::net::IpAddr::V6(v) => v.is_loopback(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -122,15 +123,27 @@ fn handle(mut client: TcpStream) -> Result<(), String> {
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut server = match TcpStream::connect((r.host.as_str(), r.port)) {
|
// Resolve and connect with a real timeout: a plain TcpStream::connect can hang ~2 minutes
|
||||||
Ok(s) => s,
|
// on a dead address, and every queued box request piles up behind it.
|
||||||
Err(e) => {
|
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");
|
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);
|
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 {
|
if r.tunnel {
|
||||||
client.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").map_err(|e| e.to_string())?;
|
client.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").map_err(|e| e.to_string())?;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -189,8 +202,12 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn only_this_machine_and_containers() {
|
fn only_this_machine_and_containers() {
|
||||||
assert!(super::allowed_peer("127.0.0.1".parse().unwrap()));
|
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.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("172.32.0.1".parse().unwrap()));
|
||||||
assert!(!super::allowed_peer("192.168.1.20".parse().unwrap()));
|
assert!(!super::allowed_peer("192.168.1.20".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,8 @@ pub struct Hub {
|
||||||
cmds: Mutex<Vec<Cmd>>,
|
cmds: Mutex<Vec<Cmd>>,
|
||||||
repaint: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
|
repaint: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
|
||||||
settings: Mutex<Settings>,
|
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>,
|
relay_status: Mutex<String>,
|
||||||
sync: Mutex<Vec<SyncReq>>,
|
sync: Mutex<Vec<SyncReq>>,
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +163,7 @@ pub struct SyncReq {
|
||||||
|
|
||||||
pub fn hub() -> &'static Hub {
|
pub fn hub() -> &'static Hub {
|
||||||
static H: OnceLock<Hub> = OnceLock::new();
|
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);
|
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 {
|
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();
|
let mut f = hub().fails.lock().unwrap();
|
||||||
if let (n, Some(t)) = *f {
|
f.retain(|_, (_, t)| t.elapsed() < Duration::from_secs(60));
|
||||||
if n >= MAX_FAILS && 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, "密碼錯太多次,請一分鐘後再試");
|
return Resp::error(429, "密碼錯太多次,請一分鐘後再試");
|
||||||
}
|
}
|
||||||
if t.elapsed() >= Duration::from_secs(60) {
|
|
||||||
*f = (0, None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let b = req.json();
|
let b = req.json();
|
||||||
let pw = b["password"].as_str().unwrap_or("");
|
let pw = b["password"].as_str().unwrap_or("");
|
||||||
let stored = hub().settings().password;
|
let stored = hub().settings().password;
|
||||||
if stored.is_empty() || !httpd::check_password(pw, &stored) {
|
if stored.is_empty() || !httpd::check_password(pw, &stored) {
|
||||||
|
{
|
||||||
let mut f = hub().fails.lock().unwrap();
|
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.
|
// Slow guessing down a little more.
|
||||||
std::thread::sleep(Duration::from_millis(600));
|
std::thread::sleep(Duration::from_millis(600));
|
||||||
return Resp::error(401, if stored.is_empty() { "這台 AnyBot 還沒設定手機密碼" } else { "密碼不對" });
|
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 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 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() };
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
match h.parse::<std::net::IpAddr>() {
|
match h.parse::<std::net::IpAddr>() {
|
||||||
Ok(std::net::IpAddr::V4(a)) => a.is_private() || a.is_loopback() || a.is_link_local(),
|
// Not link-local: a cloud VM's metadata service (169.254.169.254) must never be
|
||||||
Ok(std::net::IpAddr::V6(a)) => a.is_loopback() || (a.segments()[0] & 0xfe00) == 0xfc00,
|
// 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,
|
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.
|
/// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts.
|
||||||
/// Returns the settings to keep.
|
/// 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> {
|
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 host_name = base_settings.host_name.as_str();
|
||||||
let url = base(url);
|
let url = base(url);
|
||||||
// Plain http only inside one machine or one Docker network (e.g. http://relay:8080).
|
// 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).
|
/// 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> {
|
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 url = base(url);
|
||||||
let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": "還原備份"}))?;
|
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 session = v["token"].as_str().unwrap_or("").to_string();
|
||||||
|
|
@ -1371,9 +1389,55 @@ mod tests {
|
||||||
fn model_requests_go_only_to_private_addresses() {
|
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("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"));
|
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("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!(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": "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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,10 @@ fn keep(mut ws: WebSocket<Stream>, serial: u64, alive: &dyn Fn() -> bool) {
|
||||||
Ok(Message::Binary(b)) => {
|
Ok(Message::Binary(b)) => {
|
||||||
heard = Instant::now();
|
heard = Instant::now();
|
||||||
if let Some((head, body)) = wire::unpack(&b) {
|
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);
|
on_frame(head, body, &tx, &calls);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue