#!/usr/bin/env python3 """paas-cmk-sync — runs ON the Checkmk server (systemd timer). Model: ONE Checkmk host per paas project, named after the project's MAIN domain (uptime + cert via folder-scoped custom_checks rules on $HOSTNAME$). Every OTHER configured domain of the project gets its own "Certificate " service on that same host, via host-scoped custom_checks rules that this script reconciles (marker in the rule description). Changing a project's main domain simply renames the host: old host deleted, new one created, secondary cert rules follow. Config: /etc/paas-cmk-sync.conf (KEY=VALUE) PANEL_URL=https://deploy.younex.de TOKEN= SITE=monitoring VIP=188.40.238.163 EXTRA_DOMAINS=deploy.younex.de """ import json import ssl import sys import urllib.request CONF = "/etc/paas-cmk-sync.conf" FOLDER = "paas_sites" RULE_MARK = "paas-cert" # description: "paas-cert " def load_conf(): cfg = {} for line in open(CONF): line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) cfg[k] = v return cfg def http(method, url, body=None, headers=None, ctx=None): req = urllib.request.Request(url, method=method, data=json.dumps(body).encode() if body is not None else None) req.add_header("Content-Type", "application/json") req.add_header("Accept", "application/json") for k, v in (headers or {}).items(): req.add_header(k, v) try: with urllib.request.urlopen(req, context=ctx, timeout=30) as r: return r.status, r.read().decode() except urllib.error.HTTPError as e: return e.code, e.read().decode() def main(): cfg = load_conf() secret = open(f"/omd/sites/{cfg['SITE']}/var/check_mk/web/automation/automation.secret").read().strip() api = f"http://localhost/{cfg['SITE']}/check_mk/api/1.0" auth = {"Authorization": f"Bearer automation {secret}"} # 1. desired state from the panel: {main_host: [secondary domains...]} st, body = http("GET", f"{cfg['PANEL_URL']}/cmk-sites", headers={"Authorization": f"Bearer {cfg['TOKEN']}"}, ctx=ssl.create_default_context()) if st != 200: print(f"panel unreachable: {st}", file=sys.stderr) sys.exit(1) desired = {} for site in json.loads(body).get("sites", []): domains = [d.strip().lower() for d in site.get("domains", []) if d.strip()] if not domains: continue main = str(site.get("main") or domains[0]).strip().lower() if main not in domains: main = domains[0] desired[main] = sorted(set(domains) - {main}) for d in cfg.get("EXTRA_DOMAINS", "").split(","): d = d.strip().lower() if d: desired.setdefault(d, []) changed = False # 2. reconcile hosts in the folder st, body = http("GET", f"{api}/objects/folder_config/~{FOLDER}/collections/hosts", headers=auth) if st != 200: print(f"cannot list folder hosts ({st}): {body[:200]}", file=sys.stderr) sys.exit(1) current_hosts = {h["id"] for h in json.loads(body).get("value", [])} for host in sorted(set(desired) - current_hosts): st, body = http("POST", f"{api}/domain-types/host_config/collections/all", body={ "folder": f"/{FOLDER}", "host_name": host, "attributes": {"ipaddress": cfg["VIP"], "tag_agent": "no-agent", "alias": f"paas website {host}"}, }, headers=auth) print(f"add host {host}: {st}") changed = changed or st == 200 for host in sorted(current_hosts - set(desired)): st, body = http("DELETE", f"{api}/objects/host_config/{host}", headers=auth) print(f"remove host {host}: {st}") changed = changed or st == 204 # 3. reconcile secondary-domain certificate rules (host-scoped) st, body = http("GET", f"{api}/domain-types/rule/collections/all?ruleset_name=custom_checks", headers=auth) if st != 200: print(f"cannot list rules ({st}): {body[:200]}", file=sys.stderr) sys.exit(1) current_rules = {} # (host, domain) -> rule_id for r in json.loads(body).get("value", []): desc = (((r.get("extensions") or {}).get("properties") or {}).get("description") or "") parts = desc.split() if len(parts) == 3 and parts[0] == RULE_MARK: current_rules[(parts[1], parts[2])] = r["id"] desired_rules = {(host, dom) for host, secs in desired.items() for dom in secs} for host, dom in sorted(desired_rules - set(current_rules)): value = ("{'service_description': 'Certificate %s', " "'command_line': 'check_http -H %s -S --sni -C 21,7'}" % (dom, dom)) st, body = http("POST", f"{api}/domain-types/rule/collections/all", body={ "ruleset": "custom_checks", "folder": f"/{FOLDER}", "properties": {"description": f"{RULE_MARK} {host} {dom}"}, "value_raw": value, "conditions": {"host_name": {"match_on": [host], "operator": "one_of"}}, }, headers=auth) print(f"add cert rule {dom} on {host}: {st}") changed = changed or st == 200 for (host, dom) in sorted(set(current_rules) - desired_rules): st, body = http("DELETE", f"{api}/objects/rule/{current_rules[(host, dom)]}", headers=auth) print(f"remove cert rule {dom} on {host}: {st}") changed = changed or st == 204 if changed: st, body = http("POST", f"{api}/domain-types/activation_run/actions/activate-changes/invoke", body={"redirect": False, "sites": [cfg["SITE"]], "force_foreign_changes": True}, headers={**auth, "If-Match": "*"}) print(f"activate: {st}") else: print("in sync") if __name__ == "__main__": main()