inital commit V0.1
This commit is contained in:
Executable
+368
@@ -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()
|
||||
Reference in New Issue
Block a user