nameserver ที่ registrar ไม่ตรงกับระบบบิลลิ่ง ออดิตสามฝั่งก่อนแก้
ช่องกรอก nameserver ในแผงบิลลิ่งดูเหมือนเป็นค่าเดียวกับที่ registrar เก็บไว้ แต่ความจริงมันเป็นคนละชุดข้อมูลที่ไม่มีใครบังคับให้ตรงกัน และรู้ตัวอีกทีคือตอนที่ปุ่ม Save เด้ง error แปลกๆ ออกมา
พอดีต้องย้าย DNS ของโดเมนกลุ่มหนึ่งไปอยู่ผู้ให้บริการ DNS ตัวใหม่ ขั้นตอนสุดท้ายคือเข้าไปกดเปลี่ยน nameserver ที่ระบบบิลลิ่ง (ตัวที่ผูกกับ registrar ผ่านโมดูล) ปกติมันเป็นงานสิบวินาที กรอก ns1 ns2 แล้วกด Save จบ แต่รอบนี้กดแล้วเด้ง Wrong response from the server. Unknown error พร้อมแถบแดงที่เขียนว่า [] operator not supported for strings โผล่ขึ้นมา ผมเลยไล่ไปเรื่อยจนเจอว่าปัญหาไม่ได้อยู่ที่ registrar เลย มันอยู่ที่ข้อมูลตกค้างในฐานของระบบบิลลิ่งเอง แล้วพองัดเปิดดูจริงๆ ก็พบว่าค่าที่แผงบิลลิ่งโชว์อยู่ กับค่าที่ registrar ถืออยู่จริง ไม่ตรงกันมาเป็นปีแล้วโดยไม่มีใครรู้
ค่าทั้งหมดในบทความนี้ผมเปลี่ยนเป็นชื่อสมมุติกับ IP ตัวอย่างหมดแล้วนะครับ ส่วนอาการกับวิธีไล่คือของจริงจากงานที่ผมทำเอง
อาการ: พังเฉพาะบางโดเมน โดเมนอื่นผ่านฉลุย
จุดที่ทำให้งงตอนแรกคือมันไม่ได้พังทั้งระบบ โดเมนที่เพิ่งจดใหม่กดเปลี่ยน NS ได้ปกติ ส่วนโดเมนเก่าที่ย้ายตามกันมาหลายรอบกดทีไรเด้ง error ทุกที ถ้าระบบพังทั้งยวงผมคงโทษโมดูลไปแล้ว แต่พอมันพังเป็นบางตัว แปลว่าตัวแปรอยู่ที่ ข้อมูลของโดเมนนั้น ไม่ใช่ที่โค้ด
ผมเลยเปิดฐานของระบบบิลลิ่งแบบอ่านอย่างเดียว ดึงสองคอลัมน์ที่เกี่ยวกับ nameserver ออกมาเทียบกันระหว่างโดเมนที่พังกับโดเมนที่ผ่าน
-- อ่านอย่างเดียว: ดูค่า NS ที่ระบบบิลลิ่งเก็บไว้ กับ glue IP ที่ตกค้าง
SELECT name, status, nameservers, nsips
FROM billing_domains
WHERE status = 'Active'
ORDER BY (nsips <> '') DESC, name;
ผลออกมาชัดมาก โดเมนที่กดผ่านจะมีค่า nameservers เป็น |||| (ช่องว่างสี่ขีดคือ slot ว่างห้าช่อง) และ nsips เป็นค่าว่าง ส่วนโดเมนที่พังจะมี nameservers เหลือแค่ | ขีดเดียว แล้ว nsips มีค่าเป็น IP สองตัวคั่นด้วยขีดตั้ง แบบ 203.0.113.20|203.0.113.21 ซึ่งเป็น glue IP ของ private nameserver จากโฮสต์เดิมเมื่อหลายปีก่อน
ข้อมูลสะอาด
nameservers = |||| ครบห้าช่องตามที่โมดูลคาดหวัง
nsips = ว่าง ไม่มี glue IP ค้าง โมดูลวนลูปได้ตามปกติ
ข้อมูลตกค้างจากโฮสต์เดิม
nameservers = | สั้นกว่าที่โค้ดคาด
nsips = IP สองตัวของ private NS ที่เลิกใช้ไปแล้ว พอโค้ดเอาไปทำ array ต่อ มันระเบิดตรงนั้น
ข้อความ error ที่ว่า [] operator not supported for strings คือสำนวน PHP ยุคเก่าที่เขียน $var[] = ... ใส่ตัวแปรที่ดันเป็น string ไม่ใช่ array สมัย PHP 5 มันปล่อยผ่าน พอมาอยู่บน PHP 7 ขึ้นไปมันโยน fatal error ทันที พูดง่ายๆ คือฟังก์ชันอัปเดต NS ของโมดูลไม่ได้เผื่อกรณีที่ฟิลด์ glue IP มีค่าอยู่
ทำไมแก้ที่โค้ดไม่ได้
ปกติเจอบั๊กแบบนี้ก็แค่เปิดไฟล์ไปเติม is_array() สักบรรทัด จบ แต่โมดูล registrar ตัวนี้ถูก เข้ารหัสด้วย ionCube มาจากผู้ขาย เปิดดูไม่ได้ แก้ไม่ได้ แพตช์ไม่ได้ จะรอผู้ขายออกเวอร์ชันใหม่ก็ไม่รู้เมื่อไหร่ และที่สำคัญคือฟังก์ชันอื่นของโมดูล (ต่ออายุโดเมน จดใหม่ ดึงสถานะ) ยังทำงานปกติดี พังเฉพาะฟังก์ชันอัปเดต NS ตัวเดียว
ตัวที่พังคือ "อัปเดต nameserver" อย่างเดียว แต่ตัวที่ทำเงินคือ "ต่ออายุอัตโนมัติ" ซึ่งยังดีอยู่ ถ้าไปรื้อโมดูลหรือสลับ registrar module เพราะหงุดหงิดกับบั๊กเล็กๆ ตัวนี้ สิ่งที่พังตามมาคือรอบบิลกับ invoice ต่ออายุของลูกค้าทั้งกอง คุ้มกันไม่ได้เลย
ทางออก: เรียก API ของ registrar ตรงๆ ไปเลย
ตัว registrar เปิด HTTP API ให้อยู่แล้ว งานอัปเดต NS มันเป็นแค่คำสั่งเดียว ผมเลยเขียนสคริปต์เล็กๆ ยิงตรง ข้ามโมดูลที่พังไปเลย สองคำสั่งพอคือ getns อ่านค่าปัจจุบัน กับ setns ตั้งค่าใหม่แล้ววนกลับไปอ่านซ้ำเพื่อยืนยัน
#!/usr/bin/env python3
"""ns_tool.py - อ่าน/ตั้ง nameserver ผ่าน API ของ registrar ตรงๆ
credential อ่านจาก environment เท่านั้น ไม่ฝังในไฟล์"""
import os, sys, urllib.parse, urllib.request
import xml.etree.ElementTree as ET
API = "https://api.registrar.example.com/xml.response"
NS = {"n": "http://api.registrar.example.com/xml.response"}
def call(command, **extra):
params = {
"ApiUser": os.environ["REG_API_USER"],
"UserName": os.environ["REG_API_USER"],
"ApiKey": os.environ["REG_API_KEY"], # มาจาก env เท่านั้น
"ClientIp": os.environ["REG_CLIENT_IP"], # ต้องเป็น IP ที่อยู่ใน allowlist
"Command": command,
}
params.update(extra)
url = API + "?" + urllib.parse.urlencode(params)
with urllib.request.urlopen(url, timeout=30) as r:
return ET.fromstring(r.read())
def split_domain(domain):
sld, _, tld = domain.partition(".")
return sld, tld
def getns(domain):
sld, tld = split_domain(domain)
root = call("registrar.domains.dns.getList", SLD=sld, TLD=tld)
return [n.text for n in root.iter("{*}Nameserver")]
def setns(domain, servers):
sld, tld = split_domain(domain)
call("registrar.domains.dns.setCustom", SLD=sld, TLD=tld,
Nameservers=",".join(servers))
return getns(domain) # อ่านซ้ำเพื่อยืนยันว่าเปลี่ยนจริง
if __name__ == "__main__":
op, domain, *rest = sys.argv[1:]
print(getns(domain) if op == "getns" else setns(domain, rest))
จุดสำคัญคือฝั่ง authentication ของ registrar แบบนี้มัน ไม่ได้ดูแค่คีย์ แต่ดู "คีย์ + IP ต้นทางที่อยู่ใน allowlist" ด้วย IP ที่ถูก allow ไว้คือ IP ของเซิร์ฟเวอร์ที่ระบบบิลลิ่งรันอยู่ ไม่ใช่เครื่องที่บ้านผม (เน็ตบ้านเป็น dynamic IP หมุนทุกวันอยู่แล้ว จะไปใส่ allowlist ก็ไม่ไหว) แปลว่าสคริปต์ตัวนี้ต้องรันบนเซิร์ฟเวอร์เท่านั้น ผมเลยส่งมันไปรันผ่าน stdin ไม่ต้องวางไฟล์ทิ้งไว้บนเครื่อง
# ส่งสคริปต์เข้าไปรันบนเซิร์ฟเวอร์ผ่าน stdin ไม่เหลือไฟล์ค้าง
cat ns_tool.py | ssh [email protected] \
"REG_API_USER=\$REG_API_USER REG_API_KEY=\$REG_API_KEY \
REG_CLIENT_IP=\$REG_CLIENT_IP python3 - getns example.com"
registrar แบบนี้ให้ API key ใบเดียวต่อหนึ่งบัญชี ไม่ใช่ใบละแอป กดปุ่ม regenerate เมื่อไหร่ ใบเก่าตายทันที แล้วสิ่งที่ตายตามคือระบบต่ออายุโดเมนอัตโนมัติของระบบบิลลิ่งที่ถือคีย์ใบเดิมอยู่ โดเมนลูกค้าทั้งกองจะเงียบไปจนกว่าจะมีคนสังเกตว่า invoice ต่ออายุไม่ออก ข่าวดีคือคีย์ใบเดียวใช้พร้อมกันได้หลายตัว (ตัวตรวจสิทธิ์คือคีย์คู่กับ IP) ใช้ใบเดิมต่อได้เลย ไม่ต้องออกใหม่
ออดิตสามฝั่งก่อน อย่าเพิ่งกดแก้
พอมีเครื่องมืออ่านค่าจาก registrar ได้แล้ว ผมไม่รีบแก้โดเมนที่พังทันที เพราะคำถามที่ใหญ่กว่าคือ "แล้วตัวอื่นล่ะ ตรงกันจริงไหม" ของแบบนี้มันมีสามฝั่งที่ควรพูดตรงกันแต่ไม่มีใครบังคับให้ตรง
ฝั่งซ้ายคือความจริงเชิงกฎหมาย ฝั่งกลางคือความจริงที่คนในทีมเห็นบนหน้าจอ ฝั่งขวาคือความจริงที่ผู้ใช้ปลายทางเจอจริงตอนเปิดเว็บ ทั้งสามฝั่งมีโอกาสไม่ตรงกันได้อิสระ ผมเลยเขียนตัวออดิตมาไล่เทียบทีละโดเมน
import time, csv
LIMIT_PER_MIN = 20 # registrar จำกัดไว้ 20 คำสั่งต่อนาที
SLEEP = 60.0 / LIMIT_PER_MIN + 0.3 # หน่วง 3.3 วิ/ครั้ง เผื่อ jitter
rows = []
for d in active_domains_from_billing(): # ดึงจากฐานบิลลิ่ง (อ่านอย่างเดียว)
try:
real = getns(d["name"]) # ฝั่ง registrar
except Exception as e:
rows.append({"domain": d["name"], "verdict": "NOT-IN-REGISTRAR",
"detail": str(e)[:80]})
time.sleep(SLEEP)
continue
billing = [x for x in (d["nameservers"] or "").split("|") if x]
if not billing:
verdict = "BILLING-EMPTY" # ฐานบิลลิ่งว่าง แต่ registrar มีค่า
elif sorted(billing) != sorted(real):
verdict = "MISMATCH"
else:
verdict = "OK"
rows.append({"domain": d["name"], "verdict": verdict,
"registrar": ",".join(real), "billing": ",".join(billing),
"glue_left": d["nsips"] or ""})
time.sleep(SLEEP)
with open("ns_audit.tsv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0]), delimiter="\t")
w.writeheader(); w.writerows(rows)
ตัวหน่วงเวลา 3.3 วินาทีต่อการเรียกหนึ่งครั้งไม่ใช่ของประดับนะครับ registrar ฝั่งนี้จำกัดไว้ 20 คำสั่งต่อนาที ยิงรัวกว่านั้นมันเริ่มตอบ error กลับมาแบบไม่บอกสาเหตุ แล้วเราจะไปหลงคิดว่าโดเมนนั้นมีปัญหาทั้งที่โดนเบรกเฉยๆ กวาดโดเมนหลักร้อยตัวก็ปล่อยให้มันเดินไปสิบนาที ไปชงกาแฟรอได้
สิ่งที่ออดิตเจอ อ่านแล้วหนาว
ไฟล์รายงานที่ได้หน้าตาประมาณนี้ ผมเปิดด้วยตัวอ่านตารางแล้วเรียงตามคำตัดสิน จะเห็นกองปัญหาแยกเป็นกลุ่มชัดเจน
$ column -t -s $'\t' ns_audit.tsv | sort -k2
domain verdict registrar billing
alpha.example OK ns1.dnshost.example,ns2... ns1.dnshost.example,ns2...
bravo.example BILLING-EMPTY ns1.dnshost.example,ns2...
delta.example BILLING-EMPTY ns3.otherdns.example,ns4...
echo.example MISMATCH ns3.otherdns.example,ns4... ns1.oldhost.example,ns2...
foxtrot.example NOT-IN-REGISTRAR - ns1.oldhost.example,ns2...
เรียงจากหนักไปเบา ข้อแรกคือ ฟิลด์ nameserver ในระบบบิลลิ่งเชื่อไม่ได้เลย 20 จาก 28 โดเมนที่ยัง Active เก็บค่าเป็นว่างหรือ |||| ทั้งที่ registrar มี NS จริงอยู่ครบ แปลว่าที่ผ่านมาถ้าใครเปิดหน้าบิลลิ่งดูแล้วสรุปว่า "โดเมนนี้ยังไม่ได้ตั้ง NS" คือสรุปผิดมาตลอด ความจริงต้องไปถาม registrar เท่านั้น
ข้อสองคือสคริปต์ที่ผมเขียนไปตั้ง NS ผ่าน API ไม่ได้อัปเดตฐานบิลลิ่งให้ โดเมนที่ผมแก้ไปแล้วโชว์ค่าใหม่ถูกต้องที่ registrar แต่ในบิลลิ่งยังเป็นค่าเก่าเหมือนเดิม คือผมกำลังสร้างความไม่ตรงกันรอบใหม่ด้วยมือตัวเองถ้าไม่ตามไปเก็บ
ข้อสามคือมีเก้าโดเมนที่ยัง Active ในระบบเราจริง แต่ NS ชี้ไปที่ผู้ให้บริการ DNS เจ้าอื่นทั้งดุ้น ลูกค้าย้าย DNS ออกไปเองโดยไม่ได้บอกใคร ซึ่งไม่ผิดอะไร แต่ถ้าเราไม่รู้ว่าเขาย้ายไปแล้ว วันที่เราไปแก้ zone ฝั่งเราแล้วเว็บเขาไม่เปลี่ยน เราจะไล่ผิดจุดอยู่สามชั่วโมง
ฟิลด์ NS ในระบบบิลลิ่งไม่ได้พังวันเดียว มันค่อยๆ เพี้ยนมาหลายปีเพราะไม่เคยมีใครเอาไปเทียบกับของจริง ข้อมูลที่ไม่มีคน (หรือ cron) ตรวจ จะกลายเป็นข้อมูลผิดเสมอ ต่างกันแค่ว่าใช้เวลานานแค่ไหน
ล้างของค้างแล้ว sync กลับ อย่าลืมสำรองก่อน
พอเห็นภาพครบแล้วค่อยลงมือ ลำดับที่ผมใช้คือ backup ก่อนเสมอ แล้วค่อยเขียนทับด้วยค่าจริงจาก registrar พร้อมล้าง glue IP ที่ค้างออกไปในคราวเดียว
- dump แถวเดิมเก็บเป็น SQL ไว้ก่อน ให้ย้อนกลับได้ในคำสั่งเดียว
- เขียนค่า NS จริงจาก registrar ลงฐานบิลลิ่ง เติมขีดให้ครบห้า slot ตามที่โมดูลคาดหวัง
- ล้าง nsips ของโดเมนที่ไม่ได้ใช้ private nameserver แล้ว
- กลับเข้าแผงบิลลิ่ง กด Save ดูสักตัว ต้องไม่เด้ง error อีก
-- 1) สำรองก่อนแตะอะไรทั้งนั้น
SELECT CONCAT('UPDATE billing_domains SET nameservers=', QUOTE(nameservers),
', nsips=', QUOTE(nsips), ' WHERE id=', id, ';')
FROM billing_domains
WHERE status = 'Active';
-- 2) เขียนค่าจริงกลับ (ทำทีละแถวจากไฟล์ออดิต ไม่ใช่ UPDATE รวดเดียว)
UPDATE billing_domains
SET nameservers = 'ns1.dnshost.example|ns2.dnshost.example|||',
nsips = ''
WHERE name = 'example.com'
AND status = 'Active';
ผมย้ำเรื่องข้อ 2 หน่อย อย่าเขียน UPDATE ตัวเดียวที่ไล่ทับทุกแถว เพราะโดเมนแต่ละตัว NS ไม่เหมือนกัน (ผู้ให้บริการ DNS หลายเจ้าแจก NS เป็นคู่ที่สุ่มชื่อมาต่างกันทุกโซน) ต้องเดินจากไฟล์ออดิตทีละแถว แล้วแตะเฉพาะแถวที่ยืนยันได้ว่า registrar มีค่าจริง ส่วนแถวที่ registrar ไม่มีข้อมูล (โดเมนหมดอายุ ย้ายออก หรือปิดไปแล้ว) อย่าไปยุ่ง ปล่อยไว้อย่างนั้น เพราะไม่มีความจริงจะไปเทียบ
ผลหลังรัน แถว Active ที่ยังมี glue IP ค้างเหลือศูนย์ และโดเมนหกตัวที่เคยกด Save แล้วเด้ง error กลับมากดผ่านได้ตามปกติในแผงบิลลิ่ง เพราะต้นเหตุที่ทำให้โค้ดใน ionCube ระเบิดมันหายไปแล้ว
ทำให้มันไม่กลับมาอีก
แก้ครั้งเดียวไม่พอ เพราะต้นเหตุจริงคือไม่มีใครเฝ้าความไม่ตรงกัน ผมเลยตั้งตัวออดิตนี้ให้เดินเองเป็นรอบ แล้วส่งรายงานเฉพาะเวลาที่มีรายการเพี้ยน ถ้าทุกอย่างตรงกันก็เงียบไป ไม่ต้องเมล์มาให้รำคาญทุกสัปดาห์
# รายสัปดาห์ เช้าวันจันทร์ ยิงเฉพาะตอนเจอ MISMATCH
15 7 * * 1 cd /opt/ns-audit && /usr/bin/python3 ns_audit.py \
--out /var/log/ns-audit/$(date +\%F).tsv --alert-only >> /var/log/ns-audit/run.log 2>&1
หนึ่ง ค่าที่เชื่อได้มีที่เดียวคือที่ registrar ฝั่งบิลลิ่งเป็นแค่ภาพสะท้อนที่อาจค้าง สอง ทุกเครื่องมือที่เขียนค่าผ่าน API ต้องตามไปอัปเดตภาพสะท้อนนั้นด้วย ไม่งั้นเราสร้างความเพี้ยนรอบใหม่เอง สาม อะไรที่มีสามฝั่งต้องมีตัวเทียบอัตโนมัติเสมอ ไม่ใช่รอให้ปุ่ม Save เด้ง error ถึงจะรู้ว่าข้อมูลเน่ามาสามปีแล้ว
ส่วนตัวผมว่าบทเรียนที่แพงที่สุดของงานนี้ไม่ใช่บั๊ก PHP ยุคเก่าในโมดูลที่แพตช์ไม่ได้ แต่เป็นการที่เราเชื่อหน้าจอในระบบตัวเองมาตลอดโดยไม่เคยเอาไปเทียบกับปลายทางจริงสักครั้ง ใครดูแลโดเมนให้ลูกค้าอยู่ ลองกวาดสักรอบดูครับ ผมว่าเจอแน่ๆ อย่างน้อยหนึ่งตัว ตรงไหนผมอธิบายข้ามไปหรือมี registrar เจ้าอื่นที่ API หน้าตาไม่เหมือนนี้ เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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