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
42 changes: 42 additions & 0 deletions docs/changelog/2026-08-04-hostname-shortening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Hostname shortening and LAN deduplication

## Problem

- LAN domain suffixes (`.fritz.box`, `.home.arpa`, AD domains, …) make labels wider than necessary, reducing the number of servers visible per row in `/crossbar`.
- Multiple network interfaces or IP versions can produce several discovered entries for the same physical machine.
- `os.hostname()` often returns only the bare hostname (e.g. `macPro16`), so the domain suffix of remote servers could not be stripped reliably.

## Solution

### Local domain detection (`src/discovery/dns.ts`)

`getLocalDomain()` determines the local machine's domain suffix using a cascading, best-effort strategy:

1. **`os.hostname()`** — if it already contains a dot, the suffix is extracted directly.
2. **Environment variables** — checked in order: `USERDNSDOMAIN`, `LOCALDOMAIN`, `DOMAIN` (covers Windows AD domains and common Unix env vars).
3. **`/etc/resolv.conf`** — parsed for `search` or `domain` directives; the first search domain wins.

The function is lazy and cached. Each step is wrapped in `try/catch` — a failure in one source never crashes or blocks the system. If no source yields a domain, the function returns `null` and shortening is simply skipped.

### Hostname shortening (`src/discovery/dns.ts`)

`shortHostname(hostname)` strips the trailing domain suffix only when the hostname shares the same suffix as the local machine (case-insensitive). IP addresses and `localhost` are never shortened.

### UI integration (`src/ui/onboarding.ts`)

`hostPortOf()` now calls `shortHostname()` before appending the port, so all `/crossbar` labels use shortened hostnames:

```
llama.cpp (dagobert.fritz.box:8080)
llama.cpp (dagobert:8080)
```

### Deduplication (`src/discovery/engine.ts`)

`dedupByHostname()` resolves any unresolved IP addresses to hostnames via `dns.reverse()` and groups servers by resolved hostname plus port. When multiple IPs map to the same hostname, only the entry with the resolved hostname is kept. This prevents duplicate rows from scanning several interfaces (Wi-Fi, Ethernet, IPv4, IPv6) on the same machine.

## Tests

- `tests/discovery/dns.test.ts` — 27 tests covering the full fallback chain, shortening behavior, IP/IPv6/localhost guards, caching, and crash resistance.
- All existing tests continue to pass.
74 changes: 74 additions & 0 deletions docs/hostname-dedup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Hostname Resolution & Deduplication

## Problem

A single inference server with multiple network interfaces appears as **multiple
entries** in the Crossbar server list:

```
oMLX (192.168.188.127:8000) — WiFi NIC
oMLX (192.168.139.3:8000) — VPN/virtual NIC
oMLX (workstation.local:8000) — hostname
```

All three LAN entries point to the **same machine**. The user sees duplicate
servers and must manually ignore the extras.

## Solution

1. **Resolve** IP addresses to hostnames via reverse DNS.
2. **Deduplicate** by `hostname + port` — collapse all IPs of the same machine
into a single entry.
3. **Prefer hostname** over a still-unresolved IP when both land in the same group.
4. **Shorten labels** by stripping the shared local domain suffix for display.

Loopback (`127.0.0.1` / `localhost`) is a different key from a LAN hostname, so a
co-located server can still appear twice (once from the localhost sweep, once from
LAN) when both paths discover it. That is intentional: the URLs differ, and the
localhost entry is the better default for a local session.

## How it works

### Step 1 — Reverse DNS resolution

Each discovered IP is looked up via `dns.reverse()`:

| IP | Resolves to |
|---|---|
| `127.0.0.1` | `127.0.0.1` (no lookup) |
| `192.168.188.127` | `workstation.local` |
| `192.168.139.3` | `workstation.local` |

**Caching:** Results are cached within a single `discoverLocalhost` /
`discoverLan` call. `clearCache()` runs at the start of each call.

**Graceful fallback:** If reverse DNS fails, the original IP is retained.

### Step 2 — Dedup by hostname + port

Servers are grouped by their resolved `hostname:port` key. For each group with
more than one entry, the entry with a resolved hostname is preferred over an IP.

### Label shortening

Hostnames are displayed **without their domain suffix** to save horizontal
space in the UI. This only applies to hostnames that share the same domain
suffix as the machine Crossbar is running on (via `os.hostname()`, env hints, or
`/etc/resolv.conf`).

| Local machine | Displayed label | Full hostname (baseUrl) |
|---|---|---|
| `local-host.fritz.box` | `devbox:8080` | `devbox.fritz.box` |
| `local-host.fritz.box` | `remote.example.com:8080` | `remote.example.com` |
| `local-host.fritz.box` | `192.168.188.173:8080` | `192.168.188.173` |

### URL path preservation

Hostname replacement uses string replacement (not `URL` reconstruction) so
trailing slashes and paths are preserved — reconstructing via `URL` can introduce
a spurious `/` that breaks `${baseUrl}/v1` concatenation.

## Testing

- `tests/discovery/dns.test.ts` — resolve, shorten, cache, resolv.conf fallback
- `tests/discovery/hostname-dedup.test.ts` — multi-NIC collapse, preference, labels
232 changes: 232 additions & 0 deletions src/discovery/dns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* DNS hostname resolution for discovered servers.
*
* Resolves IP addresses to hostnames via reverse DNS (`dns.reverse()`).
* Results are cached within a scan run to avoid redundant lookups.
* Resolution failures fall back to the original value (graceful degradation).
*
* Hostnames are displayed without their domain suffix (e.g. `macpro16.fritz.box`
* → `macpro16`) to save horizontal space in the UI. The full hostname is
* preserved in the `baseUrl` for correct DNS resolution.
*
* Shortening only applies to hostnames that share the same domain suffix as
* the machine Crossbar is running on. We first inspect `os.hostname()`, then
* try cross-platform environment hints, then finally fall back to local resolver
* search/domain configuration where available (e.g. `/etc/resolv.conf`).
* Hostnames on different domains are kept in full to avoid label collisions.
*/

import { reverse } from "node:dns/promises";
import { readFileSync } from "node:fs";
import { hostname } from "node:os";

// ---------------------------------------------------------------------------
// Local domain suffix — extracted from this machine's hostname or resolver config
// ---------------------------------------------------------------------------

let localDomainCache: string | null | undefined;

function domainSuffixOfHost(host: string): string | null {
const dotIndex = host.indexOf(".");
return dotIndex > 0 ? host.slice(dotIndex) : null;
}

function normalizeDomain(domain: string): string | null {
const trimmed = domain.trim().replace(/\.+$/, "");
if (!trimmed || trimmed === "local" || trimmed === "localhost") return null;
return trimmed.startsWith(".") ? trimmed : `.${trimmed}`;
}

function localDomainFromEnv(): string | null {
const candidates = [
process.env.USERDNSDOMAIN,
process.env.LOCALDOMAIN,
process.env.DOMAIN,
];
for (const candidate of candidates) {
if (!candidate) continue;
const first = candidate.split(/[\s,;]+/).find((part) => part.length > 0);
const normalized = first ? normalizeDomain(first) : null;
if (normalized) return normalized;
}
return null;
}

function localDomainFromResolvConf(): string | null {
try {
const text = readFileSync("/etc/resolv.conf", "utf8");
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;

const searchMatch = trimmed.match(/^search\s+(.+)$/i);
if (searchMatch?.[1]) {
const first = searchMatch[1].split(/\s+/).find((part) => part.length > 0);
const normalized = first ? normalizeDomain(first) : null;
if (normalized) return normalized;
}

const domainMatch = trimmed.match(/^domain\s+(.+)$/i);
if (domainMatch?.[1]) {
const normalized = normalizeDomain(domainMatch[1]);
if (normalized) return normalized;
}
}
} catch {
// Best-effort only.
}
return null;
}

/**
* Get the domain suffix of the local machine, or null when none can be determined.
* Computed lazily and cached.
*
* Resolution order:
* 1. FQDN from `os.hostname()`
* 2. Cross-platform env hints (`USERDNSDOMAIN`, `LOCALDOMAIN`, `DOMAIN`)
* 3. Resolver search/domain config from `/etc/resolv.conf` when present
*
* Examples:
* `myMac.fritz.box` → `.fritz.box`
* `USERDNSDOMAIN=fritz.box` → `.fritz.box`
* `/etc/resolv.conf: search fritz.box` → `.fritz.box`
* `localhost` → `null`
*/
export function getLocalDomain(): string | null {
if (localDomainCache !== undefined) return localDomainCache;

const fromHostname = domainSuffixOfHost(hostname());
if (fromHostname) {
localDomainCache = normalizeDomain(fromHostname);
return localDomainCache;
}

const fromEnv = localDomainFromEnv();
if (fromEnv) {
localDomainCache = fromEnv;
return localDomainCache;
}

localDomainCache = localDomainFromResolvConf();
return localDomainCache;
}

// ---------------------------------------------------------------------------
// Cache — keyed by IP address, values are resolved hostnames or null on failure
// ---------------------------------------------------------------------------

const cache = new Map<string, string | null>();

/** Clear the cache between scan runs. */
export function clearCache(): void {
cache.clear();
localDomainCache = undefined;
}

/**
* Strip the domain suffix from a hostname for display.
*
* Only shortens hostnames that share the same domain suffix as the local
* machine (e.g. `macpro16.fritz.box` → `macpro16` when local is `myMac.fritz.box`,
* `USERDNSDOMAIN=fritz.box`, or the resolver search domain is `fritz.box`).
* Hostnames on different domains are kept in full to avoid label collisions.
*
* @param hostname - The hostname to potentially shorten.
* @returns The short hostname, or the original if no shortening applies.
*/
export function shortHostname(hostname: string): string {
// Don't strip dots from IP addresses
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) return hostname;
if (/^\[?[0-9a-fA-F:]+\]?$/.test(hostname)) return hostname; // IPv6

// No local domain suffix (e.g. localhost) — no shortening
const localDomain = getLocalDomain();
if (!localDomain) return hostname;

// Only shorten if the hostname shares our local domain suffix (case-insensitive)
const lowerHost = hostname.toLowerCase();
const lowerDomain = localDomain.toLowerCase();
if (!lowerHost.endsWith(lowerDomain)) return hostname;

// Strip the shared suffix, keeping any subdomain labels (a.b.example → a.b).
return hostname.slice(0, hostname.length - localDomain.length);
}

// ---------------------------------------------------------------------------
// Lookup helpers
// ---------------------------------------------------------------------------

/**
* Resolve an IP address to a hostname via reverse DNS.
* Returns the original IP if resolution fails (graceful degradation).
*
* Cached within a scan run — repeated calls with the same IP reuse the result.
*
* @param ip - The IP address to resolve.
* @returns The resolved hostname, or the original IP on failure.
*/
export async function resolveHostname(ip: string): Promise<string> {
// Skip already-hostname-like values
if (ip === "localhost" || ip === "127.0.0.1" || ip === "::1") return ip;
if (ip.includes(":")) return ip; // IPv6 — skip

// Check cache first — null means we already tried and it failed
const cached = cache.get(ip);
if (cached !== undefined) return cached as string;

try {
const hostnames = await reverse(ip);
// reverse() returns (string | null)[]; take the first non-null entry
const hostname = hostnames[0] as string;
if (hostname) {
cache.set(ip, hostname);
return hostname;
}
} catch {
// DNS resolution failed — fall back to original IP
}

cache.set(ip, ip);
return ip;
}

/**
* Resolve the host part of a full URL to a hostname.
*
* Examples:
* `http://192.168.1.42:11434` → `http://workstation.local:11434`
* `http://10.0.0.5:8000` → `http://devbox.local:8000`
* `http://localhost:8000` → `http://localhost:8000` (no-op)
*
* @param url - The full URL to resolve.
* @returns The URL with the host part resolved, or the original URL on failure.
*/
export async function resolveUrlHostname(url: string): Promise<string> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
// Malformed URL — return as-is
return url;
}

const hostname = parsed.hostname;

// Skip if already a hostname-like value
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return url;
if (hostname.includes(":")) return url; // IPv6 — skip

const resolved = await resolveHostname(hostname);

// No change — return original
if (resolved === hostname) return url;

// Replace only the hostname in the original string — preserves whether the
// original URL had a trailing slash/path or not (avoids introducing a
// spurious "/" that breaks downstream path concatenation, e.g. `${baseUrl}/v1`
// becoming a double slash).
const hostPattern = hostname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // escape regex chars
const re = new RegExp(`^(${parsed.protocol}//)${hostPattern}(:|/|$)`);
return url.replace(re, `$1${resolved}$2`);
}
Loading