firegex/backend/routers/nfregex.py
Sogoss 75c54f0155 Add transparent TLS proxying and certificate management for NFProxy and NFRegex
- Add Nginx stream proxy module for transparent TLS termination in NFProxy and NFRegex
- Add backend routes and SQLite schema for certificate management (upload, view, delete)
- Implement DNAT nftables redirection rules for deterministic TLS ports
- Add frontend React components (TLSConfigModal, TLSServiceFields) for certificate configuration
- Update Dockerfile and docker-entrypoint.sh to support Nginx stream module and sysctl forwarding
2026-07-20 12:25:30 +02:00

431 lines
17 KiB
Python

from base64 import b64decode
import secrets
import sqlite3
from fastapi import APIRouter, HTTPException
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
from modules.nfregex.nftables import FiregexTables
from modules.nfregex.firewall import STATUS, FirewallManager
from utils.sqlite import SQLite
from utils import ip_parse, refactor_name, socketio_emit, PortType
from utils.certs import CertsDB, populate_services_tls_config
from utils.models import ResetRequest, StatusMessageModel, TLSConfigForm
from modules.nfregex.firegex import test_regex_validity
class ServiceModel(BaseModel):
status: str
service_id: str
port: PortType
name: str
proto: str
ip_int: str
n_regex: int
n_packets: int
fail_open: bool
tls_enabled: bool = False
tls_cert: str|None = None
tls_key: str|None = None
ssl_port: int|None = None
clear_port: int|None = None
class RenameForm(BaseModel):
name:str
class SettingsForm(BaseModel):
port: PortType|None = None
proto: str|None = None
ip_int: str|None = None
fail_open: bool|None = None
class RegexModel(BaseModel):
regex:str
mode:str
id:int
service_id:str
n_packets:int
is_case_sensitive:bool
active:bool
class RegexAddForm(BaseModel):
service_id: str
regex: str
mode: str
active: bool|None = None
is_case_sensitive: bool
class ServiceAddForm(BaseModel):
name: str
port: PortType
proto: str
ip_int: str
fail_open: bool = False
tls_enabled: bool = False
tls_cert: str | None = None
tls_key: str | None = None
class ServiceAddResponse(BaseModel):
status:str
service_id: str|None = None
app = APIRouter()
db = SQLite('db/nft-regex.db', {
'services': {
'service_id': 'VARCHAR(100) PRIMARY KEY',
'status': 'VARCHAR(100) NOT NULL',
'port': 'INT NOT NULL CHECK(port > 0 and port < 65536)',
'name': 'VARCHAR(100) NOT NULL UNIQUE',
'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))',
'ip_int': 'VARCHAR(100) NOT NULL',
'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1',
'tls_enabled': 'BOOLEAN NOT NULL CHECK (tls_enabled IN (0, 1)) DEFAULT 0',
},
'regexes': {
'regex': 'TEXT NOT NULL',
'mode': 'VARCHAR(1) NOT NULL CHECK (mode IN ("C", "S", "B"))', # C = to the client, S = to the server, B = both
'service_id': 'VARCHAR(100) NOT NULL',
'blocked_packets': 'INTEGER UNSIGNED NOT NULL DEFAULT 0',
'regex_id': 'INTEGER PRIMARY KEY',
'is_case_sensitive' : 'BOOLEAN NOT NULL CHECK (is_case_sensitive IN (0, 1))',
'active' : 'BOOLEAN NOT NULL CHECK (active IN (0, 1)) DEFAULT 1',
'FOREIGN KEY (service_id)':'REFERENCES services (service_id)',
},
'QUERY':[
"CREATE UNIQUE INDEX IF NOT EXISTS unique_services ON services (port, ip_int, proto);",
"CREATE UNIQUE INDEX IF NOT EXISTS unique_regex_service ON regexes (regex,service_id,mode,is_case_sensitive);"
]
})
async def refresh_frontend(additional:list[str]=[]):
await socketio_emit(["nfregex"]+additional)
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()
try:
await firewall.init()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
async def startup():
db.init()
try:
await firewall.init()
except Exception as e:
print("WARNING cannot start firewall:", e)
async def shutdown():
db.backup()
await firewall.close()
db.disconnect()
db.restore()
def gen_service_id():
while True:
res = secrets.token_hex(8)
if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', res)) == 0:
break
return res
firewall = FirewallManager(db)
@app.get('/services', response_model=list[ServiceModel])
async def get_service_list():
"""Get the list of existent firegex services"""
res = db.query("""
SELECT
s.service_id service_id,
s.status status,
s.port port,
s.name name,
s.proto proto,
s.ip_int ip_int,
s.fail_open fail_open,
s.tls_enabled tls_enabled,
COUNT(r.regex_id) n_regex,
COALESCE(SUM(r.blocked_packets),0) n_packets
FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id
GROUP BY s.service_id;
""")
return populate_services_tls_config(res)
@app.get('/services/{service_id}', response_model=ServiceModel)
async def get_service_by_id(service_id: str):
"""Get info about a specific service using his id"""
res = db.query("""
SELECT
s.service_id service_id,
s.status status,
s.port port,
s.name name,
s.proto proto,
s.ip_int ip_int,
s.fail_open fail_open,
s.tls_enabled tls_enabled,
COUNT(r.regex_id) n_regex,
COALESCE(SUM(r.blocked_packets),0) n_packets
FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id
WHERE s.service_id = ? GROUP BY s.service_id;
""", service_id)
if len(res) == 0:
raise HTTPException(status_code=400, detail="This service does not exists!")
return populate_services_tls_config(res)[0]
@app.post('/services/{service_id}/stop', response_model=StatusMessageModel)
async def service_stop(service_id: str):
"""Request the stop of a specific service"""
await firewall.get(service_id).next(STATUS.STOP)
await refresh_frontend()
return {'status': 'ok'}
@app.post('/services/{service_id}/start', response_model=StatusMessageModel)
async def service_start(service_id: str):
"""Request the start of a specific service"""
await firewall.get(service_id).next(STATUS.ACTIVE)
await refresh_frontend()
return {'status': 'ok'}
@app.delete('/services/{service_id}', response_model=StatusMessageModel)
async def service_delete(service_id: str):
"""Request the deletion of a specific service"""
srv_res = db.query('SELECT ip_int, port FROM services WHERE service_id = ?;', service_id)
if srv_res:
CertsDB().delete_cert_and_key(srv_res[0]["ip_int"], srv_res[0]["port"])
db.query('DELETE FROM services WHERE service_id = ?;', service_id)
db.query('DELETE FROM regexes WHERE service_id = ?;', service_id)
await firewall.remove(service_id)
await refresh_frontend()
return {'status': 'ok'}
@app.put('/services/{service_id}/rename', response_model=StatusMessageModel)
async def service_rename(service_id: str, form: RenameForm):
"""Request to change the name of a specific service"""
form.name = refactor_name(form.name)
if not form.name:
raise HTTPException(status_code=400, detail="The name cannot be empty!")
try:
db.query('UPDATE services SET name=? WHERE service_id = ?;', form.name, service_id)
except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="This name is already used")
await refresh_frontend()
return {'status': 'ok'}
@app.put('/services/{service_id}/settings', response_model=StatusMessageModel)
async def service_settings(service_id: str, form: SettingsForm):
"""Request to change the settings of a specific service (will cause a restart)"""
srv_check = db.query("SELECT proto, ip_int, port, tls_enabled FROM services WHERE service_id = ?;", service_id)
if len(srv_check) == 0:
raise HTTPException(status_code=404, detail="Service not found")
old_srv = srv_check[0]
if form.proto is not None and form.proto not in ["tcp", "udp"]:
raise HTTPException(status_code=400, detail="Invalid protocol")
if old_srv["tls_enabled"]:
new_proto = form.proto if form.proto is not None else old_srv["proto"]
if new_proto != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
if form.port is not None and (form.port < 1 or form.port > 65535):
raise HTTPException(status_code=400, detail="Invalid port")
if form.ip_int is not None:
try:
form.ip_int = ip_parse(form.ip_int)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid address")
new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"]
new_port = form.port if form.port is not None else old_srv["port"]
keys = []
values = []
for key, value in form.model_dump(exclude_none=True).items():
keys.append(key)
values.append(value)
if len(keys) == 0:
raise HTTPException(status_code=400, detail="No settings to change provided")
if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]):
cert, key = CertsDB().get_cert_and_key(old_srv["ip_int"], old_srv["port"])
if cert and key:
CertsDB().upsert_cert_and_key(new_ip, new_port, cert, key)
CertsDB().delete_cert_and_key(old_srv["ip_int"], old_srv["port"])
try:
db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id)
except sqlite3.IntegrityError:
# If migration was performed, rollback CertsDB change (upsert old, delete new)
if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]):
cert, key = CertsDB().get_cert_and_key(new_ip, new_port)
if cert and key:
CertsDB().upsert_cert_and_key(old_srv["ip_int"], old_srv["port"], cert, key)
CertsDB().delete_cert_and_key(new_ip, new_port)
raise HTTPException(status_code=400, detail="A service with these settings already exists")
old_status = firewall.get(service_id).status
await firewall.remove(service_id)
await firewall.reload()
await firewall.get(service_id).next(old_status)
await refresh_frontend()
return {'status': 'ok'}
@app.get('/services/{service_id}/regexes', response_model=list[RegexModel])
async def get_service_regexe_list(service_id: str):
"""Get the list of the regexes of a service"""
if not db.query("SELECT 1 FROM services s WHERE s.service_id = ?;", service_id):
raise HTTPException(status_code=400, detail="This service does not exists!")
return db.query("""
SELECT
regex, mode, regex_id `id`, service_id,
blocked_packets n_packets, is_case_sensitive, active
FROM regexes WHERE service_id = ?;
""", service_id)
@app.get('/regexes/{regex_id}', response_model=RegexModel)
async def get_regex_by_id(regex_id: int):
"""Get regex info using his id"""
res = db.query("""
SELECT
regex, mode, regex_id `id`, service_id,
blocked_packets n_packets, is_case_sensitive, active
FROM regexes WHERE `id` = ?;
""", regex_id)
if len(res) == 0:
raise HTTPException(status_code=400, detail="This regex does not exists!")
return res[0]
@app.delete('/regexes/{regex_id}', response_model=StatusMessageModel)
async def regex_delete(regex_id: int):
"""Delete a regex using his id"""
res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id)
if len(res) != 0:
db.query('DELETE FROM regexes WHERE regex_id = ?;', regex_id)
await firewall.get(res[0]["service_id"]).update_filters()
await refresh_frontend()
return {'status': 'ok'}
@app.post('/regexes/{regex_id}/enable', response_model=StatusMessageModel)
async def regex_enable(regex_id: int):
"""Request the enabling of a regex"""
res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id)
if len(res) != 0:
db.query('UPDATE regexes SET active=1 WHERE regex_id = ?;', regex_id)
await firewall.get(res[0]["service_id"]).update_filters()
await refresh_frontend()
return {'status': 'ok'}
@app.post('/regexes/{regex_id}/disable', response_model=StatusMessageModel)
async def regex_disable(regex_id: int):
"""Request the deactivation of a regex"""
res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id)
if len(res) != 0:
db.query('UPDATE regexes SET active=0 WHERE regex_id = ?;', regex_id)
await firewall.get(res[0]["service_id"]).update_filters()
await refresh_frontend()
return {'status': 'ok'}
@app.post('/regexes', response_model=StatusMessageModel)
async def add_new_regex(form: RegexAddForm):
"""Add a new regex"""
regex_correct, message = await test_regex_validity(b64decode(form.regex))
if not regex_correct:
raise HTTPException(status_code=400, detail=f"Invalid regex: {message}")
try:
db.query("INSERT INTO regexes (service_id, regex, mode, is_case_sensitive, active ) VALUES (?, ?, ?, ?, ?);",
form.service_id, form.regex, form.mode, form.is_case_sensitive, True if form.active is None else form.active )
except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="An identical regex already exists")
await firewall.get(form.service_id).update_filters()
await refresh_frontend()
return {'status': 'ok'}
@app.post('/services', response_model=ServiceAddResponse)
async def add_new_service(form: ServiceAddForm):
"""Add a new service"""
try:
form.ip_int = ip_parse(form.ip_int)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid address")
if form.proto not in ["tcp", "udp"]:
raise HTTPException(status_code=400, detail="Invalid protocol")
if form.tls_enabled:
if form.proto != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
if not form.tls_cert or not form.tls_key:
raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled")
CertsDB().upsert_cert_and_key(form.ip_int, form.port, form.tls_cert, form.tls_key)
srv_id = None
try:
srv_id = gen_service_id()
db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, tls_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, int(form.tls_enabled))
except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="This type of service already exists")
await firewall.reload()
await refresh_frontend()
return {'status': 'ok', 'service_id': srv_id}
@app.put('/services/{service_id}/tls-config', response_model=StatusMessageModel)
async def update_service_tls(service_id: str, form: TLSConfigForm):
"""Update TLS configuration for a service"""
srv_res = db.query("SELECT ip_int, port, proto FROM services WHERE service_id = ?;", service_id)
if len(srv_res) == 0:
raise HTTPException(status_code=404, detail="Service not found")
srv = srv_res[0]
if form.tls_enabled:
if srv["proto"] != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
if not form.tls_cert or not form.tls_key:
raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled")
CertsDB().upsert_cert_and_key(srv["ip_int"], srv["port"], form.tls_cert, form.tls_key)
db.query("""
UPDATE services
SET tls_enabled = ?
WHERE service_id = ?;
""", int(form.tls_enabled), service_id)
await firewall.get(service_id).update_tls_config()
await refresh_frontend()
return {'status': 'ok'}
@app.get('/metrics', response_class = PlainTextResponse)
async def metrics():
"""Aggregate metrics"""
stats = db.query("""
SELECT
s.name,
s.status,
r.regex,
r.mode,
r.is_case_sensitive,
r.blocked_packets,
r.active
FROM regexes r LEFT JOIN services s ON s.service_id = r.service_id;
""")
metrics = []
def sanitize(s):
return s.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
for stat in stats:
props = f'service_name="{sanitize(stat["name"])}",regex="{sanitize(b64decode(stat["regex"]).decode())}",mode="{stat["mode"]}",is_case_sensitive="{stat["is_case_sensitive"]}"'
metrics.append(f'firegex_blocked_packets{{{props}}} {stat["blocked_packets"]}')
metrics.append(f'firegex_active{{{props}}} {int(stat["active"] and stat["status"] == "active")}')
return "\n".join(metrics)