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 FROM --platform=$TARGETARCH quay.io/fedora/fedora:43 AS base
RUN dnf -y update && dnf install -y python3.14 libnetfilter_queue \ RUN dnf -y update && dnf install -y python3.14 libnetfilter_queue \
libnfnetlink libmnl libcap-ng-utils nftables \ 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 RUN mkdir -p /execute/modules
WORKDIR /execute 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 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.loader import frontend_deploy, load_routers
from utils.models import ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel from utils.models import ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel
from utils.certs import CERTIFICATES_SCHEMA
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import socketio import socketio
@ -18,7 +19,7 @@ from socketio.exceptions import ConnectionRefusedError
import hashlib import hashlib
# DB init # DB init
db = SQLite('db/firegex.db') db = SQLite('db/firegex.db', CERTIFICATES_SCHEMA)
sysctl = SysctlManager({ sysctl = SysctlManager({
"net.ipv4.conf.all.forwarding": True, "net.ipv4.conf.all.forwarding": True,
"net.ipv6.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: // error code taken from the linux kernel:
// https://elixir.bootlin.com/linux/v5.18.12/source/include/linux/errno.h#L27 // https://elixir.bootlin.com/linux/v5.18.12/source/include/linux/errno.h#L27
#define ENOTSUPP 524 /* Operation is not supported */ #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) { if (error_msg->error != -ENOTSUPP && error_msg->error != -EOPNOTSUPP) {
_clear(); _clear();

View File

@ -9,6 +9,15 @@ if [ -n "$SOCKET_DIR" ]; then
chmod 755 "$SOCKET_DIR" chmod 755 "$SOCKET_DIR"
fi 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..." echo "[*] Attempting to start with capabilities..."
if capsh --caps="cap_net_admin,cap_setpcap,cap_setuid,cap_setgid,cap_sys_nice+eip" \ if capsh --caps="cap_net_admin,cap_setpcap,cap_setuid,cap_setgid,cap_sys_nice+eip" \
@ -21,7 +30,8 @@ then
--keep=1 \ --keep=1 \
--user=nobody \ --user=nobody \
--addamb=cap_net_admin,cap_sys_nice \ --addamb=cap_net_admin,cap_sys_nice \
-- -c "python3 /execute/app.py DOCKER" --shell=/usr/bin/python3 \
-- /execute/app.py DOCKER
else else
echo "[!] capsh failed, running with root user" echo "[!] capsh failed, running with root user"
exec python3 /execute/app.py DOCKER exec python3 /execute/app.py DOCKER

View File

@ -90,11 +90,11 @@ class FiregexInterceptor:
proxy_binary_path, stdin=asyncio.subprocess.DEVNULL, proxy_binary_path, stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, stderr=asyncio.subprocess.STDOUT,
env={ env=dict(os.environ, **{
"NTHREADS": os.getenv("NTHREADS","1"), "NTHREADS": os.getenv("NTHREADS","1"),
"FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0", "FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0",
"FIREGEX_NFPROXY_SOCK": self.sock_path "FIREGEX_NFPROXY_SOCK": self.sock_path
}, }),
) )
nicenessify(-10, self.process.pid) nicenessify(-10, self.process.pid)
self.outstrem_task = asyncio.create_task(self._stream_handler()) 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.firegex import FiregexInterceptor
from modules.nfproxy.nftables import FiregexTables, FiregexFilter from modules.nfproxy.nftables import FiregexTables, FiregexFilter
from modules.nfproxy.models import Service, PyFilter from modules.nfproxy.models import Service, PyFilter
from modules.nfproxy.nginx import sync_nginx_state
from utils.sqlite import SQLite from utils.sqlite import SQLite
from utils import run_func from utils import run_func
from utils.certs import CertsDB, ip_parse as certs_ip_parse
class STATUS: class STATUS:
STOP = "stop" 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) self.interceptor = await FiregexInterceptor.start(self.srv, outstream_func=self.outstream_function, exception_func=self.exception_function)
await self._update_filters_from_db() await self._update_filters_from_db()
self._set_status(STATUS.ACTIVE) self._set_status(STATUS.ACTIVE)
if self.srv.tls_enabled:
sync_nginx_state()
async def stop(self): async def stop(self):
nft.delete(self.srv) nft.delete(self.srv)
@ -79,6 +83,8 @@ class ServiceManager:
await self.interceptor.stop() await self.interceptor.stop()
self.interceptor = None self.interceptor = None
self._set_status(STATUS.STOP) self._set_status(STATUS.STOP)
if self.srv.tls_enabled:
sync_nginx_state()
async def restart(self): async def restart(self):
await self.stop() await self.stop()
@ -88,6 +94,18 @@ class ServiceManager:
async with self.lock: async with self.lock:
await self._update_filters_from_db() 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: class FirewallManager:
def __init__(self, db:SQLite, outstream_func=None, exception_func=None): def __init__(self, db:SQLite, outstream_func=None, exception_func=None):
self.db = db self.db = db
@ -99,6 +117,7 @@ class FirewallManager:
async def close(self): async def close(self):
for key in list(self.service_table.keys()): for key in list(self.service_table.keys()):
await self.remove(key) await self.remove(key)
sync_nginx_state()
async def remove(self,srv_id): async def remove(self,srv_id):
async with self.lock: async with self.lock:
@ -112,12 +131,21 @@ class FirewallManager:
async def reload(self): async def reload(self):
async with self.lock: async with self.lock:
for srv in self.db.query('SELECT * FROM services;'): services = self.db.query('SELECT * FROM services;')
srv = Service.from_dict(srv) tls_services = [s for s in services if s.get("tls_enabled") and s["service_id"] not in self.service_table]
if srv.id 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 continue
self.service_table[srv.id] = ServiceManager(srv, self.db, outstream_func=self.outstream_function, exception_func=self.exception_function) if srv.get("tls_enabled"):
await self.service_table[srv.id].next(srv.status) 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: def get(self,srv_id) -> ServiceManager:
if srv_id in self.service_table: if srv_id in self.service_table:

View File

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

View File

@ -1,5 +1,6 @@
from modules.nfproxy.models import Service from modules.nfproxy.models import Service
from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json 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): def convert_protocol_to_l4(proto:str):
if proto == "tcp": 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 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 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): class FiregexTables(NFTableManager):
input_chain = "nfproxy_input" input_chain = "nfproxy_input"
output_chain = "nfproxy_output" output_chain = "nfproxy_output"
nat_chain = "nfproxy_nat"
def __init__(self): def __init__(self):
super().__init__([ super().__init__([
@ -45,25 +56,76 @@ class FiregexTables(NFTableManager):
"hook":"postrouting", "hook":"postrouting",
"prio":108, "prio":108,
"policy":"accept" "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}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"delete":{"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}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"delete":{"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): def add(self, srv:Service, queue_range):
for ele in self.get(): for ele in self.get():
if ele.__eq__(srv): if ele.matches(srv):
return return
init, end = queue_range init, end = queue_range
if init > end: if init > end:
init, end = end, init 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( 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", "family": "inet",
"table": self.table_name, "table": self.table_name,
"chain": self.output_chain, "chain": self.output_chain,
@ -74,7 +136,7 @@ class FiregexTables(NFTableManager):
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} {"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", "family": "inet",
"table": self.table_name, "table": self.table_name,
"chain": self.input_chain, "chain": self.input_chain,
@ -90,7 +152,8 @@ class FiregexTables(NFTableManager):
def get(self) -> list[FiregexFilter]: def get(self) -> list[FiregexFilter]:
res = [] 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 ip_int = None
if isinstance(filter["expr"][0]["match"]["right"],str): if isinstance(filter["expr"][0]["match"]["right"],str):
ip_int = str(ip_parse(filter["expr"][0]["match"]["right"])) ip_int = str(ip_parse(filter["expr"][0]["match"]["right"]))
@ -103,11 +166,13 @@ class FiregexTables(NFTableManager):
port=filter["expr"][1]["match"]["right"], port=filter["expr"][1]["match"]["right"],
ip_int=ip_int ip_int=ip_int
)) ))
except (KeyError, TypeError, IndexError):
pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip
return res return res
def delete(self, srv:Service): def delete(self, srv:Service):
for filter in self.get(): for filter in self.get():
if filter.__eq__(srv): if filter.matches(srv):
self.cmd({ "delete":{ "rule": { self.cmd({ "delete":{ "rule": {
"family": "inet", "family": "inet",
"table": self.table_name, "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( self.process = await asyncio.create_subprocess_exec(
proxy_binary_path, proxy_binary_path,
stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
env={ env=dict(os.environ, **{
"MATCH_MODE": "stream" if self.srv.proto == "tcp" else "block", "MATCH_MODE": "stream" if self.srv.proto == "tcp" else "block",
"NTHREADS": os.getenv("NTHREADS","1"), "NTHREADS": os.getenv("NTHREADS","1"),
"FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0", "FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0",
}, }),
) )
nicenessify(-10, self.process.pid) nicenessify(-10, self.process.pid)
line_fut = self.process.stdout.readuntil() 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.nftables import FiregexTables, FiregexFilter
from modules.nfregex.models import Regex, Service from modules.nfregex.models import Regex, Service
from utils.sqlite import SQLite 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: class STATUS:
STOP = "stop" STOP = "stop"
@ -63,6 +65,8 @@ class ServiceManager:
self.interceptor = await FiregexInterceptor.start(self.srv) self.interceptor = await FiregexInterceptor.start(self.srv)
await self._update_filters_from_db() await self._update_filters_from_db()
self._set_status(STATUS.ACTIVE) self._set_status(STATUS.ACTIVE)
if self.srv.tls_enabled:
sync_nginx_state()
async def stop(self): async def stop(self):
nft.delete(self.srv) nft.delete(self.srv)
@ -70,6 +74,8 @@ class ServiceManager:
await self.interceptor.stop() await self.interceptor.stop()
self.interceptor = None self.interceptor = None
self._set_status(STATUS.STOP) self._set_status(STATUS.STOP)
if self.srv.tls_enabled:
sync_nginx_state()
async def restart(self): async def restart(self):
await self.stop() await self.stop()
@ -79,6 +85,18 @@ class ServiceManager:
async with self.lock: async with self.lock:
await self._update_filters_from_db() 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: class FirewallManager:
def __init__(self, db:SQLite): def __init__(self, db:SQLite):
self.db = db self.db = db
@ -88,6 +106,7 @@ class FirewallManager:
async def close(self): async def close(self):
for key in list(self.service_table.keys()): for key in list(self.service_table.keys()):
await self.remove(key) await self.remove(key)
sync_nginx_state()
async def remove(self,srv_id): async def remove(self,srv_id):
async with self.lock: async with self.lock:
@ -101,12 +120,21 @@ class FirewallManager:
async def reload(self): async def reload(self):
async with self.lock: async with self.lock:
for srv in self.db.query('SELECT * FROM services;'): services = self.db.query('SELECT * FROM services;')
srv = Service.from_dict(srv) tls_services = [s for s in services if s.get("tls_enabled") and s["service_id"] not in self.service_table]
if srv.id 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 continue
self.service_table[srv.id] = ServiceManager(srv, self.db) if srv.get("tls_enabled"):
await self.service_table[srv.id].next(srv.status) 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: def get(self,srv_id) -> ServiceManager:
if srv_id in self.service_table: if srv_id in self.service_table:

View File

@ -1,7 +1,7 @@
import base64 import base64
class Service: 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.id = service_id
self.status = status self.status = status
self.port = port self.port = port
@ -9,6 +9,9 @@ class Service:
self.proto = proto self.proto = proto
self.ip_int = ip_int self.ip_int = ip_int
self.fail_open = fail_open self.fail_open = fail_open
self.tls_enabled = bool(tls_enabled)
self.tls_cert = tls_cert
self.tls_key = tls_key
@classmethod @classmethod
def from_dict(cls, var: dict): def from_dict(cls, var: dict):

View File

@ -1,5 +1,6 @@
from modules.nfregex.models import Service from modules.nfregex.models import Service
from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json
from modules.nfproxy.nginx import get_tls_ports
class FiregexFilter: class FiregexFilter:
def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int): 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 self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int)
return False 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): class FiregexTables(NFTableManager):
input_chain = "nfregex_input" input_chain = "nfregex_input"
output_chain = "nfregex_output" output_chain = "nfregex_output"
nat_chain = "nfregex_nat"
def __init__(self): def __init__(self):
super().__init__([ super().__init__([
@ -37,23 +48,74 @@ class FiregexTables(NFTableManager):
"hook":"postrouting", "hook":"postrouting",
"prio":107, "prio":107,
"policy":"accept" "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}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"delete":{"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}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"delete":{"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): def add(self, srv:Service, queue_range):
for ele in self.get(): for ele in self.get():
if ele.__eq__(srv): if ele.matches(srv):
return return
init, end = queue_range init, end = queue_range
if init > end: if init > end:
init, end = end, init 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( self.cmd(
{ "insert":{ "rule": { { "insert":{ "rule": {
"family": "inet", "family": "inet",
@ -82,7 +144,8 @@ class FiregexTables(NFTableManager):
def get(self) -> list[FiregexFilter]: def get(self) -> list[FiregexFilter]:
res = [] 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 ip_int = None
if isinstance(filter["expr"][0]["match"]["right"],str): if isinstance(filter["expr"][0]["match"]["right"],str):
ip_int = str(ip_parse(filter["expr"][0]["match"]["right"])) ip_int = str(ip_parse(filter["expr"][0]["match"]["right"]))
@ -95,15 +158,20 @@ class FiregexTables(NFTableManager):
port=filter["expr"][1]["match"]["right"], port=filter["expr"][1]["match"]["right"],
ip_int=ip_int ip_int=ip_int
)) ))
except (KeyError, TypeError, IndexError):
pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip
return res return res
def delete(self, srv:Service): def delete(self, srv:Service):
delete_cmds = []
for filter in self.get(): for filter in self.get():
if filter.__eq__(srv): if filter.matches(srv):
self.cmd({ "delete":{ "rule": { delete_cmds.append({ "delete":{ "rule": {
"family": "inet", "family": "inet",
"table": self.table_name, "table": self.table_name,
"chain": filter.target, "chain": filter.target,
"handle": filter.id "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 modules.nfproxy.firewall import STATUS, FirewallManager
from utils.sqlite import SQLite from utils.sqlite import SQLite
from utils import ip_parse, refactor_name, socketio_emit, PortType 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 import os
from firegex.nfproxy.internals import get_filter_names from firegex.nfproxy.internals import get_filter_names
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
@ -27,6 +28,11 @@ class ServiceModel(BaseModel):
edited_packets: int edited_packets: int
blocked_packets: int blocked_packets: int
fail_open: bool 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): class RenameForm(BaseModel):
name:str name:str
@ -49,6 +55,9 @@ class ServiceAddForm(BaseModel):
proto: str proto: str
ip_int: str ip_int: str
fail_open: bool = True fail_open: bool = True
tls_enabled: bool = False
tls_cert: str | None = None
tls_key: str | None = None
class ServiceAddResponse(BaseModel): class ServiceAddResponse(BaseModel):
status:str status:str
@ -69,6 +78,7 @@ db = SQLite('db/nft-pyfilters.db', {
'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_proto IN ("tcp", "udp"))', 'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_proto IN ("tcp", "udp"))',
'ip_int': 'VARCHAR(100) NOT NULL', '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',
}, },
'pyfilter': { 'pyfilter': {
'name': 'VARCHAR(100) NOT NULL', '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]) @app.get('/services', response_model=list[ServiceModel])
async def get_service_list(): async def get_service_list():
"""Get the list of existent firegex services""" """Get the list of existent firegex services"""
return db.query(""" res = db.query("""
SELECT SELECT
s.service_id service_id, s.service_id service_id,
s.status status, s.status status,
@ -147,12 +157,14 @@ async def get_service_list():
s.proto proto, s.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.tls_enabled tls_enabled,
COUNT(f.name) n_filters, COUNT(f.name) n_filters,
COALESCE(SUM(f.blocked_packets),0) blocked_packets, COALESCE(SUM(f.blocked_packets),0) blocked_packets,
COALESCE(SUM(f.edited_packets),0) edited_packets COALESCE(SUM(f.edited_packets),0) edited_packets
FROM services s LEFT JOIN pyfilter f ON s.service_id = f.service_id FROM services s LEFT JOIN pyfilter f ON s.service_id = f.service_id
GROUP BY s.service_id; GROUP BY s.service_id;
""") """)
return populate_services_tls_config(res)
@app.get('/services/{service_id}', response_model=ServiceModel) @app.get('/services/{service_id}', response_model=ServiceModel)
async def get_service_by_id(service_id: str): 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.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.tls_enabled tls_enabled,
COUNT(f.name) n_filters, COUNT(f.name) n_filters,
COALESCE(SUM(f.blocked_packets),0) blocked_packets, COALESCE(SUM(f.blocked_packets),0) blocked_packets,
COALESCE(SUM(f.edited_packets),0) edited_packets COALESCE(SUM(f.edited_packets),0) edited_packets
@ -174,7 +187,7 @@ async def get_service_by_id(service_id: str):
""", service_id) """, service_id)
if len(res) == 0: if len(res) == 0:
raise HTTPException(status_code=400, detail="This service does not exists!") 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) @app.post('/services/{service_id}/stop', response_model=StatusMessageModel)
async def service_stop(service_id: str): 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) @app.delete('/services/{service_id}', response_model=StatusMessageModel)
async def service_delete(service_id: str): async def service_delete(service_id: str):
"""Request the deletion of a specific service""" """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 services WHERE service_id = ?;', service_id)
db.query('DELETE FROM pyfilter 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"): 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): async def service_settings(service_id: str, form: SettingsForm):
"""Request to change the settings of a specific service (will cause a restart)""" """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): if form.port is not None and (form.port < 1 or form.port > 65535):
raise HTTPException(status_code=400, detail="Invalid port") raise HTTPException(status_code=400, detail="Invalid port")
@ -227,6 +248,9 @@ async def service_settings(service_id: str, form: SettingsForm):
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid address") 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 = [] keys = []
values = [] values = []
@ -237,9 +261,21 @@ async def service_settings(service_id: str, form: SettingsForm):
if len(keys) == 0: if len(keys) == 0:
raise HTTPException(status_code=400, detail="No settings to change provided") 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: try:
db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id)
except sqlite3.IntegrityError: 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") raise HTTPException(status_code=400, detail="A service with these settings already exists")
old_status = firewall.get(service_id).status old_status = firewall.get(service_id).status
@ -250,6 +286,29 @@ async def service_settings(service_id: str, form: SettingsForm):
await refresh_frontend() await refresh_frontend()
return {'status': 'ok'} 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]) @app.get('/services/{service_id}/pyfilters', response_model=list[PyFilterModel])
async def get_service_pyfilter_list(service_id: str): async def get_service_pyfilter_list(service_id: str):
"""Get the list of the pyfilters of a service""" """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") raise HTTPException(status_code=400, detail="Invalid address")
if form.proto not in ["tcp", "http"]: if form.proto not in ["tcp", "http"]:
raise HTTPException(status_code=400, detail="Invalid protocol") 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 srv_id = None
try: try:
srv_id = gen_service_id() srv_id = gen_service_id()
db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, l4_proto) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 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)) 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: except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="This type of service already exists") raise HTTPException(status_code=400, detail="This type of service already exists")
await firewall.reload() await firewall.reload()

View File

@ -8,7 +8,8 @@ from modules.nfregex.nftables import FiregexTables
from modules.nfregex.firewall import STATUS, FirewallManager from modules.nfregex.firewall import STATUS, FirewallManager
from utils.sqlite import SQLite from utils.sqlite import SQLite
from utils import ip_parse, refactor_name, socketio_emit, PortType 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 from modules.nfregex.firegex import test_regex_validity
class ServiceModel(BaseModel): class ServiceModel(BaseModel):
@ -21,6 +22,11 @@ class ServiceModel(BaseModel):
n_regex: int n_regex: int
n_packets: int n_packets: int
fail_open: bool 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): class RenameForm(BaseModel):
name:str name:str
@ -53,6 +59,9 @@ class ServiceAddForm(BaseModel):
proto: str proto: str
ip_int: str ip_int: str
fail_open: bool = False fail_open: bool = False
tls_enabled: bool = False
tls_cert: str | None = None
tls_key: str | None = None
class ServiceAddResponse(BaseModel): class ServiceAddResponse(BaseModel):
status:str status:str
@ -68,7 +77,8 @@ db = SQLite('db/nft-regex.db', {
'name': 'VARCHAR(100) NOT NULL UNIQUE', 'name': 'VARCHAR(100) NOT NULL UNIQUE',
'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))', 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))',
'ip_int': 'VARCHAR(100) NOT NULL', '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': { 'regexes': {
'regex': 'TEXT NOT NULL', 'regex': 'TEXT NOT NULL',
@ -130,7 +140,7 @@ firewall = FirewallManager(db)
@app.get('/services', response_model=list[ServiceModel]) @app.get('/services', response_model=list[ServiceModel])
async def get_service_list(): async def get_service_list():
"""Get the list of existent firegex services""" """Get the list of existent firegex services"""
return db.query(""" res = db.query("""
SELECT SELECT
s.service_id service_id, s.service_id service_id,
s.status status, s.status status,
@ -139,11 +149,13 @@ async def get_service_list():
s.proto proto, s.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.tls_enabled tls_enabled,
COUNT(r.regex_id) n_regex, COUNT(r.regex_id) n_regex,
COALESCE(SUM(r.blocked_packets),0) n_packets COALESCE(SUM(r.blocked_packets),0) n_packets
FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id
GROUP BY s.service_id; GROUP BY s.service_id;
""") """)
return populate_services_tls_config(res)
@app.get('/services/{service_id}', response_model=ServiceModel) @app.get('/services/{service_id}', response_model=ServiceModel)
async def get_service_by_id(service_id: str): 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.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.tls_enabled tls_enabled,
COUNT(r.regex_id) n_regex, COUNT(r.regex_id) n_regex,
COALESCE(SUM(r.blocked_packets),0) n_packets COALESCE(SUM(r.blocked_packets),0) n_packets
FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id 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) """, service_id)
if len(res) == 0: if len(res) == 0:
raise HTTPException(status_code=400, detail="This service does not exists!") 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) @app.post('/services/{service_id}/stop', response_model=StatusMessageModel)
async def service_stop(service_id: str): 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) @app.delete('/services/{service_id}', response_model=StatusMessageModel)
async def service_delete(service_id: str): async def service_delete(service_id: str):
"""Request the deletion of a specific service""" """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 services WHERE service_id = ?;', service_id)
db.query('DELETE FROM regexes WHERE service_id = ?;', service_id) db.query('DELETE FROM regexes WHERE service_id = ?;', service_id)
await firewall.remove(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): async def service_settings(service_id: str, form: SettingsForm):
"""Request to change the settings of a specific service (will cause a restart)""" """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"]: if form.proto is not None and form.proto not in ["tcp", "udp"]:
raise HTTPException(status_code=400, detail="Invalid protocol") 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): if form.port is not None and (form.port < 1 or form.port > 65535):
raise HTTPException(status_code=400, detail="Invalid port") raise HTTPException(status_code=400, detail="Invalid port")
@ -218,6 +244,9 @@ async def service_settings(service_id: str, form: SettingsForm):
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid address") 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 = [] keys = []
values = [] values = []
@ -228,9 +257,21 @@ async def service_settings(service_id: str, form: SettingsForm):
if len(keys) == 0: if len(keys) == 0:
raise HTTPException(status_code=400, detail="No settings to change provided") 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: try:
db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id)
except sqlite3.IntegrityError: 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") raise HTTPException(status_code=400, detail="A service with these settings already exists")
old_status = firewall.get(service_id).status 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") raise HTTPException(status_code=400, detail="Invalid address")
if form.proto not in ["tcp", "udp"]: if form.proto not in ["tcp", "udp"]:
raise HTTPException(status_code=400, detail="Invalid protocol") 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 srv_id = None
try: try:
srv_id = gen_service_id() srv_id = gen_service_id()
db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open) VALUES (?, ?, ?, ?, ?, ?, ?)", 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) 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: except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="This type of service already exists") raise HTTPException(status_code=400, detail="This type of service already exists")
await firewall.reload() await firewall.reload()
await refresh_frontend() await refresh_frontend()
return {'status': 'ok', 'service_id': srv_id} 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) @app.get('/metrics', response_class = PlainTextResponse)
async def metrics(): async def metrics():
"""Aggregate metrics""" """Aggregate metrics"""

View File

@ -305,6 +305,8 @@ def nicenessify(priority:int, pid:int|None=None):
ps = psutil.Process(pid) ps = psutil.Process(pid)
if os.name == 'posix': if os.name == 'posix':
ps.nice(priority) ps.nice(priority)
except (psutil.AccessDenied, PermissionError) as e:
print(f"Permission denied setting priority (pid={pid}, priority={priority}): {e}")
except Exception as e: except Exception as e:
print(f"Error setting priority: {e} {traceback.format_exc()}") print(f"Error setting priority: {e} {traceback.format_exc()}")
pass 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): class ResetRequest(BaseModel):
delete:bool 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 { useForm } from '@mantine/form';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils'; import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils';
import TLSServiceFields from '../TLSServiceFields';
import { ImCross } from "react-icons/im" import { ImCross } from "react-icons/im"
import { nfproxy, Service } from './utils'; import { nfproxy, Service } from './utils';
import PortAndInterface from '../PortAndInterface'; import PortAndInterface from '../PortAndInterface';
@ -18,7 +19,10 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
ip_int:edit?.ip_int??"", ip_int:edit?.ip_int??"",
proto:edit?.proto??"tcp", proto:edit?.proto??"tcp",
fail_open: edit?.fail_open??false, fail_open: edit?.fail_open??false,
autostart: true autostart: true,
tls_enabled: false,
tls_cert: "",
tls_key: ""
} }
const form = useForm({ const form = useForm({
@ -28,6 +32,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
port: (value) => (value>0 && value<65536) ? null : "Invalid port", port: (value) => (value>0 && value<65536) ? null : "Invalid port",
proto: (value) => ["tcp","http"].includes(value) ? null : "Invalid protocol", proto: (value) => ["tcp","http"].includes(value) ? null : "Invalid protocol",
ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address", 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]) }, [opened])
const close = () =>{ const close = () =>{
onClose() onClose()
form.reset() form.reset()
@ -47,26 +55,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
const [submitLoading, setSubmitLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false)
const [error, setError] = useState<string|null>(null) const [error, setError] = useState<string|null>(null)
const submitRequest = ({ name, port, autostart, proto, ip_int, fail_open }:ServiceAddForm) =>{ const submitRequest = (values: ServiceAddForm) => {
setSubmitLoading(true) setSubmitLoading(true)
if (edit){ 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){ if (!res){
setSubmitLoading(false) setSubmitLoading(false)
close(); 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 => { }).catch( err => {
setSubmitLoading(false) setSubmitLoading(false)
setError("Request Failed! [ "+err+" ]") setError("Request Failed! [ "+err+" ]")
}) })
}else{ }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){ if (res.status === "ok" && res.service_id){
setSubmitLoading(false) setSubmitLoading(false)
close(); close();
if (autostart) nfproxy.servicestart(res.service_id) if (values.autostart) nfproxy.servicestart(res.service_id)
okNotify(`Service ${name} has been added`, `Successfully added service with port ${port}`) okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`)
}else{ }else{
setSubmitLoading(false) setSubmitLoading(false)
setError("Invalid request! [ "+res.status+" ]") setError("Invalid request! [ "+res.status+" ]")
@ -129,6 +147,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
/>} />}
</Box> </Box>
{!edit && <TLSServiceFields form={form} />}
<Group justify='flex-end' mt="md" mb="sm"> <Group justify='flex-end' mt="md" mb="sm">
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button> <Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button>
</Group> </Group>

View File

@ -10,12 +10,13 @@ import { BiRename } from 'react-icons/bi'
import RenameForm from './RenameForm'; import RenameForm from './RenameForm';
import { MenuDropDownWithButton } from '../../MainLayout'; import { MenuDropDownWithButton } from '../../MainLayout';
import { useQueryClient } from '@tanstack/react-query'; 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 { FaFilter } from "react-icons/fa";
import { IoSettingsSharp } from 'react-icons/io5'; import { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../AddEditService'; import AddEditService from '../AddEditService';
import { FaPencilAlt } from "react-icons/fa"; import { FaPencilAlt } from "react-icons/fa";
import { ExceptionWarning } from '../ExceptionWarning'; import { ExceptionWarning } from '../ExceptionWarning';
import TLSConfigModal from '../../TLSConfigModal';
export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) { 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 [deleteModal, setDeleteModal] = useState(false)
const [renameModal, setRenameModal] = useState(false) const [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false) const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const isMedium = isMediumScreen() const isMedium = isMediumScreen()
const stopService = async () => { 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%" }}> <Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}>
:{service.port} :{service.port}
</Badge> </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> </Box>
{isMedium?null:<Space w="xl" />} {isMedium?null:<Space w="xl" />}
</Box> </Box>
@ -115,6 +123,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
<Menu.Item><b>Edit service</b></Menu.Item> <Menu.Item><b>Edit service</b></Menu.Item>
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</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={<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 /> <Divider />
<Menu.Label><b>Danger zone</b></Menu.Label> <Menu.Label><b>Danger zone</b></Menu.Label>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item> <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)} onClose={()=>setEditModal(false)}
edit={service} 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, edited_packets:number,
blocked_packets:number, blocked_packets:number,
fail_open:boolean, 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 = { export type ServiceAddForm = {
name:string, name:string,
port:number, port:number,
proto:string, proto:string,
ip_int:string, ip_int:string,
fail_open: boolean, fail_open: boolean,
tls_enabled?: boolean,
tls_cert?: string | null,
tls_key?: string | null,
} }
export type ServiceSettings = { export type ServiceSettings = {
@ -88,6 +103,10 @@ export const nfproxy = {
const { status } = await putapi(`nfproxy/services/${service_id}/settings`,data) as ServerResponse; const { status } = await putapi(`nfproxy/services/${service_id}/settings`,data) as ServerResponse;
return status === "ok"?undefined:status 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) => { getpyfilterscode: async (service_id:string) => {
return await getapi(`nfproxy/services/${service_id}/code`) as 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 { useForm } from '@mantine/form';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils'; import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils';
import TLSServiceFields from '../TLSServiceFields';
import { ImCross } from "react-icons/im" import { ImCross } from "react-icons/im"
import { nfregex, Service } from './utils'; import { nfregex, Service } from './utils';
import PortAndInterface from '../PortAndInterface'; import PortAndInterface from '../PortAndInterface';
@ -18,7 +19,10 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
ip_int:edit?.ip_int??"", ip_int:edit?.ip_int??"",
proto:edit?.proto??"tcp", proto:edit?.proto??"tcp",
fail_open: edit?.fail_open??false, fail_open: edit?.fail_open??false,
autostart: true autostart: true,
tls_enabled: false,
tls_cert: "",
tls_key: ""
} }
const form = useForm({ const form = useForm({
@ -28,6 +32,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
port: (value) => (value>0 && value<65536) ? null : "Invalid port", port: (value) => (value>0 && value<65536) ? null : "Invalid port",
proto: (value) => ["tcp","udp"].includes(value) ? null : "Invalid protocol", proto: (value) => ["tcp","udp"].includes(value) ? null : "Invalid protocol",
ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address", 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]) }, [opened])
const close = () =>{ const close = () =>{
onClose() onClose()
form.reset() form.reset()
@ -47,26 +55,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
const [submitLoading, setSubmitLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false)
const [error, setError] = useState<string|null>(null) const [error, setError] = useState<string|null>(null)
const submitRequest = ({ name, port, autostart, proto, ip_int, fail_open }:ServiceAddForm) =>{ const submitRequest = (values: ServiceAddForm) => {
setSubmitLoading(true) setSubmitLoading(true)
if (edit){ 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){ if (!res){
setSubmitLoading(false) setSubmitLoading(false)
close(); 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 => { }).catch( err => {
setSubmitLoading(false) setSubmitLoading(false)
setError("Request Failed! [ "+err+" ]") setError("Request Failed! [ "+err+" ]")
}) })
}else{ }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){ if (res.status === "ok" && res.service_id){
setSubmitLoading(false) setSubmitLoading(false)
close(); close();
if (autostart) nfregex.servicestart(res.service_id) if (values.autostart) nfregex.servicestart(res.service_id)
okNotify(`Service ${name} has been added`, `Successfully added service with port ${port}`) okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`)
}else{ }else{
setSubmitLoading(false) setSubmitLoading(false)
setError("Invalid request! [ "+res.status+" ]") setError("Invalid request! [ "+res.status+" ]")
@ -129,6 +147,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
/> />
</Box> </Box>
{!edit && <TLSServiceFields form={form} disabled={form.values.proto !== 'tcp'} />}
<Group justify='flex-end' mt="md" mb="sm"> <Group justify='flex-end' mt="md" mb="sm">
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button> <Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button>
</Group> </Group>

View File

@ -14,6 +14,8 @@ import { FaFilter } from "react-icons/fa";
import { VscRegex } from "react-icons/vsc"; import { VscRegex } from "react-icons/vsc";
import { IoSettingsSharp } from 'react-icons/io5'; import { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../AddEditService'; import AddEditService from '../AddEditService';
import { TbShieldLock } from "react-icons/tb";
import TLSConfigModal from '../../TLSConfigModal';
export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) { 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 [deleteModal, setDeleteModal] = useState(false)
const [renameModal, setRenameModal] = useState(false) const [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false) const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const isMedium = isMediumScreen() const isMedium = isMediumScreen()
const stopService = async () => { 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%" }}> <Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}>
:{service.port} :{service.port}
</Badge> </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> </Box>
{isMedium?null:<Space w="xl" />} {isMedium?null:<Space w="xl" />}
</Box> </Box>
@ -109,6 +118,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
<Menu.Item><b>Edit service</b></Menu.Item> <Menu.Item><b>Edit service</b></Menu.Item>
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</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={<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 /> <Divider />
<Menu.Label><b>Danger zone</b></Menu.Label> <Menu.Label><b>Danger zone</b></Menu.Label>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item> <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)} onClose={()=>setEditModal(false)}
edit={service} 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_packets:number,
n_regex:number, n_regex:number,
fail_open:boolean, fail_open:boolean,
tls_enabled:boolean,
tls_cert?: string,
tls_key?: string,
ssl_port?: number,
clear_port?: number,
} }
export type ServiceAddForm = { export type ServiceAddForm = {
@ -21,6 +26,9 @@ export type ServiceAddForm = {
proto:string, proto:string,
ip_int:string, ip_int:string,
fail_open: boolean, fail_open: boolean,
tls_enabled?: boolean,
tls_cert?: string | null,
tls_key?: string | null,
} }
export type ServiceSettings = { export type ServiceSettings = {
@ -28,6 +36,9 @@ export type ServiceSettings = {
proto?:string, proto?:string,
ip_int?:string, ip_int?:string,
fail_open?: boolean, fail_open?: boolean,
tls_enabled?: boolean,
tls_cert?: string | null,
tls_key?: string | null,
} }
export type ServiceAddResponse = { export type ServiceAddResponse = {
@ -92,4 +103,8 @@ export const nfregex = {
const { status } = await putapi(`nfregex/services/${service_id}/settings`,data) as ServerResponse; const { status } = await putapi(`nfregex/services/${service_id}/settings`,data) as ServerResponse;
return status === "ok"?undefined:status 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 { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../../components/NFProxy/AddEditService'; import AddEditService from '../../components/NFProxy/AddEditService';
import PyFilterView from '../../components/PyFilterView'; 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 { CodeHighlight } from '@mantine/code-highlight';
import { FaPython } from "react-icons/fa"; import { FaPython } from "react-icons/fa";
import { FiFileText } from "react-icons/fi"; import { FiFileText } from "react-icons/fi";
@ -24,6 +24,7 @@ import { ModalLog } from '../../components/ModalLog';
import { useListState } from '@mantine/hooks'; import { useListState } from '@mantine/hooks';
import { ExceptionWarning } from '../../components/NFProxy/ExceptionWarning'; import { ExceptionWarning } from '../../components/NFProxy/ExceptionWarning';
import { DocsButton } from '../../components/DocsButton'; import { DocsButton } from '../../components/DocsButton';
import TLSConfigModal from '../../components/TLSConfigModal';
export default function ServiceDetailsNFProxy() { export default function ServiceDetailsNFProxy() {
@ -34,6 +35,7 @@ export default function ServiceDetailsNFProxy() {
const [deleteModal, setDeleteModal] = useState(false) const [deleteModal, setDeleteModal] = useState(false)
const [renameModal, setRenameModal] = useState(false) const [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false) const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const [buttonLoading, setButtonLoading] = useState(false) const [buttonLoading, setButtonLoading] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const filterCode = nfproxyServiceFilterCodeQuery(srv??"") 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"> <Badge size="xl" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" mr="sm">
:{serviceInfo.port} :{serviceInfo.port}
</Badge> </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> <MenuDropDownWithButton>
<Menu.Item><b>Edit service</b></Menu.Item> <Menu.Item><b>Edit service</b></Menu.Item>
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</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={<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 /> <Divider />
<Menu.Label><b>Danger zone</b></Menu.Label> <Menu.Label><b>Danger zone</b></Menu.Label>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item> <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)} onClose={()=>setEditModal(false)}
edit={serviceInfo} edit={serviceInfo}
/> />
<TLSConfigModal
opened={tlsModal}
onClose={() => setTlsModal(false)}
service={serviceInfo}
updateTlsConfig={nfproxy.updatetlsconfig}
queryKey={serviceQueryKey}
/>
<ModalLog <ModalLog
opened={openLogModal} opened={openLogModal}
close={()=>setOpenLogModal(false)} 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 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 until curl --output /dev/null --silent --fail http://127.0.0.1:4444/api/status; do
printf '.' printf '.'
sleep 1 sleep 1