dnsmasq_sync.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. #!/usr/bin/env python3
  2. """
  3. dnsmasq_sync.py — Génère une configuration dnsmasq (dhcp-host) à partir de phpIPAM.
  4. Usage:
  5. python dnsmasq_sync.py [--config config.yaml] [--dry-run]
  6. Prérequis :
  7. - phpIPAM avec API activée (Administration > phpIPAM API)
  8. - App_security = "Token" (ou "None" sans token requis)
  9. - Droits lecture sur les adresses / sous-réseaux
  10. """
  11. import argparse
  12. import ipaddress
  13. import logging
  14. import sys
  15. from datetime import datetime
  16. from pathlib import Path
  17. import requests
  18. import yaml
  19. logging.basicConfig(
  20. level=logging.INFO,
  21. format="%(asctime)s [%(levelname)s] %(message)s",
  22. datefmt="%Y-%m-%d %H:%M:%S",
  23. )
  24. log = logging.getLogger(__name__)
  25. # ---------------------------------------------------------------------------
  26. # Config
  27. # ---------------------------------------------------------------------------
  28. def load_config(path: str) -> dict:
  29. config_path = Path(path)
  30. if not config_path.exists():
  31. log.error("Fichier de configuration introuvable : %s", path)
  32. sys.exit(1)
  33. with config_path.open() as fh:
  34. cfg = yaml.safe_load(fh)
  35. _validate_config(cfg)
  36. return cfg
  37. def _validate_config(cfg: dict) -> None:
  38. required = {
  39. "phpipam": ["url", "app_id"],
  40. "dnsmasq": ["output_file"],
  41. }
  42. for section, keys in required.items():
  43. if section not in cfg:
  44. log.error("Section '%s' manquante dans la configuration.", section)
  45. sys.exit(1)
  46. for key in keys:
  47. if key not in cfg[section]:
  48. log.error("Clé '%s.%s' manquante dans la configuration.", section, key)
  49. sys.exit(1)
  50. # ---------------------------------------------------------------------------
  51. # phpIPAM API client
  52. # ---------------------------------------------------------------------------
  53. class PhpIpamClient:
  54. def __init__(self, url: str, app_id: str, token: str | None, verify_ssl: bool = True):
  55. self.base_url = url.rstrip("/")
  56. self.app_id = app_id
  57. self.session = requests.Session()
  58. self.session.verify = verify_ssl
  59. if token:
  60. # phpIPAM attend le token dans l'en-tête "token"
  61. self.session.headers.update({"token": token})
  62. def _get(self, endpoint: str, fatal: bool = False) -> list | dict:
  63. """
  64. Effectue un GET sur l'API phpIPAM.
  65. Si fatal=True, une erreur réseau/HTTP arrête le script (pour les appels critiques).
  66. Sinon, l'erreur est loggée en warning et une liste vide est retournée.
  67. """
  68. url = f"{self.base_url}/api/{self.app_id}/{endpoint.lstrip('/')}"
  69. try:
  70. resp = self.session.get(url, timeout=30)
  71. resp.raise_for_status()
  72. except requests.exceptions.SSLError:
  73. msg = (
  74. "Erreur SSL sur %s. Si vous utilisez un certificat auto-signé, "
  75. "passez verify_ssl: false dans la config."
  76. )
  77. if fatal:
  78. log.error(msg, url)
  79. sys.exit(1)
  80. log.warning(msg, url)
  81. return []
  82. except requests.exceptions.ConnectionError as exc:
  83. msg = "Impossible de joindre phpIPAM (%s) : %s"
  84. if fatal:
  85. log.error(msg, url, exc)
  86. sys.exit(1)
  87. log.warning(msg, url, exc)
  88. return []
  89. except requests.exceptions.HTTPError as exc:
  90. msg = "Erreur HTTP %s sur %s : %s"
  91. if fatal:
  92. log.error(msg, resp.status_code, url, exc)
  93. sys.exit(1)
  94. log.warning(msg, resp.status_code, url, exc)
  95. return []
  96. data = resp.json()
  97. if not data.get("success"):
  98. # 404 signifie simplement "aucun résultat"
  99. if data.get("code") == 404:
  100. return []
  101. log.warning("Réponse phpIPAM non-success pour %s : %s", endpoint, data.get("message"))
  102. return []
  103. return data.get("data") or []
  104. def get_sections(self) -> list[dict]:
  105. # Les sections sont indispensables : échec fatal
  106. return self._get("sections/", fatal=True)
  107. def get_subnets_for_section(self, section_id: int) -> list[dict]:
  108. return self._get(f"sections/{section_id}/subnets/")
  109. def get_addresses_for_subnet(self, subnet_id: int) -> list[dict]:
  110. return self._get(f"subnets/{subnet_id}/addresses/")
  111. # ---------------------------------------------------------------------------
  112. # Collecte des hôtes avec MAC
  113. # ---------------------------------------------------------------------------
  114. def collect_hosts(client: PhpIpamClient) -> list[dict]:
  115. """
  116. Parcourt toutes les sections → sous-réseaux → adresses
  117. et retourne les hôtes ayant une adresse MAC définie.
  118. """
  119. hosts = []
  120. seen_ids: set[str] = set()
  121. sections = client.get_sections()
  122. log.info("%d section(s) trouvée(s).", len(sections))
  123. for section in sections:
  124. section_id = int(section["id"])
  125. section_name = section.get("name", f"section-{section_id}")
  126. subnets = client.get_subnets_for_section(section_id)
  127. log.info(" Section '%s' : %d sous-réseau(x).", section_name, len(subnets))
  128. for subnet in subnets:
  129. subnet_id = int(subnet["id"])
  130. addresses = client.get_addresses_for_subnet(subnet_id)
  131. for addr in addresses:
  132. addr_id = str(addr.get("id", ""))
  133. mac = _normalize_mac(addr.get("mac", ""))
  134. ip = addr.get("ip", "").strip()
  135. # Ignorer si pas de MAC, pas d'IP, ou déjà traité
  136. if not mac or not ip or addr_id in seen_ids:
  137. continue
  138. # Valider l'IP
  139. try:
  140. ipaddress.ip_address(ip)
  141. except ValueError:
  142. log.warning("IP invalide ignorée : %s (id=%s)", ip, addr_id)
  143. continue
  144. seen_ids.add(addr_id)
  145. hosts.append({
  146. "mac": mac,
  147. "ip": ip,
  148. "hostname": _sanitize_hostname(addr.get("hostname") or addr.get("description") or ""),
  149. })
  150. return hosts
  151. def _normalize_mac(raw: str) -> str:
  152. """Normalise une adresse MAC en xx:xx:xx:xx:xx:xx minuscule."""
  153. if not raw:
  154. return ""
  155. mac = raw.strip().lower().replace("-", ":").replace(".", ":")
  156. # Supprimer les séparateurs puis reformater
  157. digits = mac.replace(":", "")
  158. if len(digits) != 12 or not all(c in "0123456789abcdef" for c in digits):
  159. log.warning("MAC ignorée (format invalide) : %s", raw)
  160. return ""
  161. return ":".join(digits[i:i+2] for i in range(0, 12, 2))
  162. def _sanitize_hostname(name: str) -> str:
  163. """Retourne un hostname valide ou une chaîne vide."""
  164. if not name:
  165. return ""
  166. # Remplacer les espaces et caractères non-ASCII par '-'
  167. sanitized = "".join(c if c.isalnum() or c in "-." else "-" for c in name.strip())
  168. # dnsmasq n'accepte pas les noms commençant/finissant par '-'
  169. return sanitized.strip("-")
  170. # ---------------------------------------------------------------------------
  171. # Génération du fichier dnsmasq
  172. # ---------------------------------------------------------------------------
  173. def generate_config(hosts: list[dict], header_comment: str) -> str:
  174. lines = [
  175. f"# {header_comment}",
  176. f"# Généré le : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
  177. f"# Entrées : {len(hosts)}",
  178. "",
  179. ]
  180. for host in sorted(hosts, key=lambda h: tuple(int(x) for x in h["ip"].split("."))):
  181. if host["hostname"]:
  182. line = f"dhcp-host={host['mac']},{host['ip']},{host['hostname']}"
  183. else:
  184. line = f"dhcp-host={host['mac']},{host['ip']}"
  185. lines.append(line)
  186. lines.append("")
  187. return "\n".join(lines)
  188. def write_config(content: str, output_file: str, dry_run: bool) -> None:
  189. if dry_run:
  190. print("=== DRY-RUN — contenu qui serait écrit dans", output_file, "===")
  191. print(content)
  192. return
  193. out = Path(output_file)
  194. out.parent.mkdir(parents=True, exist_ok=True)
  195. out.write_text(content, encoding="utf-8")
  196. log.info("Fichier écrit : %s", output_file)
  197. # ---------------------------------------------------------------------------
  198. # Point d'entrée
  199. # ---------------------------------------------------------------------------
  200. def parse_args() -> argparse.Namespace:
  201. parser = argparse.ArgumentParser(
  202. description="Synchronise phpIPAM → configuration dnsmasq (dhcp-host)."
  203. )
  204. parser.add_argument(
  205. "--config", default="config.yaml",
  206. help="Chemin vers le fichier de configuration YAML (défaut: config.yaml)"
  207. )
  208. parser.add_argument(
  209. "--dry-run", action="store_true",
  210. help="Affiche la config générée sans écrire le fichier"
  211. )
  212. parser.add_argument(
  213. "--debug", action="store_true",
  214. help="Active les logs de niveau DEBUG"
  215. )
  216. return parser.parse_args()
  217. def main() -> None:
  218. args = parse_args()
  219. if args.debug:
  220. logging.getLogger().setLevel(logging.DEBUG)
  221. cfg = load_config(args.config)
  222. phpipam_cfg = cfg["phpipam"]
  223. dnsmasq_cfg = cfg["dnsmasq"]
  224. client = PhpIpamClient(
  225. url=phpipam_cfg["url"],
  226. app_id=phpipam_cfg["app_id"],
  227. token=phpipam_cfg.get("token"),
  228. verify_ssl=phpipam_cfg.get("verify_ssl", True),
  229. )
  230. log.info("Connexion à phpIPAM : %s (app_id=%s)", phpipam_cfg["url"], phpipam_cfg["app_id"])
  231. hosts = collect_hosts(client)
  232. log.info("%d hôte(s) avec adresse MAC trouvé(s).", len(hosts))
  233. if not hosts:
  234. log.warning("Aucun hôte trouvé — le fichier de sortie ne sera pas modifié.")
  235. sys.exit(0)
  236. header = dnsmasq_cfg.get(
  237. "header_comment",
  238. "Généré automatiquement par dnsmasq_sync.py — NE PAS MODIFIER"
  239. )
  240. content = generate_config(hosts, header)
  241. write_config(content, dnsmasq_cfg["output_file"], dry_run=args.dry_run)
  242. if __name__ == "__main__":
  243. main()