From e31bdeb59d5ac8b807e3df1042230f647fe69d02 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 19 Jun 2026 15:57:10 +0200 Subject: [PATCH] Session-Caching, Domain-/Gruppen-Skripte; KeePass-DB aus Repo entfernen - inwx_common.py: Login mit Session-Cache, KP_PW-Option - inwx_list/add: Domain als Argument, gemeinsamer Helfer - inwx_domains(_owner).py: Domains alphabetisch, NS, Gruppen via citeq-TXT (DNS) - hosting.kdbx aus Tracking genommen und in .gitignore Co-Authored-By: Claude Opus 4.8 --- .gitignore | 10 +++ domain_owner | 3 + hosting.kdbx | Bin 2309 -> 0 bytes inwx_add_record.py | 67 ++++------------ inwx_common.py | 159 +++++++++++++++++++++++++++++++++++++ inwx_domains.py | 156 ++++++++++++++++++++++++++++++++++++ inwx_domains_owner.py | 181 ++++++++++++++++++++++++++++++++++++++++++ inwx_list.py | 94 +++++++--------------- 8 files changed, 556 insertions(+), 114 deletions(-) create mode 100644 .gitignore create mode 100644 domain_owner delete mode 100644 hosting.kdbx create mode 100644 inwx_common.py create mode 100644 inwx_domains.py create mode 100644 inwx_domains_owner.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af8a49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# KeePass-Datenbank mit Zugangsdaten – gehört NICHT ins Repo +hosting.kdbx + +# Zwischengespeicherte INWX-Session (enthält ein kurzlebiges Session-Cookie) +.inwx_session + +# Python-venv und Cache +venv/ +__pycache__/ +*.pyc diff --git a/domain_owner b/domain_owner new file mode 100644 index 0000000..6510e33 --- /dev/null +++ b/domain_owner @@ -0,0 +1,3 @@ +10 gruppe1 +20 gruppe2 +30 gruppe3 diff --git a/hosting.kdbx b/hosting.kdbx deleted file mode 100644 index 33451184d2910adb6917e4243b88509a05d3e003..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2309 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 diff --git a/inwx_add_record.py b/inwx_add_record.py index a584d98..6450558 100644 --- a/inwx_add_record.py +++ b/inwx_add_record.py @@ -1,40 +1,17 @@ #!/usr/bin/env python3 import sys -import os -import subprocess import argparse -# Import-Fix für die Library-Struktur -try: - from INWX.Domrobot import ApiClient -except ImportError: - from inwx.Domrobot import ApiClient +from inwx_common import get_client, call_api # --- KONFIGURATION --- -API_URL = 'https://api.domrobot.com' -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -KP_DB_PATH = os.path.join(BASE_DIR, "hosting.kdbx") -KP_ENTRY_NAME = "inwx" -DOMAIN = "ma151.de" +# Login/Session-Handling steckt in inwx_common.py. -def get_creds(): - """Holt die Daten sicher aus KeePassXC.""" - try: - cmd = ["keepassxc-cli", "show", "-s", KP_DB_PATH, KP_ENTRY_NAME] - output = subprocess.check_output(cmd, text=True) - creds = {} - for line in output.splitlines(): - if ":" in line: - k, v = line.split(":", 1) - creds[k.strip().lower()] = v.strip() - return creds.get("username") or creds.get("benutzername"), creds.get("password") or creds.get("passwort") - except Exception as e: - print(f"❌ KeePass-Fehler: {e}") - return None, None def main(): # 1. Argument-Parser einrichten - parser = argparse.ArgumentParser(description=f"DNS-Record zu {DOMAIN} hinzufügen.") + parser = argparse.ArgumentParser(description="DNS-Record zu einer Domain hinzufügen.") + parser.add_argument("domain", help="Domain (z.B. ma151.de)") parser.add_argument("type", help="Typ des Records (z.B. A, AAAA, CNAME, TXT)") parser.add_argument("name", help="Name/Subdomain (z.B. srv1)") parser.add_argument("content", help="Wert des Records (z.B. die IP-Adresse)") @@ -42,34 +19,23 @@ def main(): args = parser.parse_args() - # 2. Zugangsdaten laden - user, password = get_creds() - if not user or not password: - sys.exit(1) + # 2. Client holen (nutzt ggf. die zwischengespeicherte Session) + api = get_client() - api = ApiClient(api_url=API_URL) - - # 3. Login - login_res = api.login(user, password) - if login_res['code'] != 1000: - print(f"❌ Login fehlgeschlagen: {login_res['msg']}") - sys.exit(1) - - # 4. Prüfen, ob der Record existiert - full_name = f"{args.name}.{DOMAIN}" + # 3. Prüfen, ob der Record existiert + full_name = f"{args.name}.{args.domain}" print(f"🔍 Prüfe {full_name} ({args.type} -> {args.content})...") - info_res = api.call_api('nameserver.info', {'domain': DOMAIN}) + info_res = call_api(api, 'nameserver.info', {'domain': args.domain}) if info_res['code'] != 1000: print(f"❌ Fehler bei Domain-Info: {info_res['msg']}") - api.logout() sys.exit(1) records = info_res['resData'].get('record', []) already_exists = False for rec in records: - if (rec['name'].rstrip('.') == full_name and - rec['type'].upper() == args.type.upper() and + if (rec['name'].rstrip('.') == full_name and + rec['type'].upper() == args.type.upper() and rec['content'] == args.content): already_exists = True break @@ -77,22 +43,23 @@ def main(): if already_exists: print(f"ℹ️ Record existiert bereits. Keine Aktion erforderlich.") else: - # 5. Record erstellen + # 4. Record erstellen print(f"🚀 Erstelle neuen Record...") - create_res = api.call_api('nameserver.createRecord', { - 'domain': DOMAIN, + create_res = call_api(api, 'nameserver.createRecord', { + 'domain': args.domain, 'name': args.name, 'type': args.type.upper(), 'content': args.content, 'ttl': args.ttl }) - + if create_res['code'] == 1000: print(f"✅ Erfolg! Record ID: {create_res['resData']['id']}") else: print(f"❌ Fehler: {create_res['msg']} (Code: {create_res['code']})") - api.logout() + # Kein logout() -> die Session bleibt gültig und kann wiederverwendet werden. + if __name__ == "__main__": main() diff --git a/inwx_common.py b/inwx_common.py new file mode 100644 index 0000000..2e866b2 --- /dev/null +++ b/inwx_common.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Gemeinsame Helfer für die INWX-Skripte. + +Kapselt das Laden der Zugangsdaten aus KeePassXC sowie den Login mit +Session-Caching: Nach dem ersten Login wird das Session-Cookie von INWX +lokal zwischengespeichert. Folgeaufrufe nutzen diese Session wieder und +benötigen daher weder das KeePass-Master-Passwort noch einen neuen Login, +solange die Session bei INWX noch gültig ist. +""" +import json +import os +import subprocess +import sys + +import requests + +# Import-Fix für die Library-Struktur (Linux/Mac) +try: + from INWX.Domrobot import ApiClient +except ImportError: + from inwx.Domrobot import ApiClient + +# --- KONFIGURATION --- +API_URL = 'https://api.domrobot.com' +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +KP_DB_PATH = os.path.join(BASE_DIR, "hosting.kdbx") +KP_ENTRY_NAME = "inwx" +# Datei mit der zwischengespeicherten Session (enthält nur ein kurzlebiges +# Session-Cookie, niemals das Passwort). +SESSION_CACHE_PATH = os.path.join(BASE_DIR, ".inwx_session") + + +def get_creds(): + """Holt Benutzername und Passwort aus KeePassXC. + + Das KeePass-Master-Passwort wird interaktiv abgefragt. Ist die + Umgebungsvariable KP_PW gesetzt, wird deren Wert stattdessen automatisch + an keepassxc-cli übergeben (praktisch in nicht-interaktiven Umgebungen). + """ + try: + # -s sorgt dafür, dass das Passwort nicht als 'PROTECTED' ausgegeben wird + cmd = ["keepassxc-cli", "show", "-s", KP_DB_PATH, KP_ENTRY_NAME] + + master_pw = os.environ.get("KP_PW") + if master_pw: + # Master-Passwort über stdin an keepassxc-cli durchreichen. + result = subprocess.run( + cmd, input=master_pw + "\n", + capture_output=True, text=True, + ) + if result.returncode != 0: + print(f"❌ KeePass-Fehler: {result.stderr.strip()}") + return None, None + output = result.stdout + else: + # Kein KP_PW gesetzt -> keepassxc-cli fragt interaktiv nach. + output = subprocess.check_output(cmd, text=True) + + creds = {} + for line in output.splitlines(): + if ":" in line: + k, v = line.split(":", 1) + creds[k.strip().lower()] = v.strip() + + user = creds.get("username") or creds.get("benutzername") or creds.get("user") + password = creds.get("password") or creds.get("passwort") + return user, password + except Exception as e: + print(f"❌ KeePass-Fehler: {e}") + return None, None + + +def _load_cached_cookies(api): + """Lädt gespeicherte Session-Cookies in den ApiClient. True bei Erfolg.""" + if not os.path.exists(SESSION_CACHE_PATH): + return False + try: + with open(SESSION_CACHE_PATH, "r") as f: + cookies = json.load(f) + if not cookies: + return False + api.api_session.cookies = requests.utils.cookiejar_from_dict(cookies) + return True + except Exception: + return False + + +def _save_cached_cookies(api): + """Speichert die Session-Cookies (Datei nur für den Benutzer lesbar, 0600).""" + try: + cookies = requests.utils.dict_from_cookiejar(api.api_session.cookies) + fd = os.open(SESSION_CACHE_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump(cookies, f) + except Exception as e: + print(f"⚠️ Konnte Session nicht zwischenspeichern: {e}") + + +def _clear_cached_session(): + """Entfernt eine (abgelaufene) zwischengespeicherte Session.""" + try: + os.remove(SESSION_CACHE_PATH) + except FileNotFoundError: + pass + except Exception: + pass + + +def _do_login(api): + """Frischer Login via KeePass-Zugangsdaten; speichert die Session. True bei Erfolg.""" + user, password = get_creds() + if not user or not password: + return False + + print(f"Versuche Login für: {user}") + login_res = api.login(user, password) + if login_res['code'] != 1000: + print(f"❌ Login fehlgeschlagen: {login_res['msg']} (Code: {login_res['code']})") + return False + + _save_cached_cookies(api) + print("✅ Login erfolgreich (Session zwischengespeichert).") + return True + + +def get_client(): + """Liefert einen einsatzbereiten ApiClient. + + Nutzt eine zwischengespeicherte Session, falls vorhanden. Ob diese noch + gültig ist, wird erst beim ersten echten Aufruf via call_api() geprüft – + so sparen wir uns eine zusätzliche Anfrage. Ist keine Session vorhanden, + wird sofort via KeePass eingeloggt. + """ + api = ApiClient(api_url=API_URL) + if _load_cached_cookies(api): + print("ℹ️ Verwende zwischengespeicherte Session.") + return api + if not _do_login(api): + sys.exit(1) + return api + + +def call_api(api, method, params=None): + """Wie ApiClient.call_api, meldet sich aber bei abgelaufener Session neu an. + + Liefert INWX einen Autorisierungsfehler (Code 2200–2299), wird die + zwischengespeicherte Session verworfen, via KeePass neu eingeloggt und + der Aufruf einmal wiederholt. + """ + params = params or {} + res = api.call_api(method, params) + code = res.get('code') + if code is not None and 2200 <= code < 2300: + print("ℹ️ Session abgelaufen, melde neu an...") + _clear_cached_session() + if not _do_login(api): + sys.exit(1) + res = api.call_api(method, params) + return res diff --git a/inwx_domains.py b/inwx_domains.py new file mode 100644 index 0000000..2f30957 --- /dev/null +++ b/inwx_domains.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Listet alle für den Benutzer sichtbaren Domains alphabetisch sortiert auf.""" +import os +import subprocess +import sys + +from inwx_common import get_client, call_api + +PAGE_LIMIT = 100 # Domains pro API-Seite +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +OWNER_FILE = os.path.join(BASE_DIR, "domain_owner") +TXT_PREFIX = "citeq:" # Präfix der Gruppen-TXT-Einträge + + +def fetch_all_domains(api): + """Holt alle Domains über alle Seiten hinweg (domain.list ist paginiert).""" + domains = [] + page = 1 + while True: + res = call_api(api, 'domain.list', {'page': page, 'pageLimit': PAGE_LIMIT}) + if res['code'] != 1000: + print(f"❌ API Fehler: {res['msg']} (Code: {res['code']})") + sys.exit(1) + + data = res['resData'] + domains.extend(data.get('domain', [])) + + # Solange wir noch nicht alle laut 'count' geladen haben, weiterblättern. + if len(domains) >= data.get('count', 0) or not data.get('domain'): + break + page += 1 + + return domains + + +def get_nameservers(api, domain): + """Ermittelt die zuständigen Nameserver (Registry-Delegation) einer Domain. + + Nutzt domain.info -> Feld 'ns', das die delegierten Nameserver enthält und + auch dann funktioniert, wenn die DNS-Zone nicht bei INWX gehostet wird. + Liefert eine sortierte Liste der NS-Hostnamen oder None bei Fehler. + """ + res = call_api(api, 'domain.info', {'domain': domain}) + if res['code'] != 1000: + return None + + ns = res['resData'].get('ns') or [] + return sorted(n.rstrip('.') for n in ns) + + +def load_owners(): + """Liest die Zuordnung Ziffer -> Klarname aus der Datei domain_owner. + + Format pro Zeile: , wobei als Trenner + beliebiger Whitespace dient (ein oder mehrere Tabs/Leerzeichen). Der + Klarname darf selbst Leerzeichen enthalten. + Leere Zeilen und Kommentarzeilen (#) werden ignoriert. + """ + owners = {} + if not os.path.exists(OWNER_FILE): + print(f"⚠️ Datei {OWNER_FILE} nicht gefunden – Gruppen werden ohne Klarnamen angezeigt.") + return owners + try: + with open(OWNER_FILE, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split(None, 1) # an erstem Whitespace (Tab/Space) trennen + if len(parts) == 2: + ziffer, klarname = parts + owners[ziffer.strip()] = klarname.strip() + except Exception as e: + print(f"⚠️ Konnte {OWNER_FILE} nicht lesen: {e}") + return owners + + +def get_group(domain): + """Ermittelt die Gruppen-Ziffer aus dem citeq:-TXT-Eintrag einer Domain. + + Nutzt eine echte DNS-Abfrage (dig +short TXT) und funktioniert daher + unabhängig davon, wo die DNS-Zone gehostet wird. Liefert die Ziffer als + String oder None, wenn kein passender TXT-Eintrag gefunden wird. + """ + try: + out = subprocess.run( + ["dig", "+short", "TXT", domain], + capture_output=True, text=True, timeout=10, + ) + except FileNotFoundError: + print("⚠️ 'dig' nicht gefunden – Gruppen können nicht ermittelt werden.") + return None + except subprocess.TimeoutExpired: + return None + + if out.returncode != 0: + return None + + for line in out.stdout.splitlines(): + # dig liefert TXT-Werte in Anführungszeichen, lange Werte ggf. in + # mehreren Teilen ("teil1" "teil2") -> zusammenfügen und entkleiden. + content = line.replace('" "', '').strip().strip('"') + if content.startswith(TXT_PREFIX): + return content[len(TXT_PREFIX):].strip() + return None + + +def main(): + api = get_client() + + print("Rufe Domain-Liste ab...") + domains = fetch_all_domains(api) + + if not domains: + print("ℹ️ Keine Domains gefunden.") + return + + # Zuordnung Ziffer -> Klarname laden + owners = load_owners() + + # Alphabetisch sortieren (Groß-/Kleinschreibung ignorieren) + domains.sort(key=lambda d: d['domain'].lower()) + + print(f"\n{'DOMAIN':<35} {'STATUS':<8} {'GRUPPE':<18} {'NAMESERVER'}") + print("-" * 100) + for d in domains: + name = d.get('domain', '') + status = d.get('status', '') + + # Gruppen-Ziffer aus citeq:-TXT (per DNS) ermitteln und Klarnamen zuordnen + ziffer = get_group(name) + if ziffer is None: + group = "-" + elif ziffer in owners: + group = f"{ziffer} ({owners[ziffer]})" + else: + group = f"{ziffer} (?)" + + # Zuständige Nameserver pro Domain ermitteln + ns_list = get_nameservers(api, name) + if ns_list is None: + ns = "(nicht abrufbar)" + elif not ns_list: + ns = "(keine NS-Einträge gefunden)" + else: + ns = ", ".join(ns_list) + + print(f"{name:<35} {status:<8} {group:<18} {ns}") + + print(f"\nGesamt: {len(domains)} Domain(s)") + + # Kein logout() -> die Session bleibt gültig und kann wiederverwendet werden. + + +if __name__ == "__main__": + main() diff --git a/inwx_domains_owner.py b/inwx_domains_owner.py new file mode 100644 index 0000000..c96667d --- /dev/null +++ b/inwx_domains_owner.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Listet die Domains gruppiert nach den Einträgen in domain_owner auf. + +Reihenfolge richtet sich nach der Datei domain_owner: zuerst der 1. Eintrag +(Ziffer + Klarname), darunter alle Domains mit dieser Ziffer, dann eine +Leerzeile, dann der 2. Eintrag usw. Domains ohne passende Zuordnung werden +am Ende separat aufgeführt. +""" +import os +import subprocess +import sys + +from inwx_common import get_client, call_api + +PAGE_LIMIT = 100 # Domains pro API-Seite +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +OWNER_FILE = os.path.join(BASE_DIR, "domain_owner") +TXT_PREFIX = "citeq:" # Präfix der Gruppen-TXT-Einträge + + +def fetch_all_domains(api): + """Holt alle Domains über alle Seiten hinweg (domain.list ist paginiert).""" + domains = [] + page = 1 + while True: + res = call_api(api, 'domain.list', {'page': page, 'pageLimit': PAGE_LIMIT}) + if res['code'] != 1000: + print(f"❌ API Fehler: {res['msg']} (Code: {res['code']})") + sys.exit(1) + + data = res['resData'] + domains.extend(data.get('domain', [])) + + # Solange wir noch nicht alle laut 'count' geladen haben, weiterblättern. + if len(domains) >= data.get('count', 0) or not data.get('domain'): + break + page += 1 + + return domains + + +def get_nameservers(api, domain): + """Ermittelt die zuständigen Nameserver (Registry-Delegation) einer Domain. + + Nutzt domain.info -> Feld 'ns', das die delegierten Nameserver enthält und + auch dann funktioniert, wenn die DNS-Zone nicht bei INWX gehostet wird. + Liefert eine sortierte Liste der NS-Hostnamen oder None bei Fehler. + """ + res = call_api(api, 'domain.info', {'domain': domain}) + if res['code'] != 1000: + return None + + ns = res['resData'].get('ns') or [] + return sorted(n.rstrip('.') for n in ns) + + +def load_owners(): + """Liest die Zuordnung Ziffer -> Klarname aus der Datei domain_owner. + + Die Reihenfolge der Einträge aus der Datei bleibt erhalten (dict behält + seit Python 3.7 die Einfügereihenfolge). Format pro Zeile: + , Trenner ist beliebiger Whitespace (ein + oder mehrere Tabs/Leerzeichen); der Klarname darf Leerzeichen enthalten. + Leere Zeilen und Kommentarzeilen (#) werden ignoriert. + """ + owners = {} + if not os.path.exists(OWNER_FILE): + print(f"⚠️ Datei {OWNER_FILE} nicht gefunden – keine Gruppierung möglich.") + return owners + try: + with open(OWNER_FILE, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split(None, 1) # an erstem Whitespace trennen + if len(parts) == 2: + ziffer, klarname = parts + owners[ziffer.strip()] = klarname.strip() + except Exception as e: + print(f"⚠️ Konnte {OWNER_FILE} nicht lesen: {e}") + return owners + + +def get_group(domain): + """Ermittelt die Gruppen-Ziffer aus dem citeq:-TXT-Eintrag einer Domain. + + Nutzt eine echte DNS-Abfrage (dig +short TXT) und funktioniert daher + unabhängig davon, wo die DNS-Zone gehostet wird. Liefert die Ziffer als + String oder None, wenn kein passender TXT-Eintrag gefunden wird. + """ + try: + out = subprocess.run( + ["dig", "+short", "TXT", domain], + capture_output=True, text=True, timeout=10, + ) + except FileNotFoundError: + print("⚠️ 'dig' nicht gefunden – Gruppen können nicht ermittelt werden.") + return None + except subprocess.TimeoutExpired: + return None + + if out.returncode != 0: + return None + + for line in out.stdout.splitlines(): + # dig liefert TXT-Werte in Anführungszeichen, lange Werte ggf. in + # mehreren Teilen ("teil1" "teil2") -> zusammenfügen und entkleiden. + content = line.replace('" "', '').strip().strip('"') + if content.startswith(TXT_PREFIX): + return content[len(TXT_PREFIX):].strip() + return None + + +def format_domain_line(rec): + """Einzeilige Darstellung einer Domain (eingerückt unter der Gruppe).""" + if rec['ns'] is None: + ns = "(nicht abrufbar)" + elif not rec['ns']: + ns = "(keine NS-Einträge gefunden)" + else: + ns = ", ".join(rec['ns']) + return f" {rec['name']:<35} {rec['status']:<8} {ns}" + + +def main(): + api = get_client() + + print("Rufe Domain-Liste ab...") + domains = fetch_all_domains(api) + + if not domains: + print("ℹ️ Keine Domains gefunden.") + return + + # Zuordnung Ziffer -> Klarname laden (in Dateireihenfolge) + owners = load_owners() + + # Pro Domain einmal Ziffer (DNS) und Nameserver (API) ermitteln + records = [] + for d in domains: + name = d.get('domain', '') + records.append({ + 'name': name, + 'status': d.get('status', ''), + 'ziffer': get_group(name), + 'ns': get_nameservers(api, name), + }) + + # Alphabetisch sortieren (Groß-/Kleinschreibung ignorieren) + records.sort(key=lambda r: r['name'].lower()) + + # Ausgabe in der Reihenfolge der Einträge aus domain_owner + print() + for ziffer, klarname in owners.items(): + print(f"{ziffer} ({klarname})") + matching = [r for r in records if r['ziffer'] == ziffer] + if matching: + for rec in matching: + print(format_domain_line(rec)) + else: + print(" (keine Domains)") + print() # Leerzeile zwischen den Gruppen + + # Domains, die keiner Ziffer aus domain_owner zugeordnet sind + rest = [r for r in records if r['ziffer'] not in owners] + if rest: + print("Ohne Zuordnung") + for rec in rest: + ziffer = rec['ziffer'] + hinweis = "" if ziffer is None else f" [citeq:{ziffer} unbekannt]" + print(format_domain_line(rec) + hinweis) + print() + + print(f"Gesamt: {len(records)} Domain(s)") + + # Kein logout() -> die Session bleibt gültig und kann wiederverwendet werden. + + +if __name__ == "__main__": + main() diff --git a/inwx_list.py b/inwx_list.py index 0686d3e..4b68090 100644 --- a/inwx_list.py +++ b/inwx_list.py @@ -1,78 +1,44 @@ #!/usr/bin/env python3 -import sys import os -import subprocess +import sys -# Import-Fix für Linux/Mac -try: - from INWX.Domrobot import ApiClient -except ImportError: - from inwx.Domrobot import ApiClient +from inwx_common import get_client, call_api # --- KONFIGURATION --- -API_URL = 'https://api.domrobot.com' -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -KP_DB_PATH = os.path.join(BASE_DIR, "hosting.kdbx") -KP_ENTRY_NAME = "inwx" -DOMAIN = "ma151.de" +# Login/Session-Handling steckt in inwx_common.py. -def get_creds(): - """Holt die echten Daten (inkl. Passwort) aus KeePassXC.""" - try: - # -s sorgt dafür, dass das Passwort nicht als 'PROTECTED' ausgegeben wird - cmd = ["keepassxc-cli", "show", "-s", KP_DB_PATH, KP_ENTRY_NAME] - output = subprocess.check_output(cmd, text=True) - - creds = {} - for line in output.splitlines(): - if ":" in line: - k, v = line.split(":", 1) - creds[k.strip().lower()] = v.strip() - - user = creds.get("username") or creds.get("benutzername") or creds.get("user") - password = creds.get("password") or creds.get("passwort") - - return user, password - except Exception as e: - print(f"❌ KeePass-Fehler: {e}") - return None, None def main(): - user, password = get_creds() - if not user or not password: + # Domain als Argument einlesen + if len(sys.argv) < 2: + script = os.path.basename(sys.argv[0]) + print(f"Verwendung: {script} ") + print(f"Beispiel: {script} ma151.de") sys.exit(1) - # ApiClient mit der korrekten URL (Library fügt /xmlrpc/ an) - api = ApiClient(api_url=API_URL) + domain = sys.argv[1] + + # Holt einen einsatzbereiten Client (nutzt ggf. die zwischengespeicherte Session) + api = get_client() + + print(f"Rufe Records für {domain} ab...") + try: + res = call_api(api, 'nameserver.info', {'domain': domain}) + + if res['code'] == 1000: + records = res['resData']['record'] + print(f"\n{'TYP':<8} {'NAME':<35} {'WERT'}") + print("-" * 75) + for rec in records: + print(f"{rec['type']:<8} {rec['name']:<35} {rec['content']}") + else: + print(f"❌ API Fehler: {res['msg']} (Code: {res['code']})") + + except Exception as e: + print(f"❌ Fehler beim Abruf mit call_api: {e}") + + # Kein logout() -> die Session bleibt gültig und kann wiederverwendet werden. - print(f"Versuche Login für: {user}") - - # Login - login_res = api.login(user, password) - - if login_res['code'] == 1000: - print("✅ Login erfolgreich!") - print(f"Rufe Records für {DOMAIN} ab...") - - try: - # Deine Library-Version nutzt laut DEBUG 'call_api' - res = api.call_api('nameserver.info', {'domain': DOMAIN}) - - if res['code'] == 1000: - records = res['resData']['record'] - print(f"\n{'TYP':<8} {'NAME':<35} {'WERT'}") - print("-" * 75) - for rec in records: - print(f"{rec['type']:<8} {rec['name']:<35} {rec['content']}") - else: - print(f"❌ API Fehler: {res['msg']} (Code: {res['code']})") - - except Exception as e: - print(f"❌ Fehler beim Abruf mit call_api: {e}") - - api.logout() - else: - print(f"❌ Login fehlgeschlagen: {login_res['msg']} (Code: {login_res['code']})") if __name__ == "__main__": main()