Discovery — what the network scan does, and the promises it keeps
What the network scan sends, the ports it never touches, the consent it records before the first packet, and what it stores.
Written for three readers: the operator deciding whether to press Scan, the security person asked to approve it, and the sceptic who assumes the worst.
What it is
Glion can sweep a network range you name and tell you which services on it are things it could manage — a Proxmox host, a Postgres, a Portainer. It exists so the first ten minutes are “here is what you have” rather than a blank form.
What it actually sends
A scan is two phases of plain TCP connection attempts.
- Liveness. A connect to a dozen common ports on every address in the range
(
LIVENESS_PORTSinserver/src/discovery/sweep.ts). An address that answers nothing is dropped and never touched again — a machine with no open TCP port has nothing to manage. - Fingerprint. For an address that answered, a connect to the ports the signature set cares about, reading whatever banner, TLS certificate subject or short HTTP fragment the service offers.
It does not log in, send credentials, guess passwords, or send any payload beyond what a service volunteers to identify itself. The evidence it keeps is the bytes it received, verbatim, so a confirmation is auditable.
The ports it will never touch
Some ports are not “probe gently”, they are “do not connect”, because a bare TCP connection can itself cause harm:
- Print servers — LPD
515, JetDirect9100–9107. A raw connection can eject a page or jam a queue. - Industrial control — Modbus
502, Siemens S7102, DNP320000, EtherNet/IP44818, BACnet47808. A raw connection can trip a safety interlock on hardware that moves.
These are the NEVER_PROBE set. The guard is a filter on the port list
(safePorts), applied to both phases inside sweep(), so there is no code path
that reaches them even if a signature or a caller names one. never-probe.test.ts
pins it.
The consent it records
Before the first packet, the console shows the consent text
(server/src/discovery/consent.ts, GET /api/discovery/consent) and the scan
carries an acknowledgement. Glion writes a discovery_scans row — the range, the
host count, who asked, from what address, and the sha256 of the exact consent
wording accepted — before it starts. The wording can be reworded later; the hash
records which version this scan was consented under. A scan with no acknowledgement
is refused.
Scan only a network you are authorised to scan. On a shared network that authority may not be yours alone, which is the whole reason the ceremony exists.
How gentle it is
The defaults are sized for a homelab, not a datacentre fabric: 64 connections
in flight at once, 4 to any single host (DEFAULT_LIMITS). The range is capped
at a /22 (1,024 hosts). It runs one scan at a time — a second request for the
same range joins the running job rather than putting the packets on the wire twice.
What it stores, and for how long
Found services live in discovered_services, updated in place, with first_seen
preserved so “this has been here since March” is not overwritten. Since migration 020
a finding belongs to a machine (hosts, below): one row per machine and port, and one
per address and port only while nothing has placed a machine there. A service a
complete rescan of its range no longer sees is marked missing_since rather than
deleted — that it used to be there is information. Retention (on by default) ages the
record out.
Platforms that say what they run
A sweep guesses at the estate from outside. The platforms the estate already runs know
outright, so a connected one is asked (ConfigSource, beside the connector drivers):
| Platform | What a read places |
|---|---|
| OPNsense | every NIC in the DHCP lease table (Kea, dnsmasq or ISC) and the ARP table, by MAC; the firewall itself, with the API the connector reads |
| Portainer | every Docker engine, and every TCP port a running container publishes, named by its image |
| Proxmox VE | every node with 8006, and every guest NIC by the MAC in its config |
| Coolify | the servers it deploys to, at their configured addresses, and Coolify itself |
A read is GET requests with the credential the operator gave that connector; it
changes nothing on the platform and puts no sweep on the network. It runs when the
connector is added, when the operator presses Read now on the Inventory screen
(audited), and every inventory.interval_minutes (Settings → Inventory; 15 by default,
0 turns the timer off). A read that could not see everything — a table the API user may
not read, an engine that is down, an answer cut off — is incomplete, and concludes
nothing is gone. Portainer is how Docker is read: the raw daemon on TCP is not offered.
Who a machine is
A machine is keyed on an identity a platform vouches for — a MAC, or a platform’s own id for a machine it runs — never on its address, because the address is what DHCP moves. The address is an attribute with a window: when something proved the machine held it, and until when (a lease from its grant to its expiry, a reservation or a configured address until a read says otherwise, an ARP entry or a guest’s own report for twenty minutes). A machine seen at a new address keeps its row and records where it came from.
A sweep’s sighting of an address is tied to the machine proven to hold that address at that moment — the one already reporting that port, else the NIC, else a lone platform machine — and to no machine otherwise. So when DHCP hands a machine’s address to another, the next tenant inherits none of its findings, and a machine that moved keeps its own.
Questions the inventory asks
Where a finding has no machine proven behind it, where a machine that runs something has
no name, or where nothing reaches a finding and nobody has said how it should be, the
inventory asks one question (inventory_gaps, migration 021) with the evidence that
raised it and the one field its answer is written into. The operator answers on the
Inventory screen or from the dock; an agent is told the open questions and answers none.
A later read or sweep that settles a question closes it, and a dismissed one is not asked
again.
Where an agent stands
An agent cannot start a scan. Every discovery route is behind the console’s
authentication, and req.ip/consent come from the authenticated request, not from
anything an agent controls. Discovery is an operator action.