ไอพีออฟฟิศเปลี่ยนตลอด: ดันเข้า whitelist ด้วยคีย์ SSH forced command
คีย์ SSH ที่ล็อกให้ทำได้งานเดียว ไม่เชื่อคำสั่งจากฝั่ง client และไม่มี shell ให้คนถือ ทำให้ต่อให้คีย์หลุดก็ทำได้แค่เท่าที่ออกแบบไว้ ถ้าเขียน threat model ให้ชัดก่อนออกคีย์
สรุปใน 1 นาที
- อาการ/ปัญหา
- ไอพีขาออกที่อยู่หลัง CGNAT เปลี่ยนบ่อย โดนไฟร์วอลล์แบนซ้ำ
- ใครควรอ่าน
- แอดมินที่ต้องให้เครื่องข้างตัวดันไอพีเข้า whitelist อย่างปลอดภัย
- ทางแก้แบบสั้น
- ใช้คีย์ SSH ที่ล็อกคำสั่งเดียว อ่านไอพีจาก SSH_CLIENT และตรวจประเทศแบบ fail-closed พร้อมเขียน threat model
พอดีผมทำงานจากเน็ตไฟเบอร์ของผู้ให้บริการไทยรายหนึ่งที่อยู่หลัง CGNAT ไอพีที่ออกอินเทอร์เน็ตเลยเปลี่ยนเป็นช่วง ๆ บางทีเป็นวัน บางทีเปลี่ยนหลังรีบูตเราเตอร์ และมักไปตกคนละบล็อกกับของเดิม ผลคือไฟร์วอลล์ของเซิร์ฟเวอร์ (CSF กับ LFD) และตัวจับ brute force ของ DirectAdmin แบนไอพีใหม่ของผมซ้ำแล้วซ้ำอีก ทางเลี่ยงเดิมที่ผมใช้คือ whitelist ทั้งบล็อกของผู้ให้บริการ ซึ่งกว้างเกินไปและผมไม่สบายใจเลย บทความนี้เล่าวิธีที่ผมเปลี่ยนมาใช้แทน และเอาแนวเดียวกันไปใช้กับงานอีกอย่างที่ดูไม่เกี่ยวกัน
ทำไมสามวิธีที่นึกถึงก่อนถึงไม่พอ
ผมไล่ลองทีละทางก่อน และทุกทางมีจุดตกในที่ที่ผมเจอจริง
กว้างเกินไป
เปิดให้ทุกเครื่องในบล็อกของผู้ให้บริการผ่านไฟร์วอลล์ได้ รวมถึงลูกค้าคนอื่นของเขาที่ผมไม่รู้จัก ผมไม่ยอมแลกความสะดวกกับความเสี่ยงระดับนี้
พึ่งโฮสต์ภายนอก
ผมเคยตั้งชื่อ DDNS ให้ไฟร์วอลล์ resolve แล้ว วันหนึ่งโฮสต์ DDNS ที่ใช้หยุดตอบ ชื่อไม่ resolve ไอพีใหม่เลยไม่ถูกเพิ่มเงียบ ๆ
ทางที่สามคือล็อกคีย์ด้วย from= ใน authorized_keys ซึ่งใช้ไม่ได้ในกรณีนี้ เพราะ from= ต้องการต้นทางที่คงที่ แต่ไอพีต้นทางของผมคือสิ่งที่เปลี่ยนอยู่ตลอด ผมเคยเขียนเรื่องด้านกลับกันไว้แล้วใน บทความที่ from= ทำให้ backup ดึงไม่ได้เพราะไอพีต้นทางเปลี่ยน ตรงนั้นข้อจำกัดคือจุดอ่อน แต่ในบทความนี้ผมต้องออกแบบให้ไม่พึ่งต้นทางคงที่ โดยให้เซิร์ฟเวอร์เป็นฝ่ายอ่านไอพีเองจากการเชื่อมต่อจริง
ไอเดีย ให้เครื่องข้างตัวดันไอพีเข้ามาเอง
ที่ออฟฟิศมีพีซีเครื่องหนึ่งเปิดตลอด และออกเน็ตจากไอพีเดียวกับเครื่องที่ผมใช้ทำงาน ผมให้เครื่องนี้เป็นฝ่ายแจ้งเซิร์ฟเวอร์ว่าไอพีตอนนี้คืออะไร ตัวเฝ้าเป็นสคริปต์ Python ที่ใช้เฉพาะ standard library รันเป็น scheduled task ของ Windows ทุก 3 นาทีในนามระบบ เลยทำงานตั้งแต่เครื่องบูตโดยไม่ต้องมีใครล็อกอิน
- ถามไอพีจากบริการ IP echo สองแหล่งที่เป็นอิสระต่อกัน ยอมรับการเปลี่ยนเมื่อทั้งสองตอบตรงกันเท่านั้น ถ้าไม่ตรงก็ข้ามรอบนั้นและบันทึก log
- เก็บตารางช่วงเวลาของไอพีใน SQLite คือเห็นครั้งแรก เห็นครั้งหลังสุด ผู้ให้บริการ และ rDNS เลยมีประวัติให้ย้อนดู
- ดันเมื่อไอพีเปลี่ยน ดันซ้ำหลังจากที่เคยพลาด และดันทุก 6 ชั่วโมงเป็น heartbeat
- ส่งข้อความเข้าแชทเมื่อไอพีเปลี่ยน พร้อมผลของแต่ละเซิร์ฟเวอร์ และแจ้งเมื่อส่งพลาดหรือกลับมาปกติ
import urllib.request
SOURCES = ["https://echo-a.example.com/ip", "https://echo-b.example.net/ip"]
def current_ip():
seen = set()
for url in SOURCES:
try:
seen.add(urllib.request.urlopen(url, timeout=8).read().decode().strip())
except Exception:
return None # แหล่งไหนล่ม = ข้ามรอบนี้
return seen.pop() if len(seen) == 1 else None # สองแหล่งต้องตอบตรงกัน
ที่ผมบังคับให้สองแหล่งตอบตรงกัน เพราะบริการแบบนี้ตอบผิดได้หลายแบบ ล่มชั่วคราว ตอบที่อยู่ของพร็อกซีแทน หรือถูกแคชไว้ที่ชั้นไหนสักชั้น ถ้าเชื่อแหล่งเดียวแล้วดันไอพีผิดเข้า whitelist ผลคือเพิ่มไอพีที่ไม่ใช่ของผมเข้าไปในรายการอนุญาต ซึ่งแย่กว่าไม่ดันเลย การข้ามรอบเมื่อสองแหล่งขัดกันเลยเป็นทางที่ปลอดภัยกว่า เสียแค่รออีก 3 นาทีรอบถัดไป
ส่วนเหตุผลที่ดันซ้ำหลังพลาดและดัน heartbeat ทุก 6 ชั่วโมง เพราะการดันครั้งเดียวตอนไอพีเปลี่ยนอาจตกหล่นได้ เช่นเซิร์ฟเวอร์ปลายทางกำลังรีบูตในวินาทีนั้น ถ้าไม่มีการดันซ้ำ ผมจะรู้ตัวอีกทีตอนถูกแบนแล้ว heartbeat คือตาข่ายชั้นล่างที่ทำให้ระบบกลับมาถูกต้องเองภายในไม่กี่ชั่วโมง โดยที่สคริปต์ฝั่งเซิร์ฟเวอร์ตอบ OK same เมื่อไม่มีอะไรต้องเปลี่ยน เลยไม่ทำอะไรกับไฟร์วอลล์เลย
การดันคือ ssh เข้าเซิร์ฟเวอร์โดยไม่ส่งคำสั่งอะไรไปเลย คีย์ที่ใช้ถูกล็อกคำสั่งไว้ฝั่งเซิร์ฟเวอร์ ส่วนฝั่ง Windows ผมปักลายนิ้วมือของ host key ไว้ในคำสั่ง plink เพื่อไม่ต้องเชื่อมต่อกับเครื่องที่ปลอมตัวมา
& C:\tools\push\plink.exe -ssh -batch -noagent -P 22 -i C:\tools\push\push.ppk `
-hostkey "SHA256:<HOSTKEY_FINGERPRINT>" root@host-a
ฝั่งเซิร์ฟเวอร์ คีย์ที่ทำได้งานเดียว
บรรทัดใน authorized_keys ของ root เป็นหัวใจของเรื่องนี้ restrict ปิดทุกอย่างที่ SSH เปิดให้ได้ คือ pty, port forward, agent forward และ X11 ส่วน command= บังคับให้ทุกครั้งที่คีย์นี้เชื่อมต่อ จะรันสคริปต์ตัวเดียวนี้เท่านั้น ไม่ว่า client จะขอรันอะไรมา
restrict,command="/usr/local/sbin/office-ip-push" ssh-ed25519 AAAA...placeholder... ipwatch-push
สคริปต์ไม่อ่านอะไรจากฝั่ง client เลย มันเชื่อแค่ตัวแปร SSH_CLIENT ที่ sshd ใส่ให้จากการเชื่อมต่อจริง ซึ่ง client ปลอมไม่ได้ ข้อสองคือต้องเป็น IPv4 ข้อสามคือ geoiplookup ต้องตอบว่าประเทศคือไทย และถ้าไม่มี geoiplookup หรือรันพลาด ผมให้ถือว่าไม่ผ่าน เพราะผมเลือกปฏิเสธเมื่อไม่แน่ใจ (fail-closed) ซึ่งหมายความว่าทุกเครื่องปลายทางต้องลงแพ็กเกจ geoip-bin ไว้ก่อน
#!/bin/bash
# /usr/local/sbin/office-ip-push (forced command; ไม่ใช้อะไรจาก client)
PUSH_DIR=/var/lib/ip-allow-sync/push
LOG=/var/log/ip-allow-sync.log
log(){ echo "$(date '+%F %T') office-ip-push: $*" >> "$LOG"; }
IP="${SSH_CLIENT%% *}"
if ! printf '%s' "$IP" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
log "REJECT bad source"; echo "REJECT bad-source"; exit 1
fi
if ! geoiplookup "$IP" 2>/dev/null | grep -q 'Edition: TH,'; then
log "REJECT $IP not TH"; echo "REJECT not-TH"; exit 1 # ไม่มี geoiplookup = ปฏิเสธ
fi
mkdir -p "$PUSH_DIR"
OLD=$(cat "$PUSH_DIR/office" 2>/dev/null)
[ "$OLD" = "$IP" ] && { echo "OK same $IP"; exit 0; }
echo "$IP" > "$PUSH_DIR/office.tmp" && mv -f "$PUSH_DIR/office.tmp" "$PUSH_DIR/office"
log "office ${OLD:-none} -> $IP"
systemctl start ip-allow-sync.service
echo "OK set $IP"
ผมเขียนไฟล์สถานะแบบ tmp แล้ว mv ทับ เพื่อให้ไฟล์ไม่เคยอยู่ในสภาพเขียนครึ่งเดียวถ้าบริการอื่นอ่านพอดี และถ้าไอพีเหมือนที่เก็บไว้ สคริปต์ตอบ OK same แล้วจบเลยโดยไม่แตะไฟร์วอลล์ ซึ่งกันไม่ให้ heartbeat ทุก 6 ชั่วโมงไปรบกวนกฎโดยเปล่าประโยชน์

บริการที่ลงมือแก้ไฟร์วอลล์จริง
สคริปต์ข้างบนทำแค่บันทึกไอพีแล้วสั่งบริการ ip-allow-sync.service ให้ลงมือ แยกกันเพราะงานฝั่งนี้ต้องทำได้ซ้ำโดยไม่เสียหาย (idempotent) และใช้ร่วมกับเครื่องหลายเครื่องที่ไฟร์วอลล์ต่างกัน บริการนี้ทำตามลำดับว่า ลบบรรทัดของไอพีเก่าออกจาก csf.allow และ csf.ignore เพิ่มไอพีใหม่พร้อมหมายเหตุ รัน csf -dr กับ csf -tr กับไอพีใหม่เพื่อปลดแบนที่เกิดก่อนการ whitelist แล้วอัปเดตไฟล์ whitelist ของ DirectAdmin ซึ่งรับเฉพาะไอพีเปล่าไม่มีคอมเมนต์ และถ้าเครื่องไหนใช้ CrowdSec ก็ลบ decision ของทั้งไอพีเก่าและใหม่ทิ้ง
# ส่วนหลักของบริการ (ย่อ) ลบของเก่า เพิ่มของใหม่ ปลดแบน
for f in /etc/csf/csf.allow /etc/csf/csf.ignore; do
[ -n "$OLDIP" ] && sed -i -E "/^${OLDIP//./\\.}([[:space:]#]|\$)/d" "$f"
echo "${NEWIP} # pushed-ip auto" >> "$f"
done
csf -dr "$NEWIP" >/dev/null 2>&1 # ปลดแบนที่เกิดก่อน whitelist
csf -tr "$NEWIP" >/dev/null 2>&1
csf -ra # ไม่ใช่ -r : -ra ทำให้ lfd อ่าน csf.ignore ใหม่ด้วย
ข้อที่ผมต้องแก้ทีหลังคือตอนแรกผมใช้ csf -r ซึ่งโหลดกฎไฟร์วอลล์ใหม่ แต่ LFD ที่เป็นตัวแบนไม่ได้อ่านรายการ ignore ใหม่ตาม เลยมีโอกาสที่ไอพีใหม่จะยังโดนแบนทั้งที่อยู่ใน whitelist แล้ว พอเปลี่ยนเป็น csf -ra ก็ใช้ได้ อีกข้อคือบริการจะ whitelist ไอพีที่ดันมาได้ทีละหนึ่งไอพีต่อหนึ่งต้นทาง ตัวใหม่มาแทนตัวเก่า ไม่สะสม
ตัวอย่าง B คีย์เดียวกันแต่เอาไปคุมบอทโดยไม่ให้ shell
แนวเดียวกันผมเอาไปใช้กับงานอีกอย่าง มี VPS เล็กไม่กี่เครื่องที่รันตัวจับเวลาทุกนาทีเพื่อส่งเฉพาะการแจ้งเตือนที่แรงจริง ๆ ได้แก่ load, RAM, ดิสก์, บริการที่หยุด, การ ssh เข้าจากนอก whitelist, การเปลี่ยนคีย์ตรวจสอบ และรายงานตรวจความสมบูรณ์ประจำคืน บอทตัวนี้ส่งออกไปที่แชทของแอดมินคนเดียวและไม่อ่านข้อความอะไรกลับ ปัญหาแต่ละอย่างแจ้งหนึ่งครั้งตอนเกิด และอีกหนึ่งครั้งตอนหาย แนวคิดบอทแจ้งเตือนอยู่ใน บทความบอทเฝ้าเซิร์ฟเวอร์ ตรงนี้พูดแค่ส่วนเรื่องสิทธิ์
ปัญหาคือผมอยากสั่งปิดเสียงชั่วคราวก่อนทำงานตามแผน จากเครื่องอื่น โดยไม่ต้องให้เครื่องนั้นมี root คำตอบคือคีย์อีกดอกที่ล็อกคำสั่งเป็น alert-ctl และใช้ from= ด้วยเพราะเครื่องที่ควบคุมมีต้นทางคงที่
command="/usr/local/sbin/alert-ctl",restrict,from="203.0.113.5,198.51.100.0/24" ssh-ed25519 AAAA...placeholder... botctl
#!/bin/bash
# alert-ctl : สิ่งที่ client ขอจริงอยู่ใน SSH_ORIGINAL_COMMAND
read -r cmd arg <<<"${SSH_ORIGINAL_COMMAND:-help}"
case "$cmd" in
status) systemctl status alert-bot.timer --no-pager ;;
log) [[ "${arg:-50}" =~ ^[0-9]+$ ]] || arg=50
journalctl -u alert-bot.service -n "${arg:-50}" --no-pager ;;
test) systemctl start alert-bot-test.service ;;
run) systemctl start alert-bot.service ;;
mute) if [[ "$arg" =~ ^[0-9]+$ ]] && [ "$arg" -ge 1 ] && [ "$arg" -le 1440 ]; then
/usr/local/sbin/alert-mute "$arg"
else echo "mute <1-1440 minutes>"; fi ;;
unmute) /usr/local/sbin/alert-mute 0 ;;
*) echo "status | log N | test | run | mute <min, max 1440> | unmute" ;;
esac
คำสั่งที่รับมีแค่ status, log N, test, run, mute <นาที> ซึ่งสูงสุด 1440 นาที และ unmute อย่างอื่นพิมพ์วิธีใช้ออกมาเฉย ๆ เสียงที่ปิดไว้จะถูกส่งหลังหมดเวลา ไม่ได้หายไป ตัวบริการมีเวลาสูงสุด 30 วินาทีต่อคำสั่งและ 50 วินาทีต่อยูนิต และถ้าล้มเหลวจะส่งข้อความเตือนเอง สคริปต์ที่ใช้ปรับแก้ตัวมันเองต้องใช้คีย์ root อีกดอกหนึ่งที่ต่างกัน ไม่ใช่ดอกนี้ การเปลี่ยนผู้ใช้หรือคีย์บน VPS ตามแผนจะแจ้งเตือนโดยตั้งใจ
เขียน threat model ลงกระดาษก่อนออกคีย์
ผมเขียนตารางสั้น ๆ ทุกครั้งก่อนออกคีย์แบบนี้ โดยตอบสามอย่างคือ ถ้าคีย์หลุดคนร้ายได้อะไร ทำไมผมรับได้ และอะไรที่ผมไม่ได้รับประกัน
สำหรับคีย์ A คนร้ายที่ได้คีย์ไปทำได้อย่างเดียวคือ whitelist ไอพีไทยของตัวเอง ไม่ใช่ไอพีที่มันระบุเอง เพราะสคริปต์ไม่อ่านค่าจาก client และมันได้ shell ไม่ได้ ทำ port forward หรือ agent forward ไม่ได้ ใช้ tty ไม่ได้ สิ่งที่ผมต้องบอกตรง ๆ คือสิ่งที่ไม่ได้รับประกัน คนร้ายที่อยู่ในประเทศไทยยังเอาไอพีตัวเองเข้า whitelist ได้ และ whitelist ไม่ได้แปลว่าเข้าเครื่องได้ SSH ของเครื่องยังต้องมีคีย์หรือการยืนยันตัวตนของมันเองเสมอ ผมถือว่านี่คือความเสี่ยงที่รับได้ เพราะสิ่งที่คนร้ายได้คือการพ้นจากการถูกแบนด้วยไอพี ซึ่งเป็นแค่ด่านหนึ่งของหลายด่าน

ไอพีทุกตัวในบทความเป็นตัวอย่าง การออกแบบ whitelist และคีย์ที่ล็อกคำสั่งกระทบความปลอดภัยของเครื่องโดยตรง ก่อนทำจริงต้องประเมินว่าอะไรคือความเสียหายสูงสุดที่ยอมรับได้ในระบบของคุณเอง และให้ผู้ที่รับผิดชอบความปลอดภัยของเครื่องนั้นตรวจก่อนเปิดใช้
ข้อควรระวังตอนใช้งานจริง
- ทดสอบคีย์จากเครื่องที่ไม่ได้อยู่ใน whitelist เพราะถ้าทดสอบจากเครื่องที่ผ่านอยู่แล้ว จะไม่รู้เลยว่าสคริปต์ทำงานหรือแค่ไม่ถูกบล็อก
- เก็บสคริปต์ forced command ให้เล็กที่สุด อ่านจบได้ในหน้าจอเดียว และบันทึก log ทุกครั้งที่ถูกเรียก ทั้งผ่านและปฏิเสธ
- ลงแพ็กเกจที่สคริปต์พึ่งให้ครบทุกเครื่อง เช่น
geoip-binเพราะถ้าขาดจะปฏิเสธทุกคำขอ - ปักลายนิ้วมือ host key ฝั่ง client ไม่ใช้ค่าเดาเองครั้งแรก
- อย่าเอาชื่อบอท รหัสแชท หรือลายนิ้วมือของคีย์จริงไปลงเอกสารที่เผยแพร่
ถ้าคุณใช้ผู้ช่วย AI ทำงานกับ SSH อยู่ด้วย กติกาการกันพลาดฝั่งนั้นอยู่ใน บทความเรื่อง SSH ที่ปลอดภัยกับ Claude ซึ่งเสริมกับบทความนี้ตรงที่ว่าสิทธิ์ของคีย์ควรแคบที่สุดเท่าที่งานต้องการ
สรุปคือ ถ้างานของคุณคือต้องการให้เครื่องหนึ่งทำอย่างเดียวบนอีกเครื่อง ให้ออกคีย์ที่ทำได้อย่างเดียว ใส่ restrict กับ command= ให้สคริปต์เชื่อเฉพาะสิ่งที่ sshd บอก และตอบสามคำถามของ threat model ให้ได้ก่อนวางคีย์ ส่วนจะใช้ from= เสริมหรือไม่ขึ้นกับว่าต้นทางคงที่หรือเปล่า ตามสภาพของแต่ละที่ รายละเอียดบางจุดของบริการฝั่ง whitelist ผมยังไม่ได้เล่าครบ เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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