firegex/backend/routers/firewall.py
2026-10-01 10:05:17 +02:00

215 lines
8.1 KiB
Python

import re
import sqlite3
import traceback
from fastapi import APIRouter, HTTPException
from utils.sqlite import SQLite
from utils import ip_parse, ip_family, socketio_emit
from utils.models import ResetRequest, StatusMessageModel
from modules.firewall.nftables import FiregexTables
from modules.firewall.firewall import FirewallManager
from modules.firewall.models import FirewallSettings, RuleInfo, RuleModel, RuleFormAdd, Mode, Table
db = SQLite('db/firewall-rules.db', {
'rules': {
'rule_id': 'INT PRIMARY KEY CHECK (rule_id >= 0)',
'mode': 'VARCHAR(10) NOT NULL CHECK (mode IN ("in", "out", "forward"))',
'`table`': 'VARCHAR(10) NOT NULL CHECK (`table` IN ("filter", "mangle", "raw"))',
'name': 'VARCHAR(100) NOT NULL',
'active' : 'BOOLEAN NOT NULL CHECK (active IN (0, 1))',
'proto': 'VARCHAR(10) NOT NULL CHECK (proto IN ("tcp", "udp", "both", "any"))',
'src': 'VARCHAR(100) NOT NULL',
'port_src_from': 'INT CHECK(port_src_from > 0 and port_src_from < 65536)',
'port_src_to': 'INT CHECK(port_src_to > 0 and port_src_to < 65536 and port_src_from <= port_src_to)',
'dst': 'VARCHAR(100) NOT NULL',
'port_dst_from': 'INT CHECK(port_dst_from > 0 and port_dst_from < 65536)',
'port_dst_to': 'INT CHECK(port_dst_to > 0 and port_dst_to < 65536 and port_dst_from <= port_dst_to)',
'action': 'VARCHAR(10) NOT NULL CHECK (action IN ("accept", "drop", "reject"))',
},
'QUERY':[
"CREATE UNIQUE INDEX IF NOT EXISTS unique_rules ON rules (proto, src, dst, port_src_from, port_src_to, port_dst_from, port_dst_to, mode, `table`);"
]
})
app = APIRouter()
firewall = FirewallManager(db)
async def reset(params: ResetRequest):
if not params.delete:
db.backup()
await firewall.close()
FiregexTables().reset()
if params.delete:
db.delete()
db.init()
else:
db.restore()
await firewall.init()
async def startup():
db.init()
await firewall.init()
async def shutdown():
firewall.stop_watching()
if not firewall.keep_rules:
await firewall.close()
db.disconnect()
async def refresh_frontend(additional:list[str]=[]):
await socketio_emit(["firewall"]+additional)
async def apply_changes(undo=None):
"""Put what the database now says into the kernel, or put the database back.
`undo` restores what was stored before the change. nft refusing the new firewall
leaves the old one in force (`FiregexTables.set` sends it as one batch), so the
database has to follow: kept, the refused configuration would be what the watcher
tries to put back every few seconds and what the next boot comes up with — failing
each time, with no firewall behind it.
"""
try:
await firewall.reload()
except Exception as e:
if undo is None:
raise
undo()
try:
await firewall.reload()
except Exception:
traceback.print_exc()
raise HTTPException(
status_code=400,
detail=f"nftables refused the new firewall, so nothing was changed: {e}",
)
await refresh_frontend()
return {'status': 'ok'}
@app.get("/settings", response_model=FirewallSettings)
async def get_settings():
"""Get the firewall settings"""
return firewall.settings
@app.put("/settings", response_model=StatusMessageModel)
async def set_settings(form: FirewallSettings):
"""Set the firewall settings"""
before = firewall.settings
firewall.settings = form
def undo():
firewall.settings = before
return await apply_changes(undo)
@app.get('/rules', response_model=RuleInfo)
async def get_rule_list():
"""Get the list of existent firegex rules"""
return {
"policy": firewall.policy,
"rules": db.query("SELECT active, name, proto, src, dst, port_src_from, port_dst_from, port_src_to, port_dst_to, action, mode, `table` FROM rules ORDER BY rule_id;"),
"enabled": firewall.enabled
}
@app.post('/enable', response_model=StatusMessageModel)
async def enable_firewall():
"""Request enabling the firewall"""
was = firewall.enabled
firewall.enabled = True
def undo():
firewall.enabled = was
return await apply_changes(undo)
@app.post('/disable', response_model=StatusMessageModel)
async def disable_firewall():
"""Request disabling the firewall"""
firewall.enabled = False
return await apply_changes()
#: What an address field may hold when it is not an address: an interface name, as the
#: kernel takes one, or one ending in `*` to match every interface starting that way
#: (`br-*`). Fifteen characters at most, which is what `IFNAMSIZ` leaves for the name.
#:
#: Anything else used to be stored on the strength of containing no `/`, and nft then
#: refused the whole firewall over it — a name one character too long, or with a quote in
#: it, was enough to take every rule down, the policy with them.
INTERFACE = re.compile(r"[A-Za-z0-9_.:-]{1,15}|[A-Za-z0-9_.:-]{0,14}\*")
def _address_or_interface(value: str, what: str) -> tuple[str, bool]:
"""`value` normalised, and whether it is an IP address rather than an interface."""
value = (value or "").strip()
if value == "":
return value, False
try:
return ip_parse(value), True
except ValueError:
pass
if not INTERFACE.fullmatch(value):
raise HTTPException(
status_code=400,
detail=f"Invalid {what} address {value!r}: it is neither an IP address nor an "
f"interface name (at most 15 letters, digits and '_.:-', optionally "
f"ending in '*')",
)
return value, False
def parse_and_check_rule(rule:RuleModel):
if rule.table == Table.MANGLE and rule.mode == Mode.FORWARD:
raise HTTPException(status_code=400, detail="Mangle table does not support forward mode")
rule.src, is_src_ip = _address_or_interface(rule.src, "source")
rule.dst, is_dst_ip = _address_or_interface(rule.dst, "destination")
if is_src_ip and is_dst_ip and ip_family(rule.dst) != ip_family(rule.src):
raise HTTPException(status_code=400, detail="Destination and source addresses must be of the same family")
rule.port_dst_from, rule.port_dst_to = min(rule.port_dst_from, rule.port_dst_to), max(rule.port_dst_from, rule.port_dst_to)
rule.port_src_from, rule.port_src_to = min(rule.port_src_from, rule.port_src_to), max(rule.port_src_from, rule.port_src_to)
return rule
RULE_COLUMNS = ("active", "name", "proto", "src", "dst", "port_src_from", "port_dst_from",
"port_src_to", "port_dst_to", "action", "mode", "`table`")
def _write_rules(rows: list[dict]) -> None:
"""Replace every stored rule with `rows`, in order, in one transaction."""
db.queries(["DELETE FROM rules"] + [
(
f"INSERT INTO rules (rule_id, {', '.join(RULE_COLUMNS)}) "
f"VALUES (?, {', '.join('?' for _ in RULE_COLUMNS)})",
rid, *(row[column.strip('`')] for column in RULE_COLUMNS),
)
for rid, row in enumerate(rows)
])
@app.post('/rules', response_model=StatusMessageModel)
async def add_new_service(form: RuleFormAdd):
"""Edit rule table"""
rules = [parse_and_check_rule(ele) for ele in form.rules]
before = db.query(f"SELECT {', '.join(RULE_COLUMNS)} FROM rules ORDER BY rule_id;")
policy_before = firewall.policy
try:
_write_rules([
{
"active": ele.active, "name": ele.name, "proto": ele.proto,
"src": ele.src, "dst": ele.dst,
"port_src_from": ele.port_src_from, "port_dst_from": ele.port_dst_from,
"port_src_to": ele.port_src_to, "port_dst_to": ele.port_dst_to,
"action": ele.action, "mode": ele.mode, "table": ele.table,
}
for ele in rules
])
firewall.policy = form.policy.value
except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="Error saving the rules: maybe there are duplicated rules")
def undo():
_write_rules(before)
firewall.policy = policy_before
return await apply_changes(undo)