A lightweight SNMP network scanner for GLPI 11: a single static Go binary that holds no scan policy and no OID knowledge of its own, plus a GLPI plugin where all of it is configured.
Companion to glpiosquery, which covers endpoints. This covers everything that
answers SNMP but cannot run an agent — switches, routers, firewalls, printers,
PDUs, UPSes.
GLPI plugin scanner (Go, static)
├ scan targets (ranges, schedule) ──job──► expand ranges
├ SNMP credentials (core's own) ──job──► probe v1 / v2c / v3
├ OID profiles (shipped + UI) ──job──► walk standard MIBs + profile OIDs
└ ingest ◄──────────────report──────────── emit GLPI inventory JSON
│
└──► Glpi\Inventory\Inventory ──► NetworkEquipment / Printer / Unmanaged
+ ports, IPs, components
| Concern | Owned by |
|---|---|
| SNMP credentials (v1/v2c/v3, encrypted) | GLPI core — glpi_snmpcredentials |
| Turning inventory data into assets | GLPI core — native inventory pipeline |
| Ports, IPs, components, locations | GLPI core |
| Payload format | GLPI core — inventory.schema.json |
| What to scan, when, with which OIDs | this plugin |
| Talking SNMP and emitting the payload | this scanner |
Core has no IP-range or scan-task tables, so telling a scanner what to scan is
the actual gap, and it belongs in the web UI. The scanner is ~1,800 lines of Go
with one dependency (gosnmp) and ships no OIDs: profiles arrive with the job.
Standard MIBs out of the box, nothing vendor-specific, so it works across switches, routers, firewalls, UPSes, printers and VoIP phones alike:
| MIB | Gives |
|---|---|
| SNMPv2-MIB | name, description, contact, location, uptime, sysObjectID |
IF-MIB ifTable |
per-port descr, type, MTU, speed, admin/oper status, last change, in/out bytes and errors, MAC |
IF-MIB ifXTable |
64-bit octet counters, ifHighSpeed, port name, alias |
| EtherLike-MIB | duplex status |
| IP-MIB | device and per-port addresses |
| BRIDGE-MIB | forwarding table — which MACs are behind which port |
| Q-BRIDGE-MIB | VLAN names, per-port membership, tagged/untagged, PVID, VLAN-aware FDB |
IF-MIB ifStackTable / IEEE8023-LAG-MIB |
link aggregation: which ports are bundled into which port-channel |
| LLDP-MIB | neighbour chassis, port, system name/description, management address |
| CISCO-CDP-MIB | neighbours where LLDP is absent |
| ENTITY-MIB | serial, model, manufacturer, firmware, asset tag; stack members and modules |
| HOST-RESOURCES-MIB | memory |
| PRINTER-MIB (RFC 3805) | toner, ink, drum, waste and kit levels; page counters, serial and model |
| UPS-MIB (RFC 1628) | battery state, runtime, input/output lines, alarms |
| ENTITY-SENSOR-MIB | temperature, humidity, current and power sensors |
| PowerNet / XUPS / Sentry3 / PDU2 | vendor UPS identity and PDU outlet tables |
| AIRESPACE-WIRELESS-MIB | Cisco WLC: access points, radios, SSIDs, client counts |
| WLSX-WLAN-MIB / RUCKUS-ZD-WLAN-MIB | Aruba and Ruckus controllers, the same per vendor |
| UBNT-UniFi-MIB / MIKROTIK-MIB | standalone access points: networks broadcast and their clients |
The forwarding table and LLDP/CDP together give GLPI real port-level topology:
switch ports wired to the devices behind them, with Unmanaged assets for
neighbours never seen before. Verified against a simulated Cisco switch: 8
port-to-port links, neighbours resolved by LLDP, MAC-only neighbours resolved by
OUI.
Three details handled explicitly:
- Bridge ports are not ifIndex.
dot1dBasePortIfIndexmaps between them; the dev simulator offsets bridge ports by 100 so a collector conflating the two fails loudly. - VLAN membership is a bit-packed
PortList, MSB-first, indexed by bridge port, so a 24-port switch numbering from 101 needs a 14-byte bitmap. self(4)andmgmt(5)FDB entries are dropped. They are the switch's own addresses; importing them wires every switch to itself.
A port is reported either as an LLDP/CDP link or as learned MACs, never both:
GLPI's NetworkPort asset switches on the port's lldp flag and reads the whole
connections array one way or the other.
From two sources, in order of how much they can be trusted:
- IEEE8023-LAG-MIB —
dot3adAggPortAttachedAggIDsays which aggregator each port is currently attached to, and the MIB defines that identifier as the aggregator's own ifIndex. Nothing is inferred. Not every device implements it. - ifStackTable — which interfaces are layered above which. Everything implements it, but a LAG is not the only thing that stacks: a VLAN interface sits above its ports, and a tunnel above its transport.
The stack table is filtered by the aggregator's interface type, the one signal
separating a port-channel from an SVI. Default set is ieee8023adLag(161) and
propMultiplexor(54); hardware reporting a bundle as plain ethernet needs
aggregate_iftypes in a profile.
GLPI drops these ports unless told not to. glpi_networkporttypes ships
ieee8023adLag(161) with is_importable = 0 and no instantiation type, so an
aggregated interface is discarded before its member list is read — verified by
submitting one and watching only the members appear. GLPI models aggregation
properly through NetworkPortAggregate; the row is simply not switched on. The
plugin switches it on at install and reverts at uninstall, and only reverts it if
it still looks like ours.
Members are reported on the aggregator as ifIndexes, the shape GLPI resolves to port ids itself. A member not among the collected ports is dropped rather than reported, since GLPI matches by ifIndex and a name resolving to nothing leaves a silent hole in the aggregate.
ifXTable is preferred wherever present. The 32-bit ifSpeed gauge saturates
at 4294967295 on anything above 4 Gbps, and 32-bit octet counters wrap in under
a minute on a loaded 10G link. ifHighSpeed is only trusted when ifSpeed could
not have been accurate, since below saturation ifSpeed is the more precise of
the two.
GLPI's inventory schema has no field for discards, and none for live UPS state — that is monitoring data, so it is not collected rather than forced somewhere it does not belong.
Power gear becomes a native GLPI PDU asset, typed UPS, Rack PDU or ATS,
with a Power state tab.
| Collected | From |
|---|---|
| Battery status, charge %, runtime, voltage, temperature, replace-battery | UPS-MIB, PowerNet, XUPS |
| Output source — the "is it on battery?" answer | UPS-MIB |
| Input and output lines: volts, amps, watts, Hz, load % | UPS-MIB, rPDU2 phase table |
| Active alarms, named | UPS-MIB alarm table |
| Per-outlet name, on/off state and current | rPDU2, Sentry3, Raritan PX |
| Inlet temperature, humidity, per-bank sensors | ENTITY-SENSOR-MIB |
| Outlet count on the asset | GLPI Item_Plug |
| Management port: name, MAC, IP, speed, MTU | IF-MIB + IP-MIB → GLPI NetworkPort |
Power devices bypass core's inventory pipeline, for two verified reasons: there
is no Glpi\Inventory\MainAsset\PDU, so the best core could manage is filing a
UPS as Unmanaged; and the inventory schema has no vocabulary for batteries,
outlets or load, so the interesting half of the data would be dropped.
That state is a single current snapshot, not a time series. "Is this UPS on battery, and how long has it got?" is an inventory-shaped question; trending is monitoring's job.
Detection is behavioural rather than a guess from sysObjectID: a device is a
power device if it answers the standard UPS battery group, a profile-declared
outlet table, or a profile says so. sysServices cannot help — power gear
reports itself as an ordinary IP-managed appliance. A battery then separates a
UPS from a rack strip.
There is no IETF PDU MIB, so rack strips are reached entirely through vendor
profiles. Shipped: APC rPDU2, ServerTech Sentry3 and Raritan PX for outlets; APC
PowerNet and Eaton XUPS for UPS identity. Every OID came from the published MIBs
— APC's switched outlet table is under 26.9.2 while 26.9.4 is the separate
metered table.
Power OIDs accept a *factor scale suffix, because these MIBs report tenths of a
volt, amp or hertz and the factor differs per vendor for the same field:
battery_voltage = 1.3.6.1.2.1.33.1.2.5.0*0.1
outlet_current = 1.3.6.1.4.1.318.1.1.26.9.4.3.1.6*0.1
A switch's forwarding table names the MAC of the UPS plugged into it. GLPI
resolves those MACs through RuleImportAsset, which searches
$CFG_GLPI['asset_types'] — and PDU is not in that list (it is in
networkport_types, which is why the port itself works). So by default a switch
port facing a UPS links to an Unmanaged placeholder rather than the UPS asset
that already exists.
Let switch topology resolve to UPS and PDU assets adds PDU to that list.
It is off by default because asset_types is consulted widely across core.
With it off nothing churns: the placeholders are created once and left alone, measured stable across repeated scans. With it on, placeholders left by earlier scans are retired as each device is identified.
Ingest is ordered so referenced devices land before the devices referencing them — switches are imported last, so the ports they point at already exist.
GLPI reads a port's connections one of two ways, chosen by its lldp flag, and
only one of them works:
handleMacConnection()resolves each MAC through the rule engine and callsaddPortsWiring()— this links the switch port to the real asset.handleLLDPConnection()runs the same rules but guards the wiring behindcount($this->connection_ports) != 1, on a property it resets to[]immediately above and never populates. GLPI's own source marks this as dead code, with a TODO calling it "most likely" a real bug.
So an LLDP neighbour always becomes an Unmanaged placeholder even when the
device is already inventoried, meaning preferring LLDP — otherwise the richer
signal — actively loses the link.
The plugin rewrites LLDP connections it can already identify into the MAC form
the working path understands: if a port already exists carrying the neighbour's
chassis MAC, GLPI gets the MAC and links to the real asset; otherwise the LLDP
form is kept, since nothing can be linked anyway and LLDP at least yields a
placeholder named core-rtr-01 rather than one named after an OUI.
The scanner also sends the far end's port name (lldpRemPortId, when the
port-ID subtype says it is an interface name) rather than its human description,
since GLPI matches on the name.
A handset answering SNMP becomes a native GLPI Phone — name, serial, model,
manufacturer, brand, firmware, location, contact, type VoIP — plus its
network ports. Desk phones report two interfaces: the switch uplink and the PC
passthrough port. Both are recorded; the uplink MAC is the one the switch learns.
Recognising a phone is the interesting part, because the obvious signals fail:
sysServicescannot do it. A desk phone contains a two-port switch, so it sets the datalink bit and looks like network equipment.sysObjectIDonly works for vendors already in a profile.
The primary signal is LLDP: lldpLocSysCapEnabled is a BITS value in which
telephone(5) is the bit a phone sets about itself, which works for any vendor.
Profiles for Yealink, Polycom, Grandstream, Snom, Mitel, Fanvil, Gigaset, Avaya
and Cisco add the manufacturer on top; every enterprise number was checked
against the IANA registry. Cisco phones share Cisco's arc with every switch they
make, so those are matched by sysDescr regex.
Phones bypass core's inventory pipeline for a verified reason: MainAsset\Phone
extends the base MainAsset, whose prepare() reads hardware/bios — the
computer inventory shape. Only NetworkEquipment reads network_device.
Feeding core a netinventory payload with itemtype: Phone produces an asset with
no name, MAC or serial, which its own "Phone constraint (name)" rule then
refuses. Unlike PDU, Phone is in asset_types, so topology resolves to it
with no opt-in.
Most desk phones do not answer SNMP, or answer only on a voice VLAN the scanner
cannot route to. Those are inventoried by LLDP-MED: a phone announces its
manufacturer, model, serial and firmware to the switch port it is plugged into,
and the switch hands it over. A scan of one switch therefore produces a Phone
for every handset on it, for devices the scanner never spoke to.
What makes an endpoint a phone is
lldpXMedRemMediaPolicyAppType = voice(1) — a statement the MIB makes, not an
inference from the model string. The media policy table is indexed one arc wider
than the inventory table, so the application type is available per endpoint. The
alternative is guessing from model names, which turns an estate's cameras and
door controllers into telephones.
A handset visible both ways is submitted once. The MED record knows nothing about the device's interfaces, so letting it through would strip the ports the direct walk found; a device walked directly in the same pass suppresses its MED record. The match is on serial and MAC rather than address, because the two sources disagree about addresses by nature.
The OIDs are from LLDP-EXT-MED-MIB (ANSI/TIA-1057), under the TIA OUI subtree
1.0.8802.1.1.2.1.5.4795, read out of the published MIB.
An access point behind a controller is a real asset — a serial-numbered box that gets RMA'd, moved and replaced — and most cannot be scanned at all: a CAPWAP-tunnelled AP has no reachable management address. So one scanned address produces many assets: the controller, plus a NetworkEquipment for every AP it reports, each with its own name, model, serial, location, firmware and address.
The identity is the AP's own — serial first, then MAC — never the controller's address. An AP moved to a different controller is the same physical box, and a controller replaced under warranty must not orphan sixty access points.
The switch port comes for free. The controller reports each AP's Ethernet MAC, which is what the switch it is plugged into has learned, so the existing topology correlation wires the AP to its real port. An AP that never answered a single SNMP request ends up on the right port of the right switch.
Provenance is the controller's address, not the AP's. The off-target guard checks submissions against the ranges the scanner was told to sweep, and an AP legitimately sits outside them.
| Pack | |
|---|---|
| Cisco | AIRESPACE-WIRELESS-MIB — APs, radios, SSIDs |
| Aruba / HPE | WLSX-WLAN-MIB — APs, radios, SSIDs |
| Ruckus | RUCKUS-ZD-WLAN-MIB — APs, with a per-AP client count the others lack |
| UniFi | UBNT-UniFi-MIB — standalone, virtual APs |
| MikroTik | MIKROTIK-MIB — standalone, AP-mode interfaces |
Matched on enterprise roots rather than per-model: a Catalyst 9800 reports a Cisco chassis sysObjectID, so a model list stops working at the next hardware refresh. Detection is behavioural and the gate is cheap — one probe decides, so an ordinary Cisco switch does not pay for six empty walks per scan.
Adding a vendor is a profile, not a release: the collector owns the mechanism,
the profile owns the OIDs, under a wireless section (ap_name, ap_model,
ap_serial, ap_mac, ap_ip, ap_location, ap_status, ap_firmware,
ap_radios, ap_clients, radio_band, radio_channel, radio_clients,
radio_status, ssid, ssid_clients, and *_map decoders).
Client counts. Cisco and Aruba report clients per radio, in a table indexed one arc finer than the AP table. The scanner sums them back onto each access point by longest index prefix, so a per-AP total exists nowhere on the wire and is derived. Reading that column as if it were an AP column would attribute one radio's clients to the whole AP. Ruckus publishes a real per-AP count, and where a vendor does its own figure is used. A virtual-AP table lists the same SSID once per radio, so those are folded by name and their clients summed.
Radios become wifi ports. GLPI models a radio as a NetworkPort whose
instantiation is NetworkPortWifi, with the 802.11 flavour as its version — a
shape the inventory format cannot express, so the plugin writes it directly. A
standalone AP additionally gets a port per SSID linked to a WifiNetwork entry.
A controller's SSID list does not: it is estate-wide, and no MIB here says which
network is on which radio of which AP.
The Wireless tab on a controller lists its SSIDs with client counts and every access point — model, serial, status, clients, per-radio band, channel and occupancy — each linked to its own asset. It appears only on devices the scanner recorded wireless state for.
Toner, ink, drum, waste, fuser, transfer, cleaning and staple levels from
prtMarkerSuppliesTable. No vendor OIDs and no per-model profile: every printer
worth managing implements RFC 3805. Levels land on the printer's Cartridges
tab as GLPI's own properties, because GLPI matches against a closed vocabulary
and anything outside it is stored unlabelled.
Two conventions are handled: a level in physical units (tenths of grams, impressions, items) becomes a percentage of max capacity, and a level already in percent is taken as-is.
A supply that will not report a level is omitted, never sent as 0%. The MIB
says "I do not know" with a negative level — unknown(-2), other(-1) — and
someRemaining(-3) means there is some left. Turning any of those into 0%
produces an estate where every printer looks empty: toner gets ordered for
printers that do not need it, and the one cartridge that really is empty is
invisible.
Waste bottles are reported as the percentage the device gives, which for a receptacle is how full it is. That is not inverted here — GLPI stores one number per property with no room for the distinction.
Paper tray levels are collected by no one: GLPI's inventory format has nowhere to put them.
Decided from a live marker counter or a marker supplies table (printer), a
battery or outlet table (power), and the sysServices datalink/internet bits
(networking); a vendor profile can force it. Anything else is reported
Unmanaged rather than guessed, and an operator can promote it.
Both printer signals are checked because small inkjets report ink levels and no page counter at all.
Every pass probes the whole range cheaply. A device is only walked in full when that is worth doing.
Against the SNMP simulators, with tiny tables over loopback, a full walk costs 86–157× a probe — roughly 2 ms against 200–360 ms. A 48-port switch across a WAN is far worse.
first pass probed 8 in 4ms, answered 8, walked 8 in 2.5s
second pass probed 8 in 4ms, answered 8, walked 0
after a reboot on one switch walked 1 in 555ms
after deleting one printer asset walked 1 in 316ms
The decision is GLPI's, not the scanner's. Only the server knows what it already holds, when it last held it, and whether someone has since deleted the asset. A scanner keeping its own notes would skip a device whose asset had been removed, and GLPI would stay empty with nothing to explain why.
A device is walked when:
- it has never been walked, or the asset it produced no longer exists — which makes deleting an asset a supported way to force a clean re-import;
- it rebooted, seen as
sysUpTimegoing backwards. That matters twice over, since every other counter here is measured againstsysUpTime; - a standard change counter moved:
ifTableLastChange,ifStackLastChangeorentLastChangeTime; - the last full walk is older than the target's inventory interval.
That last rule exists because the counters cannot see everything: none moves when a port goes down or a MAC moves between ports. It defaults to a day. Zero switches the rule off and leaves only the change signals.
A device implementing none of the counters reports zero every pass. That is an absence of evidence, not evidence of change: treating it as a change would walk such hardware on every sweep and give back the whole saving.
Each target's form shows its recent scans — probed, answered, walked, and the timings — because those are the numbers the two intervals are tuned against.
The split is negotiated, not assumed. A scanner talking to a plugin that predates
it gets no inventory_wanted field and walks everything it finds.
A Port map tab on every NetworkEquipment with ports: the switch drawn as
its own front panel, sat immediately before core's "Network ports" table it is a
picture of.
GIGABITETHERNET0
┌──┐┌──┐┌──┐┌──┐┌──┐ 1 above 2, 3 above 4 — the arrangement of
│ 1││ 3││ 5││ 7││ 9│ the sockets, so the picture can be read
└──┘└──┘└──┘└──┘└──┘ against the hardware instead of translated
┌──┐┌──┐┌──┐┌──┐┌──┐
│ 2││ 4││ 6││ 8││10│ ▔▔▔▔ a stripe marks a trunk
└──┘└──┘└──┘└──┘└──┘
LOGICAL INTERFACES
┌───────────┐┌──────────────┐ ports with no place on the front are
│Management ││Port-channel1 │ drawn apart rather than given one
└───────────┘└──────────────┘
Four ways to colour the same switch, switched without a reload:
| Mode | Answers |
|---|---|
| Status | what is up, what is down, and what somebody shut — admin-down wins over oper-down, because "the port is down" sends a technician to check a patch lead that is fine |
| VLAN | where each VLAN actually reaches. Coloured by the untagged VLAN, which is what an ordinary device plugged in would land in; trunks carry the stripe instead |
| Speed | what negotiated at what — the 100 Mb/s port in a gigabit row, without reading 48 rows |
| Neighbour | which ports have something identified on the other end, and which are dark |
Clicking a port opens its detail without a round trip: VLANs tagged and untagged, the neighbouring asset and its port (linked), aggregate membership, MAC and addresses, last state change, and the latest counters with errors called out. Every port is also listed as a table under the faceplate.
Nothing here is a second copy of the port inventory. The scan hands ports,
VLANs and topology to GLPI's native pipeline and core owns them from there —
this is a projection of glpi_networkports and its relations, so it draws a
switch some other agent inventoried just as well. The only line it cannot show
for one of those is when it was last walked, which is the one fact that is this
plugin's.
GLPI 11 can already show a faceplate — NetworkEquipmentModelStencil — and it
is a different tool. It needs a photograph of that exact model uploaded and
every port zone placed on the image by hand, per model, before it shows
anything; and once placed it shows ifOperStatus as a coloured dot and nothing
else. This needs no preparation at all, and the layout is derived from the port
names: the trailing run of digits is the position and everything before it is the
module, which is how every vendor names ports whatever else they disagree about
(GigabitEthernet0/1, Ethernet1/48, ge-0/0/7, Te1/1/4, port12, swp3).
Splitting at the last run is what keeps a stack's members apart — otherwise two
members' port 1s are drawn on top of each other.
- Tab order is done in the browser.
CommonGLPI::defineAllTabs()isfinal, appends plugin tabs strictly last, and takes no hook;registerStandardTab()'s$orderonly sorts plugin tabs against each other. Moving one list item at load is the whole of the alternative — and the narrow-screen<select>has to be renumbered with it, because core switches tabs from it by position. - Tile colours are fixed hex, not theme tokens. Green-is-up has to mean the
same thing on every palette, or two people describing the same switch disagree
about its colour. Contrast was measured rather than assumed, in both the light
and the
auror_darkpalettes: the tile border is theme-derived so the edge stays visible (a "down" tile is 1.8:1 against the dark body), and core's solid.badgeinherits the ambient text colour in the light palette too — the same trap the dark rules already document, measuring 1.43:1 onbg-secondary.
php plugin/tests/portmap-layout.php covers the position, status and speed
rules without GLPI; the faceplate's layout, mode switching, detail panel, refresh
and tab position are checked in plugin/tests/browser/netscan-check.js.
Everything else here reads. This does not, and the same mechanism that reboots a stuck access point can black out a rack or cut the link the scanner reaches the device by.
Four gates, all of which must be open before one SNMP SET leaves a scanner:
- A global setting, off by default and off after upgrades.
- A separate write credential per target, not the community the scanner reads with. Reusing the read credentials would mean a community that happened to be read-write silently made every device it reached controllable.
- A right, checked when the action is queued —
configUPDATE rather than UPDATE on the asset: being allowed to correct a switch's serial number is not the same as being allowed to shut its ports. - An expiry. A queued action not collected within ten minutes is abandoned and marked so, so a forgotten click does not power-cycle a rack when a scanner reconnects hours later.
Actions are queued rather than performed inline, because GLPI usually cannot reach the device network at all; the scanner is the thing with a route. The queue also produces an audit trail.
The confirmation is typed, not clicked: to shut GigabitEthernet0/3 you type
its name. The mistake this prevents is acting on the wrong row of a table, which
an "are you sure?" dismissed by reflex prevents none of.
One control is built in: port_admin, which is ifAdminStatus — standard,
indexed by ifIndex, and meaning the same on every device implementing IF-MIB.
Everything vendor-specific (outlet switching, PoE) comes from a profile's
control section, because those are the ones where a wrong OID or index does not
fail — it switches off something else:
"control": { "outlet_power": "1.3.6.1.4.1.318.1.1.26.9.2.4.1.5 on=1,off=2,cycle=3" }The queue holds intent (this port, down) rather than an OID: the profile
declaring a control is matched on the device's sysObjectID and only the scanner
knows it, and an audit row saying Gi0/3 → down is still readable a year later
where 1.3.6.1.2.1.2.2.1.7.3 = 2 is not.
A device sending a trap is telling the scanner it has changed — better evidence
than the counters a discovery pass reads, and it arrives immediately. A
notification clears that device's last-inventory stamp and the next pass walks
it. Measured on the dev estate: a linkDown from one switch turned the next
sweep into probed 8, answered 8, walked 1.
The trust model is weak by design of the protocol, not of this code. An SNMPv1 or v2c trap is an unauthenticated UDP datagram with a community string in it, trivially forged by anything that can route a packet to the listener. So:
- notifications are off unless switched on;
- a scanner accepts them only from addresses inside its own scan ranges, the same rule inventory submissions follow. An empty range list closes the listener rather than opening it;
- nothing a trap says is written into an asset. It is stored and shown as the device's claim, and can cause a re-inventory where the facts are then read from the device itself.
That last rule is what makes the weak authentication tolerable: the worst a forged trap achieves is an SNMP walk of a device the scanner could already walk.
Notifications appear on a Notifications tab, named where the OID is
well-known (coldStart, warmStart, linkDown, linkUp,
authenticationFailure). They are pruned per device, since a flapping port would
otherwise fill a disk with copies of one sentence.
Binding port 162 needs CAP_NET_BIND_SERVICE, which the hardened systemd unit
does not grant. Apply packaging/glpi-netscan-traps.conf as a drop-in, or use a
port above 1024 — a drop-in rather than a change to the unit, because most
installations do not receive traps and should not carry the capability.
Both make a scan look completely successful while importing nothing, and neither raises an error. The Readiness panel checks both, and a rejected device reports which one it hit.
enabled_inventorymust be on (Setup → General → Inventory). With it off,doInventory()accepts payloads and discards them.NetworkEquipment import (by mac)ships disabled. GLPI's stock rules only import network equipment that has a serial number, and many switches expose none. Everything else falls through to import denied and lands in Administration → Refused equipment. Enable the by-mac rule under Administration → Rules → Rules for asset import.
The scanner mitigates (2) by deriving a device MAC from the lowest-numbered port when the device offers no chassis MAC.
Profiles are layered by priority; the base profile is 0 and vendor profiles sit above it. Two sources, merged at job time:
-
Shipped packs —
plugin/data/profiles/*.json, matched bysysobjectid_prefix. Diffable in git, upgraded with the plugin. A pack file may hold one profile or a list.Shipped today: the standard-MIB base, printers, UPS identity, and ~36 vendor entries covering switching/routing (Cisco, HPE/Aruba, Juniper, Arista, Extreme, Dell, Huawei, MikroTik, Ubiquiti, Brocade, ALE, NETGEAR, TP-Link, D-Link, Zyxel, Allied Telesis, 3Com), firewalls (Fortinet, Palo Alto, SonicWall, Check Point, F5), power (APC, Eaton, CyberPower, Raritan), printers (Xerox, Lexmark, Ricoh, Kyocera, Brother, Epson) and VoIP (Avaya, Yealink).
The vendor list is identification only — it sets the manufacturer and, for power and VoIP, the asset type. No data collection depends on it being complete, because collection comes from the standard MIBs.
-
UI profiles — rows under Administration → Network scanning → OID profiles, layered on top. This is how a site adds one OID for one switch model without forking the plugin or losing it on upgrade.
UI profiles are authored as field = OID lines rather than raw JSON, because the
field names are a closed vocabulary mapping onto the inventory schema, so the
form rejects a typo instead of accepting a profile that silently collects
nothing. OIDs are validated as dotted decimal.
# a plain OID
serial = 1.3.6.1.4.1.9.3.6.3.0
# alternatives, tried in order until one answers
serial = 1.3.6.1.2.1.43.5.1.1.17.1|1.3.6.1.2.1.47.1.1.1.1.11.1
# a literal, for a device that exposes no OID for the field at all
manufacturer = =Cisco
# empty deletes a field inherited from a lower-priority profile
model =
Alternatives exist because layering is otherwise destructive. The printer pack
applied to every device and overrode serial, so every switch it touched came
back with a blank serial — the base ENTITY-MIB OID was never read. A broad
profile can now say "prefer mine, fall back to the standard one".
Prefix matching is arc-wise, not string-wise: 1.3.6.1.4.1.9 does not match
enterprise 91.
Individual collectors can be switched off per profile (fdb, lldp, cdp,
vlans, hc_counters) for hardware that answers a table with wrong data
rather than no data. All are on unless turned off.
Asks hardware vendors when the support contract or warranty on each scanned device ends, and writes the answer into GLPI's own warranty fields on the asset's Financial information tab. Nothing is stored in a private format, so GLPI's warranty-expiry search option, expiry-alert cron, dashboards and CSV export keep working unchanged.
Setup → GLPI Netscan → Warranty lookups. Covers NetworkEquipment, Printer,
Phone, PDU and Computer — everything the scanner produces that GLPI can hold an
Infocom on. (Unmanaged is not in $CFG_GLPI['infocom_types'].)
| Vendor | API | Credentials |
|---|---|---|
| Cisco | Support API SN2INFO v2 | OAuth2 client ID + secret, from apiconsole.cisco.com |
| Juniper | Service Asset API v1.0 (css-asset) |
API key + application id + entity source id |
| HPE | Support Entitlement (warrantyCheck) | OAuth2 client ID + secret, issued against a support agreement |
| Fortinet | FortiCare Registration API v3 | A FortiCloud IAM API user, not a portal login |
| Pure Storage | Pure1 REST API, support contracts | Pure1 application id + an RSA private key |
| Dell | TechDirect Asset Entitlements v5 | OAuth2 client ID + secret, from techdirect.dell.com |
| HP Inc. | Product Warranty API v2 | OAuth2 client ID + secret, from developers.hp.com |
| Lenovo | Warranty & Contract v2.5 | A ClientID token from a Lenovo account representative |
| Apple | GSX REST v2 | AASP/self-servicing agreement, client certificate, Sold-To/Ship-To, activation token |
| Microsoft Surface | Surface API Management Service | Entra app in the Intune tenant + an API subscription key |
Every one needs an account with the vendor; none has an anonymous tier. Each is a separate switch on top of a master switch, and nothing is contacted until both are on. Only the serial number leaves the server — no hostname, address, entity or instance URL. Requests go through GLPI's configured proxy.
Cisco and Juniper batch (75 and 50 serials per call), so an estate of switches is a handful of requests. Lookups run hourly from cron, bounded per run, with a per-vendor interval floor; a vendor answering "wrong credentials" or "slow down" is dropped for the rest of the run.
Two of the ten answer about a fleet rather than a serial. Microsoft and Pure Storage publish no per-device endpoint, so the whole tenant or organisation is fetched once per run.
- Pure Storage matches on array name, not serial. Pure1 publishes no serial number anywhere in its public API, so an asset matches the Pure1 array whose name or FQDN equals its GLPI name (the SNMP sysName, in practice) and the Warranty tab says so. Rename in one place and not the other and it surfaces as "not found" rather than a wrong date.
- Microsoft needs its tenant enrolled for scanning first — a state change inside your Microsoft tenant, so it is a button on the settings page rather than something the cron does. The first scan takes up to five business days.
HP and HPE are told apart by asset type, not by name: the 2015 split left two companies with two APIs, and an estate's ProCurve switches and its EliteBooks both report a manufacturer of "HP". Anything on a NetworkEquipment, PDU, Enclosure or Rack goes to HPE.
Kit with no manufacturer is matched on its model, which matters more here than
anywhere else: a Cisco access point routinely lands in GLPI with no manufacturer
at all, because the enterprise OID never mapped to one, and a model of
AIR-AP2802I-E-K9 or C9120AXI-E. Both were sitting unmatched in a real
inventory, which is how the product-ID patterns in Warranty/Detector.php came
to exist.
Vendors deliberately absent, each checked rather than assumed:
- Arista Networks — the only public API on
arista.comis the software download service (custom_data/api, as used by eos-downloader); CloudVision describes devices under management, not support entitlement. - Ubiquiti —
api.ui.comand the UniFi APIs return device inventory with no coverage data; warranty runs through rma.ui.com, which wants proof of purchase. - Supermicro — the serial warranty check is a web form; RMA is email.
- Zebra, APC/Schneider, Acer, ASUS, MSI, Dynabook/Toshiba, Fujitsu, and the phone and printer makers (Polycom, Yealink, Kyocera, Brother) — a web form in every case.
- Cisco Meraki — excluded on purpose: Meraki serials are not in SN2INFO, so every access point in an estate would fail every night.
Assets from any of those are recorded as not applicable rather than failing.
A device routinely has several overlapping entitlements — a hardware warranty, a SmartNet or FortiCare contract, sometimes both with different clocks. Infocom holds one span, so the entitlement that ends last is written; the full list is on the asset's Warranty tab with the service level, when it was last checked, and why there is no warranty when there is none.
Warranty fields typed in by hand are never overwritten unless an administrator allows it, and the purchase date and supplier are only filled in when empty.
Two quirks that look like bugs here and are not:
- Cisco reports an end date and no start date. GLPI stores a start plus a
duration in months, so a coverage with no start is written as a zero-length span
ending on the right day. Every expiry view and alert is then correct; the
"start" column holds the end date, and
warranty_infosays so explicitly. - GLPI computes the expiry two different ways. The search option and the
warranty-alert cron both use
DATE_ADD(warranty_date, INTERVAL warranty_duration MONTH)and land exactly on the vendor's end date; the Financial tab subtracts a day to show the last covered day.
The engine is shared with glpiosquery, which does the same job for the machines
its agent inventories. Both ship a complete copy so neither requires the other; in
the monorepo tools/sync-warranty.sh projects one into the other, and everything
per-plugin lives in Warranty/Scope.php.
No glpi-ai tool is added for this: the data is in GLPI's native fields, which the assistant already reads.
Modelled on the osquery agent's credentials, because the threat is the same shape.
- Two-tier credentials. A shared, scopable, revocable enrollment secret is exchanged once for a per-scanner token. The token is stored as a SHA-256 hash and shown exactly once — the database never holds a value that can be replayed.
- TLS required by default, at both ends. The job response carries decrypted community strings and v3 passphrases.
- Revocation is
scanner_invalid; deactivating a scanner cuts it off at the next poll and the agent re-enrolls rather than retrying. - Scanners can only report on what they were told to scan. Reported addresses are checked against the target's ranges, and the target is resolved from the run row rather than the request.
- Entity comes from the scanner, never from the payload — a submission is data, not authority.
- Range expansion is capped at 65,536 addresses, so
/8instead of/24is a visible error rather than a scanner that appears hung for a week. - The enrollment key passed to the MSI can reach a verbose install log.
ENROLLSECRETis marked hidden, but the resolved command line of the enrolment action can still appear under/l*v. An enrollment key buys exactly one thing — the right to obtain a per-scanner token — and revoking it in GLPI kills it while already-enrolled scanners keep working on their own tokens. - Self-update payloads are verified before they execute — SHA-256, size, and the binary's own reported version, checked while the old version is still active. A self-updater that installs whatever it downloads is a remote code execution channel, and TLS to the right host proves only where the bytes came from.
- The systemd unit keeps an empty capability bounding set,
ProtectSystem=strictand a@system-serviceseccomp filter. It runs as a dedicated unprivileged user rather thanDynamicUser, which self-update made impossible: a transient UID has no persistent writable install tree to update into.
# from the GLPI root — the directory must be named for the plugin key,
# which is not the repository name
git clone https://github.com/bijstaan/glpi-netscan.git plugins/glpinetscan
php bin/console plugin:install -u glpi glpinetscan
php bin/console plugin:activate glpinetscan| Menu | Entry | For |
|---|---|---|
| Administration | Scan targets | IP ranges, credentials, schedule, timeouts — the main page |
| Administration | Scanners | Enrolled scanners, status, version. No "add": they enroll themselves |
| Administration | OID profiles | Operator-authored OID overrides layered on the shipped packs |
| Setup | Network scanning | Enrollment keys, TLS policy, readiness checks |
With glpinav installed and on, the three Administration entries move together
into its Operations section; the pages resolve their own sector, so
breadcrumbs and Add buttons follow.
Everything the plugin needs is configured on those pages; GLPI's own inventory setup screens are not involved. Check the Readiness panel first — it catches the two GLPI settings above.
Linux and Windows, amd64 and arm64. One static binary either way — no runtime, no interpreter, no agent framework.
Go to Setup → Network scanning, pick the entity the scanner belongs to, name a key, and the page generates the command with the server and key filled in.
# Linux
sudo ./install-linux.sh
glpi-netscan install --server https://glpi.example.com --secret <key>
systemctl enable --now glpi-netscan# Windows — an MSI, for GPO, Intune, SCCM or any other deployment channel
msiexec /i glpi-netscan_0.2.0_amd64.msi /qn ^
SERVER=https://glpi.example.com ENROLLSECRET=<key>
| Property | |
|---|---|
SERVER |
GLPI base URL. Required to enrol |
ENROLLSECRET |
Enrollment key. Required to enrol |
CACERT |
Path on the target machine to a PEM bundle for a private CA |
SCANNERNAME |
Name shown in GLPI; defaults to the hostname |
NOUPDATES |
1 to pin this host to the installed version |
INSTALLFOLDER |
Install root; defaults to %ProgramFiles%\GLPI Netscan |
Omit SERVER and ENROLLSECRET and the service is registered but left stopped
rather than restart-looping against a server it has never been told about.
Enrolment failing does not fail the install: the configuration is written before
GLPI is contacted, so a scanner deployed during an outage enrols on a later poll.
install-windows.ps1 does the same without an MSI. It lays out
%ProgramFiles%\GLPI Netscan in the same versioned shape the updater maintains,
keeps state under %ProgramData%\GLPINetscan, and registers the service as
LocalService — the Windows counterpart of the empty capability bounding set
on the systemd unit.
install writes the configuration and enrolls immediately, so the failures
people actually hit — wrong URL, revoked key, untrusted certificate — surface at
the terminal rather than silently at the next service start.
Keys are scoped to an entity, and the entity a scanner enrolled into is where its discovered assets land, so you issue one key per site and everything files itself with no per-asset rules. Revoking a key stops new enrollments and leaves running scanners alone; revoked keys stay listed, because their enrolment counts are the record of what was installed with them.
Built on Linux with packaging/build-msi.sh, which needs wixl and msitools.
It asserts the package's own contents — secure properties, the versioned
directory name, the service account and start type, the custom-action type — and
fails rather than shipping a subtly wrong installer. Both architectures are built
the same way (build-msi.sh <version> arm64); wixl has no arm64 target, so the
package is built as x64 and its summary-information template corrected
afterwards, that template being the only architecture-dependent thing in an MSI
database.
wixl rather than the WiX Toolset: WiX only emits an MSI on Windows, so a release
could not be produced in one place, and from v6 it requires accepting the Open
Source Maintenance Fee EULA. wixl is LGPL and native. The cost is that it
implements a subset of WiX, which is why everything past "lay down the files and
register the service" lives in glpi-netscan msi-install.
Publish a release under Setup → Network scanning → Published packages: version, platform, architecture, an https URL, its SHA-256 and its size. GLPI records where a release lives and what it should hash to; it does not host the file. The scanner authenticates the download by checksum, so the host serving it does not have to be trusted.
Then open the tap. Two switches, not one:
| Setting | Default | |
|---|---|---|
| Offer published packages to scanners | off | Lets GLPI replace the binary on every scanner host |
| Rollout percentage | 0 | Share of the fleet the release has reached |
Enabling updates moves nothing by itself. Each scanner sits in a fixed ring from 0 to 99 derived from a hash of its row id, so raising the percentage always reaches the same machines first, in the same order — a bad build is found on 5% of the estate rather than all of it. The ring is keyed on the row id rather than hostname or token because it has to be stable for the life of the machine.
The offer rides the poll a scanner already makes. Unlike the osquery agent — which needs a channel of its own, because osqueryd's protocol is fixed — this protocol is ours end to end and the poll already reports the running version.
What a scanner does with an offer
- Downloads it and checks size and SHA-256. A mismatch is refused and nothing is touched.
- Runs the new binary once and compares the version it reports against the manifest, catching a package built for the wrong architecture or the right bytes labelled as the wrong release, while the old version is still active.
- Installs it as
versions/<version>/, writes a rollback marker, flips thecurrentsymlink, and exits so systemd restarts it onto the new version. - On the next successful poll — reaching GLPI, not merely starting — clears the marker and prunes versions older than the previous one.
When it goes wrong. preflight.sh runs as ExecStartPre before every start,
including systemd's restarts after a crash, which is what lets it catch a version
that cannot start at all. It counts attempts in the marker and after three points
current back at the previous version.
It also quarantines the failed version. Without that the machine ping-pongs: the
restored version polls, is offered the same broken build, stages it again — and
because staging records whatever current points at as its rollback target, the
second attempt records the broken version as the fallback, after which there is
no way back. Quarantine is per-version, so publishing a fix reaches the machine
immediately.
A scanner installed by hand has no versions/ tree, and the updater refuses to
stage rather than replacing the running binary in place. updates_enabled = false
in agent.conf pins an individual host.
| Guard | Covers a binary that cannot start at all | |
|---|---|---|
| Linux | preflight.sh, run by systemd as ExecStartPre |
yes |
| Windows | updater.Preflight(), in-process at startup |
no |
Windows has no ExecStartPre: the service control manager starts one binary, so
a guard running before it must be a second program or live inside it. Rather than
ship a launcher whose only job is to start another program, the check runs as the
first thing the scanner does.
That leaves one narrow case uncovered on Windows — a staged binary that cannot start as a process at all — narrow because a version is only activated after the updater has run it once and compared the version it printed. What remains is a binary that runs standalone but dies as a service, and that does reach the in-process guard.
Both implementations are held to the same behaviour by a test running them side by side that fails if they roll back after a different number of failed starts.
| Tool | What it answers |
|---|---|
network_coverage |
What the scanner is watching: targets and ranges, when each last ran and whether it failed, and the devices that answered SNMP but never became an asset |
network_alarms |
What devices reported, with timestamps — a link that dropped overnight, a reboot, a power event |
power_status |
Whether a UPS is on mains or battery, charge, runtime, alarms |
wireless_status |
Access points, firmware, radios and client counts |
Deliberately not the port inventory. Ports, connections and link status end up in GLPI's own tables, and glpi-ai reads them there natively — a site with no scanner still has that data from the native agent, and offering a second copy would give the model two sources that can disagree.
network_coverage is the exception to that rule, and earns it by being about the
scanner rather than the network: an inventory by definition only contains what
made it in, so "there is nothing on that subnet" usually means nobody has scanned
it. An unmapped device that answered SNMP is either shadow kit or a broken
mapping.
Gated on GLPI's networking right rather than config, which this plugin uses
for its scanners and targets. Those are configuration and belong to an
administrator; this is diagnostic data about network devices, and gating a UPS
battery level behind the configuration right would mean a technician could not
ask why the site lost power.
Two dev targets, because one is not enough:
- a real
net-snmpagent with v1, v2c and v3 (SHA-512 / AES) all enabled, so the protocol paths are exercised against real software; - three simulated devices (snmpsim): a Cisco switch serving BRIDGE-MIB, Q-BRIDGE-MIB and LLDP-MIB, an APC Smart-UPS serving UPS-MIB and PowerNet, and an APC rack PDU serving rPDU2 outlets and ENTITY-SENSOR. net-snmp on Linux implements none of these. One persona per container, because the scanner meets one device per address — sharing an endpoint would let the first matching community win and hide the others.
The fixtures are generated rather than hand-written: FDB tables are indexed by MAC, VLAN membership is a bit-packed PortList, and sensor values carry an implied decimal precision. All are easy enough to get wrong by hand that a bad fixture would "prove" a broken collector — which happened once, when a mistyped alarm OID made correct code look broken.
# local diagnostic; talks to no server
glpi-netscan -once -target 172.20.0.7 -community public \
-profiles plugin/data/profiles/00-base.json
go test ./...
cd plugin/tests/browser && node netscan-check.js
# 370 dependency-free tests for the warranty lookup: all ten vendor clients
# against captured response shapes (asserting the requests too, since none of
# these APIs can be called from a test environment), vendor detection, failure
# classification and the projection onto GLPI's fields.
docker exec glpi-glpi-1 php /var/www/glpi/plugins/glpinetscan/tests/warranty.phpWorking end to end: enroll → job → sweep → SNMP walk → payload → GLPI. A single
scan of the dev lab produces a NetworkEquipment with ports, VLANs and topology,
a PDU typed UPS with battery state and alarms, a PDU typed Rack PDU with
eight named outlets and inlet sensors, a Phone typed VoIP with both its
interfaces, and a second Phone for a handset that answers nothing at all,
inventoried out of the switch over LLDP-MED — and the switch's ports wire to
them.
Not yet built:
- Vendor power control out of the box. The mechanism ships and is tested; what does not ship is an APC/Eaton/Raritan outlet OID, because none could be read out of a published MIB here — the Observium listing for PowerNet is incomplete in exactly that subtree. Outlet switching therefore needs a profile entry, and this is the one place where an unverified OID would switch off the wrong thing rather than merely read the wrong value.
- Rack placement. GLPI can position a PDU in a rack; SNMP does not report where it is mounted.
- Outlet-to-asset links. Outlet names are captured verbatim ("esxi-01 PSU A") but they are free text a human typed into the PDU, and a wrong power dependency is worse than none.
- Wireless client sessions. Per-AP, per-radio and per-SSID client counts are collected; the tables listing individual associated stations are not. They are a point-in-time view of people rather than an inventory of things.
- Phone line/extension.
glpi_phones.number_lineis left empty: the extension is not in any standard MIB, and the vendor objects carrying it differ per handset. It is a profile field away for a site that knows its own fleet. - Non-voice LLDP-MED endpoints. The switch reports model and serial for every
MED endpoint, cameras and door controllers included, and the scanner reads all
of it — but only phones can be delivered. Anything else could only be an
Unmanaged, and GLPI has already created one: every MED endpoint is an LLDP neighbour of the switch, and GLPI's own inventory makes anUnmanagedfor each unmatched neighbour while processing the switch. That asset wins — a later submission for the same MAC is matched to its network port instead of applied to it, and the model and serial are dropped. - Phone firmware. Collected over LLDP-MED, but
glpi_phoneshas no firmware column and GLPI only builds a firmware component for wireless controllers.
Two components, two licences.
plugin/— GPL-3.0-or-later. A GLPI plugin loaded into GLPI's process and extending its classes, so a derivative work.scanner/— MIT. A standalone program that reaches the plugin over HTTP and contains no GLPI code.