From dbb30b70f5fb0416965d55e89518612879c2b139 Mon Sep 17 00:00:00 2001 From: Pascal Bouquet Date: Sat, 19 Sep 2026 15:34:14 +0200 Subject: [PATCH] feat: migrate INWX DNS CLI to interactive Textual TUI - replace CLI menu with multi-panel Textual interface (sidebar & data table) - implement asynchronous loading for domains and DNS records via worker threads - add support for optional 2FA/TOTP auto-unlock and manual TAN modal - introduce .env configuration fallback for stored credentials - add modals for creating, editing, and deleting DNS records - enable inline row editing via mouse double-click and keyboard navigation - update project documentation in README.md --- .env | 8 + .gitignore | 1 + README.md | 51 ++- __pycache__/inwx_api.cpython-313.pyc | Bin 0 -> 6577 bytes __pycache__/tui_app.cpython-313.pyc | Bin 0 -> 19683 bytes __pycache__/tui_modals.cpython-313.pyc | Bin 0 -> 8664 bytes inwx_api.py | 132 ++++++++ main.py | 19 ++ tui_app.py | 419 +++++++++++++++++++++++++ tui_modals.py | 125 ++++++++ 10 files changed, 754 insertions(+), 1 deletion(-) create mode 100644 .env create mode 100644 .gitignore create mode 100644 __pycache__/inwx_api.cpython-313.pyc create mode 100644 __pycache__/tui_app.cpython-313.pyc create mode 100644 __pycache__/tui_modals.cpython-313.pyc create mode 100644 inwx_api.py create mode 100755 main.py create mode 100644 tui_app.py create mode 100644 tui_modals.py diff --git a/.env b/.env new file mode 100644 index 0000000..51c1000 --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +INWX_USER=dein_nutzername +INWX_PASS=dein_passwort + +# Optional: Trage hier dein 2FA/TOTP-Secret ein (Base32), +# damit der 2FA-Code automatisch erzeugt wird und keine Abfrage nötig ist. +# Wenn leer, fragt das TUI dich nach dem Code aus deiner Authenticator-App. +INWX_2FA_SECRET= + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/README.md b/README.md index e6ed673..68f6530 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,51 @@ -# inwx_tui +# INWX DNS TUI Client + +Python ane **Textual** no upyog kari banavavama aavelo ek aadhunik, clickable Terminal User Interface (TUI) client, jena thi tame INWX na DNS records ane domains ne sahelta thi manage kari shako chho. + +## Features + +- **Clickable GUI in Terminal**: Mouse ane keyboard shortcuts banne dwara chalavi shakay chhe. +- **Domain Overview**: Login thaya pachhi account ma rahela badha domains automatic left sidebar ma load thai jaay chhe. +- **DNS Records Management**: + - DNS records jova (A, AAAA, CNAME, MX, TXT, SRV, CAA, NS, PTR, vagere). + - Double-click athva `Enter` dabavi ne record edit karvo. + - Nava records dropdown menu ane custom input sathe add karva. + - Records delete ane live refresh karva. +- **2FA / TOTP Support**: + - Jo account ma 2FA chalu hoy to j code mangshe. + - `.env` ma TOTP secret mukine automatic unlock pan kari shakay chhe. +- **Non-blocking Workers**: Background async workers lidhe API call vakhte UI freeze thatu nathi. + +--- + +## File Structure + +```text +inwx_tui/ +├── .env # INWX API credentials ane config +├── inwx_api.py # INWX JSON-RPC API logic ane session management +├── tui_modals.py # Modal popups (Login, 2FA prompt, Record Edit/Add) +├── tui_app.py # Textual TUI main interface ane event handling +├── main.py # Application start karvani entrypoint script +└── README.md # Project documentation + +## Requirements + +Python 3.10+ + +Linux / macOS / WSL (Windows Terminal) + +´´´bash +pip install textual python-dotenv requests pyotp + +## Usage + +```bash +python3 main.py +``` +Or, by granting execution permission: + +```bash +chmod +x main.py +./main.py diff --git a/__pycache__/inwx_api.cpython-313.pyc b/__pycache__/inwx_api.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1e271a5593cf84350259fca94ee60fe523620055 GIT binary patch literal 6577 zcmb7ITW}lKdEP5_xr+-4h@?b`<3YCQ?ZlIl$o{@DVGjsng?a35A|iHeOwHhA!ef_Z2Qpa&6fO-s1NOb zcCi3RLA7(p{rBuSXa93>{{Q>_|LjwbMr<5e;|xEb~*+;kjv^3O426ITOBkNty`D(romMG@eST zT2>g%Wf#(DM3QC{J+7prv2^aOoSKzh&sfi=GTAF>J(N&S3g?n>RnyfGX(}9*rc_Nu zFnlI=C6$az)g;OVZ%L;4$Gb{%PSLX^N?|l87bJSyj89 z?^?)aGx|t>zmiFY66r;hzLL&{;_1cyt9n{PnRtJ^yd~C+0ZGI4^s6M^+RK%`h_mUH z7J}%uw*6?iC&Jcrm8;HnYhSF1mZ>kLiTuJfF<(*>b%>DJYota;i3s=NwMXgKpfj5 zEyxAJT#rua%he5EM%El)fW+;@WS1y|3m`-?*Frtoq>c)2LNx;*~KHi4m%jZg*_`UqQ`ETD^y;bCT*7YCX{L#(7{j-f! z@xWNg7g(KLJ7)O048E%*`c~%*ai78LdkQNd;lO&2B<>v`G>csZNR6tx z;6eN{6vYi@r>pH=;xHAZWaa?$$!ru{M|R0vh|%Z{;&NrK2;R{hrJJ~nZEuw*b(weS z5sMFI37-jKqm)#*sr#b-$8avANmhW@q9C3o~B<9GVC;ZGkp4Pw-vwgESGwQQO zC6fWJQ?kjlW^q%#!J(@YrfWQ%zLr#<(SxCZ$M^?7U`(z|V5VzP1(F4)%_vA&)J-ax zcnszs9?&MYpeopPO(vma6_d**7u9qw`}lvM|UZBAWCslcQ`%4Fi{glf`@ z`h2j#6y!-+K64=)J2PSONCo27v!E)I)&Uu#GiPFx;fa|GXTq~4gV$oRiQHmFHye?X z)YaHr8UX^7Y)&@?^+sIH;9my45nIuu(-d@}Zdfhq!2$z_zAn8Wd;dInxnO;Xjd zS77u>5^BYxcz{|AVoC}P!{a`<^q+%RCbl@wyMJD43aq%ceC09|y z$AO;->$i-3M^?C1zQFJK)J5#tQ}XY||6bo`4Xn?#JoTxUWnX&~*n989_fP!YzKx|% zX8+~QkKQz9E*2**8CMd8fV#q$_{L4XtH5{t8{bp%wcdIA_S^5jWAGineKz>-J{;;8J4k=j z!j1LNAGMRX-ot{dkIY`c5JY~6`WTNgEi;!$nGI1jCW?dPJrKhlkArRQO--qyAIPwD8P5~3ubAN zTb)_(IAE8`ocs!lcvY}LZKB)yM6o6JhHH2E!iAPCT?8o9IEM5We_`e|@n7l(sKosm` zByu5=f9WhlT}f7T9l+gZvy^l_tw~lgsGP2V$Gow$Ms_|fW;v!2C+B{ z!lWUDn_S!?ULEz}_9IweCpKxw9fA$k)*&2DEaF1PaP2tmcfs$?r65~@d#ud8b4u*G zl0rk!uj6+xm088+Gsr{W$D(u4gXh=%;AlW*RSMe*08e$5_I4KhgSBkc!gl^KY}K^q zUi;nl4?2M!#ilzWw?|gziemfvE=WL1zCCM~Hrx9O?R`aGU#We+UB7>|&^~-u*lL~L zYHh!lznfnV-j5Vp2Uey_Egkn#cT?*}i!H&GDPYsaR-G!9@`3Z7~MxUE^eH8 z7%`+%M&Oj;KDFg*S#$l3@K?h6QRC(D;_eBifu42sXY$7GpZ>{a z->E|1sbb%lv46ZMOe~L=IN^Is?=G!e|Moj;y+y90( zh{p})INAgH;&4?8whO+VVvN-u%np`^C=es6){{xcdJ^PbZxQqG7o=+T62JzD5U`&G zY@9(nAgoz!FuLr1Gz%S=vGq0(%fx@-EaDO0Xf#3K?=Cg(xh;Ik61@NU^;W#B5$wI) z%bb;Iuyv7&zk&X8f#X}*CuQ35m@wYtx+9rt46EDE_E%#ZWZ+RUKztqIML(fa0b&|r z0^|-{l)`Z>0X9AlF+EDv)xud_+;Hl8aI4sS{wKHYcRjrEuZMp5 z_6O-z<(_(1U7P;P^n+W4=JST{yuqK}hJ>l2f7%eI4QBcePiUegOzJqH|Bx5PXxE1= z%^bcxblIpdNZDd_naD9Q(aaE@)yGMOJJKSTZQzSJw zL0p6%RP~Hm+chA##^Iwt*&#Y=9pW4ucWifvSYz}Mf;0U<6*hKUWlv zf?-APo#VHUuO55y5M_$t#rZg=b)rDYnAtg$o$$FVZ4TAw@WS(AqCd)LN)$gj9oa<@HJ z`te7bv$E+hSp|T%;9)0`R!vaN4R=s$JyX
emL*A&l#Zrk1`)D(QQEbqN#qiVRs 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 f`FqOu50v-E)ZsLBc+K##NmqKKOFROl3LpeO+f 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 +License: GNU General Public License v3.0 (GPL-3.0) +Year: 2026 +""" + +import os +import json +import requests +from dotenv import load_dotenv + +load_dotenv() + +API_ENDPOINT = "https://api.domrobot.com/jsonrpc/" +SESSION_ID = None + +def get_stored_credentials(): + user = os.getenv("INWX_USER", "").strip() + pwd = os.getenv("INWX_PASS", "").strip() + secret = os.getenv("INWX_2FA_SECRET", "").strip() + + placeholders = {"", "username", "dein_nutzername", "dein_benutzername", "example_user", "password", "passwort"} + if user.lower() in placeholders or pwd.lower() in placeholders: + return None, None, secret + return user, pwd, secret + +def api_call(method, params=None): + global SESSION_ID + if params is None: + params = {} + + headers = {"Content-Type": "application/json"} + if SESSION_ID: + headers["Cookie"] = f"domrobot={SESSION_ID}" + + payload = { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1 + } + + try: + response = requests.post(API_ENDPOINT, headers=headers, data=json.dumps(payload), timeout=12) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + return {"code": -1, "msg": f"Netzwerkfehler: {e}"} + +def login(user, password): + global SESSION_ID + headers = {"Content-Type": "application/json"} + payload = { + "jsonrpc": "2.0", + "method": "account.login", + "params": {"user": user, "pass": password}, + "id": 1 + } + + try: + response = requests.post(API_ENDPOINT, headers=headers, data=json.dumps(payload), timeout=12) + response.raise_for_status() + result = response.json() + + if result.get("code") == 1000: + if "domrobot" in response.cookies: + SESSION_ID = response.cookies["domrobot"] + else: + return "ERROR", "Kein Session-Cookie von INWX erhalten." + + res_data = result.get("resData", {}) + tfa_val = res_data.get("tfa") + + # INWX liefert bei inaktivem 2FA None, 0, "0" oder "NONE" + if tfa_val in (0, "0", None, False, "", "NONE", "none"): + return "SUCCESS", "Login erfolgreich." + + return "NEEDS_2FA", f"2FA erforderlich ({tfa_val})." + + return "ERROR", result.get("msg", "Zugangsdaten ungültig.") + except requests.exceptions.RequestException as e: + return "ERROR", str(e) + +def unlock_2fa(tan_code): + res = api_call("account.unlock", {"tan": str(tan_code).strip()}) + if res.get("code") == 1000: + return True, "2FA erfolgreich entsperrt." + return False, res.get("msg", "2FA-Code ungültig.") + +def logout(): + global SESSION_ID + if SESSION_ID: + api_call("account.logout") + SESSION_ID = None + +def get_domain_list(): + res = api_call("nameserver.list") + if res.get("code") == 1000: + domains_data = res.get("resData", {}).get("domains", []) + return True, [d.get("domain") for d in domains_data if "domain" in d] + return False, res.get("msg", "Fehler beim Abrufen der Domainliste.") + +def get_dns_records(domain): + res = api_call("nameserver.info", {"domain": domain}) + if res.get("code") == 1000: + return True, res.get("resData", {}).get("record", []) + return False, res.get("msg", "Fehler beim Laden der Records.") + +def add_dns_record(domain, name, record_type, content, ttl): + params = { + "domain": domain, + "name": name, + "type": record_type, + "content": content, + "ttl": int(ttl) + } + res = api_call("nameserver.createRecord", params) + return res.get("code") == 1000, res.get("msg", "OK") + +def update_dns_record(record_id, content=None, ttl=None): + params = {"id": int(record_id)} + if content: + params["content"] = content + if ttl: + params["ttl"] = int(ttl) + res = api_call("nameserver.updateRecord", params) + return res.get("code") == 1000, res.get("msg", "OK") + +def delete_dns_record(record_id): + res = api_call("nameserver.deleteRecord", {"id": int(record_id)}) + return res.get("code") == 1000, res.get("msg", "OK") diff --git a/main.py b/main.py new file mode 100755 index 0000000..26e3acf --- /dev/null +++ b/main.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""INWX DNS TUI Client +Author: Pascal Bouquet +License: GNU General Public License v3.0 (GPL-3.0) +Year: 2026 +""" + +import sys +from tui_app import InwxTUIApp + +def main(): + app = InwxTUIApp() + try: + app.run() + except KeyboardInterrupt: + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/tui_app.py b/tui_app.py new file mode 100644 index 0000000..3c15913 --- /dev/null +++ b/tui_app.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""INWX DNS TUI Client +Author: Pascal Bouquet +License: GNU General Public License v3.0 (GPL-3.0) +Year: 2026 +""" + +import pyotp +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Header, Footer, Button, DataTable, Static, OptionList, Label +from textual.widgets.option_list import Option +from textual import work + +import inwx_api as api +from tui_modals import LoginModal, TwoFactorModal, RecordEditModal + +class InwxTUIApp(App): + CSS = """ + Screen { + layout: vertical; + background: $surface; + } + + #main_container { + height: 1fr; + } + + #sidebar { + width: 36; + min-width: 32; + background: $panel; + border-right: solid $primary-background; + } + + #sidebar_title { + width: 100%; + height: 3; + content-align: center middle; + text-style: bold; + background: $boost; + color: $accent; + border-bottom: solid $primary-background; + } + + #domain_list { + height: 1fr; + border: none; + background: $panel; + } + + #domain_list:focus { + border: none; + } + + #content_area { + width: 1fr; + height: 1fr; + } + + #table_header_info { + width: 100%; + height: 3; + content-align: left middle; + padding-left: 2; + background: $boost; + color: $text; + text-style: bold; + border-bottom: solid $primary-background; + } + + DataTable { + height: 1fr; + border: none; + } + + #action_bar { + height: 3; + background: $boost; + align: left middle; + padding: 0 1; + border-top: solid $primary-background; + } + + .action-btn { + margin-right: 1; + height: 1; + min-width: 16; + border: none; + } + + #status_bar { + height: 1; + background: $surface-darken-2; + color: $text-muted; + padding-left: 1; + } + + .dialog-box { + padding: 1 2; + background: $surface; + border: thick $primary; + width: 65; + height: auto; + align: center middle; + } + + .dialog-title { + text-style: bold; + color: $accent; + margin-bottom: 1; + width: 100%; + text-align: center; + } + + .field-label { + color: $text-muted; + margin-top: 1; + } + + .type-row { + height: 3; + align: left middle; + margin-bottom: 1; + } + + #rec_type_select { + width: 22; + margin-right: 1; + } + + #rec_type_input { + width: 1fr; + margin-bottom: 0; + } + + .dialog-buttons { + height: 3; + margin-top: 1; + align: right middle; + } + + .modal-btn { + min-width: 14; + height: 3; + margin-left: 1; + } + + Input { + margin-bottom: 0; + } + """ + + BINDINGS = [ + ("q", "quit", "Beenden"), + ("r", "refresh_current", "Aktualisieren"), + ("a", "add_record", "Neu"), + ("e", "edit_record", "Bearbeiten"), + ("d", "delete_record", "Löschen"), + ] + + def __init__(self): + super().__init__() + self.current_domain = "" + self.records_cache = {} + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + + with Horizontal(id="main_container"): + with Vertical(id="sidebar"): + yield Label("🌐 Domains", id="sidebar_title") + yield OptionList(id="domain_list") + + with Vertical(id="content_area"): + yield Label("Keine Domain gewählt", id="table_header_info") + yield DataTable(id="record_table", cursor_type="row") + + with Horizontal(id="action_bar"): + yield Button("➕ Hinzufügen (a)", variant="success", id="btn_add", classes="action-btn") + yield Button("✏️ Bearbeiten (e)", variant="warning", id="btn_edit", classes="action-btn") + yield Button("🗑️ Löschen (d)", variant="error", id="btn_delete", classes="action-btn") + + yield Static("Initialisiere...", id="status_bar") + yield Footer() + + def on_mount(self) -> None: + table = self.query_one("#record_table", DataTable) + table.add_columns("ID", "Name/Host", "Typ", "TTL", "Inhalt/Content") + + user, pwd, secret = api.get_stored_credentials() + if user and pwd: + self.set_status("Melde über .env an...") + self.async_login(user, pwd, secret) + else: + self.prompt_login_modal() + + def set_status(self, text: str) -> None: + try: + self.query_one("#status_bar", Static).update(text) + except Exception: + pass + + @work(thread=True) + def async_login(self, user, pwd, secret=None): + status, msg = api.login(user, pwd) + + if status == "SUCCESS": + self.app.call_from_thread(self.set_status, "✅ Angemeldet. Lade Domains...") + self.async_load_domains() + + elif status == "NEEDS_2FA": + if secret: + try: + totp = pyotp.TOTP(secret) + tan = totp.now() + ok, unlock_msg = api.unlock_2fa(tan) + if ok: + self.app.call_from_thread(self.set_status, "✅ 2FA via Secret bestätigt.") + self.async_load_domains() + return + except Exception: + pass + self.app.call_from_thread(self.prompt_2fa_modal) + + else: + self.app.call_from_thread(self.set_status, f"❌ Login fehlgeschlagen: {msg}") + self.app.call_from_thread(self.prompt_login_modal) + + def prompt_login_modal(self) -> None: + def on_login_submitted(creds): + if not creds: + self.set_status("Abgebrochen.") + return + u, p = creds + _, _, secret = api.get_stored_credentials() + self.async_login(u, p, secret) + + self.push_screen(LoginModal(), on_login_submitted) + + def prompt_2fa_modal(self) -> None: + def on_2fa_submitted(tan): + if not tan: + self.set_status("2FA abgebrochen.") + return + self.async_unlock(tan) + + self.push_screen(TwoFactorModal(), on_2fa_submitted) + + @work(thread=True) + def async_unlock(self, tan): + ok, msg = api.unlock_2fa(tan) + if ok: + self.app.call_from_thread(self.set_status, "✅ 2FA entsperrt.") + self.async_load_domains() + else: + self.app.call_from_thread(self.set_status, f"❌ 2FA-Fehler: {msg}") + self.app.call_from_thread(self.prompt_2fa_modal) + + @work(thread=True) + def async_load_domains(self): + ok, domains = api.get_domain_list() + + def update_ui(): + try: + d_list = self.query_one("#domain_list", OptionList) + d_list.clear_options() + if ok and domains: + for d in domains: + d_list.add_option(Option(prompt=d, id=d)) + d_list.highlighted = 0 + self.set_status(f"✅ {len(domains)} Domains geladen.") + self.async_load_dns_records(domains[0]) + elif ok: + self.set_status("Keine Domains vorhanden.") + else: + self.set_status(f"❌ Fehler: {domains}") + except Exception as e: + self.set_status(f"UI-Fehler: {e}") + + self.app.call_from_thread(update_ui) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_domain = str(event.option_id) + self.async_load_dns_records(selected_domain) + + @work(thread=True) + def async_load_dns_records(self, domain: str): + self.current_domain = domain + + def set_loading(): + try: + self.query_one("#table_header_info", Label).update(f"DNS-Zone: [b]{domain}[/b]") + self.set_status(f"Lade Einträge für {domain}...") + except Exception: + pass + + self.app.call_from_thread(set_loading) + + ok, data = api.get_dns_records(domain) + + def update_table(): + try: + table = self.query_one("#record_table", DataTable) + table.clear() + self.records_cache.clear() + + if ok: + for rec in data: + r_id = str(rec.get("id")) + self.records_cache[r_id] = rec + table.add_row( + r_id, + rec.get("name", "@"), + rec.get("type", ""), + str(rec.get("ttl", "")), + rec.get("content", ""), + key=r_id + ) + self.set_status(f"✅ {len(data)} Records für {domain} geladen.") + else: + self.set_status(f"❌ Fehler: {data}") + except Exception as e: + self.set_status(f"Tabelle konnte nicht aktualisiert werden: {e}") + + self.app.call_from_thread(update_table) + + def get_selected_record(self) -> dict | None: + table = self.query_one("#record_table", DataTable) + if table.row_count == 0 or table.cursor_coordinate is None: + return None + cell_key = table.coordinate_to_cell_key(table.cursor_coordinate) + return self.records_cache.get(cell_key.row_key.value) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + self.action_edit_record() + + def on_button_pressed(self, event: Button.Pressed) -> None: + btn_id = event.button.id + if btn_id == "btn_add": + self.action_add_record() + elif btn_id == "btn_edit": + self.action_edit_record() + elif btn_id == "btn_delete": + self.action_delete_record() + + def action_refresh_current(self) -> None: + if self.current_domain: + self.async_load_dns_records(self.current_domain) + else: + self.async_load_domains() + + # --- Asynchrone Worker-Methoden --- + + @work(thread=True) + def async_add_record(self, domain: str, data: dict) -> None: + ok, msg = api.add_dns_record( + domain, data["name"], data["type"], data["content"], data["ttl"] + ) + self.app.call_from_thread( + self.set_status, "✅ Record hinzugefügt." if ok else f"❌ Fehler: {msg}" + ) + if ok: + self.async_load_dns_records(domain) + + @work(thread=True) + def async_edit_record(self, record_id: str, data: dict) -> None: + ok, msg = api.update_dns_record(record_id, data["content"], data["ttl"]) + self.app.call_from_thread( + self.set_status, f"✅ Record {record_id} aktualisiert." if ok else f"❌ Fehler: {msg}" + ) + if ok: + self.async_load_dns_records(self.current_domain) + + @work(thread=True) + def async_delete_record(self, record_id: str) -> None: + ok, msg = api.delete_dns_record(record_id) + self.app.call_from_thread( + self.set_status, f"✅ Record {record_id} gelöscht." if ok else f"❌ Fehler: {msg}" + ) + if ok: + self.async_load_dns_records(self.current_domain) + + # --- UI Aktionen --- + + def action_add_record(self) -> None: + if not self.current_domain: + self.set_status("Erst Domain links auswählen.") + return + + def handle_add(data): + if data: + self.async_add_record(self.current_domain, data) + + self.push_screen(RecordEditModal(default_domain=self.current_domain), handle_add) + + def action_edit_record(self) -> None: + rec = self.get_selected_record() + if not rec: + self.set_status("Kein Record ausgewählt.") + return + + def handle_edit(data): + if data: + self.async_edit_record(rec["id"], data) + + self.push_screen(RecordEditModal(record=rec), handle_edit) + + def action_delete_record(self) -> None: + rec = self.get_selected_record() + if not rec: + self.set_status("Kein Record ausgewählt.") + return + + self.async_delete_record(rec["id"]) + + def action_quit(self) -> None: + api.logout() + self.exit() diff --git a/tui_modals.py b/tui_modals.py new file mode 100644 index 0000000..77fc6f1 --- /dev/null +++ b/tui_modals.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""INWX DNS TUI Client +Author: Pascal Bouquet +License: GNU General Public License v3.0 (GPL-3.0) +Year: 2026 +""" + +from textual.app import ComposeResult +from textual.containers import Vertical, Horizontal +from textual.widgets import Label, Input, Button, Select +from textual.screen import ModalScreen + +RECORD_TYPES = [ + ("A", "A"), + ("AAAA", "AAAA"), + ("CNAME", "CNAME"), + ("MX", "MX"), + ("TXT", "TXT"), + ("SRV", "SRV"), + ("CAA", "CAA"), + ("NS", "NS"), + ("PTR", "PTR"), + ("TLSA", "TLSA"), +] + +class LoginModal(ModalScreen[tuple[str, str] | None]): + """Login-Maske bei fehlender oder fehlerhafter .env.""" + def compose(self) -> ComposeResult: + with Vertical(classes="dialog-box"): + yield Label("🔐 INWX Login", classes="dialog-title") + yield Input(placeholder="Benutzername", id="user_input") + yield Input(placeholder="Passwort", password=True, id="pass_input") + with Horizontal(classes="dialog-buttons"): + yield Button("Einloggen", variant="primary", id="btn_submit", classes="modal-btn") + yield Button("Abbrechen", variant="error", id="btn_cancel", classes="modal-btn") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_submit": + user = self.query_one("#user_input", Input).value.strip() + pwd = self.query_one("#pass_input", Input).value.strip() + self.dismiss((user, pwd)) + else: + self.dismiss(None) + + +class TwoFactorModal(ModalScreen[str | None]): + """Fragt den 6-stelligen 2FA / TOTP-Code ab.""" + def compose(self) -> ComposeResult: + with Vertical(classes="dialog-box"): + yield Label("🛡️ 2FA-Bestätigung", classes="dialog-title") + yield Label("6-stelligen TAN-Code eingeben:") + yield Input(placeholder="z.B. 123456", id="tan_input", max_length=6) + with Horizontal(classes="dialog-buttons"): + yield Button("Bestätigen", variant="primary", id="btn_submit_tan", classes="modal-btn") + yield Button("Abbrechen", variant="error", id="btn_cancel", classes="modal-btn") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_submit_tan": + tan = self.query_one("#tan_input", Input).value.strip() + self.dismiss(tan) + else: + self.dismiss(None) + + +class RecordEditModal(ModalScreen[dict | None]): + """Dialog zum Anlegen und Bearbeiten von DNS-Records.""" + def __init__(self, record: dict | None = None, default_domain: str = ""): + super().__init__() + self.record = record + self.default_domain = default_domain + + def compose(self) -> ComposeResult: + is_edit = self.record is not None + title = f"Record ID {self.record['id']} bearbeiten" if is_edit else f"Neuer Record für {self.default_domain}" + + with Vertical(classes="dialog-box"): + yield Label(title, classes="dialog-title") + + if not is_edit: + yield Label("Name / Host (@ oder z.B. mail):", classes="field-label") + yield Input(placeholder="@", value="@", id="rec_name") + + yield Label("Record-Typ (Auswählen oder eintippen):", classes="field-label") + with Horizontal(classes="type-row"): + yield Select( + RECORD_TYPES, + value="A", + prompt="Typ wählen", + id="rec_type_select", + allow_blank=False + ) + yield Input(value="A", placeholder="Typ", id="rec_type_input") + + yield Label("Inhalt / Ziel:", classes="field-label") + val_content = str(self.record.get("content", "")) if is_edit else "" + yield Input(placeholder="z.B. IP, mx01.domain.tld, Text", value=val_content, id="rec_content") + + yield Label("TTL (Sekunden):", classes="field-label") + val_ttl = str(self.record.get("ttl", "3600")) if is_edit else "3600" + yield Input(placeholder="3600", value=val_ttl, id="rec_ttl") + + with Horizontal(classes="dialog-buttons"): + yield Button("Speichern", variant="success", id="btn_save", classes="modal-btn") + yield Button("Abbrechen", variant="error", id="btn_cancel", classes="modal-btn") + + def on_select_changed(self, event: Select.Changed) -> None: + """Überträgt die Dropdown-Auswahl direkt in das Text-Eingabefeld.""" + if event.select.id == "rec_type_select" and event.value != Select.BLANK: + type_input = self.query_one("#rec_type_input", Input) + type_input.value = str(event.value) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_save": + content = self.query_one("#rec_content", Input).value.strip() + ttl = self.query_one("#rec_ttl", Input).value.strip() or "3600" + + if self.record: + data = {"content": content, "ttl": ttl} + else: + name = self.query_one("#rec_name", Input).value.strip() + rec_type = self.query_one("#rec_type_input", Input).value.strip().upper() + data = {"name": name, "type": rec_type, "content": content, "ttl": ttl} + self.dismiss(data) + else: + self.dismiss(None)