EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Network / สำรวจสวิตช์และ AP ด้วย SNMP walk ดึงตาราง MAC ต่อพอร์ตมาทำ inventory
Network

สำรวจสวิตช์และ AP ด้วย SNMP walk ดึงตาราง MAC ต่อพอร์ตมาทำ inventory

โดย Ton-Or ·22 ส.ค. 2026·อ่าน 9 นาที
สำรวจสวิตช์และ AP ด้วย SNMP walk ดึงตาราง MAC ต่อพอร์ตมาทำ inventory

คำถามที่ตอบยากที่สุดตอนไปรับงานต่อจากคนอื่นคือ "เครื่องนี้เสียบอยู่พอร์ตไหน" ถ้าไม่มีผังสายให้ ก็ต้องไปถามสวิตช์เอง และสวิตช์ทุกตัวที่พูด SNMP ได้ก็ตอบคำถามนี้ได้อยู่แล้ว

พอดีผมรับงานต่อจากทีมเดิมในอาคารหนึ่ง ของที่ได้มาคือรหัสผ่านสวิตช์กับคำว่า "ผังสายอยู่ในคอมของช่างที่ลาออกไปแล้ว" สวิตช์เป็นรุ่นที่ไม่มี controller ไม่มีระบบจัดการรวม แต่ละตัวต้องเข้าหน้าเว็บทีละตัว มี AP อีกสามสิบกว่าตัวกระจายตามชั้น ผมเลยตัดสินใจไม่ไล่จดมือ แต่เขียนสคริปต์เดินถามอุปกรณ์ทั้งวงผ่าน SNMP แล้วประกอบเป็นตาราง inventory ขึ้นมาใหม่ทั้งชุด ใช้เวลาเขียนครึ่งวัน แต่ได้ของที่รันซ้ำเมื่อไหร่ก็ได้ตลอดอายุงาน

เลข IP ชื่ออุปกรณ์ และ community string ในบทความเป็นตัวอย่าง ส่วน OID กับโค้ดคือของจริง

โจทย์: รู้ว่าใครอยู่พอร์ตไหน โดยไม่ต้องเดินไปดึงสาย

ข้อมูลที่ผมต้องการมีสี่ชั้น ชั้นแรกคืออุปกรณ์ตัวนี้คือรุ่นอะไร firmware อะไร ชั้นที่สองคือมันมีกี่พอร์ต พอร์ตไหนขึ้นพอร์ตไหนดับ ความเร็วเท่าไหร่ ชั้นที่สามคือ MAC ตัวไหนเรียนรู้อยู่ที่พอร์ตไหน และชั้นที่สี่คือ MAC นั้นถือ IP อะไร

สามชั้นแรกอยู่ใน MIB มาตรฐานที่อุปกรณ์แทบทุกยี่ห้อรองรับ ส่วนชั้นที่สี่ต้องไปเอาจากตาราง ARP หรือ DHCP lease ของเราเตอร์ แล้วเอามาชนกันด้วย MAC

ทางเดินของข้อมูลbridge FDBMAC → bridge port→dot1dBasePortbridge port → ifIndex→ifTableifIndex → ชื่อพอร์ต→ARP ของเราเตอร์MAC → IP→ตาราง inventory

จุดที่คนพลาดกันมากที่สุดคือคิดว่าเลขพอร์ตในตาราง FDB คือเลขพอร์ตที่พิมพ์อยู่บนตัวเครื่อง มันไม่ใช่ครับ ตาราง FDB คืนมาเป็น bridge port number ซึ่งต้องแปลงผ่าน dot1dBasePortIfIndex ไปเป็น ifIndex ก่อน แล้วค่อยเอา ifIndex ไปถามชื่อพอร์ตจริงจาก ifTable อีกที บางยี่ห้อเลขมันบังเอิญตรงกันเลยไม่มีใครทันสังเกต พอไปเจอยี่ห้อที่ไม่ตรงก็จะได้ตารางที่ผิดทั้งใบโดยไม่รู้ตัว

เปิด SNMP แบบอ่านอย่างเดียว แล้วล็อกให้ถามได้จากเครื่องเดียว

ก่อนจะไปโค้ด ขอพูดเรื่องนี้ก่อนเพราะสำคัญกว่าตัวสคริปต์ SNMP v2c ส่ง community string ไปเป็นข้อความเปล่าๆ ไม่มีการเข้ารหัส ใครนั่งดักในวงเดียวกันก็อ่านได้หมด เพราะฉะนั้นกฎที่ผมถือคือเปิดเฉพาะ read-only, ตั้ง community เป็นค่าที่ไม่ใช่ public, และจำกัดว่าถามได้จาก IP ของเครื่องที่ใช้เก็บข้อมูลเท่านั้น

อย่าเปิด SNMP write และอย่าปล่อย community เป็น public

community ที่เขียนได้คือช่องแก้ค่าอุปกรณ์โดยไม่ต้องล็อกอิน งาน inventory ไม่มีความจำเป็นต้องใช้เลยแม้แต่นิดเดียว ส่วน community อ่านอย่างเดียวที่ตั้งเป็น public ก็เท่ากับประกาศผังเน็ตเวิร์กทั้งวงให้ทุกเครื่องในวงอ่านฟรี รวมทั้งเครื่องของแขกที่ต่อ WiFi เข้ามา ถ้าอุปกรณ์รองรับ SNMP v3 ก็ใช้ v3 ไปเลยดีกว่า

ฝั่งอุปกรณ์ตั้งประมาณนี้ ตัวอย่างเป็นสวิตช์ที่ตั้งผ่าน 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

ฝั่งเครื่องที่รันสคริปต์ ผมไม่ฝัง community ไว้ในไฟล์ครับ อ่านจาก environment แทน จะได้ commit ขึ้น git ได้โดยไม่ต้องคอยระวัง

export SNMP_RO='<RO-COMMUNITY>'
python snmp_probe.py 192.168.10.2 --community "$SNMP_RO" --out sw-core.json

GET กับ WALK ต่างกันตรงไหน

SNMP มีสองท่าที่ใช้จริง GET คือถาม OID เดียวได้ค่าเดียว เหมาะกับค่าที่รู้ตำแหน่งแน่นอนอย่าง sysName ส่วน GETNEXT คือถามว่า "OID ถัดจากนี้คืออะไร" เอามาวนซ้ำก็จะไล่เก็บทั้งกิ่งได้ อันนี้แหละที่เรียกกันว่า walk

pysnmp รุ่นใหม่ทำงานบน asyncio แล้ว โครงของ walk ที่ผมใช้เป็นแบบนี้ วนเรียก next_cmd ไปเรื่อยๆ จนกว่า OID ที่ได้จะหลุดออกนอกฐานที่เราสนใจ แล้วมี limit กันวนไม่รู้จบตอนเจออุปกรณ์ที่ตอบแปลกๆ

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

เงื่อนไข if not name.startswith(base) คือหัวใจครับ ถ้าไม่มีบรรทัดนี้ walk จะไหลออกไปนอกกิ่งที่เราสนใจแล้วเดินยาวไปทั้ง MIB จนหมดเวลา ส่วน limit ผมตั้งไว้ต่างกันตามกิ่ง กิ่งเล็กอย่าง bridge base ให้ 50 พอ ส่วน ifTable กับ FDB ต้องเผื่อเยอะกว่านั้น

OID ที่ผมใช้จริง ห้ากลุ่ม

ไม่ต้องท่องทั้ง MIB ครับ งาน inventory ใช้อยู่เท่านี้จริงๆ

กลุ่มระบุตัวตน

system group

  • 1.3.6.1.2.1.1.1.0 sysDescr รุ่นและเวอร์ชัน
  • 1.3.6.1.2.1.1.2.0 sysObjectID บอกยี่ห้อ
  • 1.3.6.1.2.1.1.3.0 sysUpTime
  • 1.3.6.1.2.1.1.5.0 sysName
  • 1.3.6.1.2.1.1.6.0 sysLocation
กลุ่มพอร์ตและตาราง L2

interface + bridge

  • 1.3.6.1.2.1.2.2 ifTable ทั้งตาราง
  • 1.3.6.1.2.1.31.1.1.1.1 ifName ชื่อพอร์ตจริง
  • 1.3.6.1.2.1.4.20 ipAddrTable
  • 1.3.6.1.2.1.17.1.4.1.2 bridge port → ifIndex
  • 1.3.6.1.2.1.17.4.3.1.2 FDB: MAC → bridge port

ตัว sysObjectID มีประโยชน์กว่าที่คิดครับ มันคือ OID ที่ชี้ไปยัง enterprise number ของผู้ผลิต เอาไปแยกยี่ห้อได้แม่นกว่าการอ่าน sysDescr ที่แต่ละยี่ห้อเขียนคนละแบบ ส่วนกิ่ง 1.3.6.1.4.1 คือ enterprise ทั้งก้อน ผมจะ walk แบบจำกัดจำนวนไว้หน่อยเพื่อดูว่ายี่ห้อนี้เปิดอะไรพิเศษไว้บ้าง เช่นพวกอุณหภูมิ พัดลม หรือสถานะ PoE ต่อพอร์ต ซึ่งไม่มีใน MIB มาตรฐาน

โครงหลักของสคริปต์เลยเป็นแบบนี้ ยิง GET ห้าค่าแล้ว walk สี่กิ่ง จบด้วย dump JSON

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)

อ่าน ifTable ให้เป็น: index ต่อท้ายคือคำตอบ

ตาราง SNMP ไม่ได้คืนมาเป็นแถวเป็นคอลัมน์ครับ มันคืนมาเป็นคู่ OID กับค่า โดยเอา index ของแถวไปต่อท้าย OID ของคอลัมน์ ยกตัวอย่าง 1.3.6.1.2.1.2.2.1.8 คือคอลัมน์ ifOperStatus ถ้าได้ 1.3.6.1.2.1.2.2.1.8.7 = 1 แปลว่า interface ที่ ifIndex เท่ากับ 7 สถานะ up

คอลัมน์ที่ผมใช้จริงในตารางนี้มีห้าตัว

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 คืนมาเป็นบิตต่อวินาที เห็น 1000000000 คือกิกะบิต เห็น 100000000 คือร้อยเมกะ อันนี้มีประโยชน์มากตอนไล่หาสายที่เข้าหัวไม่ดี เพราะพอร์ตที่ควรวิ่งกิกะแล้วเจรจาตกลงมาเหลือร้อยเมกะคือสัญญาณของสายมีปัญหา เจอมาหลายรอบแล้วครับ เดินสายใหม่หรือเข้าหัวใหม่แล้วกลับมาเป็นกิกะทันที

แปลง OID index ของตาราง FDB กลับเป็น MAC

ตรงนี้คือส่วนที่หลายคนถอดใจ แต่จริงๆ ง่ายมาก ตาราง dot1dTpFdbPort ใช้ MAC address เป็น index โดยเขียนเป็นเลขฐานสิบหกตัวคั่นจุด ต่อท้าย OID ของคอลัมน์ เพราะฉะนั้นแค่ตัดหางหกตัวสุดท้ายมาแปลงเป็นเลขฐานสิบหกก็ได้ MAC กลับมา

ถอดหาง OID ของ FDB กลับเป็น MAC (ตัวอย่าง)ส่วนหัว: OID ของคอลัมน์ dot1dTpFdbPortหางหกตัวคือ MAC เป็นเลขฐานสิบ1.3.6.1.2.1.17.4.3.1.214031100102744แปลงแต่ละตัวเป็นเลขฐานสิบหก8C1F640A1B2C8C:1F:64:0A:1B:2Cได้ MAC ของอุปกรณ์กลับมา
ตาราง FDB ใช้ MAC เป็น index ต่อท้าย OID ของคอลัมน์ ตัดหกตัวสุดท้ายมาแปลงเป็นฐานสิบหกแล้วคั่นด้วยโคลอนก็ได้ MAC กลับมา
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

พอได้ตารางนี้แล้ว งานที่เคยต้องเดินไปดึงสายทีละเส้นก็จบในคำสั่งเดียว จะหาว่า MAC ตัวนี้อยู่พอร์ตไหน หรือจะถามกลับว่าพอร์ตนี้มีกี่ตัวห้อยอยู่ก็ได้ พอร์ตที่มี MAC หลายสิบตัวคือพอร์ตที่ต่อสวิตช์ตัวถัดไปหรือ AP ที่มีลูกค้าเกาะอยู่ ส่วนพอร์ตที่มีตัวเดียวคือปลายทางจริง

5 OIDกลุ่มที่ต้องใช้
2 ชั้นแปลงเลขพอร์ต
1 ครั้งรันได้ทั้งวง
JSONรูปแบบผลลัพธ์

รันทั้งวงแล้วเก็บเป็นไฟล์ ไม่ใช่แค่ดูบนจอ

ตอนใช้จริงผมไม่ได้ยิงทีละตัวครับ วนตามรายชื่อ IP ของอุปกรณ์ที่จัดการได้ แล้วเก็บ JSON แยกไฟล์ตามวันที่ ประโยชน์คือเดือนถัดไปกลับมารันซ้ำแล้ว diff กันได้เลยว่าอะไรเปลี่ยน พอร์ตไหนตายเพิ่ม ใครย้ายที่

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)
  1. เปิด SNMP read-only บนอุปกรณ์ที่จัดการได้ทุกตัว จำกัดต้นทางที่ถามได้เป็นเครื่องเก็บข้อมูลเครื่องเดียว
  2. รัน walk เก็บ system, ifTable, bridge base และ FDB ลง JSON แยกไฟล์ต่ออุปกรณ์ ต่อวันที่
  3. ประกอบ MAC → พอร์ต ด้วย dot1dBasePortIfIndex อย่าใช้เลข bridge port ตรงๆ
  4. เอา ARP กับ DHCP lease ของเราเตอร์มาชนด้วย MAC เพื่อเติมคอลัมน์ IP และชื่อเครื่อง
  5. รันซ้ำเดือนละครั้งแล้ว diff กับรอบก่อน ตัวที่หายไปคือของที่ต้องไปตามหาที่หน้างาน

เจออุปกรณ์ที่ปิด SNMP ก็ยังตรวจได้ แต่คนละคุณภาพ

ในวงจริงจะมีของที่ไม่รองรับ SNMP หรือรองรับแต่ยี่ห้อไม่ยอมเปิดให้ใช้ ส่วนใหญ่เป็น AP ราคาประหยัดที่มีแต่หน้าเว็บ กรณีนี้ผมมีตัวสำรองที่ยิง HTTP เข้าไปอ่านหน้า status แล้วดึงค่าที่พอหาได้ออกมาด้วย regex ยอมรับว่าเปราะกว่า SNMP มาก firmware อัปทีเดียวก็พังทีหนึ่ง แต่ดีกว่าไม่มีข้อมูลเลย

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"]

ส่วนตัวผมถือว่าอุปกรณ์ที่ไม่ยอมพูด SNMP คือของที่ควรถูกเปลี่ยนในรอบหน้า ไม่ใช่เพราะมันไม่ดี แต่เพราะของที่ตรวจสอบอัตโนมัติไม่ได้ สุดท้ายจะกลายเป็นจุดบอดที่ไม่มีใครรู้ว่ามันตายไปตั้งแต่เมื่อไหร่ ใครงบจำกัดจะเก็บไว้ก่อนก็ตามสะดวก แต่ควรจดไว้ในทะเบียนว่าตัวไหนคือจุดบอดบ้าง

ที่ยังค้างในแผนของผมคือเอาผล walk ยัดเข้าฐานข้อมูลเล็กๆ แล้วทำหน้ารายงานให้ค้น MAC ได้จากช่องเดียว กับต่อ ARP ของเราเตอร์เข้ามาให้อัตโนมัติแทนที่จะดึงมือ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย