#!/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()