inital commit V0.1

This commit is contained in:
Pascal Bouquet
2026-06-02 18:45:13 +02:00
parent 05fac8a3cf
commit b76d5209cb
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""
Firewalld Zonen-, Port-, Service- & IP-Manager
Author: Pascal Bouquet <pascal@ptbos.de>
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()