~/2026/07/15/infrastructure-a-discovery-first-approach-to-documenting-an-undocumented-network.md
Infrastructure: Documenting an Undocumented Network, Discovery First
--- author: Tom Lasswell date: read: 8 min in: [engineering, strategy] tags: [discovery, network, python] ---
$ grep -n '^#' post.md
The environments I get called into are rarely undocumented by accident. They are undocumented because the person who knew the network left, the diagram was last touched three reorganizations ago, and every attempt to rebuild it starting from a blank Visio canvas has stalled the same way: nobody wants to be wrong in front of the team, so the document sits half-finished. The fix I keep coming back to is to stop trying to document from memory and interview, and start from discovery instead.
This post is the process, plus the scripts that do the collecting. There are four of them, run in this order:
Export-PassiveInventory.ps1: DHCP leases and Active Directory computer accounts.snmp_collect.py: ARP tables from routers and MAC address tables from switches, over read-only SNMP.merge_inventory.py: joins everything on MAC address and writesinventory.csvplusgaps.txt, the addresses nobody can explain.- A scoped
nmappass againstgaps.txtonly, then the merge again.
Only run any of this on networks you are authorized to assess, with the change window and the owner's sign-off in writing. Reading tables over SNMP is low-impact, but it is still access, and the active step is a port scan.
Let the Network Describe Itself First
Before I ask a single person what they think is connected to what, I pull what the infrastructure already knows about itself. Switch MAC address tables and ARP caches tell you what is actually plugged in and talking, not what someone remembers plugging in two years ago. DHCP lease tables tell you which device holds which address right now, and expired leases show what used to be there, including the things nobody put on a spreadsheet. Router and firewall configs list every VLAN, route, and rule that exists whether or not it was ever written down elsewhere. None of this requires a single meeting, and all of it is ground truth rather than institutional memory, which matters because institutional memory is exactly what is missing in an undocumented environment.
Each source answers a different question, and none is complete on its own:
| Source | Answers | Blind spot |
|---|---|---|
| DHCP leases | Which MAC holds which IP, and the hostname the client sent | Anything with a static IP |
| AD computer accounts | Which machines are domain-joined, with OS | Printers, appliances, Linux, anything not joined |
| Router ARP table | Every IP-to-MAC pair that talked through that router recently | Entries age out, so quiet devices can be missing |
| Switch MAC table | Which physical port and VLAN each MAC was seen on | No IP address at all |
The join key across all four is the MAC address. That's why the merge script is keyed on it rather than on IP or hostname.
Step 1: DHCP Leases and AD Computers
On Windows DHCP, Get-DhcpServerv4Lease returns only active leases by default; its -AllLeases switch adds offered, declined and expired leases, which is useful here because an expired lease is still evidence that a device existed. It is not evidence the device is there now, so the merge script only treats active leases as current and keeps the rest as history. The cmdlet takes scope objects from Get-DhcpServerv4Scope on the pipeline. Get-ADComputer needs -Properties for anything outside its default set, including IPv4Address, OperatingSystem and LastLogonDate.
Run it from a machine with the DhcpServer and ActiveDirectory modules (RSAT). It needs read access to each DHCP server and an ordinary domain account for the AD query.
<#
.SYNOPSIS
Exports DHCP leases and Active Directory computer accounts for discovery.
.DESCRIPTION
Reads every IPv4 scope on each DHCP server, including offered, declined and
expired leases, and writes dhcp-leases.csv. Then exports every AD computer
account with its DNS name, IPv4 address, operating system and last logon
date to ad-computers.csv. Both files feed merge_inventory.py.
.PARAMETER DhcpServer
One or more Windows DHCP servers to read.
.PARAMETER OutputPath
Folder for the two CSV files. Created if missing.
.PARAMETER SearchBase
Optional distinguished name to limit the AD computer export.
.EXAMPLE
.\Export-PassiveInventory.ps1 -DhcpServer dhcp01.corp.example -OutputPath C:\Discovery\2026-07-15
.NOTES
Requires the DhcpServer and ActiveDirectory modules (RSAT).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string[]]$DhcpServer,
[Parameter(Mandatory)]
[string]$OutputPath,
[string]$SearchBase
)
$ErrorActionPreference = 'Stop'
Import-Module DhcpServer, ActiveDirectory
New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
$leases = foreach ($server in $DhcpServer) {
$scopes = Get-DhcpServerv4Scope -ComputerName $server
foreach ($scope in $scopes) {
try {
Get-DhcpServerv4Lease -ComputerName $server -ScopeId $scope.ScopeId -AllLeases |
Select-Object -Property IPAddress, ClientId, HostName, AddressState, LeaseExpiryTime,
@{ Name = 'ScopeId'; Expression = { $scope.ScopeId } },
@{ Name = 'Server'; Expression = { $server } }
}
catch {
Write-Warning "Could not read scope $($scope.ScopeId) on ${server}: $($_.Exception.Message)"
}
}
}
$leases | Export-Csv -Path (Join-Path -Path $OutputPath -ChildPath 'dhcp-leases.csv') -NoTypeInformation
$adParams = @{
Filter = '*'
Properties = 'DNSHostName', 'IPv4Address', 'OperatingSystem', 'LastLogonDate', 'Enabled'
}
if ($SearchBase) { $adParams.SearchBase = $SearchBase }
$computers = Get-ADComputer @adParams |
Select-Object -Property Name, DNSHostName, IPv4Address, OperatingSystem, LastLogonDate, Enabled
$computers | Export-Csv -Path (Join-Path -Path $OutputPath -ChildPath 'ad-computers.csv') -NoTypeInformation
Write-Output "$(@($leases).Count) DHCP leases and $(@($computers).Count) AD computers written to $OutputPath"
.\Export-PassiveInventory.ps1 -DhcpServer dhcp01.corp.example, dhcp02.corp.example -OutputPath C:\Discovery\2026-07-15
Step 2: ARP and MAC Address Tables over SNMP
Switches and routers publish their tables in standard MIBs, so one collector works across vendors:
- ARP:
ipNetToMediaPhysAddress(1.3.6.1.2.1.4.22.1.2) in the IP-MIB. The table is indexed by interface and IP address, so the IP comes out of the row index and the MAC is the value. RFC 4293 superseded this table withipNetToPhysicalTable, which adds IPv6, but the older IPv4 table is the one most gear still answers. If yours doesn't, that's the table to switch to. - MAC table:
dot1dTpFdbPort(1.3.6.1.2.1.17.4.3.1.2) in the BRIDGE-MIB (RFC 4188). The MAC is encoded in the row index as six decimal numbers and the value is a bridge port number.dot1dTpFdbStatusmarks each entry aslearned(3),self(4),mgmt(5)and so on. - Port names: bridge port numbers aren't interface numbers.
dot1dBasePortIfIndex(1.3.6.1.2.1.17.1.4.1.2) maps bridge port toifIndex, andifName(1.3.6.1.2.1.31.1.1.1.1) turns that intoGi1/0/12.
One vendor quirk matters a lot. Cisco Catalyst switches keep a separate BRIDGE-MIB instance per VLAN, reached with community string indexing: query with community@20 to get VLAN 20's MAC table. Without it you only see VLAN 1. The collector takes a --vlans list for that.
The script shells out to Net-SNMP's snmpbulkwalk with -On (numeric OIDs), -Oq (no type labels), -Oe (numeric enums), -Ob (numeric indexes) and -Ox (hex strings, for MAC values). It reads the community string from an environment variable, which keeps it out of scripts and shell history. It is still passed to snmpbulkwalk with -c, so anyone who can list processes on the collector can see it while a walk runs: run the collector on a host only you can log in to. SNMPv2c sends that string in clear text, so use a read-only community restricted by ACL to your collector, or move to SNMPv3 if the gear supports it.
#!/usr/bin/env python3
"""Read ARP and MAC address tables from routers and switches over SNMP.
Writes arp.csv (IP -> MAC per router) and fdb.csv (MAC -> switch port per switch).
Needs Net-SNMP's snmpbulkwalk on PATH and read-only SNMP access to each device.
The community string comes from the SNMP_COMMUNITY environment variable so it
stays out of scripts and shell history. It is still visible in the process list
while snmpbulkwalk runs, so run this on a host only you can log in to.
"""
import argparse
import csv
import os
import subprocess
import sys
ARP_PHYS = ".1.3.6.1.2.1.4.22.1.2" # IP-MIB ipNetToMediaPhysAddress
FDB_PORT = ".1.3.6.1.2.1.17.4.3.1.2" # BRIDGE-MIB dot1dTpFdbPort
FDB_STATUS = ".1.3.6.1.2.1.17.4.3.1.3" # BRIDGE-MIB dot1dTpFdbStatus
BASE_PORT_IFINDEX = ".1.3.6.1.2.1.17.1.4.1.2" # BRIDGE-MIB dot1dBasePortIfIndex
IF_NAME = ".1.3.6.1.2.1.31.1.1.1.1" # IF-MIB ifName
FDB_STATUS_NAMES = {"1": "other", "2": "invalid", "3": "learned", "4": "self", "5": "mgmt"}
def walk(host, community, oid, hex_strings=False):
"""Return [(index_suffix, value)] for every row under oid."""
fmt = "-Onqeb" + ("x" if hex_strings else "")
cmd = ["snmpbulkwalk", "-v2c", "-c", community, "-t", "2", "-r", "1", fmt, host, oid]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
print(f"warning: {host} {oid}: {result.stderr.strip()}", file=sys.stderr)
return []
rows = []
for line in result.stdout.splitlines():
if not line.startswith(oid + "."):
continue # "No Such Object" and similar end-of-table lines
name, _, value = line.partition(" ")
rows.append((name[len(oid) + 1:], value.strip().strip('"')))
return rows
def hex_to_mac(value):
octets = value.replace(":", " ").split()
if len(octets) != 6:
return None
return ":".join(o.lower().zfill(2) for o in octets)
def collect_arp(host, community):
rows = []
for index, value in walk(host, community, ARP_PHYS, hex_strings=True):
parts = index.split(".")
if len(parts) != 5:
continue
mac = hex_to_mac(value)
if mac:
rows.append({"router": host, "ifindex": parts[0], "ip": ".".join(parts[1:]), "mac": mac})
return rows
def collect_fdb(host, community, vlans):
if_names = dict(walk(host, community, IF_NAME))
rows = []
# Cisco Catalyst keeps one BRIDGE-MIB instance per VLAN, reached with community@vlan.
contexts = [(community + "@" + v, v) for v in vlans] if vlans else [(community, "")]
for ctx_community, vlan in contexts:
port_ifindex = dict(walk(host, ctx_community, BASE_PORT_IFINDEX))
status = dict(walk(host, ctx_community, FDB_STATUS))
for index, port in walk(host, ctx_community, FDB_PORT):
octets = index.split(".")
if len(octets) != 6 or port == "0":
continue
ifindex = port_ifindex.get(port, "")
rows.append({
"switch": host,
"vlan": vlan,
"mac": ":".join(f"{int(o):02x}" for o in octets),
"bridge_port": port,
"port_name": if_names.get(ifindex, ""),
"status": FDB_STATUS_NAMES.get(status.get(index, ""), ""),
})
return rows
def write_csv(path, rows, fields):
with open(path, "w", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fields)
writer.writeheader()
writer.writerows(rows)
def main():
parser = argparse.ArgumentParser(description="Read ARP and MAC address tables from routers and switches over SNMP.")
parser.add_argument("--routers", nargs="*", default=[], help="Layer 3 devices to read ARP tables from")
parser.add_argument("--switches", nargs="*", default=[], help="Switches to read MAC address tables from")
parser.add_argument("--vlans", nargs="*", default=[], help="VLAN IDs for Cisco community@vlan indexing")
parser.add_argument("--out", default=".", help="Output directory")
args = parser.parse_args()
community = os.environ.get("SNMP_COMMUNITY")
if not community:
sys.exit("Set SNMP_COMMUNITY to a read-only community string.")
os.makedirs(args.out, exist_ok=True)
arp = [row for host in args.routers for row in collect_arp(host, community)]
fdb = [row for host in args.switches for row in collect_fdb(host, community, args.vlans)]
write_csv(os.path.join(args.out, "arp.csv"), arp, ["router", "ifindex", "ip", "mac"])
write_csv(os.path.join(args.out, "fdb.csv"), fdb, ["switch", "vlan", "mac", "bridge_port", "port_name", "status"])
print(f"{len(arp)} ARP entries, {len(fdb)} MAC table entries")
if __name__ == "__main__":
main()
export SNMP_COMMUNITY='read-only-string'
python3 snmp_collect.py --routers 10.0.0.1 10.20.0.1 \
--switches 10.0.0.11 10.0.0.12 --vlans 1 10 20 30 --out /srv/discovery/2026-07-15
Build the Inventory Before the Diagram
The instinct is to open a drawing tool immediately, because a diagram feels like real progress. I have learned to resist that until there is a structured inventory: every discovered device with its IP, MAC, switch port, VLAN, and whatever role can be inferred from its hostname or services. A diagram built directly from a spreadsheet like that is trustworthy in a way that a diagram built from someone's best recollection during a meeting is not, and updating a spreadsheet row is far less friction than redrawing a canvas every time discovery turns up something new.
The merge script builds that spreadsheet. Two details in it matter:
- Uplinks. A switch learns every downstream MAC on its uplink and trunk ports, so the same MAC appears on several switches. The script treats any port that learned more than
--uplink-thresholdMACs (default 5) as an uplink and keeps the port with the fewest MACs as the device's real access port. That's a heuristic: raise the threshold for ports that feed an unmanaged switch or a desk hub, and check the result against a couple of known devices. - Gaps. An IP that answered ARP but has no DHCP lease and no AD account is a statically addressed device nobody wrote down: a printer, a UPS card, a camera, an old appliance. Those go to
gaps.txt, and they're the only thing the active scan in the next step touches.
#!/usr/bin/env python3
"""Merge passive discovery exports into one inventory keyed by MAC address.
Inputs (any may be missing): arp.csv and fdb.csv from snmp_collect.py,
dhcp-leases.csv and ad-computers.csv from Export-PassiveInventory.ps1, and
optionally an nmap XML file from the scoped active scan.
Outputs inventory.csv, plus gaps.txt: IPs that answer ARP but have no DHCP
lease and no AD computer account, which is the short list worth scanning.
"""
import argparse
import csv
import ipaddress
import os
import xml.etree.ElementTree as ET
from collections import Counter, defaultdict
def read_csv(path):
if not os.path.exists(path):
return []
with open(path, newline="", encoding="utf-8-sig") as handle:
return list(csv.DictReader(handle))
def norm_mac(value):
hexdigits = "".join(c for c in (value or "").lower() if c in "0123456789abcdef")
return ":".join(hexdigits[i:i + 2] for i in range(0, 12, 2)) if len(hexdigits) == 12 else ""
def edge_ports(fdb, uplink_threshold):
"""Pick the access port for each MAC, ignoring ports that learn many MACs (uplinks, trunks)."""
per_port = Counter((r["switch"], r["port_name"] or r["bridge_port"]) for r in fdb)
best = {}
for r in fdb:
if r.get("status") not in ("", "learned"):
continue
port = (r["switch"], r["port_name"] or r["bridge_port"])
count = per_port[port]
if count > uplink_threshold:
continue
mac = norm_mac(r["mac"])
if mac and (mac not in best or count < best[mac][1]):
best[mac] = ({"switch": port[0], "port": port[1], "vlan": r["vlan"]}, count)
return {mac: info for mac, (info, _) in best.items()}
def read_nmap(path):
"""Return {ip: {"mac": ..., "vendor": ..., "services": "22/ssh OpenSSH; ..."}}."""
found = {}
if not path:
return found
for host in ET.parse(path).getroot().iter("host"):
status = host.find("status")
if status is None or status.get("state") != "up":
continue
ip, mac, vendor = "", "", ""
for addr in host.findall("address"):
if addr.get("addrtype") == "ipv4":
ip = addr.get("addr")
elif addr.get("addrtype") == "mac":
mac, vendor = norm_mac(addr.get("addr")), addr.get("vendor", "")
services = []
for port in host.iter("port"):
state = port.find("state")
if state is None or state.get("state") != "open":
continue
svc = port.find("service")
label = f'{port.get("portid")}/{svc.get("name", "") if svc is not None else ""}'
product = svc.get("product", "") if svc is not None else ""
if product:
label += " " + product
services.append(label)
if ip:
found[ip] = {"mac": mac, "vendor": vendor, "services": "; ".join(services)}
return found
def main():
parser = argparse.ArgumentParser(description="Merge passive discovery exports into one inventory keyed by MAC address.")
parser.add_argument("--dir", default=".", help="Folder holding the exports")
parser.add_argument("--nmap", help="nmap -oX file from the scoped scan")
parser.add_argument("--uplink-threshold", type=int, default=5,
help="Ports that learned more MACs than this are treated as uplinks")
args = parser.parse_args()
path = lambda name: os.path.join(args.dir, name)
inv = defaultdict(lambda: defaultdict(str))
sources = defaultdict(set)
for r in read_csv(path("arp.csv")):
mac = norm_mac(r["mac"])
if mac:
inv[mac]["ip"] = inv[mac]["ip"] or r["ip"]
sources[mac].add("arp")
for r in read_csv(path("dhcp-leases.csv")):
mac = norm_mac(r["ClientId"])
if not mac:
continue
inv[mac]["hostname"] = inv[mac]["hostname"] or r["HostName"]
if r.get("AddressState", "").startswith("Active"):
inv[mac]["ip"] = inv[mac]["ip"] or r["IPAddress"]
sources[mac].add("dhcp")
else:
sources[mac].add("dhcp-history") # expired, declined or offered: seen once, not proof it is here now
for mac, port in edge_ports(read_csv(path("fdb.csv")), args.uplink_threshold).items():
inv[mac].update(port)
sources[mac].add("switch")
ip_to_mac = {row["ip"]: mac for mac, row in inv.items() if row["ip"]}
ad_ips = set()
for r in read_csv(path("ad-computers.csv")):
ip = r.get("IPv4Address", "")
if ip:
ad_ips.add(ip)
mac = ip_to_mac.get(ip)
if mac:
inv[mac]["ad_name"] = r["Name"]
inv[mac]["os"] = r.get("OperatingSystem", "")
sources[mac].add("ad")
for ip, hit in read_nmap(args.nmap).items():
mac = hit["mac"] or ip_to_mac.get(ip) or f"ip:{ip}"
inv[mac]["ip"] = inv[mac]["ip"] or ip
inv[mac]["vendor"] = hit["vendor"]
inv[mac]["services"] = hit["services"]
sources[mac].add("nmap")
fields = ["mac", "ip", "hostname", "ad_name", "os", "vendor", "switch", "port", "vlan", "services", "sources"]
with open(path("inventory.csv"), "w", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fields)
writer.writeheader()
for mac in sorted(inv, key=lambda m: ipaddress.ip_address(inv[m]["ip"] or "0.0.0.0")):
writer.writerow({"mac": mac, **{f: inv[mac][f] for f in fields[1:-1]}, "sources": "+".join(sorted(sources[mac]))})
gaps = sorted({row["ip"] for mac, row in inv.items()
if row["ip"] and "arp" in sources[mac] and "dhcp" not in sources[mac] and row["ip"] not in ad_ips}, key=ipaddress.ip_address)
with open(path("gaps.txt"), "w") as handle:
handle.write("\n".join(gaps) + ("\n" if gaps else ""))
print(f"{len(inv)} devices in inventory.csv, {len(gaps)} unexplained IPs in gaps.txt")
if __name__ == "__main__":
main()
The output looks like this:
mac,ip,hostname,ad_name,os,vendor,switch,port,vlan,services,sources
00:50:56:ab:cd:01,10.0.0.1,,,,,,,,,arp
00:50:56:ab:cd:20,10.0.0.20,WS-ACCT-07.corp.example,WS-ACCT-07,Windows 11 Enterprise,,sw1,Gi1/0/1,10,,ad+arp+dhcp+switch
b0:7b:25:00:00:30,10.0.0.30,,,,Dell,sw1,Gi1/0/48,10,443/https lighttpd,arp+nmap+switch
The sources column is the useful part. A row with ad+arp+dhcp+switch is fully explained. A row with only arp is a question for the people who run the place.
Passive Before Active
I run passive discovery first, before anything that actively probes the network. An undocumented environment is exactly the kind of place where an aggressive subnet-wide scan finds a piece of ancient building-management or industrial gear that falls over the moment something unexpected talks to it. Nmap's own documentation makes the relevant point: crashes are rare with the default timing, and "omitting version detection is far more effective than playing with timing values at reducing these problems." So the active step is narrow on purpose: only the addresses in gaps.txt, with anything known to be fragile excluded, and version detection kept light.
#!/usr/bin/env bash
# Scoped active pass: only the addresses passive discovery could not explain.
set -euo pipefail
DIR=/srv/discovery/2026-07-15
# fragile.txt: one address or range per line (PLCs, BMS controllers, old UPS cards).
touch "$DIR/fragile.txt"
# 1. Confirm which gap addresses are alive. On a local Ethernet segment nmap uses ARP for this.
nmap -sn -iL "$DIR/gaps.txt" --excludefile "$DIR/fragile.txt" -oX "$DIR/ping.xml"
# 2. Light service detection on the top 100 ports, rate-capped.
nmap -sV --version-light --top-ports 100 -T3 --max-rate 100 \
-iL "$DIR/gaps.txt" --excludefile "$DIR/fragile.txt" -oX "$DIR/services.xml"
# 3. Fold the results back into the inventory.
python3 merge_inventory.py --dir "$DIR" --nmap "$DIR/services.xml"
-sn does host discovery only, with no port scan. -iL reads targets from a file and --excludefile removes addresses from any range you give it. --version-light is shorthand for --version-intensity 2, faster and a little less likely to identify a service than the default intensity of 7. --max-rate 100 caps sending at 100 packets per second. -oX writes the XML the merge script parses: each <host> has <address addrtype="ipv4"> and, on a local segment, <address addrtype="mac" vendor="…">, and each open <port> carries a <service name product version>. Run nmap as root (or with raw-socket capability) so it can use ARP and SYN probes.
For deeper per-device fingerprinting of what's left, the subnet fingerprinting script adds vendor lookups, SSH banners and HTTP Server headers, and the RDP and SSH scanner answers the narrower "where can someone log in remotely" question.
Interview People to Fill Gaps, Not to Start the Picture
Once discovery has done as much as it can on its own, that is when I bring in the people who have been running the place. But the conversation changes completely when you show up with "here is what I found, help me understand these three things I could not resolve on my own" instead of "tell me what you remember about the network." The first version respects their time and gets specific, useful answers. The second version puts someone on the spot to reconstruct years of undocumented decisions from memory, and the gaps in what they say get treated as gaps in the network rather than gaps in recall.
In practice the interview sheet is just the inventory filtered to rows whose sources column lacks dhcp and ad, sorted by switch and port, so someone can walk to the closet with it:
python3 - <<'EOF'
import csv
rows = [r for r in csv.DictReader(open("/srv/discovery/2026-07-15/inventory.csv"))
if not {"dhcp", "ad"} & set(r["sources"].split("+"))]
for r in sorted(rows, key=lambda r: (r["switch"], r["port"])):
print(f'{r["switch"]:<12} {r["port"]:<12} {r["ip"]:<15} {r["mac"]} {r["vendor"]} {r["services"]}')
EOF
Documentation That Outlives the Discovery Effort
The point of doing this discovery-first is not just to produce one good diagram, it is to leave behind a process that can be re-run. I hand off the inventory and the scripts that built it, not just a static drawing, so that six months later when something has inevitably changed, the next person does not have to start from zero and a stale document again. An undocumented network usually got that way because documentation was treated as a one-time deliverable instead of a habit; discovery-first work only pays off long term if it becomes the habit, not the exception.
Making it a habit is mostly scheduling. A weekly run into a dated folder, committed to a Git repository, turns git diff into a change report:
# /etc/cron.d/discovery: Sundays 02:00, passive collection only
0 2 * * 0 discovery /srv/discovery/run-weekly.sh >> /var/log/discovery.log 2>&1
#!/usr/bin/env bash
# /srv/discovery/run-weekly.sh
set -euo pipefail
. /srv/discovery/env # exports SNMP_COMMUNITY, ROUTERS, SWITCHES, VLANS (mode 600)
DIR=/srv/discovery/$(date +%F)
mkdir -p "$DIR"
cp /srv/discovery/latest/dhcp-leases.csv /srv/discovery/latest/ad-computers.csv "$DIR"/ 2>/dev/null || true
python3 /srv/discovery/snmp_collect.py --routers $ROUTERS --switches $SWITCHES --vlans $VLANS --out "$DIR"
python3 /srv/discovery/merge_inventory.py --dir "$DIR"
ln -sfn "$DIR" /srv/discovery/latest
cd /srv/discovery
# env holds the community string: it is listed in .gitignore and never staged.
git add -- "$DIR" latest
git diff --cached --quiet || git commit -qm "discovery $(date +%F)"
The Windows half runs as a scheduled task on a management server and drops its two CSVs into the share the Linux collector reads. Keep the active nmap step out of the schedule. It stays a deliberate, scoped action someone chooses to run; to flag new hosts between those runs, diff two scans instead.
From here the inventory feeds everything else: VMware inventory for the virtual side, orphaned DNS records cross-checked against live IPs, a Windows inventory that maintains itself, and ConnectWise configuration items synced from the results so the PSA matches reality.
References
- Get-DhcpServerv4Lease (Microsoft Learn)
- Get-ADComputer (Microsoft Learn)
- RFC 4188: Definitions of Managed Objects for Bridges (BRIDGE-MIB)
- RFC 4293: Management Information Base for the Internet Protocol (IP-MIB)
- RFC 1213: MIB-II (
ipNetToMediaTable) - Use SNMP to obtain the CAM table on Catalyst switches (Cisco, community string indexing)
- Net-SNMP snmpcmd options
- Nmap reference guide: host discovery, target specification, version detection, timing and performance, XML output