Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 100 additions & 4 deletions apps/desktop/src-tauri/src/machines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -696,10 +697,14 @@ pub async fn machines_discover(
let saved = machines.list().unwrap_or_default();
let mut notice = None;
let mut views: Vec<DiscoveredView> = 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.
Expand All @@ -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<String>,
/// 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<u16> {
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::<SocketAddr>()
.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 {
Expand Down Expand Up @@ -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::<IpAddr>().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]
Expand Down
6 changes: 2 additions & 4 deletions apps/desktop/src/state/machines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
10 changes: 4 additions & 6 deletions apps/desktop/src/state/machines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/src/ui/MachinesPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MachineRow machine={machine} count={2} now={Date.now()} local="1.29.1" />);
}

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");
});
});
2 changes: 1 addition & 1 deletion apps/desktop/src/ui/MachinesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ function NoMachines() {
);
}

function MachineRow({
export function MachineRow({
machine,
count,
now,
Expand Down
Loading