EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Network / Omada API ตอบ Success แต่ค่าไม่เปลี่ยน: ฟิลด์ลับของ SSID/LAN/forward
Network

Omada API ตอบ Success แต่ค่าไม่เปลี่ยน: ฟิลด์ลับของ SSID/LAN/forward

โดย Ton-Or ·4 พ.ย. 2026·อ่าน 11 นาที
Omada API ตอบ Success แต่ค่าไม่เปลี่ยน: ฟิลด์ลับของ SSID/LAN/forward

ข้อความ Success จาก API บอกได้แค่ว่าคำขอผ่านการตรวจรูปแบบ ไม่ได้บอกว่าค่าในระบบเปลี่ยนไปแล้ว ถ้าสคริปต์ไม่อ่านค่ากลับมาเทียบ คนเขียนจะเชื่อ Success ไปเรื่อย ๆ จนเจอ Wi-Fi ที่ไม่ได้ทำตามที่สั่ง

สรุปใน 1 นาที

อาการ/ปัญหา
PATCH ไป Omada controller ได้ errorCode 0 Success แต่ device type ของ SSID ไม่เปลี่ยน
ใครควรอ่าน
คนเขียนสคริปต์คุม Omada controller ผ่าน API
ทางแก้แบบสั้น
อ่านค่ากลับมาเทียบหลังทุกคำสั่ง ถ้าแก้ SSID ไม่ได้ให้ลบแล้วสร้างใหม่โดยไม่ตัดกิ่งที่ตัวเองนั่ง และดูตารางฟิลด์ของ LAN, DHCP และ port forward
ข้ามไปขั้นตอน

พอดีผมตั้งค่า Omada ของที่บ้านทั้งระบบด้วยสคริปต์ Python เพราะ API ของมันไม่มีเอกสารสาธารณะ ต้องแกะเอาจากหน้าเว็บกับการลองยิงเอง ช่วงหนึ่งผมสั่ง PATCH เปลี่ยน device type ของ SSID ได้ errorCode: 0 กับข้อความ Success. กลับมาสวย ๆ แต่พอเปิดดูใน controller ค่ายังเหมือนเดิมทุกตัวอักษร ผมนั่งเดาอยู่พักใหญ่ว่า token หมดอายุ หรือส่งผิด site ก่อนจะพบว่า controller รับคำขอ ตอบสำเร็จ แล้วทิ้งค่าที่ผมส่งไปเฉย ๆ

เรื่องพื้นฐานอย่างการ login, Csrf-Token และ self-signed cert ผมเขียนไว้แล้วใน คุม TP-Link Omada OC200 ผ่าน REST API ด้วย Python บทความนี้เล่าเฉพาะส่วนที่ต่อจากนั้น คือกับดักที่ error ไม่บอกอะไรเลย IP, MAC, ชื่อ SSID และรหัสในบทความผมเปลี่ยนเป็นตัวอย่างหมดแล้ว ฟิลด์กับ error code คือของจริงที่เจอในเซสชันตั้งค่าบ้านช่วงกลางเดือนตุลาคม และทุกอย่างเป็นพฤติกรรมของ controller รุ่นที่ผมใช้อยู่ ผมไม่ได้ลองกับเฟิร์มแวร์รุ่นอื่น เลยไม่รับรองว่าตรงกัน

อาการ: Success แต่ค่าเดิม

ผมต้องการ SSID ที่ปล่อยจาก AP และ gateway พร้อมกัน เลยส่ง PATCH เปลี่ยน deviceType ของ SSID ที่สร้างไว้แล้ว ค่าที่ลองมีสามแบบคือ 1 สำหรับ AP อย่างเดียว 2 สำหรับ gateway และ 3 สำหรับทั้งสอง ตามที่ผมไล่ลองเอง ดูแล้วเหมือน bitmask แบบเดียวกับ band ที่ 1 คือ 2.4G, 2 คือ 5G, 4 คือ 6G แต่ข้อนี้เป็นข้อสังเกตจากการลอง ไม่ใช่เอกสาร

# ตัวอย่างโครงสคริปต์ — SSID_URL คือ path ของ SSID ตัวนั้นที่ได้จาก GET list
import os, requests, urllib3
urllib3.disable_warnings()

B   = "https://192.168.10.121"
CID = "<omadacId>"
A   = f"{B}/{CID}/api/v2"
S   = f"{A}/sites/<siteId>"

s = requests.Session()
s.verify = False
s.headers["Csrf-Token"] = os.environ["OMADA_TOKEN"]    # ได้จากขั้น login

body = s.get(SSID_URL).json()["result"]
body["deviceType"] = 3
r = s.patch(SSID_URL, json=body, timeout=30)
print(r.status_code, r.json())
# 200 {'errorCode': 0, 'msg': 'Success.'}

ผลที่ได้คือ errorCode: 0 ตรงตามที่เล่า พอ GET กลับมาอ่านค่า deviceType ยังเป็น 1 ผมลองซ้ำสามรอบก็ได้ผลเหมือนกัน แสดงว่าไม่ใช่จังหวะผิด หน้าเว็บของ controller มีข้อความกำกับไว้ว่า device type ของ SSID แก้ไม่ได้หลังสร้างแล้ว คือฟิลด์ถูกล็อกตอนสร้าง แต่ API ไม่ได้ปฏิเสธคำขอที่พยายามแก้ มันรับ แล้วเมินเฉย

success ปลอม: คำตอบกับค่าจริงไม่ตรงกันสคริปต์deviceType:3controller ตอบerrorCode: 0ค่าที่เก็บจริงdeviceType:112ค่า 3 ถูกทิ้ง3 GET กลับมา เห็น 1ต้องอ่านกลับเสมอห้ามเชื่อ errorCode อย่างเดียว
ขั้น 1 และ 2 ดูสำเร็จทั้งคู่ ความจริงโผล่ตอนขั้น 3 ที่อ่านค่ากลับมาเท่านั้น

สมมติฐานของผมคือ API ตรวจรูปแบบของ body ว่าถูก schema แต่ไม่ตรวจว่าฟิลด์ที่ส่งมาเป็นฟิลด์ที่ห้ามเปลี่ยนหรือเปล่า ข้อนี้ผมไม่ได้พิสูจน์ เป็นคำอธิบายที่เข้ากับอาการเท่านั้น และเพราะไม่รู้สาเหตุแน่ ๆ ผมเลยไม่สรุปว่าฟิลด์อื่นจะทำแบบเดียวกัน แค่ตั้งกฎกับตัวเองว่าทุกคำสั่งเขียนต้องมี GET เทียบตามหลังเสมอ

โน้ตสีเขียวเขียนว่า Success แปะบนแผงสวิตช์ไฟที่หลอดไฟยังดับ มีมือถือมัลติมิเตอร์วัดอยู่ข้าง ๆ
ข้อความ Success เหมือนโน้ตแปะบนสวิตช์ มันไม่ได้บอกว่าไฟติดจริง ต้องใช้มิเตอร์ คือ GET กลับมาวัดเอง
Success ไม่ใช่หลักฐาน

เวลา API เขียนค่าได้ errorCode 0 ให้ถือว่าได้แค่ "คำขอถูกรูปแบบ" เท่านั้น ส่วนค่าที่เปลี่ยนจริงหรือไม่ต้องพิสูจน์ด้วยการอ่านกลับ แล้วเทียบเป็นฟิลด์ ๆ กับสิ่งที่ส่งไป ถ้าไม่ตรงให้สคริปต์ล้มเสียงดัง ไม่ใช่เดินต่อ

ตัวช่วยเล็ก ๆ ที่ผมใส่ในทุกคำสั่งเขียน

หลังเจอเคสนี้ผมเลิกเรียก patch ตรง ๆ แล้วห่อด้วยฟังก์ชันที่เขียน อ่านกลับ และเทียบเฉพาะฟิลด์ที่ส่งไป ถ้าฟิลด์ไหนไม่ตรงก็โยน error ออกมา พร้อมบอกว่าส่งอะไรไป ได้อะไรกลับมา

def patch_verified(s, url, changes: dict, read_url=None):
    """PATCH แล้ว GET กลับมาเทียบ ถ้าไม่ตรงให้ล้ม"""
    cur = s.get(url).json()["result"]
    cur.update(changes)                       # ส่ง object เต็มเสมอ ดูหัวข้อ LAN
    r = s.patch(url, json=cur, timeout=30).json()
    if r.get("errorCode") != 0:
        raise RuntimeError(f"PATCH ล้ม: {r}")

    after = s.get(read_url or url).json()["result"]
    wrong = {k: (v, after.get(k)) for k, v in changes.items() if after.get(k) != v}
    if wrong:
        raise RuntimeError(f"errorCode 0 แต่ค่าไม่เปลี่ยน (ส่ง, ได้): {wrong}")
    return after

ฟังก์ชันนี้ไม่ได้ฉลาด มันแค่ทำให้ "ค่าไม่เปลี่ยน" กลายเป็น exception ที่เห็นทันทีในเทอร์มินัล แทนที่จะรู้ตัวตอนผู้ใช้ในบ้านบอกว่า Wi-Fi ไม่ออกจาก gateway ข้อควรระวังคือฟิลด์บางตัวที่ controller ปรับรูปให้เอง เช่นลำดับ list หรือตัวพิมพ์ใหญ่เล็กของ MAC การเทียบตรง ๆ อาจเตือนเกินจริง ให้ปรับการเทียบเป็นฟิลด์ต่อฟิลด์ตามชนิดค่า

ทางออกของ SSID: ลบแล้วสร้างใหม่ แต่อย่าทำจาก Wi-Fi ตัวนั้น

เมื่อ device type แก้ไม่ได้ ทางเดียวที่ผมเจอคือลบ SSID แล้วสร้างใหม่ด้วยชื่อเดิม ลำดับมีผล เพราะถ้าสร้างชื่อซ้ำใน WLAN group เดียวกันก่อนลบ controller จะตอบ error -33219 เลยต้องลบก่อนแล้วค่อยสร้าง และช่วงระหว่างนั้น Wi-Fi บ้านดับประมาณ 1-2 นาที

ตรงนี้คือกับดักที่ผมเรียกว่า "ตัดกิ่งที่ตัวเองนั่งอยู่" ถ้าผมนั่งสั่งลบจากโน้ตบุ๊กที่ต่อ Wi-Fi ชื่อนั้นอยู่ พอ SSID หายการเชื่อมต่อของโน้ตบุ๊กก็ขาด คำสั่งสร้างใหม่ที่ตามมาไม่มีทางออกไปถึง controller ผมเลยสั่งงานผ่าน cloud portal ของ controller จากมือถือที่ต่อ 4G ซึ่งไม่ผ่าน Wi-Fi ของบ้านเลย session ที่สั่งงานเลยรอดตลอดช่วงที่ Wi-Fi ดับ

ลบสร้าง SSID ใหม่โดยไม่ตัดกิ่งตัวเองมือถือเน็ต 4Gcloud portalไม่ผ่าน Wi-Fi1 ลบSSID เดิม2 สร้างใหม่ชื่อรหัสเดิม3 ต่อกลับเครื่องในบ้านWi-Fi ดับ 1-2 นาที ระหว่างขั้น 1 ถึง 2โน้ตบุ๊กบน Wi-Fi นั้นหลุดก่อนถึงขั้น 2สร้างใหม่ไม่ได้ติ๊ก EAP + Gatewayตอนสร้างใหม่ ตั้งให้ครบแล้วอ่านกลับมาเทียบ
สั่งจากทางที่ไม่ผ่าน SSID ที่กำลังจะลบ เส้นแดงคือสิ่งที่เกิดถ้านั่งทำจากโน้ตบุ๊กที่เกาะ Wi-Fi ตัวนั้น
  1. เตรียมค่าทั้งหมดของ SSID เดิมไว้ก่อนลบ ชื่อ ความปลอดภัย band และรหัสที่เก็บจากแหล่งของตัวเอง ไม่ใช่จาก chat
  2. เปิด cloud portal ของ controller จากมือถือที่ปิด Wi-Fi และต่อ 4G แล้วยืนยันว่าเห็นหน้า controller จริง
  3. ลบ SSID เดิม แล้วสร้างใหม่ชื่อและรหัสเดิม ติ๊ก device type ให้ครบทั้ง EAP และ Gateway ในขั้นสร้าง
  4. รอ 1-2 นาที เครื่องในบ้านต่อกลับเองเพราะชื่อและรหัสเหมือนเดิม
  5. อ่านค่า SSID ใหม่กลับมา แล้วเทียบ device type ว่าเป็นค่าที่ตั้งใจ
ระวังก่อนลบ

การลบ SSID ตัดเครือข่ายไร้สายของทุกคนในบ้านจนกว่าจะสร้างใหม่เสร็จ และถ้า AP ทั้งหมดอาศัย Wi-Fi นั้นคุยกับ controller อยู่ก็จะเข้าไปแก้ต่อไม่ได้ ควรมีทางเข้า controller ที่ไม่ผ่าน SSID นั้นก่อนเริ่มเสมอ ผมทำที่บ้านตัวเองและพร้อมรับสภาพ ถ้าเป็นงานของลูกค้าต้องแจ้งช่วงเวลาที่เน็ตหลุดก่อน

LAN: ส่ง object เต็ม และ DHCP เป็นตัวเลข

เส้นทางแก้ Default LAN คือ PATCH ไปที่ {S}/setting/lan/networks/{id} ตรงนี้ controller ไม่เงียบ มันตอบ error ออกมาตรง ๆ แต่ข้อความบอกแค่ว่าฟิลด์ไหนเป็น null ไม่ได้บอกว่าต้องมีอะไรบ้าง ผมเจอสามเรื่องตามลำดับ

เรื่องแรก ต้องส่ง object เต็มที่ GET มา แล้วแก้เฉพาะช่องที่ต้องการ ถ้าตัดฟิลด์ออกจะได้ -1001 ว่าฟิลด์นั้น null เรื่องที่สอง lanNetworkIpv6Config ต้องมี proto ถ้าไม่มีจะได้ -1001 กับข้อความ Parameter [proto] should not be null แก้ด้วยการใส่ {"enable": 0, "proto": 0} เรื่องที่สาม dhcpSettings.ipRangeStart และ ipRangeEnd เป็นตัวเลข integer ไม่ใช่สตริง IP

import ipaddress

def ip2int(ip: str) -> int:
    return int(ipaddress.IPv4Address(ip))

net = ipaddress.ip_network("192.168.10.0/24")
print(ip2int(str(net.network_address)))    # 3232238080  ปลายต่ำของวง
print(ip2int(str(net.broadcast_address)))  # 3232238335  ปลายสูงของวง

lan = s.get(f"{S}/setting/lan/networks/<lanId>").json()["result"]
lan["lanNetworkIpv6Config"] = {"enable": 0, "proto": 0}
lan["dhcpSettings"]["ipRangeStart"] = ip2int("192.168.10.100")
lan["dhcpSettings"]["ipRangeEnd"]   = ip2int("192.168.10.200")
patch_verified(s, f"{S}/setting/lan/networks/<lanId>", {"dhcpSettings": lan["dhcpSettings"]})

ตัวเลข 3232238080 กับ 3232238335 คือ network address และ broadcast address ของวง 192.168.10.0/24 ตามที่แปลงได้ ผมใส่ไว้ให้เทียบว่าค่าที่ได้ออกมาถูกหน้าตา อีกกับดักคือถ้า site ยังไม่มี gateway เลย การแก้ Default LAN จะไม่ผ่าน ได้ error -1 หรือ -33515 พร้อมข้อความว่า LAN interfaces could not be none ต้อง adopt gateway เข้า site ให้เสร็จก่อนถึงจะแก้ได้

ตารางฟิลด์ที่เดาไม่ได้

ส่วนที่เหลือเป็นฟิลด์ที่ผมไม่มีทางเดาได้จากชื่อ ต้องเห็นของจริงถึงรู้ จดรวมไว้เป็นตารางเดียว

งาน ฟิลด์ที่เป็นกับดัก อาการถ้าผิด
DHCP reservation ใช้ชื่อ mac ไม่ใช่ clientMac -1001 ว่า must not be null
port forward ต้องมี from: 0 (0 = Source IP Any) ไม่ได้จดอาการไว้
port forward interfaceWanPortId เป็น list ของ portUuid จาก /setting/wan/networks ไม่ได้จดอาการไว้
port forward virtualWanId: [] และ dMZ: false ตัว D และ M พิมพ์ใหญ่ ไม่ได้จดอาการไว้
port forward externalPort และ forwardPort เป็นสตริง ใส่ช่วงได้เช่น "80-100" ไม่ได้จดอาการไว้
port forward protocol: 0 = All, 1 = TCP, 2 = UDP ได้โปรโตคอลไม่ตรงที่ต้องการ
LAN ipRangeStart และ ipRangeEnd เป็น integer ไม่ได้จดอาการไว้

ฟิลด์ของ port forward ผมไม่ได้เดา แต่ดักเอาจากหน้าเว็บตอนกดบันทึกจริง ทำแบบนี้ได้กับอุปกรณ์เกือบทุกตัวที่หน้าเว็บคุยกับ backend ผ่าน XHR ผมฉีด JS ครอบ XMLHttpRequest.prototype.open กับ send เก็บทุก request ที่ไม่ใช่ GET ไว้ใน window.__log แล้วกรอกฟอร์มด้วยการคลิกกับพิมพ์จริง กด Save แล้วอ่าน log เทคนิคนี้เขียนละเอียดไว้ใน ดักคำสั่งจากหน้าเว็บของอุปกรณ์ เลยไม่เล่าซ้ำ

# port forward — ตัวอย่างทั่วไป ค่า ip และพอร์ตเป็นตัวอย่าง
pf = {
    "name": "web-example",
    "status": True,
    "from": 0,                          # Source IP: Any
    "interfaceWanPortId": ["<portUuid>"],
    "virtualWanId": [],
    "externalPort": "8443",             # สตริง ใส่ช่วงได้ เช่น "8000-8010"
    "forwardPort": "443",
    "forwardIp": "192.168.10.50",
    "protocol": 1,                      # 1 = TCP
    "dMZ": False,                       # D และ M ตัวใหญ่
}
r = s.post(f"{S}/setting/transmission/portForwardings", json=pf).json()
assert r["errorCode"] == 0, r
rows = s.get(f"{S}/setting/transmission/portForwardings").json()["result"]["data"]
assert any(x["name"] == "web-example" for x in rows), "ไม่เจอรายการที่เพิ่งสร้าง"

บรรทัดสุดท้ายคือกฎเดิม เขียนแล้วอ่านกลับ ตัวอย่างนี้ผมเขียนรูปทรงของ payload จากฟิลด์ที่ยืนยันแล้วในตาราง ชื่อฟิลด์อย่าง forwardIp กับ name ผมไม่ได้ย้ำกับ controller อีกรอบตอนเขียนบทความ ถ้านำไปใช้ให้เทียบกับ request ที่ดักจากหน้าเว็บของรุ่นที่ตัวเองมี

คนนั่งบนกิ่งไม้กำลังเลื่อยกิ่งที่นั่งอยู่ใกล้โคนต้น ส่วนมือถือที่มีสัญญาณมือถือวางอยู่บนกิ่งที่ปลอดภัยข้าง ๆ
เลื่อยกิ่งได้ ถ้าตัวเองนั่งอยู่บนกิ่งอื่น สั่งลบ SSID จากช่องทางที่ไม่ผ่าน SSID นั้น
เชื่อ errorCode

สคริปต์เดินต่อ

  • ได้ Success ก็ไปขั้นถัดไป
  • รู้ว่าผิดตอนผู้ใช้บ่น
  • ไม่รู้ว่าฟิลด์ไหนถูกเมิน
  • ย้อนหาสาเหตุยาก
อ่านกลับเทียบ

สคริปต์ล้มทันที

  • ส่งแล้ว GET แล้วเทียบเป็นฟิลด์
  • ฟิลด์ที่ถูกเมินโผล่ในข้อความ error
  • เพิ่มเวลาต่อคำสั่งเล็กน้อย
  • รู้ทันทีว่าต้องลบสร้างใหม่

กับดักอื่นที่เจอระหว่างทาง

เรื่องเล็กที่ทำให้เสียเวลาไม่น้อยมีสามข้อ ข้อแรก controller ที่เพิ่งบูตหรือรีบูต /api/info จะตอบ 404 อยู่ประมาณ 2-5 นาที ทั้งที่หน้าเว็บขึ้นแล้ว ตั้ง timeout ของ login ไว้ 60-90 วินาที และอย่าตีความ 404 ช่วงนั้นว่าพัง ข้อสอง การ adopt อุปกรณ์ด้วย POST /cmd/devices/adopt ที่ส่ง {mac} มักหมดเวลาที่ 20 วินาที แต่คำสั่งสำเร็จจริง อย่ารีบ retry ให้ poll สถานะของ device แทน โดยรหัสสถานะที่ผมเจอคือ 14 = Connected, 20 = Pending, 10 และ 11 = Provisioning หรือ Configuring, 0 = Disconnected

import time

STATUS = {14: "Connected", 20: "Pending", 10: "Provisioning", 11: "Configuring", 0: "Disconnected"}

def wait_connected(s, mac, tries=40, gap=6):
    for _ in range(tries):
        devs = s.get(f"{S}/devices").json()["result"]
        d = next((x for x in devs if x["mac"].upper() == mac.upper()), None)
        st = d["status"] if d else None
        print(f"[..] {mac} -> {STATUS.get(st, st)}")
        if st == 14:
            return d
        time.sleep(gap)
    raise TimeoutError(f"{mac} ไม่เป็น Connected ภายใน {tries*gap} วินาที")

wait_connected(s, "AA-BB-CC-DD-EE-FF")   # ชื่อฟิลด์ status และโครง result ให้เทียบกับ response จริงของรุ่นที่ใช้

ข้อสาม หน้า login ของ controller ผ่านเบราว์เซอร์ที่สคริปต์ขับมีค่า autofill ค้างในช่อง user กับ password พิมพ์ทับแล้วข้อความต่อท้ายกันจนได้ Invalid username or password ต้องกด Ctrl+A ลบก่อนพิมพ์ทุกครั้ง ส่วนฝั่ง API ล้วน ๆ ไม่มีปัญหานี้

errorCode 0ที่ได้ ทั้งที่ค่าไม่เปลี่ยน
1 ฟิลด์deviceType ล็อกหลังสร้าง
1-2 นาทีWi-Fi ดับตอนสร้างใหม่
2-5 นาที/api/info ตอบ 404 หลังบูต
20 วินาทีadopt หมดเวลา แต่สำเร็จ
-33219ชื่อ SSID ซ้ำ

สิ่งที่ผมไม่รู้

ผมไม่ได้ลองว่าฟิลด์ข้างต้นเหมือนกันบนเฟิร์มแวร์รุ่นอื่นไหม และไม่รู้ความหมายของ errorCode อื่นนอกจากที่ระบุไว้ รวมถึงไม่รู้เหตุผลภายในที่ controller ยอมรับ PATCH แล้วเงียบ ที่เขียนมาข้างบนเป็นสมมติฐานที่เข้ากับอาการ ถ้าใครเจอพฤติกรรมต่างจากนี้บนรุ่นของตัวเองก็ถือว่าตารางนี้เป็นจุดเริ่มต้นให้ไปดักเทียบเองเลยครับ ส่วนวิธีที่ผมใช้ตลอดคือเขียนแล้วอ่านกลับ ทุกครั้งโดยไม่มีข้อยกเว้น ตรงไหนผมสรุปตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

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

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

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

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

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