คุม TP-Link Omada OC200 ผ่าน REST API ด้วย Python
หน้าเว็บ Omada สวยก็จริง แต่พอต้องตั้งค่า DHCP หรือ port-forward ซ้ำๆ หลายไซต์ การนั่งคลิกทีละช่องคือเสียเวลา ผมเลยรื้อดูว่าหน้าเว็บมันคุยกับ controller ด้วย REST API ตัวไหน แล้วเขียน Python ยิงตรงเข้าไปเอง จุดที่พลาดกันบ่อยที่สุดไม่ใช่ endpoint แต่เป็น token ตัวเดียวที่ต้องแนบสองที่
พอดีที่บ้านผมใช้ TP-Link OC200 เป็น controller คุมพวก access point กับ gateway แล้วมีงานที่ต้องตั้งค่าซ้ำๆ อย่าง DHCP reservation กับ port-forward การเปิดหน้าเว็บคลิกทีละช่องมันช้า ผมเลยเปิด DevTools ดูว่าหน้าเว็บมันยิง API อะไรบ้าง แล้วก็เจอว่าทุกอย่างวิ่งผ่าน REST API v2 ที่ path /{controllerId}/api/v2/... เขียน Python คุมได้หมด บทความนี้ผมเอา flow จริงที่ใช้อยู่มาเล่า ตั้งแต่ login ยัน PATCH กลับ
IP controller, controller id, user/pass และ site id ในบทความเป็นตัวอย่างที่ mock ไว้ ส่วน flow และโครง request คือของจริงที่รันแล้วผ่าน
Login: token ตัวเดียวที่ต้องแนบสองที่
จุดที่คนติดกันมากสุดคือ CSRF token ครับ ตอน login controller จะคืน token กลับมาก้อนหนึ่ง แล้ว token ตัวนี้ต้องเอาไปแนบทั้งใน HTTP header ชื่อ Csrf-Token และใน query string ?token= ของทุก request ที่ตามมา ขาดที่ใดที่หนึ่ง controller จะตอบกลับมาว่า session ไม่ผ่าน
อีกเรื่องคือ OC200 มากับ self-signed cert เลยต้องปิด SSL verify ไม่งั้น requests จะ throw ตั้งแต่ยังไม่ทันยิง:
import requests, json, urllib3
urllib3.disable_warnings()
BASE = 'https://10.0.0.2' # IP ของ OC200
CONTROLLER_ID = 'c0ffee00112233445566778899aabbcc' # ดูได้จาก URL ตอนเปิดหน้า login
API = f'{BASE}/{CONTROLLER_ID}/api/v2'
USERNAME = 'admin'
PASSWORD = '<PASS>'
session = requests.Session()
session.verify = False # OC200 ใช้ self-signed cert
# === Login ===
r = session.post(f'{API}/login',
json={'username': USERNAME, 'password': PASSWORD},
allow_redirects=False)
data = r.json()
print(data['msg']) # -> Success.
token = data['result']['token']
# token ก้อนเดียวกันนี้ ต้องแนบ 2 ที่ทุก request ที่ตามมา
session.headers.update({'Csrf-Token': token}) # ที่ 1: HTTP header
params = {'token': token} # ที่ 2: query string ?token=
allow_redirects=False ผมใส่ไว้ทุก call เพราะถ้า path ผิดนิดเดียว controller จะ 302 เด้งไปหน้า login แล้ว requests จะวิ่งตามจน error งงๆ ปิด redirect ไว้จะเห็น status จริงว่าพลาดตรงไหน
อยากเช็คเร็วๆ ว่ายิงถึง controller ไหมก่อนจะไปเขียน Python ยาว ใช้ curl ยิง login ดูก้อน token ได้เลย (ต้อง -k เพราะ self-signed):
curl -sk https://10.0.0.2/c0ffee00112233445566778899aabbcc/api/v2/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<PASS>"}'
# {"errorCode":0,"msg":"Success.","result":{"token":"...."}}
ปิด verify ได้ในเครื่อง dev แต่บน production ควร export cert ของ controller มาแล้วชี้ให้ requests ตรวจจริง (session.verify = '/path/oc200.pem') ไม่งั้นใครดักกลางทางได้ก็เห็น token แล้วยึด controller ได้ทั้งตัว และอย่า hardcode รหัสผ่านลงสคริปต์ครับ ย้ายไปอ่านจาก environment variable
หา Site ID ก่อน — ทุกอย่างแขวนใต้ /sites/{id}
Omada แบ่งการตั้งค่าเป็นราย site ครับ ก่อนจะไปแตะ device หรือ network อะไรได้ ต้องรู้ siteId ของ site ที่จะยุ่งด้วยก่อน endpoint /sites คืนมาเป็น list แบบแบ่งหน้า ต้องส่ง currentPage กับ currentPageSize ไปด้วยเสมอ ที่บ้านผมมี site เดียวก็หยิบตัวแรกมาเลย:
r = session.get(f'{API}/sites',
params={**params, 'currentPage': 1, 'currentPageSize': 10},
allow_redirects=False)
site = r.json()['result']['data'][0]
SITE_ID = site['id']
SITE_API = f'{API}/sites/{SITE_ID}'
print(site['name'], SITE_ID, site.get('timeZone'))
พอได้ SITE_API มาแล้วที่เหลือก็ต่อ path ยาวจากตรงนี้ทั้งหมด รูปแบบมันเดาได้ค่อนข้างตรง — setting/lan/... คือฝั่ง network, setting/wireless/... คือ WiFi, setting/firewall/... คือ firewall
ดึงของจริงออกมาดู: devices / SSID / DHCP / port-forward
ก่อนจะเขียนกลับ ผมชอบ GET ทุกอย่างออกมาดู structure ให้ครบก่อน endpoint พวก list เกือบทั้งหมดกินพารามิเตอร์แบ่งหน้าเหมือน /sites เลยรวบเป็น dict แล้ววนยิงทีเดียวจบ:
endpoints = {
'devices': f'{SITE_API}/devices', # AP / switch / gateway ที่ online
'ssids': f'{SITE_API}/setting/wireless/ssids', # รายการ WiFi
'lan_networks': f'{SITE_API}/setting/lan/networks', # subnet / gateway / DHCP
'dhcp_reserve': f'{SITE_API}/setting/lan/dhcpReservation', # IP จอง MAC
'port_forward': f'{SITE_API}/setting/firewall/portForwarding',
}
for name, url in endpoints.items():
r = session.get(url,
params={**params, 'currentPage': 1, 'currentPageSize': 50},
allow_redirects=False)
print(f'--- {name} ({r.status_code}) ---')
if r.status_code == 200 and r.text:
print(json.dumps(r.json()['result'], indent=2, ensure_ascii=False)[:800])
มีข้อสังเกตที่ต้องระวังตั้งแต่ตอนนี้ — Omada API มันตอบ HTTP 200 มาเกือบตลอด ต่อให้คำสั่งจริงๆ ล้มเหลว ตัวที่บอกผลจริงคือ field errorCode ใน body ครับ ไม่ใช่ status code
ตัวชี้วัดผลจริงคือ errorCode ใน JSON body — 0 คือผ่าน ค่าอื่นคือพัง (พร้อมข้อความใน msg) ถ้าเช็คแค่ status_code == 200 คุณจะนึกว่าสำเร็จทั้งที่ controller ปฏิเสธไปแล้ว โดยเฉพาะตอน PATCH
เขียนกลับด้วย PATCH — กับดัก read-only fields
ตรงนี้คือส่วนที่ผมเสียเวลามากสุด ตอนจะแก้ค่า LAN network — สมมติปิด DHCP server บน OC200 เพราะมี DHCP ตัวอื่นแจกอยู่แล้ว กับตั้ง gateway subnet ใหม่ — วิธีที่ถูกคือต้อง GET object เดิมมาทั้งก้อนก่อน แก้เฉพาะ field ที่ต้องการ แล้ว PATCH กลับไปทั้งก้อน ไม่ใช่ส่งไปแค่ field ที่แก้
แต่ดักไว้เลยว่าถ้า GET มาแล้วส่งกลับดิบๆ controller จะตอบ errorCode ไม่เป็น 0 เพราะในก้อนนั้นมี field ที่เป็น read-only ปนอยู่ (id, site, resource, interface ฯลฯ) ที่ห้ามส่งกลับ ต้อง pop ทิ้งก่อน:
NETWORK_ID = '65f1a2b3c4d5e6f708192a4c'
# 1) อ่าน object ปัจจุบันมาทั้งก้อน
r = session.get(f'{SITE_API}/setting/lan/networks',
params={**params, 'currentPage': 1, 'currentPageSize': 10},
allow_redirects=False)
current = r.json()['result']['data'][0]
# 2) แก้เฉพาะ field ที่ต้องการ
current['gatewaySubnet'] = '10.0.0.254/24'
current['dhcpSettings']['enable'] = False
current['dhcpSettings']['ipaddrStart'] = '10.0.0.100'
current['dhcpSettings']['ipaddrEnd'] = '10.0.0.200'
current['dhcpSettings']['ipRangePool'] = [
{'ipaddrStart': '10.0.0.100', 'ipaddrEnd': '10.0.0.200'}
]
# 3) ถอด read-only field ทิ้งก่อน ไม่งั้น controller ปฏิเสธ
for key in ['id', 'site', 'origName', 'primary', 'resource',
'interface', 'accessControlRule', 'rateLimit']:
current.pop(key, None)
# 4) PATCH ทั้งก้อนกลับไปที่ network id เดิม
r = session.patch(f'{SITE_API}/setting/lan/networks/{NETWORK_ID}',
params=params, json=current, allow_redirects=False)
res = r.json()
print(r.status_code, res.get('errorCode'), res.get('msg')) # -> 200 0 Success.
controller คืน field มาครบ
รวม id, site, resource, interface — สะดวกตอนอ่าน เห็นทุกอย่างที่ network นี้ตั้งไว้
ต้องถอด read-only ทิ้งก่อน
ส่ง field พวกนั้นกลับไป controller ตอบ errorCode ไม่ใช่ 0 — pop ทิ้งให้เหลือเฉพาะที่แก้ได้ แล้วค่อยยิง
เหตุผลที่ต้อง GET-แก้-PATCH ทั้งก้อนแทนที่จะส่ง field เดียว เพราะ PATCH ของ Omada มันแทนที่ทั้ง object ไม่ใช่ merge ทีละ field ถ้าส่งไปแค่ dhcpSettings field อื่นที่ไม่ได้ส่งจะโดนรีเซ็ตหรือหาย เลยต้องยกก้อนเดิมมาแก้เฉพาะจุดแล้วส่งคืนให้ครบ
สรุป endpoint ที่ใช้จริง
ทั้ง flow ถ้าถอดออกมาเป็นลำดับก็สั้นมากครับ auth ครั้งเดียวได้ token แล้ว reuse session เดิมยิงได้ยาวจนกว่า token จะหมดอายุ:
ที่ผมใช้ประจำก็วนอยู่ไม่กี่ path — setting/lan/networks กับ setting/lan/dhcpReservation สำหรับงาน network, setting/firewall/portForwarding เวลาเปิด port เข้าเครื่องข้างใน (พวกนี้ค่า publicIp/external จะเป็น 203.0.113.x ในตัวอย่าง), แล้วก็ setting/wireless/ssids เวลาจะแก้ WiFi ทีเดียวหลายตัว โครง request เหมือนกันหมด ต่างกันแค่ path กับ body
ใครจะเอาไปต่อยอดเป็นสคริปต์ sync config ข้ามหลาย controller ก็ทำได้เลยครับ หลักมันคือ token สองที่ + อ่าน errorCode ทุกครั้ง + ถอด read-only ก่อน PATCH เท่านี้ก็คุม OC200 ได้ทั้งตัวโดยไม่ต้องแตะหน้าเว็บ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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