dnsmasq_sync.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  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) -> list | dict:
  63. url = f"{self.base_url}/api/{self.app_id}/{endpoint.lstrip('/')}"
  64. try:
  65. resp = self.session.get(url, timeout=30)
  66. resp.raise_for_status()
  67. except requests.exceptions.SSLError:
  68. log.error(
  69. "Erreur SSL. Si vous utilisez un certificat auto-signé, "
  70. "passez verify_ssl: false dans la config."
  71. )
  72. sys.exit(1)
  73. except requests.exceptions.ConnectionError as exc:
  74. log.error("Impossible de joindre phpIPAM (%s) : %s", url, exc)
  75. sys.exit(1)
  76. except requests.exceptions.HTTPError as exc:
  77. log.error("Erreur HTTP %s sur %s : %s", resp.status_code, url, exc)
  78. sys.exit(1)
  79. data = resp.json()
  80. if not data.get("success"):
  81. # 404 "No addresses found" n'est pas une erreur fatale
  82. if data.get("code") == 404:
  83. return []
  84. log.warning("Réponse phpIPAM non-success pour %s : %s", endpoint, data.get("message"))
  85. return []
  86. return data.get("data") or []
  87. def get_sections(self) -> list[dict]:
  88. return self._get("sections/")
  89. def get_subnets_for_section(self, section_id: int) -> list[dict]:
  90. return self._get(f"sections/{section_id}/subnets/")
  91. def get_addresses_for_subnet(self, subnet_id: int) -> list[dict]:
  92. return self._get(f"subnets/{subnet_id}/addresses/")
  93. # ---------------------------------------------------------------------------
  94. # Collecte des hôtes avec MAC
  95. # ---------------------------------------------------------------------------
  96. def collect_hosts(client: PhpIpamClient) -> list[dict]:
  97. """
  98. Parcourt toutes les sections → sous-réseaux → adresses
  99. et retourne les hôtes ayant une adresse MAC définie.
  100. """
  101. hosts = []
  102. seen_ids: set[str] = set()
  103. sections = client.get_sections()
  104. log.info("%d section(s) trouvée(s).", len(sections))
  105. for section in sections:
  106. section_id = int(section["id"])
  107. section_name = section.get("name", f"section-{section_id}")
  108. subnets = client.get_subnets_for_section(section_id)
  109. log.info(" Section '%s' : %d sous-réseau(x).", section_name, len(subnets))
  110. for subnet in subnets:
  111. subnet_id = int(subnet["id"])
  112. addresses = client.get_addresses_for_subnet(subnet_id)
  113. for addr in addresses:
  114. addr_id = str(addr.get("id", ""))
  115. mac = _normalize_mac(addr.get("mac", ""))
  116. ip = addr.get("ip", "").strip()
  117. # Ignorer si pas de MAC, pas d'IP, ou déjà traité
  118. if not mac or not ip or addr_id in seen_ids:
  119. continue
  120. # Valider l'IP
  121. try:
  122. ipaddress.ip_address(ip)
  123. except ValueError:
  124. log.warning("IP invalide ignorée : %s (id=%s)", ip, addr_id)
  125. continue
  126. seen_ids.add(addr_id)
  127. hosts.append({
  128. "mac": mac,
  129. "ip": ip,
  130. "hostname": _sanitize_hostname(addr.get("hostname") or addr.get("description") or ""),
  131. })
  132. return hosts
  133. def _normalize_mac(raw: str) -> str:
  134. """Normalise une adresse MAC en xx:xx:xx:xx:xx:xx minuscule."""
  135. if not raw:
  136. return ""
  137. mac = raw.strip().lower().replace("-", ":").replace(".", ":")
  138. # Supprimer les séparateurs puis reformater
  139. digits = mac.replace(":", "")
  140. if len(digits) != 12 or not all(c in "0123456789abcdef" for c in digits):
  141. log.warning("MAC ignorée (format invalide) : %s", raw)
  142. return ""
  143. return ":".join(digits[i:i+2] for i in range(0, 12, 2))
  144. def _sanitize_hostname(name: str) -> str:
  145. """Retourne un hostname valide ou une chaîne vide."""
  146. if not name:
  147. return ""
  148. # Remplacer les espaces et caractères non-ASCII par '-'
  149. sanitized = "".join(c if c.isalnum() or c in "-." else "-" for c in name.strip())
  150. # dnsmasq n'accepte pas les noms commençant/finissant par '-'
  151. return sanitized.strip("-")
  152. # ---------------------------------------------------------------------------
  153. # Génération du fichier dnsmasq
  154. # ---------------------------------------------------------------------------
  155. def generate_config(hosts: list[dict], header_comment: str) -> str:
  156. lines = [
  157. f"# {header_comment}",
  158. f"# Généré le : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
  159. f"# Entrées : {len(hosts)}",
  160. "",
  161. ]
  162. for host in sorted(hosts, key=lambda h: tuple(int(x) for x in h["ip"].split("."))):
  163. if host["hostname"]:
  164. line = f"dhcp-host={host['mac']},{host['ip']},{host['hostname']}"
  165. else:
  166. line = f"dhcp-host={host['mac']},{host['ip']}"
  167. lines.append(line)
  168. lines.append("")
  169. return "\n".join(lines)
  170. def write_config(content: str, output_file: str, dry_run: bool) -> None:
  171. if dry_run:
  172. print("=== DRY-RUN — contenu qui serait écrit dans", output_file, "===")
  173. print(content)
  174. return
  175. out = Path(output_file)
  176. out.parent.mkdir(parents=True, exist_ok=True)
  177. out.write_text(content, encoding="utf-8")
  178. log.info("Fichier écrit : %s", output_file)
  179. # ---------------------------------------------------------------------------
  180. # Point d'entrée
  181. # ---------------------------------------------------------------------------
  182. def parse_args() -> argparse.Namespace:
  183. parser = argparse.ArgumentParser(
  184. description="Synchronise phpIPAM → configuration dnsmasq (dhcp-host)."
  185. )
  186. parser.add_argument(
  187. "--config", default="config.yaml",
  188. help="Chemin vers le fichier de configuration YAML (défaut: config.yaml)"
  189. )
  190. parser.add_argument(
  191. "--dry-run", action="store_true",
  192. help="Affiche la config générée sans écrire le fichier"
  193. )
  194. parser.add_argument(
  195. "--debug", action="store_true",
  196. help="Active les logs de niveau DEBUG"
  197. )
  198. return parser.parse_args()
  199. def main() -> None:
  200. args = parse_args()
  201. if args.debug:
  202. logging.getLogger().setLevel(logging.DEBUG)
  203. cfg = load_config(args.config)
  204. phpipam_cfg = cfg["phpipam"]
  205. dnsmasq_cfg = cfg["dnsmasq"]
  206. client = PhpIpamClient(
  207. url=phpipam_cfg["url"],
  208. app_id=phpipam_cfg["app_id"],
  209. token=phpipam_cfg.get("token"),
  210. verify_ssl=phpipam_cfg.get("verify_ssl", True),
  211. )
  212. log.info("Connexion à phpIPAM : %s (app_id=%s)", phpipam_cfg["url"], phpipam_cfg["app_id"])
  213. hosts = collect_hosts(client)
  214. log.info("%d hôte(s) avec adresse MAC trouvé(s).", len(hosts))
  215. if not hosts:
  216. log.warning("Aucun hôte trouvé — le fichier de sortie ne sera pas modifié.")
  217. sys.exit(0)
  218. header = dnsmasq_cfg.get(
  219. "header_comment",
  220. "Généré automatiquement par dnsmasq_sync.py — NE PAS MODIFIER"
  221. )
  222. content = generate_config(hosts, header)
  223. write_config(content, dnsmasq_cfg["output_file"], dry_run=args.dry_run)
  224. if __name__ == "__main__":
  225. main()