Inventory Switch and AP Ports with SNMP Walk and MAC Tables
The hardest question when taking over someone else's work is "Which port is this machine plugged into?" Without a cabling diagram, I have to ask the switches themselves, and every switch that speaks SNMP can already answer this question.
I took over from the previous team at a building, and what I got was the switch passwords and the explanation that "the cabling diagram is on the computer of the technician who left." The switches had no controller or central management system; I had to open each one's web interface separately. There were also more than thirty APs spread across the floors. I decided to skip taking notes by hand and write a script to query the devices across the network through SNMP, then rebuild the entire inventory table. It took half a day to write, but gave me something I could rerun whenever I needed for as long as I maintained the site.
The IP addresses, device names, and community strings in this article are examples. The OIDs and code are real.
The task: find what connects to each port without unplugging cables
I needed four layers of information. First, the device's model and firmware. Second, how many ports it had, which were up or down, and their speeds. Third, which MAC addresses had been learned on which ports. Fourth, which IP address belonged to each MAC address.
The first three layers are in standard MIBs supported by almost every vendor. The fourth comes from the router's ARP table or DHCP leases, joined by MAC address.
The most common mistake is assuming the port number in the FDB table is the number printed on the device. It is not. The FDB returns a bridge port number, which must first be mapped through dot1dBasePortIfIndex to an ifIndex, then used to look up the actual port name in ifTable. On some vendors' devices, the numbers happen to match, so nobody notices. On a vendor where they do not match, the entire table will be wrong without anyone realizing it.
Enable read-only SNMP and restrict queries to one machine
Before getting to the code, I need to cover this because it matters more than the script. SNMP v2c sends the community string in plain text without encryption. Anyone capturing traffic on the same network can read it. My rules are therefore to enable read-only access only, set the community to something other than public, and allow queries only from the IP address of the collection machine.
A writable community is a way to change device settings without logging in. Inventory work has absolutely no need for it. A read-only community set to public effectively publishes the entire network layout for every machine on the network to read freely, including guests' machines connected over WiFi. If the device supports SNMP v3, use v3 instead.
The device configuration looks roughly like this. This example is for a switch configured through its CLI.
# เปิดอ่านอย่างเดียว + จำกัดต้นทางที่ถามได้
snmp-server enable
snmp-server community <RO-COMMUNITY> read-only
snmp-server host 192.168.10.9 version 2c <RO-COMMUNITY>
snmp-server view inventory 1.3.6.1.2.1 included
On the machine running the script, I read the community from the environment instead of embedding it in the file. That lets me commit the file to git without having to watch out for it.
export SNMP_RO='<RO-COMMUNITY>'
python snmp_probe.py 192.168.10.2 --community "$SNMP_RO" --out sw-core.json
What is the difference between GET and WALK?
There are two SNMP operations I use in practice. GET queries a single OID and returns one value, which suits values with a known location, such as sysName. GETNEXT asks "What is the next OID after this one?" Repeating it in a loop collects an entire branch. That is what is called a walk.
Recent versions of pysnmp run on asyncio. My walk follows this structure: keep calling next_cmd until the returned OID leaves the base I am interested in, with a limit to prevent an endless loop when a device gives unusual responses.
import asyncio, json, argparse
from pysnmp.hlapi.asyncio import (
SnmpEngine, CommunityData, UdpTransportTarget, ContextData,
ObjectType, ObjectIdentity, get_cmd, next_cmd)
async def snmp_get(host, community, ver, oid, timeout, retries):
tr = await UdpTransportTarget.create((host, 161), timeout, retries)
mp = 0 if ver == '1' else 1 # v1 = 0, v2c = 1
e, s, i, v = await get_cmd(SnmpEngine(), CommunityData(community, mpModel=mp),
tr, ContextData(), ObjectType(ObjectIdentity(oid)))
if e or s:
return None, str(e or s)
return [(str(v[0][0]), v[0][1].prettyPrint())], None
async def walk(host, community, ver, base, timeout, retries, limit):
tr = await UdpTransportTarget.create((host, 161), timeout, retries)
mp = 0 if ver == '1' else 1
res, oid = [], ObjectIdentity(base)
for _ in range(limit):
e, s, i, v = await next_cmd(SnmpEngine(), CommunityData(community, mpModel=mp),
tr, ContextData(), ObjectType(oid))
if e or s:
break
name, val = str(v[0][0]), v[0][1].prettyPrint()
if not name.startswith(base): # หลุดออกนอกกิ่งแล้ว หยุด
break
res.append((name, val))
oid = ObjectIdentity(name) # ถัดไปจากตัวที่เพิ่งได้
return res, None
The condition if not name.startswith(base) is the key. Without this line, the walk will leave the branch I want and continue through the entire MIB until it times out. I set limit differently for each branch. For a small branch such as bridge base, 50 is enough, while ifTable and FDB need more room.
The five groups of OIDs I actually use
There is no need to memorize the whole MIB. These are all I actually use for inventory work.
system group
1.3.6.1.2.1.1.1.0sysDescr model and version1.3.6.1.2.1.1.2.0sysObjectID identifies the vendor1.3.6.1.2.1.1.3.0sysUpTime1.3.6.1.2.1.1.5.0sysName1.3.6.1.2.1.1.6.0sysLocation
interface + bridge
1.3.6.1.2.1.2.2complete ifTable1.3.6.1.2.1.31.1.1.1.1ifName actual port name1.3.6.1.2.1.4.20ipAddrTable1.3.6.1.2.1.17.1.4.1.2bridge port → ifIndex1.3.6.1.2.1.17.4.3.1.2FDB: MAC → bridge port
sysObjectID is more useful than it might seem. It is an OID pointing to the manufacturer's enterprise number, which identifies the vendor more reliably than reading sysDescr, whose format varies by vendor. The 1.3.6.1.4.1 branch contains the entire enterprise tree. I walk it with a modest limit to see what extra information a vendor exposes, such as temperatures, fans, or per-port PoE status, that is not in the standard MIB.
The main structure of the script therefore looks like this: GET five values, walk four branches, and finish with a JSON dump.
async def run(a):
out = {"target": a.target, "system": {}, "if_table": [], "ip_addrs": [],
"bridge": {}, "enterprise": []}
async def add(k, oid):
v, e = await snmp_get(a.target, a.community, a.version, oid, a.timeout, a.retries)
out['system'][k] = v[0][1] if v and not e else None
if e:
out.setdefault('errors', {})[k] = e
await add('sysDescr', '1.3.6.1.2.1.1.1.0')
await add('sysObjectID', '1.3.6.1.2.1.1.2.0')
await add('sysUpTime', '1.3.6.1.2.1.1.3.0')
await add('sysName', '1.3.6.1.2.1.1.5.0')
await add('sysLocation', '1.3.6.1.2.1.1.6.0')
out['if_table'], _ = await walk(a.target, a.community, a.version,
'1.3.6.1.2.1.2.2', a.timeout, a.retries, a.max)
out['ip_addrs'], _ = await walk(a.target, a.community, a.version,
'1.3.6.1.2.1.4.20', a.timeout, a.retries, a.max)
base, _ = await walk(a.target, a.community, a.version,
'1.3.6.1.2.1.17.1', a.timeout, a.retries, 50)
fdb, _ = await walk(a.target, a.community, a.version,
'1.3.6.1.2.1.17.4.3', a.timeout, a.retries, 100)
out['bridge'] = {"base": base, "fdb": fdb}
ent, _ = await walk(a.target, a.community, a.version,
'1.3.6.1.4.1', a.timeout, a.retries, 100)
out['enterprise'] = ent
js = json.dumps(out, indent=2)
print(js)
if a.out:
with open(a.out, 'w', encoding='utf-8') as f:
f.write(js)
Reading ifTable: the trailing index is the answer
SNMP tables do not come back as rows and columns. They return OID-value pairs, with the row index appended to the column's OID. For example, 1.3.6.1.2.1.2.2.1.8 is the ifOperStatus column. A result of 1.3.6.1.2.1.2.2.1.8.7 = 1 means the interface with ifIndex 7 is up.
I use five columns from this table.
IF = '1.3.6.1.2.1.2.2.1'
COLS = {f'{IF}.2': 'descr', f'{IF}.5': 'speed', f'{IF}.6': 'phys',
f'{IF}.7': 'admin', f'{IF}.8': 'oper'}
def parse_iftable(pairs):
rows = {}
for oid, val in pairs:
col, _, idx = oid.rpartition('.') # แยก index ตัวท้ายออกมา
key = COLS.get(col)
if key:
rows.setdefault(int(idx), {})[key] = val
for idx, r in rows.items():
r['ifindex'] = idx
r['up'] = (r.get('oper') == '1') # 1 = up, 2 = down
# ifPhysAddress มาเป็น hex string เช่น 0x8c1f640a1b2c
p = (r.get('phys') or '').replace('0x', '')
r['mac'] = ':'.join(p[i:i+2].upper() for i in range(0, len(p), 2)) if len(p) == 12 else '-'
return [rows[k] for k in sorted(rows)]
ifSpeed returns bits per second. A value of 1000000000 means gigabit, and 100000000 means a hundred megabits. This is very useful when tracking down poorly terminated cables, because a port that should run at gigabit but negotiates down to a hundred megabits is a sign of a cable problem. I have seen this many times: replacing or reterminating the cable brings it straight back to gigabit.
Convert FDB table OID indexes back into MAC addresses
This is where many people give up, but it is actually very simple. The dot1dTpFdbPort table uses the MAC address as its index, written as six decimal numbers separated by dots and appended to the column's OID. Just take the last six numbers and convert them to hexadecimal to recover the MAC address.
FDB_PORT = '1.3.6.1.2.1.17.4.3.1.2' # dot1dTpFdbPort
BASE_IFX = '1.3.6.1.2.1.17.1.4.1.2' # dot1dBasePortIfIndex
def oid_to_mac(oid: str) -> str:
tail = oid[len(FDB_PORT) + 1:].split('.')
return ':'.join('%02X' % int(x) for x in tail[-6:])
def build_mac_map(fdb_pairs, base_pairs, iftable):
# bridge port -> ifIndex
b2i = {int(o.rsplit('.', 1)[1]): int(v) for o, v in base_pairs if o.startswith(BASE_IFX)}
# ifIndex -> ชื่อพอร์ต
i2n = {r['ifindex']: r.get('descr', str(r['ifindex'])) for r in iftable}
out = []
for oid, val in fdb_pairs:
if not oid.startswith(FDB_PORT):
continue
bport = int(val)
if bport == 0: # 0 = ยังไม่รู้ว่ามาจากพอร์ตไหน ข้ามไป
continue
ifx = b2i.get(bport, bport)
out.append({'mac': oid_to_mac(oid), 'bridge_port': bport,
'ifindex': ifx, 'port': i2n.get(ifx, str(ifx))})
return out
Once I have this table, work that used to require unplugging cables one by one is done with a single command. I can find which port a MAC address is on, or reverse the question and ask how many devices are attached to a port. A port with dozens of MAC addresses connects to a downstream switch or an AP with connected clients. A port with just one is an actual endpoint.
Run it across the network and save files, rather than just viewing output
In practice, I do not query devices one at a time. I loop through a list of manageable devices' IP addresses and save separate JSON files by date. The benefit is that I can run it again the following month and diff the results to see what changed, which additional ports went down, and which devices moved.
mkdir -p inv/$(date +%F)
for ip in 192.168.10.2 192.168.10.3 192.168.10.4; do
python snmp_probe.py "$ip" --community "$SNMP_RO" --max 400 \
--out "inv/$(date +%F)/$ip.json" > /dev/null
echo "[OK] $ip"
done
# เทียบกับรอบก่อน ดูว่าอะไรหาย
diff <(jq -r '.if_table[] | "\(.ifindex) \(.descr) \(.oper)"' inv/2026-07-17/192.168.10.2.json) \
<(jq -r '.if_table[] | "\(.ifindex) \(.descr) \(.oper)"' inv/2026-08-17/192.168.10.2.json)
- Enable read-only SNMP on every manageable device, restricting queries to the collection machine alone.
- Run the walk to collect system, ifTable, bridge base, and FDB into separate JSON files for each device and date.
- Build the MAC → port mapping with dot1dBasePortIfIndex. Do not use bridge port numbers directly.
- Join the router's ARP and DHCP leases by MAC to fill in the IP and hostname columns.
- Run it once a month and diff against the previous run. Missing devices are the ones to track down on site.
Devices with SNMP disabled can still be checked, but the quality differs
A real network will have devices that do not support SNMP, or support it but whose vendors do not allow access. Most are budget APs with only a web interface. For these cases, I have a fallback that fetches the status page over HTTP and extracts whatever values it can with regex. I admit it is much more fragile than SNMP: a single firmware update can break it. But it is better than having no data at all.
MAC_RE = re.compile(r"(?i)\b([0-9A-F]{2}[:\-]){5}([0-9A-F]{2})\b")
CHAN_RE = re.compile(r"(?i)\bchannel\s*[:#\-]?\s*(\d{1,3})\b")
def fetch(url, user, password, timeout, session=None):
sess = session or requests.Session()
sess.headers.update({"User-Agent": "net-inventory/1.0"})
auth = HTTPBasicAuth(user, password) if user else None
try:
r = sess.get(url, auth=auth, timeout=timeout, allow_redirects=True, verify=False)
if r.status_code in (401, 403) and user: # บางรุ่นเป็น digest ไม่ใช่ basic
r = sess.get(url, auth=HTTPDigestAuth(user, password or ""),
timeout=timeout, allow_redirects=True, verify=False)
r.raise_for_status()
return r.text, r.url
except Exception as e:
return None, str(e)
# ไล่เดา path หน้า status ที่ยี่ห้อต่างๆ ชอบใช้
CANDIDATES = ["", "status", "Status.htm", "StatusRpm.htm", "userRpm/StatusRpm.htm",
"wlstatus.htm", "sysinfo.cgi", "state.cgi", "index.asp", "cgi-bin/luci"]
Personally, I consider devices that will not speak SNMP candidates for replacement in the next cycle. Not because they are bad, but because equipment that cannot be checked automatically eventually becomes a blind spot, with nobody knowing when it went down. If the budget is tight, keeping them for now is fine, but the inventory should record which devices are blind spots.
Still on my plan is putting the walk results into a small database and building a report page where I can search for a MAC from a single field, along with bringing in the router's ARP data automatically instead of fetching it by hand. If I have left anything out or made any typos, I will come back and fix them later.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first