manage_ipset.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. #!/usr/bin/env python3
  2. # Copyright (C) 2026 Paul Klumpp
  3. # This program is free software: you can redistribute it and/or modify
  4. # it under the terms of the GNU General Public License as published by
  5. # the Free Software Foundation, either version 3 of the License.
  6. """Keep Proxmox cluster firewall IPSets in sync with DNS.
  7. Hostnames are resolved repeatedly (A and AAAA) until no new IP
  8. addresses show up, so that all IPs behind a round-robin or anycast
  9. DNS record are collected. Afterwards the IPSet is synchronized: IPs
  10. that are gone are deleted, new IPs are added. Only the difference is
  11. written -- no flush-and-refill.
  12. Usage:
  13. manage_ipset.py # sync all IPSets from the config
  14. manage_ipset.py -c /path/to.conf # ... from a specific config
  15. manage_ipset.py <ipset> <host> [host...] # sync a single IPSet
  16. manage_ipset.py github github.com --round 30
  17. Config file format (default: manage_ipset.conf next to this script):
  18. [defaults]
  19. round=50
  20. stale_after=10
  21. # dns_server=1.1.1.1 # optional, default: system resolver
  22. # timeout=15
  23. golang
  24. host=go.dev
  25. github
  26. host=github.com
  27. host=github.io round=10 stale_after=3
  28. An IPSet may hold several hosts; their IPs are merged into one set.
  29. Per-host options: round, stale_after, dns_server, timeout.
  30. An IPSet is left untouched if any of its hosts fails to resolve, so a
  31. single broken hostname cannot strip the IPs of its siblings.
  32. Requires: python3 (stdlib only), pvesh (pve-manager), dig (dnsutils).
  33. """
  34. import argparse
  35. import ipaddress
  36. import json
  37. import shutil
  38. import subprocess
  39. import sys
  40. from datetime import datetime
  41. from pathlib import Path
  42. DEFAULTS = {"round": 50, "stale_after": 10, "timeout": 15}
  43. def check_dependencies():
  44. for cmd, pkg in (("pvesh", "pve-manager"), ("dig", "dnsutils")):
  45. if shutil.which(cmd) is None:
  46. print(f"ERROR: missing dependency: {cmd} ({pkg})")
  47. sys.exit(1)
  48. def dig(*args, timeout=15):
  49. """Run dig, return stdout. Raises OSError if dig is missing."""
  50. result = subprocess.run(["dig", *args],
  51. capture_output=True, text=True, check=False,
  52. timeout=timeout)
  53. return result.stdout
  54. def extract_ips(lines):
  55. """Return the valid IP addresses from dig +short output.
  56. dig follows CNAME chains and returns the final A/AAAA records, but
  57. it also echoes the intermediate CNAMEs into the output. Anything
  58. that is not a valid IP address (CNAMEs, TXT, noise) is dropped."""
  59. ips = set()
  60. for line in lines.splitlines():
  61. line = line.strip()
  62. if not line or line.startswith(";"):
  63. continue
  64. try:
  65. ips.add(str(ipaddress.ip_address(line)))
  66. except ValueError:
  67. continue
  68. return ips
  69. def pvesh(*args):
  70. """Run pvesh, return stdout as text. None on error."""
  71. try:
  72. result = subprocess.run(["pvesh", *args],
  73. capture_output=True, text=True)
  74. except OSError:
  75. return None
  76. if result.returncode != 0:
  77. return None
  78. return result.stdout
  79. def pvesh_ok(*args):
  80. return pvesh(*args) is not None
  81. def resolve_host(host, max_rounds=50, stale_after=10, timeout=15,
  82. dns_server=None):
  83. """Resolve A and AAAA records of host, collecting new IPs until
  84. no new one appears for stale_after rounds (up to max_rounds).
  85. dns_server: optional address to pass to dig as @server."""
  86. collected = set()
  87. stale = 0
  88. server_args = [f"@{dns_server}"] if dns_server else []
  89. for _ in range(max_rounds):
  90. ips = set()
  91. for qtype in ("A", "AAAA"):
  92. try:
  93. out = dig("+short", *server_args, host, qtype,
  94. timeout=timeout)
  95. except (OSError, subprocess.TimeoutExpired):
  96. out = ""
  97. ips |= extract_ips(out)
  98. new = ips - collected
  99. if new:
  100. collected |= new
  101. stale = 0
  102. else:
  103. stale += 1
  104. if stale >= stale_after:
  105. break
  106. return collected
  107. def parse_cidrs(payload):
  108. """Extract plain IP addresses from a pvesh JSON payload.
  109. Handles both the nested data format ({"data": [...CIDR...]})
  110. and a bare list of CIDR strings."""
  111. if isinstance(payload, dict) and "data" in payload:
  112. entries = payload["data"]
  113. elif isinstance(payload, list):
  114. entries = payload
  115. else:
  116. entries = []
  117. ips = set()
  118. for entry in entries:
  119. cidr = entry if isinstance(entry, str) else entry.get("cidr", "")
  120. ips.add(cidr.split("/")[0])
  121. return ips
  122. def ensure_ipset(name, comment=None):
  123. if comment is None:
  124. comment = f"auto-generated {datetime.now().astimezone().isoformat()}"
  125. if not pvesh_ok("get", f"/cluster/firewall/ipset/{name}"):
  126. print(f"creating IPSet {name}")
  127. pvesh("create", "/cluster/firewall/ipset", "--name", name,
  128. "--comment", comment)
  129. return
  130. # existing: refresh the comment only if it differs
  131. out = pvesh("get", f"/cluster/firewall/ipset/{name}",
  132. "--output-format", "json")
  133. current_comment = ""
  134. if out:
  135. try:
  136. data = json.loads(out)
  137. if isinstance(data, dict) and "data" in data:
  138. current_comment = data["data"].get("comment", "")
  139. elif isinstance(data, dict):
  140. current_comment = data.get("comment", "")
  141. except (json.JSONDecodeError, AttributeError):
  142. pass
  143. if current_comment != comment:
  144. pvesh("set", f"/cluster/firewall/ipset/{name}", "--comment", comment)
  145. def sync_ipset(name, ips):
  146. payload = pvesh("get", f"/cluster/firewall/ipset/{name}",
  147. "--output-format", "json")
  148. current = set()
  149. if payload:
  150. try:
  151. current = parse_cidrs(json.loads(payload))
  152. except json.JSONDecodeError:
  153. current = set()
  154. existing, new = current - ips, ips - current
  155. for ip in sorted(new):
  156. print(f"ADD: {ip}")
  157. pvesh("create", f"/cluster/firewall/ipset/{name}", "--cidr", ip)
  158. for ip in sorted(existing):
  159. print(f"DEL: {ip}")
  160. pvesh("delete", f"/cluster/firewall/ipset/{name}/{ip}")
  161. def sync_group(name, entries):
  162. """Sync one IPSet from its host entries.
  163. Returns True if the IPSet was synced, False if it was skipped
  164. because one or more hosts did not resolve."""
  165. hosts = ", ".join(e["host"] for e in entries)
  166. print(f"== {name}: {hosts}")
  167. all_ips = set()
  168. unresolved = []
  169. for entry in entries:
  170. found = resolve_host(entry["host"],
  171. max_rounds=entry["round"],
  172. stale_after=entry["stale_after"],
  173. timeout=entry["timeout"],
  174. dns_server=entry.get("dns_server") or None)
  175. if not found:
  176. unresolved.append(entry["host"])
  177. all_ips |= found
  178. if unresolved:
  179. # fail-safe: never let one broken hostname strip its siblings
  180. print(f"WARNING: no DNS results for {', '.join(unresolved)}, "
  181. f"skipping {name}")
  182. return False
  183. ensure_ipset(name)
  184. sync_ipset(name, all_ips)
  185. return True
  186. def parse_config(text):
  187. """Parse the config.
  188. Returns (ipsets, defaults) where ipsets maps ipset name -> list of
  189. host entries (dicts with host, round, stale_after, dns_server,
  190. timeout) and defaults is the [defaults] section.
  191. The [defaults] section is terminated by the first line that is not
  192. a key=value pair (i.e. an IPSet name)."""
  193. defaults = dict(DEFAULTS)
  194. ipsets = {}
  195. current = None
  196. section = None
  197. for raw in text.splitlines():
  198. line = raw.split("#", 1)[0].strip()
  199. if not line:
  200. continue
  201. if line.startswith("[") and line.endswith("]"):
  202. section = line[1:-1]
  203. current = None
  204. continue
  205. if section == "defaults":
  206. if "=" not in line:
  207. # bare line ends the [defaults] section and starts an IPSet
  208. section = None
  209. else:
  210. key, _, value = line.partition("=")
  211. key = key.strip()
  212. value = value.strip()
  213. if value:
  214. if key in ("round", "stale_after", "timeout"):
  215. defaults[key] = int(value)
  216. else:
  217. defaults[key] = value
  218. continue
  219. if "=" in line:
  220. entry = _kv(line)
  221. if current is None:
  222. continue
  223. entry["round"] = int(entry.get("round", defaults["round"]))
  224. entry["stale_after"] = int(entry.get("stale_after",
  225. defaults["stale_after"]))
  226. entry["timeout"] = int(entry.get("timeout",
  227. defaults["timeout"]))
  228. entry.setdefault("dns_server", defaults.get("dns_server", ""))
  229. current.append(entry)
  230. else:
  231. current = ipsets.setdefault(line, [])
  232. return ipsets, defaults
  233. def _kv(line):
  234. """'host=go.dev round=10' -> {host: 'go.dev', round: '10'}"""
  235. entry = {}
  236. for token in line.split():
  237. if "=" in token:
  238. key, value = token.split("=", 1)
  239. entry[key] = value
  240. return entry
  241. def default_config_path():
  242. return Path(__file__).parent / "manage_ipset.conf"
  243. def main(argv=None):
  244. parser = argparse.ArgumentParser(
  245. description="Keep Proxmox firewall IPSets in sync with DNS.")
  246. parser.add_argument("-c", "--config",
  247. help=f"config file (default: {default_config_path()})")
  248. parser.add_argument("ipset", nargs="?", help="IPSet name (single mode)")
  249. parser.add_argument("hosts", nargs="*", help="hostnames (single mode)")
  250. parser.add_argument("--round", type=int, default=DEFAULTS["round"],
  251. help="max. DNS query rounds per host")
  252. parser.add_argument("--stale-after", type=int,
  253. default=DEFAULTS["stale_after"],
  254. help="rounds without a new IP before stopping")
  255. parser.add_argument("--timeout", type=int, default=DEFAULTS["timeout"],
  256. help="dig timeout in seconds")
  257. parser.add_argument("--dns-server",
  258. help="resolver to query (default: system resolver)")
  259. args = parser.parse_args(argv)
  260. check_dependencies()
  261. if args.ipset:
  262. if not args.hosts:
  263. parser.error("hostname(s) required when an IPSet is given")
  264. entries = [{"host": host,
  265. "round": args.round,
  266. "stale_after": args.stale_after,
  267. "timeout": args.timeout,
  268. "dns_server": args.dns_server or ""}
  269. for host in args.hosts]
  270. sys.exit(0 if sync_group(args.ipset, entries) else 1)
  271. path = Path(args.config) if args.config else default_config_path()
  272. try:
  273. text = path.read_text()
  274. except OSError as err:
  275. print(f"ERROR: cannot read {path}: {err}")
  276. sys.exit(1)
  277. ipsets, _defaults = parse_config(text)
  278. if not ipsets:
  279. print(f"ERROR: no IPSets found in {path}")
  280. sys.exit(1)
  281. failed = [name for name, entries in ipsets.items()
  282. if entries and not sync_group(name, entries)]
  283. if failed:
  284. print(f"FAILED: {', '.join(failed)}")
  285. sys.exit(1)
  286. if __name__ == "__main__":
  287. main()