Cloudflare token โดน 9109 ทั้งที่ IP อยู่ใน allowlist เพราะออกทาง IPv6
allowlist ที่มีแต่ IPv4 ใช้ได้ดีจนวันที่เครื่องสักเครื่องเลือกออกทาง IPv6 แล้วทุกอย่างพังโดยที่ IP ใน list ไม่ผิดเลยสักตัว การเดาชื่อ ISP ไม่ช่วยอะไร สิ่งที่ช่วยคือเอาหลักฐานมาดูว่าใครเรียก API จากที่ไหนจริง
สรุปใน 1 นาที
- อาการ/ปัญหา
- Cloudflare token ที่ล็อก IP allowlist ได้ error 9109 บนเครื่องหนึ่ง ทั้งที่ list ครอบช่วงของ ISP แล้ว
- ใครควรอ่าน
- คนที่ใช้ Cloudflare API token แบบจำกัด IP จากหลายเครื่องหรือหลาย ISP
- ทางแก้แบบสั้น
- เปิด audit log ดู IP ที่เรียกจริง พบว่าเครื่องนั้นออกทาง IPv6 แล้วเติม IPv6 /32 ของ ISP เข้า allowlist โดยไม่ต้องเปลี่ยนค่า token
พอดี token ของ Cloudflare ที่ผมล็อกด้วย IP allowlist ไว้ ใช้งานมาเรื่อย ๆ จนวันหนึ่งเครื่องอีกเครื่องหนึ่ง (ผมเรียกว่า host-b ในบทความนี้) ดึงสถิติไม่ได้ ได้ error 9109 กลับมา ทั้งที่เครื่องหลักยังใช้ token แบบเดียวกันได้ปกติ และ IP ที่ผมรู้อยู่ว่าเป็นของ ISP เดียวกันก็อยู่ใน list หมดแล้ว เลยต้องนั่งไล่หาว่าอะไรกันแน่ที่ไม่ตรง บทความนี้เล่าตามลำดับที่ผมเดาผิดก่อน แล้วค่อยหาหลักฐานจนเจอสาเหตุ ส่วน IP ช่วง ASN และชื่อ token ในบทความผมเปลี่ยนเป็นค่าตัวอย่างทั้งหมด
อาการ: token เดียวกัน เครื่องหนึ่งผ่าน อีกเครื่องโดน 9109
เหตุที่ผมล็อก token ด้วย Client IP Address Filtering ก็เพราะเน็ตบ้านเป็น CGNAT ที่ IP ขาออกหมุนเปลี่ยนได้บ่อย การไม่ล็อกอะไรเลยแปลว่า token รั่วไปที่ไหนก็ใช้ได้ที่นั่น ผมเลยอยากให้ใช้ได้เฉพาะจากเครือข่ายของ ISP ที่ผมใช้อยู่ ส่วนเหตุผลว่าทำไมต้องใช้ทั้งช่วง ไม่ใช่ IP เดียว เคยเล่าไว้สั้น ๆ ใน callout ของ บทความเรื่อง DNS กับเรคคอร์ดเมลสีเทา แล้ว บทความนี้เป็นภาคต่อที่ลงลึกเรื่อง "หาช่วงยังไง" กับ "กับดักที่ยังเหลือ"
ตอนแรก list มีแค่ 3 รายการ คือ /16 สองช่วงกับ /32 อีกหนึ่ง ใช้ได้ตราบเท่าที่ IP ขาออกยังตกอยู่ในสอง /16 นั้น เจ้าของงานสังเกตว่าบางวัน IP ที่เห็นไม่อยู่ในกลุ่มที่คุ้น แล้วพอมี host-b มาใช้ token ด้วยก็ได้ 9109 ทันที
เดาผิดไปสองรอบก่อนจะมีหลักฐาน
รอบแรกผมเดาว่า ISP ในเครือเดียวกันอาจใช้คนละ ASN (มือถือกับ ISP อีกเจ้าที่ควบรวมกัน) เลยไปหาบล็อกของเครือมาเพิ่มเข้า list ทำเสร็จก็ไม่ใช่ปัญหาของ host-b เลย 9109 ยังอยู่เหมือนเดิม
รอบสองผมเดาว่า IP ที่ "ไม่อยู่ในกลุ่ม" ที่เจ้าของงานเห็นน่าจะเป็นมือถือ VPN หรือ WARP ของคนอื่น ข้อนี้เช็กได้ง่ายกว่าที่คิด เพราะผมมีตัวเฝ้า IP ขาออกของเครื่องเราอยู่แล้ว ย้อนดูได้ว่า 9 วันที่ผ่านมาเครื่องเรามี IP ขาออก 5 ค่า และทั้ง 5 ค่าอยู่ใน pool เดียวกันหมด ไม่มีค่าไหนหลุดออกไปนอก /16 ที่อนุญาตไว้
สองข้อเดาของผมมีรูปเดียวกัน คือเริ่มจากชื่อผู้ให้บริการแล้วคิดต่อเอา ซึ่งไม่มีอะไรมาพิสูจน์ว่าถูกหรือผิด ที่ไม่ได้เสียเวลามากกว่านี้เพราะผมหันไปหาข้อมูลที่ตรวจสอบได้สองชุด ชุดแรกคือ prefix ที่ ASN ประกาศจริง ชุดที่สองคือ audit log ว่าใครเรียกบัญชีนี้จาก IP ไหน
หาช่วง IP ของ ISP จาก RIPEstat แทนการเดา
RIPEstat มี endpoint announced-prefixes ที่บอกว่า ASN หนึ่งประกาศ prefix อะไรอยู่บ้าง ใส่ ASN ของ ISP ลงไปก็ได้รายการจริงที่อยู่บนอินเทอร์เน็ตตอนนี้ ในบทความผมใช้ AS64496 เป็นค่าตัวอย่าง ของจริงให้ใส่ ASN ของผู้ให้บริการที่คุณใช้เอง
import ipaddress, json, urllib.request
ASN = "AS64496" # ค่าตัวอย่าง ใส่ ASN ของ ISP ตัวเอง
url = f"https://stat.ripe.net/data/announced-prefixes/data.json?resource={ASN}"
with urllib.request.urlopen(url, timeout=30) as r:
prefixes = [p["prefix"] for p in json.load(r)["data"]["prefixes"]]
# collapse_addresses ต้องส่งทีละ version จะปนกันไม่ได้
v4 = [ipaddress.ip_network(p) for p in prefixes if ":" not in p]
v6 = [ipaddress.ip_network(p) for p in prefixes if ":" in p]
blocks4 = list(ipaddress.collapse_addresses(v4))
blocks6 = list(ipaddress.collapse_addresses(v6))
print(len(prefixes), "prefix ->", len(blocks4), "v4 block,", len(blocks6), "v6 block")
for b in sorted(blocks4, key=lambda n: n.prefixlen)[:10]:
print(b)
ในเคสนี้ได้ 586 prefix พอใช้ collapse_addresses ยุบช่วงที่ซ้อนกันหรือติดกันเหลือ 53 บล็อก ในนั้นมี /16 ใหญ่ 4 ช่วงที่ list ผมยังไม่มี กับบล็อกเล็กอีก 23 รายการ ตรงนี้คืออีกกับดัก ถ้าสั่งเพิ่มทุกบล็อกเข้า list ตามที่ได้ทันที ผมก็เพิ่มช่วงของลูกค้าผู้ให้บริการรายอื่นเข้าไปด้วยโดยไม่จำเป็น ช่วงของ ISP ที่ใหญ่ ๆ มักครอบเครือข่ายคนอื่นนับพันคน การเปิด token ให้ทั้งหมดคือการลดความแคบของ allowlist ลงเยอะ จะเพิ่มอะไรเข้า list ต้องมีเหตุผลรองรับ
ผมเลยเอาข้อมูลชุดที่สองมาใช้ตัดสิน
เปิด audit log แยก IP ของเราออกจาก IP ของคนอื่น
Cloudflare เก็บ audit log ของบัญชีย้อนหลังได้ 17 เดือน ในนั้นมี IP ของผู้กระทำทุกครั้ง ผมดึงออกมานับได้ 417 IP ที่เคยเรียกบัญชีนี้ จากนั้นเอามาเทียบกับช่วงของ ISP
import collections, ipaddress, json
# audit log ที่ export มาเป็นไฟล์ JSON (แต่ละรายการมี actor.ip)
logs = json.load(open("audit_export.json", encoding="utf-8"))
ips = collections.Counter(e["actor"]["ip"] for e in logs if e.get("actor", {}).get("ip"))
# ตัวอย่าง: ในเคสจริงเป็น /16 สองช่วง ที่นี่ใช้ /24 แทนเพื่อไม่ให้เป็นค่าจริง
ours = [ipaddress.ip_network("203.0.113.0/24"), ipaddress.ip_network("198.51.100.0/24")]
mine, others = collections.Counter(), collections.Counter()
for ip, n in ips.items():
a = ipaddress.ip_address(ip)
(mine if any(a in net for net in ours) else others)[ip] += n
print("ในกลุ่มที่อนุญาตแล้ว:", len(mine), " นอกกลุ่ม:", len(others))
for ip, n in others.most_common(15):
print(f"{n:5d} {ip}")
ผลที่ได้ชัดมาก IP ของเครื่องที่ใช้ token ของเราโผล่แค่สอง pool /16 ที่อนุญาตไว้แล้วเท่านั้น ส่วน IP ของ ISP รายอื่นสองสามราย ที่ผมเคยคิดว่าต้องเพิ่ม พอไล่ดูว่าเรียกอะไรก็พบว่าเป็นตัวอัปเดต DDNS ของเว็บอื่นในบัญชีเดียวกัน ไม่ใช่เครื่องเรา ข้อสรุปเลยเป็นว่า ไม่ต้องเพิ่ม

เพิ่มทุกช่วงที่ดูใกล้เคียง
list ยาวขึ้นเรื่อย ๆ แต่ไม่รู้ว่าอันไหนจำเป็น และ token เปิดให้คนอื่นใน ISP เดียวกันใช้ได้มากขึ้นโดยไม่มีเหตุผล
เพิ่มเฉพาะที่มีหลักฐานว่าเป็นเรา
แยกได้ว่า IP ไหนเป็นเครื่องเรา IP ไหนเป็นของคนอื่นในบัญชีเดียวกัน list สั้นกว่าและมีที่มาอธิบายได้ทุกบรรทัด
แต่ audit log บอกได้แค่ว่า IP ไหนเป็นเครื่องเรา มันไม่ได้บอกว่าทำไม host-b โดนปฏิเสธ ผมเลยกลับมาถามใหม่ว่า request ของ host-b ออกไปด้วย address แบบไหนกันแน่ เพราะที่ผมเทียบมาทั้งหมดเป็น IPv4 ล้วน
สาเหตุจริง: host-b ออก Cloudflare ทาง IPv6
host-b ต่อ Cloudflare ผ่าน IPv6 ซึ่งเป็น prefix /44 ของ ISP เดียวกัน ขณะที่ allowlist ของ token มีแต่ IPv4 ล้วน ๆ เครื่องที่มีทั้งสองทางจะเลือกเองว่าจะใช้ทางไหนต่อโฮสต์ปลายทาง ผลคือ request ที่ออกไปในรูป IPv6 ไม่ตรงกับรายการไหนเลย และโดนปฏิเสธด้วย 9109
วิธีตรวจว่าเครื่องหนึ่งออกทางไหน ใช้ trace ของ Cloudflare เองได้ บังคับ -4 กับ -6 เทียบกันแล้วดูบรรทัด ip=
curl -4 -s https://www.cloudflare.com/cdn-cgi/trace | grep '^ip='
# ip=203.0.113.25
curl -6 -s https://www.cloudflare.com/cdn-cgi/trace | grep '^ip='
# ip=2001:db8:b930::25
# ปล่อยให้เครื่องเลือกเอง เหมือนที่โปรแกรมทั่วไปทำ
curl -s https://www.cloudflare.com/cdn-cgi/trace | grep '^ip='
ถ้าคำสั่งที่สามได้ค่าที่ขึ้นต้นด้วย 2001: หรือรูปแบบ IPv6 อื่น แปลว่าโปรแกรมของคุณก็ออกทาง IPv6 ด้วยเหมือนกัน และ allowlist ที่มีแต่ IPv4 จะรอวันพังอยู่แล้ว ในเคสของผม ตัวเลือกที่ผมนึกได้อีกทางคือบังคับให้ client ใช้ IPv4 อย่างเดียว แต่นั่นเป็นแค่ข้อเสนอ ผมไม่ได้ทำจริงในเคสนี้ เพราะการเติมช่วง IPv6 เข้า list ตรงไปตรงมากว่า และ host-b ไม่ต้องแก้อะไร

เติม IPv6 /32 แล้วค่า token ไม่ต้องเปลี่ยน
ที่ผมทำคือเติม IPv6 /32 ของกลุ่ม ISP อีก 4 ช่วงเข้า allowlist เดิม list กลายเป็น 53 รายการ ค่า token ตัวเดิมไม่เปลี่ยน ไม่ต้อง deploy ใหม่ ไม่ต้องแตะ host-b สักบรรทัด ส่วนที่แก้อยู่ในฟิลด์ condition ของ token เท่านั้น
{
"condition": {
"request_ip": {
"in": [
"203.0.113.0/24",
"198.51.100.0/24",
"2001:db8::/32"
],
"not_in": []
}
}
}
ตัวอย่างข้างบนย่อให้เหลือสามบรรทัด ของจริงมี IPv4 ครบ 49 รายการแล้วต่อด้วย IPv6 /32 อีก 4 ช่วง
ข้อที่ต้องบอกตรง ๆ คือ token อีกตัวของเรา ที่ใช้กับเครื่องอื่น มี 49 รายการ ล้วน IPv4 และผมยังไม่ได้แก้ เพราะเครื่องนั้นยังออก IPv4 อยู่ ถ้าวันไหนมันสลับไปออก IPv6 มันจะพังแบบเดียวกัน ตรงนี้เป็นบทเรียนที่ผมจดไว้ ไม่ใช่เหตุการณ์ที่เกิดขึ้นแล้ว
กับดักบน dashboard ที่เจอตอนแก้ policy
ตอนไปแก้ token ผมเจอสามเรื่องที่ไม่ได้อยู่ในคู่มือหน้าแรก ๆ
- แก้ token ผ่าน API ได้ ต้องใช้ token ที่มีสิทธิ์ Account API Tokens Write ถ้า token ที่ถืออยู่มีแค่ Read มันแก้ list ของตัวเองไม่ได้ ต้องไปแก้ในหน้า dashboard
- ตอนขยายสิทธิ์ให้ครบ policy หนึ่งใส่ permission ได้ไม่เกิน 300 ตัว ถ้าเกินจะได้ HTTP 400 ของผมต้องการเกือบ 400 กลุ่ม เลยต้องแบ่งเป็นหลาย policy
- policy "All zones" ที่มีสิทธิ์ระดับ account ปนอยู่ 5 ตัว (settings read, billing read และพวกเดียวกัน) แก้หรือแยกไม่ได้ จนกว่าจะย้ายสิทธิ์ระดับ account ไปไว้ใน policy ของมันเอง
ผมจัดสิทธิ์ใหม่จาก 5 policy เหลือ 2 policy จำนวนสิทธิ์รวมเท่าเดิมที่ 288 ตัว แล้วเทียบรายการทีละตัวว่าตรงกับก่อนจัด ข้อนี้สำคัญเพราะถ้าจัดแล้วสิทธิ์หายไปสักตัว มันจะไปโผล่เป็น 403 กับงานที่เคยรันได้ในวันถัดไป
อย่าวาง token เต็มชุดลงในแชต ในโน้ต หรือในบทความ ทุกค่าในบทความนี้เป็นตัวอย่าง และ allowlist เป็นเพียงชั้นเสริมไม่ใช่ตัวแทนของการเก็บ token ให้ดี ถ้าสงสัยว่ารั่ว ให้ roll token ก่อน แล้วค่อยมาไล่ดูทีหลัง
สรุปสิ่งที่ผมจะเช็กก่อนทุกครั้ง
ถ้า token ที่ล็อก IP แล้วโดน 9109 ผมจะไล่ตามนี้ ข้อแรกดูว่าเครื่องที่โดนออกทาง IPv4 หรือ IPv6 ด้วย curl -4 กับ -6 เพราะเป็นข้อที่ผมเดาไม่ถึงมานานที่สุด ข้อสองดูว่า allowlist ครอบทั้งสองตระกูลของ address หรือเปล่า ข้อสามก่อนจะเพิ่มช่วงของ ISP ไหนเข้าไป เอา audit log มาแยกก่อนว่าช่วงนั้นเป็นเครื่องเราจริงหรือเป็นของคนอื่นในบัญชีเดียวกัน ข้อสี่ถ้าต้องแก้ policy ให้ดูเพดาน 300 permission กับสิทธิ์ระดับ account ที่ปนอยู่ก่อนเริ่ม
ส่วนจะบังคับ client ให้ใช้ IPv4 อย่างเดียวหรือเติม IPv6 เข้า list ก็ตามสะดวกของแต่ละที่ครับ ผมเลือกเติมเพราะไม่ต้องไปแตะเครื่องที่ใช้งานอยู่ ตรงไหนที่ผมเข้าใจกลไกของ 9109 ผิดหรือคุณเจอเงื่อนไขอื่นที่ทำให้ได้ error เดียวกัน เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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