From b76d5209cb262ba95d59129e101b59a4b6c1dd7d Mon Sep 17 00:00:00 2001 From: Pascal Bouquet Date: Tue, 2 Jun 2026 18:45:13 +0200 Subject: [PATCH] inital commit V0.1 --- firewalld-tui.py | 368 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100755 firewalld-tui.py diff --git a/firewalld-tui.py b/firewalld-tui.py new file mode 100755 index 0000000..8ca7845 --- /dev/null +++ b/firewalld-tui.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +""" +Firewalld Zonen-, Port-, Service- & IP-Manager + +Author: Pascal Bouquet +License: GNU General Public License v3.0 (GPL-3.0) +Year: 2026 +""" + +import subprocess +import sys +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Footer, Header, Input, Label, ListItem, ListView, Select + + +class AddZoneModal(ModalScreen): + def compose(self) -> ComposeResult: + with Vertical(id="dialog_container"): + yield Label("Neue Firewall-Zone erstellen", classes="dialog-title") + yield Input(placeholder="z.B. intern, dmz, custom-zone", id="dialog_zone_input") + with Horizontal(classes="dialog-buttons"): + yield Button("Übernehmen", variant="success", id="btn_confirm") + yield Button("Abbrechen", variant="error", id="btn_cancel") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_cancel": + self.dismiss(None) + elif event.button.id == "btn_confirm": + zone_name = self.query_one("#dialog_zone_input", Input).value.strip().lower() + if not zone_name or " " in zone_name: + self.app.notify("Ungültiger Name! Keine Leerzeichen erlaubt.", severity="warning") + return + self.dismiss({"type": "zone", "value": zone_name}) + + +class AddPortModal(ModalScreen): + def compose(self) -> ComposeResult: + with Vertical(id="dialog_container"): + yield Label("Port freigeben", classes="dialog-title") + yield Input(placeholder="z.B. 80 oder 443", id="dialog_port_input") + yield Select( + [("TCP", "tcp"), ("UDP", "udp")], + value="tcp", + id="dialog_proto_select", + allow_blank=False, + ) + with Horizontal(classes="dialog-buttons"): + yield Button("Übernehmen", variant="success", id="btn_confirm") + yield Button("Abbrechen", variant="error", id="btn_cancel") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_cancel": + self.dismiss(None) + elif event.button.id == "btn_confirm": + port = self.query_one("#dialog_port_input", Input).value.strip() + proto = self.query_one("#dialog_proto_select", Select).value + if not port.isdigit(): + self.app.notify("Ungültige Portnummer!", severity="warning") + return + self.dismiss({"type": "port", "value": f"{port}/{proto}"}) + + +class AddServiceModal(ModalScreen): + def __init__(self, available_services: list[str], **kwargs): + super().__init__(**kwargs) + self.available_services = available_services + + def compose(self) -> ComposeResult: + with Vertical(id="dialog_container"): + yield Label("Service erlauben", classes="dialog-title") + yield Select( + [(s, s) for s in self.available_services], + id="dialog_service_select", + allow_blank=False, + ) + with Horizontal(classes="dialog-buttons"): + yield Button("Übernehmen", variant="success", id="btn_confirm") + yield Button("Abbrechen", variant="error", id="btn_cancel") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_cancel": + self.dismiss(None) + elif event.button.id == "btn_confirm": + service = self.query_one("#dialog_service_select", Select).value + if not service or service == Select.BLANK: + self.app.notify("Kein Service ausgewählt!", severity="warning") + return + self.dismiss({"type": "service", "value": str(service)}) + + +class AddSourceModal(ModalScreen): + def compose(self) -> ComposeResult: + with Vertical(id="dialog_container"): + yield Label("IP-Adresse / Subnetz erlauben", classes="dialog-title") + yield Input(placeholder="z.B. 192.168.1.50 oder 10.0.0.0/24", id="dialog_ip_input") + with Horizontal(classes="dialog-buttons"): + yield Button("Übernehmen", variant="success", id="btn_confirm") + yield Button("Abbrechen", variant="error", id="btn_cancel") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_cancel": + self.dismiss(None) + elif event.button.id == "btn_confirm": + ip = self.query_one("#dialog_ip_input", Input).value.strip() + if not ip: + self.app.notify("Eingabe darf nicht leer sein!", severity="warning") + return + self.dismiss({"type": "source", "value": ip}) + + +class FirewallTUI(App): + CSS = """ + Screen { + align: center middle; + } + #main_container { + width: 105; + height: 35; + border: solid green; + padding: 1; + } + .section-title { + text-style: bold; + margin-top: 1; + } + Horizontal { + height: auto; + margin-bottom: 1; + } + ListView { + border: round rgb(128, 128, 128); + height: 14; + } + .list-container { + width: 1fr; + margin: 0 1; + } + .action-buttons-row { + margin-top: 1; + } + #branding_label { + text-align: center; + color: rgba(255, 255, 255, 0.5); + margin-top: 1; + width: 100%; + } + ModalScreen { + align: center middle; + background: rgba(0, 0, 0, 0.5); + } + #dialog_container { + width: 45; + height: auto; + border: thick $primary; + background: $surface; + padding: 1; + } + .dialog-title { + text-style: bold; + margin-bottom: 1; + text-align: center; + } + #dialog_container Input, #dialog_container Select { + margin-bottom: 1; + } + .dialog-buttons { + align: center middle; + } + .dialog-buttons Button { + margin: 0 1; + } + """ + + TITLE = "Firewalld TUI Manager" + + BINDINGS = [ + ("q", "quit", "Beenden"), + ("r", "refresh", "Aktualisieren"), + ("z", "add_zone_dialog", "Zone +") + ] + + def compose(self) -> ComposeResult: + yield Header() + with Vertical(id="main_container"): + yield Label("Firewall-Zone auswählen:", classes="section-title") + yield Select([], id="zone_select", allow_blank=True) + + yield Label("© Pascal Bouquet V0.1 2026", id="branding_label") + + with Horizontal(): + with Vertical(classes="list-container"): + yield Label("Offene Ports:", classes="section-title") + yield ListView(id="port_list") + with Horizontal(classes="action-buttons-row"): + yield Button("Port +", variant="success", id="action_add_port") + yield Button("Löschen", variant="error", id="action_del_port") + + with Vertical(classes="list-container"): + yield Label("Aktive Services:", classes="section-title") + yield ListView(id="service_list") + with Horizontal(classes="action-buttons-row"): + yield Button("Service +", variant="success", id="action_add_srv") + yield Button("Löschen", variant="error", id="action_del_srv") + + with Vertical(classes="list-container"): + yield Label("Erlaubte IPs (Sources):", classes="section-title") + yield ListView(id="source_list") + with Horizontal(classes="action-buttons-row"): + yield Button("IP +", variant="success", id="action_add_src") + yield Button("Löschen", variant="error", id="action_del_src") + + + yield Footer() + + def on_mount(self) -> None: + if not self.check_firewalld(): + self.notify("Fehler: firewalld läuft nicht!", severity="error") + self.exit() + return + self.reload_zones() + self.all_system_services = self.run_cmd(["--get-services"]).split() + + def run_cmd(self, cmd: list[str]) -> str: + try: + res = subprocess.run(["firewall-cmd"] + cmd, capture_output=True, text=True, check=True) + return res.stdout.strip() + except subprocess.CalledProcessError as e: + return f"Error: {e.stderr.strip()}" + + def check_firewalld(self) -> bool: + return "running" in self.run_cmd(["--state"]) + + def get_all_zones(self) -> list[str]: + zones_str = self.run_cmd(["--get-zones"]) + return ["public"] if "Error" in zones_str or not zones_str else zones_str.split() + + def reload_zones(self, select_zone: str = None) -> None: + zones = self.get_all_zones() + default_zone = self.run_cmd(["--get-default-zone"]) + zone_select = self.query_one("#zone_select", Select) + + zone_select.set_options([(z, z) for z in zones]) + zone_select.allow_blank = False + + if select_zone and select_zone in zones: + zone_select.value = select_zone + elif default_zone in zones: + zone_select.value = default_zone + + def update_zone_data(self, zone: str) -> None: + ports_str = self.run_cmd(["--zone", zone, "--list-ports"]) + p_list = self.query_one("#port_list", ListView) + p_list.clear() + if ports_str and "Error" not in ports_str: + for port in ports_str.split(): + p_list.append(ListItem(Label(port))) + else: + p_list.append(ListItem(Label("(Keine Ports)"))) + + srv_str = self.run_cmd(["--zone", zone, "--list-services"]) + srv_list = self.query_one("#service_list", ListView) + srv_list.clear() + if srv_str and "Error" not in srv_str: + for srv in srv_str.split(): + srv_list.append(ListItem(Label(srv))) + else: + srv_list.append(ListItem(Label("(Keine Services)"))) + + src_str = self.run_cmd(["--zone", zone, "--list-sources"]) + s_list = self.query_one("#source_list", ListView) + s_list.clear() + if src_str and "Error" not in src_str: + for src in src_str.split(): + s_list.append(ListItem(Label(src))) + else: + s_list.append(ListItem(Label("(Keine IPs/Sources)"))) + + def on_select_changed(self, event: Select.Changed) -> None: + if event.select.id == "zone_select" and event.value and event.value != Select.BLANK: + self.update_zone_data(str(event.value)) + + def action_refresh(self) -> None: + current_zone = self.query_one("#zone_select", Select).value + if current_zone and current_zone != Select.BLANK: + self.update_zone_data(str(current_zone)) + self.notify("Daten aktualisiert.") + + def action_add_zone_dialog(self) -> None: + self.push_screen(AddZoneModal(), self.handle_modal_result) + + def handle_modal_result(self, result) -> None: + if not result: + return + + if result["type"] == "zone": + val = result["value"] + res = self.run_cmd(["--new-zone", val, "--permanent"]) + if "success" in res: + self.run_cmd(["--reload"]) + self.reload_zones(select_zone=val) + self.notify(f"Zone '{val}' erfolgreich angelegt!") + else: + self.notify(f"Fehler: {res}", severity="error") + return + + current_zone = str(self.query_one("#zone_select", Select).value) + val = result["value"] + rtype = result["type"] + + cmd_add = [f"--zone={current_zone}", f"--add-{rtype}={val}"] + cmd_perm = [f"--zone={current_zone}", f"--add-{rtype}={val}", "--permanent"] + + res = self.run_cmd(cmd_add) + if "success" in res: + self.run_cmd(cmd_perm) + self.notify(f"{rtype.capitalize()} '{val}' erfolgreich hinzugefügt!") + self.update_zone_data(current_zone) + else: + self.notify(f"Fehler: {res}", severity="error") + + def on_button_pressed(self, event: Button.Pressed) -> None: + current_zone = self.query_one("#zone_select", Select).value + if not current_zone or current_zone == Select.BLANK: + return + + if event.button.id == "action_add_port": + self.push_screen(AddPortModal(), self.handle_modal_result) + elif event.button.id == "action_add_srv": + self.push_screen(AddServiceModal(self.all_system_services), self.handle_modal_result) + elif event.button.id == "action_add_src": + self.push_screen(AddSourceModal(), self.handle_modal_result) + + elif event.button.id == "action_del_port": + item = self.query_one("#port_list", ListView).highlighted_child + if item: + val = str(item.query_one(Label).renderable) + if "(" not in val: + self.run_cmd([f"--zone={current_zone}", f"--remove-port={val}"]) + self.run_cmd([f"--zone={current_zone}", f"--remove-port={val}", "--permanent"]) + self.notify(f"Port {val} entfernt.") + self.update_zone_data(str(current_zone)) + + elif event.button.id == "action_del_srv": + item = self.query_one("#service_list", ListView).highlighted_child + if item: + val = str(item.query_one(Label).renderable) + if "(" not in val: + self.run_cmd([f"--zone={current_zone}", f"--remove-service={val}"]) + self.run_cmd([f"--zone={current_zone}", f"--remove-service={val}", "--permanent"]) + self.notify(f"Service {val} entfernt.") + self.update_zone_data(str(current_zone)) + + elif event.button.id == "action_del_src": + item = self.query_one("#source_list", ListView).highlighted_child + if item: + val = str(item.query_one(Label).renderable) + if "(" not in val: + self.run_cmd([f"--zone={current_zone}", f"--remove-source={val}"]) + self.run_cmd([f"--zone={current_zone}", f"--remove-source={val}", "--permanent"]) + self.notify(f"Source IP {val} entfernt.") + self.update_zone_data(str(current_zone)) + + +if __name__ == "__main__": + app = FirewallTUI() + app.run()