ทำ network-as-code บน MikroTik RouterOS
config ของ RouterOS ที่ฝังอยู่ในเครื่องเดียวแล้วแก้ทีก็คลิกทีละหน้าใน Winbox คือหนี้ทางเทคนิคที่รอวันสะสาง — ผมเลยย้ายมันออกมาเป็นเอกสาร + เทมเพลต + สคริปต์ ให้ generate ได้ทีละหลายไซต์และล้าง secret ก่อนแชร์ทุกครั้ง
สรุปใน 1 นาที
- อาการ/ปัญหา
- config RouterOS ที่ฝังในเครื่องและแก้ทีละหน้าใน Winbox ดูแลหลายไซต์ไม่ไหว
- ใครควรอ่าน
- ผู้ดูแลเครือข่ายที่ใช้ MikroTik หลายไซต์
- ทางแก้แบบสั้น
- ดึง /export เก็บเป็นไฟล์ ล้าง secret ด้วยสคริปต์ sanitize แยกเป็นหมวด แล้ว generate config หลายไซต์จากเทมเพลต Jinja2 กับ variables ชุดเดียว
พอดีผมดูแล MikroTik อยู่หลายไซต์ แล้วเบื่อกับการที่ config ทั้งก้อนไปฝังอยู่ในตัว router เครื่องเดียว จะแก้อะไรทีก็เปิด Winbox คลิกไล่ทีละหน้า พอมีหลายไซต์ที่ต้องตั้งเหมือนกันแต่ต่าง subnet ก็นั่งคลิกซ้ำๆ ทุกเครื่อง ทั้งช้า ทั้งพลาดง่ายกว่าที่คิดเยอะ ผมเลยย้ายวิธีคิดมาเป็น network-as-code — เก็บ config เป็นเอกสารในโฟลเดอร์ที่ผ่าน git ได้ มี template + สคริปต์ครบ แล้วค่อย generate ลงเครื่องจริงทีหลัง
ก่อนเข้าเรื่องขอบอกไว้ก่อน — IP, subnet, hostname ในบทความนี้ผม mock ทั้งหมด (192.168.10.x, 203.0.113.x, ชื่อ rtr-site-a) เพราะจุดขายของวิธีนี้คือ "ล้างของจริงทิ้งก่อนแชร์" ผมเลยทำกับตัวบทความเองด้วย โครงสร้างและสคริปต์เป็นของจริงหมด แต่ตัวเลขไม่ใช่
แนวคิด: เก็บ config เป็นเอกสาร ไม่ใช่ฝังในเครื่อง
หัวใจของเรื่องนี้คือย้าย "ความจริง" (source of truth) ของ config ออกจากตัว router มาไว้ในไฟล์ พอมันเป็นไฟล์แล้วมันได้ทุกอย่างที่โค้ดได้ — diff, review, rollback, generate ซ้ำ
ความจริงอยู่ในหัวคนเดียว
แก้ config ก็คลิกไล่หน้า ไม่มี history ว่าใครแก้อะไรเมื่อไหร่ พอมีหลายไซต์ที่ต้องตั้งเหมือนกันก็นั่งทำซ้ำ พลาดที่ subnet ไหนไม่รู้ตัว
ความจริงอยู่ในไฟล์ + git
config เป็นเอกสารที่ diff / review / rollback ได้ ต่างไซต์ต่างกันแค่ค่า variables เทมเพลตชุดเดียวใช้ซ้ำได้หมด และก่อนแชร์มีสคริปต์ล้าง secret ให้
ทั้ง flow มันเป็นสายพานที่ตายตัว — ดึงออกมา, ล้าง, แยกหมวด, เทมเพลต, แล้วเรนเดอร์กลับเป็น config ต่อไซต์:
/export — ดึง config ออกมาเป็นไฟล์
จุดเริ่มต้นคือ export config จากเครื่องจริงมาเก็บเป็นไฟล์ .rsc ก้อนเดียว RouterOS ทำให้ในบรรทัดเดียว:
[admin@rtr-site-a] > /export compact file=all-config-2026-07-16
[admin@rtr-site-a] > /export show-sensitive ;# ระวัง: อันนี้ไม่ซ่อนรหัสอะไรเลย
compact จะเอาเฉพาะค่าที่ต่างจาก default ออกมา อ่านง่ายกว่า verbose เยอะ ผมใช้ไฟล์นี้เป็นตัวตั้งต้นชื่อ all-config-sensitive.rsc แล้วอย่าเพิ่งเอาไปไหน เพราะขั้นต่อไปคือล้างมันก่อน
sanitize ก่อนแชร์ — sanitize-rsc.ps1
RouterOS v7 ซ่อนรหัสผ่านให้บางส่วน (hide-sensitive) แต่ IP จริง, hostname, ชื่อ pppoe user, comment ไม่ถูกซ่อน และถ้าใครเผลอสั่ง /export show-sensitive คือหลุดหมดทั้ง psk ทั้งรหัส เพราะงั้นห้ามเอาไฟล์ export ดิบขึ้น git หรือส่งให้ใครก่อนผ่าน sanitize เด็ดขาด
สคริปต์นี้คือตาข่ายกันพลาด — ไล่ mask ค่าที่ตามหลัง key อ่อนไหวทุกตัวให้เป็น token กลางๆ ก่อนเอาไฟล์ไปแชร์ ตรรกะจริงมันคือ regex lookbehind ตัวเดียว:
Param(
[Parameter(Mandatory=$true)] [string]$InputPath,
[Parameter(Mandatory=$true)] [string]$OutputPath
)
$lines = Get-Content -Path $InputPath -Raw
# คีย์ที่ต้อง mask + token ที่จะเอาไปแทน
$map = @(
@{ Key = 'password'; Replacement = '<SECRET>' },
@{ Key = 'pre-shared-key'; Replacement = '<SECRET>' },
@{ Key = 'psk'; Replacement = '<SECRET>' },
@{ Key = 'chap-secret'; Replacement = '<SECRET>' },
@{ Key = 'user'; Replacement = '<USERNAME>' }
)
foreach ($entry in $map) {
$k = [Regex]::Escape($entry.Key)
# แทนค่าที่ตามหลัง key= ไปจนเจอ space หรือ backslash
$pattern = "(?<=\b$k=)[^\s\\]+"
$lines = [Regex]::Replace($lines, $pattern, $entry.Replacement)
}
Set-Content -Path $OutputPath -Value $lines -NoNewline
Write-Host "Sanitized -> $OutputPath"
(?<=\b$k=)[^\s\\]+ แปลว่า "จับทุกอย่างที่ตามหลัง key= ไปจนกว่าจะเจอ space หรือ \" — จับเฉพาะ value ไม่แตะ key พอรันเสร็จบรรทัด set password=xxxxx ก็จะกลายเป็น set password=<SECRET> ในไฟล์ผลลัพธ์ ส่วนของจริงยังอยู่ในไฟล์ต้นทางที่ไม่ commit สั่งแบบนี้:
.\scripts\sanitize-rsc.ps1 -InputPath all-config-sensitive.rsc -OutputPath docs\all-config-sanitized.rsc
ตัวจริงของสคริปต์ผมใส่คีย์ครบกว่านี้ (passphrase, secret, private-key-password, cert-passphrase ฯลฯ) แต่แกนคือ list เดียว วนแทนทีละคีย์ ใครจะเพิ่มคีย์ก็ต่อ array ได้เลย
แยกหมวดด้วย split-rsc.ps1
ไฟล์ export ก้อนเดียวมันยาวเป็นพันบรรทัด review ยาก ผมเลยมีสคริปต์แยกมันตามบรรทัดหัวข้อที่ขึ้นต้นด้วย / (เช่น /ip firewall filter, /interface vlan) ให้กลายเป็นไฟล์ย่อยหมวดละไฟล์ พอแยกแล้ว diff ทีละหมวดได้ ใครแก้ firewall ก็เห็นแค่ไฟล์ firewall:
$lines = Get-Content -Path $InputPath
$currentPath = Join-Path $OutDir 'misc.rsc'
$buffer = New-Object System.Collections.Generic.List[string]
foreach ($line in $lines) {
if ($line -match '^/') { # เจอหัวข้อใหม่ เช่น /ip firewall filter
if ($buffer.Count -gt 0) {
Add-Content -Path $currentPath -Value ($buffer -join "`n")
$buffer.Clear() | Out-Null
}
$currentPath = New-SectionFileName $line # /ip firewall filter -> ip_firewall_filter.rsc
Set-Content -Path $currentPath -Value $line
} else {
$buffer.Add($line) | Out-Null
}
}
New-SectionFileName แค่เอาบรรทัดหัวข้อมา lower-case แล้วแทน space กับ / ด้วย _ ได้ชื่อไฟล์สะอาดๆ ต่อหมวด สั่ง:
.\scripts\split-rsc.ps1 -InputPath all-config-sensitive.rsc -OutDir configs\split
ดึงพอร์ต firewall ออกมาเป็นเอกสาร — extract-fw-ports.ps1
อีกงานที่ผมทำบ่อยคือ "สรุปว่าเครื่องนี้เปิดพอร์ตอะไรออกเน็ตบ้าง" — ต้องไปไล่อ่าน /ip firewall filter กับ /ip firewall nat เอง เสียเวลาและตกหล่นง่าย ผมเลยเขียนตัว parse ที่ดึง rule ออกมาเป็น field แล้วสรุปเป็น markdown ให้:
function Get-Field($line, $name) {
$m = [regex]::Match($line, "\b" + [regex]::Escape($name) + "=([^\s]+)")
if ($m.Success) { return $m.Groups[1].Value } else { return $null }
}
foreach ($l in $filterAdds) {
if ($l -match 'disabled=yes') { continue } # ข้าม rule ที่ปิดไว้
$chain = Get-Field $l 'chain'
$action = Get-Field $l 'action'
$proto = Get-Field $l 'protocol'
$dport = Get-Field $l 'dst-port'
if ($chain -eq 'input' -and $dport) {
if (-not $action) { $action = 'accept' } # RouterOS มักละ action=accept ที่เป็น default
if ($action -eq 'accept') { $result.Filter_Input_Accept += "$proto/$dport" }
elseif ($action -eq 'drop') { $result.Filter_Input_Drop += "$proto/$dport" }
}
}
จุดที่ต้องระวังคือ RouterOS ชอบละ action=accept ไว้ (เพราะเป็น default) สคริปต์ก็เลยต้องเดา default ให้ ไม่งั้น rule ที่เปิดพอร์ตไว้จะหายไปจากสรุปเงียบๆ ตัวเต็มมันไล่ต่อไปถึง dstnat เพื่อสรุป port-forward ด้วย เช่นบนไซต์นี้มี NVR ที่เปิด 8080 ออกข้างนอกอยู่ตัวเดียว — พอมีเอกสารสรุปแบบนี้ ผมเช็คได้ทันทีว่าไม่มีพอร์ตแปลกปลอมโผล่มาเกินที่ตั้งใจ
เทมเพลต + variables: หนึ่งชุด generate ได้หลายไซต์
พอมี config สะอาดแล้ว ขั้นต่อไปคือแยก "โครง" ออกจาก "ค่า" โครงไปอยู่ในเทมเพลต Jinja2 ส่วนค่าเฉพาะไซต์ (IP, VLAN, ชื่อเครื่อง) ไปอยู่ในไฟล์ values ต่างหาก ตัวเทมเพลตแยกเป็นไฟล์ย่อยตามหมวดเหมือนตอน split — 00-system, 03-vlan, 04-ipaddress, 06-firewall ไปจนถึง 99-commit:
# design/templates/routeros/03-vlan.rsc.j2
/interface vlan
{% for v in vlans.interfaces %}
add interface={{ v.parent | default('bridge1') }} name={{ v.name }} vlan-id={{ v.id }}
{% endfor %}
# design/templates/routeros/04-ipaddress.rsc.j2
/ip address
{% for a in addressing.addresses %}
add address={{ a.cidr }} interface={{ a.if }} network={{ a.network }}
{% endfor %}
ค่าจริงต่อไซต์อยู่ในไฟล์เดียว แก้ที่นี่ที่เดียว:
# design/values/site-a.yaml — ค่าจริงต่อไซต์ (อย่า commit ของจริงลง repo กลาง)
device:
identity: rtr-site-a
timezone: Asia/Bangkok
addressing:
addresses:
- { cidr: 192.168.10.1/24, if: bridge1, network: 192.168.10.0 }
vlans:
interfaces:
- { name: vlan101, id: 101, parent: bridge1 } # ที่ไซต์ผม VLAN = เลขห้อง
- { name: vlan102, id: 102, parent: bridge1 }
หัวใจของ network-as-code คือ เทมเพลตต้องไม่มีค่าจริง ค่าเฉพาะไซต์ทั้งหมดอยู่ใน values YAML ที่ไม่ commit ขึ้น repo กลาง เวลาเปิดไซต์ใหม่ก็ก๊อป values ไฟล์เดียวมาแก้เลข ไม่ต้องแตะเทมเพลตเลย
generate ด้วย Python + Jinja2
ตัวเชื่อมระหว่างเทมเพลตกับ values คือสคริปต์ Python สั้นๆ ตัวเดียว — โหลด values YAML, วนเรนเดอร์ทุกเทมเพลตตามลำดับเลขหน้า แล้วต่อกันเป็น .rsc หนึ่งไฟล์ต่อไซต์:
#!/usr/bin/env python3
# tools/render-site.py — เรนเดอร์ template .j2 + values ของไซต์ -> configs/target/<site>.rsc
import sys, pathlib, yaml
from jinja2 import Environment, FileSystemLoader
TPL_DIR = pathlib.Path("design/templates/routeros")
OUT_DIR = pathlib.Path("configs/target")
def render(site_yaml):
values = yaml.safe_load(pathlib.Path(site_yaml).read_text(encoding="utf-8"))
env = Environment(loader=FileSystemLoader(TPL_DIR),
trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True)
parts = []
for tpl in sorted(TPL_DIR.glob("*.rsc.j2")): # 00-system, 01-interfaces, ... 99-commit
parts.append(env.get_template(tpl.name).render(**values))
OUT_DIR.mkdir(parents=True, exist_ok=True)
out = OUT_DIR / f"{values['device']['identity']}.rsc"
out.write_text("\n".join(parts), encoding="utf-8")
print(f"[OK] {site_yaml} -> {out}")
if __name__ == "__main__":
for arg in sys.argv[1:]:
render(arg)
รันทีเดียวได้หลายไซต์ python tools/render-site.py design/values/site-a.yaml design/values/site-b.yaml แล้วได้ config พร้อม import ออกมา:
# configs/target/rtr-site-a.rsc (เรนเดอร์แล้ว พร้อม /import)
/interface vlan
add interface=bridge1 name=vlan101 vlan-id=101
add interface=bridge1 name=vlan102 vlan-id=102
/ip address
add address=192.168.10.1/24 interface=bridge1 network=192.168.10.0
เอาไฟล์นี้ขึ้นเครื่องแล้ว /import file=rtr-site-a.rsc — แต่ผมย้ำว่าอย่าเพิ่ง import ทับ production ตรงๆ ให้ diff กับ config ปัจจุบันก่อน แล้วลองในเครื่องเปล่า/แล็บก่อนเสมอ ทั้ง pipeline สรุปเป็นห้าขั้นนี้:
- ดึง config ออกจากเครื่องด้วย /export เก็บเป็นไฟล์ .rsc
- ล้าง secret ด้วย sanitize-rsc.ps1 ก่อนเอาเข้า git หรือส่งให้ใคร
- แยกหมวดด้วย split-rsc.ps1 ให้ diff และ review ทีละส่วนได้
- ยกค่าเฉพาะไซต์ไปไว้ใน values YAML แล้วเหลือแต่โครงในเทมเพลต Jinja2
- เรนเดอร์ template + values เป็น .rsc ต่อไซต์ แล้ว diff ก่อน /import ลงเครื่อง
สรุปแนวทางที่ผมใช้
ส่วนตัวผมมองว่าพอ config กลายเป็นไฟล์แล้ว มันได้ทุกอย่างที่โค้ดได้ — diff เห็นว่าใครแก้บรรทัดไหน, rollback ได้, review ก่อน push ได้, เปิดไซต์ใหม่เหลือแค่ก๊อป values มาแก้เลขไม่กี่ตัว งานที่เคยคลิกครึ่งวันเหลือรันสคริปต์ไม่กี่วินาที
สคริปต์ทั้งสามตัว (sanitize / split / extract) ผมเขียนเป็น PowerShell เพราะเครื่องที่ผมทำงานเป็น Windows แต่ตรรกะมันคือ regex ล้วนๆ ใครถนัด bash หรือ Python ก็ยกไปเขียนใหม่ได้ตามสะดวก แกนของเรื่องไม่ใช่ภาษาที่ใช้ แต่คือวินัยอย่างเดียว — ห้ามให้ของจริงหลุดออกไปก่อนล้าง เท่านั้น อันนี้ไม่มีข้อยกเว้นหละ
ใครจะเอาโครงนี้ไปปรับใช้ ตัวเลข VLAN subnet tier ก็แก้ตามหน้างานได้เลย ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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