เอกสาร vendor โกหก: reverse-engineer API จริงจาก traffic ไม่ใช่จาก PDF
เมื่อ integrate กับ NVR/ตู้/อุปกรณ์ที่ doc ไม่ครบหรือผิด วิธีที่รอดคือจับ traffic จริงจาก client ของ vendor, ทดลอง endpoint ที่ไม่ documented อย่างเป็นระบบ, และ canonicalize input เพราะ firmware รับค่าเข้มงวดกว่าที่เขียนไว้เสมอ
อ่าน ~8 นาที
Thesis: PDF เขียนจาก firmware คนละรุ่น — traffic จริงคือความจริงเดียว
ผมเคยต่อ hardware API ที่มาพร้อมเอกสารหนาเป็นพันหน้า แล้วก็ยังเสียเวลาไปทั้งวันเพราะเชื่อมันมากไป. บทเรียนที่แลกด้วยเวลาหลายวันคือ vendor doc เป็นสมมติฐาน ไม่ใช่สัญญา — และบทความนี้ไม่ได้จะย้ำแค่ว่า "อย่าเชื่อ PDF" แต่จะลงลึกที่ วิธีการ reverse-engineer API จริงอย่างเป็นระบบ เพื่อให้คุณเลิกเดาแล้วหันไปอ่านความจริง.
ทำไม doc ถึงผิดบ่อยจนวางใจไม่ได้? สามเหตุผลที่ผมเจอซ้ำ: (1) firmware คนละเวอร์ชัน — doc เขียนไว้ตอน v4.38 แต่เครื่องในมือเป็น v4.48 ซึ่งเปลี่ยน validation ไปแล้ว; (2) doc เขียนจาก device คนละ class — endpoint ของ access-terminal ถูก copy มาใส่ในคู่มือ NVR ทั้งที่ NVR ตอบ notSupport; (3) แปลจีน→อังกฤษเพี้ยน — field name สะกดผิดในตัว SDK เอง, error message ถูก truncate, ตัวอย่าง XML ใช้ tag ตัวใหญ่ทั้งที่ firmware รับแค่ตัวเล็ก. เมื่อ ground truth คือ firmware ที่อยู่ตรงหน้า วิธีเดียวที่เชื่อได้คือไปดูมันคุยจริง.
จับของจริง: sniff traffic จาก official client (แม้ client เป็น IE-only)
เทคนิคที่คุ้มเวลาที่สุดคือ เปิด official app หรือ web UI ของอุปกรณ์ แล้วดักดูว่ามันยิง request อะไรออกไป. คุณไม่ต้องเดา payload — vendor เขียน client ที่ทำงานถูกไว้ให้แล้ว หน้าที่คุณคือถอด request จริงออกมา แล้ว replay ด้วยโค้ดตัวเอง. เปิด browser DevTools แท็บ Network, กด CRUD บน UI (เพิ่ม/แก้/ลบ user), แล้วดูว่ามันส่ง method อะไร ไป path ไหน body หน้าตายังไง.
ปัญหาคลาสสิกของอุปกรณ์จีน: web UI มักบังคับ Internet Explorer หรือใช้ ActiveX เก่า. อย่าให้เรื่องนี้บล็อกคุณ — DevTools ไม่ใช่ทางเดียว. ถ้า client รันได้แค่บน IE หรือเป็น native app ให้เอา proxy ดักตรงกลาง (mitmproxy, Fiddler) หรือ packet capture (Wireshark) แทน. สิ่งที่ผมมองหาในทุก request:
- auth scheme จริง — Digest? Basic? realm ชื่ออะไร? (บ่อยครั้ง doc ไม่ตรง)
- content-type + โครง body — multipart? XML? JSON? field แรกใน multipart คือ token หรือรูป?
- ลำดับ request หลาย step — บาง action ที่ดูเหมือนคำสั่งเดียว จริงๆ คือ 3-4 request ต่อกัน (ขอ token → upload → poll → commit)
ตัวอย่างจริง: การ enroll ใบหน้าลง NVR รุ่นหนึ่ง doc บอกว่าใช้ endpoint แบบ /UserInfo/<id>/Manage ตัวเดียว แต่พอถอด request จาก web UI จริงกลับเป็น 4 จังหวะ — ขอ CSRF token, upload รูปเข้า analysis session, poll จน model หน้าเสร็จ, แล้วค่อย commit ด้วย path ชั่วคราวที่ server คืนมา. ถ้าเชื่อ doc ก็ยิงตัวเดียวแล้วได้ notSupport ตลอดกาล.
Undocumented endpoint: หาเจอยังไง และทดลองยังไงให้ปลอดภัย
บ่อยครั้ง endpoint ที่คุณต้องใช้ไม่มีใน doc เลย. วิธีหาคือ (1) ถอดจาก traffic ของ UI ตามข้างบน; (2) เดา family — ถ้าอุปกรณ์เป็นตระกูล ISAPI-like ลอง path ตระกูลนั้น; (3) grep binary/firmware หา string ที่หน้าตาเป็น URL. แต่ประเด็นสำคัญกว่าคือ ทดลองยังไงไม่ให้พังของจริง เพราะนี่คือ hardware ที่กำลังใช้งานอยู่ ไม่ใช่ sandbox.
กฎที่ผมยึด: เริ่มจาก idempotent read ก่อนเสมอ ไล่ไปหา write ทีหลัง. ยิง GET ที่ไม่เปลี่ยน state (query list, device info) เพื่อยืนยันว่า endpoint มีจริงและ auth ผ่าน แล้วค่อยขยับไป write. ก่อนแตะ write บนอุปกรณ์จริง ให้เตรียม rollback — ถ้าจะ create ต้องรู้วิธี delete, ถ้าจะแก้ config ต้อง GET ค่าเดิมเก็บไว้ก่อน.
// probe endpoint อย่างปลอดภัย: read → snapshot → write → verify → rollback ได้
// 1) read-only ก่อน — ยืนยัน endpoint + auth ไม่แตะ state
const info = await http.get('/System/deviceInfo'); // 200 = endpoint มีจริง
if (info.status !== 200) throw new Error('endpoint ไม่มี หรือ auth ผิด');
// 2) snapshot ค่าเดิมก่อน write เสมอ (rollback plan)
const before = await http.get('/config/faceParam');
// 3) ค่อย write ทีละ field เล็กที่สุด แล้ว verify ด้วย read อิสระ
await http.put('/config/faceParam', { minSize: 20 });
const after = await http.get('/config/faceParam');
console.log('changed?', after.body.minSize === 20); // พิสูจน์ด้วยตา ไม่เชื่อ 200 เฉยๆ
อีกข้อ: อย่าทดลอง write ครั้งแรกบนอุปกรณ์ production ที่คนใช้อยู่. ถ้ามีอุปกรณ์ตัวสำรอง หรือ NVR ตัวที่ไม่ได้ live ให้ probe ตัวนั้นก่อน. reverse-engineering คือการทำสิ่งที่ vendor ไม่ได้การันตี — ปฏิบัติกับมันเหมือน migration บน prod DB.
Input constraint ที่ไม่เขียนไว้: alphanumeric only, omit field, canonicalize id
เมื่อเจอ endpoint แล้ว ด่านต่อไปคือ payload ที่ firmware ยอมรับ ซึ่ง เข้มงวดกว่าที่ doc เขียนเสมอ. สามข้อที่ผมเจอซ้ำจนกลายเป็น checklist:
- charset จำกัด — บาง field รับแค่
[A-Za-z0-9]. ID ที่มี dash หรือ underscore (เช่นEXT-AB12-XY) ถูก reject เป็น bad content ทันที ทั้งที่ doc ไม่เคยบอก - enum ต้องตรงเป๊ะ — ค่าที่ไม่รู้จักต้อง "ไม่ส่ง" ไม่ใช่ส่ง
"unknown"— firmware บางรุ่นรับแค่male/female; ส่งunknown= reject แต่ถ้า omit field ทั้งอัน firmware ใช้ default ของตัวเองแล้วผ่าน - id ต้อง canonicalize ที่ขอบระบบ — ถ้า upstream ส่ง id ที่มีอักขระพิเศษ ต้อง normalize ให้ตรงกับที่ firmware รับ ตั้งแต่ entry-point ไม่ใช่ปะไปทีละที่
ข้อ canonicalize สำคัญกว่าที่คิด เพราะถ้าปล่อยให้ id สองรูปแบบ (มี dash / ไม่มี dash) ไหลเข้าระบบคนละทาง คุณจะได้ split-brain — record ซ้ำสองตัวชี้คนเดียวกัน ตัวหนึ่ง enroll ลงอุปกรณ์ได้ อีกตัว enroll ไม่ได้ แล้วรูปกับข้อมูลก็หลุดกันไปคนละทาง. แก้ด้วยการ collapse ทุก id ให้เป็น canonical form เดียวตั้งแต่ประตูแรก แล้วเก็บ original ไว้ใน field อ้างอิงต่างหาก:
// canonicalize ที่ entry-point — collapse ทุก id form เป็นรูปเดียว
function canonicalId(raw) {
return raw.replace(/[^A-Za-z0-9]/g, ''); // firmware รับแค่ alphanumeric
}
const canonical = canonicalId(evt.userId); // "EXT-AB12-XY" -> "EXTAB12XY"
// lookup / create / enroll ใช้ canonical เสมอ
// เก็บ raw ไว้ใน field อ้างอิงไว้ trace ย้อนกลับ upstream
await store.upsert({ id: canonical, ref: evt.userId });
Field ที่ firmware เงียบ reject: ตรวจ response จริง ไม่ใช่แค่ HTTP 200
กับดักที่ทำให้ debug นานคือ error message ที่ opaque — ยิง payload แล้วได้แค่ badContent หรือเลข code ไม่บอกว่า field ไหนผิด. อย่านั่งเดา. วิธีที่เร็วที่สุดคือ bisect ทีละ field: เริ่มจาก minimal payload ที่ผ่านแล้วเติมทีละ field จนพัง — field ที่ทำให้พังคือตัวปัญหา; หรือเทียบกับ request ที่สำเร็จ (จับจาก UI) แล้ว diff.
// bisect หา field ผิดจาก error ที่ไม่บอกอะไร
const base = { name: 'u1' }; // minimal ที่ผ่านแน่ๆ
const suspects = [
{ gender: 'unknown' }, // enum? -> ต้อง omit ถ้าไม่ใช่ male/female
{ userId: 'AB-12' }, // charset? -> รับแค่ alphanumeric
{ valid: 'true' }, // XML attribute vs element? case?
];
for (const patch of suspects) {
const res = await put({ ...base, ...patch });
console.log(patch, res.status, res.body?.errorMsg); // ตัวไหนพัง = ตัวนั้น
}
สำคัญกว่านั้น: HTTP 200 ไม่ได้แปลว่าสำเร็จ. firmware หลายรุ่นตอบ 200 พร้อม body ที่บอกว่า operation ล้มเหลว หรือ 200 กับ body ว่างเพราะ implement ไม่ครบ. อย่าเชื่อ status code — ต้อง verify ด้วย read อิสระ: enroll แล้วต้อง query กลับมาว่า record มีจริง, upload รูปแล้วต้องเช็คว่า model count เพิ่มขึ้นจริง. "ยิงแล้วได้ 200" ไม่ใช่หลักฐานว่างานเสร็จ.
เขียน integration test กับอุปกรณ์จริง 1 ตัวก่อน scale ทั้ง fleet
reverse-engineering เสร็จบน device ตัวเดียวไม่ได้แปลว่าใช้ได้ทั้ง fleet เพราะ firmware version ต่างกันคือ API ต่างกัน. หลักที่ผมยึด: ทำ end-to-end ให้ผ่านบนอุปกรณ์จริง 1 ตัวก่อน แล้ว lock ทั้ง flow เป็น integration test — ยิง request จริง จับ response จริง assert พฤติกรรม ไม่ใช่ mock. เมื่อจะเพิ่มอุปกรณ์ตัวที่สอง (คนละรุ่น/เวอร์ชัน) รัน test เดิม ถ้าแดงตรงไหนคือ quirk ใหม่ที่ต้อง handle.
ระวังกับดัก network topology ที่มักโผล่ตอน scale: ถ้าคุณ dev บน environment ที่อยู่คนละวงกับอุปกรณ์ (เช่น container ที่ push ขาเข้าไม่ถึง) การรับ event แบบ push อาจใช้ไม่ได้เลย ทั้งที่ยิง request ขาออกได้ปกติ. ในเคสแบบนั้นการ poll แทน push (ยิงขาออกล้วนไป query event) เป็น fallback ที่ทะลุกำแพงได้ — แต่จำไว้ว่ามันคือ workaround ของ topology ไม่ใช่ของ API. ทดสอบทั้งสองโหมด อย่า assume ว่า push จะทำงานเมื่อ deploy จริง.
จด quirk เป็น runbook: เจอครั้งเดียวจำ ไม่ debug ซ้ำ
ทุก quirk ที่คุณถอดออกมาด้วยเลือดเนื้อ ถ้าไม่จด อีกสามเดือนคุณ (หรือเพื่อนร่วมทีม) จะ debug ซ้ำจากศูนย์. ผมเก็บทุกอย่างเป็น runbook สั้นๆ ต่ออุปกรณ์: firmware version ที่ทดสอบ, endpoint จริง (ไม่ใช่ที่ doc บอก), constraint ที่ firmware เงียบ reject, และ workaround พร้อม reference commit. รูปแบบที่ผมใช้:
## Device X — firmware v4.48 (verified 2026-xx-xx)
- enroll: POST /Face/Record (multipart, field แรก = token ไม่ใช่รูป)
- userId: [A-Za-z0-9] เท่านั้น — dash/underscore -> 400 badContent
- gender: รับแค่ male/female — ค่าอื่น omit field (อย่าส่ง unknown)
- 200 != success: ต้อง query กลับดู modelCnt เพิ่มจริง
- rollback: DELETE /Face/<key>/Manage
runbook แบบนี้เปลี่ยน tribal knowledge ให้เป็น asset ที่ตรวจย้อนได้. ครั้งหน้าที่เจอ device รุ่นเดียวกัน คุณข้าม discovery phase ทั้งดุ้น. และเมื่อเจอ firmware ใหม่ที่ behavior เปลี่ยน คุณมี baseline ให้ diff ว่าอะไรต่างจากเดิม.
สรุป: reverse-engineer เป็นกระบวนการ ไม่ใช่การเดา
- doc ผิดเป็นค่า default — firmware คนละเวอร์ชัน, doc จาก device คนละ class, แปลจีน→อังกฤษเพี้ยน; traffic จริงคือความจริงเดียว
- sniff จาก official client — ถอด request จริงด้วย DevTools/proxy/packet capture; ดู auth scheme, โครง body, และลำดับ multi-step ที่ doc ซ่อนไว้
- probe endpoint อย่างปลอดภัย — read ก่อน write, snapshot ค่าเดิมไว้ rollback, อย่าลองครั้งแรกบน device ที่ใช้งานอยู่
- input เข้มกว่า doc เสมอ — charset จำกัด, enum ต้อง omit เมื่อไม่รู้ค่า, canonicalize id ที่ขอบเพื่อกัน split-brain
- HTTP 200 ไม่ใช่ความสำเร็จ — verify ด้วย read อิสระ; bisect ทีละ field หา validation ที่ firmware เงียบ reject
- test บน device จริง 1 ตัวก่อน scale — lock flow เป็น integration test; ระวัง topology (push อาจไม่ทำงาน ต้อง poll fallback)
- จด runbook ต่ออุปกรณ์ — version + endpoint จริง + quirk + rollback; เปลี่ยน tribal knowledge เป็น asset ที่ diff ได้




