From 3f29f7eaa60a9d9596ee96b429d00e6bd1931b3d Mon Sep 17 00:00:00 2001 From: Nik Rozman Date: Mon, 24 Jul 2023 22:02:37 +0200 Subject: [PATCH] Initial commit --- backend/hetzner.py | 68 ++++++++++++++++ backend/ovh.py | 44 +++++++++++ config.ini | 12 +++ main.py | 189 +++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | Bin 0 -> 104 bytes zones.ini | 1 + 6 files changed, 314 insertions(+) create mode 100644 backend/hetzner.py create mode 100644 backend/ovh.py create mode 100644 config.ini create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 zones.ini diff --git a/backend/hetzner.py b/backend/hetzner.py new file mode 100644 index 0000000..b530db2 --- /dev/null +++ b/backend/hetzner.py @@ -0,0 +1,68 @@ +import configparser +import requests + + +def reverse_ip_to_standard(reverse_ip): + parts = reverse_ip.split(".") + parts = parts[:4] + standard_ip = ".".join(reversed(parts)) + + return standard_ip + + +class HetznerBackend: + def __init__(self, config_file='config.ini'): + self.config_file = config_file + self.username = None + self.password = None + + self.load_credentials() + + def load_credentials(self): + config = configparser.ConfigParser() + config.read(self.config_file) + + if "Hetzner" not in config: + raise ValueError("Hetzner section not found in config.ini") + + self.username = config["Hetzner"].get("username") + self.password = config["Hetzner"].get("password") + + if not self.username or not self.password: + raise ValueError("Hetzner username or password not provided in config.ini") + + def update_record(self, reverse_ip, record): + ip = reverse_ip_to_standard(reverse_ip) + + # Check if the record contains the necessary data + if not record or "content" not in record[0]: + print("Invalid record data. 'content' field is missing.") + return None + + domain = record[0]["content"].strip(".") + + data = {"ptr": domain} + response = requests.post(f"https://robot-ws.your-server.de/rdns/{ip}", + data=data, auth=(self.username, self.password)) + + if response.status_code == 200: + print("Updated RDNS record for: " + ip + " to: " + record[0]["content"]) + return response.json() + else: + print(f"Failed to fetch rDNS data. Status code: {response.status_code}") + return None + + def create_record(self, reverse_ip, record): + # Hetzner's API treats creation and updates the same way so just call update_record instead + self.update_record(reverse_ip, record) + pass + + def delete_record(self, reverse_ip): + ip = reverse_ip_to_standard(reverse_ip) + response = requests.delete(f"https://robot-ws.your-server.de/rdns/{ip}", auth=(self.username, self.password)) + + if response.status_code == 200: + print("Deleted RDNS record for: " + ip) + else: + print(f"Failed to delete rDNS record. Status code: {response.status_code}") + return None diff --git a/backend/ovh.py b/backend/ovh.py new file mode 100644 index 0000000..a45f5e7 --- /dev/null +++ b/backend/ovh.py @@ -0,0 +1,44 @@ +import configparser +import requests + + +def reverse_ip_to_standard(reverse_ip): + parts = reverse_ip.split(".") + parts = parts[:4] + standard_ip = ".".join(reversed(parts)) + + return standard_ip + + +class OvhBackend: + def __init__(self, config_file='config.ini'): + self.config_file = config_file + self.username = None + self.password = None + + self.load_credentials() + + def load_credentials(self): + config = configparser.ConfigParser() + config.read(self.config_file) + + if "OVH" not in config: + raise ValueError("OVH section not found in config.ini") + + self.username = config["OVH"].get("username") + self.password = config["OVH"].get("password") + + if not self.username or not self.password: + raise ValueError("OVH username or password not provided in config.ini") + + def update_record(self, reverse_ip, record): + # Do update record stuff here + pass + + def create_record(self, reverse_ip, record): + # Do create record stuff here + pass + + def delete_record(self, reverse_ip): + # Do delete record stuff here + pass \ No newline at end of file diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..6465895 --- /dev/null +++ b/config.ini @@ -0,0 +1,12 @@ +[PowerDNS] +api_endpoint=https://x/api/v1/servers/localhost +api_key=x +frequency=5 + +[Hetzner] +username=x +password=x + +[OVH] +username=x +password=x \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..1291894 --- /dev/null +++ b/main.py @@ -0,0 +1,189 @@ +import os +import requests +import json +import configparser +import schedule +import time +from backend.hetzner import HetznerBackend +from backend.ovh import OvhBackend + + +def compare_zone_records(saved_records, new_records, backend_type): + saved_records_dict = {} + for record in saved_records["rrsets"]: + saved_records_dict[record["name"]] = record["records"] + + new_records_dict = {} + for record in new_records["rrsets"]: + new_records_dict[record["name"]] = record["records"] + + if backend_type == "hetzner": + backend = HetznerBackend() + elif backend_type == "ovh": + backend = OvhBackend() + else: + print(f"Specified backend not supported: {backend_type}") + return + + for name, new_records_list in new_records_dict.items(): + if name not in saved_records_dict: + # Do create here + backend.create_record(name, new_records_list) # Pass the name along with the content + + elif new_records_list != saved_records_dict[name]: + # Do update here + backend.update_record(name, new_records_list) # Pass the name along with the content + + for name, saved_records_list in saved_records_dict.items(): + if name not in new_records_dict: + # Do delete here + backend.delete_record(name) # Pass the name to the delete_record method + + +class PowerDNSProxy: + def __init__(self, config_file='config.ini', zones_file='zones.ini'): + self.api_url = None + self.api_key = None + self.config_file = config_file + self.freq = 5 # Default frequency + self.zones_with_backend = {} # Dictionary to store zones with their backend + self.zones_file = zones_file + + # noinspection PyTypeChecker + def load_config(self): + config = configparser.ConfigParser() + config.read(self.config_file) + + if "PowerDNS" not in config: + print("PowerDNS section not found in config.ini") + return False + else: + self.api_url = config["PowerDNS"]["api_endpoint"] + self.api_key = config["PowerDNS"]["api_key"] + + if not self.api_url or not self.api_key: + print("API URL or API Key not provided. Please check the configuration.") + return False + + self.freq = int(config["PowerDNS"].get("frequency", 5)) # Set the frequency from config + self.zones_with_backend = self.read_zones_from_config() # Call read_zones_from_config here + return True + + def main(self): + if not self.load_config(): + return + + zones = self.get_zones() + if type(zones) is int: + print("Error getting zones: http code " + str(zones)) + return + + tracked_zones = {} + for zone in zones: + zone_name = zone["name"] + if zone_name in self.zones_with_backend: # Use self.zones_with_backend instead of config_zones + tracked_zones[zone_name] = { + "id": zone["id"], + "serial": zone["serial"], + "backend": self.zones_with_backend[zone_name]["backend"] + } + + print("Zones tracked: " + str(len(tracked_zones))) + + with open("states.json", "w") as f: + json.dump(tracked_zones, f, indent=4) + + print("States saved to states.json") + + # Define the scheduled job function + def scheduled_job(): + self.check_zone_serial() + + # Schedule the job to run every 'freq' seconds + schedule.every(self.freq).seconds.do(scheduled_job) + + while True: + schedule.run_pending() + time.sleep(1) + + def get_zones(self): + headers = {"X-API-Key": self.api_key} + response = requests.get(self.api_url + "/zones", headers=headers) + if response.status_code == 200: + return response.json() + else: + return response.status_code + + def read_zones_from_config(self): + config = configparser.ConfigParser(allow_no_value=True) + config.read(self.zones_file) + + zones_with_backend = {} + + for section in config.sections(): + backend = "default" # Initialize the backend variable + if "backend" in config[section]: + backend = config[section]["backend"].strip('"') # Remove any quotes around the backend value + zones_with_backend[section.strip()] = {"backend": backend} + + return zones_with_backend + + def get_zone_records(self, zone_id): + headers = {"X-API-Key": self.api_key} + response = requests.get(self.api_url + f"/zones/{zone_id}", headers=headers) + if response.status_code == 200: + return response.json() + else: + print(f"Error getting zone records: http code {response.status_code}") + return None + + def save_zone_records(self, zone_data, backend): + zone_name = zone_data["name"] + zone_id = zone_data["id"] + + records = self.get_zone_records(zone_id) + if records is not None: + states_dir = "states" + if not os.path.exists(states_dir): + os.makedirs(states_dir) + + filename = os.path.join(states_dir, f"{zone_name}.json") + + try: + with open(filename, "r") as f: + saved_records = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + print(f"File {filename} not found or invalid, creating new file") + saved_records = [] + + with open(filename, "w") as f: + json.dump(records, f, indent=4) + + print(f"Zone records for {zone_name} saved to {filename}") + compare_zone_records(saved_records, records, backend) + else: + print(f"Error getting zone records for {zone_name}") + + def check_zone_serial(self): + zones = self.get_zones() + # Open states.json + with open("states.json", "r") as f: + tracked_zones = json.load(f) + + print("Checking zone serials from saved states") + + for zone in zones: + if zone["name"] in tracked_zones: + if tracked_zones[zone["name"]]["serial"] != zone["serial"]: + print("Zone " + zone["name"] + " has changed!") + self.save_zone_records(zone, tracked_zones[zone["name"]]["backend"]) + tracked_zones[zone["name"]]["serial"] = zone["serial"] + + # Update the state.json file with the updated tracked_zones data + with open("states.json", "w") as f: + json.dump(tracked_zones, f, indent=4) + + +if __name__ == "__main__": + pdns_proxy = PowerDNSProxy() + pdns_proxy.main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..f94c7461c674bc530c5d71eb8ef549f9fba66ea8 GIT binary patch literal 104 zcmezWuZSU)p^%{zNES1c0I@9)8ZqcG7&90GNdpF61}>mzGD8MXMG8<|4v+?^0m*{Y bK~$776ai&&7%~}>fI4Aj7y#u>z$O3y{&f&K literal 0 HcmV?d00001 diff --git a/zones.ini b/zones.ini new file mode 100644 index 0000000..32d7781 --- /dev/null +++ b/zones.ini @@ -0,0 +1 @@ +[4.3.2.1.in-addr.arpa.] backend = "ovh" [1.2.3.4.in-addr.arpa.] backend = "hetzner" \ No newline at end of file