From 75c54f01554096499fca6373680c7adf76048e3d Mon Sep 17 00:00:00 2001 From: Sogoss <> Date: Mon, 20 Jul 2026 12:25:30 +0200 Subject: [PATCH] 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 --- Dockerfile | 2 +- backend/app.py | 3 +- backend/binsrc/classes/nfqueue.cpp | 1 + backend/docker-entrypoint.sh | 12 +- backend/modules/nfproxy/firegex.py | 4 +- backend/modules/nfproxy/firewall.py | 38 ++++- backend/modules/nfproxy/models.py | 6 + backend/modules/nfproxy/nftables.py | 141 ++++++++++++----- backend/modules/nfproxy/nginx.py | 103 ++++++++++++ backend/modules/nfregex/firegex.py | 4 +- backend/modules/nfregex/firewall.py | 38 ++++- backend/modules/nfregex/models.py | 5 +- backend/modules/nfregex/nftables.py | 146 +++++++++++++----- backend/routers/certificates.py | 36 +++++ backend/routers/nfproxy.py | 74 ++++++++- backend/routers/nfregex.py | 85 +++++++++- backend/utils/__init__.py | 2 + backend/utils/certs.py | 106 +++++++++++++ backend/utils/models.py | 7 +- .../src/components/NFProxy/AddEditService.tsx | 34 +++- .../components/NFProxy/ServiceRow/index.tsx | 18 ++- frontend/src/components/NFProxy/utils.ts | 19 +++ .../src/components/NFRegex/AddEditService.tsx | 34 +++- .../components/NFRegex/ServiceRow/index.tsx | 17 ++ frontend/src/components/NFRegex/utils.ts | 15 ++ frontend/src/components/TLSConfigModal.tsx | 137 ++++++++++++++++ frontend/src/components/TLSServiceFields.tsx | 78 ++++++++++ frontend/src/pages/NFProxy/ServiceDetails.tsx | 18 ++- tests/nginx_test.py | 121 +++++++++++++++ tests/run_tests.sh | 3 + 30 files changed, 1184 insertions(+), 123 deletions(-) create mode 100644 backend/modules/nfproxy/nginx.py create mode 100644 backend/routers/certificates.py create mode 100644 backend/utils/certs.py create mode 100644 frontend/src/components/TLSConfigModal.tsx create mode 100644 frontend/src/components/TLSServiceFields.tsx create mode 100644 tests/nginx_test.py diff --git a/Dockerfile b/Dockerfile index a9474a8..a90e776 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ RUN bun run build FROM --platform=$TARGETARCH quay.io/fedora/fedora:43 AS base RUN dnf -y update && dnf install -y python3.14 libnetfilter_queue \ libnfnetlink libmnl libcap-ng-utils nftables \ - vectorscan libtins python3-nftables libpcap && dnf clean all + vectorscan libtins python3-nftables libpcap nginx nginx-mod-stream && dnf clean all RUN mkdir -p /execute/modules WORKDIR /execute diff --git a/backend/app.py b/backend/app.py index 624fd00..6cf5d14 100644 --- a/backend/app.py +++ b/backend/app.py @@ -11,6 +11,7 @@ from utils.sqlite import SQLite from utils import API_VERSION, FIREGEX_PORT, FIREGEX_HOST, FIREGEX_SOCKET, JWT_ALGORITHM, get_interfaces, socketio_emit, DEBUG, SysctlManager, NORELOAD from utils.loader import frontend_deploy, load_routers from utils.models import ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel +from utils.certs import CERTIFICATES_SCHEMA from contextlib import asynccontextmanager from fastapi.middleware.cors import CORSMiddleware import socketio @@ -18,7 +19,7 @@ from socketio.exceptions import ConnectionRefusedError import hashlib # DB init -db = SQLite('db/firegex.db') +db = SQLite('db/firegex.db', CERTIFICATES_SCHEMA) sysctl = SysctlManager({ "net.ipv4.conf.all.forwarding": True, "net.ipv6.conf.all.forwarding": True, diff --git a/backend/binsrc/classes/nfqueue.cpp b/backend/binsrc/classes/nfqueue.cpp index 7303f52..8bb0128 100644 --- a/backend/binsrc/classes/nfqueue.cpp +++ b/backend/binsrc/classes/nfqueue.cpp @@ -451,6 +451,7 @@ class NfQueue { // error code taken from the linux kernel: // https://elixir.bootlin.com/linux/v5.18.12/source/include/linux/errno.h#L27 #define ENOTSUPP 524 /* Operation is not supported */ + #define EOPNOTSUPP 95 /* Operation not supported on transport endpoint */ if (error_msg->error != -ENOTSUPP && error_msg->error != -EOPNOTSUPP) { _clear(); diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index e95b9bc..9cad0e3 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -9,6 +9,15 @@ if [ -n "$SOCKET_DIR" ]; then chmod 755 "$SOCKET_DIR" fi +# Set host sysctls while running as root. +if [ -d "/sys_host" ]; then + echo "[*] Setting host sysctls..." + echo 1 > /sys_host/net.ipv4.conf.all.forwarding 2>/dev/null || true + echo 1 > /sys_host/net.ipv6.conf.all.forwarding 2>/dev/null || true + echo 1 > /sys_host/net.ipv4.conf.all.route_localnet 2>/dev/null || true + echo 1 > /sys_host/net.ipv4.ip_forward 2>/dev/null || true +fi + echo "[*] Attempting to start with capabilities..." if capsh --caps="cap_net_admin,cap_setpcap,cap_setuid,cap_setgid,cap_sys_nice+eip" \ @@ -21,7 +30,8 @@ then --keep=1 \ --user=nobody \ --addamb=cap_net_admin,cap_sys_nice \ - -- -c "python3 /execute/app.py DOCKER" + --shell=/usr/bin/python3 \ + -- /execute/app.py DOCKER else echo "[!] capsh failed, running with root user" exec python3 /execute/app.py DOCKER diff --git a/backend/modules/nfproxy/firegex.py b/backend/modules/nfproxy/firegex.py index 9ef3fef..6999427 100644 --- a/backend/modules/nfproxy/firegex.py +++ b/backend/modules/nfproxy/firegex.py @@ -90,11 +90,11 @@ class FiregexInterceptor: proxy_binary_path, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, - env={ + env=dict(os.environ, **{ "NTHREADS": os.getenv("NTHREADS","1"), "FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0", "FIREGEX_NFPROXY_SOCK": self.sock_path - }, + }), ) nicenessify(-10, self.process.pid) self.outstrem_task = asyncio.create_task(self._stream_handler()) diff --git a/backend/modules/nfproxy/firewall.py b/backend/modules/nfproxy/firewall.py index 045f9ab..6f2cb5e 100644 --- a/backend/modules/nfproxy/firewall.py +++ b/backend/modules/nfproxy/firewall.py @@ -2,8 +2,10 @@ import asyncio from modules.nfproxy.firegex import FiregexInterceptor from modules.nfproxy.nftables import FiregexTables, FiregexFilter from modules.nfproxy.models import Service, PyFilter +from modules.nfproxy.nginx import sync_nginx_state from utils.sqlite import SQLite from utils import run_func +from utils.certs import CertsDB, ip_parse as certs_ip_parse class STATUS: STOP = "stop" @@ -72,6 +74,8 @@ class ServiceManager: self.interceptor = await FiregexInterceptor.start(self.srv, outstream_func=self.outstream_function, exception_func=self.exception_function) await self._update_filters_from_db() self._set_status(STATUS.ACTIVE) + if self.srv.tls_enabled: + sync_nginx_state() async def stop(self): nft.delete(self.srv) @@ -79,6 +83,8 @@ class ServiceManager: await self.interceptor.stop() self.interceptor = None self._set_status(STATUS.STOP) + if self.srv.tls_enabled: + sync_nginx_state() async def restart(self): await self.stop() @@ -88,6 +94,18 @@ class ServiceManager: async with self.lock: await self._update_filters_from_db() + async def update_tls_config(self): + async with self.lock: + srv_dict = self.db.query("SELECT * FROM services WHERE service_id = ?;", self.srv.id)[0] + if srv_dict.get("tls_enabled"): + cert, key = CertsDB().get_cert_and_key(srv_dict["ip_int"], srv_dict["port"]) + srv_dict["tls_cert"] = cert + srv_dict["tls_key"] = key + self.srv = Service.from_dict(srv_dict) + if self.status == STATUS.ACTIVE: + await self.restart() + sync_nginx_state() + class FirewallManager: def __init__(self, db:SQLite, outstream_func=None, exception_func=None): self.db = db @@ -99,6 +117,7 @@ class FirewallManager: async def close(self): for key in list(self.service_table.keys()): await self.remove(key) + sync_nginx_state() async def remove(self,srv_id): async with self.lock: @@ -112,12 +131,21 @@ class FirewallManager: async def reload(self): async with self.lock: - for srv in self.db.query('SELECT * FROM services;'): - srv = Service.from_dict(srv) - if srv.id in self.service_table: + services = self.db.query('SELECT * FROM services;') + tls_services = [s for s in services if s.get("tls_enabled") and s["service_id"] not in self.service_table] + certs_map = CertsDB().get_multiple_certs_and_keys(tls_services) if tls_services else {} + + for srv in services: + if srv["service_id"] in self.service_table: continue - self.service_table[srv.id] = ServiceManager(srv, self.db, outstream_func=self.outstream_function, exception_func=self.exception_function) - await self.service_table[srv.id].next(srv.status) + if srv.get("tls_enabled"): + cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None)) + srv["tls_cert"] = cert + srv["tls_key"] = key + srv_obj = Service.from_dict(srv) + self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db, outstream_func=self.outstream_function, exception_func=self.exception_function) + await self.service_table[srv_obj.id].next(srv_obj.status) + sync_nginx_state() def get(self,srv_id) -> ServiceManager: if srv_id in self.service_table: diff --git a/backend/modules/nfproxy/models.py b/backend/modules/nfproxy/models.py index 140f47f..ad6389e 100644 --- a/backend/modules/nfproxy/models.py +++ b/backend/modules/nfproxy/models.py @@ -8,6 +8,9 @@ class Service: proto: str, ip_int: str, fail_open: bool, + tls_enabled: bool = False, + tls_cert: str | None = None, + tls_key: str | None = None, **other, ): self.id = service_id @@ -17,6 +20,9 @@ class Service: self.proto = proto self.ip_int = ip_int self.fail_open = fail_open + self.tls_enabled = bool(tls_enabled) + self.tls_cert = tls_cert + self.tls_key = tls_key @classmethod def from_dict(cls, var: dict): diff --git a/backend/modules/nfproxy/nftables.py b/backend/modules/nfproxy/nftables.py index 6aa7c71..7f8a87e 100644 --- a/backend/modules/nfproxy/nftables.py +++ b/backend/modules/nfproxy/nftables.py @@ -1,5 +1,6 @@ from modules.nfproxy.models import Service from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json +from modules.nfproxy.nginx import get_tls_ports def convert_protocol_to_l4(proto:str): if proto == "tcp": @@ -22,9 +23,19 @@ class FiregexFilter: return self.port == o.port and self.proto == convert_protocol_to_l4(o.proto) and ip_parse(self.ip_int) == ip_parse(o.ip_int) return False + def matches(self, srv: Service) -> bool: + if self.port == srv.port and ip_parse(self.ip_int) == ip_parse(srv.ip_int): + return True + ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port) + loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1" + if ip_parse(self.ip_int) == ip_parse(loopback_ip) and self.port in (ssl_port, clear_port): + return True + return False + class FiregexTables(NFTableManager): input_chain = "nfproxy_input" output_chain = "nfproxy_output" + nat_chain = "nfproxy_nat" def __init__(self): super().__init__([ @@ -45,69 +56,123 @@ class FiregexTables(NFTableManager): "hook":"postrouting", "prio":108, "policy":"accept" + }}}, + {"add":{"chain":{ #NAT chain for DNAT + "family":"inet", + "table":self.table_name, + "name":self.nat_chain, + "type":"nat", + "hook":"prerouting", + "prio":-100, + "policy":"accept" }}} ],[ {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, + {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}}, + {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}}, ]) def add(self, srv:Service, queue_range): for ele in self.get(): - if ele.__eq__(srv): + if ele.matches(srv): return init, end = queue_range if init > end: init, end = end, init - self.cmd( - { "insert":{ "rule": { - "family": "inet", - "table": self.table_name, - "chain": self.output_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, - {'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "sport"}}, "op": "==", "right": int(srv.port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}}, - {"insert":{"rule":{ - "family": "inet", - "table": self.table_name, - "chain": self.input_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)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + + 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': convert_protocol_to_l4(str(srv.proto)), 'field': 'dport'}}, 'op': '==', 'right': int(srv.port)}}, + {'match': {'left': {'meta': {'key': 'iifname'}}, 'op': '!=', 'right': 'lo'}}, + {'dnat': {'family': ip_family(srv.ip_int), 'addr': loopback_ip, 'port': int(ssl_port)}} ] - }}} - ) + }}}, + {"insert":{"rule":{ # Send cleartext inbound traffic to NFQUEUE + "family": "inet", + "table": self.table_name, + "chain": self.input_chain, + "expr": [ + {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': loopback_ip}}, + {'match': {'left': {'payload': {'protocol': convert_protocol_to_l4(str(srv.proto)), 'field': 'dport'}}, 'op': '==', 'right': int(clear_port)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + ] + }}}, + {"insert":{"rule":{ # Send cleartext outbound traffic to NFQUEUE + "family": "inet", + "table": self.table_name, + "chain": self.output_chain, + "expr": [ + {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': loopback_ip}}, + {'match': {'left': {'payload': {'protocol': convert_protocol_to_l4(str(srv.proto)), 'field': 'sport'}}, 'op': '==', 'right': int(clear_port)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + ] + }}} + ) + else: + self.cmd( + { "insert":{ "rule": { # Send non-TLS outbound traffic to NFQUEUE + "family": "inet", + "table": self.table_name, + "chain": self.output_chain, + "expr": [ + {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, + {'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "sport"}}, "op": "==", "right": int(srv.port)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + ] + }}}, + {"insert":{"rule":{ # Send non-TLS 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': 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)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + ] + }}} + ) def get(self) -> list[FiregexFilter]: res = [] - for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]): - ip_int = None - if isinstance(filter["expr"][0]["match"]["right"],str): - ip_int = str(ip_parse(filter["expr"][0]["match"]["right"])) - else: - ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}' - res.append(FiregexFilter( - target=filter["chain"], - id=int(filter["handle"]), - proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"], - port=filter["expr"][1]["match"]["right"], - ip_int=ip_int - )) + for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain,self.nat_chain]): + try: + ip_int = None + if isinstance(filter["expr"][0]["match"]["right"],str): + ip_int = str(ip_parse(filter["expr"][0]["match"]["right"])) + else: + ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}' + res.append(FiregexFilter( + target=filter["chain"], + id=int(filter["handle"]), + proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"], + port=filter["expr"][1]["match"]["right"], + ip_int=ip_int + )) + except (KeyError, TypeError, IndexError): + pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip return res def delete(self, srv:Service): for filter in self.get(): - if filter.__eq__(srv): + if filter.matches(srv): self.cmd({ "delete":{ "rule": { "family": "inet", "table": self.table_name, diff --git a/backend/modules/nfproxy/nginx.py b/backend/modules/nfproxy/nginx.py new file mode 100644 index 0000000..c110590 --- /dev/null +++ b/backend/modules/nfproxy/nginx.py @@ -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) + diff --git a/backend/modules/nfregex/firegex.py b/backend/modules/nfregex/firegex.py index 3d62666..d73b80f 100644 --- a/backend/modules/nfregex/firegex.py +++ b/backend/modules/nfregex/firegex.py @@ -101,11 +101,11 @@ class FiregexInterceptor: self.process = await asyncio.create_subprocess_exec( proxy_binary_path, stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE, - env={ + env=dict(os.environ, **{ "MATCH_MODE": "stream" if self.srv.proto == "tcp" else "block", "NTHREADS": os.getenv("NTHREADS","1"), "FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0", - }, + }), ) nicenessify(-10, self.process.pid) line_fut = self.process.stdout.readuntil() diff --git a/backend/modules/nfregex/firewall.py b/backend/modules/nfregex/firewall.py index ec9231e..8ffec45 100644 --- a/backend/modules/nfregex/firewall.py +++ b/backend/modules/nfregex/firewall.py @@ -3,6 +3,8 @@ from modules.nfregex.firegex import FiregexInterceptor, RegexFilter from modules.nfregex.nftables import FiregexTables, FiregexFilter from modules.nfregex.models import Regex, Service from utils.sqlite import SQLite +from modules.nfproxy.nginx import sync_nginx_state +from utils.certs import CertsDB, ip_parse as certs_ip_parse class STATUS: STOP = "stop" @@ -63,6 +65,8 @@ class ServiceManager: self.interceptor = await FiregexInterceptor.start(self.srv) await self._update_filters_from_db() self._set_status(STATUS.ACTIVE) + if self.srv.tls_enabled: + sync_nginx_state() async def stop(self): nft.delete(self.srv) @@ -70,6 +74,8 @@ class ServiceManager: await self.interceptor.stop() self.interceptor = None self._set_status(STATUS.STOP) + if self.srv.tls_enabled: + sync_nginx_state() async def restart(self): await self.stop() @@ -79,6 +85,18 @@ class ServiceManager: async with self.lock: await self._update_filters_from_db() + async def update_tls_config(self): + async with self.lock: + srv_dict = self.db.query("SELECT * FROM services WHERE service_id = ?;", self.srv.id)[0] + if srv_dict.get("tls_enabled"): + cert, key = CertsDB().get_cert_and_key(srv_dict["ip_int"], srv_dict["port"]) + srv_dict["tls_cert"] = cert + srv_dict["tls_key"] = key + self.srv = Service.from_dict(srv_dict) + if self.status == STATUS.ACTIVE: + await self.restart() + sync_nginx_state() + class FirewallManager: def __init__(self, db:SQLite): self.db = db @@ -88,6 +106,7 @@ class FirewallManager: async def close(self): for key in list(self.service_table.keys()): await self.remove(key) + sync_nginx_state() async def remove(self,srv_id): async with self.lock: @@ -101,12 +120,21 @@ class FirewallManager: async def reload(self): async with self.lock: - for srv in self.db.query('SELECT * FROM services;'): - srv = Service.from_dict(srv) - if srv.id in self.service_table: + services = self.db.query('SELECT * FROM services;') + tls_services = [s for s in services if s.get("tls_enabled") and s["service_id"] not in self.service_table] + certs_map = CertsDB().get_multiple_certs_and_keys(tls_services) if tls_services else {} + + for srv in services: + if srv["service_id"] in self.service_table: continue - self.service_table[srv.id] = ServiceManager(srv, self.db) - await self.service_table[srv.id].next(srv.status) + if srv.get("tls_enabled"): + cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None)) + srv["tls_cert"] = cert + srv["tls_key"] = key + srv_obj = Service.from_dict(srv) + self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db) + await self.service_table[srv_obj.id].next(srv_obj.status) + sync_nginx_state() def get(self,srv_id) -> ServiceManager: if srv_id in self.service_table: diff --git a/backend/modules/nfregex/models.py b/backend/modules/nfregex/models.py index c06daa6..2bd8268 100644 --- a/backend/modules/nfregex/models.py +++ b/backend/modules/nfregex/models.py @@ -1,7 +1,7 @@ import base64 class Service: - def __init__(self, service_id: str, status: str, port: int, name: str, proto: str, ip_int: str, fail_open: bool, **other): + def __init__(self, service_id: str, status: str, port: int, name: str, proto: str, ip_int: str, fail_open: bool, tls_enabled: bool = False, tls_cert: str | None = None, tls_key: str | None = None, **other): self.id = service_id self.status = status self.port = port @@ -9,6 +9,9 @@ class Service: self.proto = proto self.ip_int = ip_int self.fail_open = fail_open + self.tls_enabled = bool(tls_enabled) + self.tls_cert = tls_cert + self.tls_key = tls_key @classmethod def from_dict(cls, var: dict): diff --git a/backend/modules/nfregex/nftables.py b/backend/modules/nfregex/nftables.py index baaab10..f53aa3f 100644 --- a/backend/modules/nfregex/nftables.py +++ b/backend/modules/nfregex/nftables.py @@ -1,5 +1,6 @@ from modules.nfregex.models import Service from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json +from modules.nfproxy.nginx import get_tls_ports class FiregexFilter: def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int): @@ -14,9 +15,19 @@ class FiregexFilter: return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int) return False + def matches(self, srv: Service) -> bool: + if self.port == srv.port and ip_parse(self.ip_int) == ip_parse(srv.ip_int): + return True + ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port) + loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1" + if ip_parse(self.ip_int) == ip_parse(loopback_ip) and self.port in (ssl_port, clear_port): + return True + return False + class FiregexTables(NFTableManager): input_chain = "nfregex_input" output_chain = "nfregex_output" + nat_chain = "nfregex_nat" def __init__(self): super().__init__([ @@ -37,73 +48,130 @@ class FiregexTables(NFTableManager): "hook":"postrouting", "prio":107, "policy":"accept" + }}}, + {"add":{"chain":{ + "family":"inet", + "table":self.table_name, + "name":self.nat_chain, + "type":"nat", + "hook":"prerouting", + "prio":-100, + "policy":"accept" }}} ],[ {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, + {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}}, + {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}}, ]) def add(self, srv:Service, queue_range): for ele in self.get(): - if ele.__eq__(srv): + if ele.matches(srv): return init, end = queue_range if init > end: init, end = end, init - self.cmd( - { "insert":{ "rule": { - "family": "inet", - "table": self.table_name, - "chain": self.output_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, - {'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "sport"}}, "op": "==", "right": int(srv.port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}}, - {"insert":{"rule":{ - "family": "inet", - "table": self.table_name, - "chain": self.input_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)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + + if srv.tls_enabled: + ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port) + loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1" + self.cmd( + { "insert":{ "rule": { # Redirect TLS traffic to local Nginx SSL proxy + "family": "inet", + "table": self.table_name, + "chain": self.nat_chain, + "expr": [ + {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, + {'match': {'left': {'payload': {'protocol': str(srv.proto), 'field': 'dport'}}, 'op': '==', 'right': int(srv.port)}}, + {'match': {'left': {'meta': {'key': 'iifname'}}, 'op': '!=', 'right': 'lo'}}, + {'dnat': {'family': ip_family(srv.ip_int), 'addr': loopback_ip, 'port': int(ssl_port)}} ] - }}} - ) + }}}, + {"insert":{"rule":{ # Send cleartext inbound traffic to NFQUEUE + "family": "inet", + "table": self.table_name, + "chain": self.input_chain, + "expr": [ + {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': loopback_ip}}, + {'match': {'left': {'payload': {'protocol': str(srv.proto), 'field': 'dport'}}, 'op': '==', 'right': int(clear_port)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + ] + }}}, + {"insert":{"rule":{ # Send cleartext outbound traffic to NFQUEUE + "family": "inet", + "table": self.table_name, + "chain": self.output_chain, + "expr": [ + {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': loopback_ip}}, + {'match': {'left': {'payload': {'protocol': str(srv.proto), 'field': 'sport'}}, 'op': '==', 'right': int(clear_port)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + ] + }}} + ) + else: + self.cmd( + { "insert":{ "rule": { + "family": "inet", + "table": self.table_name, + "chain": self.output_chain, + "expr": [ + {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, + {'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "sport"}}, "op": "==", "right": int(srv.port)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + ] + }}}, + {"insert":{"rule":{ + "family": "inet", + "table": self.table_name, + "chain": self.input_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)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + ] + }}} + ) def get(self) -> list[FiregexFilter]: res = [] - for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]): - ip_int = None - if isinstance(filter["expr"][0]["match"]["right"],str): - ip_int = str(ip_parse(filter["expr"][0]["match"]["right"])) - else: - ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}' - res.append(FiregexFilter( - target=filter["chain"], - id=int(filter["handle"]), - proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"], - port=filter["expr"][1]["match"]["right"], - ip_int=ip_int - )) + for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain,self.nat_chain]): + try: + ip_int = None + if isinstance(filter["expr"][0]["match"]["right"],str): + ip_int = str(ip_parse(filter["expr"][0]["match"]["right"])) + else: + ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}' + res.append(FiregexFilter( + target=filter["chain"], + id=int(filter["handle"]), + proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"], + port=filter["expr"][1]["match"]["right"], + ip_int=ip_int + )) + except (KeyError, TypeError, IndexError): + pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip return res def delete(self, srv:Service): + delete_cmds = [] for filter in self.get(): - if filter.__eq__(srv): - self.cmd({ "delete":{ "rule": { + if filter.matches(srv): + delete_cmds.append({ "delete":{ "rule": { "family": "inet", "table": self.table_name, "chain": filter.target, "handle": filter.id }}}) + if delete_cmds: + self.cmd(*delete_cmds) \ No newline at end of file diff --git a/backend/routers/certificates.py b/backend/routers/certificates.py new file mode 100644 index 0000000..4a8ab85 --- /dev/null +++ b/backend/routers/certificates.py @@ -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"} diff --git a/backend/routers/nfproxy.py b/backend/routers/nfproxy.py index 27335b3..ae44e76 100644 --- a/backend/routers/nfproxy.py +++ b/backend/routers/nfproxy.py @@ -6,7 +6,8 @@ from modules.nfproxy.nftables import FiregexTables from modules.nfproxy.firewall import STATUS, FirewallManager from utils.sqlite import SQLite from utils import ip_parse, refactor_name, socketio_emit, PortType -from utils.models import ResetRequest, StatusMessageModel +from utils.certs import CertsDB, populate_services_tls_config +from utils.models import ResetRequest, StatusMessageModel, TLSConfigForm import os from firegex.nfproxy.internals import get_filter_names from fastapi.responses import PlainTextResponse @@ -27,6 +28,11 @@ class ServiceModel(BaseModel): edited_packets: int blocked_packets: int fail_open: bool + tls_enabled: bool + tls_cert: str|None = None + tls_key: str|None = None + ssl_port: int|None = None + clear_port: int|None = None class RenameForm(BaseModel): name:str @@ -49,6 +55,9 @@ class ServiceAddForm(BaseModel): proto: str ip_int: str fail_open: bool = True + tls_enabled: bool = False + tls_cert: str | None = None + tls_key: str | None = None class ServiceAddResponse(BaseModel): status:str @@ -69,6 +78,7 @@ db = SQLite('db/nft-pyfilters.db', { 'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_proto IN ("tcp", "udp"))', 'ip_int': 'VARCHAR(100) NOT NULL', 'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1', + 'tls_enabled': 'BOOLEAN NOT NULL CHECK (tls_enabled IN (0, 1)) DEFAULT 0', }, 'pyfilter': { 'name': 'VARCHAR(100) NOT NULL', @@ -138,7 +148,7 @@ firewall = FirewallManager(db, outstream_func=outstream_func, exception_func=exc @app.get('/services', response_model=list[ServiceModel]) async def get_service_list(): """Get the list of existent firegex services""" - return db.query(""" + res = db.query(""" SELECT s.service_id service_id, s.status status, @@ -147,12 +157,14 @@ async def get_service_list(): s.proto proto, s.ip_int ip_int, s.fail_open fail_open, + s.tls_enabled tls_enabled, COUNT(f.name) n_filters, COALESCE(SUM(f.blocked_packets),0) blocked_packets, COALESCE(SUM(f.edited_packets),0) edited_packets FROM services s LEFT JOIN pyfilter f ON s.service_id = f.service_id GROUP BY s.service_id; """) + return populate_services_tls_config(res) @app.get('/services/{service_id}', response_model=ServiceModel) async def get_service_by_id(service_id: str): @@ -166,6 +178,7 @@ async def get_service_by_id(service_id: str): s.proto proto, s.ip_int ip_int, s.fail_open fail_open, + s.tls_enabled tls_enabled, COUNT(f.name) n_filters, COALESCE(SUM(f.blocked_packets),0) blocked_packets, COALESCE(SUM(f.edited_packets),0) edited_packets @@ -174,7 +187,7 @@ async def get_service_by_id(service_id: str): """, service_id) if len(res) == 0: raise HTTPException(status_code=400, detail="This service does not exists!") - return res[0] + return populate_services_tls_config(res)[0] @app.post('/services/{service_id}/stop', response_model=StatusMessageModel) async def service_stop(service_id: str): @@ -193,6 +206,9 @@ async def service_start(service_id: str): @app.delete('/services/{service_id}', response_model=StatusMessageModel) async def service_delete(service_id: str): """Request the deletion of a specific service""" + srv_res = db.query('SELECT ip_int, port FROM services WHERE service_id = ?;', service_id) + if srv_res: + CertsDB().delete_cert_and_key(srv_res[0]["ip_int"], srv_res[0]["port"]) db.query('DELETE FROM services WHERE service_id = ?;', service_id) db.query('DELETE FROM pyfilter WHERE service_id = ?;', service_id) if os.path.exists(f"db/nfproxy_filters/{service_id}.py"): @@ -218,6 +234,11 @@ async def service_rename(service_id: str, form: RenameForm): async def service_settings(service_id: str, form: SettingsForm): """Request to change the settings of a specific service (will cause a restart)""" + srv_check = db.query("SELECT ip_int, port, tls_enabled FROM services WHERE service_id = ?;", service_id) + if len(srv_check) == 0: + raise HTTPException(status_code=404, detail="Service not found") + old_srv = srv_check[0] + if form.port is not None and (form.port < 1 or form.port > 65535): raise HTTPException(status_code=400, detail="Invalid port") @@ -227,6 +248,9 @@ async def service_settings(service_id: str, form: SettingsForm): except ValueError: raise HTTPException(status_code=400, detail="Invalid address") + new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"] + new_port = form.port if form.port is not None else old_srv["port"] + keys = [] values = [] @@ -237,9 +261,21 @@ async def service_settings(service_id: str, form: SettingsForm): if len(keys) == 0: raise HTTPException(status_code=400, detail="No settings to change provided") + if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]): + cert, key = CertsDB().get_cert_and_key(old_srv["ip_int"], old_srv["port"]) + if cert and key: + CertsDB().upsert_cert_and_key(new_ip, new_port, cert, key) + CertsDB().delete_cert_and_key(old_srv["ip_int"], old_srv["port"]) + try: db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) except sqlite3.IntegrityError: + # If migration was performed, rollback CertsDB change (upsert old, delete new) + if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]): + cert, key = CertsDB().get_cert_and_key(new_ip, new_port) + if cert and key: + CertsDB().upsert_cert_and_key(old_srv["ip_int"], old_srv["port"], cert, key) + CertsDB().delete_cert_and_key(new_ip, new_port) raise HTTPException(status_code=400, detail="A service with these settings already exists") old_status = firewall.get(service_id).status @@ -250,6 +286,29 @@ async def service_settings(service_id: str, form: SettingsForm): await refresh_frontend() return {'status': 'ok'} +@app.put('/services/{service_id}/tls-config', response_model=StatusMessageModel) +async def update_service_tls(service_id: str, form: TLSConfigForm): + """Update TLS configuration for a service""" + srv_res = db.query("SELECT ip_int, port FROM services WHERE service_id = ?;", service_id) + if len(srv_res) == 0: + raise HTTPException(status_code=404, detail="Service not found") + srv = srv_res[0] + + if form.tls_enabled: + if not form.tls_cert or not form.tls_key: + raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled") + CertsDB().upsert_cert_and_key(srv["ip_int"], srv["port"], form.tls_cert, form.tls_key) + + db.query(""" + UPDATE services + SET tls_enabled = ? + WHERE service_id = ?; + """, int(form.tls_enabled), service_id) + + await firewall.get(service_id).update_tls_config() + await refresh_frontend() + return {'status': 'ok'} + @app.get('/services/{service_id}/pyfilters', response_model=list[PyFilterModel]) async def get_service_pyfilter_list(service_id: str): """Get the list of the pyfilters of a service""" @@ -302,11 +361,16 @@ async def add_new_service(form: ServiceAddForm): raise HTTPException(status_code=400, detail="Invalid address") if form.proto not in ["tcp", "http"]: raise HTTPException(status_code=400, detail="Invalid protocol") + if form.tls_enabled: + if not form.tls_cert or not form.tls_key: + raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled") + CertsDB().upsert_cert_and_key(form.ip_int, form.port, form.tls_cert, form.tls_key) + srv_id = None try: srv_id = gen_service_id() - db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, l4_proto) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, convert_protocol_to_l4(form.proto)) + db.query("INSERT INTO services (service_id, name, port, status, proto, ip_int, fail_open, l4_proto, tls_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, convert_protocol_to_l4(form.proto), int(form.tls_enabled)) except sqlite3.IntegrityError: raise HTTPException(status_code=400, detail="This type of service already exists") await firewall.reload() diff --git a/backend/routers/nfregex.py b/backend/routers/nfregex.py index 66bc2d0..f145682 100644 --- a/backend/routers/nfregex.py +++ b/backend/routers/nfregex.py @@ -8,7 +8,8 @@ from modules.nfregex.nftables import FiregexTables from modules.nfregex.firewall import STATUS, FirewallManager from utils.sqlite import SQLite from utils import ip_parse, refactor_name, socketio_emit, PortType -from utils.models import ResetRequest, StatusMessageModel +from utils.certs import CertsDB, populate_services_tls_config +from utils.models import ResetRequest, StatusMessageModel, TLSConfigForm from modules.nfregex.firegex import test_regex_validity class ServiceModel(BaseModel): @@ -21,6 +22,11 @@ class ServiceModel(BaseModel): n_regex: int n_packets: int fail_open: bool + tls_enabled: bool = False + tls_cert: str|None = None + tls_key: str|None = None + ssl_port: int|None = None + clear_port: int|None = None class RenameForm(BaseModel): name:str @@ -53,6 +59,9 @@ class ServiceAddForm(BaseModel): proto: str ip_int: str fail_open: bool = False + tls_enabled: bool = False + tls_cert: str | None = None + tls_key: str | None = None class ServiceAddResponse(BaseModel): status:str @@ -68,7 +77,8 @@ db = SQLite('db/nft-regex.db', { 'name': 'VARCHAR(100) NOT NULL UNIQUE', 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))', 'ip_int': 'VARCHAR(100) NOT NULL', - 'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1' + 'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1', + 'tls_enabled': 'BOOLEAN NOT NULL CHECK (tls_enabled IN (0, 1)) DEFAULT 0', }, 'regexes': { 'regex': 'TEXT NOT NULL', @@ -130,7 +140,7 @@ firewall = FirewallManager(db) @app.get('/services', response_model=list[ServiceModel]) async def get_service_list(): """Get the list of existent firegex services""" - return db.query(""" + res = db.query(""" SELECT s.service_id service_id, s.status status, @@ -139,11 +149,13 @@ async def get_service_list(): s.proto proto, s.ip_int ip_int, s.fail_open fail_open, + s.tls_enabled tls_enabled, COUNT(r.regex_id) n_regex, COALESCE(SUM(r.blocked_packets),0) n_packets FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id GROUP BY s.service_id; """) + return populate_services_tls_config(res) @app.get('/services/{service_id}', response_model=ServiceModel) async def get_service_by_id(service_id: str): @@ -157,6 +169,7 @@ async def get_service_by_id(service_id: str): s.proto proto, s.ip_int ip_int, s.fail_open fail_open, + s.tls_enabled tls_enabled, COUNT(r.regex_id) n_regex, COALESCE(SUM(r.blocked_packets),0) n_packets FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id @@ -164,7 +177,7 @@ async def get_service_by_id(service_id: str): """, service_id) if len(res) == 0: raise HTTPException(status_code=400, detail="This service does not exists!") - return res[0] + return populate_services_tls_config(res)[0] @app.post('/services/{service_id}/stop', response_model=StatusMessageModel) async def service_stop(service_id: str): @@ -183,6 +196,9 @@ async def service_start(service_id: str): @app.delete('/services/{service_id}', response_model=StatusMessageModel) async def service_delete(service_id: str): """Request the deletion of a specific service""" + srv_res = db.query('SELECT ip_int, port FROM services WHERE service_id = ?;', service_id) + if srv_res: + CertsDB().delete_cert_and_key(srv_res[0]["ip_int"], srv_res[0]["port"]) db.query('DELETE FROM services WHERE service_id = ?;', service_id) db.query('DELETE FROM regexes WHERE service_id = ?;', service_id) await firewall.remove(service_id) @@ -206,8 +222,18 @@ async def service_rename(service_id: str, form: RenameForm): async def service_settings(service_id: str, form: SettingsForm): """Request to change the settings of a specific service (will cause a restart)""" + srv_check = db.query("SELECT proto, ip_int, port, tls_enabled FROM services WHERE service_id = ?;", service_id) + if len(srv_check) == 0: + raise HTTPException(status_code=404, detail="Service not found") + old_srv = srv_check[0] + if form.proto is not None and form.proto not in ["tcp", "udp"]: raise HTTPException(status_code=400, detail="Invalid protocol") + + if old_srv["tls_enabled"]: + new_proto = form.proto if form.proto is not None else old_srv["proto"] + if new_proto != "tcp": + raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol") if form.port is not None and (form.port < 1 or form.port > 65535): raise HTTPException(status_code=400, detail="Invalid port") @@ -217,6 +243,9 @@ async def service_settings(service_id: str, form: SettingsForm): form.ip_int = ip_parse(form.ip_int) except ValueError: raise HTTPException(status_code=400, detail="Invalid address") + + new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"] + new_port = form.port if form.port is not None else old_srv["port"] keys = [] values = [] @@ -227,10 +256,22 @@ async def service_settings(service_id: str, form: SettingsForm): if len(keys) == 0: raise HTTPException(status_code=400, detail="No settings to change provided") + + if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]): + cert, key = CertsDB().get_cert_and_key(old_srv["ip_int"], old_srv["port"]) + if cert and key: + CertsDB().upsert_cert_and_key(new_ip, new_port, cert, key) + CertsDB().delete_cert_and_key(old_srv["ip_int"], old_srv["port"]) try: db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) except sqlite3.IntegrityError: + # If migration was performed, rollback CertsDB change (upsert old, delete new) + if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]): + cert, key = CertsDB().get_cert_and_key(new_ip, new_port) + if cert and key: + CertsDB().upsert_cert_and_key(old_srv["ip_int"], old_srv["port"], cert, key) + CertsDB().delete_cert_and_key(new_ip, new_port) raise HTTPException(status_code=400, detail="A service with these settings already exists") old_status = firewall.get(service_id).status @@ -322,17 +363,49 @@ async def add_new_service(form: ServiceAddForm): raise HTTPException(status_code=400, detail="Invalid address") if form.proto not in ["tcp", "udp"]: raise HTTPException(status_code=400, detail="Invalid protocol") + if form.tls_enabled: + if form.proto != "tcp": + raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol") + if not form.tls_cert or not form.tls_key: + raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled") + CertsDB().upsert_cert_and_key(form.ip_int, form.port, form.tls_cert, form.tls_key) + srv_id = None try: srv_id = gen_service_id() - db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open) VALUES (?, ?, ?, ?, ?, ?, ?)", - srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open) + db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, tls_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, int(form.tls_enabled)) except sqlite3.IntegrityError: raise HTTPException(status_code=400, detail="This type of service already exists") await firewall.reload() await refresh_frontend() return {'status': 'ok', 'service_id': srv_id} +@app.put('/services/{service_id}/tls-config', response_model=StatusMessageModel) +async def update_service_tls(service_id: str, form: TLSConfigForm): + """Update TLS configuration for a service""" + srv_res = db.query("SELECT ip_int, port, proto FROM services WHERE service_id = ?;", service_id) + if len(srv_res) == 0: + raise HTTPException(status_code=404, detail="Service not found") + srv = srv_res[0] + + if form.tls_enabled: + if srv["proto"] != "tcp": + raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol") + if not form.tls_cert or not form.tls_key: + raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled") + CertsDB().upsert_cert_and_key(srv["ip_int"], srv["port"], form.tls_cert, form.tls_key) + + db.query(""" + UPDATE services + SET tls_enabled = ? + WHERE service_id = ?; + """, int(form.tls_enabled), service_id) + + await firewall.get(service_id).update_tls_config() + await refresh_frontend() + return {'status': 'ok'} + @app.get('/metrics', response_class = PlainTextResponse) async def metrics(): """Aggregate metrics""" diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py index 650c6bb..087bbf0 100644 --- a/backend/utils/__init__.py +++ b/backend/utils/__init__.py @@ -305,6 +305,8 @@ def nicenessify(priority:int, pid:int|None=None): ps = psutil.Process(pid) if os.name == 'posix': ps.nice(priority) + except (psutil.AccessDenied, PermissionError) as e: + print(f"Permission denied setting priority (pid={pid}, priority={priority}): {e}") except Exception as e: print(f"Error setting priority: {e} {traceback.format_exc()}") pass diff --git a/backend/utils/certs.py b/backend/utils/certs.py new file mode 100644 index 0000000..ed77d66 --- /dev/null +++ b/backend/utils/certs.py @@ -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 diff --git a/backend/utils/models.py b/backend/utils/models.py index 962c0b1..db0f413 100644 --- a/backend/utils/models.py +++ b/backend/utils/models.py @@ -24,4 +24,9 @@ class IpInterface(BaseModel): name: str class ResetRequest(BaseModel): - delete:bool \ No newline at end of file + delete:bool + +class TLSConfigForm(BaseModel): + tls_enabled: bool + tls_cert: str|None = None + tls_key: str|None = None \ No newline at end of file diff --git a/frontend/src/components/NFProxy/AddEditService.tsx b/frontend/src/components/NFProxy/AddEditService.tsx index caca32c..13e1af2 100644 --- a/frontend/src/components/NFProxy/AddEditService.tsx +++ b/frontend/src/components/NFProxy/AddEditService.tsx @@ -2,6 +2,7 @@ import { Button, Group, Space, TextInput, Notification, Modal, Switch, Segmented import { useForm } from '@mantine/form'; import { useEffect, useState } from 'react'; import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils'; +import TLSServiceFields from '../TLSServiceFields'; import { ImCross } from "react-icons/im" import { nfproxy, Service } from './utils'; import PortAndInterface from '../PortAndInterface'; @@ -18,7 +19,10 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=> ip_int:edit?.ip_int??"", proto:edit?.proto??"tcp", fail_open: edit?.fail_open??false, - autostart: true + autostart: true, + tls_enabled: false, + tls_cert: "", + tls_key: "" } const form = useForm({ @@ -28,6 +32,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=> port: (value) => (value>0 && value<65536) ? null : "Invalid port", proto: (value) => ["tcp","http"].includes(value) ? null : "Invalid protocol", ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address", + tls_cert: (value, values) => !edit && values.tls_enabled && !value ? "Certificate is required when TLS is enabled" : null, + tls_key: (value, values) => !edit && values.tls_enabled && !value ? "Private key is required when TLS is enabled" : null, } }) @@ -38,6 +44,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=> } }, [opened]) + + const close = () =>{ onClose() form.reset() @@ -47,26 +55,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=> const [submitLoading, setSubmitLoading] = useState(false) const [error, setError] = useState(null) - const submitRequest = ({ name, port, autostart, proto, ip_int, fail_open }:ServiceAddForm) =>{ + const submitRequest = (values: ServiceAddForm) => { setSubmitLoading(true) if (edit){ - nfproxy.settings(edit.service_id, { port, ip_int, fail_open }).then( res => { + nfproxy.settings(edit.service_id, { port: values.port, ip_int: values.ip_int, fail_open: values.fail_open }).then( res => { if (!res){ setSubmitLoading(false) close(); - okNotify(`Service ${name} settings updated`, `Successfully updated settings for service ${name}`) + okNotify(`Service ${values.name} settings updated`, `Successfully updated settings for service ${values.name}`) } }).catch( err => { setSubmitLoading(false) setError("Request Failed! [ "+err+" ]") }) }else{ - nfproxy.servicesadd({ name, port, proto, ip_int, fail_open }).then( res => { + const payload = { + name: values.name, + port: values.port, + proto: values.proto, + ip_int: values.ip_int, + fail_open: values.fail_open, + tls_enabled: values.tls_enabled, + tls_cert: values.tls_enabled ? values.tls_cert : null, + tls_key: values.tls_enabled ? values.tls_key : null, + } + nfproxy.servicesadd(payload).then( res => { if (res.status === "ok" && res.service_id){ setSubmitLoading(false) close(); - if (autostart) nfproxy.servicestart(res.service_id) - okNotify(`Service ${name} has been added`, `Successfully added service with port ${port}`) + if (values.autostart) nfproxy.servicestart(res.service_id) + okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`) }else{ setSubmitLoading(false) setError("Invalid request! [ "+res.status+" ]") @@ -129,6 +147,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=> />} + {!edit && } + diff --git a/frontend/src/components/NFProxy/ServiceRow/index.tsx b/frontend/src/components/NFProxy/ServiceRow/index.tsx index 37d12fb..43ae1fa 100644 --- a/frontend/src/components/NFProxy/ServiceRow/index.tsx +++ b/frontend/src/components/NFProxy/ServiceRow/index.tsx @@ -10,12 +10,13 @@ import { BiRename } from 'react-icons/bi' import RenameForm from './RenameForm'; import { MenuDropDownWithButton } from '../../MainLayout'; import { useQueryClient } from '@tanstack/react-query'; -import { TbPlugConnected } from "react-icons/tb"; +import { TbPlugConnected, TbShieldLock } from "react-icons/tb"; import { FaFilter } from "react-icons/fa"; import { IoSettingsSharp } from 'react-icons/io5'; import AddEditService from '../AddEditService'; import { FaPencilAlt } from "react-icons/fa"; import { ExceptionWarning } from '../ExceptionWarning'; +import TLSConfigModal from '../../TLSConfigModal'; export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) { @@ -30,6 +31,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli const [deleteModal, setDeleteModal] = useState(false) const [renameModal, setRenameModal] = useState(false) const [editModal, setEditModal] = useState(false) + const [tlsModal, setTlsModal] = useState(false) const isMedium = isMediumScreen() const stopService = async () => { @@ -91,6 +93,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli :{service.port} + {service.tls_enabled && ( + + + TLS (lo:{service.clear_port}) + + )} {isMedium?null:} @@ -115,6 +123,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli Edit service } onClick={()=>setEditModal(true)}>Service Settings } onClick={()=>setRenameModal(true)}>Change service name + } onClick={()=>setTlsModal(true)}>Configure TLS Danger zone } onClick={()=>setDeleteModal(true)}>Delete Service @@ -160,5 +169,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli onClose={()=>setEditModal(false)} edit={service} /> + setTlsModal(false)} + service={service} + updateTlsConfig={nfproxy.updatetlsconfig} + queryKey={serviceQueryKey} + /> } diff --git a/frontend/src/components/NFProxy/utils.ts b/frontend/src/components/NFProxy/utils.ts index 3ce66de..bcb979f 100644 --- a/frontend/src/components/NFProxy/utils.ts +++ b/frontend/src/components/NFProxy/utils.ts @@ -13,14 +13,29 @@ export type Service = { edited_packets:number, blocked_packets:number, fail_open:boolean, + tls_enabled:boolean, + tls_cert?:string, + tls_key?:string, + ssl_port?:number, + clear_port?:number, } +export type TLSConfig = { + tls_enabled: boolean, + tls_cert: string | null, + tls_key: string | null, +} + + export type ServiceAddForm = { name:string, port:number, proto:string, ip_int:string, fail_open: boolean, + tls_enabled?: boolean, + tls_cert?: string | null, + tls_key?: string | null, } export type ServiceSettings = { @@ -88,6 +103,10 @@ export const nfproxy = { const { status } = await putapi(`nfproxy/services/${service_id}/settings`,data) as ServerResponse; return status === "ok"?undefined:status }, + updatetlsconfig: async (service_id:string, data:TLSConfig) => { + const { status } = await putapi(`nfproxy/services/${service_id}/tls-config`,data) as ServerResponse; + return status === "ok"?undefined:status + }, getpyfilterscode: async (service_id:string) => { return await getapi(`nfproxy/services/${service_id}/code`) as string; }, diff --git a/frontend/src/components/NFRegex/AddEditService.tsx b/frontend/src/components/NFRegex/AddEditService.tsx index 4817f89..92b2571 100644 --- a/frontend/src/components/NFRegex/AddEditService.tsx +++ b/frontend/src/components/NFRegex/AddEditService.tsx @@ -2,6 +2,7 @@ import { Button, Group, Space, TextInput, Notification, Modal, Switch, Segmented import { useForm } from '@mantine/form'; import { useEffect, useState } from 'react'; import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils'; +import TLSServiceFields from '../TLSServiceFields'; import { ImCross } from "react-icons/im" import { nfregex, Service } from './utils'; import PortAndInterface from '../PortAndInterface'; @@ -18,7 +19,10 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=> ip_int:edit?.ip_int??"", proto:edit?.proto??"tcp", fail_open: edit?.fail_open??false, - autostart: true + autostart: true, + tls_enabled: false, + tls_cert: "", + tls_key: "" } const form = useForm({ @@ -28,6 +32,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=> port: (value) => (value>0 && value<65536) ? null : "Invalid port", proto: (value) => ["tcp","udp"].includes(value) ? null : "Invalid protocol", ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address", + tls_cert: (value, values) => !edit && values.tls_enabled && !value ? "Certificate is required when TLS is enabled" : null, + tls_key: (value, values) => !edit && values.tls_enabled && !value ? "Private key is required when TLS is enabled" : null, } }) @@ -38,6 +44,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=> } }, [opened]) + + const close = () =>{ onClose() form.reset() @@ -47,26 +55,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=> const [submitLoading, setSubmitLoading] = useState(false) const [error, setError] = useState(null) - const submitRequest = ({ name, port, autostart, proto, ip_int, fail_open }:ServiceAddForm) =>{ + const submitRequest = (values: ServiceAddForm) => { setSubmitLoading(true) if (edit){ - nfregex.settings(edit.service_id, { port, proto, ip_int, fail_open }).then( res => { + nfregex.settings(edit.service_id, { port: values.port, proto: values.proto, ip_int: values.ip_int, fail_open: values.fail_open }).then( res => { if (!res){ setSubmitLoading(false) close(); - okNotify(`Service ${name} settings updated`, `Successfully updated settings for service ${name}`) + okNotify(`Service ${values.name} settings updated`, `Successfully updated settings for service ${values.name}`) } }).catch( err => { setSubmitLoading(false) setError("Request Failed! [ "+err+" ]") }) }else{ - nfregex.servicesadd({ name, port, proto, ip_int, fail_open }).then( res => { + const payload = { + name: values.name, + port: values.port, + proto: values.proto, + ip_int: values.ip_int, + fail_open: values.fail_open, + tls_enabled: values.tls_enabled, + tls_cert: values.tls_enabled ? values.tls_cert : null, + tls_key: values.tls_enabled ? values.tls_key : null, + } + nfregex.servicesadd(payload).then( res => { if (res.status === "ok" && res.service_id){ setSubmitLoading(false) close(); - if (autostart) nfregex.servicestart(res.service_id) - okNotify(`Service ${name} has been added`, `Successfully added service with port ${port}`) + if (values.autostart) nfregex.servicestart(res.service_id) + okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`) }else{ setSubmitLoading(false) setError("Invalid request! [ "+res.status+" ]") @@ -129,6 +147,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=> /> + {!edit && } + diff --git a/frontend/src/components/NFRegex/ServiceRow/index.tsx b/frontend/src/components/NFRegex/ServiceRow/index.tsx index 0842e32..b4e77f9 100644 --- a/frontend/src/components/NFRegex/ServiceRow/index.tsx +++ b/frontend/src/components/NFRegex/ServiceRow/index.tsx @@ -14,6 +14,8 @@ import { FaFilter } from "react-icons/fa"; import { VscRegex } from "react-icons/vsc"; import { IoSettingsSharp } from 'react-icons/io5'; import AddEditService from '../AddEditService'; +import { TbShieldLock } from "react-icons/tb"; +import TLSConfigModal from '../../TLSConfigModal'; export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) { @@ -28,6 +30,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli const [deleteModal, setDeleteModal] = useState(false) const [renameModal, setRenameModal] = useState(false) const [editModal, setEditModal] = useState(false) + const [tlsModal, setTlsModal] = useState(false) const isMedium = isMediumScreen() const stopService = async () => { @@ -89,6 +92,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli :{service.port} + {service.tls_enabled && ( + + + TLS (lo:{service.clear_port}) + + )} {isMedium?null:} @@ -109,6 +118,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli Edit service } onClick={()=>setEditModal(true)}>Service Settings } onClick={()=>setRenameModal(true)}>Change service name + } onClick={()=>setTlsModal(true)}>Configure TLS Danger zone } onClick={()=>setDeleteModal(true)}>Delete Service @@ -154,5 +164,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli onClose={()=>setEditModal(false)} edit={service} /> + setTlsModal(false)} + service={service} + updateTlsConfig={nfregex.updatetlsconfig} + queryKey={serviceQueryKey} + /> } diff --git a/frontend/src/components/NFRegex/utils.ts b/frontend/src/components/NFRegex/utils.ts index 0fd8b3a..465f720 100644 --- a/frontend/src/components/NFRegex/utils.ts +++ b/frontend/src/components/NFRegex/utils.ts @@ -13,6 +13,11 @@ export type Service = { n_packets:number, n_regex:number, fail_open:boolean, + tls_enabled:boolean, + tls_cert?: string, + tls_key?: string, + ssl_port?: number, + clear_port?: number, } export type ServiceAddForm = { @@ -21,6 +26,9 @@ export type ServiceAddForm = { proto:string, ip_int:string, fail_open: boolean, + tls_enabled?: boolean, + tls_cert?: string | null, + tls_key?: string | null, } export type ServiceSettings = { @@ -28,6 +36,9 @@ export type ServiceSettings = { proto?:string, ip_int?:string, fail_open?: boolean, + tls_enabled?: boolean, + tls_cert?: string | null, + tls_key?: string | null, } export type ServiceAddResponse = { @@ -92,4 +103,8 @@ export const nfregex = { const { status } = await putapi(`nfregex/services/${service_id}/settings`,data) as ServerResponse; return status === "ok"?undefined:status }, + updatetlsconfig: async (service_id: string, data: { tls_enabled: boolean, tls_cert: string | null, tls_key: string | null }) => { + const { status } = await putapi(`nfregex/services/${service_id}/tls-config`, data) as ServerResponse; + return status === "ok" ? undefined : status; + }, } \ No newline at end of file diff --git a/frontend/src/components/TLSConfigModal.tsx b/frontend/src/components/TLSConfigModal.tsx new file mode 100644 index 0000000..fe0ce60 --- /dev/null +++ b/frontend/src/components/TLSConfigModal.tsx @@ -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; + 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(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 ( + +
+ + + {form.values.tls_enabled && ( + <> + +