fix(desktop): relay host status, display, and server-side LAN candidate

- Probe relay hosts through a throwaway E2EE tunnel in the host switcher
  instead of an HTTP probe against the relay:// pseudo-URL, which always
  reported Unreachable
- Show 'via OpenChamber Relay' for relay hosts in the switcher and the
  servers list instead of the raw relay:// pseudo-URL; hide the URL-centric
  edit action for relay hosts (saving it would drop the tunnel descriptor)
- Pairing LAN candidate prefers the address the requesting client actually
  reached the server on; interface scanning could pick an unroutable virtual
  bridge (docker0), producing links whose LAN leg silently failed and forced
  devices onto the relay
This commit is contained in:
Bohdan Triapitsyn
2026-07-10 03:22:57 +03:00
parent cc4de243c6
commit 26e88355e1
4 changed files with 46 additions and 12 deletions
@@ -450,6 +450,13 @@ export function DesktopHostSwitcherDialog({
const localClientToken = await getLocalClientToken(); const localClientToken = await getLocalClientToken();
const results = await Promise.all( const results = await Promise.all(
hosts.map(async (h) => { hosts.map(async (h) => {
// Relay hosts have no HTTP address to probe — check reachability
// through a throwaway E2EE tunnel instead.
if (h.relay) {
const startedAt = performance.now();
const ok = await probeRelayHost(h.relay).catch(() => false);
return [h.id, { status: ok ? ('ok' as const) : ('unreachable' as const), latencyMs: Math.round(performance.now() - startedAt) } satisfies HostStatus] as const;
}
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url); const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url);
if (!url) { if (!url) {
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const; return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
@@ -898,7 +905,9 @@ export function DesktopHostSwitcherDialog({
const displayLabel = host.id === LOCAL_HOST_ID const displayLabel = host.id === LOCAL_HOST_ID
? t('desktopHostSwitcher.instance.local') ? t('desktopHostSwitcher.instance.local')
: redactSensitiveUrl(host.label); : redactSensitiveUrl(host.label);
const displayUrl = redactSensitiveUrl(effectiveUrl); // Relay hosts have a relay:// pseudo-URL that means nothing to a
// person — say how the connection works instead.
const displayUrl = host.relay ? t('mobile.connect.relay.badge') : redactSensitiveUrl(effectiveUrl);
return ( return (
<div <div
@@ -1459,16 +1459,23 @@ export const RemoteInstancesPage: React.FC = () => {
<p className="typography-ui-label text-foreground truncate">{redactSensitiveUrl(host.label)}</p> <p className="typography-ui-label text-foreground truncate">{redactSensitiveUrl(host.label)}</p>
{directDefaultHostId === host.id ? <span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.header.default')}</span> : null} {directDefaultHostId === host.id ? <span className="typography-micro text-muted-foreground">{t('desktopHostSwitcher.header.default')}</span> : null}
</div> </div>
<p className="typography-micro text-muted-foreground font-mono truncate">{redactSensitiveUrl(host.apiUrl || host.url)}</p> <p className={cn('typography-micro text-muted-foreground truncate', !host.relay && 'font-mono')}>
{host.relay ? t('mobile.connect.relay.badge') : redactSensitiveUrl(host.apiUrl || host.url)}
</p>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void setDefaultDirectHost(host.id)} disabled={directSaving || directDefaultHostId === host.id} aria-label={t('desktopHostSwitcher.actions.setAsDefaultAria')}> <Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void setDefaultDirectHost(host.id)} disabled={directSaving || directDefaultHostId === host.id} aria-label={t('desktopHostSwitcher.actions.setAsDefaultAria')}>
{directDefaultHostId === host.id ? <Icon name="star-fill" className="h-3.5 w-3.5" /> : <Icon name="star" className="h-3.5 w-3.5" />} {directDefaultHostId === host.id ? <Icon name="star-fill" className="h-3.5 w-3.5" /> : <Icon name="star" className="h-3.5 w-3.5" />}
</Button> </Button>
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => beginEditDirectHost(host)} disabled={directSaving}> {/* The edit form is URL/token-centric; saving it would drop a
<Icon name="pencil" className="h-3.5 w-3.5" /> relay host's tunnel descriptor. Relay hosts are re-imported
{t('desktopHostSwitcher.actions.edit')} via a fresh pairing link instead. */}
</Button> {host.relay ? null : (
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => beginEditDirectHost(host)} disabled={directSaving}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
</Button>
)}
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void handleRemoveDirectHost(host.id)} disabled={directSaving}> <Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void handleRemoveDirectHost(host.id)} disabled={directSaving}>
<Icon name="delete-bin" className="h-3.5 w-3.5" /> <Icon name="delete-bin" className="h-3.5 w-3.5" />
{t('settings.common.actions.delete')} {t('settings.common.actions.delete')}
+23 -5
View File
@@ -1117,17 +1117,35 @@ async function main(options = {}) {
// a specific non-loopback host → that host), NOT from how the UI was opened — so // a specific non-loopback host → that host), NOT from how the UI was opened — so
// "Local network" works even when the UI is opened on localhost, and is absent // "Local network" works even when the UI is opened on localhost, and is absent
// when the server is only bound to loopback (a LAN link would not connect). // when the server is only bound to loopback (a LAN link would not connect).
const resolvePairingTransports = () => { // The IPv4 the requesting client actually reached this server on (if any).
// Strips the IPv6-mapped prefix; loopback means "not a LAN path".
const requestReachedLanAddress = (req) => {
const raw = typeof req?.socket?.localAddress === 'string' ? req.socket.localAddress : '';
const address = raw.startsWith('::ffff:') ? raw.slice(7) : raw;
if (!/^\d+\.\d+\.\d+\.\d+$/.test(address)) return null;
if (address.startsWith('127.')) return null;
return address;
};
const resolvePairingTransports = (req) => {
const activePort = tunnelRuntimeContext.getActivePort() || port; const activePort = tunnelRuntimeContext.getActivePort() || port;
const local = `http://127.0.0.1:${activePort}`; const local = `http://127.0.0.1:${activePort}`;
let lanHost = null; let lanHost = null;
if (isNetworkExposedBindHost(effectiveBindHost)) { if (isNetworkExposedBindHost(effectiveBindHost)) {
// Prefer the address the client is ALREADY talking to us on — it is the
// one interface guaranteed to be routable from that client's network.
// Interface scanning is only a fallback: on servers with virtual bridges
// (docker0 etc.) the first non-internal IPv4 can be an address no other
// machine can reach, which produced pairing links whose LAN candidate
// silently failed and forced devices onto the relay.
lanHost = requestReachedLanAddress(req);
try { try {
for (const list of Object.values(os.networkInterfaces())) { if (!lanHost) {
for (const entry of (list || [])) { for (const list of Object.values(os.networkInterfaces())) {
if (entry.family === 'IPv4' && !entry.internal) { lanHost = entry.address; break; } for (const entry of (list || [])) {
if (entry.family === 'IPv4' && !entry.internal) { lanHost = entry.address; break; }
}
if (lanHost) break;
} }
if (lanHost) break;
} }
} catch { } catch {
lanHost = null; lanHost = null;
@@ -800,7 +800,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
app.get('/api/client-auth/pairing/transports', async (req, res, next) => { app.get('/api/client-auth/pairing/transports', async (req, res, next) => {
await runWithClientCreateAuth(req, res, next, async () => { await runWithClientCreateAuth(req, res, next, async () => {
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
res.json(getPairingTransports()); res.json(getPairingTransports(req));
}); });
}); });