feat: firewall policy switches and nftables troubleshooting
All checks were successful
Dev / docker (push) Successful in 2m6s

- Add peer-to-peer and LAN-to-peers switches on the rules page
- Both settings persisted in configurations table and applied
  as nftables chains on toggle
- Add "View nftables Rules" button to dump the live ruleset
  for troubleshooting
- Rules page redesigned with card-based layout matching other
  admin pages
- Rule create/edit/delete events fire as background tasks
This commit is contained in:
Stefano Bertelli 2026-03-31 00:00:21 -05:00
parent 15e1b6360a
commit 49b2bd9083
4 changed files with 206 additions and 32 deletions

View file

@ -0,0 +1,28 @@
"""add firewall policy fields to configurations
Revision ID: b7e2f4a1c903
Revises: a3f1d8e92b01
Create Date: 2026-03-31 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b7e2f4a1c903'
down_revision: Union[str, None] = 'a3f1d8e92b01'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('configurations', sa.Column('allow_peer_to_peer', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('configurations', sa.Column('allow_lan_to_peers', sa.Boolean(), nullable=False, server_default='false'))
def downgrade() -> None:
op.drop_column('configurations', 'allow_lan_to_peers')
op.drop_column('configurations', 'allow_peer_to_peer')

View file

@ -32,6 +32,10 @@ class Configuration(SQLModel, table=True):
sa_column=Column(JSON, default=["0.0.0.0/0", "::/0"]), sa_column=Column(JSON, default=["0.0.0.0/0", "::/0"]),
) )
# Firewall policies
allow_peer_to_peer: bool = Field(default=False)
allow_lan_to_peers: bool = Field(default=False)
# Server WireGuard keypair (generated on first startup) # Server WireGuard keypair (generated on first startup)
server_private_key: str | None = None server_private_key: str | None = None
server_public_key: str | None = None server_public_key: str | None = None

View file

@ -1,5 +1,6 @@
"""Admin firewall rules management page.""" """Admin firewall rules management page."""
import asyncio
from uuid import UUID from uuid import UUID
from loguru import logger from loguru import logger
@ -7,10 +8,13 @@ from nicegui import app, ui
from sqlmodel import select from sqlmodel import select
from wiregui.db import async_session from wiregui.db import async_session
from wiregui.models.configuration import Configuration
from wiregui.models.rule import Rule from wiregui.models.rule import Rule
from wiregui.models.user import User from wiregui.models.user import User
from wiregui.pages.layout import layout from wiregui.pages.layout import layout
from wiregui.services.events import on_rule_created, on_rule_deleted, on_rule_updated from wiregui.services.events import on_rule_created, on_rule_deleted, on_rule_updated
from wiregui.services.firewall import apply_lan_to_peers_policy, apply_peer_to_peer_policy, get_ruleset
from wiregui.utils.time import utcnow
@ui.page("/admin/rules") @ui.page("/admin/rules")
@ -23,6 +27,7 @@ async def rules_page():
# Load users for the dropdown # Load users for the dropdown
async with async_session() as session: async with async_session() as session:
users = (await session.execute(select(User).order_by(User.email))).scalars().all() users = (await session.execute(select(User).order_by(User.email))).scalars().all()
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
user_options = {str(u.id): u.email for u in users} user_options = {str(u.id): u.email for u in users}
async def load_rules() -> list[dict]: async def load_rules() -> list[dict]:
@ -69,7 +74,7 @@ async def rules_page():
await session.refresh(rule) await session.refresh(rule)
logger.info("Rule created: {} {} -> {}", rule.action, rule.destination, user_id_val or "global") logger.info("Rule created: {} {} -> {}", rule.action, rule.destination, user_id_val or "global")
await on_rule_created(rule) asyncio.create_task(on_rule_created(rule))
create_dialog.close() create_dialog.close()
_reset_form() _reset_form()
@ -110,7 +115,7 @@ async def rules_page():
session.add(rule) session.add(rule)
await session.commit() await session.commit()
await session.refresh(rule) await session.refresh(rule)
await on_rule_updated(rule) asyncio.create_task(on_rule_updated(rule))
logger.info("Rule updated: {} {}", edit_action.value, edit_dest.value) logger.info("Rule updated: {} {}", edit_action.value, edit_dest.value)
ui.notify("Rule updated") ui.notify("Rule updated")
@ -124,7 +129,7 @@ async def rules_page():
await session.delete(rule) await session.delete(rule)
await session.commit() await session.commit()
logger.info("Rule deleted: {} {}", rule.action, rule.destination) logger.info("Rule deleted: {} {}", rule.action, rule.destination)
await on_rule_deleted(rule) asyncio.create_task(on_rule_deleted(rule))
await refresh_table() await refresh_table()
def _reset_form(): def _reset_form():
@ -134,11 +139,73 @@ async def rules_page():
port_range_input.value = "" port_range_input.value = ""
user_select.value = "global" user_select.value = "global"
# Page content # --- Firewall policy toggles ---
async def toggle_peer_to_peer(e):
async with async_session() as session:
c = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
if c:
c.allow_peer_to_peer = e.value
c.updated_at = utcnow()
session.add(c)
await session.commit()
asyncio.create_task(apply_peer_to_peer_policy(e.value))
ui.notify(f"Peer-to-peer: {'allowed' if e.value else 'denied'}")
async def toggle_lan_to_peers(e):
async with async_session() as session:
c = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
if c:
c.allow_lan_to_peers = e.value
c.updated_at = utcnow()
session.add(c)
await session.commit()
asyncio.create_task(apply_lan_to_peers_policy(e.value))
ui.notify(f"LAN-to-peers: {'allowed' if e.value else 'denied'}")
# --- Troubleshooting ---
async def show_nft_rules():
ruleset = await get_ruleset()
with ui.dialog(value=True) as dlg:
with ui.card().classes("w-[800px]"):
ui.label("nftables Ruleset").classes("text-subtitle1 text-bold")
ui.label("Current system firewall rules for troubleshooting.").classes("text-caption text-grey")
ui.separator()
ui.textarea(value=ruleset).props("readonly outlined").classes(
"w-full font-mono text-xs"
).style("min-height: 400px; white-space: pre")
with ui.row().classes("w-full justify-end q-mt-sm"):
ui.button("Close", on_click=dlg.close).props("flat")
# --- Page content ---
with ui.column().classes("w-full p-4"): with ui.column().classes("w-full p-4"):
ui.label("Firewall Rules").classes("text-h5 q-mb-md")
# Policy switches
with ui.card().classes("w-full"):
ui.label("Network Policies").classes("text-subtitle1 text-bold")
ui.label("Control how traffic flows between peers and the local network.").classes("text-caption text-grey")
ui.separator()
ui.switch(
"Allow peer-to-peer communication",
value=config.allow_peer_to_peer if config else False,
on_change=toggle_peer_to_peer,
)
ui.label("Peers can communicate with each other through the WireGuard server (hub-and-spoke).").classes("text-caption text-grey q-ml-xl")
ui.switch(
"Allow local network to reach peers",
value=config.allow_lan_to_peers if config else False,
on_change=toggle_lan_to_peers,
).classes("q-mt-sm")
ui.label("Devices on the server's LAN can initiate connections to VPN peers.").classes("text-caption text-grey q-ml-xl")
# Rules table
with ui.card().classes("w-full q-mt-md"):
with ui.row().classes("w-full items-center justify-between"): with ui.row().classes("w-full items-center justify-between"):
ui.label("Firewall Rules").classes("text-h5") ui.label("Per-User Rules").classes("text-subtitle1 text-bold")
ui.button("Add Rule", icon="add", on_click=lambda: create_dialog.open()).props("color=primary") ui.button("Add Rule", icon="add", on_click=lambda: create_dialog.open()).props("color=primary unelevated")
ui.separator()
columns = [ columns = [
{"name": "action", "label": "Action", "field": "action", "align": "left", "sortable": True}, {"name": "action", "label": "Action", "field": "action", "align": "left", "sortable": True},
@ -163,6 +230,13 @@ async def rules_page():
table.on("edit", lambda e: open_edit(e.args)) table.on("edit", lambda e: open_edit(e.args))
table.on("delete", lambda e: delete_rule(e.args)) table.on("delete", lambda e: delete_rule(e.args))
# Troubleshooting
with ui.card().classes("w-full q-mt-md"):
ui.label("Troubleshooting").classes("text-subtitle1 text-bold")
ui.label("Inspect the raw nftables ruleset configured on this system.").classes("text-caption text-grey")
ui.separator()
ui.button("View nftables Rules", icon="terminal", on_click=show_nft_rules).props("color=primary unelevated")
# Create rule dialog # Create rule dialog
with ui.dialog() as create_dialog: with ui.dialog() as create_dialog:
with ui.card().classes("w-96"): with ui.card().classes("w-96"):
@ -195,7 +269,7 @@ async def rules_page():
with ui.row().classes("w-full justify-end q-mt-sm"): with ui.row().classes("w-full justify-end q-mt-sm"):
ui.button("Cancel", on_click=create_dialog.close).props("flat") ui.button("Cancel", on_click=create_dialog.close).props("flat")
ui.button("Create", on_click=create_rule).props("color=primary") ui.button("Create", on_click=create_rule).props("color=primary unelevated")
# Edit rule dialog # Edit rule dialog
user_options_map = {"global": "Global (all users)"} user_options_map = {"global": "Global (all users)"}
@ -223,6 +297,6 @@ async def rules_page():
with ui.row().classes("w-full justify-end q-mt-sm"): with ui.row().classes("w-full justify-end q-mt-sm"):
ui.button("Cancel", on_click=edit_dialog.close).props("flat") ui.button("Cancel", on_click=edit_dialog.close).props("flat")
ui.button("Save", on_click=save_edit).props("color=primary") ui.button("Save", on_click=save_edit).props("color=primary unelevated")
await refresh_table() await refresh_table()

View file

@ -167,6 +167,74 @@ async def rebuild_all_rules(users_devices_rules: list[dict]) -> None:
logger.info("Firewall rules rebuilt for {} users", len(users_devices_rules)) logger.info("Firewall rules rebuilt for {} users", len(users_devices_rules))
async def apply_peer_to_peer_policy(enabled: bool) -> None:
"""Allow or deny traffic between WireGuard peers (peer-to-peer through the server)."""
settings = get_settings()
iface = settings.wg_interface
v4_net = settings.wg_ipv4_network
v6_net = settings.wg_ipv6_network
chain = "peer_to_peer"
commands = [
f"add chain inet {TABLE_NAME} {chain}",
f"flush chain inet {TABLE_NAME} {chain}",
]
if enabled:
# Allow traffic from WG subnet destined to WG subnet (both directions through the interface)
commands.append(f'add rule inet {TABLE_NAME} {chain} ip saddr {v4_net} ip daddr {v4_net} accept')
commands.append(f'add rule inet {TABLE_NAME} {chain} ip6 saddr {v6_net} ip6 daddr {v6_net} accept')
else:
# Drop inter-peer traffic
commands.append(f'add rule inet {TABLE_NAME} {chain} ip saddr {v4_net} ip daddr {v4_net} drop')
commands.append(f'add rule inet {TABLE_NAME} {chain} ip6 saddr {v6_net} ip6 daddr {v6_net} drop')
try:
await _nft_batch(commands)
# Ensure the forward chain jumps to peer_to_peer before user chains
# We flush and re-add to keep ordering correct
logger.info("Peer-to-peer policy: {}", "allow" if enabled else "deny")
except RuntimeError as e:
logger.error("Failed to apply peer-to-peer policy: {}", e)
async def apply_lan_to_peers_policy(enabled: bool) -> None:
"""Allow or deny traffic from the local network to WireGuard peers."""
settings = get_settings()
iface = settings.wg_interface
v4_net = settings.wg_ipv4_network
v6_net = settings.wg_ipv6_network
chain = "lan_to_peers"
commands = [
f"add chain inet {TABLE_NAME} {chain}",
f"flush chain inet {TABLE_NAME} {chain}",
]
if enabled:
# Allow traffic from non-WG sources destined to WG subnet (LAN → peers)
commands.append(f'add rule inet {TABLE_NAME} {chain} ip saddr != {v4_net} ip daddr {v4_net} accept')
commands.append(f'add rule inet {TABLE_NAME} {chain} ip6 saddr != {v6_net} ip6 daddr {v6_net} accept')
else:
# Drop LAN → peer traffic
commands.append(f'add rule inet {TABLE_NAME} {chain} ip saddr != {v4_net} ip daddr {v4_net} drop')
commands.append(f'add rule inet {TABLE_NAME} {chain} ip6 saddr != {v6_net} ip6 daddr {v6_net} drop')
try:
await _nft_batch(commands)
logger.info("LAN-to-peers policy: {}", "allow" if enabled else "deny")
except RuntimeError as e:
logger.error("Failed to apply LAN-to-peers policy: {}", e)
async def get_ruleset() -> str:
"""Dump the current nftables ruleset for troubleshooting."""
try:
return await _nft("list ruleset")
except RuntimeError as e:
return f"Error: {e}"
def _user_chain_name(user_id: str) -> str: def _user_chain_name(user_id: str) -> str:
"""Generate a deterministic chain name from a user ID.""" """Generate a deterministic chain name from a user ID."""
# Use first 12 chars of UUID (without hyphens) to keep names short # Use first 12 chars of UUID (without hyphens) to keep names short