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
This commit is contained in:
Sogoss 2026-07-20 12:25:30 +02:00
parent bd10f9e8b8
commit 75c54f0155
30 changed files with 1184 additions and 123 deletions

View File

@ -16,7 +16,7 @@ RUN bun run build
FROM --platform=$TARGETARCH quay.io/fedora/fedora:43 AS base
RUN dnf -y update && dnf install -y python3.14 libnetfilter_queue \
libnfnetlink libmnl libcap-ng-utils nftables \
vectorscan libtins python3-nftables libpcap && dnf clean all
vectorscan libtins python3-nftables libpcap nginx nginx-mod-stream && dnf clean all
RUN mkdir -p /execute/modules
WORKDIR /execute

View File

@ -11,6 +11,7 @@ from utils.sqlite import SQLite
from utils import API_VERSION, FIREGEX_PORT, FIREGEX_HOST, FIREGEX_SOCKET, JWT_ALGORITHM, get_interfaces, socketio_emit, DEBUG, SysctlManager, NORELOAD
from utils.loader import frontend_deploy, load_routers
from utils.models import ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel
from utils.certs import CERTIFICATES_SCHEMA
from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware
import socketio
@ -18,7 +19,7 @@ from socketio.exceptions import ConnectionRefusedError
import hashlib
# DB init
db = SQLite('db/firegex.db')
db = SQLite('db/firegex.db', CERTIFICATES_SCHEMA)
sysctl = SysctlManager({
"net.ipv4.conf.all.forwarding": True,
"net.ipv6.conf.all.forwarding": True,

View File

@ -451,6 +451,7 @@ class NfQueue {
// error code taken from the linux kernel:
// https://elixir.bootlin.com/linux/v5.18.12/source/include/linux/errno.h#L27
#define ENOTSUPP 524 /* Operation is not supported */
#define EOPNOTSUPP 95 /* Operation not supported on transport endpoint */
if (error_msg->error != -ENOTSUPP && error_msg->error != -EOPNOTSUPP) {
_clear();

View File

@ -9,6 +9,15 @@ if [ -n "$SOCKET_DIR" ]; then
chmod 755 "$SOCKET_DIR"
fi
# Set host sysctls while running as root.
if [ -d "/sys_host" ]; then
echo "[*] Setting host sysctls..."
echo 1 > /sys_host/net.ipv4.conf.all.forwarding 2>/dev/null || true
echo 1 > /sys_host/net.ipv6.conf.all.forwarding 2>/dev/null || true
echo 1 > /sys_host/net.ipv4.conf.all.route_localnet 2>/dev/null || true
echo 1 > /sys_host/net.ipv4.ip_forward 2>/dev/null || true
fi
echo "[*] Attempting to start with capabilities..."
if capsh --caps="cap_net_admin,cap_setpcap,cap_setuid,cap_setgid,cap_sys_nice+eip" \
@ -21,7 +30,8 @@ then
--keep=1 \
--user=nobody \
--addamb=cap_net_admin,cap_sys_nice \
-- -c "python3 /execute/app.py DOCKER"
--shell=/usr/bin/python3 \
-- /execute/app.py DOCKER
else
echo "[!] capsh failed, running with root user"
exec python3 /execute/app.py DOCKER

View File

@ -90,11 +90,11 @@ class FiregexInterceptor:
proxy_binary_path, stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env={
env=dict(os.environ, **{
"NTHREADS": os.getenv("NTHREADS","1"),
"FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0",
"FIREGEX_NFPROXY_SOCK": self.sock_path
},
}),
)
nicenessify(-10, self.process.pid)
self.outstrem_task = asyncio.create_task(self._stream_handler())

View File

@ -2,8 +2,10 @@ import asyncio
from modules.nfproxy.firegex import FiregexInterceptor
from modules.nfproxy.nftables import FiregexTables, FiregexFilter
from modules.nfproxy.models import Service, PyFilter
from modules.nfproxy.nginx import sync_nginx_state
from utils.sqlite import SQLite
from utils import run_func
from utils.certs import CertsDB, ip_parse as certs_ip_parse
class STATUS:
STOP = "stop"
@ -72,6 +74,8 @@ class ServiceManager:
self.interceptor = await FiregexInterceptor.start(self.srv, outstream_func=self.outstream_function, exception_func=self.exception_function)
await self._update_filters_from_db()
self._set_status(STATUS.ACTIVE)
if self.srv.tls_enabled:
sync_nginx_state()
async def stop(self):
nft.delete(self.srv)
@ -79,6 +83,8 @@ class ServiceManager:
await self.interceptor.stop()
self.interceptor = None
self._set_status(STATUS.STOP)
if self.srv.tls_enabled:
sync_nginx_state()
async def restart(self):
await self.stop()
@ -88,6 +94,18 @@ class ServiceManager:
async with self.lock:
await self._update_filters_from_db()
async def update_tls_config(self):
async with self.lock:
srv_dict = self.db.query("SELECT * FROM services WHERE service_id = ?;", self.srv.id)[0]
if srv_dict.get("tls_enabled"):
cert, key = CertsDB().get_cert_and_key(srv_dict["ip_int"], srv_dict["port"])
srv_dict["tls_cert"] = cert
srv_dict["tls_key"] = key
self.srv = Service.from_dict(srv_dict)
if self.status == STATUS.ACTIVE:
await self.restart()
sync_nginx_state()
class FirewallManager:
def __init__(self, db:SQLite, outstream_func=None, exception_func=None):
self.db = db
@ -99,6 +117,7 @@ class FirewallManager:
async def close(self):
for key in list(self.service_table.keys()):
await self.remove(key)
sync_nginx_state()
async def remove(self,srv_id):
async with self.lock:
@ -112,12 +131,21 @@ class FirewallManager:
async def reload(self):
async with self.lock:
for srv in self.db.query('SELECT * FROM services;'):
srv = Service.from_dict(srv)
if srv.id in self.service_table:
services = self.db.query('SELECT * FROM services;')
tls_services = [s for s in services if s.get("tls_enabled") and s["service_id"] not in self.service_table]
certs_map = CertsDB().get_multiple_certs_and_keys(tls_services) if tls_services else {}
for srv in services:
if srv["service_id"] in self.service_table:
continue
self.service_table[srv.id] = ServiceManager(srv, self.db, outstream_func=self.outstream_function, exception_func=self.exception_function)
await self.service_table[srv.id].next(srv.status)
if srv.get("tls_enabled"):
cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None))
srv["tls_cert"] = cert
srv["tls_key"] = key
srv_obj = Service.from_dict(srv)
self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db, outstream_func=self.outstream_function, exception_func=self.exception_function)
await self.service_table[srv_obj.id].next(srv_obj.status)
sync_nginx_state()
def get(self,srv_id) -> ServiceManager:
if srv_id in self.service_table:

View File

@ -8,6 +8,9 @@ class Service:
proto: str,
ip_int: str,
fail_open: bool,
tls_enabled: bool = False,
tls_cert: str | None = None,
tls_key: str | None = None,
**other,
):
self.id = service_id
@ -17,6 +20,9 @@ class Service:
self.proto = proto
self.ip_int = ip_int
self.fail_open = fail_open
self.tls_enabled = bool(tls_enabled)
self.tls_cert = tls_cert
self.tls_key = tls_key
@classmethod
def from_dict(cls, var: dict):

View File

@ -1,5 +1,6 @@
from modules.nfproxy.models import Service
from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json
from modules.nfproxy.nginx import get_tls_ports
def convert_protocol_to_l4(proto:str):
if proto == "tcp":
@ -22,9 +23,19 @@ class FiregexFilter:
return self.port == o.port and self.proto == convert_protocol_to_l4(o.proto) and ip_parse(self.ip_int) == ip_parse(o.ip_int)
return False
def matches(self, srv: Service) -> bool:
if self.port == srv.port and ip_parse(self.ip_int) == ip_parse(srv.ip_int):
return True
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port)
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1"
if ip_parse(self.ip_int) == ip_parse(loopback_ip) and self.port in (ssl_port, clear_port):
return True
return False
class FiregexTables(NFTableManager):
input_chain = "nfproxy_input"
output_chain = "nfproxy_output"
nat_chain = "nfproxy_nat"
def __init__(self):
super().__init__([
@ -45,25 +56,76 @@ class FiregexTables(NFTableManager):
"hook":"postrouting",
"prio":108,
"policy":"accept"
}}},
{"add":{"chain":{ #NAT chain for DNAT
"family":"inet",
"table":self.table_name,
"name":self.nat_chain,
"type":"nat",
"hook":"prerouting",
"prio":-100,
"policy":"accept"
}}}
],[
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
])
def add(self, srv:Service, queue_range):
for ele in self.get():
if ele.__eq__(srv):
if ele.matches(srv):
return
init, end = queue_range
if init > end:
init, end = end, init
if srv.tls_enabled:
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port)
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1"
self.cmd(
{ "insert":{ "rule": {
{ "insert":{ "rule": { # Redirect TLS traffic to local Nginx SSL proxy
"family": "inet",
"table": self.table_name,
"chain": self.nat_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}},
{'match': {'left': {'payload': {'protocol': convert_protocol_to_l4(str(srv.proto)), 'field': 'dport'}}, 'op': '==', 'right': int(srv.port)}},
{'match': {'left': {'meta': {'key': 'iifname'}}, 'op': '!=', 'right': 'lo'}},
{'dnat': {'family': ip_family(srv.ip_int), 'addr': loopback_ip, 'port': int(ssl_port)}}
]
}}},
{"insert":{"rule":{ # Send cleartext inbound traffic to NFQUEUE
"family": "inet",
"table": self.table_name,
"chain": self.input_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': loopback_ip}},
{'match': {'left': {'payload': {'protocol': convert_protocol_to_l4(str(srv.proto)), 'field': 'dport'}}, 'op': '==', 'right': int(clear_port)}},
{"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}},
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
]
}}},
{"insert":{"rule":{ # Send cleartext outbound traffic to NFQUEUE
"family": "inet",
"table": self.table_name,
"chain": self.output_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': loopback_ip}},
{'match': {'left': {'payload': {'protocol': convert_protocol_to_l4(str(srv.proto)), 'field': 'sport'}}, 'op': '==', 'right': int(clear_port)}},
{"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}},
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
]
}}}
)
else:
self.cmd(
{ "insert":{ "rule": { # Send non-TLS outbound traffic to NFQUEUE
"family": "inet",
"table": self.table_name,
"chain": self.output_chain,
@ -74,7 +136,7 @@ class FiregexTables(NFTableManager):
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
]
}}},
{"insert":{"rule":{
{"insert":{"rule":{ # Send non-TLS inbound traffic to NFQUEUE
"family": "inet",
"table": self.table_name,
"chain": self.input_chain,
@ -90,7 +152,8 @@ class FiregexTables(NFTableManager):
def get(self) -> list[FiregexFilter]:
res = []
for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]):
for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain,self.nat_chain]):
try:
ip_int = None
if isinstance(filter["expr"][0]["match"]["right"],str):
ip_int = str(ip_parse(filter["expr"][0]["match"]["right"]))
@ -103,11 +166,13 @@ class FiregexTables(NFTableManager):
port=filter["expr"][1]["match"]["right"],
ip_int=ip_int
))
except (KeyError, TypeError, IndexError):
pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip
return res
def delete(self, srv:Service):
for filter in self.get():
if filter.__eq__(srv):
if filter.matches(srv):
self.cmd({ "delete":{ "rule": {
"family": "inet",
"table": self.table_name,

View File

@ -0,0 +1,103 @@
import subprocess
import os
import glob
from utils.sqlite import SQLite
from utils.certs import CertsDB, get_tls_ports, ip_parse as certs_ip_parse
NGINX_CONF_PATH = "/tmp/firegex_nginx.conf"
NGINX_PID_PATH = "/tmp/firegex_nginx.pid"
NGINX_LOG_PATH = "/tmp/firegex_nginx_error.log"
def _get_active_tls_services(db_name: str) -> list[dict]:
db = SQLite(db_name)
db.connect()
try:
return db.query("SELECT * FROM services WHERE status = 'active' AND tls_enabled = 1;")
except Exception:
return []
finally:
db.disconnect()
def update_nginx(services: list[dict]) -> None:
if not services:
subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-s", "stop", "-e", NGINX_LOG_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Clean up certificates, configuration, and pid
for pattern in ("/tmp/firegex_cert_*", "/tmp/firegex_key_*", NGINX_CONF_PATH, NGINX_PID_PATH, NGINX_LOG_PATH):
for path in (glob.glob(pattern) if "*" in pattern else [pattern]):
try:
os.remove(path)
except OSError:
pass
return
servers_config = []
for srv in services:
srv_id = srv["service_id"]
cert_path = f"/tmp/firegex_cert_{srv_id}.crt"
key_path = f"/tmp/firegex_key_{srv_id}.key"
with open(cert_path, "w") as f:
f.write(srv.get("tls_cert") or "")
with open(key_path, "w") as f:
f.write(srv.get("tls_key") or "")
ssl_port, clear_port = get_tls_ports(srv["ip_int"], srv["port"])
ip_addr = srv["ip_int"].split("/")[0]
is_ipv6 = ":" in ip_addr
loopback_ip = "[::1]" if is_ipv6 else "127.0.0.1"
dest_ip = f"[{ip_addr}]" if is_ipv6 else ip_addr
servers_config.append(
f" server {{\n"
f" listen {loopback_ip}:{ssl_port} ssl;\n"
f" proxy_pass {loopback_ip}:{clear_port};\n"
f" ssl_certificate {cert_path};\n"
f" ssl_certificate_key {key_path};\n"
f" ssl_protocols TLSv1.2 TLSv1.3;\n"
f" }}\n"
f" server {{\n"
f" listen {loopback_ip}:{clear_port};\n"
f" proxy_pass {dest_ip}:{srv['port']};\n"
f" proxy_ssl on;\n"
f" proxy_ssl_verify off;\n"
f" proxy_ssl_protocols TLSv1.2 TLSv1.3;\n"
f" }}"
)
config_content = ""
if os.path.isdir("/usr/share/nginx/modules"):
config_content += "include /usr/share/nginx/modules/*.conf;\n"
config_content += (
f"pid {NGINX_PID_PATH};\n"
f"error_log {NGINX_LOG_PATH} info;\n\n"
f"events {{\n"
f" worker_connections 1024;\n"
f"}}\n\n"
f"stream {{\n"
+ "\n".join(servers_config) + "\n"
f"}}\n"
)
with open(NGINX_CONF_PATH, "w") as f:
f.write(config_content)
# Try to reload. If Nginx is not running, start it.
res = subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-s", "reload", "-e", NGINX_LOG_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if res.returncode != 0:
subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-e", NGINX_LOG_PATH], check=True)
def sync_nginx_state() -> None:
services = _get_active_tls_services('db/nft-pyfilters.db') + _get_active_tls_services('db/nft-regex.db')
# Batch-resolve certificates using CertsDB
certs_map = CertsDB().get_multiple_certs_and_keys(services)
for srv in services:
cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None))
srv["tls_cert"] = cert
srv["tls_key"] = key
update_nginx(services)

View File

@ -101,11 +101,11 @@ class FiregexInterceptor:
self.process = await asyncio.create_subprocess_exec(
proxy_binary_path,
stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
env={
env=dict(os.environ, **{
"MATCH_MODE": "stream" if self.srv.proto == "tcp" else "block",
"NTHREADS": os.getenv("NTHREADS","1"),
"FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0",
},
}),
)
nicenessify(-10, self.process.pid)
line_fut = self.process.stdout.readuntil()

View File

@ -3,6 +3,8 @@ from modules.nfregex.firegex import FiregexInterceptor, RegexFilter
from modules.nfregex.nftables import FiregexTables, FiregexFilter
from modules.nfregex.models import Regex, Service
from utils.sqlite import SQLite
from modules.nfproxy.nginx import sync_nginx_state
from utils.certs import CertsDB, ip_parse as certs_ip_parse
class STATUS:
STOP = "stop"
@ -63,6 +65,8 @@ class ServiceManager:
self.interceptor = await FiregexInterceptor.start(self.srv)
await self._update_filters_from_db()
self._set_status(STATUS.ACTIVE)
if self.srv.tls_enabled:
sync_nginx_state()
async def stop(self):
nft.delete(self.srv)
@ -70,6 +74,8 @@ class ServiceManager:
await self.interceptor.stop()
self.interceptor = None
self._set_status(STATUS.STOP)
if self.srv.tls_enabled:
sync_nginx_state()
async def restart(self):
await self.stop()
@ -79,6 +85,18 @@ class ServiceManager:
async with self.lock:
await self._update_filters_from_db()
async def update_tls_config(self):
async with self.lock:
srv_dict = self.db.query("SELECT * FROM services WHERE service_id = ?;", self.srv.id)[0]
if srv_dict.get("tls_enabled"):
cert, key = CertsDB().get_cert_and_key(srv_dict["ip_int"], srv_dict["port"])
srv_dict["tls_cert"] = cert
srv_dict["tls_key"] = key
self.srv = Service.from_dict(srv_dict)
if self.status == STATUS.ACTIVE:
await self.restart()
sync_nginx_state()
class FirewallManager:
def __init__(self, db:SQLite):
self.db = db
@ -88,6 +106,7 @@ class FirewallManager:
async def close(self):
for key in list(self.service_table.keys()):
await self.remove(key)
sync_nginx_state()
async def remove(self,srv_id):
async with self.lock:
@ -101,12 +120,21 @@ class FirewallManager:
async def reload(self):
async with self.lock:
for srv in self.db.query('SELECT * FROM services;'):
srv = Service.from_dict(srv)
if srv.id in self.service_table:
services = self.db.query('SELECT * FROM services;')
tls_services = [s for s in services if s.get("tls_enabled") and s["service_id"] not in self.service_table]
certs_map = CertsDB().get_multiple_certs_and_keys(tls_services) if tls_services else {}
for srv in services:
if srv["service_id"] in self.service_table:
continue
self.service_table[srv.id] = ServiceManager(srv, self.db)
await self.service_table[srv.id].next(srv.status)
if srv.get("tls_enabled"):
cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None))
srv["tls_cert"] = cert
srv["tls_key"] = key
srv_obj = Service.from_dict(srv)
self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db)
await self.service_table[srv_obj.id].next(srv_obj.status)
sync_nginx_state()
def get(self,srv_id) -> ServiceManager:
if srv_id in self.service_table:

View File

@ -1,7 +1,7 @@
import base64
class Service:
def __init__(self, service_id: str, status: str, port: int, name: str, proto: str, ip_int: str, fail_open: bool, **other):
def __init__(self, service_id: str, status: str, port: int, name: str, proto: str, ip_int: str, fail_open: bool, tls_enabled: bool = False, tls_cert: str | None = None, tls_key: str | None = None, **other):
self.id = service_id
self.status = status
self.port = port
@ -9,6 +9,9 @@ class Service:
self.proto = proto
self.ip_int = ip_int
self.fail_open = fail_open
self.tls_enabled = bool(tls_enabled)
self.tls_cert = tls_cert
self.tls_key = tls_key
@classmethod
def from_dict(cls, var: dict):

View File

@ -1,5 +1,6 @@
from modules.nfregex.models import Service
from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json
from modules.nfproxy.nginx import get_tls_ports
class FiregexFilter:
def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int):
@ -14,9 +15,19 @@ class FiregexFilter:
return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int)
return False
def matches(self, srv: Service) -> bool:
if self.port == srv.port and ip_parse(self.ip_int) == ip_parse(srv.ip_int):
return True
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port)
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1"
if ip_parse(self.ip_int) == ip_parse(loopback_ip) and self.port in (ssl_port, clear_port):
return True
return False
class FiregexTables(NFTableManager):
input_chain = "nfregex_input"
output_chain = "nfregex_output"
nat_chain = "nfregex_nat"
def __init__(self):
super().__init__([
@ -37,23 +48,74 @@ class FiregexTables(NFTableManager):
"hook":"postrouting",
"prio":107,
"policy":"accept"
}}},
{"add":{"chain":{
"family":"inet",
"table":self.table_name,
"name":self.nat_chain,
"type":"nat",
"hook":"prerouting",
"prio":-100,
"policy":"accept"
}}}
],[
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
])
def add(self, srv:Service, queue_range):
for ele in self.get():
if ele.__eq__(srv):
if ele.matches(srv):
return
init, end = queue_range
if init > end:
init, end = end, init
if srv.tls_enabled:
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port)
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1"
self.cmd(
{ "insert":{ "rule": { # Redirect TLS traffic to local Nginx SSL proxy
"family": "inet",
"table": self.table_name,
"chain": self.nat_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}},
{'match': {'left': {'payload': {'protocol': str(srv.proto), 'field': 'dport'}}, 'op': '==', 'right': int(srv.port)}},
{'match': {'left': {'meta': {'key': 'iifname'}}, 'op': '!=', 'right': 'lo'}},
{'dnat': {'family': ip_family(srv.ip_int), 'addr': loopback_ip, 'port': int(ssl_port)}}
]
}}},
{"insert":{"rule":{ # Send cleartext inbound traffic to NFQUEUE
"family": "inet",
"table": self.table_name,
"chain": self.input_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': loopback_ip}},
{'match': {'left': {'payload': {'protocol': str(srv.proto), 'field': 'dport'}}, 'op': '==', 'right': int(clear_port)}},
{"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}},
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
]
}}},
{"insert":{"rule":{ # Send cleartext outbound traffic to NFQUEUE
"family": "inet",
"table": self.table_name,
"chain": self.output_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': loopback_ip}},
{'match': {'left': {'payload': {'protocol': str(srv.proto), 'field': 'sport'}}, 'op': '==', 'right': int(clear_port)}},
{"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}},
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
]
}}}
)
else:
self.cmd(
{ "insert":{ "rule": {
"family": "inet",
@ -82,7 +144,8 @@ class FiregexTables(NFTableManager):
def get(self) -> list[FiregexFilter]:
res = []
for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]):
for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain,self.nat_chain]):
try:
ip_int = None
if isinstance(filter["expr"][0]["match"]["right"],str):
ip_int = str(ip_parse(filter["expr"][0]["match"]["right"]))
@ -95,15 +158,20 @@ class FiregexTables(NFTableManager):
port=filter["expr"][1]["match"]["right"],
ip_int=ip_int
))
except (KeyError, TypeError, IndexError):
pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip
return res
def delete(self, srv:Service):
delete_cmds = []
for filter in self.get():
if filter.__eq__(srv):
self.cmd({ "delete":{ "rule": {
if filter.matches(srv):
delete_cmds.append({ "delete":{ "rule": {
"family": "inet",
"table": self.table_name,
"chain": filter.target,
"handle": filter.id
}}})
if delete_cmds:
self.cmd(*delete_cmds)

View File

@ -0,0 +1,36 @@
from fastapi import APIRouter
from pydantic import BaseModel
from utils.models import StatusMessageModel
from utils.certs import CertsDB
app = APIRouter()
class CertificateModel(BaseModel):
ip_int: str
port: int
cert: str
key: str
class CertificateResponseModel(BaseModel):
ip_int: str
port: int
cert: str | None = None
key: str | None = None
@app.get("", response_model=CertificateResponseModel)
async def get_certificate(ip_int: str, port: int):
"""Retrieve TLS certificate and key by ip_int and port"""
cert, key = CertsDB().get_cert_and_key(ip_int, port)
return {"ip_int": ip_int, "port": port, "cert": cert, "key": key}
@app.post("", response_model=StatusMessageModel)
async def upsert_certificate(form: CertificateModel):
"""Create or update TLS certificate and key"""
CertsDB().upsert_cert_and_key(form.ip_int, form.port, form.cert, form.key)
return {"status": "ok"}
@app.delete("", response_model=StatusMessageModel)
async def delete_certificate(ip_int: str, port: int):
"""Delete TLS certificate and key by ip_int and port"""
CertsDB().delete_cert_and_key(ip_int, port)
return {"status": "ok"}

View File

@ -6,7 +6,8 @@ from modules.nfproxy.nftables import FiregexTables
from modules.nfproxy.firewall import STATUS, FirewallManager
from utils.sqlite import SQLite
from utils import ip_parse, refactor_name, socketio_emit, PortType
from utils.models import ResetRequest, StatusMessageModel
from utils.certs import CertsDB, populate_services_tls_config
from utils.models import ResetRequest, StatusMessageModel, TLSConfigForm
import os
from firegex.nfproxy.internals import get_filter_names
from fastapi.responses import PlainTextResponse
@ -27,6 +28,11 @@ class ServiceModel(BaseModel):
edited_packets: int
blocked_packets: int
fail_open: bool
tls_enabled: bool
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
@ -49,6 +55,9 @@ class ServiceAddForm(BaseModel):
proto: str
ip_int: str
fail_open: bool = True
tls_enabled: bool = False
tls_cert: str | None = None
tls_key: str | None = None
class ServiceAddResponse(BaseModel):
status:str
@ -69,6 +78,7 @@ db = SQLite('db/nft-pyfilters.db', {
'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_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',
},
'pyfilter': {
'name': 'VARCHAR(100) NOT NULL',
@ -138,7 +148,7 @@ firewall = FirewallManager(db, outstream_func=outstream_func, exception_func=exc
@app.get('/services', response_model=list[ServiceModel])
async def get_service_list():
"""Get the list of existent firegex services"""
return db.query("""
res = db.query("""
SELECT
s.service_id service_id,
s.status status,
@ -147,12 +157,14 @@ async def get_service_list():
s.proto proto,
s.ip_int ip_int,
s.fail_open fail_open,
s.tls_enabled tls_enabled,
COUNT(f.name) n_filters,
COALESCE(SUM(f.blocked_packets),0) blocked_packets,
COALESCE(SUM(f.edited_packets),0) edited_packets
FROM services s LEFT JOIN pyfilter f ON s.service_id = f.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):
@ -166,6 +178,7 @@ async def get_service_by_id(service_id: str):
s.proto proto,
s.ip_int ip_int,
s.fail_open fail_open,
s.tls_enabled tls_enabled,
COUNT(f.name) n_filters,
COALESCE(SUM(f.blocked_packets),0) blocked_packets,
COALESCE(SUM(f.edited_packets),0) edited_packets
@ -174,7 +187,7 @@ async def get_service_by_id(service_id: str):
""", service_id)
if len(res) == 0:
raise HTTPException(status_code=400, detail="This service does not exists!")
return res[0]
return populate_services_tls_config(res)[0]
@app.post('/services/{service_id}/stop', response_model=StatusMessageModel)
async def service_stop(service_id: str):
@ -193,6 +206,9 @@ async def service_start(service_id: str):
@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 pyfilter WHERE service_id = ?;', service_id)
if os.path.exists(f"db/nfproxy_filters/{service_id}.py"):
@ -218,6 +234,11 @@ async def service_rename(service_id: str, form: RenameForm):
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 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.port is not None and (form.port < 1 or form.port > 65535):
raise HTTPException(status_code=400, detail="Invalid port")
@ -227,6 +248,9 @@ async def service_settings(service_id: str, form: SettingsForm):
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 = []
@ -237,9 +261,21 @@ async def service_settings(service_id: str, form: SettingsForm):
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
@ -250,6 +286,29 @@ async def service_settings(service_id: str, form: SettingsForm):
await refresh_frontend()
return {'status': 'ok'}
@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 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 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('/services/{service_id}/pyfilters', response_model=list[PyFilterModel])
async def get_service_pyfilter_list(service_id: str):
"""Get the list of the pyfilters of a service"""
@ -302,11 +361,16 @@ async def add_new_service(form: ServiceAddForm):
raise HTTPException(status_code=400, detail="Invalid address")
if form.proto not in ["tcp", "http"]:
raise HTTPException(status_code=400, detail="Invalid protocol")
if form.tls_enabled:
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, l4_proto) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, convert_protocol_to_l4(form.proto))
db.query("INSERT INTO services (service_id, name, port, status, proto, ip_int, fail_open, l4_proto, tls_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, convert_protocol_to_l4(form.proto), int(form.tls_enabled))
except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="This type of service already exists")
await firewall.reload()

View File

@ -8,7 +8,8 @@ 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.models import ResetRequest, StatusMessageModel
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):
@ -21,6 +22,11 @@ class ServiceModel(BaseModel):
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
@ -53,6 +59,9 @@ class ServiceAddForm(BaseModel):
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
@ -68,7 +77,8 @@ db = SQLite('db/nft-regex.db', {
'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'
'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',
@ -130,7 +140,7 @@ firewall = FirewallManager(db)
@app.get('/services', response_model=list[ServiceModel])
async def get_service_list():
"""Get the list of existent firegex services"""
return db.query("""
res = db.query("""
SELECT
s.service_id service_id,
s.status status,
@ -139,11 +149,13 @@ async def get_service_list():
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):
@ -157,6 +169,7 @@ async def get_service_by_id(service_id: str):
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
@ -164,7 +177,7 @@ async def get_service_by_id(service_id: str):
""", service_id)
if len(res) == 0:
raise HTTPException(status_code=400, detail="This service does not exists!")
return res[0]
return populate_services_tls_config(res)[0]
@app.post('/services/{service_id}/stop', response_model=StatusMessageModel)
async def service_stop(service_id: str):
@ -183,6 +196,9 @@ async def service_start(service_id: str):
@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)
@ -206,9 +222,19 @@ async def service_rename(service_id: str, form: RenameForm):
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")
@ -218,6 +244,9 @@ async def service_settings(service_id: str, form: SettingsForm):
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 = []
@ -228,9 +257,21 @@ async def service_settings(service_id: str, form: SettingsForm):
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
@ -322,17 +363,49 @@ async def add_new_service(form: ServiceAddForm):
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) VALUES (?, ?, ?, ?, ?, ?, ?)",
srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open)
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"""

View File

@ -305,6 +305,8 @@ def nicenessify(priority:int, pid:int|None=None):
ps = psutil.Process(pid)
if os.name == 'posix':
ps.nice(priority)
except (psutil.AccessDenied, PermissionError) as e:
print(f"Permission denied setting priority (pid={pid}, priority={priority}): {e}")
except Exception as e:
print(f"Error setting priority: {e} {traceback.format_exc()}")
pass

106
backend/utils/certs.py Normal file
View File

@ -0,0 +1,106 @@
import hashlib
from ipaddress import ip_interface
from utils.sqlite import SQLite
CERTIFICATES_SCHEMA = {
'certificates': {
'ip_int': 'VARCHAR(100) NOT NULL',
'port': 'INT NOT NULL',
'cert': 'TEXT NOT NULL',
'key': 'TEXT NOT NULL',
'PRIMARY KEY': '(ip_int, port)'
}
}
_certs_db = SQLite('db/firegex.db', schema=CERTIFICATES_SCHEMA)
def ip_parse(ip: str) -> str:
try:
return str(ip_interface(ip).network)
except Exception:
return ip
def get_tls_ports(ip: str, port: int) -> tuple[int, int]:
ip_addr = ip.split("/")[0]
key = f"{ip_addr}:{port}"
h = int.from_bytes(hashlib.sha256(key.encode('utf-8')).digest()[:4], 'big')
ssl_port = 10000 + (h % 10000)
clear_port = 20000 + (h % 10000)
return ssl_port, clear_port
class CertsDB:
def __init__(self, db_path='db/firegex.db'):
# Ignore db_path if it matches the default, otherwise instantiate SQLite
# (mostly for compatibility with tests if they pass custom paths)
if db_path == 'db/firegex.db':
self.db = _certs_db
else:
self.db = SQLite(db_path)
if self.db.conn is None:
self.db.connect()
def get_cert_and_key(self, ip_int: str, port: int) -> tuple[str | None, str | None]:
ip_int = ip_parse(ip_int)
res = self.db.query("SELECT cert, key FROM certificates WHERE ip_int = ? AND port = ?;", ip_int, port)
if res:
return res[0]["cert"], res[0]["key"]
return None, None
def get_multiple_certs_and_keys(self, services: list[dict]) -> dict[tuple[str, int], tuple[str, str]]:
if not services:
return {}
conditions = []
params = []
for s in services:
conditions.append("(ip_int = ? AND port = ?)")
params.extend([ip_parse(s["ip_int"]), s["port"]])
query_str = "SELECT ip_int, port, cert, key FROM certificates WHERE " + " OR ".join(conditions) + ";"
res = self.db.query(query_str, *params)
return {(ip_parse(row["ip_int"]), row["port"]): (row["cert"], row["key"]) for row in res}
def upsert_cert_and_key(self, ip_int: str, port: int, cert: str, key: str) -> None:
ip_int = ip_parse(ip_int)
self.db.query("INSERT OR REPLACE INTO certificates (ip_int, port, cert, key) VALUES (?, ?, ?, ?);", ip_int, port, cert, key)
def delete_cert_and_key(self, ip_int: str, port: int) -> None:
ip_int = ip_parse(ip_int)
# Check if the certificate is still used by any service in regex DB
regex_db = SQLite('db/nft-regex.db')
regex_db.connect()
try:
regex_used = regex_db.query("SELECT 1 FROM services WHERE ip_int = ? AND port = ?;", ip_int, port)
except Exception:
regex_used = []
finally:
regex_db.disconnect()
# Check if the certificate is still used by any service in proxy DB
proxy_db = SQLite('db/nft-pyfilters.db')
proxy_db.connect()
try:
proxy_used = proxy_db.query("SELECT 1 FROM services WHERE ip_int = ? AND port = ?;", ip_int, port)
except Exception:
proxy_used = []
finally:
proxy_db.disconnect()
if not regex_used and not proxy_used:
self.db.query("DELETE FROM certificates WHERE ip_int = ? AND port = ?;", ip_int, port)
def populate_services_tls_config(services: list[dict]) -> list[dict]:
if not services:
return services
certs_map = CertsDB().get_multiple_certs_and_keys(services)
for srv in services:
if srv.get("tls_enabled"):
srv["ssl_port"], srv["clear_port"] = get_tls_ports(srv["ip_int"], srv["port"])
cert, key = certs_map.get((ip_parse(srv["ip_int"]), srv["port"]), (None, None))
srv["tls_cert"] = cert
srv["tls_key"] = key
else:
srv["ssl_port"], srv["clear_port"] = None, None
srv["tls_cert"], srv["tls_key"] = None, None
return services

View File

@ -25,3 +25,8 @@ class IpInterface(BaseModel):
class ResetRequest(BaseModel):
delete:bool
class TLSConfigForm(BaseModel):
tls_enabled: bool
tls_cert: str|None = None
tls_key: str|None = None

View File

@ -2,6 +2,7 @@ import { Button, Group, Space, TextInput, Notification, Modal, Switch, Segmented
import { useForm } from '@mantine/form';
import { useEffect, useState } from 'react';
import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils';
import TLSServiceFields from '../TLSServiceFields';
import { ImCross } from "react-icons/im"
import { nfproxy, Service } from './utils';
import PortAndInterface from '../PortAndInterface';
@ -18,7 +19,10 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
ip_int:edit?.ip_int??"",
proto:edit?.proto??"tcp",
fail_open: edit?.fail_open??false,
autostart: true
autostart: true,
tls_enabled: false,
tls_cert: "",
tls_key: ""
}
const form = useForm({
@ -28,6 +32,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
port: (value) => (value>0 && value<65536) ? null : "Invalid port",
proto: (value) => ["tcp","http"].includes(value) ? null : "Invalid protocol",
ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address",
tls_cert: (value, values) => !edit && values.tls_enabled && !value ? "Certificate is required when TLS is enabled" : null,
tls_key: (value, values) => !edit && values.tls_enabled && !value ? "Private key is required when TLS is enabled" : null,
}
})
@ -38,6 +44,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
}
}, [opened])
const close = () =>{
onClose()
form.reset()
@ -47,26 +55,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
const [submitLoading, setSubmitLoading] = useState(false)
const [error, setError] = useState<string|null>(null)
const submitRequest = ({ name, port, autostart, proto, ip_int, fail_open }:ServiceAddForm) =>{
const submitRequest = (values: ServiceAddForm) => {
setSubmitLoading(true)
if (edit){
nfproxy.settings(edit.service_id, { port, ip_int, fail_open }).then( res => {
nfproxy.settings(edit.service_id, { port: values.port, ip_int: values.ip_int, fail_open: values.fail_open }).then( res => {
if (!res){
setSubmitLoading(false)
close();
okNotify(`Service ${name} settings updated`, `Successfully updated settings for service ${name}`)
okNotify(`Service ${values.name} settings updated`, `Successfully updated settings for service ${values.name}`)
}
}).catch( err => {
setSubmitLoading(false)
setError("Request Failed! [ "+err+" ]")
})
}else{
nfproxy.servicesadd({ name, port, proto, ip_int, fail_open }).then( res => {
const payload = {
name: values.name,
port: values.port,
proto: values.proto,
ip_int: values.ip_int,
fail_open: values.fail_open,
tls_enabled: values.tls_enabled,
tls_cert: values.tls_enabled ? values.tls_cert : null,
tls_key: values.tls_enabled ? values.tls_key : null,
}
nfproxy.servicesadd(payload).then( res => {
if (res.status === "ok" && res.service_id){
setSubmitLoading(false)
close();
if (autostart) nfproxy.servicestart(res.service_id)
okNotify(`Service ${name} has been added`, `Successfully added service with port ${port}`)
if (values.autostart) nfproxy.servicestart(res.service_id)
okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`)
}else{
setSubmitLoading(false)
setError("Invalid request! [ "+res.status+" ]")
@ -129,6 +147,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
/>}
</Box>
{!edit && <TLSServiceFields form={form} />}
<Group justify='flex-end' mt="md" mb="sm">
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button>
</Group>

View File

@ -10,12 +10,13 @@ import { BiRename } from 'react-icons/bi'
import RenameForm from './RenameForm';
import { MenuDropDownWithButton } from '../../MainLayout';
import { useQueryClient } from '@tanstack/react-query';
import { TbPlugConnected } from "react-icons/tb";
import { TbPlugConnected, TbShieldLock } from "react-icons/tb";
import { FaFilter } from "react-icons/fa";
import { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../AddEditService';
import { FaPencilAlt } from "react-icons/fa";
import { ExceptionWarning } from '../ExceptionWarning';
import TLSConfigModal from '../../TLSConfigModal';
export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) {
@ -30,6 +31,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
const [deleteModal, setDeleteModal] = useState(false)
const [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const isMedium = isMediumScreen()
const stopService = async () => {
@ -91,6 +93,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}>
:{service.port}
</Badge>
{service.tls_enabled && (
<Badge color="green" radius="md" size="lg" variant="light">
<TbShieldLock size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
TLS (lo:{service.clear_port})
</Badge>
)}
</Box>
{isMedium?null:<Space w="xl" />}
</Box>
@ -115,6 +123,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
<Menu.Item><b>Edit service</b></Menu.Item>
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
<Menu.Item leftSection={<TbShieldLock size={18} />} onClick={()=>setTlsModal(true)}>Configure TLS</Menu.Item>
<Divider />
<Menu.Label><b>Danger zone</b></Menu.Label>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
@ -160,5 +169,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
onClose={()=>setEditModal(false)}
edit={service}
/>
<TLSConfigModal
opened={tlsModal}
onClose={() => setTlsModal(false)}
service={service}
updateTlsConfig={nfproxy.updatetlsconfig}
queryKey={serviceQueryKey}
/>
</>
}

View File

@ -13,14 +13,29 @@ export type Service = {
edited_packets:number,
blocked_packets:number,
fail_open:boolean,
tls_enabled:boolean,
tls_cert?:string,
tls_key?:string,
ssl_port?:number,
clear_port?:number,
}
export type TLSConfig = {
tls_enabled: boolean,
tls_cert: string | null,
tls_key: string | null,
}
export type ServiceAddForm = {
name:string,
port:number,
proto:string,
ip_int:string,
fail_open: boolean,
tls_enabled?: boolean,
tls_cert?: string | null,
tls_key?: string | null,
}
export type ServiceSettings = {
@ -88,6 +103,10 @@ export const nfproxy = {
const { status } = await putapi(`nfproxy/services/${service_id}/settings`,data) as ServerResponse;
return status === "ok"?undefined:status
},
updatetlsconfig: async (service_id:string, data:TLSConfig) => {
const { status } = await putapi(`nfproxy/services/${service_id}/tls-config`,data) as ServerResponse;
return status === "ok"?undefined:status
},
getpyfilterscode: async (service_id:string) => {
return await getapi(`nfproxy/services/${service_id}/code`) as string;
},

View File

@ -2,6 +2,7 @@ import { Button, Group, Space, TextInput, Notification, Modal, Switch, Segmented
import { useForm } from '@mantine/form';
import { useEffect, useState } from 'react';
import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils';
import TLSServiceFields from '../TLSServiceFields';
import { ImCross } from "react-icons/im"
import { nfregex, Service } from './utils';
import PortAndInterface from '../PortAndInterface';
@ -18,7 +19,10 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
ip_int:edit?.ip_int??"",
proto:edit?.proto??"tcp",
fail_open: edit?.fail_open??false,
autostart: true
autostart: true,
tls_enabled: false,
tls_cert: "",
tls_key: ""
}
const form = useForm({
@ -28,6 +32,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
port: (value) => (value>0 && value<65536) ? null : "Invalid port",
proto: (value) => ["tcp","udp"].includes(value) ? null : "Invalid protocol",
ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address",
tls_cert: (value, values) => !edit && values.tls_enabled && !value ? "Certificate is required when TLS is enabled" : null,
tls_key: (value, values) => !edit && values.tls_enabled && !value ? "Private key is required when TLS is enabled" : null,
}
})
@ -38,6 +44,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
}
}, [opened])
const close = () =>{
onClose()
form.reset()
@ -47,26 +55,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
const [submitLoading, setSubmitLoading] = useState(false)
const [error, setError] = useState<string|null>(null)
const submitRequest = ({ name, port, autostart, proto, ip_int, fail_open }:ServiceAddForm) =>{
const submitRequest = (values: ServiceAddForm) => {
setSubmitLoading(true)
if (edit){
nfregex.settings(edit.service_id, { port, proto, ip_int, fail_open }).then( res => {
nfregex.settings(edit.service_id, { port: values.port, proto: values.proto, ip_int: values.ip_int, fail_open: values.fail_open }).then( res => {
if (!res){
setSubmitLoading(false)
close();
okNotify(`Service ${name} settings updated`, `Successfully updated settings for service ${name}`)
okNotify(`Service ${values.name} settings updated`, `Successfully updated settings for service ${values.name}`)
}
}).catch( err => {
setSubmitLoading(false)
setError("Request Failed! [ "+err+" ]")
})
}else{
nfregex.servicesadd({ name, port, proto, ip_int, fail_open }).then( res => {
const payload = {
name: values.name,
port: values.port,
proto: values.proto,
ip_int: values.ip_int,
fail_open: values.fail_open,
tls_enabled: values.tls_enabled,
tls_cert: values.tls_enabled ? values.tls_cert : null,
tls_key: values.tls_enabled ? values.tls_key : null,
}
nfregex.servicesadd(payload).then( res => {
if (res.status === "ok" && res.service_id){
setSubmitLoading(false)
close();
if (autostart) nfregex.servicestart(res.service_id)
okNotify(`Service ${name} has been added`, `Successfully added service with port ${port}`)
if (values.autostart) nfregex.servicestart(res.service_id)
okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`)
}else{
setSubmitLoading(false)
setError("Invalid request! [ "+res.status+" ]")
@ -129,6 +147,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
/>
</Box>
{!edit && <TLSServiceFields form={form} disabled={form.values.proto !== 'tcp'} />}
<Group justify='flex-end' mt="md" mb="sm">
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button>
</Group>

View File

@ -14,6 +14,8 @@ import { FaFilter } from "react-icons/fa";
import { VscRegex } from "react-icons/vsc";
import { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../AddEditService';
import { TbShieldLock } from "react-icons/tb";
import TLSConfigModal from '../../TLSConfigModal';
export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) {
@ -28,6 +30,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
const [deleteModal, setDeleteModal] = useState(false)
const [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const isMedium = isMediumScreen()
const stopService = async () => {
@ -89,6 +92,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}>
:{service.port}
</Badge>
{service.tls_enabled && (
<Badge color="green" radius="md" size="lg" variant="light">
<TbShieldLock size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
TLS (lo:{service.clear_port})
</Badge>
)}
</Box>
{isMedium?null:<Space w="xl" />}
</Box>
@ -109,6 +118,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
<Menu.Item><b>Edit service</b></Menu.Item>
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
<Menu.Item leftSection={<TbShieldLock size={18} />} onClick={()=>setTlsModal(true)}>Configure TLS</Menu.Item>
<Divider />
<Menu.Label><b>Danger zone</b></Menu.Label>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
@ -154,5 +164,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
onClose={()=>setEditModal(false)}
edit={service}
/>
<TLSConfigModal
opened={tlsModal}
onClose={() => setTlsModal(false)}
service={service}
updateTlsConfig={nfregex.updatetlsconfig}
queryKey={serviceQueryKey}
/>
</>
}

View File

@ -13,6 +13,11 @@ export type Service = {
n_packets:number,
n_regex:number,
fail_open:boolean,
tls_enabled:boolean,
tls_cert?: string,
tls_key?: string,
ssl_port?: number,
clear_port?: number,
}
export type ServiceAddForm = {
@ -21,6 +26,9 @@ export type ServiceAddForm = {
proto:string,
ip_int:string,
fail_open: boolean,
tls_enabled?: boolean,
tls_cert?: string | null,
tls_key?: string | null,
}
export type ServiceSettings = {
@ -28,6 +36,9 @@ export type ServiceSettings = {
proto?:string,
ip_int?:string,
fail_open?: boolean,
tls_enabled?: boolean,
tls_cert?: string | null,
tls_key?: string | null,
}
export type ServiceAddResponse = {
@ -92,4 +103,8 @@ export const nfregex = {
const { status } = await putapi(`nfregex/services/${service_id}/settings`,data) as ServerResponse;
return status === "ok"?undefined:status
},
updatetlsconfig: async (service_id: string, data: { tls_enabled: boolean, tls_cert: string | null, tls_key: string | null }) => {
const { status } = await putapi(`nfregex/services/${service_id}/tls-config`, data) as ServerResponse;
return status === "ok" ? undefined : status;
},
}

View File

@ -0,0 +1,137 @@
import { Button, Group, Space, Textarea, Switch, Modal, Notification } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useEffect, useState } from 'react';
import { ImCross } from "react-icons/im";
import { okNotify } from '../js/utils';
import { useQueryClient } from '@tanstack/react-query';
export interface Service {
service_id: string;
name: string;
port: number;
proto: string;
ip_int: string;
fail_open: boolean;
tls_enabled: boolean;
tls_cert?: string;
tls_key?: string;
ssl_port?: number;
clear_port?: number;
}
interface TLSConfigModalProps {
opened: boolean;
onClose: () => void;
service: Service;
updateTlsConfig: (serviceId: string, data: { tls_enabled: boolean; tls_cert: string | null; tls_key: string | null }) => Promise<string | undefined>;
queryKey: readonly unknown[];
}
export default function TLSConfigModal({ opened, onClose, service, updateTlsConfig, queryKey }: TLSConfigModalProps) {
const queryClient = useQueryClient();
const [submitLoading, setSubmitLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const form = useForm({
initialValues: {
tls_enabled: service.tls_enabled,
tls_cert: service.tls_cert ?? "",
tls_key: service.tls_key ?? "",
},
validate: {
tls_cert: (value, values) => values.tls_enabled && !value.trim() ? "Certificate is required when TLS is enabled" : null,
tls_key: (value, values) => values.tls_enabled && !value.trim() ? "Private key is required when TLS is enabled" : null,
}
});
useEffect(() => {
if (opened) {
form.setValues({
tls_enabled: service.tls_enabled,
tls_cert: service.tls_cert ?? "",
tls_key: service.tls_key ?? "",
});
setError(null);
}
}, [opened, service]);
const close = () => {
onClose();
form.reset();
setError(null);
};
const submitRequest = (values: { tls_enabled: boolean; tls_cert: string; tls_key: string }) => {
setSubmitLoading(true);
setError(null);
const payload = {
tls_enabled: values.tls_enabled,
tls_cert: values.tls_enabled ? values.tls_cert : null,
tls_key: values.tls_enabled ? values.tls_key : null,
};
updateTlsConfig(service.service_id, payload)
.then((res) => {
setSubmitLoading(false);
if (!res) {
close();
okNotify(`TLS configuration updated`, `Successfully updated TLS configuration for service ${service.name}`);
queryClient.invalidateQueries({ queryKey });
} else {
setError(`Error: [ ${res} ]`);
}
})
.catch((err) => {
setSubmitLoading(false);
setError(`Request Failed! [ ${err} ]`);
});
};
return (
<Modal size="xl" title={`TLS Configuration - ${service.name}`} opened={opened} onClose={close} closeOnClickOutside={false} centered>
<form onSubmit={form.onSubmit(submitRequest)}>
<Switch
label="Enable TLS Decryption / Encryption"
description="Decrypt and encrypt incoming and outgoing traffic using Nginx stream proxy"
{...form.getInputProps('tls_enabled', { type: 'checkbox' })}
/>
{form.values.tls_enabled && (
<>
<Space h="md" />
<Textarea
label="TLS Certificate (PEM format)"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
minRows={6}
styles={{ input: { fontFamily: 'monospace' } }}
{...form.getInputProps('tls_cert')}
/>
<Space h="md" />
<Textarea
label="TLS Private Key (PEM format)"
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
minRows={6}
styles={{ input: { fontFamily: 'monospace' } }}
{...form.getInputProps('tls_key')}
/>
</>
)}
<Group mt="xl" justify="flex-end" mb="sm">
<Button loading={submitLoading} type="submit">Save Configuration</Button>
</Group>
{error && (
<>
<Space h="md" />
<Notification icon={<ImCross size={14} />} color="red" onClose={() => setError(null)}>
Error: {error}
</Notification>
<Space h="md" />
</>
)}
</form>
</Modal>
);
}

View File

@ -0,0 +1,78 @@
import { Space, Switch, Textarea } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useEffect } from 'react';
import { getapi, regex_ipv4, regex_ipv6 } from '../js/utils';
interface TLSServiceFieldsProps {
form: UseFormReturnType<any>;
disabled?: boolean;
}
export default function TLSServiceFields({ form, disabled }: TLSServiceFieldsProps) {
useEffect(() => {
if (!form.values.tls_enabled || !form.values.ip_int || !form.values.port) {
return;
}
const isIpValid = form.values.ip_int.match(regex_ipv4) || form.values.ip_int.match(regex_ipv6);
const isPortValid = form.values.port > 0 && form.values.port < 65536;
if (!isIpValid || !isPortValid) {
return;
}
const debounceTimer = setTimeout(() => {
getapi(`certificates?ip_int=${encodeURIComponent(form.values.ip_int)}&port=${form.values.port}`)
.then(res => {
if (res && res.cert) {
form.setFieldValue('tls_cert', res.cert);
}
if (res && res.key) {
form.setFieldValue('tls_key', res.key);
}
})
.catch(err => {
console.error("Failed to fetch certificate", err);
});
}, 500);
return () => clearTimeout(debounceTimer);
}, [form.values.tls_enabled, form.values.ip_int, form.values.port]);
useEffect(() => {
if (disabled && form.values.tls_enabled) {
form.setFieldValue('tls_enabled', false);
}
}, [disabled, form.values.tls_enabled]);
return (
<>
<Space h="md" />
<Switch
label="Enable TLS Decryption / Encryption"
description="Decrypt and encrypt incoming and outgoing traffic using Nginx stream proxy"
disabled={disabled}
{...form.getInputProps('tls_enabled', { type: 'checkbox' })}
/>
{form.values.tls_enabled && (
<>
<Space h="md" />
<Textarea
label="TLS Certificate (PEM format)"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
minRows={4}
styles={{ input: { fontFamily: 'monospace' } }}
{...form.getInputProps('tls_cert')}
/>
<Space h="md" />
<Textarea
label="TLS Private Key (PEM format)"
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
minRows={4}
styles={{ input: { fontFamily: 'monospace' } }}
{...form.getInputProps('tls_key')}
/>
</>
)}
</>
);
}

View File

@ -16,7 +16,7 @@ import { FaArrowLeft } from "react-icons/fa";
import { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../../components/NFProxy/AddEditService';
import PyFilterView from '../../components/PyFilterView';
import { TbPlugConnected } from 'react-icons/tb';
import { TbPlugConnected, TbShieldLock } from 'react-icons/tb';
import { CodeHighlight } from '@mantine/code-highlight';
import { FaPython } from "react-icons/fa";
import { FiFileText } from "react-icons/fi";
@ -24,6 +24,7 @@ import { ModalLog } from '../../components/ModalLog';
import { useListState } from '@mantine/hooks';
import { ExceptionWarning } from '../../components/NFProxy/ExceptionWarning';
import { DocsButton } from '../../components/DocsButton';
import TLSConfigModal from '../../components/TLSConfigModal';
export default function ServiceDetailsNFProxy() {
@ -34,6 +35,7 @@ export default function ServiceDetailsNFProxy() {
const [deleteModal, setDeleteModal] = useState(false)
const [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const [buttonLoading, setButtonLoading] = useState(false)
const queryClient = useQueryClient()
const filterCode = nfproxyServiceFilterCodeQuery(srv??"")
@ -136,11 +138,18 @@ export default function ServiceDetailsNFProxy() {
<Badge size="xl" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" mr="sm">
:{serviceInfo.port}
</Badge>
{serviceInfo.tls_enabled && (
<Badge color="green" size="xl" variant="light" radius="md" mr="sm">
<TbShieldLock size={16} style={{ marginRight: 6, verticalAlign: 'middle' }} />
TLS (lo:{serviceInfo.clear_port})
</Badge>
)}
<MenuDropDownWithButton>
<Menu.Item><b>Edit service</b></Menu.Item>
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
<Menu.Item leftSection={<TbShieldLock size={18} />} onClick={()=>setTlsModal(true)}>Configure TLS</Menu.Item>
<Divider />
<Menu.Label><b>Danger zone</b></Menu.Label>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
@ -227,6 +236,13 @@ export default function ServiceDetailsNFProxy() {
onClose={()=>setEditModal(false)}
edit={serviceInfo}
/>
<TLSConfigModal
opened={tlsModal}
onClose={() => setTlsModal(false)}
service={serviceInfo}
updateTlsConfig={nfproxy.updatetlsconfig}
queryKey={serviceQueryKey}
/>
<ModalLog
opened={openLogModal}
close={()=>setOpenLogModal(false)}

121
tests/nginx_test.py Normal file
View File

@ -0,0 +1,121 @@
#!/usr/bin/env python3
import sys
import os
from unittest.mock import MagicMock
sys.modules['socketio'] = MagicMock()
sys.modules['socketio.exceptions'] = MagicMock()
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../backend')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'utils')))
from modules.nfproxy.nginx import get_tls_ports, update_nginx, NGINX_CONF_PATH
from colors import colors, puts, sep
# Create mock services
services = [
{
"service_id": "a1b2c3d4",
"name": "test_service",
"port": 443,
"status": "active",
"proto": "tcp",
"ip_int": "127.0.0.1",
"tls_enabled": 1,
"tls_cert": "CERT_CONTENT",
"tls_key": "KEY_CONTENT"
},
{
"service_id": "e5f6a7b8",
"name": "test_service_ipv6",
"port": 8443,
"status": "active",
"proto": "tcp",
"ip_int": "::1",
"tls_enabled": 1,
"tls_cert": "CERT6_CONTENT",
"tls_key": "KEY6_CONTENT"
}
]
try:
# We monkey-patch subprocess.run to prevent it from calling real nginx if it fails
import subprocess
orig_run = subprocess.run
def mock_run(args, *a, **k):
# Just pretend it succeeded
class MockCompletedProcess:
returncode = 0
stdout = b""
stderr = b""
return MockCompletedProcess()
subprocess.run = mock_run
sep()
puts("Running Nginx Config Unit Test...", color=colors.cyan, is_bold=True)
update_nginx(services)
puts("Nginx update completed successfully! ✔", color=colors.green)
# Check if config exists and has valid contents
if os.path.exists(NGINX_CONF_PATH):
with open(NGINX_CONF_PATH, "r") as f:
content = f.read()
# Check pid and error_log paths
assert "pid /tmp/firegex_nginx.pid;" in content
assert "error_log /tmp/firegex_nginx_error.log info;" in content
# Check cert paths
assert "ssl_certificate /tmp/firegex_cert_a1b2c3d4.crt;" in content
assert "ssl_certificate /tmp/firegex_cert_e5f6a7b8.crt;" in content
# Verify deterministic ports for IPv4
# sha256("127.0.0.1:443")[:4] as int = 3737521872
# 3737521872 % 10000 = 3936
# ssl_port = 10000 + 3936 = 13936
# clear_port = 20000 + 3936 = 23936
assert "127.0.0.1:13936" in content
assert "127.0.0.1:23936" in content
# Verify deterministic ports & bracket wrapping for IPv6
# sha256("::1:8443")[:4] as int = 2439169896
# 2439169896 % 10000 = 9896
# ssl_port = 10000 + 9896 = 19896
# clear_port = 20000 + 9896 = 29896
assert "[::1]:19896" in content
assert "[::1]:29896" in content
assert "[::1]:8443" in content
puts("Configuration assertions passed! ✔", color=colors.green)
# Check temp files were created
assert os.path.exists("/tmp/firegex_cert_a1b2c3d4.crt")
assert os.path.exists("/tmp/firegex_key_a1b2c3d4.key")
assert os.path.exists("/tmp/firegex_cert_e5f6a7b8.crt")
assert os.path.exists("/tmp/firegex_key_e5f6a7b8.key")
puts("Temporary certificate files created! ✔", color=colors.green)
else:
puts("Error: nginx.conf not found! ✗", color=colors.red)
sys.exit(1)
# Write dummy files to simulate Nginx creating PID and error log files
with open("/tmp/firegex_nginx.pid", "w") as f:
f.write("12345")
with open("/tmp/firegex_nginx_error.log", "w") as f:
f.write("log info")
# Test cleanup by passing an empty list
update_nginx([])
assert not os.path.exists(NGINX_CONF_PATH)
assert not os.path.exists("/tmp/firegex_cert_a1b2c3d4.crt")
assert not os.path.exists("/tmp/firegex_key_a1b2c3d4.key")
assert not os.path.exists("/tmp/firegex_cert_e5f6a7b8.crt")
assert not os.path.exists("/tmp/firegex_key_e5f6a7b8.key")
assert not os.path.exists("/tmp/firegex_nginx.pid")
assert not os.path.exists("/tmp/firegex_nginx_error.log")
puts("Cleanup verified successfully! ✔", color=colors.green)
finally:
# Restore subprocess.run
subprocess.run = orig_run
puts("Test passed! ✔", color=colors.green, is_bold=True)
sep()

View File

@ -8,6 +8,9 @@ ERROR=0
pip3 install -r requirements.txt
echo "Running Nginx config unit test"
python3 nginx_test.py || ERROR=1
until curl --output /dev/null --silent --fail http://127.0.0.1:4444/api/status; do
printf '.'
sleep 1