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
This commit is contained in:
1 parent
b6574815e7
commit
dbb30b70f5
10 files changed
+754
-1
No files matched your search
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""INWX DNS TUI Client
|
||||
Author: Pascal Bouquet <pascal@ptbos.de>
|
||||
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)
|
||||
Reference in new issue
Block a user