diff --git a/.env b/.env new file mode 100644 index 0000000..3b8d31b --- /dev/null +++ b/.env @@ -0,0 +1 @@ +IPV64_API_KEY=dein_api_token_hier diff --git a/README.md b/README.md index 3430e18..0ba6008 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ -# ipv64-tui +# IPv64 DNS TUI Client + +[Deutsch](#deutsch) | [English](#english) + +--- + +## Deutsch + +Ein moderner Terminal-Client (TUI) zur Verwaltung von DNS-Records auf [IPv64.net](https://ipv64.net), entwickelt mit **Python**, **Textual** und **HTTPX**. + +### Funktionen + +- **Übersichtliche Oberfläche:** Anzeige aller Domains und DNS-Einträge in interaktiven Tabellen. +- **Vollständige DNS-Verwaltung:** Anlegen, Bearbeiten und Löschen von Records (A, AAAA, CNAME, TXT, MX, NS, SRV). +- **Flexibles Auth-Handling:** API-Key aus Umgebungsvariablen (`export`), `.env`-Datei oder interaktiver Terminalabfrage. +- **Tastaturgesteuert:** Schnelle Navigation und Aktionen über Hotkeys. + +### Voraussetzungen + +- Python 3.10+ +- Abhängigkeiten: + ```bash + pip install textual httpx python-dotenv +``` + +### Konfiguration +Optional kann eine .env-Datei im Stammverzeichnis angelegt werden: + +```bash +IPV64_API_KEY=dein_api_token_hier +``` +Alternativ kann der Schlüssel vor dem Start exportiert werden + +```bash +export IPV64_API_KEY="dein_api_token_hier" +``` +Wird kein Key gefunden (oder steht noch der Standard-Platzhalter in der .env), fragt das Tool beim Start interaktiv nach dem Token. + + +### Starten + +```bash +python3 main.py +``` + +### Tastenkürzel + +|Taste|Aktion| +|------|------| +|r|Domains & Records neu von IPv64 laden| +|e / Enter|Ausgewählten Record ins Formular laden (Bearbeiten)| +|d|Ausgewählten Record sofort löschen| +|Tab / Shift+Tab|"Zwischen Feldern, Tabelle und Buttons wechseln"| +|q|Anwendung beenden| diff --git a/__pycache__/api_client.cpython-313.pyc b/__pycache__/api_client.cpython-313.pyc new file mode 100644 index 0000000..424c219 Binary files /dev/null and b/__pycache__/api_client.cpython-313.pyc differ diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000..d8d2a0f Binary files /dev/null and b/__pycache__/app.cpython-313.pyc differ diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..6afd0b2 Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/widgets.cpython-313.pyc b/__pycache__/widgets.cpython-313.pyc new file mode 100644 index 0000000..5081547 Binary files /dev/null and b/__pycache__/widgets.cpython-313.pyc differ diff --git a/api_client.py b/api_client.py new file mode 100755 index 0000000..540f62c --- /dev/null +++ b/api_client.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""IPv64 DNS TUI Client +Author: Pascal Bouquet +License: GNU General Public License v3.0 (GPL-3.0) +Year: 2026 +""" + +import httpx + +BASE_URL = "https://ipv64.net/api.php" + + +class IPv64Client: + def __init__(self, api_key: str): + self.headers = {"Authorization": f"Bearer {api_key.strip()}"} + + async def get_account_info(self) -> dict: + """[GET] get_account_info: Ruft Kontoinformationen und Limits ab.""" + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{BASE_URL}?get_account_info", + headers=self.headers, + timeout=10.0, + ) + resp.raise_for_status() + return resp.json() + + async def get_domains(self) -> dict: + """[GET] get_domains: Liefert alle Domains und Records.""" + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{BASE_URL}?get_domains", + headers=self.headers, + timeout=10.0, + ) + resp.raise_for_status() + return resp.json() + + async def add_domain(self, domain_name: str) -> dict: + """[POST] add_domain: Legt eine neue Domain an.""" + data = {"add_domain": domain_name} + async with httpx.AsyncClient() as client: + resp = await client.post( + BASE_URL, + headers=self.headers, + data=data, + timeout=10.0, + ) + resp.raise_for_status() + return resp.json() + + async def delete_domain(self, domain_name: str) -> dict: + """[DELETE] del_domain: Löscht Domain mit allen Einträgen.""" + data = {"del_domain": domain_name} + async with httpx.AsyncClient() as client: + resp = await client.request( + "DELETE", + BASE_URL, + headers=self.headers, + data=data, + timeout=10.0, + ) + resp.raise_for_status() + return resp.json() + + async def add_record( + self, + domain: str, + subdomain: str, + typ: str, + content: str, + prio: str = "", + ) -> dict: + """[POST] add_record: DNS-Record anlegen.""" + data = { + "add_record": domain, + "praefix": subdomain, + "type": typ.upper(), + "content": content, + } + if prio and prio.strip() and prio.strip().lower() != "none": + data["prio"] = prio.strip() + + async with httpx.AsyncClient() as client: + resp = await client.post( + BASE_URL, + headers=self.headers, + data=data, + timeout=10.0, + ) + resp.raise_for_status() + return resp.json() + + async def delete_record_by_id(self, record_id: int | str) -> dict: + """[DELETE] del_record per ID.""" + data = {"del_record": str(record_id)} + async with httpx.AsyncClient() as client: + resp = await client.request( + "DELETE", + BASE_URL, + headers=self.headers, + data=data, + timeout=10.0, + ) + resp.raise_for_status() + return resp.json() diff --git a/app.py b/app.py new file mode 100755 index 0000000..83414cd --- /dev/null +++ b/app.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""IPv64 DNS TUI Client +Author: Pascal Bouquet +License: GNU General Public License v3.0 (GPL-3.0) +Year: 2026 +""" + +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import ( + Button, + DataTable, + Footer, + Header, + Label, + Select, + Static, +) + +from api_client import IPv64Client +from widgets import RecordForm + + +class IPv64TUI(App): + CSS = """ + Screen { + layout: vertical; + } + #top-bar { + height: 3; + margin: 1 1 0 1; + } + #main-container { + height: 1fr; + margin: 0 1; + } + #left-pane { + width: 30%; + border: round $primary; + padding: 0 1; + } + #right-pane { + width: 70%; + border: round $primary; + } + #form-pane { + height: auto; + border: round $accent; + margin: 0 1 1 1; + padding: 1; + } + .form-row { + height: 3; + align-vertical: middle; + margin-bottom: 1; + } + .form-row Input, .form-row Select { + width: 1fr; + } + .form-label { + width: 12; + } + .hidden { + display: none; + } + #status { + height: 1; + margin: 0 1; + color: $text-muted; + } + """ + + BINDINGS = [ + ("r", "refresh_data", "Aktualisieren"), + ("e", "edit_selected", "Bearbeiten"), + ("d", "delete_selected", "Löschen"), + ("q", "quit", "Beenden"), + ] + + def __init__(self, api_key: str): + super().__init__() + self.client = IPv64Client(api_key) + self.domains_cache = {} + self.current_domain = None + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("IPv64.net DNS Manager", id="top-bar") + + with Horizontal(id="main-container"): + with Vertical(id="left-pane"): + yield Label("Domain auswählen:") + yield Select([], id="domain-select", prompt="Wähle Domain...") + yield Button("Domains neu laden", id="btn-refresh", variant="primary") + with Vertical(id="right-pane"): + yield DataTable(id="records-table") + + yield RecordForm() + yield Static("Bereit.", id="status") + yield Footer() + + async def on_mount(self) -> None: + table = self.query_one("#records-table", DataTable) + table.cursor_type = "row" + table.add_columns("ID", "Typ", "Subdomain", "Inhalt", "Prio") + await self.load_domains() + + async def load_domains(self) -> None: + self.set_status("Lade Domains von IPv64...") + try: + res = await self.client.get_domains() + domains_sub = res.get("subdomains", {}) + self.domains_cache = domains_sub + + options = [(dom, dom) for dom in domains_sub.keys()] + dom_select = self.query_one("#domain-select", Select) + + # Verhindert, dass während des Updates unerwünschte Events feuern + with self.prevent(Select.Changed): + dom_select.set_options(options) + + # Vorherige Auswahl beibehalten oder Fallback auf erste Domain + if self.current_domain and self.current_domain in self.domains_cache: + dom_select.value = self.current_domain + elif options: + self.current_domain = options[0][0] + dom_select.value = self.current_domain + else: + self.current_domain = None + dom_select.value = Select.BLANK + + if self.current_domain: + self.update_table(self.current_domain) + else: + self.query_one("#records-table", DataTable).clear() + + self.set_status(f"{len(options)} Domains geladen.") + except Exception as e: + self.set_status(f"Fehler: {e}") + + def update_table(self, domain_name: str) -> None: + table = self.query_one("#records-table", DataTable) + table.clear() + domain_data = self.domains_cache.get(domain_name, {}) + records = domain_data.get("records", []) + + for rec in records: + rec_id = str(rec.get("record_id", "")) + table.add_row( + rec_id, + str(rec.get("type", "-")), + str(rec.get("praefix", "@")), + str(rec.get("content", "-")), + str(rec.get("prio", "-")), + key=rec_id, + ) + + def on_select_changed(self, event: Select.Changed) -> None: + if event.select.id == "domain-select": + if event.value != Select.BLANK: + self.current_domain = str(event.value) + self.update_table(self.current_domain) + # Formular zurücksetzen beim Domain-Wechsel + self.query_one(RecordForm).reset_mode() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Öffnet den Bearbeitungsmodus auch bei Doppel-/Enter-Klick auf eine Zeile.""" + self.action_edit_selected() + + async def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-refresh": + await self.load_domains() + elif event.button.id == "btn-submit": + await self.submit_record() + elif event.button.id == "btn-cancel": + self.query_one(RecordForm).reset_mode() + self.set_status("Bearbeitung abgebrochen.") + + def action_edit_selected(self) -> None: + table = self.query_one("#records-table", DataTable) + if table.row_count == 0 or table.cursor_row is None: + self.set_status("Kein Eintrag zum Bearbeiten ausgewählt.") + return + + row_data = table.get_row_at(table.cursor_row) + rec_id, typ, subdomain, content, prio = ( + str(row_data[0]), + str(row_data[1]), + str(row_data[2]), + str(row_data[3]), + str(row_data[4]), + ) + + form = self.query_one(RecordForm) + form.set_edit_mode(rec_id, subdomain, typ, content, prio) + self.set_status(f"Bearbeite Record ID {rec_id}. Daten im Formular anpassen.") + + async def submit_record(self) -> None: + if not self.current_domain: + self.set_status("Bitte zuerst eine Domain auswählen.") + return + + form = self.query_one(RecordForm) + data = form.get_data() + + if not data["content"]: + self.set_status("Content darf nicht leer sein.") + return + + old_record_id = data["record_id"] + + try: + # Wenn wir im Bearbeitungsmodus sind: Alten Eintrag zuerst löschen + if old_record_id: + self.set_status(f"Aktualisiere Record {old_record_id}...") + del_res = await self.client.delete_record_by_id(old_record_id) + if del_res.get("status") not in ("success", None) and del_res.get("info") != "success": + self.set_status(f"Fehler beim Ersetzen des Records: {del_res}") + return + else: + self.set_status("Erstelle neuen Record...") + + res = await self.client.add_record( + self.current_domain, + data["subdomain"], + data["type"], + data["content"], + data["prio"], + ) + + if res.get("status") == "success" or res.get("info") == "success": + msg = "Record aktualisiert." if old_record_id else "Record angelegt." + self.set_status(msg) + form.reset_mode() + await self.load_domains() + else: + self.set_status(f"API-Rückmeldung: {res}") + except Exception as e: + self.set_status(f"Fehler: {e}") + + async def action_delete_selected(self) -> None: + table = self.query_one("#records-table", DataTable) + if table.row_count == 0 or table.cursor_row is None: + self.set_status("Keine Einträge vorhanden.") + return + + current_row_idx = table.cursor_row + row_data = table.get_row_at(current_row_idx) + record_id = str(row_data[0]) + + if not record_id or record_id == "-": + self.set_status("Keine gültige Record-ID gefunden.") + return + + self.set_status(f"Lösche Record ID {record_id}...") + try: + res = await self.client.delete_record_by_id(record_id) + if res.get("status") == "success" or res.get("info") == "success": + self.set_status(f"Record {record_id} gelöscht.") + # Falls wir diesen Record gerade im Formular bearbeitet hatten: Formular leeren + form = self.query_one(RecordForm) + if form.edit_record_id == record_id: + form.reset_mode() + await self.load_domains() + else: + self.set_status(f"API-Rückmeldung: {res}") + except Exception as e: + self.set_status(f"Fehler beim Löschen: {e}") + + def action_refresh_data(self) -> None: + self.run_worker(self.load_domains()) + + def set_status(self, text: str) -> None: + self.query_one("#status", Static).update(text) diff --git a/config.py b/config.py new file mode 100755 index 0000000..e48b1e0 --- /dev/null +++ b/config.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""IPv64 DNS TUI Client +Author: Pascal Bouquet +License: GNU General Public License v3.0 (GPL-3.0) +Year: 2026 +""" + +import os +import sys +from dotenv import load_dotenv + +load_dotenv() + +ENV_KEY_NAME = "IPV64_API_KEY" + +# Standard-Platzhalter, die als "nicht konfiguriert" gewertet werden +PLACEHOLDERS = { + "", + "dein_api_token_hier", + "dein_api_key", + "dein_geheimer_api_schluessel", + "your_api_key_here", +} + + +def get_api_key() -> str: + token = os.getenv(ENV_KEY_NAME, "").strip() + + # Falls der Token leer ist oder noch ein Platzhalter drinsteht + if token.lower() in PLACEHOLDERS: + print("Kein gültiger API-Token gefunden (Platzhalter oder leer).") + try: + token = input("Bitte IPv64 API-Token eingeben: ").strip() + except (KeyboardInterrupt, EOFError): + print("\nAbgebrochen.") + sys.exit(0) + + if not token or token.lower() in PLACEHOLDERS: + print("Fehler: Ein gültiger Token ist erforderlich, um fortzufahren.") + sys.exit(1) + + return token diff --git a/main.py b/main.py new file mode 100755 index 0000000..daf5dae --- /dev/null +++ b/main.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""IPv64 DNS TUI Client +Author: Pascal Bouquet +License: GNU General Public License v3.0 (GPL-3.0) +Year: 2026 +""" + +from app import IPv64TUI +from config import get_api_key + + +def main(): + api_key = get_api_key() + app = IPv64TUI(api_key=api_key) + app.run() + + +if __name__ == "__main__": + main() diff --git a/widgets.py b/widgets.py new file mode 100755 index 0000000..9b2a101 --- /dev/null +++ b/widgets.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""IPv64 DNS TUI Client +Author: Pascal Bouquet +License: GNU General Public License v3.0 (GPL-3.0) +Year: 2026 +""" + +from rich.markup import escape +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Button, Input, Label, Select, Static + + +class RecordForm(Static): + """Eingabe- und Bearbeitungsformular für DNS-Records.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.edit_record_id: str | None = None + + def compose(self) -> ComposeResult: + with Vertical(id="form-pane"): + yield Label("Record hinzufügen:", id="form-title") + with Horizontal(classes="form-row"): + yield Label("Subdomain:", classes="form-label") + yield Input(placeholder="z. B. www oder mx01", id="in-subdomain") + yield Label("Typ:", classes="form-label") + yield Select( + [ + ("A", "A"), + ("AAAA", "AAAA"), + ("CNAME", "CNAME"), + ("TXT", "TXT"), + ("MX", "MX"), + ("NS", "NS"), + ("SRV", "SRV"), + ], + value="A", + id="sel-type", + ) + with Horizontal(classes="form-row"): + yield Label("Content/IP:", classes="form-label") + yield Input(placeholder="192.0.2.1 oder Ziel", id="in-content") + yield Label("Prio:", classes="form-label") + yield Input(placeholder="10 (nur MX/SRV)", id="in-prio") + yield Button("Erstellen", id="btn-submit", variant="success") + yield Button( + "Abbrechen", + id="btn-cancel", + variant="default", + classes="hidden", + ) + + def set_edit_mode( + self, record_id: str, subdomain: str, typ: str, content: str, prio: str + ) -> None: + """Schaltet das Formular in den Bearbeitungsmodus.""" + self.edit_record_id = record_id + + # Runde Klammern und escape() verhindern den Rich-Markup-Error + clean_id = escape(str(record_id)) + self.query_one("#form-title", Label).update( + f"Record bearbeiten (ID: {clean_id}):" + ) + + # Subdomain bereinigen: @ oder leer wird zu leerem Input + self.query_one("#in-subdomain", Input).value = "" if subdomain in ("@", "") else subdomain + + # Select-Wert für Typ setzen, falls in Optionen vorhanden + sel_type = self.query_one("#sel-type", Select) + if typ in [opt[1] for opt in sel_type._options]: + sel_type.value = typ + + self.query_one("#in-content", Input).value = "" if content == "-" else content + self.query_one("#in-prio", Input).value = "" if prio in ("-", "None") else prio + + btn_submit = self.query_one("#btn-submit", Button) + btn_submit.label = "Speichern" + btn_submit.variant = "warning" + + self.query_one("#btn-cancel", Button).remove_class("hidden") + + def reset_mode(self) -> None: + """Setzt das Formular wieder auf 'Neuer Record' zurück.""" + self.edit_record_id = None + self.query_one("#form-title", Label).update("Record hinzufügen:") + self.clear() + + btn_submit = self.query_one("#btn-submit", Button) + btn_submit.label = "Erstellen" + btn_submit.variant = "success" + + self.query_one("#btn-cancel", Button).add_class("hidden") + + def get_data(self) -> dict: + return { + "record_id": self.edit_record_id, + "subdomain": self.query_one("#in-subdomain", Input).value.strip(), + "type": str(self.query_one("#sel-type", Select).value), + "content": self.query_one("#in-content", Input).value.strip(), + "prio": self.query_one("#in-prio", Input).value.strip(), + } + + def clear(self) -> None: + self.query_one("#in-subdomain", Input).value = "" + self.query_one("#in-content", Input).value = "" + self.query_one("#in-prio", Input).value = ""