Date: 2026-07-10 (accepted 2026-07-12)
Accepted (implemented in 0.12.0)
Registering resources is manual, one IP at a time. The trigger for this ADR was concrete: an operator adding nine cameras in one site network, typing each IP, name, and port by hand. R-052 already names this — "no auto-discovery means every new server must be manually added" — with a placeholder mitigation (a bulk import API for power-users who can script it). That mitigation helps someone who already has a machine-readable inventory; it does nothing for the operator standing in front of an unknown /24 asking which hosts even exist, and what are they?
The hub is the natural place to answer that. A Site carries a cidr and an optional gatewayPeerId — the site-type peer that routes that CIDR back through WireGuard (ADR-0006, ADR-0005). When that route exists, the hub can reach every host in the site's subnet. So the hub can enumerate live hosts, fingerprint them by open port, and hand the operator a checklist to bulk-create Resource rows from — instead of nine round-trips through the resource form.
The tension is that network scanning is intrusive, and Islandr's whole security posture is built on least privilege (ADR-0011): the process runs as an unprivileged user whose only escalation is sudo scoped to exactly nft and wg. A naive scanner reaches for raw ICMP/ARP sweeps, which need CAP_NET_RAW — widening that grant and denting the exact posture ADR-0011 exists to protect. A scan is also a recon primitive: whoever can trigger it can map a remote network. The design has to earn its usefulness without becoming either a privilege escalation or an unbounded port-scanner.
Add an admin-triggered, asynchronous discovery scan that reaches live hosts purely through unprivileged sockets, is bound to a site's own declared CIDR, and produces a reviewable list the admin bulk-imports into Resource rows. Nothing about it widens the ADR-0011 privilege model, and nothing about it touches the enforcement path.
Host liveness is established with user-space sockets only — never a raw socket, never CAP_NET_RAW, never a new sudoers entry:
connect() to a fixed probe-port set — 22, 80, 443, 445, 554, 631, 3389, 5900, 8006, 8080, 8123, 8443, 9100. connect succeeding (open) or being refused (closed) both prove the host is up and, for an open port, tell us which service. A timeout is ambiguous (down, or filtered).40125) on a connect()-ed DatagramSocket. If the host is up and the port closed, the kernel delivers the ICMP port unreachable against that connected socket and Java surfaces it as java.net.PortUnreachableException — without a raw socket. An exception is a positive liveness signal even when no probed TCP port is open; a timeout yields nothing. This is exactly the technique nmap falls back to in unprivileged mode, and the reason it works is the reason we can use it: sending UDP and reading the connected-socket error are both unprivileged.A host counts as live if any TCP probe answered (open or refused) or the UDP probe raised PortUnreachableException. The two probes are complementary: TCP gives liveness plus a service fingerprint; the UDP probe adds hosts that expose none of the probed TCP ports but still emit ICMP — partially closing the "silent host" gap (R-140).
Scanning a /24 with connect-timeouts takes seconds to tens of seconds, too long to hold an HTTP request open. A scan is an in-memory job (a ConcurrentHashMap, ~5-minute TTL, not persisted, cleared on restart — no Flyway migration, no job table). Bounded concurrency (e.g. 64 in-flight probes) and a short per-host timeout keep it quick and gentle.
POST /api/v1/sites/{siteId}/discovery/scan → 202 { jobId }
GET /api/v1/sites/{siteId}/discovery/scan/{jobId} → { state: running|done|failed,
progress, hosts: [
{ ip, openPorts[], typeGuess,
alreadyRegistered } ] }
DELETE /api/v1/sites/{siteId}/discovery/scan/{jobId} → cancel
POST /api/v1/sites/{siteId}/discovery/import → [ { ip, name, type, ports[] } ]
All four are admin-only (isAdmin=true, per T-005). import is transactional and idempotent on (site, ip) — re-importing an already-registered host is a no-op, so a second scan+import never duplicates resources. Results mark alreadyRegistered so the UI greys out hosts that are already Resource rows.
gatewayPeerId (a tunnel route) must have a recent handshake on that peer. A promised tunnel that is down is failed fast — "Der Gateway-Peer dieser Site ist nicht verbunden (kein aktueller Handshake)" — because the scan would otherwise silently find zero hosts.islandr.discovery.mode != real) nothing is actually probed, so the route precondition is skipped entirely. This keeps the feature testable in Docker/dev without WireGuard.cidr resolves to ≤ 1024 hosts (/22 or smaller). Larger CIDRs are rejected — a /16 would spawn 65k probe fan-outs (R-142).POST while a job runs supersedes it: the running scan is cancelled and replaced, so "scan again" always starts fresh and never dead-ends on a stale job (e.g. one orphaned by a client that navigated away). The one-active-scan invariant — and with it the connect-rate bound — is preserved.Site — the tightest honest consent story: you can only scan what you declared you own.discovery.scan_started, actor + site + CIDR + host count) and every import (discovery.import, actor + site + created IPs) is written to the append-only audit log (§8.4).<cidr> auf, um erreichbare Geräte zu finden." No stealth — TCP connect() is a full handshake the target logs; that honesty is a feature.The probe set serves two purposes, and only the first drives the type guess:
type (table below).80, 443, 8080, 8123, 8443 — prove reachability but are too generic to name a type on their own (a web UI on 8443 could be anything; 8123 is Home Assistant but there is no such resource type). They establish that the host is up and reachable over HTTP(S); they yield a computer guess only as a last resort and otherwise leave typeGuess = unknown. 445 is deliberately not treated as a NAS on its own, because every Windows host also opens it.Guesses are pre-filled in the import table and freely editable:
| Open port(s) | typeGuess |
|---|---|
| 554 (RTSP) | camera |
| 631 or 9100 | printer |
| 3389 (RDP) | computer |
| 5900 (VNC) | computer |
| 22 (SSH) | computer |
| 8006 (Proxmox) | rackserver |
| 445 (SMB) without 3389 | nas |
| 445 (SMB) with 3389 | computer |
| only liveness-only web ports | computer |
| none (ICMP-only) | unknown |
The camera type this leans on (RTSP/554) already exists in the resource-type set (added in V13, alongside rackserver/kvm from 0.11.0) — icon, typeLabels, DE/EN i18n, and the backend @Pattern allowlist are all in place. Discovery reuses it rather than adding a type; without a camera type the fingerprint for the very use case that triggered this ADR would be useless, so its prior existence is a precondition, not a side effect.
wg/nftDiscovery follows the project's mock-first pattern: a MockDiscoveryScanner returns synthetic hosts so the dev laptop and the test suite never touch a real network, and the real socket scanner is selected only in real mode. This keeps the feature testable without a live subnet and consistent with how wg/nft adapters already resolve.
The scan is plain JVM outbound TCP/UDP over the kernel's existing WireGuard route. It does not go through nft, wg, or the 0.11.0 socket proxy (ADR-0012). Discovery therefore works even in the degraded "enforcement unavailable" mode — a scan needs a route, not a working enforcement channel.
Baseline: A — unprivileged TCP-connect + UDP-unreachable async scan, bound to the site's CIDR (the decision).
| Criterion (weight) | A: unprivileged scan (baseline) | B: raw ICMP/ARP scan | C: manual IP paste / CSV import | D: scan-agent on the site gateway |
|---|---|---|---|---|
| Stays inside the ADR-0011 privilege model (5) | 0 | -1 | 0 | 0 |
| Host-discovery completeness (4) | 0 | +1 | -1 | +1 |
| Bulk-create quick-start value (fingerprint) (4) | 0 | 0 | -1 | 0 |
| Consent / minimal scan surface (3) | 0 | 0 | +1 | -1 |
| No new component to operate (3) | 0 | 0 | 0 | -1 |
| Implementation cost (2) | 0 | -1 | +1 | -1 |
| Weighted total | 0 | −3 | −3 | −4 |
Honest reading:
CAP_NET_RAW, which means a new capability or sudoers grant. That trades away the one thing ADR-0011 exists to protect, for a completeness gain the UDP-unreachable probe already recovers most of. Clear reject on the highest-weighted criterion.A wins by staying inside the privilege model while still producing a fingerprinted, bulk-importable list. Its real cost — incomplete discovery for fully-filtered hosts — is named as R-140 and mitigated (UDP probe) rather than denied.
Positive
sudoers line, no capability. An RCE gains no scanning power it did not already have as an outbound-socket process.nft/wg.Risks created
connect() is non-stealth and logged by the target; every scan is audited; there is no way to point it at a network the operator has not already registered./22, ≤1024 hosts), bounded concurrency, short per-host timeout, and one active scan per site.Accepted trade-offs
These fired when the ADR moved from Proposed to Accepted (0.12.0) — deferred until then so the live risk/threat registers did not carry entries for an unbuilt feature. All are now in place:
discovery building block (§5.3) in arc42.Site/Resource/gatewayPeerId model discovery populates.java.net.PortUnreachableException / DatagramSocket.connect — the unprivileged connected-UDP-socket ICMP-unreachable mechanism.nmap unprivileged host discovery (connect() TCP scan + connected-UDP ECONNREFUSED fallback).