Skip to content

Discovery: services from ports, self-improving names, and symmetric identity - #348

Merged
Timmoth merged 9 commits into
stagingfrom
feature/network-host-identity
Sep 29, 2026
Merged

Timmoth merged 9 commits into
stagingfrom
feature/network-host-identity

Conversation

@Timmoth

@Timmoth Timmoth commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Builds on the network-host-identity work with four changes to how discovery names and identifies things, plus a UI fix.

Every open port becomes a Service

The open-ports label is gone. It was a comma-separated string nothing could link to, filter on, or hang a note from, and RackPeek already has a resource for "a thing listening on an address and a port". Each open port now gets its own Service with a stable id, running on its host.

What a service announced about itself still beats what its port implies. Where nothing answered, the name falls back to host plus the port's usual service, and an unrecognised port keeps its number rather than guessing.

Labels named for what they are

vendor becomes nic-vendor — the OUI identifies whoever owns the network interface, which is not the same claim as who made the machine. segment is dropped: it described the firewall's wiring rather than the machine.

Neither label has ever been in a release, so no migration is needed.

Names improve until a person chooses one

A placeholder like host-1a2b3c4d now gives way to a real name when a collector turns up knowing it, and every reference follows — stored runsOn, stored connections, and services named after their host. A new userNamed flag records a human choice and discovery never touches the name again.

The upgrade is one-way, and one real name never replaces another, or two collectors knowing different names would rename a box back and forth every run.

The address bridge is symmetric

It previously fired only when the incoming card was the scan and the stored one was agent-grade — so sweeping before running the hypervisor left duplicates where the other order produced one card. Two scan cards can now bridge too, but only when exactly one of them saw a MAC.

Measured on a nine-subnet lab, sweeping first: 14 machines were in the inventory twice, and are now in it once.

Service links that work

Every service link was derived as http:// regardless of port, so SSH on 22 rendered as http://host:22/. The dependency trees were worse — they fed display text (Ip: 10.0.0.5:3000) straight into an href. Both rules now live in one place, and ports a browser cannot open get no link at all.

Testing

64 new tests (Discovery 475, MCP 71, main 378, E2E 106). Each new behaviour has a test proven to fail without its change. Verified end to end against a live nine-subnet lab.

🤖 Generated with Claude Code

Timmoth and others added 9 commits September 27, 2026 22:22
A sweep of a multi-VLAN homelab produced 26 anonymous "host-<hash>" cards
out of 27: ARP is link-local so remote subnets yield no MAC, and few home
networks have PTR records. The sweep found everything and recognised
nothing. Three sources close most of that gap without a new dependency.

Service banners. Once a host is known alive it is asked what it is: a TLS
certificate's common name (443/8006/8443), an HTTP page title or Server
header, then an SSH greeting. This is the identification half of
`nmap -sV` in about 150 lines of BCL sockets. Only open ports are asked —
a handshake with a closed port buys nothing but a timeout.

The three answers are different claims, so they are treated differently.
An X.509 common name is a host name by construction, so a certificate
names the machine. A page title names an application, and a host may run
several, so each becomes a Service resource hanging off the card rather
than renaming it. An SSH greeting names only the daemon — every Linux box
on a subnet answers "OpenSSH" — so it annotates and never names. An
appliance's own management page is not a service on itself, so a title
matching the host's own name is skipped.

Open ports. Liveness stops at the first answer, which left pingable hosts
with no port evidence at all. A living host is now checked against a wider
list (554 cameras, 1883 brokers, 8123, 9000, 3000, 32400 and friends) and
the result lands in an open-ports label. These are observations, not
conclusions: "554 is open" is a fact, "this is a camera" is an inference
the reader is better placed to draw, and a port number is a convention
rather than a guarantee — so nothing is named from one. They are, however,
the best possible targets for the banner probes.

MAC vendor. Where a MAC is known the card gains the organisation IEEE
assigned that OUI to, from a curated 10,868-entry subset of the registry
generated by generate-oui-table.py. A hypervisor's own prefix beats the
locally-administered bit so a KVM guest reads as QEMU/KVM rather than
anonymous, while an address a phone invented for itself is reported as
randomised instead of attributed to whoever owns the block.

Services are also anchored by address: one whose runsOn resolves to
nothing takes the system at its own IP, which fixes the dangling link a
docker collector leaves when it can only guess its host's name. Narrow by
design — only an unresolvable link, only when exactly one system claims
that address, and never for systems, where a shared address means the same
machine rather than a parent.

Cards stay sparse and identity stays put: nothing learned here writes an
OS or a core count, and a name from a service never moves a discoveryId.
--no-identify restores a pure liveness sweep.

All names, addresses and MAC suffixes in tests and docs are invented; the
OUI prefixes are public IEEE registry data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Proxmox only records a guest's address when someone set one statically, so
on a DHCP estate every guest arrived with no address at all. That cost
more than the field: a network sweep of another subnet has no ARP entry to
work from, so it identifies a host by address alone — and with the guests
carrying no address there was nothing to match, leaving a second, emptier
card beside every guest the hypervisor had already described in full.

Guests are now asked where they are. A VM answers through its
qemu-guest-agent, a container through its running interfaces; a stopped
guest is never asked, since it has nothing to report and the call is one
round trip per guest.

Choosing among the answers is the hard part. A guest that runs containers
reports several interfaces — docker0 and its per-network bridges, Home
Assistant's hassio, any VPN tunnel — and recording 172.17.0.1 as the
machine's address would be worse than recording nothing, because every
container host on the estate reports the same one. The NIC MACs Proxmox
assigned are the discriminator: an interface carrying one is a NIC the
hypervisor gave the guest, anything else the guest invented. Where the
MACs cannot be read, nothing is claimed. A statically configured address
still wins, being what the administrator asked for.

With addresses in hand a sweep's find can be matched to the guest it
actually is, so the resolver gains an address bridge beside the MAC one.
Narrow on purpose: only a scan-grade card that produced no MAC of its own,
only against an agent-grade card of the same kind, and only when exactly
one stored system claims that address. Services anchor to the stored card
in preference to a sweep's stand-in for the same reason.

On a live two-node estate this turned nine scanned cards on the guest
subnet into six guests updated in place and two genuinely unknown hosts,
with the discovered web services landing on the real VMs rather than on
stand-ins.

The read orchestration moves into the domain on the way past. The CLI and
the MCP tool each had a copy, and the copies had already drifted — the MCP
one dropped the guests' MACs, silently costing every guest its chance of
unifying with a scan. Now there is one, and it is tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Proxmox number written as a string took the whole run down. Its perl
backend quotes numeric fields inconsistently — "vmid":"100" and
"maxdisk":"512110190592" both turn up across versions — and the
TryGetInt32 family does not return false for a string, it throws. The
kind is now checked first and a quoted number parsed, which is what the
caller meant either way. Before this, one such field produced
"Unexpected error occurred" plus a stack trace on the CLI, and MCP
forwarded the BCL's "requires an element of type 'Number'" as though the
user had done something wrong.

An ssh:// or npipe:// DOCKER_HOST crashed. `docker context` sets the
first for a remote host and the second is the Windows default, so both
are ordinary values to find in the environment. HttpClient accepts either
URI and only throws NotSupportedException on the first request, which was
past the command's catch list. They are now refused where the client is
built, through the UriFormatException both front ends already turn into
"not a usable Docker endpoint" — one phrasing for a bad endpoint rather
than two — and the message says how to forward the socket instead.

A remote engine with no IPv4 address had its services recorded at this
machine's address. Services are recorded at their host's address and the
inventory holds IPv4 only, so when the endpoint resolves to IPv6 alone the
address is genuinely unknown; substituting whatever machine ran the
command gave every service a confident, wrong address that then flowed
into the ansible, ssh and hosts exports. There is no fallback now, and the
run stops with an explanation.

Each fix has tests that fail without it: quoted numbers across the guest
list, both unreachable endpoint schemes, and an engine answering on an
IPv6-only socket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The collector for everything a sweep can see but not identify.

ARP is link-local. Sweeping from one machine yields a MAC for that
machine's own segment and nothing but an address for every other subnet —
and an address alone cannot survive a DHCP re-lease or be matched against
anything already documented. The firewall routes every subnet, so its
neighbour table carries the MAC for all of them, the name it handed out,
and which leg each machine answered on.

Cards are seeded exactly as the network sweep seeds its own, keyed on the
MAC, because both describe the same thing by the same evidence: a machine
observed on the network rather than asked about itself. So a host the
firewall knows and a host a sweep found are one card, whichever ran first,
with no special case anywhere to say so. On a live estate this turned 21
resources into 13 updated in place and 8 machines no sweep had ever seen —
devices that answer no port and no ping but sit in the firewall's table.

What it leaves out, on purpose: the firewall's own addresses (every routed
subnet contributes one and they are all the same box, which is a Firewall
rather than the handful of Systems this would invent), entries that have
aged out, broadcast and multicast, and neighbours on public addresses.
That last one is the ISP's equipment on the WAN leg — not the user's
infrastructure, and recording it would put a public address into a file
people commit. --include-public asks for them.

Tolerant of the endpoint rename in OPNsense 25.7, which moved these
actions from camelCase to snake_case and broke integrations pinned to
either spelling; both are tried. Both response shapes are read too, since
the search wrapper wraps the same rows in an object. A key that lacks the
Diagnostics: ARP Table privilege gets a redirect to the login page rather
than a 401, so that is reported as the permission problem it is.

Verified against a live two-firewall estate; the fixtures and tests use
invented addresses and MAC suffixes throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An open port was landing in an `open-ports` label: a comma-separated string
nothing could link to, filter on, or hang a note from. RackPeek already has a
resource for "a thing listening on an address and a port", so each open port
now becomes a Service with its own stable id, running on the host that serves
it — nebula-ssh, nebula-https, nebula-proxmox.

What a service said about itself still beats what its port number implies,
because a port is a convention and an answer is evidence. Where nothing
answered the name falls back to the host plus the port's usual service, and an
unrecognised port keeps its number as nebula-tcp-9987 rather than guessing. An
appliance whose management page only says its own name back is named for the
port instead, so a firewall called opnsense gains an opnsense-https rather than
a second card called opnsense.

Two labels change with it. `vendor` becomes `nic-vendor`, because the OUI
identifies whoever owns the network interface, which is not the same claim as
who made the machine — a Proxmox guest's virtual NIC reads Proxmox while the
box underneath it is a Dell. And `segment` goes: it described the firewall's
wiring rather than the machine, and changed whenever anything was re-cabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A collector names a card from whatever it could see, and when it could see
nothing the name falls back to a slug of the card's own id — host-1a2b3c4d says
only that something is there. A later run, or a collector that can see more,
often does know the machine's name: a firewall knows what it handed out over
DHCP, a hypervisor knows what its guest is called. Those placeholders now give
way to real names, and everything pointing at the old name follows: stored
runsOn, stored connections, services named after their host, and the incoming
payload's own references.

A new `userNamed` flag draws the line discovery must not cross. Renaming sets
it, every rename path goes through one use case, and nothing in discovery
touches the name again. A resource with no discoveryId is user-named whatever
the flag says — nothing but a person could have written it. The upgrade is
one-way, placeholder to real, and one real name never replaces another, or two
collectors that each knew a different name would rename a box back and forth on
every run.

The address bridge is now symmetric. It fired only when the incoming card was
the scan and the stored one was agent-grade, so sweeping before running the
hypervisor left both cards behind where the other order produced one. Which
collector ran first is an accident of what someone typed and must not decide
what the inventory holds. Two scan cards can bridge as well, but only when
exactly one of them saw a MAC: a firewall's neighbour table names the interface
answering at an address where a sweep of a subnet it does not sit on only knows
something replied, and those are not two stand-ins. Cards of equal standing
never bridge.

Measured on a nine-subnet lab, sweeping first: fourteen machines were in the
inventory twice, and are now in it once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs, one cause: three places each decided for themselves what a service's
URL was, and all three were wrong.

The card derived every link as http:// regardless of the port, so SSH on 22
rendered as http://10.0.0.5:22/ — a link that looks real, invites a click and
cannot load. It was reading Network.Protocol as though it named a scheme, but
discovery writes the transport there (TCP), which says nothing about what rides
on top of it. The dependency trees had it worse: they fed NetworkString() into
an href, and that was display text — "Ip: 10.0.0.5:3000", prefix and trailing
space included — so the link was dead on arrival.

Both rules now live in one place. A scheme is only inferred where the
convention is genuinely a web one; ssh, smb, mqtt, dns and anything uncurated
get no link at all, because a dead link is worse than none. A URL someone typed
by hand still always wins, and an explicit http or https in the protocol field
is still believed, which is the only thing that can answer on an uncurated port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BrowsableUrl built its result with UriBuilder, which throws UriFormatException
on a host it cannot parse — "a/b", a name with spaces, a bare run of dots. The
address field holds whatever a person typed or a collector read off a device,
and neither is obliged to produce something a URL can be built from.

That throw was already latent on the service card, where it would have spoiled
one card. It matters more now: the dependency trees call this for every service
they render, so a single malformed address would blank the entire hardware or
system page instead of costing one link.

A missing link costs a click. Letting this escape costs the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Timmoth
Timmoth merged commit b9b35be into staging Sep 29, 2026
6 checks passed
@Timmoth
Timmoth deleted the feature/network-host-identity branch September 29, 2026 08:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant