diff --git a/apps/desktop/src-tauri/src/machines.rs b/apps/desktop/src-tauri/src/machines.rs index b1c969026..2c846d3f5 100644 --- a/apps/desktop/src-tauri/src/machines.rs +++ b/apps/desktop/src-tauri/src/machines.rs @@ -22,6 +22,7 @@ use rustcode_client::{ }; use serde::Serialize; use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -696,10 +697,14 @@ pub async fn machines_discover( let saved = machines.list().unwrap_or_default(); let mut notice = None; let mut views: Vec = match browse_async(DISCOVERY).await { - Ok(found) => found - .iter() - .map(|machine| discovered(machine, &saved)) - .collect(), + Ok(found) => { + let this = ThisComputer::read(); + found + .iter() + .filter(|machine| !this.is(machine)) + .map(|machine| discovered(machine, &saved)) + .collect() + } // A network without multicast (a container, a firewall) cannot browse, // but the overlay rows come from this machine's own registry: the sheet // lists them and says what failed, instead of showing nothing. @@ -717,6 +722,67 @@ pub async fn machines_discover( }) } +/// How this computer is told apart from another one in a browse: Bonjour +/// announces the gateway this computer runs too, and the Add sheet must not +/// offer to connect it to itself. +struct ThisComputer { + /// The gateway id this computer presents (its own gateway's, when it runs one). + gateway_id: Option, + /// The port its gateway listens on. + port: u16, + /// Whether an address belongs to one of this computer's interfaces. + owns: fn(IpAddr) -> bool, +} + +impl ThisComputer { + fn read() -> Self { + let dir = config_dir().ok(); + Self { + gateway_id: dir + .as_deref() + .and_then(|dir| rustcode_client::machine::Identity::load(dir).ok()) + .map(|identity| identity.host_id), + port: dir + .as_deref() + .and_then(gateway_port) + .unwrap_or(rustcode_client::DEFAULT_PORT), + owns: is_local_address, + } + } + + /// The same gateway id, or one of this computer's own addresses on its + /// gateway port (an announcement that carries no id, or an old one). + fn is(&self, machine: &DiscoveredMachine) -> bool { + if let (Some(own), Some(found)) = (&self.gateway_id, &machine.gateway_id) { + if own == found { + return true; + } + } + machine.port == self.port + && machine + .addresses + .iter() + .any(|address| (self.owns)(*address)) + } +} + +/// The port the local gateway listens on, from its registration file. +fn gateway_port(config_dir: &Path) -> Option { + let bytes = std::fs::read(config_dir.join("remote/registration.json")).ok()?; + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + let listen = value.get("listen_address")?.as_str()?; + listen + .parse::() + .ok() + .map(|address| address.port()) +} + +/// Whether `address` is one of this computer's own: only an address on a local +/// interface can be bound. +fn is_local_address(address: IpAddr) -> bool { + address.is_loopback() || std::net::UdpSocket::bind((address, 0)).is_ok() +} + /// What one Bonjour record says, plus what this app already knows of the /// machine when it is saved. fn discovered(machine: &DiscoveredMachine, saved: &[MachineView]) -> DiscoveredView { @@ -1341,6 +1407,36 @@ mod tests { assert_eq!(bonjour["saved"], true); } + fn record(gateway_id: Option<&str>, address: &str, port: u16) -> DiscoveredMachine { + DiscoveredMachine { + name: "This Mac".into(), + port, + addresses: vec![address.parse().unwrap()], + gateway_id: gateway_id.map(Into::into), + protocol: Some("1".into()), + caps: vec!["desktops".into()], + } + } + + /// Bonjour hears this computer's own gateway too: it is not a machine to + /// ask to connect (#3432). + #[test] + fn this_computer_is_left_out_of_the_machines_found() { + let this = ThisComputer { + gateway_id: Some("gw-own".into()), + port: 17879, + owns: |address| address == "192.168.1.5".parse::().unwrap(), + }; + // By gateway id, whatever address it announced. + assert!(this.is(&record(Some("gw-own"), "192.168.1.99", 17879))); + // By address: one of this computer's own, on its gateway port. + assert!(this.is(&record(None, "192.168.1.5", 17879))); + assert!(this.is(&record(Some("gw-other"), "192.168.1.5", 17879))); + // Another computer, and another gateway on this one's address. + assert!(!this.is(&record(Some("gw-other"), "192.168.1.20", 17879))); + assert!(!this.is(&record(Some("gw-other"), "192.168.1.5", 18000))); + } + /// A machine with nothing but a LAN route is not offered as an overlay /// peer: the browse is what finds it. #[test] diff --git a/apps/desktop/src/state/machines.test.ts b/apps/desktop/src/state/machines.test.ts index 8ea60b573..e8d183548 100644 --- a/apps/desktop/src/state/machines.test.ts +++ b/apps/desktop/src/state/machines.test.ts @@ -372,14 +372,12 @@ describe("machines rows", () => { test("a row under the name says what the connection is doing", () => { // The state is the row's chip; the line under the name is the route. expect(machineLine(MACHINE)).toBe("LAN · Unencrypted"); - // The shell's hint is shown too: a machine reachable over the LAN whose - // NetBird address is blocked says what to check. + // The shell's hint is the row's own line, not part of this one (#3433). const hinted = machineLine({ ...MACHINE, hint: "Not reachable over NetBird (100.90.16.237:17879). On that machine, check that NetBird allows inbound connections.", }); - expect(hinted).toStartWith("LAN · Unencrypted. "); - expect(hinted).toContain("NetBird allows inbound connections"); + expect(hinted).toBe("LAN · Unencrypted"); expect(machineLine({ ...MACHINE, state: "connecting" })).toContain("Connecting"); expect( machineLine({ diff --git a/apps/desktop/src/state/machines.ts b/apps/desktop/src/state/machines.ts index f36c7c3cc..ebc85e2f4 100644 --- a/apps/desktop/src/state/machines.ts +++ b/apps/desktop/src/state/machines.ts @@ -312,12 +312,10 @@ export function routeLabel(route: MachineRoute | null | undefined): string { */ export function machineLine(machine: Machine): string { switch (machine.state) { - case "connected": { - const line = routeLabel(machine.route); - // Why another route to the same machine is not used, when the shell - // knows: the LAN works and NetBird does not, or the other way round. - return machine.hint ? `${line}. ${machine.hint}` : line; - } + // The shell's hint (why another route to this machine is not used) is + // the row's own line under this one, so it is not repeated here. + case "connected": + return routeLabel(machine.route); case "connecting": return `Connecting over ${routeLabel(machine.route)}`; case "needsPairing": diff --git a/apps/desktop/src/ui/MachinesPage.test.tsx b/apps/desktop/src/ui/MachinesPage.test.tsx new file mode 100644 index 000000000..9c777d9d5 --- /dev/null +++ b/apps/desktop/src/ui/MachinesPage.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { Machine } from "../state/machines"; +import { MachineRow } from "./MachinesPage"; + +const HINT = + "Not reachable over the VPN ([fdfd::1]:17879). On that machine, check that the VPN allows inbound connections."; + +const MACHINE: Machine = { + id: "gw-7c1d9e4f2a8b6d30", + name: "Studio", + platform: "macos", + version: "1.29.1", + main: false, + routes: [{ kind: "lan", address: "192.168.1.31:17879", encrypted: false }], + route: { kind: "lan", address: "192.168.1.31:17879", encrypted: false }, + state: "connected", + detail: null, + hint: HINT, + retryInSecs: null, + addedAt: "2026-11-02T09:00:00+00:00", + lastConnectedAt: "2026-11-08T09:00:00+00:00", +}; + +function render(machine: Machine): string { + return renderToStaticMarkup(); +} + +describe("a machine row", () => { + test("says an unreachable route once", () => { + const html = render(MACHINE); + expect(html.split("Not reachable over the VPN").length - 1).toBe(1); + expect(html).toContain("LAN · Unencrypted"); + }); +}); diff --git a/apps/desktop/src/ui/MachinesPage.tsx b/apps/desktop/src/ui/MachinesPage.tsx index 83163aef4..3b28d30a9 100644 --- a/apps/desktop/src/ui/MachinesPage.tsx +++ b/apps/desktop/src/ui/MachinesPage.tsx @@ -203,7 +203,7 @@ function NoMachines() { ); } -function MachineRow({ +export function MachineRow({ machine, count, now,