From d15c927f55009a2922f289adce390b9c21e9f7f8 Mon Sep 17 00:00:00 2001 From: Pascal Bouquet Date: Thu, 24 Sep 2026 13:51:42 +0200 Subject: [PATCH] feat: initial commit for ipv64 DNS TUI client - Implement modular Textual TUI (main, app, widgets, config, api_client) - Integrate IPv64.net REST API client using httpx - Support record listing, creation, in-place edit, and deletion - Add flexible token resolution (.env, env export, interactive prompt) - Add bilingual README (DE/EN) and project configuration --- .env | 1 + README.md | 55 ++++- __pycache__/api_client.cpython-313.pyc | Bin 0 -> 6106 bytes __pycache__/app.cpython-313.pyc | Bin 0 -> 14098 bytes __pycache__/config.cpython-313.pyc | Bin 0 -> 1571 bytes __pycache__/widgets.cpython-313.pyc | Bin 0 -> 5879 bytes api_client.py | 106 ++++++++++ app.py | 274 +++++++++++++++++++++++++ config.py | 42 ++++ main.py | 19 ++ widgets.py | 107 ++++++++++ 11 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 .env create mode 100644 __pycache__/api_client.cpython-313.pyc create mode 100644 __pycache__/app.cpython-313.pyc create mode 100644 __pycache__/config.cpython-313.pyc create mode 100644 __pycache__/widgets.cpython-313.pyc create mode 100755 api_client.py create mode 100755 app.py create mode 100755 config.py create mode 100755 main.py create mode 100755 widgets.py 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 0000000000000000000000000000000000000000..424c2190d3cfa62d6994ca62c511d9887fa1efbb GIT binary patch literal 6106 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 P9!Qrc?fpAJ>S_2lz#g;x literal 0 HcmV?d00001 diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8d2a0f53a68251f21d488226532e66ed22f2a3f GIT binary patch literal 14098 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

-m!i|OO~;u`ISlINx?K$tfoOoAP_Dg*`f%(0O+33Cc+GZFh?Q_4lrjSCk`-2BFyXva|Oc8 zc#TF4@RDF@C?4UJlC3dC*OR^au2WOaV#;4Z@F@km&h4K!XWiTHa@!%gw4r&iI_vJd z%XNO@s$AOGlyf!TFRPv#&Xoo3S8QCWZOv7*-SxKJuc)0Hnm@I4a4=hO>aO<`d@pTk zU3~VV{%pnByWX>ptWpxq}bAl&AWQ z#9i3#ZvX5v^ZT>z=DS=oe7rI7hWn8ZYPeSx`0^3d(sZw^@k>(Zo~!v!(h2DM>sxzj z=pWmAY|KCH?B2oN*iZ)18x79x{p^ih9FBJcf#T*4d-opO%{D8f+}v;PvDj{zaQv3d z-cxD4<+B3KV2KpLv-@56J$N4iMT&%}Aw02b9)TX#rMb3ylOScnbV5%L(?h!aC=^$u z4#*hm9#!J-Ag_mXsZyZS_H92%hzZ!W7w9*cLDmWTZXCqjAWL=(H~6Kyu7sy#1~doR z)PGE7&Rm+3Z9-;5k>awYKRFRiq-FDI6i)?M*#>q8x)cEjCLAR@KSlG6^bMx=7q>Nn=1K^+JF-Z^i3> z%YD_ow58>j)j4nH47*ahb%tH$s#iEyo~zDs)pPap?EFiMPh1~c=8mtp%k%CnS@)Kl zduyKCn&Y$stfz1>1n4j+%#@1xE#lBtD~Og`!f7*%R_m}xhi zHa!WFzyS=AG$$G8wGK0d8U$t-13h_kMrDCkhX*Z}q6(BlcyiY~+6-Y}46{QU)?~wH z*sReImacf5LW7ZLGf-1Q1{9ErrQ4dP6TYL+r*+D(2`n4QbE-RR)?3+v`y`n0!YLsr zM*1v&gSu*oDy5ewnx7iAxuRFNs26L&*tjjdpJ12*#ipXl)TnW}!YrSv2MVKAXNR$> zMjma|hqh`dg|{YRc-%f+NY`S+by}>jwr)zW>^2qpfQ3;XJ@}Gj;(HLZwrXSq@~L;uD-|UF{(&~;0C9wPXairI4 zB0?chEi`E;O}pz9xv1&@2=94x0D|cMO_s+L?;TfV$)re~#Q)4My~-Z9_2Xv)>>o;k6ywRzdO`QzaJ zeDF{eVUpM5cyJiMfEmV2M zTeVkf-x+&1`F8SNS#ZT$llL}cy$$mhm%S~J@2|@H8sPszd%o##w(0N(_4)2I+3qvB zrZX!$n({k3vO79v2k!anf4iY}rLk>s>$S$kr>+HOk1hLmloEX07$T|ncLaa>@_R4O z4lMiYA5~K2q1777>(6_(!G8d-ymxEXyLG-kzim%;+n#&gy&so?THf20^>!_Ldp>r0 zubjGk>K$o*-`~AFmt1N-v{ZZep7Y4(dnn(oKYq?pm0dK}bV4w!|9;p&d8;2#G|Z<` zY;I!y1(=<|yt66mZ2Iq22D1JnJr8T~dU;kFnl9Vukn}i@h(z-0}MR0?eIy7AWro zSez1KAg-}gBpIC)OYB%Id>HsW_yqXk&2_|xp|)fK$omRNB_as}_?pK^Qwq~Xu*!rP zBN_qVQxkR*``Dy@lhFM9#J0YAQpB} zaV-9#OdrMOND>?nN-(k!t7+j$xsRPC_6vQW}BZ1T;|4WmVaR zp2gY6aJDMoh!Y1zNCD^r_6x+I7mv#{0h1JDTCq_58_1GwLtvzna;__m%Z?woRw`=p z6+5#PJM$II*^1_c;p^LS6^CXVE9INsYPs4nA6OX5mAB4Vf9I>2>AAn5YR>m=<=d6> z8y7}$)or;AyJmVmRuRwP^4a(Px zprX)2=Cvh2Y1H?qk>b{I5z0OTRJsynT#LrnprGmXfkL2rJ__wZs;=x6Qnm6#Y8a`u zx^b!Z%HEZ}w1AZmd|0uHdxiARul1kh8ErkLY+7Ev4r7`+i@G=-~i&t*HH zF#!BzYE-Yz=_Mvp8Q4ck-I18871ZcP`(t_kfQyeo0PO1UUO9OA;FTknkIbcV zj^K*Nf90jiF9AR%-cIB^jrSV@3)_Fzbge1Zuy@wH?5%qYvX*todS^Q4ZOnW3WW9i6 z`&Mm~w`rB3oFT$aC47YC5aFYGh^!~?i{_CIm@)%Edh17HW7vs<{lJ<_HIp2dU~a(O z8_mQfc<{1<-ZeEPCW0-;L0+KX2{`FG51C`U2!{^*Ux@L97#alm;g80!&J-!})glSn zDVf_EAi<7aMSsGwM0eZDb;Xms4$)6=h1h(0jry2#g5jCvw%+6F9-kFFBj1JJgI8g9 zsfOmCyThtshf!aJh--~Z><56|1kJ8}4EM2#>|>=B1HXi}X8*@l7}5$<24gWm(_!r0 zB9Fcg4a^s|L#D08qWLz9UDeSus1vuFV`v{GJ?FR#m%*X7E$=bhWL&h0C%@+;>qpSu#d z9GQ25hmKIxXUpqz;2>Bu>OqAlxeTXybNmCL2tz4x5^R#|Y# zeP*Rx4gckA_@lH9I`ev{`w+cIcONkSd>6!S)Y09Y<{R~074UJhlZLRw(}HbwA{PE# zffqw-(ohY=o)QCC5eU1E0&_sV4hhhT`out>8?%_e6f*~u)}co$yI!4Ixuj`WXUeci zFkvlK%PYwto(01kcHJ_Tb#tu6Iqhr)KBwBJ&L*uePL@()Ug_Ju}=VPR}eiS9aBrbJpJf zGvlicOPf8*1!pX?&n);?ywLL1ZK=D80>1YT+Q6xh1m6D%Ul`rA%e}f*BYy~JsHhhu+7crxiCYmudWYm9_Y$!|dhJvlEBVb{; zhPI^M_F6>7)~k_b3?~ZeN(fvh#G;KL=!_|CI;EXx=o^SIH5!8>Y%X=bjZ>$}U=&8Q zPOVlRi)N#78ffM~S(pet26OGg;t<|$^-aVue0zwP!~r~y%?%4yyphIGm>WCRP)6~H zjVX)g23tPIfwHjLAkPHvDOxBa4oibs2wRF~fgbWH{VWbOP9(Dg{aE{G`D@3f?gE20 zh4L`Fc9n=zwi2|WH{iErD>bY;+f|1vh83f_d|_*8UBuyP?XnVwE4XQYdKf}? zJe?K+4@4xT6?OgC+EdpMk*L#~dK7N}KN>}2d@>%JNb?C1?i5LYEFtu5sBGF_d^1CX ziZ2;Bl|}8454YE>QU-3>NYbEejweApU%|z;DPCG^N=)*{5lH@NK$THZQmc?U_~|N0 zPm76!h|h5$SHU4%^It<1XaomX=ui7qgo+j}iKIi*UM&?IxCwDQ9r^?*?;KWe;Aa>#c zA<2ru*@Npi3qgQWaO@3Sff-|%>`EoTU!v3jz8EXi3ZD}C7A9dPrE$gMizT#%Vv5W6 zWlT_^gII~idmHkkUxG9t+NsBUmUsXFdoUIDXxK_;@ZOy;;)Zzm{&sERTw!Mq5ET%pXmiYc<@4yOQ|5L|% zjy!)P%OCl`mE#BVHG{L(6<^I;omV^OUd;J+F12>$d|e-m-#LHd{L;|Vx!z}%o*m2e zj;;8s-a2*l)KbF}A60&2TdE&g_6y)I`2ss>aYKB^vbPiZp}4F=*{Tq+kL$9;P@WwI z*DfT?p1tbCuV=1$)UO8)OSStEW%0RZdN6MKu3Wr)arVWWhoA4yd4h|+Use9R@_NND zwtUc^YkTsyt%ty2wA2%xWuQE6c%E7I)~oSlXal>Jc4RBy#!DdY4P?Cmt%KXz7lp;+ z(9e6`&d-~uji>0(+|cT0Ra9By7Y{Ap$V>c}uzIg|HTJdAH-a5~EOm1S-Fw)4b7z+c zK5jMAka4T2%MKsE*+ciyW^fe;;p284-N%@3*PC&CC#Jd`@Zb*hJ>uW zFUZ{S`1^J;cj{Q6zq6CYDM1F}C58f)+uw!XgM%P@bp1fZElAg&|0iR?z|;VZ!Ptn< z55ba5ky!xuR|!I?W5Q^y>k3diK9D3f^7;l0TR;%7@D1%~!GdvFuomkU%T%7jlrmmM?tg;!C5>CpfuoyVZSyWt!}_y zi{SyXGP_=A(=r6!jahg%523Q!qr)+;pQw|<>S$ALt_vLM)C90zqc_#qDPnSg?vZeqykAh!-E9s_#WqupV|MqYgMMKptJ z`8EzPA|i~O>mZ_Bp&D{)?t02wbdWSA_zdcl#(;kbT7tkYz2ovV*53YQ=K&0kHCysE zjoF&U8PAGt_?^o6+Gn`?{>nLSzU*yB&R;)c`@N@Xu7AOotJRJQmuinLJG*cdnz`1z z@XW%AMQN$3bD2B1YI52-^i$e>*{XflZMmxMe0evV(|XFk638_4+OZH=*tmFNi9fpR z?fSTK<6Ey@ef9n4bCqqg<`rKh9KUAa1U8WK?N;%3*}wgpLEL9P%GU!BSAuRGn&Pmz zeetQqo~7FTdFO$w^8h&o?f8=vh53HHdwXv!eRJpL<89O}3w=CjzGdxpz{iK>^zjz+ zhdwioSJFWAVU-`pn@K!K<9G`R+w8s7%!i%+UO#i&!vf`PKMN_hs~L!w;FlR%1mvO~ zbVIP7|Jzuz)+CQ6!LUR@JXC}K76WCHk&7vGUPR;s9%16*8+|^N;Y#i=*REy9;#@bd zp;QUgfZsu3hdFb;DgatnuBz!C*Q^{Bnelz;5)Z#V!a*pz-UyA**~oWYxUY;y3+E}* z);jg3BcsRw>}iuN%8{IUvkd(h4<0Nn&{Gu+6-x%$r;iqUbd*}k@F1}iR|e_6?4G^! zenXB6=P7;g(WP)^N050;5ZI?a3J6SA6ubQ zI7s6F>*2CvD0NXFCrjcef-^Sdf~Raft@w@6D>w-|nGjae(ck0XJsgl3*#K9t$zR@p z#|vCu;8zymR<`U?FKC0eI}wQlXtV=L5N0~6OlKLc0S8q$-qfK zH0!mz_Qa~&%+}2_k0^MpI^AsL+@^;VydLsqcH2Ye7Pfh{oA$8o1!k3k*P{6$eywh^ zu>0pP{PflLUR|XidcFQ3eyw^X9+sD}XX%Fx0k-+!S$ZSe@vwttgR2&r-8Uz#Qt+BjKfMr_=3N3kzTn~Z`BH8Ymap78l`j+Yl1R&P+_ve=sJ7?2UF5M2;gQ1O@C%K)8CYP1BKtD;{O2)^FzY` literal 0 HcmV?d00001 diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6afd0b2245b1b39ad7fbc7fd73283791cd5cf7f1 GIT binary patch literal 1571 zcmb6ZO>Y}T^zC}rcIkjjxN4M8g6)LX!S5GQ6GZ%j`WBhCAM^WK|z zZ(4~&4A@$fzP+&~0Q~AR!QwkaE60eBKmh^@r|?;haBa*Jp5ah7bV(org6qN<%ynE4 zC;}1Bfg)~kB&>w5M`n2?G6XjS60Py+=w2gJ*qNV2Yk37-*~p+()4-M+TlQStCJP7^ zG^ebYXvOwk^RSB+>uvt!y1QvRQx&`v%Nb>CId}nG%x|EJ*usRF7raf=D5H)J?YuB` z7EN3%6ffXJ_VP5B*H@X^zHCTdSxQ+r`!nO=JpJ=V#UBLPpKX*Oo+P zLxD;aY*?yRH`Fb>OOv~{M*>dOF~RhS69d=Y!j`HVn9$+2u!ePPY~y1Gr>vVEb{uTd zFu|@zta9hV+K4>>gxGF<&-oW#I?)w_r#O)Sj6un_Z!d+L>sS;cfvk&zN3mc7ialL< zKn40zyA^!tOKfWxpp*z~6n>87!;vp!d zf-H(p1ymQ0ffDK3ZooL4hEji^FAW6LEFS@F+^_3D)e2qD$3_vmwxf{us&GCu3RNy0 z%@eRA;=OcYGb-P1MGWqP~9fQd0lPwk-< z!*ww_o|mp8JFtc~v9*Bq60zk?MiaZNV{3}|o!~7oZ~^NkW-Tv268ku=;kXEss!doV zHZ%Ge^+HQD+~bO4AMbC=AiWyEosu`SzQiNzKUkVKlMdd+8vWJ>rF) z!Nb(4{h`HX>iqrG>b=xzGbO(#{}qG&CqEWHkAD*XH1W-Fb8zlZO70I74bt#u zYyHlv?`I#x`y08x|42e|?5h{|`_8n0XUF-5V+Yc6Cx#znk3^>U$MHMxhu%{wY4|aH zZuQyF-C>TwbPtWF>^mx}D)Fa}Jk0@Vh zu6QP1B5ZWPze;C@fuj(|alZ)yC$uEsVn0FbXBhbf;_oCL^o=y6-^1a4z9AhA^mEBO L%AddxLCo!640&b? literal 0 HcmV?d00001 diff --git a/__pycache__/widgets.cpython-313.pyc b/__pycache__/widgets.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..50815473c9b91a059af23ad7f248e1905fef8f27 GIT binary patch literal 5879 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 +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 = ""