mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 15:30:59 +00:00
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:
parent
bd10f9e8b8
commit
75c54f0155
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
103
backend/modules/nfproxy/nginx.py
Normal file
103
backend/modules/nfproxy/nginx.py
Normal 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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
36
backend/routers/certificates.py
Normal file
36
backend/routers/certificates.py
Normal 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"}
|
||||
@ -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()
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -305,6 +305,8 @@ def nicenessify(priority:int, pid:int|None=None):
|
||||
ps = psutil.Process(pid)
|
||||
if os.name == 'posix':
|
||||
ps.nice(priority)
|
||||
except (psutil.AccessDenied, PermissionError) as e:
|
||||
print(f"Permission denied setting priority (pid={pid}, priority={priority}): {e}")
|
||||
except Exception as e:
|
||||
print(f"Error setting priority: {e} {traceback.format_exc()}")
|
||||
pass
|
||||
|
||||
106
backend/utils/certs.py
Normal file
106
backend/utils/certs.py
Normal 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
|
||||
@ -24,4 +24,9 @@ class IpInterface(BaseModel):
|
||||
name: str
|
||||
|
||||
class ResetRequest(BaseModel):
|
||||
delete:bool
|
||||
delete:bool
|
||||
|
||||
class TLSConfigForm(BaseModel):
|
||||
tls_enabled: bool
|
||||
tls_cert: str|None = None
|
||||
tls_key: str|None = None
|
||||
@ -2,6 +2,7 @@ import { Button, Group, Space, TextInput, Notification, Modal, Switch, Segmented
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils';
|
||||
import TLSServiceFields from '../TLSServiceFields';
|
||||
import { ImCross } from "react-icons/im"
|
||||
import { nfproxy, Service } from './utils';
|
||||
import PortAndInterface from '../PortAndInterface';
|
||||
@ -18,7 +19,10 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
ip_int:edit?.ip_int??"",
|
||||
proto:edit?.proto??"tcp",
|
||||
fail_open: edit?.fail_open??false,
|
||||
autostart: true
|
||||
autostart: true,
|
||||
tls_enabled: false,
|
||||
tls_cert: "",
|
||||
tls_key: ""
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
@ -28,6 +32,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
port: (value) => (value>0 && value<65536) ? null : "Invalid port",
|
||||
proto: (value) => ["tcp","http"].includes(value) ? null : "Invalid protocol",
|
||||
ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address",
|
||||
tls_cert: (value, values) => !edit && values.tls_enabled && !value ? "Certificate is required when TLS is enabled" : null,
|
||||
tls_key: (value, values) => !edit && values.tls_enabled && !value ? "Private key is required when TLS is enabled" : null,
|
||||
}
|
||||
})
|
||||
|
||||
@ -38,6 +44,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
}
|
||||
}, [opened])
|
||||
|
||||
|
||||
|
||||
const close = () =>{
|
||||
onClose()
|
||||
form.reset()
|
||||
@ -47,26 +55,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
const [submitLoading, setSubmitLoading] = useState(false)
|
||||
const [error, setError] = useState<string|null>(null)
|
||||
|
||||
const submitRequest = ({ name, port, autostart, proto, ip_int, fail_open }:ServiceAddForm) =>{
|
||||
const submitRequest = (values: ServiceAddForm) => {
|
||||
setSubmitLoading(true)
|
||||
if (edit){
|
||||
nfproxy.settings(edit.service_id, { port, ip_int, fail_open }).then( res => {
|
||||
nfproxy.settings(edit.service_id, { port: values.port, ip_int: values.ip_int, fail_open: values.fail_open }).then( res => {
|
||||
if (!res){
|
||||
setSubmitLoading(false)
|
||||
close();
|
||||
okNotify(`Service ${name} settings updated`, `Successfully updated settings for service ${name}`)
|
||||
okNotify(`Service ${values.name} settings updated`, `Successfully updated settings for service ${values.name}`)
|
||||
}
|
||||
}).catch( err => {
|
||||
setSubmitLoading(false)
|
||||
setError("Request Failed! [ "+err+" ]")
|
||||
})
|
||||
}else{
|
||||
nfproxy.servicesadd({ name, port, proto, ip_int, fail_open }).then( res => {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
port: values.port,
|
||||
proto: values.proto,
|
||||
ip_int: values.ip_int,
|
||||
fail_open: values.fail_open,
|
||||
tls_enabled: values.tls_enabled,
|
||||
tls_cert: values.tls_enabled ? values.tls_cert : null,
|
||||
tls_key: values.tls_enabled ? values.tls_key : null,
|
||||
}
|
||||
nfproxy.servicesadd(payload).then( res => {
|
||||
if (res.status === "ok" && res.service_id){
|
||||
setSubmitLoading(false)
|
||||
close();
|
||||
if (autostart) nfproxy.servicestart(res.service_id)
|
||||
okNotify(`Service ${name} has been added`, `Successfully added service with port ${port}`)
|
||||
if (values.autostart) nfproxy.servicestart(res.service_id)
|
||||
okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`)
|
||||
}else{
|
||||
setSubmitLoading(false)
|
||||
setError("Invalid request! [ "+res.status+" ]")
|
||||
@ -129,6 +147,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
/>}
|
||||
</Box>
|
||||
|
||||
{!edit && <TLSServiceFields form={form} />}
|
||||
|
||||
<Group justify='flex-end' mt="md" mb="sm">
|
||||
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button>
|
||||
</Group>
|
||||
|
||||
@ -10,12 +10,13 @@ import { BiRename } from 'react-icons/bi'
|
||||
import RenameForm from './RenameForm';
|
||||
import { MenuDropDownWithButton } from '../../MainLayout';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { TbPlugConnected } from "react-icons/tb";
|
||||
import { TbPlugConnected, TbShieldLock } from "react-icons/tb";
|
||||
import { FaFilter } from "react-icons/fa";
|
||||
import { IoSettingsSharp } from 'react-icons/io5';
|
||||
import AddEditService from '../AddEditService';
|
||||
import { FaPencilAlt } from "react-icons/fa";
|
||||
import { ExceptionWarning } from '../ExceptionWarning';
|
||||
import TLSConfigModal from '../../TLSConfigModal';
|
||||
|
||||
export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) {
|
||||
|
||||
@ -30,6 +31,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
|
||||
const [deleteModal, setDeleteModal] = useState(false)
|
||||
const [renameModal, setRenameModal] = useState(false)
|
||||
const [editModal, setEditModal] = useState(false)
|
||||
const [tlsModal, setTlsModal] = useState(false)
|
||||
const isMedium = isMediumScreen()
|
||||
|
||||
const stopService = async () => {
|
||||
@ -91,6 +93,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
|
||||
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}>
|
||||
:{service.port}
|
||||
</Badge>
|
||||
{service.tls_enabled && (
|
||||
<Badge color="green" radius="md" size="lg" variant="light">
|
||||
<TbShieldLock size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
|
||||
TLS (lo:{service.clear_port})
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{isMedium?null:<Space w="xl" />}
|
||||
</Box>
|
||||
@ -115,6 +123,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
|
||||
<Menu.Item><b>Edit service</b></Menu.Item>
|
||||
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
|
||||
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
|
||||
<Menu.Item leftSection={<TbShieldLock size={18} />} onClick={()=>setTlsModal(true)}>Configure TLS</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Label><b>Danger zone</b></Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
|
||||
@ -160,5 +169,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
|
||||
onClose={()=>setEditModal(false)}
|
||||
edit={service}
|
||||
/>
|
||||
<TLSConfigModal
|
||||
opened={tlsModal}
|
||||
onClose={() => setTlsModal(false)}
|
||||
service={service}
|
||||
updateTlsConfig={nfproxy.updatetlsconfig}
|
||||
queryKey={serviceQueryKey}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -2,6 +2,7 @@ import { Button, Group, Space, TextInput, Notification, Modal, Switch, Segmented
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils';
|
||||
import TLSServiceFields from '../TLSServiceFields';
|
||||
import { ImCross } from "react-icons/im"
|
||||
import { nfregex, Service } from './utils';
|
||||
import PortAndInterface from '../PortAndInterface';
|
||||
@ -18,7 +19,10 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
ip_int:edit?.ip_int??"",
|
||||
proto:edit?.proto??"tcp",
|
||||
fail_open: edit?.fail_open??false,
|
||||
autostart: true
|
||||
autostart: true,
|
||||
tls_enabled: false,
|
||||
tls_cert: "",
|
||||
tls_key: ""
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
@ -28,6 +32,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
port: (value) => (value>0 && value<65536) ? null : "Invalid port",
|
||||
proto: (value) => ["tcp","udp"].includes(value) ? null : "Invalid protocol",
|
||||
ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address",
|
||||
tls_cert: (value, values) => !edit && values.tls_enabled && !value ? "Certificate is required when TLS is enabled" : null,
|
||||
tls_key: (value, values) => !edit && values.tls_enabled && !value ? "Private key is required when TLS is enabled" : null,
|
||||
}
|
||||
})
|
||||
|
||||
@ -38,6 +44,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
}
|
||||
}, [opened])
|
||||
|
||||
|
||||
|
||||
const close = () =>{
|
||||
onClose()
|
||||
form.reset()
|
||||
@ -47,26 +55,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
const [submitLoading, setSubmitLoading] = useState(false)
|
||||
const [error, setError] = useState<string|null>(null)
|
||||
|
||||
const submitRequest = ({ name, port, autostart, proto, ip_int, fail_open }:ServiceAddForm) =>{
|
||||
const submitRequest = (values: ServiceAddForm) => {
|
||||
setSubmitLoading(true)
|
||||
if (edit){
|
||||
nfregex.settings(edit.service_id, { port, proto, ip_int, fail_open }).then( res => {
|
||||
nfregex.settings(edit.service_id, { port: values.port, proto: values.proto, ip_int: values.ip_int, fail_open: values.fail_open }).then( res => {
|
||||
if (!res){
|
||||
setSubmitLoading(false)
|
||||
close();
|
||||
okNotify(`Service ${name} settings updated`, `Successfully updated settings for service ${name}`)
|
||||
okNotify(`Service ${values.name} settings updated`, `Successfully updated settings for service ${values.name}`)
|
||||
}
|
||||
}).catch( err => {
|
||||
setSubmitLoading(false)
|
||||
setError("Request Failed! [ "+err+" ]")
|
||||
})
|
||||
}else{
|
||||
nfregex.servicesadd({ name, port, proto, ip_int, fail_open }).then( res => {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
port: values.port,
|
||||
proto: values.proto,
|
||||
ip_int: values.ip_int,
|
||||
fail_open: values.fail_open,
|
||||
tls_enabled: values.tls_enabled,
|
||||
tls_cert: values.tls_enabled ? values.tls_cert : null,
|
||||
tls_key: values.tls_enabled ? values.tls_key : null,
|
||||
}
|
||||
nfregex.servicesadd(payload).then( res => {
|
||||
if (res.status === "ok" && res.service_id){
|
||||
setSubmitLoading(false)
|
||||
close();
|
||||
if (autostart) nfregex.servicestart(res.service_id)
|
||||
okNotify(`Service ${name} has been added`, `Successfully added service with port ${port}`)
|
||||
if (values.autostart) nfregex.servicestart(res.service_id)
|
||||
okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`)
|
||||
}else{
|
||||
setSubmitLoading(false)
|
||||
setError("Invalid request! [ "+res.status+" ]")
|
||||
@ -129,6 +147,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!edit && <TLSServiceFields form={form} disabled={form.values.proto !== 'tcp'} />}
|
||||
|
||||
<Group justify='flex-end' mt="md" mb="sm">
|
||||
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button>
|
||||
</Group>
|
||||
|
||||
@ -14,6 +14,8 @@ import { FaFilter } from "react-icons/fa";
|
||||
import { VscRegex } from "react-icons/vsc";
|
||||
import { IoSettingsSharp } from 'react-icons/io5';
|
||||
import AddEditService from '../AddEditService';
|
||||
import { TbShieldLock } from "react-icons/tb";
|
||||
import TLSConfigModal from '../../TLSConfigModal';
|
||||
|
||||
export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) {
|
||||
|
||||
@ -28,6 +30,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
|
||||
const [deleteModal, setDeleteModal] = useState(false)
|
||||
const [renameModal, setRenameModal] = useState(false)
|
||||
const [editModal, setEditModal] = useState(false)
|
||||
const [tlsModal, setTlsModal] = useState(false)
|
||||
const isMedium = isMediumScreen()
|
||||
|
||||
const stopService = async () => {
|
||||
@ -89,6 +92,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
|
||||
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}>
|
||||
:{service.port}
|
||||
</Badge>
|
||||
{service.tls_enabled && (
|
||||
<Badge color="green" radius="md" size="lg" variant="light">
|
||||
<TbShieldLock size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
|
||||
TLS (lo:{service.clear_port})
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{isMedium?null:<Space w="xl" />}
|
||||
</Box>
|
||||
@ -109,6 +118,7 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
|
||||
<Menu.Item><b>Edit service</b></Menu.Item>
|
||||
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
|
||||
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
|
||||
<Menu.Item leftSection={<TbShieldLock size={18} />} onClick={()=>setTlsModal(true)}>Configure TLS</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Label><b>Danger zone</b></Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
|
||||
@ -154,5 +164,12 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
|
||||
onClose={()=>setEditModal(false)}
|
||||
edit={service}
|
||||
/>
|
||||
<TLSConfigModal
|
||||
opened={tlsModal}
|
||||
onClose={() => setTlsModal(false)}
|
||||
service={service}
|
||||
updateTlsConfig={nfregex.updatetlsconfig}
|
||||
queryKey={serviceQueryKey}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
@ -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;
|
||||
},
|
||||
}
|
||||
137
frontend/src/components/TLSConfigModal.tsx
Normal file
137
frontend/src/components/TLSConfigModal.tsx
Normal 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----- ... -----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----- ... -----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>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/TLSServiceFields.tsx
Normal file
78
frontend/src/components/TLSServiceFields.tsx
Normal 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----- ... -----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----- ... -----END PRIVATE KEY-----"
|
||||
minRows={4}
|
||||
styles={{ input: { fontFamily: 'monospace' } }}
|
||||
{...form.getInputProps('tls_key')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -16,7 +16,7 @@ import { FaArrowLeft } from "react-icons/fa";
|
||||
import { IoSettingsSharp } from 'react-icons/io5';
|
||||
import AddEditService from '../../components/NFProxy/AddEditService';
|
||||
import PyFilterView from '../../components/PyFilterView';
|
||||
import { TbPlugConnected } from 'react-icons/tb';
|
||||
import { TbPlugConnected, TbShieldLock } from 'react-icons/tb';
|
||||
import { CodeHighlight } from '@mantine/code-highlight';
|
||||
import { FaPython } from "react-icons/fa";
|
||||
import { FiFileText } from "react-icons/fi";
|
||||
@ -24,6 +24,7 @@ import { ModalLog } from '../../components/ModalLog';
|
||||
import { useListState } from '@mantine/hooks';
|
||||
import { ExceptionWarning } from '../../components/NFProxy/ExceptionWarning';
|
||||
import { DocsButton } from '../../components/DocsButton';
|
||||
import TLSConfigModal from '../../components/TLSConfigModal';
|
||||
|
||||
export default function ServiceDetailsNFProxy() {
|
||||
|
||||
@ -34,6 +35,7 @@ export default function ServiceDetailsNFProxy() {
|
||||
const [deleteModal, setDeleteModal] = useState(false)
|
||||
const [renameModal, setRenameModal] = useState(false)
|
||||
const [editModal, setEditModal] = useState(false)
|
||||
const [tlsModal, setTlsModal] = useState(false)
|
||||
const [buttonLoading, setButtonLoading] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const filterCode = nfproxyServiceFilterCodeQuery(srv??"")
|
||||
@ -136,11 +138,18 @@ export default function ServiceDetailsNFProxy() {
|
||||
<Badge size="xl" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" mr="sm">
|
||||
:{serviceInfo.port}
|
||||
</Badge>
|
||||
{serviceInfo.tls_enabled && (
|
||||
<Badge color="green" size="xl" variant="light" radius="md" mr="sm">
|
||||
<TbShieldLock size={16} style={{ marginRight: 6, verticalAlign: 'middle' }} />
|
||||
TLS (lo:{serviceInfo.clear_port})
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<MenuDropDownWithButton>
|
||||
<Menu.Item><b>Edit service</b></Menu.Item>
|
||||
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
|
||||
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
|
||||
<Menu.Item leftSection={<TbShieldLock size={18} />} onClick={()=>setTlsModal(true)}>Configure TLS</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Label><b>Danger zone</b></Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
|
||||
@ -227,6 +236,13 @@ export default function ServiceDetailsNFProxy() {
|
||||
onClose={()=>setEditModal(false)}
|
||||
edit={serviceInfo}
|
||||
/>
|
||||
<TLSConfigModal
|
||||
opened={tlsModal}
|
||||
onClose={() => setTlsModal(false)}
|
||||
service={serviceInfo}
|
||||
updateTlsConfig={nfproxy.updatetlsconfig}
|
||||
queryKey={serviceQueryKey}
|
||||
/>
|
||||
<ModalLog
|
||||
opened={openLogModal}
|
||||
close={()=>setOpenLogModal(false)}
|
||||
|
||||
121
tests/nginx_test.py
Normal file
121
tests/nginx_test.py
Normal 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()
|
||||
@ -8,6 +8,9 @@ ERROR=0
|
||||
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
echo "Running Nginx config unit test"
|
||||
python3 nginx_test.py || ERROR=1
|
||||
|
||||
until curl --output /dev/null --silent --fail http://127.0.0.1:4444/api/status; do
|
||||
printf '.'
|
||||
sleep 1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user