~/2026/07/15/infrastructure-a-discovery-first-approach-to-documenting-an-undocumented-network.md

Infrastructure: Documenting an Undocumented Network, Discovery First

---
author: 
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:

  1. Export-PassiveInventory.ps1: DHCP leases and Active Directory computer accounts.
  2. snmp_collect.py: ARP tables from routers and MAC address tables from switches, over read-only SNMP.
  3. merge_inventory.py: joins everything on MAC address and writes inventory.csv plus gaps.txt, the addresses nobody can explain.
  4. A scoped nmap pass against gaps.txt only, 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:

SourceAnswersBlind spot
DHCP leasesWhich MAC holds which IP, and the hostname the client sentAnything with a static IP
AD computer accountsWhich machines are domain-joined, with OSPrinters, appliances, Linux, anything not joined
Router ARP tableEvery IP-to-MAC pair that talked through that router recentlyEntries age out, so quiet devices can be missing
Switch MAC tableWhich physical port and VLAN each MAC was seen onNo 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.

powershell
<#
.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"
powershell
.\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 with ipNetToPhysicalTable, 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. dot1dTpFdbStatus marks each entry as learned(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 to ifIndex, and ifName (1.3.6.1.2.1.31.1.1.1.1) turns that into Gi1/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.

python
#!/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()
bash
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-threshold MACs (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.
python
#!/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:

text
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.

bash
#!/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:

bash
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:

bash
# /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
bash
#!/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