diff --git a/dns/cronjob.yaml b/dns/cronjob.yaml new file mode 100644 index 0000000..c3c43ab --- /dev/null +++ b/dns/cronjob.yaml @@ -0,0 +1,64 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: dns-sync +spec: + schedule: "*/5 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 1 + template: + spec: + serviceAccountName: dns-sync + restartPolicy: Never + containers: + - name: sync + image: python:3.12-alpine + command: + - sh + - -c + - | + pip install --quiet PyJWT cryptography requests pyyaml && + python /config/sync.py + env: + - name: TRANSIP_ACCOUNT_NAME + valueFrom: + secretKeyRef: + name: transip-credentials + key: account_name + - name: TRANSIP_PRIVATE_KEY_PATH + value: /secrets/private_key + volumeMounts: + - name: secrets + mountPath: /secrets + readOnly: true + - name: script + mountPath: /config/sync.py + subPath: sync.py + readOnly: true + - name: domains + mountPath: /config/domains + readOnly: true + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "200m" + volumes: + - name: secrets + secret: + secretName: transip-credentials + items: + - key: private_key + path: private_key + - name: script + configMap: + name: dns-sync-script + - name: domains + configMap: + name: dns-domains diff --git a/dns/domains/rubenhensen.nl.yaml b/dns/domains/rubenhensen.nl.yaml new file mode 100644 index 0000000..7935fe4 --- /dev/null +++ b/dns/domains/rubenhensen.nl.yaml @@ -0,0 +1,17 @@ +records: + - name: "@" + expire: 86400 + type: A + content: "62.41.87.114" + - name: mail + expire: 86400 + type: A + content: "46.224.26.65" + - name: "phocaslustrum" + expire: 86400 + type: CNAME + content: "@" + - name: "ynab" + expire: 86400 + type: CNAME + content: "@" diff --git a/dns/external-secret.yaml b/dns/external-secret.yaml new file mode 100644 index 0000000..28bbad8 --- /dev/null +++ b/dns/external-secret.yaml @@ -0,0 +1,21 @@ +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: transip-credentials +spec: + secretStoreRef: + name: vault-backend + kind: ClusterSecretStore + refreshInterval: 15m + target: + name: transip-credentials + creationPolicy: Owner + data: + - secretKey: private_key + remoteRef: + key: kv/transip + property: private_key + - secretKey: account_name + remoteRef: + key: kv/transip + property: account_name diff --git a/dns/kustomization.yaml b/dns/kustomization.yaml new file mode 100644 index 0000000..f41d991 --- /dev/null +++ b/dns/kustomization.yaml @@ -0,0 +1,17 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: dns + +resources: + - external-secret.yaml + - rbac.yaml + - cronjob.yaml + +configMapGenerator: + - name: dns-sync-script + files: + - sync.py + - name: dns-domains + files: + - domains/rubenhensen.nl.yaml diff --git a/dns/rbac.yaml b/dns/rbac.yaml new file mode 100644 index 0000000..0b69273 --- /dev/null +++ b/dns/rbac.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: dns-sync diff --git a/dns/sync.py b/dns/sync.py new file mode 100644 index 0000000..3ea8f64 --- /dev/null +++ b/dns/sync.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Sync DNS records from YAML files to TransIP via their REST API.""" + +import base64 +import json +import os +import sys +import time +import uuid +from glob import glob +from pathlib import Path + +import jwt +import requests +import yaml + +TRANSIP_API = "https://api.transip.nl/v6" +DOMAINS_DIR = "/config/domains" + + +def get_access_token(account_name: str, private_key: str) -> str: + """Authenticate with TransIP API and return a bearer token.""" + now = int(time.time()) + payload = { + "iss": account_name, + "sub": account_name, + "aud": "api.transip.nl", + "jti": str(uuid.uuid4()), + "iat": now, + "nbf": now, + "exp": now + 300, + "global_key": True, + } + token = jwt.encode(payload, private_key, algorithm="RS512") + resp = requests.post( + f"{TRANSIP_API}/auth", + json={"login": account_name, "nonce": payload["jti"], "global_key": True}, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + timeout=30, + ) + resp.raise_for_status() + return resp.json()["token"] + + +def sync_domain(domain: str, records: list, token: str) -> None: + """Replace all DNS entries for a domain.""" + entries = [] + for r in records: + entries.append({ + "name": r["name"], + "expire": r["expire"], + "type": r["type"], + "content": r["content"], + }) + + resp = requests.put( + f"{TRANSIP_API}/domains/{domain}/dns", + json={"dnsEntries": entries}, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + timeout=30, + ) + resp.raise_for_status() + print(f"Synced {len(entries)} records for {domain}") + + +def main(): + account_name = os.environ.get("TRANSIP_ACCOUNT_NAME") + private_key_path = os.environ.get("TRANSIP_PRIVATE_KEY_PATH", "/secrets/private_key") + + if not account_name: + print("ERROR: TRANSIP_ACCOUNT_NAME not set") + sys.exit(1) + + private_key = Path(private_key_path).read_text().strip() + + print("Authenticating with TransIP API...") + token = get_access_token(account_name, private_key) + + domain_files = sorted(glob(f"{DOMAINS_DIR}/*.yaml")) + if not domain_files: + print("No domain files found") + sys.exit(0) + + errors = 0 + for filepath in domain_files: + domain = Path(filepath).stem + print(f"Processing {domain}...") + try: + with open(filepath) as f: + data = yaml.safe_load(f) + sync_domain(domain, data["records"], token) + except Exception as e: + print(f"ERROR syncing {domain}: {e}") + errors += 1 + + if errors: + print(f"Completed with {errors} error(s)") + sys.exit(1) + + print("All domains synced successfully") + + +if __name__ == "__main__": + main()