graphics and TLS feature total refactoring

This commit is contained in:
Domingo Dirutigliano 2026-07-23 13:39:01 +02:00
parent 75c54f0155
commit c37d2b31e8
No known key found for this signature in database
78 changed files with 4062 additions and 2461 deletions

View File

@ -1,46 +0,0 @@
# This workflow will upload a Python Package using Twine when a release is created
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Upload Python Package (fgex alias)
on:
release:
types:
- published
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Extract tag name
id: tag
run: echo TAG_NAME=$(echo $GITHUB_REF | cut -d / -f 3) >> $GITHUB_OUTPUT
- name: Update version in setup.py
run: >-
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/fgex-pip/setup.py;
- name: Build package
run: cd fgex-lib/fgex-pip && python -m build && mv ./dist ../../
- name: Publish package
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN_FGEX }}

View File

@ -1,47 +0,0 @@
# This workflow will upload a Python Package using Twine when a release is created
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Upload Python Package
on:
release:
types:
- published
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Extract tag name
id: tag
run: echo TAG_NAME=$(echo $GITHUB_REF | cut -d / -f 3) >> $GITHUB_OUTPUT
- name: Update version in setup.py
run: >-
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/setup.py;
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/firegex/__init__.py;
- name: Build package
run: cd fgex-lib && python -m build && mv ./dist ../
- name: Publish package
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}

View File

@ -1,4 +1,4 @@
name: Create and publish Docker images name: Test, build and publish release artifacts
on: on:
release: release:
@ -10,17 +10,33 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
# Gate for every publish target below: nothing gets pushed to Docker/PyPI unless this passes.
run_tests:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build and run firegex
run: python3 run.py start -P testpassword
- name: Run tests
run: sudo apt-get install -y iperf3 && cd tests && ./run_tests.sh
docker_build: docker_build:
needs: run_tests
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
include: include:
- os: ubuntu-latest - os: ubuntu-latest
arch: amd64 arch: amd64
run_tests: true
- os: ubuntu-24.04-arm - os: ubuntu-24.04-arm
arch: arm64 arch: arm64
run_tests: true
permissions: permissions:
contents: read contents: read
packages: write packages: write
@ -33,14 +49,6 @@ jobs:
id: lowercase id: lowercase
run: echo "image_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT run: echo "image_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Build and run firegex
if: matrix.run_tests
run: python3 run.py start -P testpassword
- name: Run tests
if: matrix.run_tests
run: sudo apt-get install -y iperf3 && cd tests && ./run_tests.sh
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@master uses: docker/setup-buildx-action@master
@ -208,3 +216,64 @@ jobs:
echo "Assets uploaded successfully!" echo "Assets uploaded successfully!"
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pypi_publish_firegex:
needs: run_tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Extract tag name
id: tag
run: echo TAG_NAME=$(echo $GITHUB_REF | cut -d / -f 3) >> $GITHUB_OUTPUT
- name: Update version in setup.py
run: >-
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/setup.py;
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/firegex/__init__.py;
- name: Build package
run: cd fgex-lib && python -m build && mv ./dist ../
- name: Publish package
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
pypi_publish_fgex_alias:
needs: run_tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Extract tag name
id: tag
run: echo TAG_NAME=$(echo $GITHUB_REF | cut -d / -f 3) >> $GITHUB_OUTPUT
- name: Update version in setup.py
run: >-
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/fgex-pip/setup.py;
- name: Build package
run: cd fgex-lib/fgex-pip && python -m build && mv ./dist ../../
- name: Publish package
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN_FGEX }}

1
.gitignore vendored
View File

@ -50,3 +50,4 @@
**/npm-debug.log* **/npm-debug.log*
**/yarn-debug.log* **/yarn-debug.log*
**/yarn-error.log* **/yarn-error.log*
.claude

87
CLAUDE.md Normal file
View File

@ -0,0 +1,87 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Firegex is a firewall built for CTF Attack-Defense competitions: it sits in front of a vulnerable service and blocks/mangles malicious traffic before it reaches it. It ships several independent filtering modules (regex-based, Python-filter-based, port hijacking, plain nftables rules, TLS decrypt-and-reinspect) behind a single FastAPI backend and React frontend.
## Commands
### Running the whole app (Docker or standalone)
```bash
python3 run.py # build+start (Docker if available, else standalone chroot on Linux)
python3 run.py --standalone # force standalone mode (no Docker)
python3 run.py stop [--clear] # stop (optionally wipe the DB volume/rootfs)
python3 run.py restart # restart WITHOUT rebuilding (stale image if source changed)
python3 run.py status
python3 run.py config --password [newpass] # change the running instance's password; omit the value to be prompted interactively
```
`run.py` has zero external dependencies (stdlib only) by design. `restart` does not rebuild the Docker image; after a source change use `stop` then the bare start command to force `docker compose up -d --build`.
### Frontend (`frontend/`)
```bash
bun install # bun.lock is canonical
bun run dev # vite dev server
bun run build # tsc && vite build
npx tsc --noEmit -p . # typecheck only (no separate lint script is configured)
```
### Backend (`backend/`)
Needs Linux with `NET_ADMIN` (nftables/nfqueue) and root, so it's normally only run via `run.py` (Docker or standalone). Direct iteration on a Linux box/VM:
```bash
cd backend && DEBUG=1 python3 app.py DEBUG
```
### Tests (`tests/`)
No pytest — plain scripts driven against a **live, already-running** firegex instance (`http://127.0.0.1:4444` by default), root/Linux required.
```bash
cd tests
./run_tests.sh [password] # full suite (defaults to "testpassword")
python3 nfregex_test.py -p <pw> -m tcp [--ipv6] [--tls]
python3 nfproxy_test.py -p <pw> [--ipv6] [--tls]
python3 ph_test.py -p <pw> -m tcp|udp [--ipv6]
python3 tls_test.py -p <pw> [--ipv6]
python3 api_test.py -p <pw>
python3 nginx_test.py # the only test that doesn't need a live instance
```
CI (`.github/workflows/docker-image.yml`) does exactly: `python3 run.py start -P testpassword` then `cd tests && ./run_tests.sh`.
## Architecture
### Three tiers
React (Vite + Mantine + react-router + TanStack Query + socket.io-client) → FastAPI backend → per-module C++ binaries that do the actual packet interception via nftables/NFQUEUE. The backend mostly orchestrates: SQLite-backed config, spawning/talking to the C++ processes over unix sockets, and deploying the built React app.
### Backend router auto-discovery (`backend/utils/loader.py`)
Every `.py` file in `backend/routers/` is auto-imported and mounted at `/api/<filename>` if it exposes a module-level `app` (a `fastapi.APIRouter`). Optional module-level `startup()`, `shutdown()`, `reset(params)` async functions are collected and called by the app's lifespan/`/api/reset` handlers. **Adding a new module = adding a new file here** — nothing else needs to register it.
### Per-module pattern
Each filtering module (`nfproxy`, `nfregex`, `porthijack`, `firewall`, `tls`) follows the same shape:
- `backend/routers/<mod>.py` — FastAPI endpoints + its own SQLite DB, via `utils.sqlite.SQLite`.
- `backend/modules/<mod>/models.py` — plain `Service`/filter model, `from_dict`.
- `backend/modules/<mod>/firewall.py` — `FirewallManager`/`ServiceManager` pair: `FirewallManager` tracks one `ServiceManager` per DB row (in-memory), `ServiceManager.next(status)` starts/stops the interceptor/nft rules for that one service.
- `backend/modules/<mod>/nftables.py` — builds/tears down the module's nftables rules; the manager class extends `utils.NFTableManager` (a `Singleton`), so there's exactly one nft-rule-set owner per module.
### nfproxy vs nfregex
Both intercept via NFQUEUE, but differently:
- **nfregex** (`backend/binsrc/nfregex.cpp` → `cppregex`): pure C++, matches PCRE2/hyperscan regexes against the stream entirely in the binary. Faster, but rules are just regex+mode.
- **nfproxy** (`backend/binsrc/nfproxy.cpp`, `binsrc/pyproxy/*`, embeds libpython → `cpproxy`): user-supplied Python filter code (via the `firegex` pip package, `fgex-lib/`) runs inside the C++ process through the embedded interpreter, talking back to the backend over a unix socket for stats/exceptions. Much more flexible, slower than nfregex.
### TLS Decrypt bridge (`backend/modules/tls/`)
A `tls_streams` DB row describes one public `ip:port` to decrypt. `ssl_port`/`clear_port` are two loopback ports **deterministically derived from a hash of `ip:port`** (`get_tls_ports()`), not stored arbitrarily. nginx (config generated by `modules/tls/nginx.py`) does the actual TLS work per active stream:
1. `listen loopback:ssl_port ssl` → terminates the public TLS connection, forwards **plaintext** to `loopback:clear_port`.
2. `listen loopback:clear_port` → re-encrypts (`proxy_ssl on`) and forwards to the real `ip:port`. The real backend is expected to also speak TLS (mirrors a CTF challenge service that natively does TLS) — this is not a generic "terminate TLS in front of a plaintext service" reverse proxy.
The only unencrypted hop is the loopback leg between `ssl_port` and `clear_port` — an nfproxy/nfregex service must attach there (`target_type="tls"`, `tls_stream_id=...`) to see decrypted content. Both modules' `nftables.py` has a `resolve_target(srv)` helper that swaps in `(loopback_ip, clear_port)` for `target_type="tls"` services instead of the service's own `ip_int`/`port` (which mirror the stream's real destination for display only). Stopping a TLS stream cascades to stop dependent filter services (and vice versa on start); deleting a stream still referenced by a service is rejected. This cross-module logic lives in `routers/tls.py`, which imports `routers.nfproxy`/`routers.nfregex` directly (one-directional — those modules only import `modules.tls.*`, never `routers.tls`).
### SQLite wrapper (`backend/utils/sqlite.py`)
Each DB file's schema is versioned by hashing the Python schema dict; on mismatch the **entire file is deleted and recreated** (not migrated) — any schema dict change wipes that DB file on next startup, including `db/firegex.db`'s `keys_values` table (password/JWT secret). `dump()`/`load()` back the export/import backup feature; `dump()` deliberately excludes `password`/`secret` from exported `keys_values` rows, so `/api/import` explicitly re-preserves the current password/secret around the import.
### Real-time frontend updates
One Socket.IO event, `"update"`, whose payload is a list of strings treated as a react-query `queryKey` prefix (e.g. `["nfproxy"]`, `["tls_streams"]`). `App.tsx` does `queryClient.invalidateQueries({ queryKey: data })` — prefix matching invalidates any query whose key starts with that array. Convention: each module's mutating endpoints emit the same tag string that module's frontend queries use as the first `queryKey` element. `staleTime: Infinity` is set globally, so this event is the **only** automatic refresh mechanism — a missing/mismatched tag means silent staleness for other clients/tabs.
### Frontend structure
`pages/<Module>/` (routes) + `components/<Module>/` (ServiceRow, AddEditService, `utils.ts` with query key + API wrapper) per module, mirroring the backend split. Detail drill-down lives at `pages/<Module>/ServiceDetails.tsx` under a nested `:srv` route (see `App.tsx`'s `PageRouting`). Theme tokens are CSS variables in `src/index.css`; Mantine `Card` defaults are set globally in `index.tsx`. Prefer Mantine `Group`/`Stack` with `gap` over the legacy `Box className="center-flex"` utility classes still present in older parts of the codebase.
### `fgex-lib/`
Separate, independently-published PyPI package (`pip install fgex`/`firegex`) — the library users write nfproxy filters against (`@pyfilter`, `RawPacket`/`HttpRequest`/etc.), plus the `fgex` CLI and a local `proxysim` for testing filters without a real firegex instance. Not part of the backend/frontend build.

View File

@ -4,6 +4,7 @@ import utils
import os import os
import asyncio import asyncio
import logging import logging
import base64
from fastapi import FastAPI, HTTPException, Depends, APIRouter from fastapi import FastAPI, HTTPException, Depends, APIRouter
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import jwt from jose import jwt
@ -11,7 +12,6 @@ from utils.sqlite import SQLite
from utils import API_VERSION, FIREGEX_PORT, FIREGEX_HOST, FIREGEX_SOCKET, JWT_ALGORITHM, get_interfaces, socketio_emit, DEBUG, SysctlManager, NORELOAD from utils import API_VERSION, FIREGEX_PORT, FIREGEX_HOST, FIREGEX_SOCKET, JWT_ALGORITHM, get_interfaces, socketio_emit, DEBUG, SysctlManager, NORELOAD
from utils.loader import frontend_deploy, load_routers from utils.loader import frontend_deploy, load_routers
from utils.models import ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel from utils.models import ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel
from utils.certs import CERTIFICATES_SCHEMA
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import socketio import socketio
@ -19,7 +19,7 @@ from socketio.exceptions import ConnectionRefusedError
import hashlib import hashlib
# DB init # DB init
db = SQLite('db/firegex.db', CERTIFICATES_SCHEMA) db = SQLite('db/firegex.db')
sysctl = SysctlManager({ sysctl = SysctlManager({
"net.ipv4.conf.all.forwarding": True, "net.ipv4.conf.all.forwarding": True,
"net.ipv6.conf.all.forwarding": True, "net.ipv6.conf.all.forwarding": True,
@ -219,6 +219,62 @@ async def reset_firegex(form: ResetRequest):
await refresh_frontend() await refresh_frontend()
return {'status': 'ok'} return {'status': 'ok'}
@api.get('/export')
async def export_db():
"""Export all configuration databases as JSON"""
dbs = {}
if not os.path.exists('db'):
return dbs
for f in os.listdir('db'):
if f.endswith('.db'):
temp_db = SQLite(os.path.join('db', f))
dbs[f] = temp_db.dump()
# Export nfproxy filters
if os.path.exists('db/nfproxy_filters'):
dbs['nfproxy_filters'] = {}
for f in os.listdir('db/nfproxy_filters'):
if f.endswith('.py'):
with open(os.path.join('db/nfproxy_filters', f), 'rb') as script_file:
dbs['nfproxy_filters'][f] = base64.b64encode(script_file.read()).decode('utf-8')
return dbs
@api.post('/import', response_model=StatusMessageModel)
async def import_db(data: dict):
"""Import all configuration databases from JSON"""
if not os.path.exists('db'):
os.makedirs('db')
# Backups never contain the password/secret (export_db strips them), so preserve
# the current session's own values instead of losing them on import.
current_password = db.get("password")
current_secret = db.get("secret")
for db_file, db_data in data.items():
if db_file.endswith('.db'):
temp_db = SQLite(os.path.join('db', db_file))
# Just load the data, we assume schemas exist or will be matched
# We don't delete the whole file to preserve DB_VERSION and schemas
temp_db.load(db_data)
elif db_file == 'nfproxy_filters':
if not os.path.exists('db/nfproxy_filters'):
os.makedirs('db/nfproxy_filters')
for f, script_content in db_data.items():
if f.endswith('.py'):
with open(os.path.join('db/nfproxy_filters', f), 'wb') as script_file:
script_file.write(base64.b64decode(script_content))
if current_password is not None:
db.put("password", current_password)
if current_secret is not None:
db.put("secret", current_secret)
# Restart the application state
await shutdown_main()
await startup_main()
return {'status': 'ok'}
app.include_router(api) app.include_router(api)
frontend_deploy(app) frontend_deploy(app)

View File

@ -1,3 +1,4 @@
#define DEBUG
#define PY_SSIZE_T_CLEAN #define PY_SSIZE_T_CLEAN
#include <Python.h> #include <Python.h>

View File

@ -97,7 +97,7 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
pyq->pkt->drop();// This is needed because the callback has to take the updated pkt pointer! pyq->pkt->drop();// This is needed because the callback has to take the updated pkt pointer!
} }
void filter_action(NfQueue::PktRequest<PyProxyQueue>* pkt, Stream& stream, const string& data){ void filter_action(NfQueue::PktRequest<PyProxyQueue>* pkt, Stream& stream, const string& data, bool is_client){
auto stream_search = sctx.streams_ctx.find(pkt->sid); auto stream_search = sctx.streams_ctx.find(pkt->sid);
pyfilter_ctx* stream_match; pyfilter_ctx* stream_match;
if (stream_search == sctx.streams_ctx.end()){ if (stream_search == sctx.streams_ctx.end()){
@ -129,7 +129,7 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
stream_match = stream_search->second; stream_match = stream_search->second;
} }
auto result = stream_match->handle_packet(pkt, data); auto result = stream_match->handle_packet(pkt, data, is_client);
switch(result.action){ switch(result.action){
case PyFilterResponse::ACCEPT: case PyFilterResponse::ACCEPT:
return pkt->accept(); return pkt->accept();
@ -169,21 +169,21 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
} }
static void on_data_recv(Stream& stream, PyProxyQueue* pyq, const string& data) { static void on_data_recv(Stream& stream, PyProxyQueue* pyq, const string& data, bool is_client) {
pyq->pkt->fix_data_payload(); pyq->pkt->fix_data_payload();
pyq->filter_action(pyq->pkt, stream, data); //Only here the rebuilt_tcp_data is set pyq->filter_action(pyq->pkt, stream, data, is_client); //Only here the rebuilt_tcp_data is set
} }
//Input data filtering //Input data filtering
static void on_client_data(Stream& stream, PyProxyQueue* pyq) { static void on_client_data(Stream& stream, PyProxyQueue* pyq) {
auto data = stream.client_payload(); auto data = stream.client_payload();
on_data_recv(stream, pyq, string((char*)data.data(), data.size())); on_data_recv(stream, pyq, string((char*)data.data(), data.size()), true);
} }
//Server data filtering //Server data filtering
static void on_server_data(Stream& stream, PyProxyQueue* pyq) { static void on_server_data(Stream& stream, PyProxyQueue* pyq) {
auto data = stream.server_payload(); auto data = stream.server_payload();
on_data_recv(stream, pyq, string((char*)data.data(), data.size())); on_data_recv(stream, pyq, string((char*)data.data(), data.size()), false);
} }
// A stream was terminated. The second argument is the reason why it was terminated // A stream was terminated. The second argument is the reason why it was terminated

View File

@ -105,7 +105,8 @@ struct pyfilter_ctx {
py_filter_response handle_packet( py_filter_response handle_packet(
NfQueue::PktRequest<PyProxyQueue>* pkt, NfQueue::PktRequest<PyProxyQueue>* pkt,
const string& data const string& data,
bool is_client
){ ){
PyObject * packet_info = PyDict_New(); PyObject * packet_info = PyDict_New();
@ -113,7 +114,7 @@ struct pyfilter_ctx {
set_item_to_dict(packet_info, "data", PyBytes_FromStringAndSize(data.c_str(), data.size())); set_item_to_dict(packet_info, "data", PyBytes_FromStringAndSize(data.c_str(), data.size()));
set_item_to_dict(packet_info, "l4_size", PyLong_FromLong(pkt->data_size())); set_item_to_dict(packet_info, "l4_size", PyLong_FromLong(pkt->data_size()));
set_item_to_dict(packet_info, "raw_packet", PyBytes_FromStringAndSize(pkt->packet.c_str(), pkt->packet.size())); set_item_to_dict(packet_info, "raw_packet", PyBytes_FromStringAndSize(pkt->packet.c_str(), pkt->packet.size()));
set_item_to_dict(packet_info, "is_input", PyBool_FromLong(pkt->is_input)); set_item_to_dict(packet_info, "is_input", PyBool_FromLong(is_client));
set_item_to_dict(packet_info, "is_ipv6", PyBool_FromLong(pkt->is_ipv6)); set_item_to_dict(packet_info, "is_ipv6", PyBool_FromLong(pkt->is_ipv6));
set_item_to_dict(packet_info, "is_tcp", PyBool_FromLong(pkt->l4_proto == NfQueue::L4Proto::TCP)); set_item_to_dict(packet_info, "is_tcp", PyBool_FromLong(pkt->l4_proto == NfQueue::L4Proto::TCP));
@ -243,6 +244,7 @@ typedef map<stream_id, pyfilter_ctx*> matching_map;
struct stream_ctx { struct stream_ctx {
matching_map streams_ctx; matching_map streams_ctx;
NfQueue::tcp_ack_map tcp_ack_ctx; NfQueue::tcp_ack_map tcp_ack_ctx;
void clean_stream_by_id(stream_id sid){ void clean_stream_by_id(stream_id sid){

View File

@ -6,7 +6,6 @@ import traceback
from fastapi import HTTPException from fastapi import HTTPException
import time import time
from utils import run_func from utils import run_func
from utils import DEBUG
from utils import nicenessify from utils import nicenessify
nft = FiregexTables() nft = FiregexTables()
@ -65,8 +64,6 @@ class FiregexInterceptor:
while True: while True:
try: try:
out_data = (await self.process.stdout.read(1024*10)).decode(errors="ignore") out_data = (await self.process.stdout.read(1024*10)).decode(errors="ignore")
if DEBUG:
print(out_data, end="")
except asyncio.exceptions.LimitOverrunError: except asyncio.exceptions.LimitOverrunError:
self.outstrem_buffer = "" self.outstrem_buffer = ""
continue continue
@ -137,7 +134,6 @@ class FiregexInterceptor:
raise HTTPException(status_code=500, detail="Can't read from nfq client") from e raise HTTPException(status_code=500, detail="Can't read from nfq client") from e
if line.startswith("BLOCKED "): if line.startswith("BLOCKED "):
filter_name = line.split()[1] filter_name = line.split()[1]
print("BLOCKED", filter_name)
async with self.filter_map_lock: async with self.filter_map_lock:
if filter_name in self.filter_map: if filter_name in self.filter_map:
self.filter_map[filter_name].blocked_packets+=1 self.filter_map[filter_name].blocked_packets+=1

View File

@ -2,10 +2,9 @@ import asyncio
from modules.nfproxy.firegex import FiregexInterceptor from modules.nfproxy.firegex import FiregexInterceptor
from modules.nfproxy.nftables import FiregexTables, FiregexFilter from modules.nfproxy.nftables import FiregexTables, FiregexFilter
from modules.nfproxy.models import Service, PyFilter from modules.nfproxy.models import Service, PyFilter
from modules.nfproxy.nginx import sync_nginx_state from utils.sqlite import SQLite
from utils.sqlite import SQLite from utils.sqlite import SQLite
from utils import run_func from utils import run_func
from utils.certs import CertsDB, ip_parse as certs_ip_parse
class STATUS: class STATUS:
STOP = "stop" STOP = "stop"
@ -51,16 +50,17 @@ class ServiceManager:
def __update_status_db(self, status): def __update_status_db(self, status):
self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id) self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id)
async def next(self,to): async def next(self,to,persist:bool=True):
async with self.lock: async with self.lock:
if to == STATUS.STOP: if to == STATUS.STOP:
await self.stop() await self.stop(persist=persist)
if to == STATUS.ACTIVE: if to == STATUS.ACTIVE:
await self.restart() await self.restart()
def _set_status(self,status): def _set_status(self,status,persist:bool=True):
self.status = status self.status = status
self.__update_status_db(status) if persist:
self.__update_status_db(status)
def read_outstrem_buffer(self): def read_outstrem_buffer(self):
if self.interceptor: if self.interceptor:
@ -74,17 +74,13 @@ class ServiceManager:
self.interceptor = await FiregexInterceptor.start(self.srv, outstream_func=self.outstream_function, exception_func=self.exception_function) self.interceptor = await FiregexInterceptor.start(self.srv, outstream_func=self.outstream_function, exception_func=self.exception_function)
await self._update_filters_from_db() await self._update_filters_from_db()
self._set_status(STATUS.ACTIVE) self._set_status(STATUS.ACTIVE)
if self.srv.tls_enabled:
sync_nginx_state()
async def stop(self): async def stop(self,persist:bool=True):
nft.delete(self.srv) nft.delete(self.srv)
if self.interceptor: if self.interceptor:
await self.interceptor.stop() await self.interceptor.stop()
self.interceptor = None self.interceptor = None
self._set_status(STATUS.STOP) self._set_status(STATUS.STOP,persist=persist)
if self.srv.tls_enabled:
sync_nginx_state()
async def restart(self): async def restart(self):
await self.stop() await self.stop()
@ -94,17 +90,7 @@ class ServiceManager:
async with self.lock: async with self.lock:
await self._update_filters_from_db() await self._update_filters_from_db()
async def update_tls_config(self):
async with self.lock:
srv_dict = self.db.query("SELECT * FROM services WHERE service_id = ?;", self.srv.id)[0]
if srv_dict.get("tls_enabled"):
cert, key = CertsDB().get_cert_and_key(srv_dict["ip_int"], srv_dict["port"])
srv_dict["tls_cert"] = cert
srv_dict["tls_key"] = key
self.srv = Service.from_dict(srv_dict)
if self.status == STATUS.ACTIVE:
await self.restart()
sync_nginx_state()
class FirewallManager: class FirewallManager:
def __init__(self, db:SQLite, outstream_func=None, exception_func=None): def __init__(self, db:SQLite, outstream_func=None, exception_func=None):
@ -116,13 +102,16 @@ class FirewallManager:
async def close(self): async def close(self):
for key in list(self.service_table.keys()): for key in list(self.service_table.keys()):
await self.remove(key) try:
sync_nginx_state() await self.remove(key, persist=False)
except Exception:
# Don't let one broken service block shutdown of the others
self.service_table.pop(key, None)
async def remove(self,srv_id): async def remove(self,srv_id,persist:bool=True):
async with self.lock: async with self.lock:
if srv_id in self.service_table: if srv_id in self.service_table:
await self.service_table[srv_id].next(STATUS.STOP) await self.service_table[srv_id].next(STATUS.STOP,persist=persist)
del self.service_table[srv_id] del self.service_table[srv_id]
async def init(self): async def init(self):
@ -132,20 +121,13 @@ class FirewallManager:
async def reload(self): async def reload(self):
async with self.lock: async with self.lock:
services = self.db.query('SELECT * FROM services;') 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: for srv in services:
if srv["service_id"] in self.service_table: if srv["service_id"] in self.service_table:
continue continue
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) 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) 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) await self.service_table[srv_obj.id].next(srv_obj.status)
sync_nginx_state()
def get(self,srv_id) -> ServiceManager: def get(self,srv_id) -> ServiceManager:
if srv_id in self.service_table: if srv_id in self.service_table:

View File

@ -3,12 +3,13 @@ class Service:
self, self,
service_id: str, service_id: str,
status: str, status: str,
port: int, port: int | None,
name: str, name: str,
proto: str, proto: str,
ip_int: str, ip_int: str | None,
fail_open: bool, fail_open: bool,
tls_enabled: bool = False, target_type: str = "flow",
tls_stream_id: str | None = None,
tls_cert: str | None = None, tls_cert: str | None = None,
tls_key: str | None = None, tls_key: str | None = None,
**other, **other,
@ -20,7 +21,8 @@ class Service:
self.proto = proto self.proto = proto
self.ip_int = ip_int self.ip_int = ip_int
self.fail_open = fail_open self.fail_open = fail_open
self.tls_enabled = bool(tls_enabled) self.target_type = target_type
self.tls_stream_id = tls_stream_id
self.tls_cert = tls_cert self.tls_cert = tls_cert
self.tls_key = tls_key self.tls_key = tls_key

View File

@ -1,6 +1,6 @@
from modules.nfproxy.models import Service from modules.nfproxy.models import Service
from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json
from modules.nfproxy.nginx import get_tls_ports from modules.tls.manager import get_tls_ports, TLSManager
def convert_protocol_to_l4(proto:str): def convert_protocol_to_l4(proto:str):
if proto == "tcp": if proto == "tcp":
@ -10,6 +10,20 @@ def convert_protocol_to_l4(proto:str):
else: else:
raise Exception("Invalid protocol") raise Exception("Invalid protocol")
def resolve_target(srv: Service) -> tuple[str, int] | None:
"""Returns the (ip_int, port) a service's intercept rule should actually match.
For target_type=='tls' this is the loopback clear_port where nginx exposes the
decrypted plaintext, not the service's own (real backend) ip_int/port. Returns
None if the linked TLS stream can't be resolved (e.g. it was deleted)."""
if srv.target_type == "tls":
tls_stream = TLSManager().get_stream(srv.tls_stream_id)
if not tls_stream:
return None
_, clear_port = get_tls_ports(tls_stream["ip_int"], tls_stream["port"])
loopback_ip = "::1" if ip_family(tls_stream["ip_int"]) == "ip6" else "127.0.0.1"
return loopback_ip, clear_port
return srv.ip_int, srv.port
class FiregexFilter: class FiregexFilter:
def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int): def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int):
self.id = id self.id = id
@ -19,18 +33,20 @@ class FiregexFilter:
self.ip_int = str(ip_int) self.ip_int = str(ip_int)
def __eq__(self, o: object) -> bool: def __eq__(self, o: object) -> bool:
if isinstance(o, FiregexFilter) or isinstance(o, Service): if isinstance(o, FiregexFilter):
return self.port == o.port and self.proto == convert_protocol_to_l4(o.proto) and ip_parse(self.ip_int) == ip_parse(o.ip_int) return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int)
if isinstance(o, Service):
return self.matches(o)
return False return False
def matches(self, srv: Service) -> bool: def matches(self, srv: Service) -> bool:
if self.port == srv.port and ip_parse(self.ip_int) == ip_parse(srv.ip_int): if self.proto != convert_protocol_to_l4(srv.proto):
return True return False
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port) target = resolve_target(srv)
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1" if target is None:
if ip_parse(self.ip_int) == ip_parse(loopback_ip) and self.port in (ssl_port, clear_port): return False
return True target_ip, target_port = target
return False return self.port == target_port and ip_parse(self.ip_int) == ip_parse(target_ip)
class FiregexTables(NFTableManager): class FiregexTables(NFTableManager):
input_chain = "nfproxy_input" input_chain = "nfproxy_input"
@ -56,23 +72,12 @@ class FiregexTables(NFTableManager):
"hook":"postrouting", "hook":"postrouting",
"prio":108, "prio":108,
"policy":"accept" "policy":"accept"
}}},
{"add":{"chain":{ #NAT chain for DNAT
"family":"inet",
"table":self.table_name,
"name":self.nat_chain,
"type":"nat",
"hook":"prerouting",
"prio":-100,
"policy":"accept"
}}} }}}
],[ ],[
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
]) ])
def add(self, srv:Service, queue_range): def add(self, srv:Service, queue_range):
@ -81,78 +86,44 @@ class FiregexTables(NFTableManager):
if ele.matches(srv): if ele.matches(srv):
return return
target = resolve_target(srv)
if target is None:
return
target_ip, target_port = target
init, end = queue_range init, end = queue_range
if init > end: if init > end:
init, end = end, init init, end = end, init
if srv.tls_enabled: self.cmd(
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port) { "insert":{ "rule": { # Send non-TLS outbound traffic to NFQUEUE (Client -> Server)
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1" "family": "inet",
self.cmd( "table": self.table_name,
{ "insert":{ "rule": { # Redirect TLS traffic to local Nginx SSL proxy "chain": self.output_chain,
"family": "inet", "expr": [
"table": self.table_name, {'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}},
"chain": self.nat_chain, {'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "dport"}}, "op": "==", "right": int(target_port)}},
"expr": [ {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}},
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
{'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 non-TLS inbound traffic to NFQUEUE (Server -> Client)
"family": "inet",
"table": self.table_name,
"chain": self.input_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}},
{'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "sport"}}, "op": "==", "right": int(target_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 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]: def get(self) -> list[FiregexFilter]:
res = [] res = []
for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain,self.nat_chain]): for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]):
try: try:
ip_int = None ip_int = None
if isinstance(filter["expr"][0]["match"]["right"],str): if isinstance(filter["expr"][0]["match"]["right"],str):

View File

@ -3,8 +3,6 @@ from modules.nfregex.firegex import FiregexInterceptor, RegexFilter
from modules.nfregex.nftables import FiregexTables, FiregexFilter from modules.nfregex.nftables import FiregexTables, FiregexFilter
from modules.nfregex.models import Regex, Service from modules.nfregex.models import Regex, Service
from utils.sqlite import SQLite from utils.sqlite import SQLite
from modules.nfproxy.nginx import sync_nginx_state
from utils.certs import CertsDB, ip_parse as certs_ip_parse
class STATUS: class STATUS:
STOP = "stop" STOP = "stop"
@ -45,19 +43,20 @@ class ServiceManager:
def __update_status_db(self, status): def __update_status_db(self, status):
self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id) self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id)
async def next(self,to): async def next(self,to,persist:bool=True):
async with self.lock: async with self.lock:
if to == STATUS.STOP: if to == STATUS.STOP:
await self.stop() await self.stop(persist=persist)
if to == STATUS.ACTIVE: if to == STATUS.ACTIVE:
await self.restart() await self.restart()
def _stats_updater(self,filter:RegexFilter): def _stats_updater(self,filter:RegexFilter):
self.db.query("UPDATE regexes SET blocked_packets = ? WHERE regex_id = ?;", filter.blocked, filter.id) self.db.query("UPDATE regexes SET blocked_packets = ? WHERE regex_id = ?;", filter.blocked, filter.id)
def _set_status(self,status): def _set_status(self,status,persist:bool=True):
self.status = status self.status = status
self.__update_status_db(status) if persist:
self.__update_status_db(status)
async def start(self): async def start(self):
if not self.interceptor: if not self.interceptor:
@ -65,17 +64,13 @@ class ServiceManager:
self.interceptor = await FiregexInterceptor.start(self.srv) self.interceptor = await FiregexInterceptor.start(self.srv)
await self._update_filters_from_db() await self._update_filters_from_db()
self._set_status(STATUS.ACTIVE) self._set_status(STATUS.ACTIVE)
if self.srv.tls_enabled:
sync_nginx_state()
async def stop(self): async def stop(self,persist:bool=True):
nft.delete(self.srv) nft.delete(self.srv)
if self.interceptor: if self.interceptor:
await self.interceptor.stop() await self.interceptor.stop()
self.interceptor = None self.interceptor = None
self._set_status(STATUS.STOP) self._set_status(STATUS.STOP,persist=persist)
if self.srv.tls_enabled:
sync_nginx_state()
async def restart(self): async def restart(self):
await self.stop() await self.stop()
@ -85,17 +80,7 @@ class ServiceManager:
async with self.lock: async with self.lock:
await self._update_filters_from_db() await self._update_filters_from_db()
async def update_tls_config(self):
async with self.lock:
srv_dict = self.db.query("SELECT * FROM services WHERE service_id = ?;", self.srv.id)[0]
if srv_dict.get("tls_enabled"):
cert, key = CertsDB().get_cert_and_key(srv_dict["ip_int"], srv_dict["port"])
srv_dict["tls_cert"] = cert
srv_dict["tls_key"] = key
self.srv = Service.from_dict(srv_dict)
if self.status == STATUS.ACTIVE:
await self.restart()
sync_nginx_state()
class FirewallManager: class FirewallManager:
def __init__(self, db:SQLite): def __init__(self, db:SQLite):
@ -105,13 +90,16 @@ class FirewallManager:
async def close(self): async def close(self):
for key in list(self.service_table.keys()): for key in list(self.service_table.keys()):
await self.remove(key) try:
sync_nginx_state() await self.remove(key, persist=False)
except Exception:
# Don't let one broken service block shutdown of the others
self.service_table.pop(key, None)
async def remove(self,srv_id): async def remove(self,srv_id,persist:bool=True):
async with self.lock: async with self.lock:
if srv_id in self.service_table: if srv_id in self.service_table:
await self.service_table[srv_id].next(STATUS.STOP) await self.service_table[srv_id].next(STATUS.STOP,persist=persist)
del self.service_table[srv_id] del self.service_table[srv_id]
async def init(self): async def init(self):
@ -121,20 +109,13 @@ class FirewallManager:
async def reload(self): async def reload(self):
async with self.lock: async with self.lock:
services = self.db.query('SELECT * FROM services;') 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: for srv in services:
if srv["service_id"] in self.service_table: if srv["service_id"] in self.service_table:
continue continue
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) srv_obj = Service.from_dict(srv)
self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db) self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db)
await self.service_table[srv_obj.id].next(srv_obj.status) await self.service_table[srv_obj.id].next(srv_obj.status)
sync_nginx_state()
def get(self,srv_id) -> ServiceManager: def get(self,srv_id) -> ServiceManager:
if srv_id in self.service_table: if srv_id in self.service_table:

View File

@ -1,7 +1,7 @@
import base64 import base64
class Service: class Service:
def __init__(self, service_id: str, status: str, port: int, name: str, proto: str, ip_int: str, fail_open: bool, tls_enabled: bool = False, tls_cert: str | None = None, tls_key: str | None = None, **other): def __init__(self, service_id: str, status: str, port: int | None, name: str, proto: str, ip_int: str | None, fail_open: bool, target_type: str = "flow", tls_stream_id: str | None = None, tls_cert: str | None = None, tls_key: str | None = None, **other):
self.id = service_id self.id = service_id
self.status = status self.status = status
self.port = port self.port = port
@ -9,7 +9,8 @@ class Service:
self.proto = proto self.proto = proto
self.ip_int = ip_int self.ip_int = ip_int
self.fail_open = fail_open self.fail_open = fail_open
self.tls_enabled = bool(tls_enabled) self.target_type = target_type
self.tls_stream_id = tls_stream_id
self.tls_cert = tls_cert self.tls_cert = tls_cert
self.tls_key = tls_key self.tls_key = tls_key

View File

@ -1,6 +1,20 @@
from modules.nfregex.models import Service from modules.nfregex.models import Service
from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json
from modules.nfproxy.nginx import get_tls_ports from modules.tls.manager import get_tls_ports, TLSManager
def resolve_target(srv: Service) -> tuple[str, int] | None:
"""Returns the (ip_int, port) a service's intercept rule should actually match.
For target_type=='tls' this is the loopback clear_port where nginx exposes the
decrypted plaintext, not the service's own (real backend) ip_int/port. Returns
None if the linked TLS stream can't be resolved (e.g. it was deleted)."""
if srv.target_type == "tls":
tls_stream = TLSManager().get_stream(srv.tls_stream_id)
if not tls_stream:
return None
_, clear_port = get_tls_ports(tls_stream["ip_int"], tls_stream["port"])
loopback_ip = "::1" if ip_family(tls_stream["ip_int"]) == "ip6" else "127.0.0.1"
return loopback_ip, clear_port
return srv.ip_int, srv.port
class FiregexFilter: class FiregexFilter:
def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int): def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int):
@ -11,18 +25,20 @@ class FiregexFilter:
self.ip_int = str(ip_int) self.ip_int = str(ip_int)
def __eq__(self, o: object) -> bool: def __eq__(self, o: object) -> bool:
if isinstance(o, FiregexFilter) or isinstance(o, Service): if isinstance(o, FiregexFilter):
return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int) return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int)
if isinstance(o, Service):
return self.matches(o)
return False return False
def matches(self, srv: Service) -> bool: def matches(self, srv: Service) -> bool:
if self.port == srv.port and ip_parse(self.ip_int) == ip_parse(srv.ip_int): if self.proto != str(srv.proto):
return True return False
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port) target = resolve_target(srv)
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1" if target is None:
if ip_parse(self.ip_int) == ip_parse(loopback_ip) and self.port in (ssl_port, clear_port): return False
return True target_ip, target_port = target
return False return self.port == target_port and ip_parse(self.ip_int) == ip_parse(target_ip)
class FiregexTables(NFTableManager): class FiregexTables(NFTableManager):
input_chain = "nfregex_input" input_chain = "nfregex_input"
@ -48,23 +64,12 @@ class FiregexTables(NFTableManager):
"hook":"postrouting", "hook":"postrouting",
"prio":107, "prio":107,
"policy":"accept" "policy":"accept"
}}},
{"add":{"chain":{
"family":"inet",
"table":self.table_name,
"name":self.nat_chain,
"type":"nat",
"hook":"prerouting",
"prio":-100,
"policy":"accept"
}}} }}}
],[ ],[
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
]) ])
def add(self, srv:Service, queue_range): def add(self, srv:Service, queue_range):
@ -73,78 +78,44 @@ class FiregexTables(NFTableManager):
if ele.matches(srv): if ele.matches(srv):
return return
target = resolve_target(srv)
if target is None:
return
target_ip, target_port = target
init, end = queue_range init, end = queue_range
if init > end: if init > end:
init, end = end, init init, end = end, init
if srv.tls_enabled: self.cmd(
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port) { "insert":{ "rule": {
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1" "family": "inet",
self.cmd( "table": self.table_name,
{ "insert":{ "rule": { # Redirect TLS traffic to local Nginx SSL proxy "chain": self.output_chain,
"family": "inet", "expr": [
"table": self.table_name, {'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}},
"chain": self.nat_chain, {'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "sport"}}, "op": "==", "right": int(target_port)}},
"expr": [ {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}},
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
{'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":{
"family": "inet",
"table": self.table_name,
"chain": self.input_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}},
{'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "dport"}}, "op": "==", "right": int(target_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 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]: def get(self) -> list[FiregexFilter]:
res = [] res = []
for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain,self.nat_chain]): for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]):
try: try:
ip_int = None ip_int = None
if isinstance(filter["expr"][0]["match"]["right"],str): if isinstance(filter["expr"][0]["match"]["right"],str):

View File

@ -22,20 +22,21 @@ class ServiceManager:
nft.add(self.srv) nft.add(self.srv)
self._set_status(True) self._set_status(True)
async def disable(self): async def disable(self,persist:bool=True):
if self.active: if self.active:
async with self.lock: async with self.lock:
nft.delete(self.srv) nft.delete(self.srv)
self._set_status(False) self._set_status(False,persist=persist)
async def refresh(self, srv:Service): async def refresh(self, srv:Service):
self.srv = srv self.srv = srv
if self.active: if self.active:
await self.restart() await self.restart()
def _set_status(self,active): def _set_status(self,active,persist:bool=True):
self.active = active self.active = active
self.db.query("UPDATE services SET active = ? WHERE service_id = ?;", active, self.srv.service_id) if persist:
self.db.query("UPDATE services SET active = ? WHERE service_id = ?;", active, self.srv.service_id)
async def restart(self): async def restart(self):
await self.disable() await self.disable()
@ -49,12 +50,16 @@ class FirewallManager:
async def close(self): async def close(self):
for key in list(self.service_table.keys()): for key in list(self.service_table.keys()):
await self.remove(key) try:
await self.remove(key, persist=False)
except Exception:
# Don't let one broken service block shutdown of the others
self.service_table.pop(key, None)
async def remove(self,srv_id): async def remove(self,srv_id,persist:bool=True):
async with self.lock: async with self.lock:
if srv_id in self.service_table: if srv_id in self.service_table:
await self.service_table[srv_id].disable() await self.service_table[srv_id].disable(persist=persist)
del self.service_table[srv_id] del self.service_table[srv_id]
async def init(self): async def init(self):

View File

@ -0,0 +1 @@
# TLS Decrypt Module

View File

@ -0,0 +1,92 @@
import hashlib
from ipaddress import ip_interface
from utils.sqlite import SQLite
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
TLS_SCHEMA = {
'tls_streams': {
'id': 'VARCHAR(100) PRIMARY KEY',
'name': 'VARCHAR(100) NOT NULL',
'ip_int': 'VARCHAR(100) NOT NULL',
'port': 'INT NOT NULL',
'cert': 'TEXT NOT NULL',
'key': 'TEXT NOT NULL',
'status': 'VARCHAR(50) NOT NULL', # 'active' or 'stop'
'ssl_port': 'INT NOT NULL',
'clear_port': 'INT NOT NULL',
'UNIQUE': '(ip_int, port)'
}
}
class TLSManager:
def __init__(self, db_path='db/nft-tls.db'):
self.db = SQLite(db_path, schema=TLS_SCHEMA)
self.db.init()
def add_stream(self, stream_id: str, name: str, ip_int: str, port: int, cert: str, key: str, status: str = 'stop') -> None:
ip_int = ip_parse(ip_int)
ssl_port, clear_port = get_tls_ports(ip_int, port)
self.db.query(
"INSERT INTO tls_streams (id, name, ip_int, port, cert, key, status, ssl_port, clear_port) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
stream_id, name, ip_int, port, cert, key, status, ssl_port, clear_port
)
def get_streams(self) -> list[dict]:
return self.db.query("SELECT * FROM tls_streams;")
def get_stream(self, stream_id: str) -> dict | None:
res = self.db.query("SELECT * FROM tls_streams WHERE id = ?;", stream_id)
return res[0] if res else None
def get_stream_by_ip_port(self, ip_int: str, port: int) -> dict | None:
ip_int = ip_parse(ip_int)
res = self.db.query("SELECT * FROM tls_streams WHERE ip_int = ? AND port = ?;", ip_int, port)
return res[0] if res else None
def update_status(self, stream_id: str, status: str) -> None:
self.db.query("UPDATE tls_streams SET status = ? WHERE id = ?;", status, stream_id)
def update_stream(self, stream_id: str, name: str | None = None, ip_int: str | None = None, port: int | None = None, cert: str | None = None, key: str | None = None) -> None:
stream = self.get_stream(stream_id)
if not stream:
return
fields: dict = {}
if name is not None:
fields["name"] = name
if cert is not None:
fields["cert"] = cert
if key is not None:
fields["key"] = key
new_ip = ip_parse(ip_int) if ip_int is not None else stream["ip_int"]
new_port = port if port is not None else stream["port"]
if ip_int is not None:
fields["ip_int"] = new_ip
if port is not None:
fields["port"] = new_port
if ip_int is not None or port is not None:
ssl_port, clear_port = get_tls_ports(new_ip, new_port)
fields["ssl_port"] = ssl_port
fields["clear_port"] = clear_port
if not fields:
return
set_clause = ", ".join(f"{k} = ?" for k in fields)
self.db.query(f"UPDATE tls_streams SET {set_clause} WHERE id = ?;", *fields.values(), stream_id)
def delete_stream(self, stream_id: str) -> None:
self.db.query("DELETE FROM tls_streams WHERE id = ?;", stream_id)
def get_active_streams(self) -> list[dict]:
return self.db.query("SELECT * FROM tls_streams WHERE status = 'active';")

View File

@ -0,0 +1,43 @@
from utils import NFTableManager, nftables_int_to_json, ip_family
from modules.tls.manager import TLSManager
class TLSTables(NFTableManager):
nat_chain = "tls_nat"
def __init__(self):
super().__init__([
{"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.nat_chain}}},
])
def reload(self):
self.init()
manager = TLSManager()
streams = manager.get_active_streams()
cmds = []
for srv in streams:
loopback_ip = "::1" if ip_family(srv["ip_int"]) == "ip6" else "127.0.0.1"
cmds.append({"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': 'tcp', '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(srv["ssl_port"])}}
]
}}})
if cmds:
self.cmd(*cmds)
tls_firewall = TLSTables()

View File

@ -1,25 +1,17 @@
import subprocess import subprocess
import os import os
import glob import glob
from utils.sqlite import SQLite from modules.tls.manager import TLSManager
from utils.certs import CertsDB, get_tls_ports, ip_parse as certs_ip_parse
NGINX_CONF_PATH = "/tmp/firegex_nginx.conf" NGINX_CONF_PATH = "/tmp/firegex_nginx.conf"
NGINX_PID_PATH = "/tmp/firegex_nginx.pid" NGINX_PID_PATH = "/tmp/firegex_nginx.pid"
NGINX_LOG_PATH = "/tmp/firegex_nginx_error.log" NGINX_LOG_PATH = "/tmp/firegex_nginx_error.log"
def _get_active_tls_services(db_name: str) -> list[dict]: def update_nginx() -> None:
db = SQLite(db_name) manager = TLSManager()
db.connect() streams = manager.get_active_streams()
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 streams:
if not services:
subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-s", "stop", "-e", NGINX_LOG_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 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 # Clean up certificates, configuration, and pid
@ -32,17 +24,18 @@ def update_nginx(services: list[dict]) -> None:
return return
servers_config = [] servers_config = []
for srv in services: for srv in streams:
srv_id = srv["service_id"] srv_id = srv["id"]
cert_path = f"/tmp/firegex_cert_{srv_id}.crt" cert_path = f"/tmp/firegex_cert_{srv_id}.crt"
key_path = f"/tmp/firegex_key_{srv_id}.key" key_path = f"/tmp/firegex_key_{srv_id}.key"
with open(cert_path, "w") as f: with open(cert_path, "w") as f:
f.write(srv.get("tls_cert") or "") f.write(srv.get("cert") or "")
with open(key_path, "w") as f: with open(key_path, "w") as f:
f.write(srv.get("tls_key") or "") f.write(srv.get("key") or "")
ssl_port, clear_port = get_tls_ports(srv["ip_int"], srv["port"]) ssl_port = srv["ssl_port"]
clear_port = srv["clear_port"]
ip_addr = srv["ip_int"].split("/")[0] ip_addr = srv["ip_int"].split("/")[0]
is_ipv6 = ":" in ip_addr is_ipv6 = ":" in ip_addr
@ -88,16 +81,3 @@ def update_nginx(services: list[dict]) -> None:
res = subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-s", "reload", "-e", NGINX_LOG_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) res = subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-s", "reload", "-e", NGINX_LOG_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if res.returncode != 0: if res.returncode != 0:
subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-e", NGINX_LOG_PATH], check=True) subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-e", NGINX_LOG_PATH], check=True)
def sync_nginx_state() -> None:
services = _get_active_tls_services('db/nft-pyfilters.db') + _get_active_tls_services('db/nft-regex.db')
# Batch-resolve certificates using CertsDB
certs_map = CertsDB().get_multiple_certs_and_keys(services)
for srv in services:
cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None))
srv["tls_cert"] = cert
srv["tls_key"] = key
update_nginx(services)

View File

@ -0,0 +1,22 @@
from modules.tls.manager import TLSManager
from modules.tls.nginx import update_nginx
from modules.tls.nftables import tls_firewall
from utils import socketio_emit
async def reload_tls():
update_nginx()
tls_firewall.reload()
await socketio_emit(["tls_streams"])
async def activate_stream(stream_id: str | None) -> bool:
"""Activate a TLS stream if it exists and isn't already active. Returns False if the stream doesn't exist."""
if not stream_id:
return False
manager = TLSManager()
stream = manager.get_stream(stream_id)
if not stream:
return False
if stream["status"] != "active":
manager.update_status(stream_id, "active")
await reload_tls()
return True

View File

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

View File

@ -50,12 +50,9 @@ async def startup():
await firewall.init() await firewall.init()
async def shutdown(): async def shutdown():
keep_rules = firewall.keep_rules if not firewall.keep_rules:
db.backup()
if not keep_rules:
await firewall.close() await firewall.close()
db.disconnect() db.disconnect()
db.restore()
async def refresh_frontend(additional:list[str]=[]): async def refresh_frontend(additional:list[str]=[]):
await socketio_emit(["firewall"]+additional) await socketio_emit(["firewall"]+additional)

View File

@ -6,8 +6,8 @@ from modules.nfproxy.nftables import FiregexTables
from modules.nfproxy.firewall import STATUS, FirewallManager from modules.nfproxy.firewall import STATUS, FirewallManager
from utils.sqlite import SQLite from utils.sqlite import SQLite
from utils import ip_parse, refactor_name, socketio_emit, PortType from utils import ip_parse, refactor_name, socketio_emit, PortType
from utils.certs import CertsDB, populate_services_tls_config from modules.tls.service import activate_stream
from utils.models import ResetRequest, StatusMessageModel, TLSConfigForm from utils.models import ResetRequest, StatusMessageModel
import os import os
from firegex.nfproxy.internals import get_filter_names from firegex.nfproxy.internals import get_filter_names
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
@ -20,27 +20,27 @@ import utils
class ServiceModel(BaseModel): class ServiceModel(BaseModel):
service_id: str service_id: str
status: str status: str
port: PortType port: PortType|None = None
name: str name: str
proto: str proto: str
ip_int: str ip_int: str|None = None
n_filters: int n_filters: int
edited_packets: int edited_packets: int
blocked_packets: int blocked_packets: int
fail_open: bool fail_open: bool
tls_enabled: bool target_type: str
tls_cert: str|None = None tls_stream_id: str|None = None
tls_key: str|None = None
ssl_port: int|None = None
clear_port: int|None = None
class RenameForm(BaseModel): class RenameForm(BaseModel):
name:str name:str
class SettingsForm(BaseModel): class SettingsForm(BaseModel):
port: PortType|None = None port: PortType|None = None
proto: str|None = None
ip_int: str|None = None ip_int: str|None = None
fail_open: bool|None = None fail_open: bool|None = None
target_type: str|None = None
tls_stream_id: str|None = None
class PyFilterModel(BaseModel): class PyFilterModel(BaseModel):
name: str name: str
@ -51,13 +51,12 @@ class PyFilterModel(BaseModel):
class ServiceAddForm(BaseModel): class ServiceAddForm(BaseModel):
name: str name: str
port: PortType port: PortType|None = None
proto: str proto: str
ip_int: str ip_int: str|None = None
fail_open: bool = True fail_open: bool = True
tls_enabled: bool = False target_type: str = "flow"
tls_cert: str | None = None tls_stream_id: str|None = None
tls_key: str | None = None
class ServiceAddResponse(BaseModel): class ServiceAddResponse(BaseModel):
status:str status:str
@ -72,13 +71,14 @@ db = SQLite('db/nft-pyfilters.db', {
'services': { 'services': {
'service_id': 'VARCHAR(100) PRIMARY KEY', 'service_id': 'VARCHAR(100) PRIMARY KEY',
'status': 'VARCHAR(100) NOT NULL', 'status': 'VARCHAR(100) NOT NULL',
'port': 'INT NOT NULL CHECK(port > 0 and port < 65536)', 'target_type': 'VARCHAR(10) NOT NULL CHECK(target_type IN ("flow", "tls")) DEFAULT "flow"',
'tls_stream_id': 'VARCHAR(100)',
'port': 'INT CHECK(port > 0 and port < 65536)',
'name': 'VARCHAR(100) NOT NULL UNIQUE', 'name': 'VARCHAR(100) NOT NULL UNIQUE',
'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "http"))', 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "http"))',
'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_proto IN ("tcp", "udp"))', 'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_proto IN ("tcp", "udp"))',
'ip_int': 'VARCHAR(100) NOT NULL', 'ip_int': 'VARCHAR(100)',
'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1', 'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1',
'tls_enabled': 'BOOLEAN NOT NULL CHECK (tls_enabled IN (0, 1)) DEFAULT 0',
}, },
'pyfilter': { 'pyfilter': {
'name': 'VARCHAR(100) NOT NULL', 'name': 'VARCHAR(100) NOT NULL',
@ -125,10 +125,8 @@ async def startup():
utils.socketio.on("nfproxy-exception-leave", leave_exception) utils.socketio.on("nfproxy-exception-leave", leave_exception)
async def shutdown(): async def shutdown():
db.backup()
await firewall.close() await firewall.close()
db.disconnect() db.disconnect()
db.restore()
def gen_service_id(): def gen_service_id():
while True: while True:
@ -157,14 +155,15 @@ async def get_service_list():
s.proto proto, s.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.tls_enabled tls_enabled, s.target_type target_type,
s.tls_stream_id tls_stream_id,
COUNT(f.name) n_filters, COUNT(f.name) n_filters,
COALESCE(SUM(f.blocked_packets),0) blocked_packets, COALESCE(SUM(f.blocked_packets),0) blocked_packets,
COALESCE(SUM(f.edited_packets),0) edited_packets COALESCE(SUM(f.edited_packets),0) edited_packets
FROM services s LEFT JOIN pyfilter f ON s.service_id = f.service_id FROM services s LEFT JOIN pyfilter f ON s.service_id = f.service_id
GROUP BY s.service_id; GROUP BY s.service_id;
""") """)
return populate_services_tls_config(res) return res
@app.get('/services/{service_id}', response_model=ServiceModel) @app.get('/services/{service_id}', response_model=ServiceModel)
async def get_service_by_id(service_id: str): async def get_service_by_id(service_id: str):
@ -178,7 +177,8 @@ async def get_service_by_id(service_id: str):
s.proto proto, s.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.tls_enabled tls_enabled, s.target_type target_type,
s.tls_stream_id tls_stream_id,
COUNT(f.name) n_filters, COUNT(f.name) n_filters,
COALESCE(SUM(f.blocked_packets),0) blocked_packets, COALESCE(SUM(f.blocked_packets),0) blocked_packets,
COALESCE(SUM(f.edited_packets),0) edited_packets COALESCE(SUM(f.edited_packets),0) edited_packets
@ -187,7 +187,7 @@ async def get_service_by_id(service_id: str):
""", service_id) """, service_id)
if len(res) == 0: if len(res) == 0:
raise HTTPException(status_code=400, detail="This service does not exists!") raise HTTPException(status_code=400, detail="This service does not exists!")
return populate_services_tls_config(res)[0] return res[0]
@app.post('/services/{service_id}/stop', response_model=StatusMessageModel) @app.post('/services/{service_id}/stop', response_model=StatusMessageModel)
async def service_stop(service_id: str): async def service_stop(service_id: str):
@ -199,6 +199,10 @@ async def service_stop(service_id: str):
@app.post('/services/{service_id}/start', response_model=StatusMessageModel) @app.post('/services/{service_id}/start', response_model=StatusMessageModel)
async def service_start(service_id: str): async def service_start(service_id: str):
"""Request the start of a specific service""" """Request the start of a specific service"""
srv = db.query("SELECT target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id)
if srv and srv[0]["target_type"] == "tls":
if not await activate_stream(srv[0]["tls_stream_id"]):
raise HTTPException(status_code=400, detail="Linked TLS stream not found")
await firewall.get(service_id).next(STATUS.ACTIVE) await firewall.get(service_id).next(STATUS.ACTIVE)
await refresh_frontend() await refresh_frontend()
return {'status': 'ok'} return {'status': 'ok'}
@ -206,9 +210,6 @@ async def service_start(service_id: str):
@app.delete('/services/{service_id}', response_model=StatusMessageModel) @app.delete('/services/{service_id}', response_model=StatusMessageModel)
async def service_delete(service_id: str): async def service_delete(service_id: str):
"""Request the deletion of a specific service""" """Request the deletion of a specific service"""
srv_res = db.query('SELECT ip_int, port FROM services WHERE service_id = ?;', service_id)
if srv_res:
CertsDB().delete_cert_and_key(srv_res[0]["ip_int"], srv_res[0]["port"])
db.query('DELETE FROM services WHERE service_id = ?;', service_id) db.query('DELETE FROM services WHERE service_id = ?;', service_id)
db.query('DELETE FROM pyfilter WHERE service_id = ?;', service_id) db.query('DELETE FROM pyfilter WHERE service_id = ?;', service_id)
if os.path.exists(f"db/nfproxy_filters/{service_id}.py"): if os.path.exists(f"db/nfproxy_filters/{service_id}.py"):
@ -234,7 +235,7 @@ async def service_rename(service_id: str, form: RenameForm):
async def service_settings(service_id: str, form: SettingsForm): async def service_settings(service_id: str, form: SettingsForm):
"""Request to change the settings of a specific service (will cause a restart)""" """Request to change the settings of a specific service (will cause a restart)"""
srv_check = db.query("SELECT ip_int, port, tls_enabled FROM services WHERE service_id = ?;", service_id) srv_check = db.query("SELECT ip_int, port, target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id)
if len(srv_check) == 0: if len(srv_check) == 0:
raise HTTPException(status_code=404, detail="Service not found") raise HTTPException(status_code=404, detail="Service not found")
old_srv = srv_check[0] old_srv = srv_check[0]
@ -251,63 +252,42 @@ async def service_settings(service_id: str, form: SettingsForm):
new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"] 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"] new_port = form.port if form.port is not None else old_srv["port"]
new_target_type = form.target_type if form.target_type is not None else old_srv["target_type"]
new_tls_stream_id = form.tls_stream_id if form.tls_stream_id is not None else old_srv["tls_stream_id"]
if new_target_type == "tls" and not new_tls_stream_id:
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
if new_target_type == "flow" and (new_ip is None or new_port is None):
raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
keys = [] keys = []
values = [] values = []
for key, value in form.model_dump(exclude_none=True).items(): for key, value in form.model_dump(exclude_none=True).items():
keys.append(key) keys.append(key)
values.append(value) values.append(value)
if key == "proto":
keys.append("l4_proto")
values.append(convert_protocol_to_l4(value))
if len(keys) == 0: if len(keys) == 0:
raise HTTPException(status_code=400, detail="No settings to change provided") raise HTTPException(status_code=400, detail="No settings to change provided")
if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]):
cert, key = CertsDB().get_cert_and_key(old_srv["ip_int"], old_srv["port"])
if cert and key:
CertsDB().upsert_cert_and_key(new_ip, new_port, cert, key)
CertsDB().delete_cert_and_key(old_srv["ip_int"], old_srv["port"])
try: try:
db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id)
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
# If migration was performed, rollback CertsDB change (upsert old, delete new)
if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]):
cert, key = CertsDB().get_cert_and_key(new_ip, new_port)
if cert and key:
CertsDB().upsert_cert_and_key(old_srv["ip_int"], old_srv["port"], cert, key)
CertsDB().delete_cert_and_key(new_ip, new_port)
raise HTTPException(status_code=400, detail="A service with these settings already exists") raise HTTPException(status_code=400, detail="A service with these settings already exists")
old_status = firewall.get(service_id).status old_status = firewall.get(service_id).status
await firewall.remove(service_id) await firewall.remove(service_id)
if old_status == STATUS.ACTIVE and new_target_type == "tls":
await activate_stream(new_tls_stream_id)
await firewall.reload() await firewall.reload()
await firewall.get(service_id).next(old_status) await firewall.get(service_id).next(old_status)
await refresh_frontend() await refresh_frontend()
return {'status': 'ok'} return {'status': 'ok'}
@app.put('/services/{service_id}/tls-config', response_model=StatusMessageModel)
async def update_service_tls(service_id: str, form: TLSConfigForm):
"""Update TLS configuration for a service"""
srv_res = db.query("SELECT ip_int, port FROM services WHERE service_id = ?;", service_id)
if len(srv_res) == 0:
raise HTTPException(status_code=404, detail="Service not found")
srv = srv_res[0]
if form.tls_enabled:
if not form.tls_cert or not form.tls_key:
raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled")
CertsDB().upsert_cert_and_key(srv["ip_int"], srv["port"], form.tls_cert, form.tls_key)
db.query("""
UPDATE services
SET tls_enabled = ?
WHERE service_id = ?;
""", int(form.tls_enabled), service_id)
await firewall.get(service_id).update_tls_config()
await refresh_frontend()
return {'status': 'ok'}
@app.get('/services/{service_id}/pyfilters', response_model=list[PyFilterModel]) @app.get('/services/{service_id}/pyfilters', response_model=list[PyFilterModel])
async def get_service_pyfilter_list(service_id: str): async def get_service_pyfilter_list(service_id: str):
@ -355,22 +335,25 @@ async def pyfilter_disable(service_id: str, filter_name: str):
@app.post('/services', response_model=ServiceAddResponse) @app.post('/services', response_model=ServiceAddResponse)
async def add_new_service(form: ServiceAddForm): async def add_new_service(form: ServiceAddForm):
"""Add a new service""" """Add a new service"""
try: if form.target_type == "flow":
form.ip_int = ip_parse(form.ip_int) if form.ip_int is None or form.port is None:
except ValueError: raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
raise HTTPException(status_code=400, detail="Invalid address") try:
form.ip_int = ip_parse(form.ip_int)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid address")
elif form.target_type == "tls":
if not form.tls_stream_id:
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
if form.proto not in ["tcp", "http"]: if form.proto not in ["tcp", "http"]:
raise HTTPException(status_code=400, detail="Invalid protocol") raise HTTPException(status_code=400, detail="Invalid protocol")
if form.tls_enabled:
if not form.tls_cert or not form.tls_key:
raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled")
CertsDB().upsert_cert_and_key(form.ip_int, form.port, form.tls_cert, form.tls_key)
srv_id = None srv_id = None
try: try:
srv_id = gen_service_id() srv_id = gen_service_id()
db.query("INSERT INTO services (service_id, name, port, status, proto, ip_int, fail_open, l4_proto, tls_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", db.query("INSERT INTO services (service_id, name, port, status, proto, ip_int, fail_open, l4_proto, target_type, tls_stream_id) 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)) srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, convert_protocol_to_l4(form.proto), form.target_type, form.tls_stream_id)
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="This type of service already exists") raise HTTPException(status_code=400, detail="This type of service already exists")
await firewall.reload() await firewall.reload()

View File

@ -8,25 +8,22 @@ from modules.nfregex.nftables import FiregexTables
from modules.nfregex.firewall import STATUS, FirewallManager from modules.nfregex.firewall import STATUS, FirewallManager
from utils.sqlite import SQLite from utils.sqlite import SQLite
from utils import ip_parse, refactor_name, socketio_emit, PortType from utils import ip_parse, refactor_name, socketio_emit, PortType
from utils.certs import CertsDB, populate_services_tls_config from modules.tls.service import activate_stream
from utils.models import ResetRequest, StatusMessageModel, TLSConfigForm from utils.models import ResetRequest, StatusMessageModel
from modules.nfregex.firegex import test_regex_validity from modules.nfregex.firegex import test_regex_validity
class ServiceModel(BaseModel): class ServiceModel(BaseModel):
status: str status: str
service_id: str service_id: str
port: PortType port: PortType|None = None
name: str name: str
proto: str proto: str
ip_int: str ip_int: str|None = None
n_regex: int n_regex: int
n_packets: int n_packets: int
fail_open: bool fail_open: bool
tls_enabled: bool = False target_type: str
tls_cert: str|None = None tls_stream_id: str|None = None
tls_key: str|None = None
ssl_port: int|None = None
clear_port: int|None = None
class RenameForm(BaseModel): class RenameForm(BaseModel):
name:str name:str
@ -36,6 +33,8 @@ class SettingsForm(BaseModel):
proto: str|None = None proto: str|None = None
ip_int: str|None = None ip_int: str|None = None
fail_open: bool|None = None fail_open: bool|None = None
target_type: str|None = None
tls_stream_id: str|None = None
class RegexModel(BaseModel): class RegexModel(BaseModel):
regex:str regex:str
@ -55,13 +54,12 @@ class RegexAddForm(BaseModel):
class ServiceAddForm(BaseModel): class ServiceAddForm(BaseModel):
name: str name: str
port: PortType port: PortType|None = None
proto: str proto: str
ip_int: str ip_int: str|None = None
fail_open: bool = False fail_open: bool = False
tls_enabled: bool = False target_type: str = "flow"
tls_cert: str | None = None tls_stream_id: str|None = None
tls_key: str | None = None
class ServiceAddResponse(BaseModel): class ServiceAddResponse(BaseModel):
status:str status:str
@ -73,12 +71,13 @@ db = SQLite('db/nft-regex.db', {
'services': { 'services': {
'service_id': 'VARCHAR(100) PRIMARY KEY', 'service_id': 'VARCHAR(100) PRIMARY KEY',
'status': 'VARCHAR(100) NOT NULL', 'status': 'VARCHAR(100) NOT NULL',
'port': 'INT NOT NULL CHECK(port > 0 and port < 65536)', 'target_type': 'VARCHAR(10) NOT NULL CHECK(target_type IN ("flow", "tls")) DEFAULT "flow"',
'tls_stream_id': 'VARCHAR(100)',
'port': 'INT CHECK(port > 0 and port < 65536)',
'name': 'VARCHAR(100) NOT NULL UNIQUE', 'name': 'VARCHAR(100) NOT NULL UNIQUE',
'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))', 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))',
'ip_int': 'VARCHAR(100) NOT NULL', 'ip_int': 'VARCHAR(100)',
'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1', 'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1',
'tls_enabled': 'BOOLEAN NOT NULL CHECK (tls_enabled IN (0, 1)) DEFAULT 0',
}, },
'regexes': { 'regexes': {
'regex': 'TEXT NOT NULL', 'regex': 'TEXT NOT NULL',
@ -123,10 +122,8 @@ async def startup():
print("WARNING cannot start firewall:", e) print("WARNING cannot start firewall:", e)
async def shutdown(): async def shutdown():
db.backup()
await firewall.close() await firewall.close()
db.disconnect() db.disconnect()
db.restore()
def gen_service_id(): def gen_service_id():
while True: while True:
@ -149,13 +146,14 @@ async def get_service_list():
s.proto proto, s.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.tls_enabled tls_enabled, s.target_type target_type,
s.tls_stream_id tls_stream_id,
COUNT(r.regex_id) n_regex, COUNT(r.regex_id) n_regex,
COALESCE(SUM(r.blocked_packets),0) n_packets COALESCE(SUM(r.blocked_packets),0) n_packets
FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id
GROUP BY s.service_id; GROUP BY s.service_id;
""") """)
return populate_services_tls_config(res) return res
@app.get('/services/{service_id}', response_model=ServiceModel) @app.get('/services/{service_id}', response_model=ServiceModel)
async def get_service_by_id(service_id: str): async def get_service_by_id(service_id: str):
@ -169,7 +167,8 @@ async def get_service_by_id(service_id: str):
s.proto proto, s.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.tls_enabled tls_enabled, s.target_type target_type,
s.tls_stream_id tls_stream_id,
COUNT(r.regex_id) n_regex, COUNT(r.regex_id) n_regex,
COALESCE(SUM(r.blocked_packets),0) n_packets COALESCE(SUM(r.blocked_packets),0) n_packets
FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id
@ -177,7 +176,7 @@ async def get_service_by_id(service_id: str):
""", service_id) """, service_id)
if len(res) == 0: if len(res) == 0:
raise HTTPException(status_code=400, detail="This service does not exists!") raise HTTPException(status_code=400, detail="This service does not exists!")
return populate_services_tls_config(res)[0] return res[0]
@app.post('/services/{service_id}/stop', response_model=StatusMessageModel) @app.post('/services/{service_id}/stop', response_model=StatusMessageModel)
async def service_stop(service_id: str): async def service_stop(service_id: str):
@ -189,6 +188,10 @@ async def service_stop(service_id: str):
@app.post('/services/{service_id}/start', response_model=StatusMessageModel) @app.post('/services/{service_id}/start', response_model=StatusMessageModel)
async def service_start(service_id: str): async def service_start(service_id: str):
"""Request the start of a specific service""" """Request the start of a specific service"""
srv = db.query("SELECT target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id)
if srv and srv[0]["target_type"] == "tls":
if not await activate_stream(srv[0]["tls_stream_id"]):
raise HTTPException(status_code=400, detail="Linked TLS stream not found")
await firewall.get(service_id).next(STATUS.ACTIVE) await firewall.get(service_id).next(STATUS.ACTIVE)
await refresh_frontend() await refresh_frontend()
return {'status': 'ok'} return {'status': 'ok'}
@ -196,9 +199,6 @@ async def service_start(service_id: str):
@app.delete('/services/{service_id}', response_model=StatusMessageModel) @app.delete('/services/{service_id}', response_model=StatusMessageModel)
async def service_delete(service_id: str): async def service_delete(service_id: str):
"""Request the deletion of a specific service""" """Request the deletion of a specific service"""
srv_res = db.query('SELECT ip_int, port FROM services WHERE service_id = ?;', service_id)
if srv_res:
CertsDB().delete_cert_and_key(srv_res[0]["ip_int"], srv_res[0]["port"])
db.query('DELETE FROM services WHERE service_id = ?;', service_id) db.query('DELETE FROM services WHERE service_id = ?;', service_id)
db.query('DELETE FROM regexes WHERE service_id = ?;', service_id) db.query('DELETE FROM regexes WHERE service_id = ?;', service_id)
await firewall.remove(service_id) await firewall.remove(service_id)
@ -222,7 +222,7 @@ async def service_rename(service_id: str, form: RenameForm):
async def service_settings(service_id: str, form: SettingsForm): async def service_settings(service_id: str, form: SettingsForm):
"""Request to change the settings of a specific service (will cause a restart)""" """Request to change the settings of a specific service (will cause a restart)"""
srv_check = db.query("SELECT proto, ip_int, port, tls_enabled FROM services WHERE service_id = ?;", service_id) srv_check = db.query("SELECT proto, ip_int, port, target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id)
if len(srv_check) == 0: if len(srv_check) == 0:
raise HTTPException(status_code=404, detail="Service not found") raise HTTPException(status_code=404, detail="Service not found")
old_srv = srv_check[0] old_srv = srv_check[0]
@ -230,11 +230,6 @@ async def service_settings(service_id: str, form: SettingsForm):
if form.proto is not None and form.proto not in ["tcp", "udp"]: if form.proto is not None and form.proto not in ["tcp", "udp"]:
raise HTTPException(status_code=400, detail="Invalid protocol") raise HTTPException(status_code=400, detail="Invalid protocol")
if old_srv["tls_enabled"]:
new_proto = form.proto if form.proto is not None else old_srv["proto"]
if new_proto != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
if form.port is not None and (form.port < 1 or form.port > 65535): if form.port is not None and (form.port < 1 or form.port > 65535):
raise HTTPException(status_code=400, detail="Invalid port") raise HTTPException(status_code=400, detail="Invalid port")
@ -246,6 +241,19 @@ async def service_settings(service_id: str, form: SettingsForm):
new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"] 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"] new_port = form.port if form.port is not None else old_srv["port"]
new_proto = form.proto if form.proto is not None else old_srv["proto"]
new_target_type = form.target_type if form.target_type is not None else old_srv["target_type"]
new_tls_stream_id = form.tls_stream_id if form.tls_stream_id is not None else old_srv["tls_stream_id"]
if new_target_type == "tls":
if new_proto != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
if not new_tls_stream_id:
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
if new_target_type == "flow" and (new_ip is None or new_port is None):
raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
keys = [] keys = []
values = [] values = []
@ -257,25 +265,15 @@ async def service_settings(service_id: str, form: SettingsForm):
if len(keys) == 0: if len(keys) == 0:
raise HTTPException(status_code=400, detail="No settings to change provided") raise HTTPException(status_code=400, detail="No settings to change provided")
if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]):
cert, key = CertsDB().get_cert_and_key(old_srv["ip_int"], old_srv["port"])
if cert and key:
CertsDB().upsert_cert_and_key(new_ip, new_port, cert, key)
CertsDB().delete_cert_and_key(old_srv["ip_int"], old_srv["port"])
try: try:
db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id)
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
# If migration was performed, rollback CertsDB change (upsert old, delete new)
if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]):
cert, key = CertsDB().get_cert_and_key(new_ip, new_port)
if cert and key:
CertsDB().upsert_cert_and_key(old_srv["ip_int"], old_srv["port"], cert, key)
CertsDB().delete_cert_and_key(new_ip, new_port)
raise HTTPException(status_code=400, detail="A service with these settings already exists") raise HTTPException(status_code=400, detail="A service with these settings already exists")
old_status = firewall.get(service_id).status old_status = firewall.get(service_id).status
await firewall.remove(service_id) await firewall.remove(service_id)
if old_status == STATUS.ACTIVE and new_target_type == "tls":
await activate_stream(new_tls_stream_id)
await firewall.reload() await firewall.reload()
await firewall.get(service_id).next(old_status) await firewall.get(service_id).next(old_status)
@ -357,54 +355,33 @@ async def add_new_regex(form: RegexAddForm):
@app.post('/services', response_model=ServiceAddResponse) @app.post('/services', response_model=ServiceAddResponse)
async def add_new_service(form: ServiceAddForm): async def add_new_service(form: ServiceAddForm):
"""Add a new service""" """Add a new service"""
try: if form.target_type == "flow":
form.ip_int = ip_parse(form.ip_int) if form.ip_int is None or form.port is None:
except ValueError: raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
raise HTTPException(status_code=400, detail="Invalid address") try:
form.ip_int = ip_parse(form.ip_int)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid address")
elif form.target_type == "tls":
if not form.tls_stream_id:
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
if form.proto not in ["tcp", "udp"]: if form.proto not in ["tcp", "udp"]:
raise HTTPException(status_code=400, detail="Invalid protocol") raise HTTPException(status_code=400, detail="Invalid protocol")
if form.tls_enabled: if form.target_type == "tls" and form.proto != "tcp":
if form.proto != "tcp": raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
if not form.tls_cert or not form.tls_key:
raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled")
CertsDB().upsert_cert_and_key(form.ip_int, form.port, form.tls_cert, form.tls_key)
srv_id = None srv_id = None
try: try:
srv_id = gen_service_id() srv_id = gen_service_id()
db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, tls_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, target_type, tls_stream_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, int(form.tls_enabled)) srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, form.target_type, form.tls_stream_id)
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="This type of service already exists") raise HTTPException(status_code=400, detail="This type of service already exists")
await firewall.reload() await firewall.reload()
await refresh_frontend() await refresh_frontend()
return {'status': 'ok', 'service_id': srv_id} return {'status': 'ok', 'service_id': srv_id}
@app.put('/services/{service_id}/tls-config', response_model=StatusMessageModel)
async def update_service_tls(service_id: str, form: TLSConfigForm):
"""Update TLS configuration for a service"""
srv_res = db.query("SELECT ip_int, port, proto FROM services WHERE service_id = ?;", service_id)
if len(srv_res) == 0:
raise HTTPException(status_code=404, detail="Service not found")
srv = srv_res[0]
if form.tls_enabled:
if srv["proto"] != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
if not form.tls_cert or not form.tls_key:
raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled")
CertsDB().upsert_cert_and_key(srv["ip_int"], srv["port"], form.tls_cert, form.tls_key)
db.query("""
UPDATE services
SET tls_enabled = ?
WHERE service_id = ?;
""", int(form.tls_enabled), service_id)
await firewall.get(service_id).update_tls_config()
await refresh_frontend()
return {'status': 'ok'}
@app.get('/metrics', response_class = PlainTextResponse) @app.get('/metrics', response_class = PlainTextResponse)
async def metrics(): async def metrics():
@ -428,3 +405,52 @@ async def metrics():
metrics.append(f'firegex_blocked_packets{{{props}}} {stat["blocked_packets"]}') metrics.append(f'firegex_blocked_packets{{{props}}} {stat["blocked_packets"]}')
metrics.append(f'firegex_active{{{props}}} {int(stat["active"] and stat["status"] == "active")}') metrics.append(f'firegex_active{{{props}}} {int(stat["active"] and stat["status"] == "active")}')
return "\n".join(metrics) return "\n".join(metrics)
@app.get('/services/{service_id}/export')
async def export_service_regexes(service_id: str):
"""Export all regexes for a specific service as JSON"""
if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', service_id)) == 0:
raise HTTPException(status_code=404, detail="Service not found")
regexes = db.query("SELECT regex, mode, is_case_sensitive, active FROM regexes WHERE service_id = ?;", service_id)
return {"regexes": regexes}
@app.post('/services/{service_id}/import', response_model=StatusMessageModel)
async def import_service_regexes(service_id: str, data: dict):
"""Import regexes for a specific service from JSON"""
if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', service_id)) == 0:
raise HTTPException(status_code=404, detail="Service not found")
regexes = data.get("regexes", [])
if not isinstance(regexes, list):
raise HTTPException(status_code=400, detail="Invalid format")
for r in regexes:
regex = r.get("regex")
mode = r.get("mode")
is_case_sensitive = r.get("is_case_sensitive", False)
active = r.get("active", True)
if not regex or not mode:
continue
try:
db.query('INSERT INTO regexes (regex, mode, service_id, is_case_sensitive, active) VALUES (?, ?, ?, ?, ?);',
regex, mode, service_id, int(is_case_sensitive), int(active))
# Since we inserted it directly, we need to notify the firewall to update its rules if it's active
if active and firewall.get(service_id).status == STATUS.ACTIVE:
# Easiest way to sync is to restart the service rule engine
await firewall.get(service_id).next(STATUS.ACTIVE)
except sqlite3.IntegrityError:
# Skip duplicates
pass
# Force reload of all rules for this service
old_status = firewall.get(service_id).status
await firewall.remove(service_id)
await firewall.reload()
await firewall.get(service_id).next(old_status)
await refresh_frontend()
return {'status': 'ok'}

View File

@ -67,10 +67,8 @@ async def startup():
await firewall.init() await firewall.init()
async def shutdown(): async def shutdown():
db.backup()
await firewall.close() await firewall.close()
db.disconnect() db.disconnect()
db.restore()
async def refresh_frontend(additional:list[str]=[]): async def refresh_frontend(additional:list[str]=[]):
await socketio_emit(["porthijack"]+additional) await socketio_emit(["porthijack"]+additional)

177
backend/routers/tls.py Normal file
View File

@ -0,0 +1,177 @@
from fastapi import APIRouter, HTTPException
import subprocess
from pydantic import BaseModel
from modules.tls.manager import TLSManager, ip_parse
from modules.tls.service import reload_tls
from utils.models import StatusMessageModel
import secrets
import routers.nfproxy as nfproxy_router
import routers.nfregex as nfregex_router
def gen_service_id():
while True:
res = secrets.token_hex(8)
if not manager.get_stream(res):
break
return res
class TLSStreamAddForm(BaseModel):
name: str
ip_int: str
port: int
cert: str
key: str
enabled: bool = True
class TLSStreamEditForm(BaseModel):
name: str | None = None
ip_int: str | None = None
port: int | None = None
cert: str | None = None
key: str | None = None
class TLSStreamResponse(BaseModel):
status: str
stream_id: str | None = None
app = APIRouter()
manager = TLSManager()
# (module, human name) pairs of the routers that can depend on a TLS stream
DEPENDENT_ROUTERS = (("nfproxy", nfproxy_router), ("nfregex", nfregex_router))
def get_dependent_services(stream_id: str) -> list[tuple[str, "object", str, str]]:
"""Returns (module_name, router_module, service_id, status) for every nfproxy/nfregex service linked to a TLS stream."""
deps = []
for module_name, router_mod in DEPENDENT_ROUTERS:
rows = router_mod.db.query(
"SELECT service_id, status FROM services WHERE target_type = 'tls' AND tls_stream_id = ?;", stream_id
)
for row in rows:
deps.append((module_name, router_mod, row["service_id"], row["status"]))
return deps
async def sync_dependent_services_address(stream_id: str, new_ip: str, new_port: int):
"""After a stream's ip/port changed, mirror it onto dependent services and rebind their live nft rules."""
touched_modules = set()
for module_name, router_mod, srv_id, status in get_dependent_services(stream_id):
router_mod.db.query("UPDATE services SET ip_int = ?, port = ? WHERE service_id = ?;", new_ip, new_port, srv_id)
await router_mod.firewall.remove(srv_id)
await router_mod.firewall.reload()
await router_mod.firewall.get(srv_id).next(status)
touched_modules.add(module_name)
for module_name, router_mod in DEPENDENT_ROUTERS:
if module_name in touched_modules:
await router_mod.refresh_frontend()
async def cascade_stop_dependents(stream_id: str):
"""When a TLS stream is stopped, stop every active service that depends on it."""
touched_modules = set()
for module_name, router_mod, srv_id, status in get_dependent_services(stream_id):
if status == "active":
await router_mod.firewall.get(srv_id).next("stop")
touched_modules.add(module_name)
for module_name, router_mod in DEPENDENT_ROUTERS:
if module_name in touched_modules:
await router_mod.refresh_frontend()
async def startup():
await reload_tls()
async def shutdown():
subprocess.run(["nginx", "-c", "/tmp/firegex_nginx.conf", "-s", "stop", "-e", "/tmp/firegex_nginx_error.log"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def reset(form):
if form.delete:
manager.db.delete()
manager.db.init()
await reload_tls()
@app.get('/streams')
async def get_streams():
return manager.get_streams()
@app.post('/streams', response_model=TLSStreamResponse)
async def add_stream(form: TLSStreamAddForm):
try:
ip_int = ip_parse(form.ip_int)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid address")
if manager.get_stream_by_ip_port(ip_int, form.port):
raise HTTPException(status_code=400, detail="A TLS stream for this IP and Port already exists")
stream_id = gen_service_id()
try:
manager.add_stream(stream_id, form.name, ip_int, form.port, form.cert, form.key, status="active" if form.enabled else "stop")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
await reload_tls()
return {"status": "ok", "stream_id": stream_id}
@app.put('/streams/{stream_id}', response_model=StatusMessageModel)
async def edit_stream(stream_id: str, form: TLSStreamEditForm):
stream = manager.get_stream(stream_id)
if not stream:
raise HTTPException(status_code=404, detail="TLS stream not found")
new_ip_int = None
if form.ip_int is not None:
try:
new_ip_int = ip_parse(form.ip_int)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid address")
if form.port is not None and (form.port < 1 or form.port > 65535):
raise HTTPException(status_code=400, detail="Invalid port")
check_ip = new_ip_int if new_ip_int is not None else stream["ip_int"]
check_port = form.port if form.port is not None else stream["port"]
address_changed = check_ip != stream["ip_int"] or check_port != stream["port"]
if address_changed:
existing = manager.get_stream_by_ip_port(check_ip, check_port)
if existing and existing["id"] != stream_id:
raise HTTPException(status_code=400, detail="A TLS stream for this IP and Port already exists")
try:
manager.update_stream(stream_id, name=form.name, ip_int=new_ip_int, port=form.port, cert=form.cert, key=form.key)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if address_changed:
await sync_dependent_services_address(stream_id, check_ip, check_port)
await reload_tls()
return {"status": "ok"}
@app.delete('/streams/{stream_id}', response_model=StatusMessageModel)
async def delete_stream(stream_id: str):
stream = manager.get_stream(stream_id)
if not stream:
raise HTTPException(status_code=404, detail="TLS stream not found")
if get_dependent_services(stream_id):
raise HTTPException(status_code=400, detail="Cannot delete this TLS stream because it is actively used by NFProxy or NFRegex services.")
manager.delete_stream(stream_id)
await reload_tls()
return {"status": "ok"}
@app.post('/streams/{stream_id}/start', response_model=StatusMessageModel)
async def start_stream(stream_id: str):
if not manager.get_stream(stream_id):
raise HTTPException(status_code=404, detail="TLS stream not found")
manager.update_status(stream_id, "active")
await reload_tls()
return {"status": "ok"}
@app.post('/streams/{stream_id}/stop', response_model=StatusMessageModel)
async def stop_stream(stream_id: str):
if not manager.get_stream(stream_id):
raise HTTPException(status_code=404, detail="TLS stream not found")
manager.update_status(stream_id, "stop")
await reload_tls()
await cascade_stop_dependents(stream_id)
return {"status": "ok"}

View File

@ -15,7 +15,10 @@ from pathlib import Path
from fastapi import HTTPException, status from fastapi import HTTPException, status
LOCALHOST_IP = socket.gethostbyname(os.getenv("LOCALHOST_IP","127.0.0.1")) _localhost_env = os.getenv("LOCALHOST_IP", "127.0.0.1")
if _localhost_env == "None":
_localhost_env = "127.0.0.1"
LOCALHOST_IP = socket.gethostbyname(_localhost_env)
socketio:AsyncServer = None socketio:AsyncServer = None
sid_list:set = set() sid_list:set = set()
@ -25,9 +28,12 @@ ROUTERS_DIR = os.path.join(ROOT_DIR,"routers")
ON_DOCKER = "DOCKER" in sys.argv ON_DOCKER = "DOCKER" in sys.argv
DEBUG = "DEBUG" in sys.argv DEBUG = "DEBUG" in sys.argv
NORELOAD = "NORELOAD" in sys.argv NORELOAD = "NORELOAD" in sys.argv
FIREGEX_PORT = int(os.getenv("PORT","4444")) _port_env = os.getenv("PORT", "4444")
FIREGEX_HOST = os.getenv("HOST","0.0.0.0") FIREGEX_PORT = int(_port_env) if _port_env and _port_env != "None" else 4444
FIREGEX_SOCKET_DIR = os.getenv("SOCKET_DIR", None) _host_env = os.getenv("HOST", "0.0.0.0")
FIREGEX_HOST = _host_env if _host_env and _host_env != "None" else "0.0.0.0"
_socket_dir_env = os.getenv("SOCKET_DIR", None)
FIREGEX_SOCKET_DIR = None if _socket_dir_env == "None" else _socket_dir_env
FIREGEX_SOCKET = os.path.join(FIREGEX_SOCKET_DIR, "firegex.sock") if FIREGEX_SOCKET_DIR else None FIREGEX_SOCKET = os.path.join(FIREGEX_SOCKET_DIR, "firegex.sock") if FIREGEX_SOCKET_DIR else None
JWT_ALGORITHM: str = "HS256" JWT_ALGORITHM: str = "HS256"
def _get_version(): def _get_version():

View File

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

View File

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

View File

@ -125,3 +125,44 @@ class SQLite():
def set(self, key, value): def set(self, key, value):
return self.put(key, value) return self.put(key, value)
def dump(self) -> dict:
self.connect()
res = {}
tables = self.query("SELECT name FROM sqlite_master WHERE type='table';")
for table in tables:
tname = table["name"]
if tname == 'keys_values':
res[tname] = self.query(f"SELECT * FROM {tname} WHERE key NOT IN ('password', 'secret');")
else:
res[tname] = self.query(f"SELECT * FROM {tname};")
return res
def load(self, data: dict):
self.connect()
cur = self.conn.cursor()
try:
cur.execute("BEGIN")
for table_name, rows in data.items():
# Verify table exists to prevent SQL injection or schema mismatches
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (table_name,))
if len(cur.fetchall()) == 0:
continue
cur.execute(f"DELETE FROM {table_name};")
if rows:
cols = list(rows[0].keys())
placeholders = ", ".join(["?"] * len(cols))
col_names = ", ".join(cols)
query = f"INSERT INTO {table_name} ({col_names}) VALUES ({placeholders})"
for row in rows:
cur.execute(query, [row[c] for c in cols])
cur.execute("COMMIT")
except Exception as e:
cur.execute("ROLLBACK")
raise e
finally:
cur.close()
try:
self.conn.commit()
except Exception:
pass

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 298 KiB

View File

@ -5,35 +5,35 @@
"": { "": {
"name": "firegex-frontend", "name": "firegex-frontend",
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "latest", "@hello-pangea/dnd": "^18.0.1",
"@mantine/code-highlight": "latest", "@mantine/code-highlight": "^9.3.2",
"@mantine/core": "latest", "@mantine/core": "^9.3.2",
"@mantine/form": "latest", "@mantine/form": "^9.3.2",
"@mantine/hooks": "latest", "@mantine/hooks": "^9.3.2",
"@mantine/modals": "latest", "@mantine/modals": "^9.3.2",
"@mantine/notifications": "latest", "@mantine/notifications": "^9.3.2",
"@tanstack/react-query": "latest", "@monaco-editor/react": "^4.7.0",
"@vitejs/plugin-react-swc": "latest", "@tanstack/react-query": "^5.101.0",
"buffer": "latest", "@vitejs/plugin-react-swc": "^4.3.1",
"react": "latest", "buffer": "^6.0.3",
"react-dom": "latest", "react": "^19.2.7",
"react-icons": "latest", "react-dom": "^19.2.7",
"react-router": "latest", "react-icons": "^5.6.0",
"shiki": "latest", "react-router": "^8.0.1",
"socket.io-client": "latest", "shiki": "^4.2.0",
"typescript": "latest", "socket.io-client": "^4.8.3",
"zustand": "latest", "typescript": "^6.0.3",
"zustand": "^5.0.14",
}, },
"devDependencies": { "devDependencies": {
"@tanstack/react-query-devtools": "latest", "@tanstack/react-query-devtools": "^5.101.0",
"@types/jest": "latest", "@types/jest": "^30.0.0",
"@types/node": "latest", "@types/node": "^26.0.0",
"@types/react": "latest", "@types/react": "^19.2.17",
"@types/react-dom": "latest", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "^6.0.2",
"vite": "latest", "vite": "^8.0.16",
"vite-plugin-svgr": "latest", "vite-plugin-svgr": "^5.2.0",
"vite-tsconfig-paths": "latest",
}, },
}, },
}, },
@ -126,6 +126,10 @@
"@mantine/store": ["@mantine/store@9.3.2", "", { "peerDependencies": { "react": "^19.2.0" } }, "sha512-uNmvjkwfUHZJ5bvzQroLFvubuUAHjBM0rh69RBRsUCG5FR5K4f8y4SX8+lch0wS6UJuuvodsSIK8H5jvZuFSgQ=="], "@mantine/store": ["@mantine/store@9.3.2", "", { "peerDependencies": { "react": "^19.2.0" } }, "sha512-uNmvjkwfUHZJ5bvzQroLFvubuUAHjBM0rh69RBRsUCG5FR5K4f8y4SX8+lch0wS6UJuuvodsSIK8H5jvZuFSgQ=="],
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
"@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
@ -272,6 +276,8 @@
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
@ -342,6 +348,8 @@
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="],
"dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="], "dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
"electron-to-chromium": ["electron-to-chromium@1.5.90", "", {}, "sha512-C3PN4aydfW91Natdyd449Kw+BzhLmof6tzy5W1pFC5SpQxVXT+oyiyOG9AgYYSN9OdA/ik3YkCrpwqI8ug5Tug=="], "electron-to-chromium": ["electron-to-chromium@1.5.90", "", {}, "sha512-C3PN4aydfW91Natdyd449Kw+BzhLmof6tzy5W1pFC5SpQxVXT+oyiyOG9AgYYSN9OdA/ik3YkCrpwqI8ug5Tug=="],
@ -374,8 +382,6 @@
"globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], "globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@ -448,6 +454,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
@ -460,6 +468,8 @@
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.14", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ=="], "nanoid": ["nanoid@3.3.14", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ=="],
@ -554,6 +564,8 @@
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@ -570,8 +582,6 @@
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"tsconfck": ["tsconfck@3.1.4", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], "type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
@ -606,8 +616,6 @@
"vite-plugin-svgr": ["vite-plugin-svgr@5.2.0", "", { "dependencies": { "@rollup/pluginutils": "^5.3.0", "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0" }, "peerDependencies": { "vite": ">=3.0.0" } }, "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ=="], "vite-plugin-svgr": ["vite-plugin-svgr@5.2.0", "", { "dependencies": { "@rollup/pluginutils": "^5.3.0", "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0" }, "peerDependencies": { "vite": ">=3.0.0" } }, "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
"ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], "ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],

View File

@ -11,6 +11,7 @@
"@mantine/hooks": "^9.3.2", "@mantine/hooks": "^9.3.2",
"@mantine/modals": "^9.3.2", "@mantine/modals": "^9.3.2",
"@mantine/notifications": "^9.3.2", "@mantine/notifications": "^9.3.2",
"@monaco-editor/react": "^4.7.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@vitejs/plugin-react-swc": "^4.3.1", "@vitejs/plugin-react-swc": "^4.3.1",
"buffer": "^6.0.3", "buffer": "^6.0.3",
@ -39,7 +40,6 @@
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"vite": "^8.0.16", "vite": "^8.0.16",
"vite-plugin-svgr": "^5.2.0", "vite-plugin-svgr": "^5.2.0",
"vite-tsconfig-paths": "^6.1.1",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^26.0.0", "@types/node": "^26.0.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",

View File

@ -1,7 +1,8 @@
import { Button, Group, Loader, LoadingOverlay, Notification, Space, PasswordInput, Title, Box } from '@mantine/core'; import { Button, Card, Group, Image, Loader, LoadingOverlay, Notification, Space, Stack, PasswordInput, Title, Text, Code, ThemeIcon, Box } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ImCross } from 'react-icons/im'; import { ImCross } from 'react-icons/im';
import { MdCloudOff } from 'react-icons/md';
import { Outlet, Route, Routes } from 'react-router'; import { Outlet, Route, Routes } from 'react-router';
import MainLayout from './components/MainLayout'; import MainLayout from './components/MainLayout';
import { PasswordSend, ServerStatusResponse } from './js/models'; import { PasswordSend, ServerStatusResponse } from './js/models';
@ -15,158 +16,177 @@ import NFProxy from './pages/NFProxy';
import ServiceDetailsNFProxy from './pages/NFProxy/ServiceDetails'; import ServiceDetailsNFProxy from './pages/NFProxy/ServiceDetails';
import { useAuthStore } from './js/store'; import { useAuthStore } from './js/store';
const AuthShell = ({ children }: { children: React.ReactNode }) => (
<Box style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", padding: "40px 20px" }}>
<Card withBorder radius="lg" p="xl" shadow="md" style={{ width: "100%", maxWidth: 440, borderColor: 'var(--fourth_color)', backgroundColor: 'var(--third_color)', boxShadow: '0 8px 32px rgba(0,0,0,0.3)' }}>
<Stack align="center" gap={2} mb="lg">
<Image src="/header-logo.png" alt="Firegex logo" w={56} />
<Title order={2} ta="center" style={{ fontFamily: 'Hanken Grotesk', color: 'var(--accent-color)' }}>[Fi]*regex</Title>
</Stack>
{children}
</Card>
</Box>
)
const StatusCard = ({ title, description, color, children }: { title: string, description: string, color: string, children?: React.ReactNode }) => (
<Box style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", padding: "40px 20px" }}>
<Card withBorder radius="lg" p="xl" shadow="md" style={{ width: "100%", maxWidth: 480, borderColor: 'var(--fourth_color)', backgroundColor: 'var(--third_color)', boxShadow: '0 8px 32px rgba(0,0,0,0.3)' }}>
<Stack align="center" gap="md">
<ThemeIcon size={64} radius="xl" color={color} variant="light">
<MdCloudOff size={32} />
</ThemeIcon>
<Title order={2} ta="center">{title}</Title>
<Text c="dimmed" ta="center">{description}</Text>
{children}
</Stack>
</Card>
</Box>
)
function App() { function App() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [systemStatus, setSystemStatus] = useState<ServerStatusResponse>({status:"", loggined:false}) const [systemStatus, setSystemStatus] = useState<ServerStatusResponse>({ status: "", loggined: false })
const [reqError, setReqError] = useState<undefined|string>() const [reqError, setReqError] = useState<undefined | string>()
const [error, setError] = useState<string|null>() const [error, setError] = useState<string | null>()
const [loadinBtn, setLoadingBtn] = useState(false); const [loadinBtn, setLoadingBtn] = useState(false);
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { access_token } = useAuthStore() const { access_token } = useAuthStore()
useEffect(()=>{ useEffect(() => {
socketio.auth = { token: access_token || "" } socketio.auth = { token: access_token || "" }
socketio.connect() socketio.connect()
getStatus() getStatus()
socketio.on("update", (data) => { socketio.on("update", (data) => {
queryClient.invalidateQueries({ queryKey: data }) queryClient.invalidateQueries({ queryKey: data })
}) })
socketio.on("connect_error", (err) => { socketio.on("connect_error", (err) => {
if (access_token){ if (access_token) {
errorNotify("Socket.Io connection failed! ",`Error message: [${err.message}]`) errorNotify("Socket.Io connection failed! ", `Error message: [${err.message}]`)
} }
getStatus() getStatus()
}); });
return () => { return () => {
socketio.off("update") socketio.off("update")
socketio.off("connect_error") socketio.off("connect_error")
socketio.disconnect() socketio.disconnect()
} }
},[access_token]) }, [access_token])
const getStatus = () =>{ const getStatus = () => {
getstatus().then( res =>{ getstatus().then(res => {
setSystemStatus(res) setSystemStatus(res)
setReqError(undefined) setReqError(undefined)
}).catch(err=>{ }).catch(err => {
setReqError(err.toString()) setReqError(err.toString())
setTimeout(getStatus, 500) setTimeout(getStatus, 500)
}).finally( ()=>setLoading(false) ) }).finally(() => setLoading(false))
} }
useEffect(()=>{ useEffect(() => {
getStatus() getStatus()
},[]) }, [])
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
password:"", password: "",
}, },
validate:{ validate: {
password: (value) => value !== "" ? null : "Password is required", password: (value) => value !== "" ? null : "Password is required",
} }
}) })
if (loading){ if (loading) {
return <LoadingOverlay visible/> return <LoadingOverlay visible />
}else if (reqError){ } else if (reqError) {
return <Box className='center-flex-row' style={{padding:"100px"}}> return <StatusCard title="Can't reach Firegex 🔥" description="The backend isn't responding. Retrying automatically..." color="red">
<Title order={1} style={{textAlign:"center"}}>Error launching Firegex! 🔥</Title> <Code block style={{ width: "100%", wordBreak: "break-word", backgroundColor: 'var(--secondary_color)' }}>{reqError}</Code>
<Space h="md" /> <Loader size="sm" />
<Title order={4} style={{textAlign:"center"}}>Error communicating with backend</Title> </StatusCard>
<Space h="md" /> } else if (systemStatus.status === "init") {
Error: {reqError}
<Space h="xl" />
<Loader />
</Box>
}else if (systemStatus.status === "init"){
const submitRequest = async (values:PasswordSend) => { const submitRequest = async (values: PasswordSend) => {
setLoadingBtn(true) setLoadingBtn(true)
await setpassword(values).then(res => { await setpassword(values).then(res => {
if(!res){ if (!res) {
setSystemStatus({loggined:true, status:"run"}) setSystemStatus({ loggined: true, status: "run" })
}else{ } else {
setError(res) setError(res)
} }
}).catch( err => setError(err.toString())) }).catch(err => setError(err.toString()))
setLoadingBtn(false) setLoadingBtn(false)
form.reset() form.reset()
} }
return <Box className='center-flex-row' style={{padding:"100px"}}> return <AuthShell>
<Title order={3} style={{textAlign:"center"}}>Setup: Choose the password for access to the firewall 🔒</Title> <Title order={5} ta="center" style={{ color: 'var(--text-secondary)', fontWeight: 400 }}>Choose the password for access to the firewall 🔒</Title>
<Space h="xl" /> <Space h="xl" />
<form onSubmit={form.onSubmit(submitRequest)} style={{width:"80%"}}> <form onSubmit={form.onSubmit(submitRequest)} style={{ width: "100%" }}>
<PasswordInput <PasswordInput
label="Password" label="Password"
placeholder="$3cr3t" placeholder="$3cr3t"
{...form.getInputProps('password')} {...form.getInputProps('password')}
/> />
<Group align="right" mt="md"> <Group mt="xl">
<Button loading={loadinBtn} type="submit">Set Password</Button> <Button loading={loadinBtn} type="submit" variant="light" color="cyan" fullWidth>Set Password</Button>
</Group> </Group>
</form> </form>
<Space h="xl" /> {error && <>
{error?<> <Space h="lg" />
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}> <Notification icon={<ImCross size={14} />} color="red" onClose={() => { setError(null) }}>
Error: {error} Error: {error}
</Notification><Space h="md" /></>:null} </Notification></>}
</Box> </AuthShell>
}else if (systemStatus.status === "run" && !systemStatus.loggined){ } else if (systemStatus.status === "run" && !systemStatus.loggined) {
const submitRequest = async (values:PasswordSend) => { const submitRequest = async (values: PasswordSend) => {
setLoadingBtn(true) setLoadingBtn(true)
await login(values).then(res => { await login(values).then(res => {
if(!res){ if (!res) {
queryClient.invalidateQueries() queryClient.invalidateQueries()
setSystemStatus({...systemStatus, loggined:true}) setSystemStatus({ ...systemStatus, loggined: true })
}else{ } else {
setError("Login failed") setError("Login failed")
} }
}).catch( err => setError(err.toString())) }).catch(err => setError(err.toString()))
setLoadingBtn(false) setLoadingBtn(false)
form.reset() form.reset()
} }
return <Box className='center-flex-row' style={{padding:"100px"}}> return <AuthShell>
<Title order={2} style={{textAlign:"center"}}>Welcome to Firegex 🔥</Title> <Title order={5} ta="center" style={{ color: 'var(--text-secondary)', fontWeight: 400 }}>Insert the password to unlock the firewall 🔒</Title>
<Space h="xl" /> <Space h="xl" />
<Title order={2} style={{textAlign:"center"}}>Before you use the firewall, insert the password 🔒</Title> <form onSubmit={form.onSubmit(submitRequest)} style={{ width: "100%" }}>
<Space h="xl" /> <PasswordInput
<form onSubmit={form.onSubmit(submitRequest)} style={{width:"80%"}}> label="Password"
<PasswordInput placeholder="$3cr3t"
label="Password" {...form.getInputProps('password')}
placeholder="$3cr3t" />
{...form.getInputProps('password')} <Group mt="xl">
/> <Button loading={loadinBtn} type="submit" variant="light" color="cyan" fullWidth>Login</Button>
<Group align="right" mt="md"> </Group>
<Button loading={loadinBtn} type="submit">Login</Button> </form>
</Group> {error && <>
</form> <Space h="lg" />
<Space h="xl" /> <Notification icon={<ImCross size={14} />} color="red" onClose={() => { setError(null) }}>
{error?<> Error: {error}
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}> </Notification></>}
Error: {error} </AuthShell>
</Notification><Space h="md" /></>:null} } else if (systemStatus.status === "run" && systemStatus.loggined) {
</Box>
}else if (systemStatus.status === "run" && systemStatus.loggined){
return <PageRouting getStatus={getStatus} /> return <PageRouting getStatus={getStatus} />
}else{ } else {
return <Box className='center-flex-row' style={{padding:"100px"}}> return <StatusCard title="Unexpected error 🔥" description="Firegex returned an unexpected status. Try reloading the page." color="orange" />
<Title order={1} style={{textAlign:"center"}}>Error launching Firegex! 🔥</Title>
<Space h="md" />
<Title order={4} style={{textAlign:"center"}}>Error communicating with backend</Title>
</Box>
} }
} }
const PageRouting = ({ getStatus }:{ getStatus:()=>void }) => { import TLSDecrypt from './pages/TLSDecrypt';
const PageRouting = ({ getStatus }: { getStatus: () => void }) => {
return <Routes> return <Routes>
<Route element={<MainLayout><Outlet /></MainLayout>}> <Route element={<MainLayout><Outlet /></MainLayout>}>
<Route path="tls-decrypt" element={<TLSDecrypt />} />
<Route path="nfregex" element={<NFRegex><Outlet /></NFRegex>} > <Route path="nfregex" element={<NFRegex><Outlet /></NFRegex>} >
<Route path=":srv" element={<ServiceDetailsNFRegex />} /> <Route path=":srv" element={<ServiceDetailsNFRegex />} />
</Route> </Route>
@ -175,9 +195,9 @@ const PageRouting = ({ getStatus }:{ getStatus:()=>void }) => {
</Route> </Route>
<Route path="firewall" element={<Firewall />} /> <Route path="firewall" element={<Firewall />} />
<Route path="porthijack" element={<PortHijack />} /> <Route path="porthijack" element={<PortHijack />} />
<Route path="*" element={<HomeRedirector />} /> <Route path="*" element={<HomeRedirector />} />
</Route> </Route>
</Routes> </Routes>
} }

View File

@ -1,4 +1,4 @@
import { Button, Group, Space, TextInput, Notification, Switch, Modal, Select } from '@mantine/core'; import { Button, Group, Space, TextInput, Notification, Switch, Modal, Card, Text, Title, SegmentedControl, Box } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useState } from 'react'; import { useState } from 'react';
import { RegexAddForm } from '../js/models'; import { RegexAddForm } from '../js/models';
@ -68,48 +68,73 @@ function AddNewRegex({ opened, onClose, service }:{ opened:boolean, onClose:()=>
} }
return <Modal size="xl" title="Add a new regex filter" opened={opened} onClose={close} closeOnClickOutside={false} centered> return <Modal size="lg" title="Add a new regex filter" opened={opened} onClose={close} closeOnClickOutside={false} centered>
<form onSubmit={form.onSubmit(submitRequest)}> <form onSubmit={form.onSubmit(submitRequest)}>
<TextInput <TextInput
label="Regex" label="Regex"
placeholder="[A-Z0-9]{31}=" placeholder="[A-Z0-9]{31}="
size="md"
data-autofocus
{...form.getInputProps('regex')} {...form.getInputProps('regex')}
/> />
<Space h="md" />
<Switch <Space h="xl" />
label="Case insensitive" <Group justify="space-between" align="center">
{...form.getInputProps('is_case_insensitive', { type: 'checkbox' })} <Box>
/> <Text fw={500} size="sm">Packet Direction</Text>
<Space h="md" /> <Text size="xs" c="dimmed">Choose which traffic this regex should filter</Text>
<Switch </Box>
label="Deactivate" <SegmentedControl
{...form.getInputProps('deactive', { type: 'checkbox' })} value={form.values.mode}
/> onChange={(val) => form.setFieldValue('mode', val)}
<Space h="md" /> data={[
<Select { label: 'Client → Server', value: 'C' },
data={[ { label: 'Bidirectional', value: 'B' },
{ value: 'C', label: 'Client -> Server' }, { label: 'Server → Client', value: 'S' },
{ value: 'S', label: 'Server -> Client' }, ]}
{ value: 'B', label: 'Both (Client <-> Server)' }, color="cyan"
]} size="sm"
label="Choose the source of the packets to filter" />
variant="filled"
{...form.getInputProps('mode')}
/>
<Group align="right" mt="md">
<Button loading={submitLoading} type="submit">Add Filter</Button>
</Group> </Group>
<Space h="md" /> <Space h="xl" />
<Card withBorder radius="md" p="md" bg="transparent">
<Group justify="space-between" mb="xs">
<Text fw={500} size="sm">Case Insensitive</Text>
<Switch
color="cyan"
{...form.getInputProps('is_case_insensitive', { type: 'checkbox' })}
/>
</Group>
<Text size="xs" c="dimmed" mb="md">
Ignore letter casing when matching this regex against packets.
</Text>
<Group justify="space-between" mb="xs">
<Text fw={500} size="sm">Start Deactivated</Text>
<Switch
color="orange"
{...form.getInputProps('deactive', { type: 'checkbox' })}
/>
</Group>
<Text size="xs" c="dimmed">
Create the filter but keep it disabled initially.
</Text>
</Card>
<Group justify="flex-end" mt="xl">
<Button variant="default" onClick={close}>Cancel</Button>
<Button loading={submitLoading} type="submit" color="cyan">Add Filter</Button>
</Group>
{error?<> {error?<>
<Space h="md" />
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}> <Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}>
Error: {error} Error: {error}
</Notification><Space h="md" /></>:null} </Notification></>:null}
</form> </form>
</Modal> </Modal>
} }
export default AddNewRegex; export default AddNewRegex;

View File

@ -1,27 +1,26 @@
import { SegmentedControl, SegmentedControlProps } from "@mantine/core"; import { SegmentedControl, SegmentedControlProps } from "@mantine/core";
import { RuleMode, Table } from "./utils"; import { RuleMode, Table } from "./utils";
import { table } from "console";
export const ModeSelector = (props:Omit<SegmentedControlProps, "data"> & { table: Table }) => { export const ModeSelector = (props: Omit<SegmentedControlProps, "data"> & { table: Table }) => {
const isFilterTable = props.table == Table.FILTER const isFilterTable = props.table == Table.FILTER
return <SegmentedControl return <SegmentedControl
data={[ data={[
{ {
value: RuleMode.IN, value: RuleMode.IN,
label: isFilterTable?'IN':'PREROUTING', label: isFilterTable ? 'IN' : 'PREROUTING',
}, },
...(isFilterTable?[{ ...(isFilterTable ? [{
value: RuleMode.FORWARD, value: RuleMode.FORWARD,
label: 'FWD', label: 'FWD',
}]:[]), }] : []),
{ {
value: RuleMode.OUT, value: RuleMode.OUT,
label: isFilterTable?'OUT':'POSTROUTING', label: isFilterTable ? 'OUT' : 'POSTROUTING',
} }
]} ]}
size={props.size?props.size:"xs"} size={props.size ? props.size : "xs"}
{...props} {...props}
/> />
} }

View File

@ -1,15 +1,21 @@
import { Button, Group, Space, TextInput, Notification, Modal, Switch, SegmentedControl, Box, Tooltip, ActionIcon } from '@mantine/core'; import { Button, Group, Space, TextInput, Notification, Modal, Switch, SegmentedControl, Box, Tooltip, ActionIcon, Title, Tabs, Text, UnstyledButton, Card, Divider } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils'; import { okNotify, regex_ipv4, regex_ipv6, postapi } from '../../js/utils';
import TLSServiceFields from '../TLSServiceFields'; import TLSServiceFields from '../TLSServiceFields';
import { ImCross } from "react-icons/im" import { ImCross } from "react-icons/im"
import { nfproxy, Service } from './utils'; import { nfproxy, Service } from './utils';
import PortAndInterface from '../PortAndInterface'; import PortAndInterface from '../PortAndInterface';
import { IoMdInformationCircleOutline } from "react-icons/io"; import { IoMdInformationCircleOutline } from "react-icons/io";
import { TbNetwork, TbShieldLock, TbSettings, TbTarget } from "react-icons/tb";
import { ServiceAddForm as ServiceAddFormOriginal } from './utils'; import { ServiceAddForm as ServiceAddFormOriginal } from './utils';
type ServiceAddForm = ServiceAddFormOriginal & {autostart: boolean} type ServiceAddForm = ServiceAddFormOriginal & {
autostart: boolean,
tls_enabled: boolean,
tls_cert: string,
tls_key: string
}
function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>void, edit?:Service }) { function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>void, edit?:Service }) {
@ -20,7 +26,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
proto:edit?.proto??"tcp", proto:edit?.proto??"tcp",
fail_open: edit?.fail_open??false, fail_open: edit?.fail_open??false,
autostart: true, autostart: true,
tls_enabled: false, tls_enabled: edit?.target_type === 'tls',
tls_stream_id: edit?.tls_stream_id ?? undefined,
tls_cert: "", tls_cert: "",
tls_key: "" tls_key: ""
} }
@ -32,8 +39,6 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
port: (value) => (value>0 && value<65536) ? null : "Invalid port", port: (value) => (value>0 && value<65536) ? null : "Invalid port",
proto: (value) => ["tcp","http"].includes(value) ? null : "Invalid protocol", proto: (value) => ["tcp","http"].includes(value) ? null : "Invalid protocol",
ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address", ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address",
tls_cert: (value, values) => !edit && values.tls_enabled && !value ? "Certificate is required when TLS is enabled" : null,
tls_key: (value, values) => !edit && values.tls_enabled && !value ? "Private key is required when TLS is enabled" : null,
} }
}) })
@ -55,10 +60,34 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
const [submitLoading, setSubmitLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false)
const [error, setError] = useState<string|null>(null) const [error, setError] = useState<string|null>(null)
const submitRequest = (values: ServiceAddForm) => { const submitRequest = async (values: ServiceAddForm) => {
setSubmitLoading(true) setSubmitLoading(true)
let streamId = values.tls_stream_id;
if (edit){ if (edit){
nfproxy.settings(edit.service_id, { port: values.port, ip_int: values.ip_int, fail_open: values.fail_open }).then( res => { if (values.tls_enabled && values.tls_cert && values.tls_key) {
try {
const res = await postapi('tls/streams', {
name: values.name,
ip_int: values.ip_int,
port: values.port,
cert: values.tls_cert,
key: values.tls_key
});
if (res.status !== "ok") {
setSubmitLoading(false);
setError("Failed to create TLS Stream!");
return;
}
streamId = res.stream_id;
} catch (err: any) {
setSubmitLoading(false);
setError("Failed to create TLS Stream: " + err);
return;
}
}
nfproxy.settings(edit.service_id, { port: values.port, proto: values.proto, ip_int: values.ip_int, fail_open: values.fail_open, target_type: values.tls_enabled ? "tls" : "flow", tls_stream_id: streamId }).then( res => {
if (!res){ if (!res){
setSubmitLoading(false) setSubmitLoading(false)
close(); close();
@ -69,15 +98,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
setError("Request Failed! [ "+err+" ]") setError("Request Failed! [ "+err+" ]")
}) })
}else{ }else{
if (values.tls_enabled && values.tls_cert && values.tls_key) {
try {
const res = await postapi('tls/streams', {
name: values.name,
ip_int: values.ip_int,
port: values.port,
cert: values.tls_cert,
key: values.tls_key
});
if (res.status !== "ok") {
setSubmitLoading(false);
setError("Failed to create TLS Stream!");
return;
}
streamId = res.stream_id;
} catch (err: any) {
setSubmitLoading(false);
setError("Failed to create TLS Stream: " + err);
return;
}
}
const payload = { const payload = {
name: values.name, name: values.name,
port: values.port, port: values.port,
proto: values.proto, proto: values.proto,
ip_int: values.ip_int, ip_int: values.ip_int,
fail_open: values.fail_open, fail_open: values.fail_open,
tls_enabled: values.tls_enabled, target_type: values.tls_enabled ? "tls" : "flow",
tls_cert: values.tls_enabled ? values.tls_cert : null, tls_stream_id: streamId,
tls_key: values.tls_enabled ? values.tls_key : null,
} }
nfproxy.servicesadd(payload).then( res => { nfproxy.servicesadd(payload).then( res => {
if (res.status === "ok" && res.service_id){ if (res.status === "ok" && res.service_id){
@ -97,68 +147,131 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
} }
return <Modal size="xl" title={edit?`Editing ${edit.name} service`:"Add a new service"} opened={opened} onClose={close} closeOnClickOutside={false} centered> return <Modal size="xl" title={edit?`Editing ${edit.name} service`:"Add a new service"} opened={opened} onClose={close} closeOnClickOutside={false} centered styles={{ body: { maxHeight: 'calc(100vh - 150px)', overflowY: 'auto' } }}>
<form onSubmit={form.onSubmit(submitRequest)}> <form onSubmit={form.onSubmit(submitRequest)}>
{!edit?<TextInput <Tabs defaultValue="general" color="cyan" variant="outline" radius="md">
label="Service name" <Tabs.List>
placeholder="Challenge 01" <Tabs.Tab value="general" leftSection={<TbSettings size={16} />}>
{...form.getInputProps('name')} General
/>:null} </Tabs.Tab>
<Space h="md" /> <Tabs.Tab value="advanced" leftSection={<TbShieldLock size={16} />}>
<PortAndInterface form={form} int_name="ip_int" port_name="port" label={"Public IP Interface and port (ipv4/ipv6 + CIDR allowed)"} /> Advanced
<Space h="md" /> </Tabs.Tab>
</Tabs.List>
<Box className='center-flex'> <Tabs.Panel value="general" pt="xl" pb="md">
<Box> {!edit ? (
{!edit?<Switch <TextInput
label="Auto-Start Service" label="Service name"
{...form.getInputProps('autostart', { type: 'checkbox' })} placeholder="Challenge 01"
/>:null} size="md"
<Space h="sm" /> {...form.getInputProps('name')}
<Group gap="xs" wrap="nowrap" align="center">
<Switch
label="Enable fail-open nfqueue"
{...form.getInputProps('fail_open', { type: 'checkbox' })}
/> />
<Tooltip ) : null}
label={<> <Space h="md" />
Firegex use internally nfqueue to handle packets<br />enabling this option will allow packets to pass through the firewall <br /> in case the filtering is too slow or too many traffic is coming<br /> <Title order={6} mb="xs">Protocol</Title>
</>} <SegmentedControl
zIndex={1000000} fullWidth
size="md"
color="cyan"
data={[
{ label: 'TCP', value: 'tcp' },
{ label: 'HTTP', value: 'http' },
]}
{...form.getInputProps('proto')}
/>
<Space h="xl" />
<Divider mb="xl" label="Target Setup" labelPosition="center" />
<Title order={6} mb="xs">Target Type</Title>
<Group grow mb="xl">
<UnstyledButton
onClick={() => form.setFieldValue('tls_enabled', false)}
style={{
padding: '1rem',
borderRadius: '12px',
border: form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)',
backgroundColor: form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)',
transition: 'all 0.2s ease'
}}
> >
<ActionIcon <Group wrap="nowrap">
variant="subtle" <TbNetwork size={32} color={!form.values.tls_enabled ? "var(--accent-color)" : "gray"} />
color="gray" <div>
size="sm" <Text fw={600}>Direct Flow</Text>
onClick={(e: any) => { e.preventDefault(); e.stopPropagation(); }} <Text size="xs" c="dimmed">Forward traffic directly without decryption</Text>
> </div>
<IoMdInformationCircleOutline size={18} /> </Group>
</ActionIcon> </UnstyledButton>
</Tooltip> <UnstyledButton
onClick={() => form.setFieldValue('tls_enabled', true)}
style={{
padding: '1rem',
borderRadius: '12px',
border: !form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)',
backgroundColor: !form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)',
transition: 'all 0.2s ease'
}}
>
<Group wrap="nowrap">
<TbShieldLock size={32} color={form.values.tls_enabled ? "var(--accent-color)" : "gray"} />
<div>
<Text fw={600}>TLS Decrypted</Text>
<Text size="xs" c="dimmed">Decrypt TLS traffic for deep inspection</Text>
</div>
</Group>
</UnstyledButton>
</Group> </Group>
</Box>
<Box className="flex-spacer"></Box>
{edit?null:<SegmentedControl
data={[
{ label: 'TCP', value: 'tcp' },
{ label: 'HTTP', value: 'http' },
]}
{...form.getInputProps('proto')}
/>}
</Box>
{!edit && <TLSServiceFields form={form} />} {!form.values.tls_enabled ? (
<PortAndInterface form={form} int_name="ip_int" port_name="port" label={"Public IP Interface and port (ipv4/ipv6 + CIDR allowed)"} />
) : (
<TLSServiceFields form={form} />
)}
</Tabs.Panel>
<Group justify='flex-end' mt="md" mb="sm"> <Tabs.Panel value="advanced" pt="xl" pb="md">
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button> <Card withBorder padding="lg" radius="md" bg="transparent">
</Group> {!edit ? (
<Box mb="xl">
<Text fw={500} mb="xs">Auto-Start Service</Text>
<Text size="sm" c="dimmed" mb="md">Automatically start this service as soon as it is created.</Text>
<Switch
size="md"
color="cyan"
{...form.getInputProps('autostart', { type: 'checkbox' })}
/>
</Box>
) : null}
{error?<> <Box>
<Text fw={500} mb="xs">Enable fail-open nfqueue</Text>
<Text size="sm" c="dimmed" mb="md">
Firegex uses nfqueue internally to handle packets. Enabling this option will allow packets to pass through the firewall automatically in case the regex engine is overloaded or crashes.
</Text>
<Switch
size="md"
color="cyan"
{...form.getInputProps('fail_open', { type: 'checkbox' })}
/>
</Box>
</Card>
</Tabs.Panel>
</Tabs>
{error ? <>
<Space h="md" /> <Space h="md" />
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}> <Notification icon={<ImCross size={14} />} color="red" onClose={() => { setError(null) }}>
Error: {error} Error: {error}
</Notification><Space h="md" /> </Notification>
</>:null} </> : null}
<Group justify='flex-end' mt="xl" pt="md" style={{ borderTop: '1px solid var(--fourth_color)' }}>
<Button variant="default" onClick={close}>Cancel</Button>
<Button loading={submitLoading} type="submit" color="cyan" disabled={edit ? !form.isDirty() : false}>
{edit ? "Save Changes" : "Create Service"}
</Button>
</Group>
</form> </form>
</Modal> </Modal>

View File

@ -0,0 +1,125 @@
import { Button, FileButton, Group, Modal, Notification, Space, Textarea, ActionIcon, Tooltip } from "@mantine/core";
import { nfproxy, Service } from "./utils";
import { useEffect, useState } from "react";
import { ImCross } from "react-icons/im";
import { okNotify } from "../../js/utils";
import { MdUploadFile, MdSave } from "react-icons/md";
import { TbCode } from "react-icons/tb";
import Editor from "@monaco-editor/react";
import { Box } from "@mantine/core";
export const EditFilterModal = ({ opened, onClose, service }: { opened: boolean, onClose: () => void, service?: Service }) => {
const close = () => {
onClose()
setError(null)
}
const [submitLoading, setSubmitLoading] = useState(false)
const [error, setError] = useState<string|null>(null)
const [code, setCode] = useState<string>("");
// Fetch code on open
useEffect(() => {
if (opened && service) {
nfproxy.getpyfilterscode(service.service_id).then(fetchedCode => {
setCode(fetchedCode || "");
}).catch(err => {
setError("Error fetching code: " + err);
});
}
}, [opened, service])
const handleSave = () => {
if (!service) return;
setSubmitLoading(true);
nfproxy.setpyfilterscode(service.service_id, code).then(res => {
if (!res) {
okNotify(`Service ${service.name} code updated`, `Successfully updated python code for proxy filter.`);
close();
} else {
setError("Error: " + res);
}
}).catch(err => {
setError("Error: " + err);
}).finally(() => {
setSubmitLoading(false);
});
}
const handleFileUpload = (file: File | null) => {
if (file) {
file.text().then(text => {
setCode(text);
okNotify("File loaded", "Python code loaded into editor. Remember to save changes.");
});
}
}
return (
<Modal
opened={opened && service != null}
onClose={close}
title={
<Group gap="xs">
<TbCode size={20} color="var(--accent-color)" />
Python Script Editor
</Group>
}
size="80%"
closeOnClickOutside={false}
centered
styles={{ body: { display: 'flex', flexDirection: 'column', height: '70vh' } }}
>
<Box style={{ flexGrow: 1, border: '1px solid var(--fourth_color)', borderRadius: 8, overflow: 'hidden' }}>
<Editor
height="100%"
language="python"
theme="vs-dark"
value={code}
onChange={(value) => setCode(value || "")}
options={{
minimap: { enabled: false },
fontSize: 14,
fontFamily: 'JetBrains Mono, monospace',
lineHeight: 1.5,
scrollBeyondLastLine: false,
smoothScrolling: true,
}}
/>
</Box>
<Space h="md" />
<Group justify="space-between" mt="auto">
<FileButton onChange={handleFileUpload} accept=".py" multiple={false}>
{(props) => (
<Button variant="light" color="gray" leftSection={<MdUploadFile size={16}/>} {...props}>
Load from .py file
</Button>
)}
</FileButton>
<Group>
<Button variant="default" onClick={close}>Cancel</Button>
<Button
color="cyan"
leftSection={<MdSave size={16} />}
onClick={handleSave}
loading={submitLoading}
>
Save Code
</Button>
</Group>
</Group>
{error ? (
<>
<Space h="md" />
<Notification icon={<ImCross size={14} />} color="red" onClose={() => setError(null)}>
{error}
</Notification>
</>
) : null}
</Modal>
);
}

View File

@ -1,22 +1,23 @@
import { ActionIcon, Badge, Box, Divider, Menu, Space, Title, Tooltip } from '@mantine/core'; import { ActionIcon, Badge, Box, Divider, Menu, Space, Title, Tooltip, Card, Group, Button, Text } from '@mantine/core';
import { useState } from 'react'; import { useState } from 'react';
import { FaPlay, FaStop } from 'react-icons/fa'; import { FaPlay, FaStop, FaTrash } from 'react-icons/fa';
import { nfproxy, Service, serviceQueryKey } from '../utils'; import { nfproxy, Service, serviceQueryKey } from '../utils';
import { MdDoubleArrow, MdOutlineArrowForwardIos } from "react-icons/md"
import YesNoModal from '../../YesNoModal'; import YesNoModal from '../../YesNoModal';
import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../../js/utils'; import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../../js/utils';
import { BsTrashFill } from 'react-icons/bs'; import { BsTrashFill } from 'react-icons/bs';
import { BiRename } from 'react-icons/bi' import { BiRename } from 'react-icons/bi';
import RenameForm from './RenameForm'; import RenameForm from './RenameForm';
import { MenuDropDownWithButton } from '../../MainLayout'; import { MenuDropDownWithButton } from '../../MainLayout';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { TbPlugConnected, TbShieldLock } from "react-icons/tb"; import { TbPlugConnected, TbShieldLock, TbHexagon } from "react-icons/tb";
import { FaFilter } from "react-icons/fa"; import { FaFilter } from "react-icons/fa";
import { IoSettingsSharp } from 'react-icons/io5'; import { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../AddEditService'; import AddEditService from '../AddEditService';
import { FaPencilAlt } from "react-icons/fa"; import { FaPencilAlt } from "react-icons/fa";
import { ExceptionWarning } from '../ExceptionWarning'; import { ExceptionWarning } from '../ExceptionWarning';
import TLSConfigModal from '../../TLSConfigModal'; import { MdChevronRight } from "react-icons/md";
import TLSAssociationBadge from '../../TLSAssociationBadge';
export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) { export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) {
@ -31,7 +32,6 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
const [deleteModal, setDeleteModal] = useState(false) const [deleteModal, setDeleteModal] = useState(false)
const [renameModal, setRenameModal] = useState(false) const [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false) const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const isMedium = isMediumScreen() const isMedium = isMediumScreen()
const stopService = async () => { const stopService = async () => {
@ -79,79 +79,87 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
} }
return <> return <>
<Box className='firegex__nfregex__rowbox'> <Card
<Box className="firegex__nfregex__row" style={{width:"100%", flexDirection: isMedium?"row":"column"}}> withBorder
<Box> radius="md"
<Box className="center-flex" style={{ justifyContent: "flex-start" }}> p="md"
<MdDoubleArrow size={30} style={{color: "white"}}/> w="100%"
<Title className="firegex__nfregex__name" ml="xs"> bg="transparent"
{service.name} className="firegex__clickable_row"
</Title> style={{ borderColor: 'var(--fourth_color)' }}
onClick={onClick}
>
<Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"}>
<Group wrap="nowrap" align="flex-start">
<Box style={{
width: 42,
height: 42,
borderRadius: 8,
backgroundColor: status_color === 'teal' ? 'rgba(32, 201, 151, 0.1)' : 'rgba(250, 82, 82, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: status_color === 'teal' ? 'var(--mantine-color-teal-filled)' : 'var(--mantine-color-red-filled)'
}}>
<TbHexagon size={24} />
</Box> </Box>
<Box className="center-flex" style={{ gap: 8, marginTop: 15, justifyContent: "flex-start" }}> <Box>
<Badge color={status_color} radius="md" size="lg" variant="filled">{service.status}</Badge> <Group gap="xs" align="center">
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}> <Text fw={600} size="md">{service.name}</Text>
:{service.port} <Badge color={status_color} variant="light" size="xs" radius="sm">
</Badge> {service.status.toUpperCase()}
{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> </Badge>
)} {service.target_type === 'tls' && <TLSAssociationBadge tlsStreamId={service.tls_stream_id} />}
</Group>
<Group gap="xs" mt={4}>
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
PORT: :{service.port}
</Text>
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
• {service.target_type === 'tls' ? 'decrypted traffic to ' : ''}{service.ip_int} ON {service.proto.toUpperCase()}
</Text>
</Group>
</Box> </Box>
{isMedium?null:<Space w="xl" />} </Group>
</Box>
<Box className={isMedium?"center-flex":"center-flex-row"}> <Group gap="xs" wrap="nowrap">
<Box className="center-flex-row"> <Group gap="xs" onClick={(e) => e.stopPropagation()}>
<Badge color={service.ip_int.match(regex_ipv4)?"cyan":"pink"} radius="sm" size="md" variant="filled">{service.ip_int} on {service.proto}</Badge>
<Space h="xs" />
<Box className='center-flex'>
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2}} /> {service.blocked_packets}</Badge>
<Space w="xs" />
<Badge color="orange" radius="sm" size="md" variant="filled"><FaPencilAlt style={{ marginBottom: -2}} /> {service.edited_packets}</Badge>
<Space w="xs" />
<Badge color="violet" radius="sm" size="md" variant="filled"><TbPlugConnected style={{ marginBottom: -2}} size={13} /> {service.n_filters}</Badge>
</Box>
</Box>
{isMedium?<Space w="xl" />:<Space h="lg" />}
<Box className="center-flex">
<ExceptionWarning service_id={service.service_id} /> <ExceptionWarning service_id={service.service_id} />
<Space w="sm"/> {service.status === "stop" ? (
<MenuDropDownWithButton> <Button variant="default" size="xs" leftSection={<FaPlay size={10} />} onClick={startService} loading={buttonLoading}>
<Menu.Item><b>Edit service</b></Menu.Item> Start
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item> </Button>
<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> <Button variant="default" size="xs" leftSection={<FaStop size={10} />} onClick={stopService} loading={buttonLoading}>
<Divider /> Stop
<Menu.Label><b>Danger zone</b></Menu.Label> </Button>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item> )}
</MenuDropDownWithButton>
<Space w="md"/> <Menu>
<Tooltip label="Stop service" zIndex={0} color="red"> <Menu.Target>
<ActionIcon color="red" loading={buttonLoading} <Button variant="default" size="xs" leftSection={<IoSettingsSharp size={10} />}>
onClick={stopService} size="xl" radius="md" variant="filled" Options
disabled={service.status === "stop"} </Button>
aria-describedby="tooltip-stop-id"> </Menu.Target>
<FaStop size="20px" /> <Menu.Dropdown>
</ActionIcon> <Menu.Label>Actions</Menu.Label>
</Tooltip> <Menu.Item leftSection={<BiRename size={14} />} onClick={() => setRenameModal(true)}>Rename Service</Menu.Item>
<Space w="md"/> <Menu.Item leftSection={<IoSettingsSharp size={14} />} onClick={() => setEditModal(true)}>Settings</Menu.Item>
<Tooltip label="Start service" zIndex={0} color="teal"> </Menu.Dropdown>
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading} </Menu>
variant="filled" disabled={!["stop","pause"].includes(service.status)?true:false}>
<FaPlay size="20px" /> <Button variant="default" size="xs" leftSection={<FaTrash size={10} />} onClick={() => setDeleteModal(true)}>
</ActionIcon> Delete
</Tooltip> </Button>
{isMedium?<Space w="xl" />:<Space w="md" />} </Group>
{onClick?<Box className='firegex__service_forward_btn'> <Tooltip label="View details" position="left">
<MdOutlineArrowForwardIos onClick={onClick} style={{cursor:"pointer"}} size={25} /> <Box style={{ display: 'flex', alignItems: 'center', color: 'var(--text-secondary)' }}>
</Box>:null} <MdChevronRight size={22} />
</Box> </Box>
</Box> </Tooltip>
</Box> </Group>
</Box> </Group>
</Card>
<YesNoModal <YesNoModal
title='Are you sure to delete this service?' title='Are you sure to delete this service?'
description={`You are going to delete the service '${service.port}', causing the stopping of the firewall and deleting all the filters associated. This will cause the shutdown of your service! ⚠️`} description={`You are going to delete the service '${service.port}', causing the stopping of the firewall and deleting all the filters associated. This will cause the shutdown of your service! ⚠️`}
@ -169,12 +177,5 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
onClose={()=>setEditModal(false)} onClose={()=>setEditModal(false)}
edit={service} edit={service}
/> />
<TLSConfigModal
opened={tlsModal}
onClose={() => setTlsModal(false)}
service={service}
updateTlsConfig={nfproxy.updatetlsconfig}
queryKey={serviceQueryKey}
/>
</> </>
} }

View File

@ -17,7 +17,6 @@ export const UploadFilterModal = ({ opened, onClose, service }: { opened: boolea
useEffect(() => { useEffect(() => {
if (opened && file){ if (opened && file){
file.bytes().then( code => { file.bytes().then( code => {
console.log(code.toString())
setSubmitLoading(true) setSubmitLoading(true)
nfproxy.setpyfilterscode(service?.service_id??"",code.toString()).then( res => { nfproxy.setpyfilterscode(service?.service_id??"",code.toString()).then( res => {
if (!res){ if (!res){

View File

@ -13,11 +13,8 @@ export type Service = {
edited_packets:number, edited_packets:number,
blocked_packets:number, blocked_packets:number,
fail_open:boolean, fail_open:boolean,
tls_enabled:boolean, target_type:string,
tls_cert?:string, tls_stream_id:string|null,
tls_key?:string,
ssl_port?:number,
clear_port?:number,
} }
export type TLSConfig = { export type TLSConfig = {
@ -33,15 +30,17 @@ export type ServiceAddForm = {
proto:string, proto:string,
ip_int:string, ip_int:string,
fail_open: boolean, fail_open: boolean,
tls_enabled?: boolean, target_type?: string,
tls_cert?: string | null, tls_stream_id?: string | null,
tls_key?: string | null,
} }
export type ServiceSettings = { export type ServiceSettings = {
port?:number, port?:number,
proto?:string,
ip_int?:string, ip_int?:string,
fail_open?: boolean, fail_open?: boolean,
target_type?: string,
tls_stream_id?: string | null,
} }
export type ServiceAddResponse = { export type ServiceAddResponse = {

View File

@ -1,15 +1,21 @@
import { Button, Group, Space, TextInput, Notification, Modal, Switch, SegmentedControl, Box, Tooltip, ActionIcon } from '@mantine/core'; import { Button, Group, Space, TextInput, Notification, Modal, Switch, SegmentedControl, Box, Tooltip, ActionIcon, Title, Tabs, Text, UnstyledButton, Badge, Card, Divider } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils'; import { okNotify, regex_ipv4, regex_ipv6, postapi } from '../../js/utils';
import TLSServiceFields from '../TLSServiceFields'; import TLSServiceFields from '../TLSServiceFields';
import { ImCross } from "react-icons/im" import { ImCross } from "react-icons/im"
import { nfregex, Service } from './utils'; import { nfregex, Service } from './utils';
import PortAndInterface from '../PortAndInterface'; import PortAndInterface from '../PortAndInterface';
import { IoMdInformationCircleOutline } from "react-icons/io"; import { IoMdInformationCircleOutline } from "react-icons/io";
import { TbNetwork, TbShieldLock, TbSettings, TbTarget } from "react-icons/tb";
import { ServiceAddForm as ServiceAddFormOriginal } from './utils'; import { ServiceAddForm as ServiceAddFormOriginal } from './utils';
type ServiceAddForm = ServiceAddFormOriginal & {autostart: boolean} type ServiceAddForm = ServiceAddFormOriginal & {
autostart: boolean,
tls_enabled: boolean,
tls_cert: string,
tls_key: string
}
function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>void, edit?:Service }) { function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>void, edit?:Service }) {
@ -20,7 +26,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
proto:edit?.proto??"tcp", proto:edit?.proto??"tcp",
fail_open: edit?.fail_open??false, fail_open: edit?.fail_open??false,
autostart: true, autostart: true,
tls_enabled: false, tls_enabled: edit?.target_type === 'tls',
tls_stream_id: edit?.tls_stream_id ?? undefined,
tls_cert: "", tls_cert: "",
tls_key: "" tls_key: ""
} }
@ -32,8 +39,6 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
port: (value) => (value>0 && value<65536) ? null : "Invalid port", port: (value) => (value>0 && value<65536) ? null : "Invalid port",
proto: (value) => ["tcp","udp"].includes(value) ? null : "Invalid protocol", proto: (value) => ["tcp","udp"].includes(value) ? null : "Invalid protocol",
ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address", ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address",
tls_cert: (value, values) => !edit && values.tls_enabled && !value ? "Certificate is required when TLS is enabled" : null,
tls_key: (value, values) => !edit && values.tls_enabled && !value ? "Private key is required when TLS is enabled" : null,
} }
}) })
@ -55,10 +60,34 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
const [submitLoading, setSubmitLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false)
const [error, setError] = useState<string|null>(null) const [error, setError] = useState<string|null>(null)
const submitRequest = (values: ServiceAddForm) => { const submitRequest = async (values: ServiceAddForm) => {
setSubmitLoading(true) setSubmitLoading(true)
let streamId = values.tls_stream_id;
if (edit){ if (edit){
nfregex.settings(edit.service_id, { port: values.port, proto: values.proto, ip_int: values.ip_int, fail_open: values.fail_open }).then( res => { if (values.tls_enabled && values.tls_cert && values.tls_key) {
try {
const res = await postapi('tls/streams', {
name: values.name,
ip_int: values.ip_int,
port: values.port,
cert: values.tls_cert,
key: values.tls_key
});
if (res.status !== "ok") {
setSubmitLoading(false);
setError("Failed to create TLS Stream!");
return;
}
streamId = res.stream_id;
} catch (err: any) {
setSubmitLoading(false);
setError("Failed to create TLS Stream: " + err);
return;
}
}
nfregex.settings(edit.service_id, { port: values.port, proto: values.proto, ip_int: values.ip_int, fail_open: values.fail_open, target_type: values.tls_enabled ? "tls" : "flow", tls_stream_id: streamId }).then( res => {
if (!res){ if (!res){
setSubmitLoading(false) setSubmitLoading(false)
close(); close();
@ -69,15 +98,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
setError("Request Failed! [ "+err+" ]") setError("Request Failed! [ "+err+" ]")
}) })
}else{ }else{
if (values.tls_enabled && values.tls_cert && values.tls_key) {
try {
const res = await postapi('tls/streams', {
name: values.name,
ip_int: values.ip_int,
port: values.port,
cert: values.tls_cert,
key: values.tls_key
});
if (res.status !== "ok") {
setSubmitLoading(false);
setError("Failed to create TLS Stream!");
return;
}
streamId = res.stream_id;
} catch (err: any) {
setSubmitLoading(false);
setError("Failed to create TLS Stream: " + err);
return;
}
}
const payload = { const payload = {
name: values.name, name: values.name,
port: values.port, port: values.port,
proto: values.proto, proto: values.proto,
ip_int: values.ip_int, ip_int: values.ip_int,
fail_open: values.fail_open, fail_open: values.fail_open,
tls_enabled: values.tls_enabled, target_type: values.tls_enabled ? "tls" : "flow",
tls_cert: values.tls_enabled ? values.tls_cert : null, tls_stream_id: streamId,
tls_key: values.tls_enabled ? values.tls_key : null,
} }
nfregex.servicesadd(payload).then( res => { nfregex.servicesadd(payload).then( res => {
if (res.status === "ok" && res.service_id){ if (res.status === "ok" && res.service_id){
@ -97,68 +147,134 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
} }
return <Modal size="xl" title={edit?`Editing ${edit.name} service`:"Add a new service"} opened={opened} onClose={close} closeOnClickOutside={false} centered> return <Modal size="xl" title={edit?`Editing ${edit.name} service`:"Add a new service"} opened={opened} onClose={close} closeOnClickOutside={false} centered styles={{ body: { maxHeight: 'calc(100vh - 150px)', overflowY: 'auto' } }}>
<form onSubmit={form.onSubmit(submitRequest)}> <form onSubmit={form.onSubmit(submitRequest)}>
{!edit?<TextInput <Tabs defaultValue="general" color="cyan" variant="outline" radius="md">
label="Service name" <Tabs.List>
placeholder="Challenge 01" <Tabs.Tab value="general" leftSection={<TbSettings size={16} />}>
{...form.getInputProps('name')} General
/>:null} </Tabs.Tab>
<Space h="md" /> <Tabs.Tab value="advanced" leftSection={<TbShieldLock size={16} />}>
<PortAndInterface form={form} int_name="ip_int" port_name="port" label={"Public IP Interface and port (ipv4/ipv6 + CIDR allowed)"} /> Advanced
<Space h="md" /> </Tabs.Tab>
</Tabs.List>
<Box className='center-flex'> <Tabs.Panel value="general" pt="xl" pb="md">
<Box> {!edit ? (
{!edit?<Switch <TextInput
label="Auto-Start Service" label="Service name"
{...form.getInputProps('autostart', { type: 'checkbox' })} placeholder="Challenge 01"
/>:null} size="md"
<Space h="sm" /> {...form.getInputProps('name')}
<Group gap="xs" wrap="nowrap" align="center">
<Switch
label="Enable fail-open nfqueue"
{...form.getInputProps('fail_open', { type: 'checkbox' })}
/> />
<Tooltip ) : null}
label={<> <Space h="md" />
Firegex use internally nfqueue to handle packets<br />enabling this option will allow packets to pass through the firewall <br /> in case the filtering is too slow or too many traffic is coming<br /> <Title order={6} mb="xs">Protocol</Title>
</>} <SegmentedControl
zIndex={1000000} fullWidth
size="md"
color="cyan"
data={[
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
]}
{...form.getInputProps('proto')}
disabled={edit !== undefined}
/>
<Space h="xl" />
<Divider mb="xl" label="Target Setup" labelPosition="center" />
<Title order={6} mb="xs">Target Type</Title>
<Group grow mb="xl">
<UnstyledButton
onClick={() => form.setFieldValue('tls_enabled', false)}
style={{
padding: '1rem',
borderRadius: '12px',
border: form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)',
backgroundColor: form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)',
transition: 'all 0.2s ease'
}}
> >
<ActionIcon <Group wrap="nowrap">
variant="subtle" <TbNetwork size={32} color={!form.values.tls_enabled ? "var(--accent-color)" : "gray"} />
color="gray" <div>
size="sm" <Text fw={600}>Direct Flow</Text>
onClick={(e: any) => { e.preventDefault(); e.stopPropagation(); }} <Text size="xs" c="dimmed">Forward traffic directly without decryption</Text>
> </div>
<IoMdInformationCircleOutline size={18} /> </Group>
</ActionIcon> </UnstyledButton>
</Tooltip> <UnstyledButton
onClick={() => { if (form.values.proto === 'tcp') form.setFieldValue('tls_enabled', true) }}
style={{
padding: '1rem',
borderRadius: '12px',
border: !form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)',
backgroundColor: !form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)',
opacity: form.values.proto !== 'tcp' && !form.values.tls_enabled ? 0.5 : 1,
transition: 'all 0.2s ease',
cursor: form.values.proto !== 'tcp' ? 'not-allowed' : 'pointer'
}}
>
<Group wrap="nowrap">
<TbShieldLock size={32} color={form.values.tls_enabled ? "var(--accent-color)" : "gray"} />
<div>
<Text fw={600}>TLS Decrypted</Text>
<Text size="xs" c="dimmed">Decrypt TLS traffic for deep inspection</Text>
</div>
</Group>
</UnstyledButton>
</Group> </Group>
</Box>
<Box className="flex-spacer"></Box>
<SegmentedControl
data={[
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
]}
{...form.getInputProps('proto')}
/>
</Box>
{!edit && <TLSServiceFields form={form} disabled={form.values.proto !== 'tcp'} />} {!form.values.tls_enabled ? (
<PortAndInterface form={form} int_name="ip_int" port_name="port" label={"Public IP Interface and port (ipv4/ipv6 + CIDR allowed)"} />
) : (
<TLSServiceFields form={form} disabled={form.values.proto !== 'tcp'} />
)}
</Tabs.Panel>
<Group justify='flex-end' mt="md" mb="sm"> <Tabs.Panel value="advanced" pt="xl" pb="md">
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button> <Card withBorder padding="lg" radius="md" bg="transparent">
</Group> {!edit ? (
<Box mb="xl">
<Text fw={500} mb="xs">Auto-Start Service</Text>
<Text size="sm" c="dimmed" mb="md">Automatically start this service as soon as it is created.</Text>
<Switch
size="md"
color="cyan"
{...form.getInputProps('autostart', { type: 'checkbox' })}
/>
</Box>
) : null}
{error?<> <Box>
<Text fw={500} mb="xs">Enable fail-open nfqueue</Text>
<Text size="sm" c="dimmed" mb="md">
Firegex uses nfqueue internally to handle packets. Enabling this option will allow packets to pass through the firewall automatically in case the regex engine is overloaded or crashes.
</Text>
<Switch
size="md"
color="cyan"
{...form.getInputProps('fail_open', { type: 'checkbox' })}
/>
</Box>
</Card>
</Tabs.Panel>
</Tabs>
{error ? <>
<Space h="md" /> <Space h="md" />
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}> <Notification icon={<ImCross size={14} />} color="red" onClose={() => { setError(null) }}>
Error: {error} Error: {error}
</Notification><Space h="md" /> </Notification>
</>:null} </> : null}
<Group justify='flex-end' mt="xl" pt="md" style={{ borderTop: '1px solid var(--fourth_color)' }}>
<Button variant="default" onClick={close}>Cancel</Button>
<Button loading={submitLoading} type="submit" color="cyan" disabled={edit ? !form.isDirty() : false}>
{edit ? "Save Changes" : "Create Service"}
</Button>
</Group>
</form> </form>
</Modal> </Modal>

View File

@ -1,21 +1,20 @@
import { ActionIcon, Badge, Box, Divider, Grid, Menu, Space, Title, Tooltip } from '@mantine/core'; import { ActionIcon, Badge, Box, Divider, Menu, Space, Title, Tooltip, Card, Group, Button, Text } from '@mantine/core';
import { useState } from 'react'; import { useState } from 'react';
import { FaPlay, FaStop } from 'react-icons/fa'; import { FaPlay, FaStop, FaTrash } from 'react-icons/fa';
import { nfregex, Service, serviceQueryKey } from '../utils'; import { nfregex, Service, serviceQueryKey } from '../utils';
import { MdDoubleArrow, MdOutlineArrowForwardIos } from "react-icons/md"
import YesNoModal from '../../YesNoModal'; import YesNoModal from '../../YesNoModal';
import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../../js/utils'; import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../../js/utils';
import { BsTrashFill } from 'react-icons/bs'; import { BsTrashFill } from 'react-icons/bs';
import { BiRename } from 'react-icons/bi' import { BiRename } from 'react-icons/bi';
import RenameForm from './RenameForm'; import RenameForm from './RenameForm';
import { MenuDropDownWithButton } from '../../MainLayout'; import { MenuDropDownWithButton } from '../../MainLayout';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { FaFilter } from "react-icons/fa";
import { VscRegex } from "react-icons/vsc";
import { IoSettingsSharp } from 'react-icons/io5'; import { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../AddEditService'; import AddEditService from '../AddEditService';
import { TbShieldLock } from "react-icons/tb"; import { TbShieldLock, TbHexagon } from "react-icons/tb";
import TLSConfigModal from '../../TLSConfigModal'; import { MdChevronRight } from "react-icons/md";
import TLSAssociationBadge from '../../TLSAssociationBadge';
export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) { export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) {
@ -30,7 +29,6 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
const [deleteModal, setDeleteModal] = useState(false) const [deleteModal, setDeleteModal] = useState(false)
const [renameModal, setRenameModal] = useState(false) const [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false) const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const isMedium = isMediumScreen() const isMedium = isMediumScreen()
const stopService = async () => { const stopService = async () => {
@ -78,75 +76,86 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
} }
return <> return <>
<Box className='firegex__nfregex__rowbox'> <Card
<Box className="firegex__nfregex__row" style={{width:"100%", flexDirection: isMedium?"row":"column"}}> withBorder
<Box> radius="md"
<Box className="center-flex" style={{ justifyContent: "flex-start" }}> p="md"
<MdDoubleArrow size={30} style={{color: "white"}}/> w="100%"
<Title className="firegex__nfregex__name" ml="xs"> bg="transparent"
{service.name} className="firegex__clickable_row"
</Title> style={{ borderColor: 'var(--fourth_color)' }}
onClick={onClick}
>
<Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"}>
<Group wrap="nowrap" align="flex-start">
<Box style={{
width: 42,
height: 42,
borderRadius: 8,
backgroundColor: status_color === 'teal' ? 'rgba(32, 201, 151, 0.1)' : 'rgba(250, 82, 82, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: status_color === 'teal' ? 'var(--mantine-color-teal-filled)' : 'var(--mantine-color-red-filled)'
}}>
<TbHexagon size={24} />
</Box> </Box>
<Box className="center-flex" style={{ gap: 8, marginTop: 15, justifyContent: "flex-start" }}> <Box>
<Badge color={status_color} radius="md" size="lg" variant="filled">{service.status}</Badge> <Group gap="xs" align="center">
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}> <Text fw={600} size="md">{service.name}</Text>
:{service.port} <Badge color={status_color} variant="light" size="xs" radius="sm">
</Badge> {service.status.toUpperCase()}
{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> </Badge>
)} {service.target_type === 'tls' && <TLSAssociationBadge tlsStreamId={service.tls_stream_id} />}
</Group>
<Group gap="xs" mt={4}>
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
PORT: :{service.port}
</Text>
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
• {service.target_type === 'tls' ? 'decrypted traffic to ' : ''}{service.ip_int} ON {service.proto.toUpperCase()}
</Text>
</Group>
</Box> </Box>
{isMedium?null:<Space w="xl" />} </Group>
</Box>
<Box className={isMedium?"center-flex":"center-flex-row"}> <Group gap="xs" wrap="nowrap">
<Box className="center-flex-row"> <Group gap="xs" onClick={(e) => e.stopPropagation()}>
<Badge color={service.ip_int.match(regex_ipv4)?"cyan":"pink"} radius="sm" size="md" variant="filled">{service.ip_int} on {service.proto}</Badge> {service.status === "stop" ? (
<Space h="xs" /> <Button variant="default" size="xs" leftSection={<FaPlay size={10} />} onClick={startService} loading={buttonLoading}>
<Box className='center-flex'> Start
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2}} /> {service.n_packets}</Badge> </Button>
<Space w="xs" /> ) : (
<Badge color="violet" radius="sm" size="md" variant="filled"><VscRegex style={{ marginBottom: -2}} size={13} /> {service.n_regex}</Badge> <Button variant="default" size="xs" leftSection={<FaStop size={10} />} onClick={stopService} loading={buttonLoading}>
Stop
</Button>
)}
<Menu>
<Menu.Target>
<Button variant="default" size="xs" leftSection={<IoSettingsSharp size={10} />}>
Options
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Actions</Menu.Label>
<Menu.Item leftSection={<BiRename size={14} />} onClick={() => setRenameModal(true)}>Rename Service</Menu.Item>
<Menu.Item leftSection={<IoSettingsSharp size={14} />} onClick={() => setEditModal(true)}>Settings</Menu.Item>
</Menu.Dropdown>
</Menu>
<Button variant="default" size="xs" leftSection={<FaTrash size={10} />} onClick={() => setDeleteModal(true)}>
Delete
</Button>
</Group>
<Tooltip label="View details" position="left">
<Box style={{ display: 'flex', alignItems: 'center', color: 'var(--text-secondary)' }}>
<MdChevronRight size={22} />
</Box> </Box>
</Box> </Tooltip>
{isMedium?<Space w="xl" />:<Space h="lg" />} </Group>
<Box className="center-flex"> </Group>
<MenuDropDownWithButton> </Card>
<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>
</MenuDropDownWithButton>
<Space w="md"/>
<Tooltip label="Stop service" zIndex={0} color="red">
<ActionIcon color="red" loading={buttonLoading}
onClick={stopService} size="xl" radius="md" variant="filled"
disabled={service.status === "stop"}
aria-describedby="tooltip-stop-id">
<FaStop size="20px" />
</ActionIcon>
</Tooltip>
<Space w="md"/>
<Tooltip label="Start service" zIndex={0} color="teal">
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading}
variant="filled" disabled={!["stop","pause"].includes(service.status)?true:false}>
<FaPlay size="20px" />
</ActionIcon>
</Tooltip>
{isMedium?<Space w="xl" />:<Space w="md" />}
{onClick?<Box className='firegex__service_forward_btn'>
<MdOutlineArrowForwardIos onClick={onClick} style={{cursor:"pointer"}} size={25} />
</Box>:null}
</Box>
</Box>
</Box>
</Box>
<YesNoModal <YesNoModal
title='Are you sure to delete this service?' title='Are you sure to delete this service?'
description={`You are going to delete the service '${service.port}', causing the stopping of the firewall and deleting all the regex associated. This will cause the shutdown of your service! ⚠️`} description={`You are going to delete the service '${service.port}', causing the stopping of the firewall and deleting all the regex associated. This will cause the shutdown of your service! ⚠️`}
@ -164,12 +173,5 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
onClose={()=>setEditModal(false)} onClose={()=>setEditModal(false)}
edit={service} edit={service}
/> />
<TLSConfigModal
opened={tlsModal}
onClose={() => setTlsModal(false)}
service={service}
updateTlsConfig={nfregex.updatetlsconfig}
queryKey={serviceQueryKey}
/>
</> </>
} }

View File

@ -13,11 +13,8 @@ export type Service = {
n_packets:number, n_packets:number,
n_regex:number, n_regex:number,
fail_open:boolean, fail_open:boolean,
tls_enabled:boolean, target_type:string,
tls_cert?: string, tls_stream_id:string|null,
tls_key?: string,
ssl_port?: number,
clear_port?: number,
} }
export type ServiceAddForm = { export type ServiceAddForm = {
@ -26,9 +23,8 @@ export type ServiceAddForm = {
proto:string, proto:string,
ip_int:string, ip_int:string,
fail_open: boolean, fail_open: boolean,
tls_enabled?: boolean, target_type?: string,
tls_cert?: string | null, tls_stream_id?: string | null,
tls_key?: string | null,
} }
export type ServiceSettings = { export type ServiceSettings = {
@ -36,9 +32,8 @@ export type ServiceSettings = {
proto?:string, proto?:string,
ip_int?:string, ip_int?:string,
fail_open?: boolean, fail_open?: boolean,
tls_enabled?: boolean, target_type?: string,
tls_cert?: string | null, tls_stream_id?: string | null,
tls_key?: string | null,
} }
export type ServiceAddResponse = { export type ServiceAddResponse = {

View File

@ -2,11 +2,13 @@ import { Divider, Group, MantineColor, ScrollArea, Text, ThemeIcon, Title, Unsty
import { useState } from "react"; import { useState } from "react";
import { TbPlugConnected } from "react-icons/tb"; import { TbPlugConnected } from "react-icons/tb";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useQueryClient } from "@tanstack/react-query";
import { GrDirections } from "react-icons/gr"; import { GrDirections } from "react-icons/gr";
import { PiWallLight } from "react-icons/pi"; import { PiWallLight } from "react-icons/pi";
import { useNavbarStore } from "../../js/store"; import { useNavbarStore } from "../../js/store";
import { getMainPath } from "../../js/utils"; import { getMainPath, getapi, postapi, okNotify, errorNotify } from "../../js/utils";
import { BsRegex } from "react-icons/bs"; import { BsRegex } from "react-icons/bs";
import { MdDownload, MdUpload } from "react-icons/md";
function NavBarButton({ navigate, closeNav, name, icon, color, disabled, onClick }: function NavBarButton({ navigate, closeNav, name, icon, color, disabled, onClick }:
{ navigate?: string, closeNav: () => void, name: string, icon: any, color: MantineColor, disabled?: boolean, onClick?: CallableFunction }) { { navigate?: string, closeNav: () => void, name: string, icon: any, color: MantineColor, disabled?: boolean, onClick?: CallableFunction }) {
@ -29,6 +31,7 @@ function NavBarButton({ navigate, closeNav, name, icon, color, disabled, onClick
export default function NavBar() { export default function NavBar() {
const [toggle, setToggleState] = useState(false); const [toggle, setToggleState] = useState(false);
const { navOpened, closeNav } = useNavbarStore() const { navOpened, closeNav } = useNavbarStore()
const queryClient = useQueryClient()
return <AppShell.Navbar p="md" hidden={!navOpened}> return <AppShell.Navbar p="md" hidden={!navOpened}>
<Box px="xs" mt="xs"> <Box px="xs" mt="xs">
@ -40,11 +43,48 @@ export default function NavBar() {
<NavBarButton navigate="firewall" closeNav={closeNav} name="Firewall Rules" color="red" icon={<PiWallLight size={19} />} /> <NavBarButton navigate="firewall" closeNav={closeNav} name="Firewall Rules" color="red" icon={<PiWallLight size={19} />} />
<NavBarButton navigate="porthijack" closeNav={closeNav} name="Hijack Port to Proxy" color="blue" icon={<GrDirections size={19} />} /> <NavBarButton navigate="porthijack" closeNav={closeNav} name="Hijack Port to Proxy" color="blue" icon={<GrDirections size={19} />} />
<NavBarButton navigate="nfproxy" closeNav={closeNav} name="Netfilter Proxy" color="lime" icon={<TbPlugConnected size={19} />} /> <NavBarButton navigate="nfproxy" closeNav={closeNav} name="Netfilter Proxy" color="lime" icon={<TbPlugConnected size={19} />} />
{/* <Box px="xs" mt="lg"> <NavBarButton navigate="tls-decrypt" closeNav={closeNav} name="TLS Decryption" color="cyan" icon={<TbPlugConnected size={19} />} />
<Title order={5}>Experimental Features 🧪</Title> <Box px="xs" mt="lg">
<Title order={5}>Backup & Restore</Title>
</Box> </Box>
<Text></Text> <Divider my="xs" />
<Divider my="xs" /> */} <NavBarButton closeNav={closeNav} name="Export Backup" color="yellow" icon={<MdDownload size={19} />} onClick={() => {
getapi('export').then(data => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `firegex_backup_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
okNotify("Backup Exported", "Your configuration backup has been downloaded.");
}).catch(err => errorNotify("Export Failed", err.toString()));
}} />
<NavBarButton closeNav={closeNav} name="Import Backup" color="orange" icon={<MdUpload size={19} />} onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
postapi('import', data).then(() => {
okNotify("Backup Imported", "Configuration has been restored successfully.");
queryClient.invalidateQueries();
}).catch(err => errorNotify("Import Failed", err.toString()));
} catch (err: any) {
errorNotify("Invalid JSON", err.toString());
}
};
reader.readAsText(file);
};
input.click();
}} />
</Box> </Box>
</AppShell.Navbar> </AppShell.Navbar>

View File

@ -1,10 +1,11 @@
import { Button, Group, Space, TextInput, Notification, Modal, Switch, SegmentedControl, Box } from '@mantine/core'; import { Button, Group, Space, TextInput, Notification, Modal, Switch, SegmentedControl, Box, Card, Divider, Tabs, Title } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useState } from 'react'; import { useState } from 'react';
import { okNotify, regex_ipv6_no_cidr, regex_ipv4_no_cidr } from '../../js/utils'; import { okNotify, regex_ipv6_no_cidr, regex_ipv4_no_cidr } from '../../js/utils';
import { ImCross } from "react-icons/im" import { ImCross } from "react-icons/im"
import { porthijack } from './utils'; import { porthijack } from './utils';
import PortAndInterface from '../PortAndInterface'; import PortAndInterface from '../PortAndInterface';
import { TbSettings, TbShieldLock } from 'react-icons/tb';
type ServiceAddForm = { type ServiceAddForm = {
name:string, name:string,
@ -66,36 +67,68 @@ function AddNewService({ opened, onClose }:{ opened:boolean, onClose:()=>void })
} }
return <Modal size="xl" title="Add a new service" opened={opened} onClose={close} closeOnClickOutside={false} centered> return <Modal size="xl" title="Add a new service" opened={opened} onClose={close} closeOnClickOutside={false} centered>
<form onSubmit={form.onSubmit(submitRequest)}> <form onSubmit={form.onSubmit(submitRequest)}>
<TextInput <Tabs defaultValue="general" color="cyan" variant="outline" radius="md">
label="Service name" <Tabs.List>
placeholder="Challenge 01" <Tabs.Tab value="general" leftSection={<TbSettings size={16} />}>
{...form.getInputProps('name')} General
/> </Tabs.Tab>
<Space h="md" /> <Tabs.Tab value="advanced" leftSection={<TbShieldLock size={16} />}>
<PortAndInterface form={form} int_name="ip_src" port_name="public_port" label="Public IP Address and port (ipv4/ipv6)" /> Advanced
<Space h="md" /> </Tabs.Tab>
<PortAndInterface form={form} int_name="ip_dst" port_name="proxy_port" label="Proxy/Internal IP Address and port (ipv4/ipv6)" /> </Tabs.List>
<Space h="md" />
<Box className='center-flex'> <Tabs.Panel value="general" pt="xl" pb="md">
<Switch <TextInput
label="Auto-Start Service" label="Service name"
{...form.getInputProps('autostart', { type: 'checkbox' })} placeholder="Challenge 01"
/> size="md"
<Box className="flex-spacer" /> {...form.getInputProps('name')}
<SegmentedControl />
data={[ <Space h="md" />
{ label: 'TCP', value: 'tcp' }, <Title order={6} mb="xs">Protocol</Title>
{ label: 'UDP', value: 'udp' }, <SegmentedControl
]} fullWidth
{...form.getInputProps('proto')} size="md"
/> color="cyan"
</Box> data={[
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
]}
{...form.getInputProps('proto')}
/>
<Group justify='flex-end' mt="md" mb="sm"> <Space h="xl" />
<Button loading={submitLoading} type="submit">Add Service</Button> <Divider mb="xl" label="Interface & Port Setup" labelPosition="center" />
<PortAndInterface form={form} int_name="ip_src" port_name="public_port" label="Public IP Address and port (ipv4/ipv6)" />
<Space h="md" />
<PortAndInterface form={form} int_name="ip_dst" port_name="proxy_port" label="Proxy/Internal IP Address and port (ipv4/ipv6)" />
</Tabs.Panel>
<Tabs.Panel value="advanced" pt="xl" pb="md">
<Card withBorder padding="lg" radius="md" bg="transparent">
<Box>
<Group justify="space-between" mb="xs">
<Title order={6}>Auto-Start Service</Title>
<Switch
size="md"
color="cyan"
{...form.getInputProps('autostart', { type: 'checkbox' })}
/>
</Group>
<Title order={6} size="sm" c="dimmed" style={{ fontWeight: "normal" }}>
Automatically start this service as soon as it is created.
</Title>
</Box>
</Card>
</Tabs.Panel>
</Tabs>
<Group justify='flex-end' mt="xl" mb="sm">
<Button variant="default" onClick={close}>Cancel</Button>
<Button loading={submitLoading} type="submit" color="cyan">Add Service</Button>
</Group> </Group>
{error?<> {error?<>

View File

@ -1,16 +1,16 @@
import { ActionIcon, Badge, Box, Divider, Menu, Space, Title, Tooltip } from '@mantine/core'; import { ActionIcon, Badge, Box, Divider, Menu, Space, Title, Tooltip, Card, Group, Button, Text } from '@mantine/core';
import { useState } from 'react'; import { useState } from 'react';
import { FaPlay, FaStop } from 'react-icons/fa'; import { FaPlay, FaStop, FaTrash } from 'react-icons/fa';
import { porthijack, Service } from '../utils'; import { porthijack, Service } from '../utils';
import YesNoModal from '../../YesNoModal'; import YesNoModal from '../../YesNoModal';
import { errorNotify, isMediumScreen, okNotify } from '../../../js/utils'; import { errorNotify, isMediumScreen, okNotify } from '../../../js/utils';
import { BsArrowRepeat, BsTrashFill } from 'react-icons/bs'; import { BsArrowRepeat, BsTrashFill } from 'react-icons/bs';
import { BiRename } from 'react-icons/bi' import { BiRename } from 'react-icons/bi';
import RenameForm from './RenameForm'; import RenameForm from './RenameForm';
import ChangeDestination from './ChangeDestination'; import ChangeDestination from './ChangeDestination';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { MenuDropDownWithButton } from '../../MainLayout'; import { MenuDropDownWithButton } from '../../MainLayout';
import { MdDoubleArrow } from "react-icons/md"; import { TbHexagon } from "react-icons/tb";
export default function ServiceRow({ service }:{ service:Service }) { export default function ServiceRow({ service }:{ service:Service }) {
@ -69,67 +69,80 @@ export default function ServiceRow({ service }:{ service:Service }) {
} }
return <> return <>
<Box className='firegex__nfregex__rowbox'> <Card
<Box className="firegex__nfregex__row" style={{width:"100%", flexDirection: isMedium?"row":"column"}}> withBorder
<Box> radius="md"
<Box className="center-flex" style={{ justifyContent: "flex-start" }}> p="md"
<MdDoubleArrow size={30} style={{color: "white"}}/> w="100%"
<Title className="firegex__nfregex__name" ml="xs"> bg="transparent"
{service.name} style={{
</Title> borderColor: 'var(--fourth_color)',
transition: 'border-color 0.2s ease',
'&:hover': { borderColor: 'var(--mantine-color-dark-4)' }
}}
>
<Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"}>
<Group wrap="nowrap" align="flex-start">
<Box style={{
width: 42,
height: 42,
borderRadius: 8,
backgroundColor: status_color === 'teal' ? 'rgba(32, 201, 151, 0.1)' : 'rgba(250, 82, 82, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: status_color === 'teal' ? 'var(--mantine-color-teal-filled)' : 'var(--mantine-color-red-filled)'
}}>
<TbHexagon size={24} />
</Box> </Box>
<Box className="center-flex" style={{ gap: 8, marginTop: 15, justifyContent: "flex-start" }}> <Box>
<Badge color={status_color} radius="md" size="md" variant="filled">{service.active?"ENABLED":"DISABLED"}</Badge> <Group gap="xs" align="center">
<Badge color={service.proto === "tcp"?"cyan":"orange"} radius="md" size="md" variant="filled"> <Text fw={600} size="md">{service.name}</Text>
{service.proto} <Badge color={status_color} variant="light" size="xs" radius="sm">
</Badge> {service.active ? "ENABLED" : "DISABLED"}
</Badge>
</Group>
<Group gap="xs" mt={4}>
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
FROM {service.ip_src}:{service.public_port}
</Text>
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
• TO {service.ip_dst}:{service.proxy_port} ON {service.proto.toUpperCase()}
</Text>
</Group>
</Box> </Box>
{isMedium?null:<Space w="xl" />} </Group>
</Box>
<Box className={isMedium?"center-flex":"center-flex-row"}> <Group gap="xs" onClick={(e) => e.stopPropagation()}>
<Box className="center-flex-row"> {service.active ? (
<Badge color="lime" radius="sm" size="lg" variant="filled"> <Button variant="default" size="xs" leftSection={<FaStop size={10} />} onClick={stopService} loading={buttonLoading}>
FROM {service.ip_src} :{service.public_port} Stop
</Badge> </Button>
<Space h="sm" /> ) : (
<Badge color="blue" radius="sm" size="lg" variant="filled"> <Button variant="default" size="xs" leftSection={<FaPlay size={10} />} onClick={startService} loading={buttonLoading}>
<Box className="center-flex"> Start
TO {service.ip_dst} :{service.proxy_port} </Button>
</Box> )}
</Badge>
</Box> <Menu>
{isMedium?<Space w="xl" />:<Space h="lg" />} <Menu.Target>
<Box className="center-flex"> <Button variant="default" size="xs" leftSection={<BsArrowRepeat size={10} />}>
<MenuDropDownWithButton> Options
<Menu.Label><b>Rename service</b></Menu.Label> </Button>
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item> </Menu.Target>
<Menu.Label><b>Change destination</b></Menu.Label> <Menu.Dropdown>
<Menu.Item leftSection={<BsArrowRepeat size={18} />} onClick={()=>setChangeDestModal(true)}>Change hijacking destination</Menu.Item> <Menu.Label>Actions</Menu.Label>
<Divider /> <Menu.Item leftSection={<BiRename size={14} />} onClick={()=>setRenameModal(true)}>Rename service</Menu.Item>
<Menu.Label><b>Danger zone</b></Menu.Label> <Menu.Item leftSection={<BsArrowRepeat size={14} />} onClick={()=>setChangeDestModal(true)}>Change hijacking destination</Menu.Item>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item> </Menu.Dropdown>
</MenuDropDownWithButton> </Menu>
<Space w="md"/>
<Tooltip label="Stop service" zIndex={0} color="red"> <Button variant="default" size="xs" leftSection={<FaTrash size={10} />} onClick={() => setDeleteModal(true)}>
<ActionIcon color="red" loading={buttonLoading} Delete
onClick={stopService} size="xl" radius="md" variant="filled" </Button>
disabled={!service.active} </Group>
aria-describedby="tooltip-stop-id"> </Group>
<FaStop size="20px" /> </Card>
</ActionIcon>
</Tooltip>
<Space w="md"/>
<Tooltip label="Start service" zIndex={0} color="teal">
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading}
variant="filled" disabled={service.active}>
<FaPlay size="20px" />
</ActionIcon>
</Tooltip>
</Box>
</Box>
</Box>
</Box>
<YesNoModal <YesNoModal
title='Are you sure to delete this service?' title='Are you sure to delete this service?'

View File

@ -1,4 +1,4 @@
import { Text, Badge, Space, ActionIcon, Tooltip, Box } from '@mantine/core'; import { Text, Badge, Space, ActionIcon, Tooltip, Box, Card, Group } from '@mantine/core';
import { useState } from 'react'; import { useState } from 'react';
import { PyFilter } from '../../js/models'; import { PyFilter } from '../../js/models';
import { errorNotify, isMediumScreen, okNotify } from '../../js/utils'; import { errorNotify, isMediumScreen, okNotify } from '../../js/utils';
@ -21,24 +21,43 @@ export default function PyFilterView({ filterInfo }:{ filterInfo:PyFilter }) {
}).catch( err => errorNotify(`Filter ${filterInfo.name} ${filterInfo.active?"deactivation":"activation"} failed!`,`Error: ${err}`)) }).catch( err => errorNotify(`Filter ${filterInfo.name} ${filterInfo.active?"deactivation":"activation"} failed!`,`Error: ${err}`))
} }
return <Box my="sm" display="flex" style={{alignItems:"center"}}> return (
<Card
withBorder
shadow="sm"
radius="md"
p="sm"
mb="sm"
bg="transparent"
style={{ borderColor: 'var(--fourth_color)', transition: 'border-color 0.2s ease' }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" align="center">
<Badge size="xs" circle color={filterInfo.active ? "lime" : "red"} variant="filled" />
<Text fw={600} style={{ fontFamily: 'monospace' }}>{filterInfo.name}</Text>
</Group>
<Box className="firegex__regexview__pyfilter_text" style={{ width: "100%", alignItems: "center"}} display="flex" > <Group gap="xs" wrap="nowrap">
<Badge size="sm" radius="lg" mr="sm" color={filterInfo.active?"lime":"red"} variant="filled" /> <Badge size="sm" color="yellow" variant="light" leftSection={<FaFilter size={10} style={{ marginTop: 2 }} />}>
{filterInfo.name} {filterInfo.blocked_packets}
<Box className='flex-spacer' /> </Badge>
<Badge size="sm" color="orange" variant="light" leftSection={<FaPencilAlt size={10} style={{ marginTop: 2 }} />}>
{filterInfo.edited_packets}
</Badge>
<Space w="xs" /> <Space w="xs" />
{isMedium?<> <Tooltip label={filterInfo.active ? "Deactivate" : "Activate"} color={filterInfo.active ? "orange" : "teal"}>
<Badge size="md" radius="md" color="yellow" variant="filled"><FaFilter style={{ marginBottom: -2, marginRight: 2}} /> {filterInfo.blocked_packets}</Badge> <ActionIcon
<Space w="xs" /> color={filterInfo.active ? "orange" : "teal"}
<Badge size="md" radius="md" color="orange" variant="filled"><FaPencilAlt style={{ marginBottom: -1, marginRight: 2}} /> {filterInfo.edited_packets}</Badge> onClick={changeRegexStatus}
<Space w="lg" /> size="lg"
</>:null} radius="md"
<Tooltip label={filterInfo.active?"Deactivate":"Activate"} zIndex={0} color={filterInfo.active?"orange":"teal"}> variant="light"
<ActionIcon color={filterInfo.active?"orange":"teal"} onClick={changeRegexStatus} size="lg" radius="md" variant="filled"> >
{filterInfo.active?<FaPause size="20px" />:<FaPlay size="20px" />}</ActionIcon> {filterInfo.active ? <FaPause size={16} /> : <FaPlay size={16} />}
</ActionIcon>
</Tooltip> </Tooltip>
</Box> </Group>
</Group>
</Box> </Card>
)
} }

View File

@ -1,4 +1,4 @@
import { Text, Title, Badge, Space, ActionIcon, Tooltip, Box } from '@mantine/core'; import { Text, Title, Badge, Space, ActionIcon, Tooltip, Box, Card, Code, Group } from '@mantine/core';
import { useState } from 'react'; import { useState } from 'react';
import { RegexFilter } from '../../js/models'; import { RegexFilter } from '../../js/models';
import { b64decode, errorNotify, isMediumScreen, okNotify } from '../../js/utils'; import { b64decode, errorNotify, isMediumScreen, okNotify } from '../../js/utils';
@ -41,45 +41,93 @@ function RegexView({ regexInfo }:{ regexInfo:RegexFilter }) {
}).catch( err => errorNotify(`Regex ${regex_expr} ${regexInfo.active?"deactivation":"activation"} failed!`,`Error: ${err}`)) }).catch( err => errorNotify(`Regex ${regex_expr} ${regexInfo.active?"deactivation":"activation"} failed!`,`Error: ${err}`))
} }
return <Box className="firegex__regexview__box"> return (
<Box> <Card
<Box className='center-flex' style={{width: "100%"}}> withBorder
<Box className="firegex__regexview__outer_regex_text"> shadow="sm"
<Text className="firegex__regexview__regex_text" onClick={()=>{ radius="md"
p="sm"
mb="sm"
bg="transparent"
style={{ borderColor: 'var(--fourth_color)', transition: 'border-color 0.2s ease' }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Box style={{ flexGrow: 1, overflow: 'hidden' }}>
<Tooltip label="Click to copy regex" position="top-start">
<Code
block
color="dark.8"
style={{
cursor: 'pointer',
overflowX: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
fontSize: '1.1rem',
padding: '12px 16px',
borderRadius: '8px',
border: '1px solid var(--fourth_color)',
backgroundColor: 'rgba(0,0,0,0.2)'
}}
onClick={() => {
clipboard.copy(regex_expr) clipboard.copy(regex_expr)
okNotify("Regex copied to clipboard!",`The regex '${regex_expr}' has been copied to the clipboard!`) okNotify("Regex copied to clipboard!", `The regex '${regex_expr}' has been copied to the clipboard!`)
}}>{regex_expr}</Text> }}
</Box> >
<Space w="xs" /> {regex_expr}
<Tooltip label={regexInfo.active?"Deactivate":"Activate"} zIndex={0} color={regexInfo.active?"orange":"teal"}> </Code>
<ActionIcon color={regexInfo.active?"orange":"teal"} onClick={changeRegexStatus} size="xl" radius="md" variant="filled" </Tooltip>
>{regexInfo.active?<FaPause size="20px" />:<FaPlay size="20px" />}</ActionIcon>
</Tooltip>
<Space w="xs" />
<Tooltip label="Delete regex" zIndex={0} color="red" >
<ActionIcon color="red" onClick={()=>setDeleteModal(true)} size="xl" radius="md" variant="filled">
<BsTrashFill size={22} /></ActionIcon>
</Tooltip>
</Box>
<Box display="flex" mt="sm" ml="xs">
<Badge size="md" color="yellow" variant="filled"><FaFilter style={{ marginBottom: -2}} /> {regexInfo.n_packets}</Badge>
<Space w="xs" />
<Badge size="md" color={regexInfo.active?"lime":"red"} variant="filled">{regexInfo.active?"ACTIVE":"DISABLED"}</Badge>
<Space w="xs" />
<Badge size="md" color={regexInfo.is_case_sensitive?"grape":"pink"} variant="filled">{regexInfo.is_case_sensitive?"Strict":"Loose"}</Badge>
<Space w="xs" />
<Badge size="md" color="blue" variant="filled">{mode_string}</Badge>
</Box>
</Box> </Box>
<YesNoModal
title='Are you sure to delete this regex?'
description={`You are going to delete the regex '${regex_expr}'.`}
onClose={()=>setDeleteModal(false)}
action={deleteRegex}
opened={deleteModal}
/>
</Box> <Group gap="sm" wrap="nowrap" ml="md">
<Tooltip label={regexInfo.active ? "Deactivate" : "Activate"} color={regexInfo.active ? "orange" : "teal"}>
<ActionIcon
color={regexInfo.active ? "orange" : "teal"}
onClick={changeRegexStatus}
size="xl"
radius="md"
variant="light"
>
{regexInfo.active ? <FaPause size={18} /> : <FaPlay size={18} />}
</ActionIcon>
</Tooltip>
<Tooltip label="Delete regex" color="red">
<ActionIcon
color="red"
onClick={() => setDeleteModal(true)}
size="xl"
radius="md"
variant="light"
>
<BsTrashFill size={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Group gap="sm" mt="md">
<Badge size="md" color="yellow" variant="light" leftSection={<FaFilter size={12} style={{ marginTop: 2 }} />}>
{regexInfo.n_packets} packets
</Badge>
<Badge size="md" color={regexInfo.active ? "lime" : "red"} variant="light">
{regexInfo.active ? "ACTIVE" : "DISABLED"}
</Badge>
<Badge size="md" color={regexInfo.is_case_sensitive ? "grape" : "pink"} variant="light">
{regexInfo.is_case_sensitive ? "Strict" : "Loose"}
</Badge>
<Badge size="md" color="blue" variant="light">
{mode_string}
</Badge>
</Group>
<YesNoModal
title='Are you sure to delete this regex?'
description={`You are going to delete the regex '${regex_expr}'.`}
onClose={() => setDeleteModal(false)}
action={deleteRegex}
opened={deleteModal}
/>
</Card>
)
} }
export default RegexView; export default RegexView;

View File

@ -0,0 +1,38 @@
import { Badge, Tooltip } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { getapi } from '../js/utils';
import { TbShieldLock } from 'react-icons/tb';
interface TLSStream {
id: string;
name: string;
ip_int: string;
port: number;
status: string;
ssl_port: number;
clear_port: number;
}
export default function TLSAssociationBadge({ tlsStreamId, size = "xs" }: { tlsStreamId?: string | null, size?: string }) {
const { data: streams } = useQuery<TLSStream[]>({
queryKey: ["tls_streams"],
queryFn: () => getapi("tls/streams")
});
if (!tlsStreamId) return null;
const stream = streams?.find(s => s.id === tlsStreamId);
return (
<Tooltip
multiline
w={280}
label={stream
? `Decrypts TLS stream "${stream.name}" (public target ${stream.ip_int}:${stream.port}). The filter inspects the decrypted traffic on loopback:${stream.clear_port}, then it gets re-encrypted before reaching the real backend.`
: "Linked to a TLS Decrypt stream (stream details unavailable)"}
>
<Badge color="green" size={size as any} variant="light" radius="sm" leftSection={<TbShieldLock size={12} style={{ marginTop: 2 }} />}>
TLS{stream ? `: ${stream.name}` : ""}
</Badge>
</Tooltip>
)
}

View File

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

View File

@ -1,7 +1,10 @@
import { Space, Switch, Textarea } from '@mantine/core'; import { Box, Button, Select, Space, TextInput, Textarea, Title, Group, FileButton, Text } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form'; import { UseFormReturnType } from '@mantine/form';
import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { getapi, regex_ipv4, regex_ipv6 } from '../js/utils'; import { getapi, regex_ipv4, regex_ipv6 } from '../js/utils';
import PortAndInterface from './PortAndInterface';
import { MdAdd } from 'react-icons/md';
interface TLSServiceFieldsProps { interface TLSServiceFieldsProps {
form: UseFormReturnType<any>; form: UseFormReturnType<any>;
@ -9,70 +12,107 @@ interface TLSServiceFieldsProps {
} }
export default function TLSServiceFields({ form, disabled }: TLSServiceFieldsProps) { export default function TLSServiceFields({ form, disabled }: TLSServiceFieldsProps) {
useEffect(() => { const { data: streams, isLoading } = useQuery<any[]>({
if (!form.values.tls_enabled || !form.values.ip_int || !form.values.port) { queryKey: ["tls_streams"],
return; queryFn: () => getapi("tls/streams")
} });
const isIpValid = form.values.ip_int.match(regex_ipv4) || form.values.ip_int.match(regex_ipv6); const [isCreatingNew, setIsCreatingNew] = useState(false);
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(() => { useEffect(() => {
if (disabled && form.values.tls_enabled) { // Don't auto-pick/overwrite the stream selection while editing an existing
form.setFieldValue('tls_enabled', false); // service — its ip_int/port/tls_stream_id already reflect the bound stream.
if (disabled) return;
if (!isCreatingNew && form.values.tls_enabled && streams && streams.length > 0) {
const match = streams.find(s => s.ip_int === form.values.ip_int && s.port === form.values.port);
if (!match) {
form.setFieldValue('ip_int', streams[0].ip_int);
form.setFieldValue('port', streams[0].port);
form.setFieldValue('tls_stream_id', streams[0].id);
} else if (!form.values.tls_stream_id) {
form.setFieldValue('tls_stream_id', match.id);
}
} }
}, [disabled, form.values.tls_enabled]); }, [streams, isCreatingNew, form.values.tls_enabled, disabled]);
const handleFileUpload = (file: File | null, fieldName: string) => {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
form.setFieldValue(fieldName, text);
};
reader.readAsText(file);
};
if (!form.values.tls_enabled) return null;
return ( return (
<> <Box pt="md">
<Space h="md" /> <Group justify="space-between" align="center" mb="sm">
<Switch <Title order={6}>TLS Decryption Target</Title>
label="Enable TLS Decryption / Encryption" <Button size="xs" variant="light" leftSection={<MdAdd />} onClick={() => setIsCreatingNew(!isCreatingNew)} disabled={disabled}>
description="Decrypt and encrypt incoming and outgoing traffic using Nginx stream proxy" {isCreatingNew ? "Select Existing" : "Create New"}
disabled={disabled} </Button>
{...form.getInputProps('tls_enabled', { type: 'checkbox' })} </Group>
/> <Space h="sm" />
{form.values.tls_enabled && (
{!isCreatingNew ? (
<Select
data={streams?.map(s => ({
value: s.id,
label: `${s.name} (${s.ip_int}:${s.port})`
})) || []}
value={form.values.tls_stream_id || null}
onChange={(val) => {
if (val) {
form.setFieldValue('tls_stream_id', val);
const stream = streams?.find(s => s.id === val);
if (stream) {
form.setFieldValue('ip_int', stream.ip_int);
form.setFieldValue('port', stream.port);
}
}
}}
placeholder="Select a TLS Stream"
disabled={disabled || isLoading}
/>
) : (
<> <>
<Space h="md" /> <PortAndInterface form={form} int_name="ip_int" port_name="port" label="New TLS Stream Target IP & Port" />
<Space h="sm" />
<Group justify="space-between" align="center" mb={4}>
<Text size="sm" fw={500}>TLS Certificate (PEM format) {isCreatingNew && form.values.tls_enabled && <Text span c="red">*</Text>}</Text>
<FileButton onChange={(file) => handleFileUpload(file, 'tls_cert')} accept=".pem,.crt,.cer,.txt,*">
{(props) => <Button {...props} size="xs" variant="subtle">Load from file</Button>}
</FileButton>
</Group>
<Textarea <Textarea
label="TLS Certificate (PEM format)" placeholder="-----BEGIN CERTIFICATE-----..."
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
minRows={4} minRows={4}
styles={{ input: { fontFamily: 'monospace' } }} styles={{ input: { fontFamily: 'monospace' } }}
{...form.getInputProps('tls_cert')} {...form.getInputProps('tls_cert')}
required={isCreatingNew && form.values.tls_enabled}
/> />
<Space h="md" />
<Space h="sm" />
<Group justify="space-between" align="center" mb={4}>
<Text size="sm" fw={500}>TLS Private Key (PEM format) {isCreatingNew && form.values.tls_enabled && <Text span c="red">*</Text>}</Text>
<FileButton onChange={(file) => handleFileUpload(file, 'tls_key')} accept=".pem,.key,.txt,*">
{(props) => <Button {...props} size="xs" variant="subtle">Load from file</Button>}
</FileButton>
</Group>
<Textarea <Textarea
label="TLS Private Key (PEM format)" placeholder="-----BEGIN PRIVATE KEY-----..."
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
minRows={4} minRows={4}
styles={{ input: { fontFamily: 'monospace' } }} styles={{ input: { fontFamily: 'monospace' } }}
{...form.getInputProps('tls_key')} {...form.getInputProps('tls_key')}
required={isCreatingNew && form.values.tls_enabled}
/> />
</> </>
)} )}
</> </Box>
); );
} }

View File

@ -1,17 +1,23 @@
@import url('https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Lato&display=swap');
:root{ :root{
--primary_color: #242a33; --primary_color: #161618;
--secondary_color: #1A1B1E; --secondary_color: #0a0a0c;
--third_color:#25262b; --third_color: #1e1e20;
--fourth_color: #25262b99; --fourth_color: #2c2c2f;
--accent-color: #00e5ff;
--accent-hover: #00b8cc;
--text-primary: #f5f5f5;
--text-secondary: #9aa0a6;
--mantine-color-body: var(--secondary_color) !important; --mantine-color-body: var(--secondary_color) !important;
--mantine-font-family: 'Inter', sans-serif !important;
} }
body { body {
margin: 0; margin: 0;
font-family: 'Lato', sans-serif; font-family: 'Inter', sans-serif;
background-color: var(--secondary_color);
color: var(--text-primary);
} }
.center-flex{ .center-flex{
@ -132,19 +138,20 @@ body {
} }
.firegex__porthijack__servicerow__name{ .firegex__porthijack__servicerow__name{
font-family: 'Hanken Grotesk', sans-serif;
font-size: 1.8em; font-size: 1.8em;
font-weight: bolder; font-weight: 700;
margin-right: 10px; margin-right: 10px;
margin-bottom: 13px; margin-bottom: 13px;
color:#FFF; color: var(--text-primary);
} }
.firegex__porthijack__servicerow__portInput *{ .firegex__porthijack__servicerow__portInput *{
color: #FFF; color: var(--accent-color);
font-family: 'JetBrains Mono', monospace;
font-weight: bold; font-weight: bold;
font-size: 1em; font-size: 1em;
margin-top: -1px; margin-top: -1px;
text-decoration: underline;
} }
.firegex__nfregex__row{ .firegex__nfregex__row{
@ -155,13 +162,21 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background-color: transparent;
border-radius: 12px;
margin-bottom: 10px;
transition: all 0.2s ease;
}
.firegex__nfregex__row:hover{
} }
.firegex__nfregex__name{ .firegex__nfregex__name{
font-family: 'Hanken Grotesk', sans-serif;
font-size: 1.6em; font-size: 1.6em;
font-weight: bolder; font-weight: 700;
text-transform: capitalize; text-transform: capitalize;
color:#FFF; color: var(--text-primary);
max-width: 300px; max-width: 300px;
overflow: hidden; overflow: hidden;
margin-left: -20px; margin-left: -20px;
@ -175,12 +190,13 @@ body {
.firegex__navbar__unstyled_button { .firegex__navbar__unstyled_button {
display: block; display: block;
width: 100%; width: 100%;
padding: var(--mantine-spacing-xs); padding: 12px 16px;
border-radius: var(--mantine-radius-sm); border-radius: 8px;
color: var(--mantine-color-dark-0); color: var(--text-secondary);
background-color: transparent; background-color: transparent;
font-weight: 500;
opacity: 1; transition: all 0.2s ease;
border: 1px solid transparent;
} }
.firegex__navbar__unstyled_button.disabled { .firegex__navbar__unstyled_button.disabled {
@ -188,16 +204,20 @@ body {
} }
.firegex__navbar__unstyled_button.selected{ .firegex__navbar__unstyled_button.selected{
background-color: var(--mantine-color-dark-6); background-color: var(--third_color);
color: var(--accent-color);
border-color: var(--fourth_color);
} }
.firegex__navbar__unstyled_button:hover { .firegex__navbar__unstyled_button:hover {
background-color: var(--mantine-color-dark-6); background-color: var(--third_color);
color: var(--text-primary);
} }
.firegex__footer{ .firegex__footer{
margin-top: 50px; margin-top: 50px;
background-color: var(--primary_color); background-color: var(--primary_color);
border-top: 1px solid var(--third_color);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -227,9 +247,29 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
display: flex; display: flex;
background-color: var(--fourth_color); background-color: transparent;
border-radius: 10px; border-radius: 8px;
border: #444 3px solid; border: 1px solid var(--fourth_color);
margin-bottom: 16px;
}
.mantine-CodeHighlight-root, .mantine-CodeHighlight-pre {
background-color: transparent !important;
}
.firegex__clickable_row {
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.firegex__clickable_row:hover {
border-color: var(--accent-color);
background-color: rgba(255, 255, 255, 0.03);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
.firegex__clickable_row:active {
background-color: rgba(255, 255, 255, 0.05);
} }
@media (max-width: 599px) { @media (max-width: 599px) {

View File

@ -55,9 +55,72 @@ export const customShikiAdapter: CodeHighlightAdapter = {
}, },
}; };
import { createTheme } from '@mantine/core';
const myTheme = createTheme({
fontFamily: 'Inter, sans-serif',
headings: { fontFamily: 'Hanken Grotesk, sans-serif' },
primaryColor: 'cyan',
colors: {
dark: [
'#f5f5f5', // 0: text
'#9aa0a6', // 1: text-secondary
'#5f6368', // 2: outline
'#3c4043', // 3: outline-variant
'#2c2c2f', // 4: surface-variant / fourth_color
'#1e1e20', // 5: surface-container-high / third_color
'#1a1a1c', // 6: surface-container
'#161618', // 7: surface-container-low / primary_color
'#0a0a0c', // 8: background / secondary_color
'#050505', // 9: surface-container-lowest
],
cyan: [
'#e0ffff',
'#b3fbff',
'#80f6ff',
'#4df0ff',
'#20ebff',
'#00e5ff', // 5: surface-tint / accent
'#00b8cc',
'#008f9f',
'#006975',
'#004f59',
]
},
components: {
AppShell: {
styles: {
root: {
backgroundColor: '#0a0a0c',
},
navbar: {
backgroundColor: '#161618',
borderRight: '1px solid #2c2c2f',
},
header: {
backgroundColor: '#161618',
borderBottom: '1px solid #2c2c2f',
}
}
},
Card: {
defaultProps: {
bg: '#1e1e20',
radius: 'md',
withBorder: true,
},
styles: {
root: {
borderColor: '#2c2c2f',
}
}
}
}
});
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MantineProvider defaultColorScheme='dark'> <MantineProvider defaultColorScheme='dark' theme={myTheme}>
<CodeHighlightAdapterProvider adapter={customShikiAdapter}> <CodeHighlightAdapterProvider adapter={customShikiAdapter}>
<Notifications /> <Notifications />
<BrowserRouter> <BrowserRouter>

View File

@ -1,4 +1,4 @@
import { Button, Group, Space, Modal, Switch } from '@mantine/core'; import { Button, Card, Group, Space, Modal, Stack, Switch } from '@mantine/core';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { errorNotify, okNotify } from '../../js/utils'; import { errorNotify, okNotify } from '../../js/utils';
import { FirewallSettings, firewall } from '../../components/Firewall/utils'; import { FirewallSettings, firewall } from '../../components/Firewall/utils';
@ -33,22 +33,20 @@ export function SettingsModal({ opened, onClose }:{ opened:boolean, onClose:()=>
return <Modal size="xl" title="Change firewall settings" opened={opened} onClose={onClose} closeOnClickOutside={false} centered> return <Modal size="xl" title="Change firewall settings" opened={opened} onClose={onClose} closeOnClickOutside={false} centered>
<Switch label="Keep rules on firegex shutdown" checked={settings.keep_rules} onChange={v => setSettings({...settings, keep_rules:v.target.checked})}/> <Card withBorder radius="md" p="md" bg="transparent" style={{ borderColor: 'var(--fourth_color)' }}>
<Stack gap="md">
<Switch label="Keep rules on firegex shutdown" checked={settings.keep_rules} onChange={v => setSettings({...settings, keep_rules:v.target.checked})}/>
<Switch label="Allow loopback to communicate with itself" checked={settings.allow_loopback} onChange={v => setSettings({...settings, allow_loopback:v.target.checked})}/>
<Switch label="Allow established connection (essential to allow opening connection) (Dangerous to disable)" checked={settings.allow_established} onChange={v => setSettings({...settings, allow_established:v.target.checked})}/>
<Switch label="Allow icmp packets" checked={settings.allow_icmp} onChange={v => setSettings({...settings, allow_icmp:v.target.checked})}/>
<Switch label="Allow multicast DNS" checked={settings.multicast_dns} onChange={v => setSettings({...settings, multicast_dns:v.target.checked})}/>
<Switch label="Allow UPnP protocol" checked={settings.allow_upnp} onChange={v => setSettings({...settings, allow_upnp:v.target.checked})}/>
<Switch label="Drop invalid packet" checked={settings.drop_invalid} onChange={v => setSettings({...settings, drop_invalid:v.target.checked})}/>
<Switch label="Allow DHCP" checked={settings.allow_dhcp} onChange={v => setSettings({...settings, allow_dhcp:v.target.checked})}/>
</Stack>
</Card>
<Space h="md" /> <Space h="md" />
<Switch label="Allow loopback to communicate with itself" checked={settings.allow_loopback} onChange={v => setSettings({...settings, allow_loopback:v.target.checked})}/> <Group align="right">
<Space h="md" />
<Switch label="Allow established connection (essential to allow opening connection) (Dangerous to disable)" checked={settings.allow_established} onChange={v => setSettings({...settings, allow_established:v.target.checked})}/>
<Space h="md" />
<Switch label="Allow icmp packets" checked={settings.allow_icmp} onChange={v => setSettings({...settings, allow_icmp:v.target.checked})}/>
<Space h="md" />
<Switch label="Allow multicast DNS" checked={settings.multicast_dns} onChange={v => setSettings({...settings, multicast_dns:v.target.checked})}/>
<Space h="md" />
<Switch label="Allow UPnP protocol" checked={settings.allow_upnp} onChange={v => setSettings({...settings, allow_upnp:v.target.checked})}/>
<Space h="md" />
<Switch label="Drop invalid packet" checked={settings.drop_invalid} onChange={v => setSettings({...settings, drop_invalid:v.target.checked})}/>
<Space h="md" />
<Switch label="Allow DHCP" checked={settings.allow_dhcp} onChange={v => setSettings({...settings, allow_dhcp:v.target.checked})}/>
<Group align="right" mt="md">
<Button loading={submitLoading} onClick={submitRequest}>Save Setting</Button> <Button loading={submitLoading} onClick={submitRequest}>Save Setting</Button>
</Group> </Group>
</Modal> </Modal>

View File

@ -1,4 +1,4 @@
import { ActionIcon, Badge, Box, Divider, FloatingIndicator, LoadingOverlay, Space, Switch, Table, Tabs, TextInput, ThemeIcon, Title, Tooltip, useMantineTheme } from "@mantine/core" import { ActionIcon, Badge, Box, Card, Divider, FloatingIndicator, Group, LoadingOverlay, Space, Stack, Switch, Table, Tabs, TextInput, ThemeIcon, Title, Tooltip, useMantineTheme } from "@mantine/core"
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { BsPlusLg, BsTrashFill } from "react-icons/bs" import { BsPlusLg, BsTrashFill } from "react-icons/bs"
import { rem } from '@mantine/core'; import { rem } from '@mantine/core';
@ -25,196 +25,186 @@ import { PiWallLight } from "react-icons/pi";
export const Firewall = () => { export const Firewall = () => {
const [currentPolicy, setCurrentPolicy] = useState<ActionType>(ActionType.ACCEPT) const [currentPolicy, setCurrentPolicy] = useState<ActionType>(ActionType.ACCEPT)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const rules = firewallRulesQuery() const rules = firewallRulesQuery()
const [state, handlers] = useListState<Rule & {rule_id:string}>([]); const [state, handlers] = useListState<Rule & { rule_id: string }>([]);
const [enableFwModal, setEnableFwModal] = useState(false) const [enableFwModal, setEnableFwModal] = useState(false)
const [applyChangeModal, setApplyChangeModal] = useState(false) const [applyChangeModal, setApplyChangeModal] = useState(false)
const [settingsModal, setSettingsModal] = useState(false) const [settingsModal, setSettingsModal] = useState(false)
const theme = useMantineTheme(); const theme = useMantineTheme();
const isMedium = useMediaQuery(`(min-width: 950px)`)??true const isMedium = useMediaQuery(`(min-width: 950px)`) ?? true
const isSmall = useMediaQuery(`(max-width: 600px)`)??false
const [updateMevalueinternal, internalUpdateme] = useState(false) const [updateMevalueinternal, internalUpdateme] = useState(false)
const updateMe = () => { const updateMe = () => {
internalUpdateme(!updateMevalueinternal) internalUpdateme(!updateMevalueinternal)
}
useEffect(() => {
if (rules.isError)
errorNotify("Firewall Update failed!", getErrorMessage(rules.error))
}, [rules.isError])
useEffect(() => {
if (!rules.isLoading && rules.isFetched && !rules.isFetching) {
setCurrentPolicy(rules.data?.policy ?? ActionType.ACCEPT)
handlers.setState(JSON.parse(JSON.stringify((rules.data?.rules ?? []).map(v => ({ rule_id: makeid(30), ...v })))))
} }
}, [rules.isFetched, rules.isLoading, rules.isFetching])
useEffect(()=> { const fwEnabled = rules.data?.enabled ?? false
if(rules.isError) const valuesChanged = JSON.stringify(rules.data?.rules) != JSON.stringify(state.map(v => {
errorNotify("Firewall Update failed!", getErrorMessage(rules.error)) const { rule_id, ...rest } = v
},[rules.isError]) return rest
})) || rules.data?.policy != currentPolicy
const [selectedTab, setSelectedTab] = useState<NFTables>(NFTables.FILTER)
useEffect(()=> {
if(!rules.isLoading && rules.isFetched && !rules.isFetching){ const enableFirewall = () => {
setCurrentPolicy(rules.data?.policy??ActionType.ACCEPT) if (valuesChanged) {
handlers.setState(JSON.parse(JSON.stringify((rules.data?.rules??[]).map( v => ({rule_id: makeid(30), ...v}))))) applyChangesRaw().then(() => enableFirewallRaw())
} else {
enableFirewallRaw()
}
}
const enableFirewallRaw = () => {
return firewall.enable()
.then(() => okNotify("Firewall enabled", "The firewall has been enabled"))
.catch((e) => errorNotify("Firewall enable failed!", getErrorMessage(e)))
}
const disableFirewallRaw = () => {
return firewall.disable()
.then(() => okNotify("Firewall disabled", "The firewall has been disabled"))
.catch((e) => errorNotify("Firewall disable failed!", getErrorMessage(e)))
}
const switchState = () => {
if (fwEnabled)
disableFirewallRaw()
else
if ([ActionType.REJECT, ActionType.DROP].includes(currentPolicy) || valuesChanged) {
setEnableFwModal(true)
} else {
enableFirewall()
}
}
const applyChanges = () => {
if (fwEnabled && rules.data?.policy == ActionType.ACCEPT && [ActionType.REJECT, ActionType.DROP].includes(currentPolicy)) {
setApplyChangeModal(true)
} else {
applyChangesRaw()
}
}
const emptyRuleAdd = () => {
handlers.insert(0, {
rule_id: makeid(30),
active: true,
name: "Rule name",
proto: Protocol.TCP,
src: "",
dst: "",
port_src_from: 1,
port_dst_from: 8080,
port_src_to: 65535,
port_dst_to: 8080,
action: ActionType.ACCEPT,
mode: RuleMode.IN,
table: selectedTab
})
}
const parseRules = () => {
return state
}
const applyChangesRaw = () => {
const parsedRules = parseRules()
if (parsedRules === undefined) {
errorNotify("Firewall rules apply failed!", "The firewall rules are not valid")
return Promise.reject()
} else {
return firewall.ruleset({ rules: state, policy: currentPolicy })
.then(() => okNotify("Firewall rules applied", "The firewall rules has been applied"))
.catch((e) => errorNotify("Firewall rules apply failed!", getErrorMessage(e)))
}
}
const items = state.map((item, index) => (
item.table == selectedTab && <Draggable key={item.rule_id} index={index} draggableId={item.rule_id}>
{(provided, snapshot) => {
const customInt = [
{ value: "0.0.0.0/0", netint: "ANY IPv4", label: "0.0.0.0/0" },
{ value: "::/0", netint: "ANY IPv6", label: "::/0" },
{ value: "", netint: "ANY", label: "ANY" }
]
const src_custom_int = customInt.map(v => v.value).includes(item.src) ? [] : [{ value: item.src, netint: "SELECTED", label: item.src }]
const dst_custom_int = customInt.map(v => v.value).includes(item.dst) ? [] : [{ value: item.dst, netint: "SELECTED", label: item.dst }]
const [srcPortEnabled, setSrcPortEnabled] = useState(item.port_src_from != 1 || item.port_src_to != 65535)
const [dstPortEnabled, setDstPortEnabled] = useState(item.port_dst_from != 1 || item.port_dst_to != 65535)
const [srcPortValue, setSrcPortValue] = useState(item.port_src_from == item.port_src_to ? `${item.port_src_from}` : `${item.port_src_from}-${item.port_src_to}`)
const [dstPortValue, setDstPortValue] = useState(item.port_dst_from == item.port_dst_to ? `${item.port_dst_from}` : `${item.port_dst_from}-${item.port_dst_to}`)
const port_range_setter = (rule: Rule, v: string, { src = false, dst = false }: { src?: boolean, dst?: boolean }) => {
const elements = v.split("-")
const values = [elements[0] ? parseInt(elements[0]) : 0, elements[1] ? parseInt(elements[1]) : 0]
values[1] = values[1] ? values[1] : values[0]
if (src) {
rule.port_src_from = values[0]
rule.port_src_to = values[1]
setSrcPortValue(v)
}
if (dst) {
rule.port_dst_from = values[0]
rule.port_dst_to = values[1]
setDstPortValue(v)
}
updateMe()
} }
},[rules.isFetched, rules.isLoading, rules.isFetching])
const fwEnabled = rules.data?.enabled??false const ip_setter = (rule: Rule, v: string | null, { src = false, dst = false }: { src?: boolean, dst?: boolean }) => {
const valuesChanged = JSON.stringify(rules.data?.rules) != JSON.stringify(state.map(v => { const values = v ? v : ""
const {rule_id, ...rest} = v if (src) {
return rest rule.src = values
})) || rules.data?.policy != currentPolicy }
const [selectedTab, setSelectedTab] = useState<NFTables>(NFTables.FILTER) if (dst) {
rule.dst = values
}
const enableFirewall = () => { updateMe()
if (valuesChanged){
applyChangesRaw().then(()=>enableFirewallRaw())
}else{
enableFirewallRaw()
}
}
const enableFirewallRaw = () => {
return firewall.enable()
.then(()=>okNotify("Firewall enabled", "The firewall has been enabled"))
.catch((e)=>errorNotify("Firewall enable failed!", getErrorMessage(e)))
}
const disableFirewallRaw = () => {
return firewall.disable()
.then(()=>okNotify("Firewall disabled", "The firewall has been disabled"))
.catch((e)=>errorNotify("Firewall disable failed!", getErrorMessage(e)))
}
const switchState = () => {
if (fwEnabled)
disableFirewallRaw()
else
if ([ActionType.REJECT, ActionType.DROP].includes(currentPolicy) || valuesChanged){
setEnableFwModal(true)
}else{
enableFirewall()
} }
}
const applyChanges = () => { const proto_any = item.proto == Protocol.ANY
if (fwEnabled && rules.data?.policy == ActionType.ACCEPT && [ActionType.REJECT, ActionType.DROP].includes(currentPolicy)){ const disabletab = {
setApplyChangeModal(true) port_box: proto_any,
}else{ src_port: !srcPortEnabled || proto_any,
applyChangesRaw() dst_port: !dstPortEnabled || proto_any
} }
}
const condDiv = (val:React.ReactNode, cond:boolean) => { return <Card
if (cond) ref={provided.innerRef}
return <Box>{val}</Box> {...provided.draggableProps}
else withBorder
return val radius="md"
} p="md"
mb="md"
const emptyRuleAdd = () => { w="100%"
handlers.insert(0,{ bg="transparent"
rule_id: makeid(30), style={{ borderColor: 'var(--fourth_color)', transition: 'border-color 0.2s ease' }}
active: true, >
name: "Rule name", <Group wrap="nowrap" align="flex-start" gap="sm">
proto: Protocol.TCP, <Box {...provided.dragHandleProps} style={{ display: 'flex', alignItems: 'center', height: rem(36) }}>
src: "", <TbGripVertical style={{ width: rem(20), height: rem(20), color: 'var(--text-secondary)' }} />
dst: "", </Box>
port_src_from: 1, <Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
port_dst_from: 8080, <Group wrap="nowrap" gap="sm">
port_src_to: 65535,
port_dst_to: 8080,
action: ActionType.ACCEPT,
mode: RuleMode.IN,
table: selectedTab
})
}
const parseRules = () => {
return state
}
const applyChangesRaw = () => {
const parsedRules = parseRules()
if (parsedRules === undefined){
errorNotify("Firewall rules apply failed!", "The firewall rules are not valid")
return Promise.reject()
}else{
return firewall.ruleset({rules:state, policy:currentPolicy})
.then(()=>okNotify("Firewall rules applied", "The firewall rules has been applied"))
.catch((e)=>errorNotify("Firewall rules apply failed!", getErrorMessage(e)))
}
}
const items = state.map((item, index) => (
item.table == selectedTab && <Draggable key={item.rule_id} index={index} draggableId={item.rule_id}>
{(provided, snapshot) => {
const customInt = [
{ value: "0.0.0.0/0", netint: "ANY IPv4", label: "0.0.0.0/0" },
{ value: "::/0", netint: "ANY IPv6", label: "::/0" },
{ value: "", netint: "ANY", label: "ANY" }
]
const src_custom_int = customInt.map(v => v.value).includes(item.src)?[]:[{ value: item.src, netint: "SELECTED", label: item.src }]
const dst_custom_int = customInt.map(v => v.value).includes(item.dst)?[]:[{ value: item.dst, netint: "SELECTED", label: item.dst }]
const [srcPortEnabled, setSrcPortEnabled] = useState(item.port_src_from != 1 || item.port_src_to != 65535)
const [dstPortEnabled, setDstPortEnabled] = useState(item.port_dst_from != 1 || item.port_dst_to != 65535)
const [srcPortValue, setSrcPortValue] = useState(item.port_src_from==item.port_src_to?`${item.port_src_from}`:`${item.port_src_from}-${item.port_src_to}`)
const [dstPortValue, setDstPortValue] = useState(item.port_dst_from==item.port_dst_to?`${item.port_dst_from}`:`${item.port_dst_from}-${item.port_dst_to}`)
const port_range_setter = (rule:Rule, v:string, {src=false, dst=false}:{src?:boolean, dst?:boolean}) => {
const elements = v.split("-")
const values = [elements[0]?parseInt(elements[0]):0, elements[1]?parseInt(elements[1]):0]
values[1] = values[1]?values[1]:values[0]
if (src){
rule.port_src_from = values[0]
rule.port_src_to = values[1]
setSrcPortValue(v)
}
if (dst){
rule.port_dst_from = values[0]
rule.port_dst_to = values[1]
setDstPortValue(v)
}
updateMe()
}
const ip_setter = (rule:Rule, v:string|null, {src=false, dst=false}:{src?:boolean, dst?:boolean}) => {
const values = v?v:""
if (src){
rule.src = values
}
if (dst){
rule.dst = values
}
updateMe()
}
const disable_style = { opacity:"0.4", cursor:"not-allowed" }
const proto_any = item.proto == Protocol.ANY
const disabletab = {
port_box: proto_any,
src_port: !srcPortEnabled || proto_any,
dst_port: !dstPortEnabled || proto_any
}
const additionalStyle = {
port_box: disabletab.port_box?disable_style:{},
src_port: disabletab.src_port?disable_style:{},
dst_port: disabletab.dst_port?disable_style:{}
}
return <Box
ref={provided.innerRef}
{...provided.draggableProps}
>
<Box className='center-flex' style={{width:"100%"}}>
<Box {...provided.dragHandleProps}>
<TbGripVertical style={{ width: rem(30), height: rem(40) }} />
</Box>
<Space w="sm" />
<Box className={isMedium?"center-flex":"center-flex-row"} style={{width:"100%"}}>
<Box className="center-flex-row" style={{width:"100%"}}>
<Box className="center-flex" style={{width:"97%"}}>
<Switch <Switch
checked={item.active} checked={item.active}
onChange={() =>{ onChange={() => {
item.active = !item.active item.active = !item.active
updateMe() updateMe()
}} }}
@ -234,215 +224,207 @@ export const Firewall = () => {
) )
} }
/> />
<Space w="sm" /> <ActionIcon color="red" onClick={() => handlers.remove(index)} size="lg" radius="md" variant="filled"><BsTrashFill size={18} /></ActionIcon>
<ActionIcon color="red" onClick={()=>handlers.remove(index)} size="lg" radius="md" variant="filled"><BsTrashFill size={18} /></ActionIcon> <TextInput defaultValue={item.name} onChange={(v) => { item.name = v.target.value; updateMe() }} style={{ flex: 1 }} />
<Space w="sm" /> </Group>
<TextInput defaultValue={item.name} onChange={(v)=>{item.name = v.target.value;updateMe()}} style={{width:"100%"}}/>
</Box> <Group wrap={isMedium ? "nowrap" : "wrap"} align="flex-start" gap="md">
<Space h="sm" /> <Stack gap="xs" style={{ flex: 1, minWidth: isMedium ? 0 : "100%" }}>
<Box className="center-flex" style={{width:"97%"}}>
<Box style={{width:"100%"}}>
<InterfaceInput <InterfaceInput
initialCustomInterfaces={[...src_custom_int, ...customInt]} initialCustomInterfaces={[...src_custom_int, ...customInt]}
value={item.src} value={item.src}
onChange={v => ip_setter(item, v, {src:true})} onChange={v => ip_setter(item, v, { src: true })}
includeInterfaceNames includeInterfaceNames
/> />
<Space h="sm" /> <Group gap="xs" wrap="nowrap">
<Box className="center-flex" style={{width:"100%"}}> <OnOffButton value={srcPortEnabled} onClick={() => {
<OnOffButton value={srcPortEnabled} onClick={() =>{ const value = !srcPortEnabled
const value = !srcPortEnabled setSrcPortEnabled(value)
setSrcPortEnabled(value) if (!value) {
if (!value){ item.port_src_from = 1
item.port_src_from = 1 item.port_src_to = 65535
item.port_src_to = 65535 updateMe()
updateMe() } else {
}else{ port_range_setter(item, srcPortValue, { src: true })
port_range_setter(item, srcPortValue, {src:true}) }
} }} size="lg" disabled={disabletab.port_box} variant="light" />
}} size="lg" disabled={disabletab.port_box} style={additionalStyle.port_box} variant="light" /> <PortRangeInput
<Space w="xs" /> onChange={v => port_range_setter(item, v.target.value, { src: true })}
<PortRangeInput value={srcPortValue}
onChange={v => port_range_setter(item, v.target.value, {src:true})} disabled={disabletab.src_port}
value={srcPortValue} error={!disabletab.src_port && !srcPortValue}
disabled={disabletab.src_port} style={{ flex: 1 }}
error={!disabletab.src_port && !srcPortValue} />
style={{width:"100%", ...additionalStyle.src_port}} </Group>
/> </Stack>
</Box>
</Box> <Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', alignSelf: isMedium ? 'stretch' : 'center', padding: isMedium ? '28px 0 0' : '4px 0' }}>
<Space w="lg" /> <LuArrowBigRightDash size={22} color="var(--text-secondary)" style={isMedium ? undefined : { transform: 'rotate(90deg)' }} />
<LuArrowBigRightDash size={100} /> </Box>
<Space w="lg" />
<Box style={{width:"100%"}}> <Stack gap="xs" style={{ flex: 1, minWidth: isMedium ? 0 : "100%" }}>
<InterfaceInput <InterfaceInput
initialCustomInterfaces={[...dst_custom_int, ...customInt]} initialCustomInterfaces={[...dst_custom_int, ...customInt]}
defaultValue={item.dst} defaultValue={item.dst}
onChange={v => ip_setter(item, v, {dst:true})} onChange={v => ip_setter(item, v, { dst: true })}
includeInterfaceNames includeInterfaceNames
/> />
<Space h="sm" /> <Group gap="xs" wrap="nowrap">
<Box className="center-flex" style={{width:"100%"}}> <OnOffButton value={dstPortEnabled} onClick={() => {
<OnOffButton value={dstPortEnabled} onClick={() =>{
const value = !dstPortEnabled const value = !dstPortEnabled
setDstPortEnabled(value) setDstPortEnabled(value)
if (!value){ if (!value) {
item.port_dst_from = 1 item.port_dst_from = 1
item.port_dst_to = 65535 item.port_dst_to = 65535
updateMe() updateMe()
}else{ } else {
port_range_setter(item, dstPortValue, {dst:true}) port_range_setter(item, dstPortValue, { dst: true })
} }
}} size="lg" disabled={disabletab.port_box} style={additionalStyle.port_box} variant="light" /> }} size="lg" disabled={disabletab.port_box} variant="light" />
<Space w="xs" />
<PortRangeInput <PortRangeInput
onChange={v => port_range_setter(item, v.target.value, {dst:true})} onChange={v => port_range_setter(item, v.target.value, { dst: true })}
value={dstPortValue} value={dstPortValue}
disabled={disabletab.dst_port} disabled={disabletab.dst_port}
error={!disabletab.dst_port && !dstPortValue} error={!disabletab.dst_port && !dstPortValue}
style={{width:"100%", ...additionalStyle.dst_port}} style={{ flex: 1 }}
/> />
</Box> </Group>
</Box> </Stack>
</Box> </Group>
</Box>
{!isMedium?<Space h="sm" />:null} <Group gap="xs" wrap="wrap">
<Box className={isMedium?"center-flex-row":"center-flex"} style={isMedium?{}:{width:"100%", justifyContent:"space-around"}}> <ModeSelector
{condDiv(<>{condDiv(<ModeSelector value={item.mode}
value={item.mode} onChange={(value) => { item.mode = value as RuleMode; updateMe() }}
onChange={(value)=>{item.mode = value as RuleMode;updateMe()}} style={{ flexGrow: 1, minWidth: rem(160) }}
style={{width:"100%"}} table={item.table}
table={item.table} />
/>, !isMedium)} <ProtocolSelector
<Space h="xs" /> value={item.proto}
{condDiv(<ProtocolSelector onChange={(value) => { item.proto = value as Protocol; updateMe() }}
value={item.proto} style={{ flexGrow: 1, minWidth: rem(160) }}
onChange={(value)=>{item.proto = value as Protocol;updateMe()}} />
style={{width:"100%"}} <ActionTypeSelector
/>, !isMedium)}</>, isSmall)} value={item.action}
<Space h="xs" /> onChange={(value) => { item.action = value as ActionType; updateMe() }}
{condDiv(<ActionTypeSelector style={{ flexGrow: 1, minWidth: rem(160) }}
value={item.action} />
onChange={(value)=>{item.action = value as ActionType;updateMe()}} </Group>
style={{width:"100%"}} </Stack>
/>, !isMedium)} </Group>
</Box> </Card>
</Box> }}
</Box> </Draggable>
<Space h="md" /> )).filter(v => v);
<Divider />
<Space h="md" />
</Box>
}}
</Draggable>
)).filter(v => v);
return <> return <>
<Space h="sm" /> <Space h="sm" />
<LoadingOverlay visible={rules.isLoading} /> <LoadingOverlay visible={rules.isLoading} />
<Box className={isMedium?'center-flex':'center-flex-row'}> <Box className={isMedium ? 'center-flex' : 'center-flex-row'}>
<Title order={5} className="center-flex"><ThemeIcon radius="md" size="md" variant='filled' color='red' ><PiWallLight size={20} /></ThemeIcon><Space w="xs" />Firewall Rules</Title> <Title order={5} className="center-flex"><ThemeIcon radius="md" size="md" variant='filled' color='red' ><PiWallLight size={20} /></ThemeIcon><Space w="xs" />Firewall Rules</Title>
{isMedium?<Box className='flex-spacer' />:<Space h="sm" />} {isMedium ? <Box className='flex-spacer' /> : <Space h="sm" />}
<Box className='center-flex'> <Box className='center-flex'>
Enabled: <Space w="sm" /> <Switch checked={fwEnabled} onChange={switchState} /> Enabled: <Space w="sm" /> <Switch checked={fwEnabled} onChange={switchState} />
<Space w="sm" /> <Space w="sm" />
Policy: Policy:
<Space w="xs" /> <Space w="xs" />
<ActionTypeSelector <ActionTypeSelector
value={currentPolicy} value={currentPolicy}
onChange={(value)=>setCurrentPolicy(value as ActionType)} onChange={(value) => setCurrentPolicy(value as ActionType)}
/> />
</Box> </Box>
{isMedium?<Box className='flex-spacer' />:<Space h="sm" />} {isMedium ? <Box className='flex-spacer' /> : <Space h="sm" />}
<Box className='center-flex'> <Box className='center-flex'>
<Space w="xs" /> <Space w="xs" />
<Badge size="md" radius="sm" color="green" variant="filled"><FaDirections style={{ marginBottom: -1, marginRight: 4}}/>Rules: {rules.isLoading?0:rules.data?.rules.length}</Badge> <Badge size="md" radius="sm" color="green" variant="filled"><FaDirections style={{ marginBottom: -1, marginRight: 4 }} />Rules: {rules.isLoading ? 0 : rules.data?.rules.length}</Badge>
<Space w="md" /> <Space w="md" />
<Tooltip label="Add a new rule" position='bottom' color="blue"> <Tooltip label="Add a new rule" position='bottom' color="blue">
<ActionIcon color="blue" onClick={emptyRuleAdd} size="lg" radius="md" variant="filled"><BsPlusLg size={18} /></ActionIcon> <ActionIcon color="blue" onClick={emptyRuleAdd} size="lg" radius="md" variant="filled"><BsPlusLg size={18} /></ActionIcon>
</Tooltip> </Tooltip>
<Space w="xs" /> <Space w="xs" />
<Tooltip label="Refresh" position='bottom' color="indigo"> <Tooltip label="Refresh" position='bottom' color="indigo">
<ActionIcon color="indigo" onClick={()=>queryClient.invalidateQueries({ queryKey: ["firewall"] })} size="lg" radius="md" variant="filled" <ActionIcon color="indigo" onClick={() => queryClient.invalidateQueries({ queryKey: ["firewall"] })} size="lg" radius="md" variant="filled"
loading={rules.isFetching}><TbReload size={18} /></ActionIcon> loading={rules.isFetching}><TbReload size={18} /></ActionIcon>
</Tooltip> </Tooltip>
<Space w="xs" /> <Space w="xs" />
<Tooltip label="Settings" position='bottom' color="cyan"> <Tooltip label="Settings" position='bottom' color="cyan">
<ActionIcon color="cyan" onClick={()=>setSettingsModal(true)} size="lg" radius="md" variant="filled"><IoSettingsSharp size={18} /></ActionIcon> <ActionIcon color="cyan" onClick={() => setSettingsModal(true)} size="lg" radius="md" variant="filled"><IoSettingsSharp size={18} /></ActionIcon>
</Tooltip> </Tooltip>
<Space w="xs" /> <Space w="xs" />
<Tooltip label="Apply" position='bottom' color="grape"> <Tooltip label="Apply" position='bottom' color="grape">
<ActionIcon color="grape" onClick={applyChanges} size="lg" radius="md" variant="filled" disabled={!valuesChanged}> <ActionIcon color="grape" onClick={applyChanges} size="lg" radius="md" variant="filled" disabled={!valuesChanged}>
<TiTick size={22} /></ActionIcon> <TiTick size={22} /></ActionIcon>
</Tooltip> </Tooltip>
</Box> </Box>
</Box> </Box>
<Space h="xl" />
<Divider />
<Space h="md" />
<Group justify="center" gap="sm">
<Box>Filtering Table:</Box>
<Tabs variant="pills" value={selectedTab} onChange={(v) => setSelectedTab(v == NFTables.MANGLE ? NFTables.MANGLE : NFTables.FILTER)}>
<Tabs.List>
<Tabs.Tab value={NFTables.FILTER}>
Filter
</Tabs.Tab>
<Tabs.Tab value={NFTables.MANGLE}>
Mangle
</Tabs.Tab>
</Tabs.List>
</Tabs>
</Group>
{items.length > 0 ? <DragDropContext
onDragEnd={({ destination, source }) =>
handlers.reorder({ from: source.index, to: destination?.index || 0 })
}
>
<Space h="md" />
<Droppable droppableId="dnd-list" direction="vertical">
{(provided) => (
<Box {...provided.droppableProps} ref={provided.innerRef}>
{items}
{provided.placeholder}
</Box>
)}
</Droppable>
</DragDropContext> : <>
<Box className='center-flex-row'>
<Space h="xl" /> <Space h="xl" />
<Divider /> <Title className='center-flex' style={{ textAlign: "center" }} order={3}>Firewall Rules allows you to use nftables but through a web interface</Title>
<Space h="md"/> <Space h="xs" />
<Tabs variant="pills" value={selectedTab} onChange={(v)=>setSelectedTab(v==NFTables.MANGLE?NFTables.MANGLE:NFTables.FILTER)} style={{ display:"flex", justifyContent:"center", alignItems:"center"}}> <Title className='center-flex' style={{ textAlign: "center" }} order={5}>Add new rules, sort it and enable the firewall: be carefull, wrong rules could also drops out firegex access</Title>
<Box mr="md">Filtering Table:</Box> <Space h="lg" />
<Tabs.List> <Box className='center-flex' style={{ gap: 20 }}>
<Tabs.Tab value={NFTables.FILTER}> <Tooltip label="Add a new rule" color="blue">
Filter <ActionIcon color="blue" onClick={emptyRuleAdd} size="xl" radius="md" variant="filled">
</Tabs.Tab> <BsPlusLg size="20px" /></ActionIcon>
<Tabs.Tab value={NFTables.MANGLE}> </Tooltip>
Mangle
</Tabs.Tab>
</Tabs.List>
</Tabs>
{items.length > 0?<DragDropContext
onDragEnd={({ destination, source }) =>
handlers.reorder({ from: source.index, to: destination?.index || 0 })
}
>
<Space h="md" />
<Droppable droppableId="dnd-list" direction="vertical">
{(provided) => (
<Box {...provided.droppableProps} ref={provided.innerRef}>
{items}
{provided.placeholder}
</Box>
)}
</Droppable>
</DragDropContext>:<>
<Box className='center-flex-row'>
<Space h="xl" />
<Title className='center-flex' style={{textAlign:"center"}} order={3}>Firewall Rules allows you to use nftables but through a web interface</Title>
<Space h="xs" />
<Title className='center-flex' style={{textAlign:"center"}} order={5}>Add new rules, sort it and enable the firewall: be carefull, wrong rules could also drops out firegex access</Title>
<Space h="lg" />
<Box className='center-flex' style={{gap: 20}}>
<Tooltip label="Add a new rule" color="blue">
<ActionIcon color="blue" onClick={emptyRuleAdd} size="xl" radius="md" variant="filled">
<BsPlusLg size="20px" /></ActionIcon>
</Tooltip>
</Box>
</Box> </Box>
</Box>
</>} </>}
<YesNoModal <YesNoModal
title='Are you sure to apply the changes to the firewall?' title='Are you sure to apply the changes to the firewall?'
description={`If there is a malconfiguration you can lose the access to your server! ⚠️`} description={`If there is a malconfiguration you can lose the access to your server! ⚠️`}
onClose={()=>setEnableFwModal(false) } onClose={() => setEnableFwModal(false)}
action={enableFirewall} action={enableFirewall}
opened={enableFwModal} opened={enableFwModal}
/> />
<YesNoModal <YesNoModal
title='Are you sure to apply the changes to the firewall?' title='Are you sure to apply the changes to the firewall?'
description={`If there is a malconfiguration you can lose the access to your server! ⚠️`} description={`If there is a malconfiguration you can lose the access to your server! ⚠️`}
onClose={()=>setApplyChangeModal(false) } onClose={() => setApplyChangeModal(false)}
action={applyChangesRaw} action={applyChangesRaw}
opened={applyChangeModal} opened={applyChangeModal}
/> />
<SettingsModal <SettingsModal
opened={settingsModal} opened={settingsModal}
onClose={()=>setSettingsModal(false)} onClose={() => setSettingsModal(false)}
/> />
</> </>
} }

View File

@ -1,4 +1,4 @@
import { ActionIcon, Box, Code, Grid, LoadingOverlay, Space, Title, Tooltip } from '@mantine/core'; import { ActionIcon, Box, Code, LoadingOverlay, Space, Stack, Title, Tooltip, Card, Group, ThemeIcon } from '@mantine/core';
import { Navigate, useNavigate, useParams } from 'react-router'; import { Navigate, useNavigate, useParams } from 'react-router';
import { Badge, Divider, Menu } from '@mantine/core'; import { Badge, Divider, Menu } from '@mantine/core';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -16,7 +16,7 @@ import { FaArrowLeft } from "react-icons/fa";
import { IoSettingsSharp } from 'react-icons/io5'; import { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../../components/NFProxy/AddEditService'; import AddEditService from '../../components/NFProxy/AddEditService';
import PyFilterView from '../../components/PyFilterView'; import PyFilterView from '../../components/PyFilterView';
import { TbPlugConnected, TbShieldLock } from 'react-icons/tb'; import { TbPlugConnected } from 'react-icons/tb';
import { CodeHighlight } from '@mantine/code-highlight'; import { CodeHighlight } from '@mantine/code-highlight';
import { FaPython } from "react-icons/fa"; import { FaPython } from "react-icons/fa";
import { FiFileText } from "react-icons/fi"; import { FiFileText } from "react-icons/fi";
@ -24,36 +24,35 @@ import { ModalLog } from '../../components/ModalLog';
import { useListState } from '@mantine/hooks'; import { useListState } from '@mantine/hooks';
import { ExceptionWarning } from '../../components/NFProxy/ExceptionWarning'; import { ExceptionWarning } from '../../components/NFProxy/ExceptionWarning';
import { DocsButton } from '../../components/DocsButton'; import { DocsButton } from '../../components/DocsButton';
import TLSConfigModal from '../../components/TLSConfigModal'; import TLSAssociationBadge from '../../components/TLSAssociationBadge';
export default function ServiceDetailsNFProxy() { export default function ServiceDetailsNFProxy() {
const {srv} = useParams() const { srv } = useParams()
const services = nfproxyServiceQuery() const services = nfproxyServiceQuery()
const serviceInfo = services.data?.find(s => s.service_id == srv) const serviceInfo = services.data?.find(s => s.service_id == srv)
const filtersList = nfproxyServicePyfiltersQuery(srv??"") const filtersList = nfproxyServicePyfiltersQuery(srv ?? "")
const [deleteModal, setDeleteModal] = useState(false) const [deleteModal, setDeleteModal] = useState(false)
const [renameModal, setRenameModal] = useState(false) const [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false) const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const [buttonLoading, setButtonLoading] = useState(false) const [buttonLoading, setButtonLoading] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const filterCode = nfproxyServiceFilterCodeQuery(srv??"") const filterCode = nfproxyServiceFilterCodeQuery(srv ?? "")
const navigate = useNavigate() const navigate = useNavigate()
const isMedium = isMediumScreen() const isMedium = isMediumScreen()
const [openLogModal, setOpenLogModal] = useState(false) const [openLogModal, setOpenLogModal] = useState(false)
const [logData, logDataSetters] = useListState<string>([]); const [logData, logDataSetters] = useListState<string>([]);
useEffect(()=>{ useEffect(() => {
if (srv){ if (srv) {
if (openLogModal){ if (openLogModal) {
logDataSetters.setState([]) logDataSetters.setState([])
socketio.emit("nfproxy-outstream-join", { service: srv }); socketio.emit("nfproxy-outstream-join", { service: srv });
socketio.on(`nfproxy-outstream-${srv}`, (data) => { socketio.on(`nfproxy-outstream-${srv}`, (data) => {
logDataSetters.append(data) logDataSetters.append(data)
}); });
}else{ } else {
socketio.emit("nfproxy-outstream-leave", { service: srv }); socketio.emit("nfproxy-outstream-leave", { service: srv });
socketio.off(`nfproxy-outstream-${srv}`); socketio.off(`nfproxy-outstream-${srv}`);
logDataSetters.setState([]) logDataSetters.setState([])
@ -70,7 +69,7 @@ export default function ServiceDetailsNFProxy() {
if (!srv || !serviceInfo || filtersList.isError) return <Navigate to="/" replace /> if (!srv || !serviceInfo || filtersList.isError) return <Navigate to="/" replace />
let status_color = "gray"; let status_color = "gray";
switch(serviceInfo.status){ switch (serviceInfo.status) {
case "stop": status_color = "red"; break; case "stop": status_color = "red"; break;
case "active": status_color = "teal"; break; case "active": status_color = "teal"; break;
} }
@ -78,27 +77,27 @@ export default function ServiceDetailsNFProxy() {
const startService = async () => { const startService = async () => {
setButtonLoading(true) setButtonLoading(true)
await nfproxy.servicestart(serviceInfo.service_id).then(res => { await nfproxy.servicestart(serviceInfo.service_id).then(res => {
if(!res){ if (!res) {
okNotify(`Service ${serviceInfo.name} started successfully!`,`The service on ${serviceInfo.port} has been started!`) okNotify(`Service ${serviceInfo.name} started successfully!`, `The service on ${serviceInfo.port} has been started!`)
queryClient.invalidateQueries({ queryKey: serviceQueryKey }) queryClient.invalidateQueries({ queryKey: serviceQueryKey })
}else{ } else {
errorNotify(`An error as occurred during the starting of the service ${serviceInfo.port}`,`Error: ${res}`) errorNotify(`An error as occurred during the starting of the service ${serviceInfo.port}`, `Error: ${res}`)
} }
}).catch(err => { }).catch(err => {
errorNotify(`An error as occurred during the starting of the service ${serviceInfo.port}`,`Error: ${err}`) errorNotify(`An error as occurred during the starting of the service ${serviceInfo.port}`, `Error: ${err}`)
}) })
setButtonLoading(false) setButtonLoading(false)
} }
const deleteService = () => { const deleteService = () => {
nfproxy.servicedelete(serviceInfo.service_id).then(res => { nfproxy.servicedelete(serviceInfo.service_id).then(res => {
if (!res){ if (!res) {
okNotify("Service delete complete!",`The service ${serviceInfo.name} has been deleted!`) okNotify("Service delete complete!", `The service ${serviceInfo.name} has been deleted!`)
queryClient.invalidateQueries({ queryKey: serviceQueryKey }) queryClient.invalidateQueries({ queryKey: serviceQueryKey })
}else } else
errorNotify("An error occurred while deleting a service",`Error: ${res}`) errorNotify("An error occurred while deleting a service", `Error: ${res}`)
}).catch(err => { }).catch(err => {
errorNotify("An error occurred while deleting a service",`Error: ${err}`) errorNotify("An error occurred while deleting a service", `Error: ${err}`)
}) })
} }
@ -106,146 +105,140 @@ export default function ServiceDetailsNFProxy() {
setButtonLoading(true) setButtonLoading(true)
await nfproxy.servicestop(serviceInfo.service_id).then(res => { await nfproxy.servicestop(serviceInfo.service_id).then(res => {
if(!res){ if (!res) {
okNotify(`Service ${serviceInfo.name} stopped successfully!`,`The service on ${serviceInfo.port} has been stopped!`) okNotify(`Service ${serviceInfo.name} stopped successfully!`, `The service on ${serviceInfo.port} has been stopped!`)
queryClient.invalidateQueries({ queryKey: serviceQueryKey }) queryClient.invalidateQueries({ queryKey: serviceQueryKey })
}else{ } else {
errorNotify(`An error as occurred during the stopping of the service ${serviceInfo.port}`,`Error: ${res}`) errorNotify(`An error as occurred during the stopping of the service ${serviceInfo.port}`, `Error: ${res}`)
} }
}).catch(err => { }).catch(err => {
errorNotify(`An error as occurred during the stopping of the service ${serviceInfo.port}`,`Error: ${err}`) errorNotify(`An error as occurred during the stopping of the service ${serviceInfo.port}`, `Error: ${err}`)
}) })
setButtonLoading(false); setButtonLoading(false);
} }
return <> return <>
<Space h="sm" />
<LoadingOverlay visible={filtersList.isLoading} /> <LoadingOverlay visible={filtersList.isLoading} />
<Box className={isMedium?'center-flex':'center-flex-row'} style={{ justifyContent: "space-between"}} px="md" mt="lg"> <Card
<Box> withBorder
<Title order={1}> shadow="md"
<Box className="center-flex"> radius="md"
<MdDoubleArrow /><Space w="sm" />{serviceInfo.name} p="xl"
</Box> mb="xl"
</Title> bg="var(--third_color)"
</Box> style={{ borderColor: 'var(--fourth_color)', overflow: "visible" }} // Allows dropdowns to overflow outside
{isMedium?null:<Space h="md" />} >
<Box className='center-flex'> <Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"} gap="xl">
<ExceptionWarning service_id={srv} /> <Box>
<Space w="sm" /> <Group gap="xs" wrap="nowrap" align="center">
<Badge color={status_color} radius="md" size="xl" variant="filled" mr="sm"> <Tooltip label="Go back" position="top">
{serviceInfo.status} <ActionIcon color="cyan" variant="subtle" radius="md" onClick={() => navigate("/nfproxy")}>
</Badge> <FaArrowLeft size={16} />
<Badge size="xl" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" mr="sm"> </ActionIcon>
:{serviceInfo.port} </Tooltip>
</Badge> <MdDoubleArrow size={24} style={{ color: "var(--text-secondary)" }} />
{serviceInfo.tls_enabled && ( <Title order={2} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<Badge color="green" size="xl" variant="light" radius="md" mr="sm"> {serviceInfo.name}
<TbShieldLock size={16} style={{ marginRight: 6, verticalAlign: 'middle' }} /> </Title>
TLS (lo:{serviceInfo.clear_port}) </Group>
</Badge> <Group gap="sm" mt="md">
)} <ExceptionWarning service_id={srv} />
<Badge color={status_color} radius="sm" size="lg" variant="light">{serviceInfo.status}</Badge>
<MenuDropDownWithButton> <Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="sm">
<Menu.Item><b>Edit service</b></Menu.Item> :{serviceInfo.port}
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item> </Badge>
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item> {serviceInfo.target_type === 'tls' && (
<Menu.Item leftSection={<TbShieldLock size={18} />} onClick={()=>setTlsModal(true)}>Configure TLS</Menu.Item> <TLSAssociationBadge tlsStreamId={serviceInfo.tls_stream_id} size="lg" />
<Divider /> )}
<Menu.Label><b>Danger zone</b></Menu.Label> <Badge color={serviceInfo.ip_int.match(regex_ipv4) ? "cyan" : "pink"} radius="sm" size="lg" variant="light">
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item> {serviceInfo.target_type === 'tls' ? 'decrypted traffic to ' : ''}{serviceInfo.ip_int} on {serviceInfo.proto}
</MenuDropDownWithButton> </Badge>
<Space w="md"/> </Group>
<Tooltip label="Show logs" zIndex={0} color="cyan">
<ActionIcon color="cyan" size="lg" radius="md" onClick={()=>setOpenLogModal(true)} loading={buttonLoading} variant="filled">
<FiFileText size="20px" />
</ActionIcon>
</Tooltip>
</Box>
</Box>
{isMedium?null:<Space h="md" />}
<Box className={isMedium?'center-flex':'center-flex-row'} style={{ justifyContent: "space-between"}} px="md" mt="lg">
<Box className={isMedium?'center-flex':'center-flex-row'}>
<Box className='center-flex'>
<Badge color="orange" radius="sm" size="md" variant="filled"><FaPencilAlt style={{ marginBottom: -2}} /> {serviceInfo.edited_packets}</Badge>
<Space w="xs" />
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2}} /> {serviceInfo.blocked_packets}</Badge>
<Space w="xs" />
<Badge color="violet" radius="sm" size="md" variant="filled"><TbPlugConnected style={{ marginBottom: -2}} size={13} /> {serviceInfo.n_filters}</Badge>
</Box> </Box>
{isMedium?<Space w="xs" />:<Space h="xs" />}
<Badge color={serviceInfo.ip_int.match(regex_ipv4)?"cyan":"pink"} radius="sm" size="md" variant="filled" mr="xs">{serviceInfo.ip_int} on {serviceInfo.proto}</Badge>
</Box>
{isMedium?null:<Space h="xl" />}
<Box className='center-flex'>
<Tooltip label="Go back" zIndex={0} color="cyan">
<ActionIcon color="cyan"
onClick={() => navigate("/")} size="xl" radius="md" variant="filled"
aria-describedby="tooltip-back-id">
<FaArrowLeft size="25px" />
</ActionIcon>
</Tooltip>
<Space w="md"/>
<Tooltip label="Stop service" zIndex={0} color="red">
<ActionIcon color="red" loading={buttonLoading}
onClick={stopService} size="xl" radius="md" variant="filled"
disabled={serviceInfo.status === "stop"}
aria-describedby="tooltip-stop-id">
<FaStop size="20px" />
</ActionIcon>
</Tooltip>
<Space w="md"/>
<Tooltip label="Start service" zIndex={0} color="teal">
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading}
variant="filled" disabled={!["stop","pause"].includes(serviceInfo.status)?true:false}>
<FaPlay size="20px" />
</ActionIcon>
</Tooltip>
</Box>
</Box>
<Divider my="xl" /> <Box style={{ flexGrow: 1, minWidth: isMedium ? 'auto' : '100%' }}>
<Group gap="xl" justify={isMedium ? "flex-start" : "space-between"}>
<Group gap="xs">
<Badge color="yellow" radius="sm" size="lg" variant="light" leftSection={<FaFilter size={12} style={{ marginTop: 2 }} />}>
{serviceInfo.blocked_packets}
</Badge>
<Badge color="orange" radius="sm" size="lg" variant="light" leftSection={<FaPencilAlt size={12} style={{ marginTop: 2 }} />}>
{serviceInfo.edited_packets}
</Badge>
<Badge color="violet" radius="sm" size="lg" variant="light" leftSection={<TbPlugConnected size={12} style={{ marginTop: 2 }} />}>
{serviceInfo.n_filters}
</Badge>
</Group>
{filterCode.data?<> <Group gap="sm" justify="flex-end" style={{ flexGrow: 1 }}>
<Title order={3} style={{textAlign:"center"}} className="center-flex"><FaPython style={{ marginBottom: -3 }} size={30} /><Space w="xs" />Filter code</Title> <Tooltip label={serviceInfo.status === "stop" ? "Cannot stop" : "Stop service"} position="bottom" color="red">
<CodeHighlight code={filterCode.data} language="python" mt="lg" /> <ActionIcon color="red" loading={buttonLoading} onClick={stopService} size="xl" radius="md" variant="light" disabled={serviceInfo.status === "stop"}>
</>: null} <FaStop size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={!["stop", "pause"].includes(serviceInfo.status) ? "Cannot start" : "Start service"} position="bottom" color="teal">
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading} variant="light" disabled={!["stop", "pause"].includes(serviceInfo.status)}>
<FaPlay size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label="Show logs" position="bottom" color="cyan">
<ActionIcon color="cyan" size="xl" radius="md" onClick={() => setOpenLogModal(true)} variant="light">
<FiFileText size={18} />
</ActionIcon>
</Tooltip>
<MenuDropDownWithButton>
<Menu.Label><b>Service Tools</b></Menu.Label>
<Menu.Item leftSection={<IoSettingsSharp size={16} />} onClick={() => setEditModal(true)}>Service Settings</Menu.Item>
<Menu.Item leftSection={<BiRename size={16} />} onClick={() => setRenameModal(true)}>Change Name</Menu.Item>
<Divider />
<Menu.Label><b>Danger zone</b></Menu.Label>
<Menu.Item color="red" leftSection={<BsTrashFill size={16} />} onClick={() => setDeleteModal(true)}>Delete Service</Menu.Item>
</MenuDropDownWithButton>
</Group>
</Group>
</Box>
</Group>
</Card>
{(!filtersList.data || filtersList.data.length == 0)?<> {filterCode.data ? <>
<Space h="xl" /> <Group justify="center" gap="xs" mt="xl">
<Title className='center-flex' style={{textAlign:"center"}} order={3}>No filters found! Create some proxy filters, install the firegex client:<Space w="xs" /><Code mb={-4} >pip install -U fgex</Code></Title> <ThemeIcon radius="md" size="md" variant="light" color="yellow"><FaPython size={16} /></ThemeIcon>
<Space h="xs" /> <Title order={3}>Filter code</Title>
<Title className='center-flex' style={{textAlign:"center"}} order={3}>Read the documentation for more information<Space w="sm" /><DocsButton doc='nfproxy'/></Title> </Group>
<Space h="xs" /> <Card withBorder radius="md" p={0} bg="transparent" style={{ borderColor: 'var(--fourth_color)', marginTop: '20px' }}>
<Title className='center-flex' style={{textAlign:"center"}} order={3}>Then create a new filter file with the following syntax and upload it here (using the button above)</Title> <CodeHighlight code={filterCode.data} language="python" style={{ backgroundColor: 'transparent' }} />
</>:<>{filtersList.data?.map( (filterInfo) => <PyFilterView filterInfo={filterInfo} key={filterInfo.name}/>)}</> </Card>
</> : null}
{(!filtersList.data || filtersList.data.length == 0) ? (
<Stack align="center" gap="xs" py="xl">
<Title order={3} ta="center">No filters found! Create some proxy filters, install the firegex client:<Space w="xs" /><Code mb={-4} >pip install -U fgex</Code></Title>
<Title order={3} ta="center">Read the documentation for more information<Space w="sm" /><DocsButton doc='nfproxy' /></Title>
<Title order={3} ta="center">Then create a new filter file with the following syntax and upload it here (using the button above)</Title>
</Stack>
) : <Box mt="xs">{filtersList.data?.map((filterInfo) => <PyFilterView filterInfo={filterInfo} key={filterInfo.name} />)}</Box>
} }
<YesNoModal <YesNoModal
title='Are you sure to delete this service?' title='Are you sure to delete this service?'
description={`You are going to delete the service '${serviceInfo.port}', causing the stopping of the firewall and deleting all the regex associated. This will cause the shutdown of your service! ⚠️`} description={`You are going to delete the service '${serviceInfo.port}', causing the stopping of the firewall and deleting all the regex associated. This will cause the shutdown of your service! ⚠️`}
onClose={()=>setDeleteModal(false) } onClose={() => setDeleteModal(false)}
action={deleteService} action={deleteService}
opened={deleteModal} opened={deleteModal}
/> />
<RenameForm <RenameForm
onClose={()=>setRenameModal(false)} onClose={() => setRenameModal(false)}
opened={renameModal} opened={renameModal}
service={serviceInfo} service={serviceInfo}
/> />
<AddEditService <AddEditService
opened={editModal} opened={editModal}
onClose={()=>setEditModal(false)} onClose={() => setEditModal(false)}
edit={serviceInfo} edit={serviceInfo}
/> />
<TLSConfigModal
opened={tlsModal}
onClose={() => setTlsModal(false)}
service={serviceInfo}
updateTlsConfig={nfproxy.updatetlsconfig}
queryKey={serviceQueryKey}
/>
<ModalLog <ModalLog
opened={openLogModal} opened={openLogModal}
close={()=>setOpenLogModal(false)} close={() => setOpenLogModal(false)}
title={`Logs for service ${serviceInfo.name}`} title={`Logs for service ${serviceInfo.name}`}
data={logData.join("")} data={logData.join("")}
/> />

View File

@ -6,12 +6,12 @@ import ServiceRow from '../../components/NFProxy/ServiceRow';
import { errorNotify, getErrorMessage, isMediumScreen } from '../../js/utils'; import { errorNotify, getErrorMessage, isMediumScreen } from '../../js/utils';
import AddEditService from '../../components/NFProxy/AddEditService'; import AddEditService from '../../components/NFProxy/AddEditService';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { TbPlugConnected, TbReload } from 'react-icons/tb'; import { TbPlugConnected, TbReload, TbCode } from 'react-icons/tb';
import { EXAMPLE_PYFILTER, nfproxy, nfproxyServiceQuery } from '../../components/NFProxy/utils'; import { EXAMPLE_PYFILTER, nfproxy, nfproxyServiceQuery } from '../../components/NFProxy/utils';
import { FaFilter, FaPencilAlt, FaServer } from 'react-icons/fa'; import { FaFilter, FaPencilAlt, FaServer } from 'react-icons/fa';
import { MdUploadFile } from "react-icons/md"; import { MdUploadFile } from "react-icons/md";
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useFileDialog } from '@mantine/hooks'; import { EditFilterModal } from '../../components/NFProxy/EditFilterModal';
import { CodeHighlight } from '@mantine/code-highlight'; import { CodeHighlight } from '@mantine/code-highlight';
import { DocsButton } from '../../components/DocsButton'; import { DocsButton } from '../../components/DocsButton';
@ -24,69 +24,8 @@ export default function NFProxy({ children }: { children: any }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const isMedium = isMediumScreen() const isMedium = isMediumScreen()
const services = nfproxyServiceQuery() const services = nfproxyServiceQuery()
const fileDialog = useFileDialog({ const [editorOpen, setEditorOpen] = useState(false);
accept: ".py", // File upload logic has been moved to EditFilterModal
multiple: false,
resetOnOpen: true,
onChange: (files) => {
if (files?.length??0 > 0)
setFile(files![0])
}
});
const [file, setFile] = useState<File | null>(null);
useEffect(() => {
if (!srv) return
const service = services.data?.find(s => s.service_id === srv)
if (!service) return
if (file){
console.log("Uploading code")
const notify_id = notifications.show(
{
title: "Uploading code",
message: `Uploading code for service ${service.name}`,
color: "blue",
icon: <MdUploadFile size={20} />,
autoClose: false,
loading: true,
}
)
file.text()
.then( code => nfproxy.setpyfilterscode(service?.service_id??"",code.toString()))
.then( res => {
if (!res){
notifications.update({
id: notify_id,
title: "Code uploaded",
message: `Successfully uploaded code for service ${service.name}`,
color: "green",
icon: <MdUploadFile size={20} />,
autoClose: 5000,
loading: false,
})
}else{
notifications.update({
id: notify_id,
title: "Code upload failed",
message: `Error: ${res}`,
color: "red",
icon: <MdUploadFile size={20} />,
autoClose: 5000,
loading: false,
})
}
}).catch( err => {
notifications.update({
id: notify_id,
title: "Code upload failed",
message: `Error: ${err}`,
color: "red",
icon: <MdUploadFile size={20} />,
autoClose: 5000,
loading: false,
})
}).finally(()=>{setFile(null)})
}
}, [file])
useEffect(()=> { useEffect(()=> {
if(services.isError) if(services.isError)
@ -103,21 +42,21 @@ export default function NFProxy({ children }: { children: any }) {
<Box className='center-flex' > <Box className='center-flex' >
{isMedium?"General stats:":null} {isMedium?"General stats:":null}
<Space w="xs" /> <Space w="xs" />
<Badge size="md" radius="sm" color="green" variant="filled"><FaServer style={{ marginBottom: -1, marginRight: 4}} />Services: {services.isLoading?0:services.data?.length}</Badge> <Badge size="md" radius="sm" color="green" variant="filled"><FaServer style={{ marginBottom: -1, marginRight: 4}} />Services: {services.isLoading?0:Array.isArray(services.data)?services.data.length:0}</Badge>
<Space w="xs" /> <Space w="xs" />
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:services.data?.reduce((acc, s)=> acc+=s.blocked_packets, 0)}</Badge> <Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:Array.isArray(services.data)?services.data.reduce((acc, s)=> acc+=s.blocked_packets, 0):0}</Badge>
<Space w="xs" /> <Space w="xs" />
<Badge color="orange" radius="sm" size="md" variant="filled"><FaPencilAlt style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:services.data?.reduce((acc, s)=> acc+=s.edited_packets, 0)}</Badge> <Badge color="orange" radius="sm" size="md" variant="filled"><FaPencilAlt style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:Array.isArray(services.data)?services.data.reduce((acc, s)=> acc+=s.edited_packets, 0):0}</Badge>
<Space w="xs" /> <Space w="xs" />
<Badge size="md" radius="sm" color="violet" variant="filled"><TbPlugConnected style={{ marginBottom: -2, marginRight: 4}} size={13} />{services.isLoading?0:services.data?.reduce((acc, s)=> acc+=s.n_filters, 0)}</Badge> <Badge size="md" radius="sm" color="violet" variant="filled"><TbPlugConnected style={{ marginBottom: -2, marginRight: 4}} size={13} />{services.isLoading?0:Array.isArray(services.data)?services.data.reduce((acc, s)=> acc+=s.n_filters, 0):0}</Badge>
<Space w="xs" /> <Space w="xs" />
</Box> </Box>
{isMedium?null:<Space h="md" />} {isMedium?null:<Space h="md" />}
<Box className='center-flex' > <Box className='center-flex' >
{ srv? { srv?
<Tooltip label="Upload a new filter code" position='bottom' color="blue"> <Tooltip label="Edit python filter code" position='bottom' color="blue">
<ActionIcon color="blue" size="lg" radius="md" variant="filled" onClick={fileDialog.open}> <ActionIcon color="blue" size="lg" radius="md" variant="filled" onClick={() => setEditorOpen(true)}>
<MdUploadFile size={18} /> <TbCode size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
: <Tooltip label="Add a new service" position='bottom' color="blue"> : <Tooltip label="Add a new service" position='bottom' color="blue">
@ -136,8 +75,8 @@ export default function NFProxy({ children }: { children: any }) {
<DocsButton doc="nfproxy" /> <DocsButton doc="nfproxy" />
</Box> </Box>
</Box> </Box>
<Space h="md" /> <Space h="xl" />
<Box className="center-flex-row" style={{gap: 20}}> <Box className="center-flex-row" style={{ gap: 12, width: "100%" }}>
{srv?null:<> {srv?null:<>
<LoadingOverlay visible={services.isLoading} /> <LoadingOverlay visible={services.isLoading} />
{(services.data && services.data?.length > 0)?services.data.map( srv => <ServiceRow service={srv} key={srv.service_id} onClick={()=>{ {(services.data && services.data?.length > 0)?services.data.map( srv => <ServiceRow service={srv} key={srv.service_id} onClick={()=>{
@ -167,6 +106,11 @@ export default function NFProxy({ children }: { children: any }) {
{!srv? {!srv?
<AddEditService opened={open} onClose={closeModal} />:null <AddEditService opened={open} onClose={closeModal} />:null
} }
<EditFilterModal
opened={editorOpen}
onClose={() => setEditorOpen(false)}
service={services.data?.find(s => s.service_id === srv)}
/>
</> </>
} }

View File

@ -1,4 +1,4 @@
import { ActionIcon, Box, Grid, LoadingOverlay, Space, Title, Tooltip } from '@mantine/core'; import { ActionIcon, Box, Grid, LoadingOverlay, Space, Stack, Title, Tooltip, Card, Group } from '@mantine/core';
import { Navigate, useNavigate, useParams } from 'react-router'; import { Navigate, useNavigate, useParams } from 'react-router';
import RegexView from '../../components/RegexView'; import RegexView from '../../components/RegexView';
import AddNewRegex from '../../components/AddNewRegex'; import AddNewRegex from '../../components/AddNewRegex';
@ -8,9 +8,9 @@ import { Badge, Divider, Menu } from '@mantine/core';
import { useState } from 'react'; import { useState } from 'react';
import { FaFilter, FaPlay, FaStop } from 'react-icons/fa'; import { FaFilter, FaPlay, FaStop } from 'react-icons/fa';
import { nfregex, serviceQueryKey } from '../../components/NFRegex/utils'; import { nfregex, serviceQueryKey } from '../../components/NFRegex/utils';
import { MdDoubleArrow } from "react-icons/md" import { MdDoubleArrow, MdDownload, MdUpload } from "react-icons/md"
import YesNoModal from '../../components/YesNoModal'; import YesNoModal from '../../components/YesNoModal';
import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../js/utils'; import { errorNotify, isMediumScreen, okNotify, regex_ipv4, getapi, postapi } from '../../js/utils';
import { BsTrashFill } from 'react-icons/bs'; import { BsTrashFill } from 'react-icons/bs';
import { BiRename } from 'react-icons/bi' import { BiRename } from 'react-icons/bi'
import RenameForm from '../../components/NFRegex/ServiceRow/RenameForm'; import RenameForm from '../../components/NFRegex/ServiceRow/RenameForm';
@ -20,6 +20,7 @@ import { FaArrowLeft } from "react-icons/fa";
import { VscRegex } from 'react-icons/vsc'; import { VscRegex } from 'react-icons/vsc';
import { IoSettingsSharp } from 'react-icons/io5'; import { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../../components/NFRegex/AddEditService'; import AddEditService from '../../components/NFRegex/AddEditService';
import TLSAssociationBadge from '../../components/TLSAssociationBadge';
export default function ServiceDetailsNFRegex() { export default function ServiceDetailsNFRegex() {
@ -89,88 +90,132 @@ export default function ServiceDetailsNFRegex() {
} }
return <> return <>
<Space h="sm" />
<LoadingOverlay visible={regexesList.isLoading} /> <LoadingOverlay visible={regexesList.isLoading} />
<Box className={isMedium?'center-flex':'center-flex-row'} style={{ justifyContent: "space-between"}} px="md" mt="lg"> <Card
<Box> withBorder
<Title order={1}> shadow="md"
<Box className="center-flex"> radius="md"
<MdDoubleArrow /><Space w="sm" />{serviceInfo.name} p="xl"
</Box> mb="xl"
</Title> bg="var(--third_color)"
</Box> style={{ borderColor: 'var(--fourth_color)', overflow: "visible" }} // Allows dropdowns to overflow outside
{isMedium?null:<Space h="md" />} >
<Box className='center-flex'> <Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"} gap="xl">
<Badge color={status_color} radius="md" size="xl" variant="filled" mr="sm"> <Box>
{serviceInfo.status} <Group gap="xs" wrap="nowrap" align="center">
</Badge> <Tooltip label="Go back" position="top">
<Badge size="xl" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" mr="sm"> <ActionIcon color="cyan" variant="subtle" radius="md" onClick={() => navigate("/nfregex")}>
:{serviceInfo.port} <FaArrowLeft size={16} />
</Badge> </ActionIcon>
</Tooltip>
<MdDoubleArrow size={24} style={{ color: "var(--text-secondary)" }} />
<Title order={2} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{serviceInfo.name}
</Title>
</Group>
<Group gap="sm" mt="md">
<Badge color={status_color} radius="sm" size="lg" variant="light">{serviceInfo.status}</Badge>
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="sm">
:{serviceInfo.port}
</Badge>
{serviceInfo.target_type === 'tls' && (
<TLSAssociationBadge tlsStreamId={serviceInfo.tls_stream_id} size="lg" />
)}
<Badge color={serviceInfo.ip_int.match(regex_ipv4) ? "cyan" : "pink"} radius="sm" size="lg" variant="light">
{serviceInfo.target_type === 'tls' ? 'decrypted traffic to ' : ''}{serviceInfo.ip_int} on {serviceInfo.proto}
</Badge>
</Group>
</Box>
<MenuDropDownWithButton> <Box style={{ flexGrow: 1, minWidth: isMedium ? 'auto' : '100%' }}>
<Menu.Item><b>Edit service</b></Menu.Item> <Group gap="xl" justify={isMedium ? "flex-start" : "space-between"}>
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item> <Group gap="xs">
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item> <Badge color="yellow" radius="sm" size="lg" variant="light" leftSection={<FaFilter size={12} style={{ marginTop: 2 }} />}>
<Divider /> {serviceInfo.n_packets}
<Menu.Label><b>Danger zone</b></Menu.Label> </Badge>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item> <Badge color="violet" radius="sm" size="lg" variant="light" leftSection={<VscRegex size={12} style={{ marginTop: 2 }} />}>
</MenuDropDownWithButton> {serviceInfo.n_regex}
</Box> </Badge>
</Box> </Group>
{isMedium?null:<Space h="md" />}
<Box className={isMedium?'center-flex':'center-flex-row'} style={{ justifyContent: "space-between"}} px="md" mt="lg"> <Group gap="sm" justify="flex-end" style={{ flexGrow: 1 }}>
<Box className={isMedium?'center-flex':'center-flex-row'}> <Tooltip label={serviceInfo.status === "stop" ? "Cannot stop" : "Stop service"} position="bottom" color="red">
<Box className='center-flex'> <ActionIcon color="red" loading={buttonLoading} onClick={stopService} size="xl" radius="md" variant="light" disabled={serviceInfo.status === "stop"}>
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2}} /> {serviceInfo.n_packets}</Badge> <FaStop size={18} />
<Space w="xs" /> </ActionIcon>
<Badge color="violet" radius="sm" size="md" variant="filled"><VscRegex style={{ marginBottom: -2}} size={13} /> {serviceInfo.n_regex}</Badge> </Tooltip>
<Tooltip label={!["stop", "pause"].includes(serviceInfo.status) ? "Cannot start" : "Start service"} position="bottom" color="teal">
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading} variant="light" disabled={!["stop", "pause"].includes(serviceInfo.status)}>
<FaPlay size={18} />
</ActionIcon>
</Tooltip>
<MenuDropDownWithButton>
<Menu.Label><b>Service Tools</b></Menu.Label>
<Menu.Item leftSection={<IoSettingsSharp size={16} />} onClick={() => setEditModal(true)}>Service Settings</Menu.Item>
<Menu.Item leftSection={<BiRename size={16} />} onClick={() => setRenameModal(true)}>Change Name</Menu.Item>
<Divider />
<Menu.Label><b>Rules Management</b></Menu.Label>
<Menu.Item leftSection={<MdDownload size={16} />} onClick={() => {
getapi(`nfregex/services/${serviceInfo.service_id}/export`).then(data => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `firegex_nfregex_${serviceInfo.name}_backup.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
okNotify("Exported", "Regex rules have been exported.");
}).catch(err => errorNotify("Export Failed", err.toString()));
}}>Export Rules</Menu.Item>
<Menu.Item leftSection={<MdUpload size={16} />} onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
postapi(`nfregex/services/${serviceInfo.service_id}/import`, data).then(() => {
okNotify("Imported", "Regex rules have been imported successfully.");
queryClient.invalidateQueries({ queryKey: serviceQueryKey });
queryClient.invalidateQueries({ queryKey: ["nfregex-service-regexes", serviceInfo.service_id] });
}).catch(err => errorNotify("Import Failed", err.toString()));
} catch (err: any) {
errorNotify("Invalid JSON", err.toString());
}
};
reader.readAsText(file);
};
input.click();
}}>Import Rules</Menu.Item>
<Divider />
<Menu.Label><b>Danger zone</b></Menu.Label>
<Menu.Item color="red" leftSection={<BsTrashFill size={16} />} onClick={() => setDeleteModal(true)}>Delete Service</Menu.Item>
</MenuDropDownWithButton>
</Group>
</Group>
</Box> </Box>
{isMedium?<Space w="xs" />:<Space h="xs" />} </Group>
<Badge color={serviceInfo.ip_int.match(regex_ipv4)?"cyan":"pink"} radius="sm" size="md" variant="filled" mr="xs">{serviceInfo.ip_int} on {serviceInfo.proto}</Badge> </Card>
</Box> {(!regexesList.data || regexesList.data.length == 0) ? (
{isMedium?null:<Space h="xl" />} <Stack align="center" gap="xl" py="xl">
<Box className='center-flex'> <Title order={3} ta="center">No regex found for this service! Add one by clicking the "+" buttons</Title>
<Tooltip label="Go back" zIndex={0} color="cyan"> <Tooltip label="Add a new regex" zIndex={0} color="blue">
<ActionIcon color="cyan" <ActionIcon color="blue" onClick={()=>setOpen(true)} size="xl" radius="md" variant="filled"
onClick={() => navigate("/")} size="xl" radius="md" variant="filled" aria-describedby="tooltip-AddRegex-id"><BsPlusLg size="20px" /></ActionIcon>
aria-describedby="tooltip-back-id">
<FaArrowLeft size="25px" />
</ActionIcon>
</Tooltip> </Tooltip>
<Space w="md"/> </Stack>
<Tooltip label="Stop service" zIndex={0} color="red"> ) : (
<ActionIcon color="red" loading={buttonLoading} <Grid mt="xs">
onClick={stopService} size="xl" radius="md" variant="filled"
disabled={serviceInfo.status === "stop"}
aria-describedby="tooltip-stop-id">
<FaStop size="20px" />
</ActionIcon>
</Tooltip>
<Space w="md"/>
<Tooltip label="Start service" zIndex={0} color="teal">
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading}
variant="filled" disabled={!["stop","pause"].includes(serviceInfo.status)?true:false}>
<FaPlay size="20px" />
</ActionIcon>
</Tooltip>
</Box>
</Box>
<Divider my="xl" />
{(!regexesList.data || regexesList.data.length == 0)?<>
<Space h="xl" />
<Title className='center-flex' style={{textAlign:"center"}} order={3}>No regex found for this service! Add one by clicking the "+" buttons</Title>
<Space h="xl" /> <Space h="xl" />
<Box className='center-flex'>
<Tooltip label="Add a new regex" zIndex={0} color="blue">
<ActionIcon color="blue" onClick={()=>setOpen(true)} size="xl" radius="md" variant="filled"
aria-describedby="tooltip-AddRegex-id"><BsPlusLg size="20px" /></ActionIcon>
</Tooltip>
</Box>
</>:
<Grid>
{regexesList.data?.map( (regexInfo) => <Grid.Col key={regexInfo.id} span={{ lg:6, xs: 12 }}><RegexView regexInfo={regexInfo} /></Grid.Col>)} {regexesList.data?.map( (regexInfo) => <Grid.Col key={regexInfo.id} span={{ lg:6, xs: 12 }}><RegexView regexInfo={regexInfo} /></Grid.Col>)}
</Grid> </Grid>
} )}
{srv?<AddNewRegex opened={open} onClose={() => {setOpen(false);}} service={srv} />:null} {srv?<AddNewRegex opened={open} onClose={() => {setOpen(false);}} service={srv} />:null}
<YesNoModal <YesNoModal

View File

@ -38,11 +38,11 @@ function NFRegex({ children }: { children: any }) {
<Box className='center-flex' > <Box className='center-flex' >
{isMedium?"General stats:":null} {isMedium?"General stats:":null}
<Space w="xs" /> <Space w="xs" />
<Badge size="md" radius="sm" color="green" variant="filled"><FaServer style={{ marginBottom: -1, marginRight: 4}} />Services: {services.isLoading?0:services.data?.length}</Badge> <Badge size="md" radius="sm" color="green" variant="filled"><FaServer style={{ marginBottom: -1, marginRight: 4}} />Services: {services.isLoading?0:Array.isArray(services.data)?services.data.length:0}</Badge>
<Space w="xs" /> <Space w="xs" />
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:services.data?.reduce((acc, s)=> acc+=s.n_packets, 0)}</Badge> <Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:Array.isArray(services.data)?services.data.reduce((acc, s)=> acc+=s.n_packets, 0):0}</Badge>
<Space w="xs" /> <Space w="xs" />
<Badge size="md" radius="sm" color="violet" variant="filled"><VscRegex style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:services.data?.reduce((acc, s)=> acc+=s.n_regex, 0)}</Badge> <Badge size="md" radius="sm" color="violet" variant="filled"><VscRegex style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:Array.isArray(services.data)?services.data.reduce((acc, s)=> acc+=s.n_regex, 0):0}</Badge>
<Space w="xs" /> <Space w="xs" />
</Box> </Box>
{isMedium?null:<Space h="md" />} {isMedium?null:<Space h="md" />}
@ -64,8 +64,8 @@ function NFRegex({ children }: { children: any }) {
<DocsButton doc="nfregex" /> <DocsButton doc="nfregex" />
</Box> </Box>
</Box> </Box>
<Space h="md" /> <Space h="xl" />
<Box className="center-flex-row" style={{gap: 20}}> <Box className="center-flex-row" style={{ gap: 12, width: "100%" }}>
{srv?null:<> {srv?null:<>
<LoadingOverlay visible={services.isLoading} /> <LoadingOverlay visible={services.isLoading} />
{(services.data && services.data?.length > 0)?services.data.map( srv => <ServiceRow service={srv} key={srv.service_id} onClick={()=>{ {(services.data && services.data?.length > 0)?services.data.map( srv => <ServiceRow service={srv} key={srv.service_id} onClick={()=>{

View File

@ -47,8 +47,8 @@ function PortHijack() {
<DocsButton doc="porthijack" /> <DocsButton doc="porthijack" />
</Box> </Box>
</Box> </Box>
<Space h="md" /> <Space h="xl" />
<Box className="center-flex-row" style={{gap: 20}}> <Box className="center-flex-row" style={{ gap: 12, width: "100%" }}>
<LoadingOverlay visible={services.isLoading} /> <LoadingOverlay visible={services.isLoading} />
{(services.data && services.data.length > 0) ?services.data.map( srv => <ServiceRow service={srv} key={srv.service_id} />):<> {(services.data && services.data.length > 0) ?services.data.map( srv => <ServiceRow service={srv} key={srv.service_id} />):<>
<Box className='center-flex-row'> <Box className='center-flex-row'>

View File

@ -0,0 +1,232 @@
import { ActionIcon, Box, Button, Checkbox, Code, Collapse, Group, Modal, NumberInput, Paper, Space, Table, Text, TextInput, Textarea, Title, Tooltip, FileButton } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useDisclosure } from "@mantine/hooks";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { FaPlay, FaStop, FaTrash, FaPencilAlt } from "react-icons/fa";
import { MdAdd } from "react-icons/md";
import { getapi, postapi, putapi, deleteapi, errorNotify, okNotify } from "../../js/utils";
import PortAndInterface from "../../components/PortAndInterface";
import { TbShieldLock } from "react-icons/tb";
export const tlsStreamsQueryKey = ["tls_streams"];
interface TLSStream {
id: string;
name: string;
ip_int: string;
port: number;
cert: string;
key: string;
status: string;
ssl_port: number;
clear_port: number;
}
const StreamFormModal = ({ opened, close, editStream }: { opened: boolean, close: () => void, editStream?: TLSStream | null }) => {
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const isEdit = !!editStream;
const form = useForm({
initialValues: {
name: "",
ip_int: "",
port: 443,
cert: "",
key: ""
},
validate: {
name: (val) => val.length > 0 ? null : "Name is required",
ip_int: (val) => val.length > 0 ? null : "Target IP is required",
port: (val) => val > 0 && val <= 65535 ? null : "Invalid port",
cert: (val) => val.length > 0 ? null : "Certificate is required",
key: (val) => val.length > 0 ? null : "Private Key is required"
}
});
useEffect(() => {
if (opened) {
if (editStream) {
form.setValues({
name: editStream.name,
ip_int: editStream.ip_int,
port: editStream.port,
cert: editStream.cert,
key: editStream.key
});
} else {
form.reset();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, editStream?.id]);
const mutation = useMutation({
mutationFn: (values: typeof form.values) => {
return isEdit ? putapi(`tls/streams/${editStream!.id}`, values) : postapi("tls/streams", values);
},
onSuccess: () => {
okNotify(isEdit ? "Stream updated" : "Stream created", `The TLS stream was successfully ${isEdit ? "updated" : "created"}`);
queryClient.invalidateQueries({ queryKey: tlsStreamsQueryKey });
form.reset();
close();
},
onError: (err: any) => {
errorNotify(`Error ${isEdit ? "updating" : "creating"} stream`, err.toString());
},
onSettled: () => setLoading(false)
});
const handleFileUpload = (file: File | null, fieldName: string) => {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
form.setFieldValue(fieldName, text);
};
reader.readAsText(file);
};
return (
<Modal opened={opened} onClose={close} title={<Group gap="xs"><TbShieldLock size={20} color="var(--accent-color)" /><Text fw={500}>{isEdit ? "Edit TLS Decrypt Stream" : "Add New TLS Decrypt Stream"}</Text></Group>} centered styles={{ body: { maxHeight: 'calc(100vh - 150px)', overflowY: 'auto' } }}>
<form onSubmit={form.onSubmit((v) => { setLoading(true); mutation.mutate(v); })}>
<TextInput label="Name" placeholder="My HTTPS Service" {...form.getInputProps("name")} required />
<Space h="sm" />
<PortAndInterface form={form} int_name="ip_int" port_name="port" label="Target" />
<Space h="sm" />
<Group justify="space-between" align="center" mb={4}>
<Text size="sm" fw={500}>TLS Certificate (PEM format) <Text span c="red">*</Text></Text>
<FileButton onChange={(file) => handleFileUpload(file, 'cert')} accept=".pem,.crt,.cer,.txt,*">
{(props) => <Button {...props} size="xs" variant="subtle">Load from file</Button>}
</FileButton>
</Group>
<Textarea placeholder="-----BEGIN CERTIFICATE-----..." minRows={4} styles={{ input: { fontFamily: 'monospace' } }} {...form.getInputProps("cert")} required />
<Space h="sm" />
<Group justify="space-between" align="center" mb={4}>
<Text size="sm" fw={500}>TLS Private Key (PEM format) <Text span c="red">*</Text></Text>
<FileButton onChange={(file) => handleFileUpload(file, 'key')} accept=".pem,.key,.txt,*">
{(props) => <Button {...props} size="xs" variant="subtle">Load from file</Button>}
</FileButton>
</Group>
<Textarea placeholder="-----BEGIN PRIVATE KEY-----..." minRows={4} styles={{ input: { fontFamily: 'monospace' } }} {...form.getInputProps("key")} required />
<Space h="md" />
<Group justify="right">
<Button type="submit" loading={loading}>{isEdit ? "Save Changes" : "Create Stream"}</Button>
</Group>
</form>
</Modal>
);
};
const TLSDecrypt = () => {
const queryClient = useQueryClient();
const [opened, { open, close }] = useDisclosure(false);
const [editStream, setEditStream] = useState<TLSStream | null>(null);
const { data: streams, isLoading } = useQuery<TLSStream[]>({
queryKey: tlsStreamsQueryKey,
queryFn: () => getapi("tls/streams")
});
const actionMutation = useMutation({
mutationFn: ({ action, id }: { action: string, id: string }) => {
if (action === "delete") return deleteapi(`tls/streams/${id}`);
return postapi(`tls/streams/${id}/${action}`);
},
onSuccess: (_, { action }) => {
okNotify("Success", `Stream ${action} successful`);
queryClient.invalidateQueries({ queryKey: tlsStreamsQueryKey });
},
onError: (err: any) => {
errorNotify("Error", err.toString());
}
});
const openAdd = () => { setEditStream(null); open(); };
const openEdit = (stream: TLSStream) => { setEditStream(stream); open(); };
return (
<Box p="md">
<Group justify="space-between" mb="md">
<Title order={2}>TLS Decryption Streams</Title>
<Button leftSection={<MdAdd />} onClick={openAdd}>Add Stream</Button>
</Group>
<Text c="dimmed" mb="xl">
Manage central TLS decryption streams. These streams transparently decrypt inbound TLS traffic,
forward it to inspection services like NFProxy/NFRegex in cleartext, and re-encrypt the traffic
before sending it to the backend.
</Text>
<Box p="md" style={{ backgroundColor: 'var(--third_color)', borderRadius: '16px', border: '1px solid var(--fourth_color)', boxShadow: '0 8px 32px rgba(0,0,0,0.3)' }}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Target</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Internal SSL Port</Table.Th>
<Table.Th>Internal Clear Port</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{streams?.map(stream => (
<Table.Tr key={stream.id}>
<Table.Td>{stream.name}</Table.Td>
<Table.Td>{stream.ip_int}:{stream.port}</Table.Td>
<Table.Td>
<Text c={stream.status === "active" ? "green" : "red"} fw={500}>
{stream.status.toUpperCase()}
</Text>
</Table.Td>
<Table.Td><Code>{stream.ssl_port}</Code></Table.Td>
<Table.Td><Code>{stream.clear_port}</Code></Table.Td>
<Table.Td>
<Group gap="xs">
{stream.status === "stop" ? (
<Tooltip label="Start Stream">
<ActionIcon color="green" onClick={() => actionMutation.mutate({ action: "start", id: stream.id })}>
<FaPlay />
</ActionIcon>
</Tooltip>
) : (
<Tooltip label="Stop Stream">
<ActionIcon color="orange" onClick={() => actionMutation.mutate({ action: "stop", id: stream.id })}>
<FaStop />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Edit Stream">
<ActionIcon color="cyan" onClick={() => openEdit(stream)}>
<FaPencilAlt />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Stream">
<ActionIcon color="red" onClick={() => actionMutation.mutate({ action: "delete", id: stream.id })}>
<FaTrash />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
{streams?.length === 0 && (
<Table.Tr>
<Table.Td colSpan={6} ta="center">No streams found</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Box>
<StreamFormModal opened={opened} close={() => { close(); setEditStream(null); }} editStream={editStream} />
</Box>
);
};
export default TLSDecrypt;

View File

@ -1,10 +1,11 @@
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import viteTsconfigPaths from 'vite-tsconfig-paths';
import svgrPlugin from 'vite-plugin-svgr'; import svgrPlugin from 'vite-plugin-svgr';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), viteTsconfigPaths(), svgrPlugin()], plugins: [react(), svgrPlugin()],
resolve: {
tsconfigPaths: true,
},
}) })

83
run.py
View File

@ -185,6 +185,7 @@ def gen_args(args_to_parse: list[str]|None = None):
parser_config.add_argument('--port', "-p", type=int, required=False, help='Set default port for web service') parser_config.add_argument('--port', "-p", type=int, required=False, help='Set default port for web service')
parser_config.add_argument('--host', required=False, help='Set default host IP address to bind the service to') parser_config.add_argument('--host', required=False, help='Set default host IP address to bind the service to')
parser_config.add_argument('--socket-dir', required=False, type=str, help=f'Listen on socket_dir/firegex.sock instead of TCP (default from config: {config["socket_dir"]})', default=config["socket_dir"]) parser_config.add_argument('--socket-dir', required=False, type=str, help=f'Listen on socket_dir/firegex.sock instead of TCP (default from config: {config["socket_dir"]})', default=config["socket_dir"])
parser_config.add_argument('--password', required=False, type=str, nargs='?', const='', help='Change the password of the firewall (omit the value to be prompted for it interactively)')
parser_config.add_argument('--show', required=False, action="store_true", help='Show current configuration', default=False) parser_config.add_argument('--show', required=False, action="store_true", help='Show current configuration', default=False)
args = parser.parse_args(args=args_to_parse) args = parser.parse_args(args=args_to_parse)
@ -214,14 +215,15 @@ def gen_args(args_to_parse: list[str]|None = None):
args.threads = multiprocessing.cpu_count() args.threads = multiprocessing.cpu_count()
# Use config values as fallback, but allow command line to override # Use config values as fallback, but allow command line to override
if "port" not in args or args.port < 1: if "command" in args and args.command != "config":
args.port = config["port"] if "port" not in args or args.port is None or args.port < 1:
args.port = config["port"]
if "host" not in args: if "host" not in args or args.host is None:
args.host = config["host"] args.host = config["host"]
if "socket_dir" not in args: if "socket_dir" not in args or args.socket_dir is None:
args.socket_dir = config["socket_dir"] args.socket_dir = config["socket_dir"]
# Save configuration if values were specified via command line and differ from config # Save configuration if values were specified via command line and differ from config
config_changed = False config_changed = False
@ -873,12 +875,12 @@ def run_standalone():
puts("Starting as daemon...", color=colors.green) puts("Starting as daemon...", color=colors.green)
try: try:
# Start process in background out_file = open('/tmp/app.log', 'w')
process = subprocess.Popen( process = subprocess.Popen(
chroot_cmd, chroot_cmd,
shell=True, shell=True,
stdout=subprocess.DEVNULL, stdout=out_file,
stderr=subprocess.DEVNULL, stderr=out_file,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
preexec_fn=os.setsid # Create new session preexec_fn=os.setsid # Create new session
) )
@ -963,6 +965,69 @@ def handle_config_command(args):
config_changed = True config_changed = True
puts(f"Socket dir set to: {args.socket_dir}", color=colors.green) puts(f"Socket dir set to: {args.socket_dir}", color=colors.green)
if hasattr(args, 'password') and args.password is not None:
new_password = args.password
if new_password == "":
# No value was passed on the command line: prompt for it interactively
# (masked input), so the new password never lands in shell history or `ps`.
while True:
puts("Insert a new password for firegex: ", end="", color=colors.yellow, is_bold=True, flush=True)
new_password = getpass.getpass("")
if len(new_password) < 8:
puts("The password has to be at least 8 char long", color=colors.red, is_bold=True, flush=True)
else:
break
puts("Confirm the password: ", end="", color=colors.yellow, is_bold=True, flush=True)
if getpass.getpass("") != new_password:
puts("Passwords don't match!", color=colors.red, is_bold=True, flush=True)
exit(1)
elif len(new_password) < 8:
puts("Error: The password has to be at least 8 char long", color=colors.red)
exit(1)
hashed = hash_psw(new_password)
if g.standalone_mode and os.path.isfile(os.path.join(g.rootfs_path, "execute/db/firegex.db")):
import sqlite3
db_path = os.path.join(g.rootfs_path, "execute/db/firegex.db")
try:
conn = sqlite3.connect(db_path)
try:
conn.execute('INSERT INTO keys_values (key, value) VALUES (?, ?)', ('password', hashed))
except Exception:
conn.execute('UPDATE keys_values SET value = ? WHERE key = ?', (hashed, 'password'))
conn.commit()
conn.close()
puts("Password changed successfully! It will take effect immediately.", color=colors.green)
except Exception as e:
puts(f"Error changing password: {e}", color=colors.red)
exit(1)
elif volume_exists():
# The hash is passed as a plain argv item (sys.argv[1]) rather than interpolated
# into the -c source, so it never needs shell/Python-string escaping.
py_code = (
"import sqlite3, sys\n"
"conn = sqlite3.connect('/execute/db/firegex.db')\n"
"hashed = sys.argv[1]\n"
"try:\n"
" conn.execute('INSERT INTO keys_values (key, value) VALUES (?, ?)', ('password', hashed))\n"
"except Exception:\n"
" conn.execute('UPDATE keys_values SET value = ? WHERE key = ?', (hashed, 'password'))\n"
"conn.commit()\n"
"conn.close()\n"
)
try:
subprocess.run(["docker", "exec", "firegex", "python3", "-c", py_code, hashed], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
puts("Password changed successfully! It will take effect immediately.", color=colors.green)
except subprocess.CalledProcessError:
puts("Error: Could not change password. Is Firegex running?", color=colors.red)
exit(1)
else:
puts("Error: Firegex is not running and no standalone data found.", color=colors.red)
exit(1)
config_changed = True
if config_changed: if config_changed:
if save_config(config): if save_config(config):
puts(f"Configuration saved to {g.configfile}", color=colors.green) puts(f"Configuration saved to {g.configfile}", color=colors.green)

View File

@ -121,6 +121,28 @@ if __name__ == "__main__":
) )
exit(1) exit(1)
# Backups never contain the password/secret (see export_db), so importing one back
# must not lock the current session out or force a re-login.
backup = firegex.export_backup()
if backup:
puts("Sucessfully exported backup ✔", color=colors.green)
else:
puts("Test Failed: Couldn't export backup ✗", color=colors.red)
exit(1)
if firegex.import_backup(backup):
puts("Sucessfully imported backup ✔", color=colors.green)
else:
puts("Test Failed: Couldn't import backup ✗", color=colors.red)
exit(1)
firegex3 = FiregexAPI(args.address)
if firegex3.login(args.password):
puts("Password survived the backup import ✔", color=colors.green)
else:
puts("Test Failed: Password was lost/changed after backup import ✗", color=colors.red)
exit(1)
puts("List of available interfaces:", color=colors.yellow) puts("List of available interfaces:", color=colors.yellow)
for interface in firegex.get_interfaces(): for interface in firegex.get_interfaces():
puts( puts(

View File

@ -2,6 +2,7 @@
from utils.colors import colors, puts, sep from utils.colors import colors, puts, sep
from utils.firegexapi import FiregexAPI from utils.firegexapi import FiregexAPI
from utils.tcpserver import TcpServer from utils.tcpserver import TcpServer
from utils.tls_helpers import generate_self_signed_cert_key, tls_connect_send_recv
import argparse import argparse
import secrets import secrets
import time import time
@ -42,6 +43,9 @@ if __name__ == "__main__":
parser.add_argument( parser.add_argument(
"--verbose", "-V", action="store_true", help="Verbose output", default=False "--verbose", "-V", action="store_true", help="Verbose output", default=False
) )
parser.add_argument(
"--tls", "-t", action="store_true", help="Test TLS Decrypt", default=False
)
args = parser.parse_args() args = parser.parse_args()
sep() sep()
@ -458,14 +462,14 @@ if __name__ == "__main__":
X-TeSt: {secret.decode()} X-TeSt: {secret.decode()}
Content-Length: 15 Content-Length: 15
A Friendly Body""").replace("\n", "\r\n") A Friendly Body""").replace("\n ", "\n").replace("\n", "\r\n")
REQUEST_BODY_TEST = textwrap.dedent(f"""POST / HTTP/1.1 REQUEST_BODY_TEST = textwrap.dedent(f"""POST / HTTP/1.1
Host: localhost Host: localhost
X-TeSt: NotTheSecret X-TeSt: NotTheSecret
Content-Length: {len(secret.decode())} Content-Length: {len(secret.decode())}
{secret.decode()}""").replace("\n", "\r\n") {secret.decode()}""").replace("\n ", "\n").replace("\n", "\r\n")
HTTP_REQUEST_STREAM_TEST = textwrap.dedent(f""" HTTP_REQUEST_STREAM_TEST = textwrap.dedent(f"""
from firegex.nfproxy.models import HttpRequest from firegex.nfproxy.models import HttpRequest
@ -624,14 +628,14 @@ if __name__ == "__main__":
X-TeSt: {secret.decode()} X-TeSt: {secret.decode()}
Content-Length: 15 Content-Length: 15
A Friendly Body""").replace("\n", "\r\n") A Friendly Body""").replace("\n ", "\n").replace("\n", "\r\n")
RESPONSE_BODY_TEST = textwrap.dedent(f"""HTTP/1.1 200 OK RESPONSE_BODY_TEST = textwrap.dedent(f"""HTTP/1.1 200 OK
Host: localhost Host: localhost
X-TeSt: NotTheSecret X-TeSt: NotTheSecret
Content-Length: {len(secret.decode())} Content-Length: {len(secret.decode())}
{secret.decode()}""").replace("\n", "\r\n") {secret.decode()}""").replace("\n ", "\n").replace("\n", "\r\n")
HTTP_RESPONSE_STREAM_TEST = textwrap.dedent(f""" HTTP_RESPONSE_STREAM_TEST = textwrap.dedent(f"""
from firegex.nfproxy.models import HttpResponse from firegex.nfproxy.models import HttpResponse
@ -856,9 +860,9 @@ if __name__ == "__main__":
puts("Test Failed: Coulnd't rename service ✗", color=colors.red) puts("Test Failed: Coulnd't rename service ✗", color=colors.red)
exit_test(1) exit_test(1)
# Change settings # Change settings, including proto (exercises the proto -> l4_proto conversion path)
if firegex.nfproxy_settings_service( if firegex.nfproxy_settings_service(
service_id, 1338, "::dead:beef" if args.ipv6 else "123.123.123.123", True service_id, 1338, "::dead:beef" if args.ipv6 else "123.123.123.123", True, proto="tcp"
): ):
srv_updated = firegex.nfproxy_get_service(service_id) srv_updated = firegex.nfproxy_get_service(service_id)
if ( if (
@ -866,6 +870,7 @@ if __name__ == "__main__":
and ("::dead:beef" if args.ipv6 else "123.123.123.123") and ("::dead:beef" if args.ipv6 else "123.123.123.123")
in srv_updated["ip_int"] in srv_updated["ip_int"]
and srv_updated["fail_open"] and srv_updated["fail_open"]
and srv_updated["proto"] == "tcp"
): ):
puts("Sucessfully changed service settings ✔", color=colors.green) puts("Sucessfully changed service settings ✔", color=colors.green)
else: else:
@ -878,4 +883,126 @@ if __name__ == "__main__":
puts("Test Failed: Coulnd't change service settings ✗", color=colors.red) puts("Test Failed: Coulnd't change service settings ✗", color=colors.red)
exit_test(1) exit_test(1)
if args.tls:
# Testing a target_type='tls' pyfilter service: the backend must also speak TLS
# since nginx's loopback clear_port leg always re-encrypts to the real destination.
tls_service_name = f"{args.service_name} TLS"
tls_port = args.port + 10
for ele in firegex.nfproxy_get_services():
if ele["name"] == tls_service_name or ele["port"] == tls_port:
firegex.nfproxy_delete_service(ele["service_id"])
for s in firegex.tls_get_streams():
if s["port"] == tls_port:
firegex.tls_stop_stream(s["id"])
firegex.tls_delete_stream(s["id"])
backend_cert, backend_key = generate_self_signed_cert_key("127.0.0.1")
tls_server = TcpServer(tls_port, ipv6=args.ipv6, tls_cert=backend_cert, tls_key=backend_key)
tls_server.start()
time.sleep(0.5)
stream_cert, stream_key = generate_self_signed_cert_key("localhost")
tls_stream_id = firegex.tls_add_stream(
tls_service_name + "_Stream", "::1" if args.ipv6 else "127.0.0.1", tls_port, stream_cert, stream_key
)
if tls_stream_id:
puts(f"Sucessfully created TLS stream {tls_stream_id} ✔", color=colors.green)
else:
puts("Test Failed: Couldn't create TLS stream ✗", color=colors.red)
tls_server.stop()
exit_test(1)
if firegex.tls_start_stream(tls_stream_id):
puts("Sucessfully started TLS stream ✔", color=colors.green)
else:
puts("Test Failed: Couldn't start TLS stream ✗", color=colors.red)
tls_server.stop()
firegex.tls_delete_stream(tls_stream_id)
exit_test(1)
tls_ssl_port = firegex.tls_get_stream(tls_stream_id)["ssl_port"]
tls_srv_id = firegex.nfproxy_add_service(
tls_service_name, tls_port, "tcp", "::1" if args.ipv6 else "127.0.0.1",
target_type="tls", tls_stream_id=tls_stream_id
)
if tls_srv_id:
puts(f"Sucessfully created TLS service {tls_srv_id} ✔", color=colors.green)
else:
puts("Test Failed: Couldn't create TLS service ✗", color=colors.red)
tls_server.stop()
firegex.tls_delete_stream(tls_stream_id)
exit_test(1)
if firegex.nfproxy_start_service(tls_srv_id):
puts("Sucessfully started TLS service ✔", color=colors.green)
else:
puts("Test Failed: Couldn't start TLS service ✗", color=colors.red)
firegex.nfproxy_delete_service(tls_srv_id)
tls_server.stop()
firegex.tls_delete_stream(tls_stream_id)
exit_test(1)
tls_secret = bytes(secrets.token_hex(16).encode())
if firegex.nfproxy_set_code(tls_srv_id, get_vedict_test(tls_secret.decode(), "REJECT")):
puts(f"Sucessfully added TLS filter for {str(tls_secret)} ✔", color=colors.green)
else:
puts("Test Failed: Couldn't add TLS filter ✗", color=colors.red)
exit_test(1)
time.sleep(0.5)
blocked_response = tls_connect_send_recv(
tls_ssl_port, args.ipv6, secrets.token_bytes(40) + tls_secret + secrets.token_bytes(40)
)
if not blocked_response:
puts("The malicious request over TLS was successfully blocked ✔", color=colors.green)
else:
puts("Test Failed: The malicious request over TLS wasn't blocked ✗", color=colors.red)
exit_test(1)
clean_data = secrets.token_bytes(64)
if tls_connect_send_recv(tls_ssl_port, args.ipv6, clean_data) == clean_data:
puts("Clean traffic over TLS passed through correctly ✔", color=colors.green)
else:
puts("Test Failed: Clean traffic over TLS wasn't echoed back correctly ✗", color=colors.red)
exit_test(1)
# Cascade: stopping the TLS stream should stop the dependent service
if firegex.tls_stop_stream(tls_stream_id):
time.sleep(1)
if firegex.nfproxy_get_service(tls_srv_id)["status"] == "stop":
puts("TLS stream stop correctly cascaded to stop the dependent service ✔", color=colors.green)
else:
puts("Test Failed: Dependent service wasn't cascade-stopped ✗", color=colors.red)
exit_test(1)
else:
puts("Test Failed: Couldn't stop the TLS stream ✗", color=colors.red)
exit_test(1)
# Cascade: starting the dependent service should reactivate the TLS stream
if firegex.nfproxy_start_service(tls_srv_id):
time.sleep(1)
if firegex.tls_get_stream(tls_stream_id)["status"] == "active":
puts("Starting the service correctly cascaded to reactivate the TLS stream ✔", color=colors.green)
else:
puts("Test Failed: TLS stream wasn't cascade-reactivated ✗", color=colors.red)
exit_test(1)
else:
puts("Test Failed: Couldn't start the TLS service ✗", color=colors.red)
exit_test(1)
# Deleting a still-referenced TLS stream must be rejected
if not firegex.tls_delete_stream(tls_stream_id):
puts("Correctly rejected deletion of a TLS stream still in use ✔", color=colors.green)
else:
puts("Test Failed: Deleting an in-use TLS stream should have failed ✗", color=colors.red)
exit_test(1)
firegex.nfproxy_delete_service(tls_srv_id)
tls_server.stop()
firegex.tls_stop_stream(tls_stream_id)
firegex.tls_delete_stream(tls_stream_id)
puts("Sucessfully cleaned up TLS test service ✔", color=colors.green)
exit_test(0) exit_test(0)

View File

@ -3,6 +3,7 @@ from utils.colors import colors, puts, sep
from utils.firegexapi import FiregexAPI from utils.firegexapi import FiregexAPI
from utils.tcpserver import TcpServer from utils.tcpserver import TcpServer
from utils.udpserver import UdpServer from utils.udpserver import UdpServer
from utils.tls_helpers import generate_self_signed_cert_key, tls_connect_send_recv
import argparse import argparse
import secrets import secrets
import base64 import base64
@ -49,14 +50,25 @@ if __name__ == "__main__":
help="Select the protocol", help="Select the protocol",
default="tcp", default="tcp",
) )
parser.add_argument(
"--tls", "-t", action="store_true", help="Test TLS Decrypt", default=False
)
args = parser.parse_args() args = parser.parse_args()
if args.tls and args.proto != "tcp":
puts("TLS is only supported with TCP protocol.", color=colors.red)
exit(1)
sep() sep()
puts("Testing will start on ", color=colors.cyan, end="") puts("Testing will start on ", color=colors.cyan, end="")
puts(f"{args.address}", color=colors.yellow) puts(f"{args.address}", color=colors.yellow)
firegex = FiregexAPI(args.address) firegex = FiregexAPI(args.address)
status = firegex.status()
if status.get("status") == "init":
firegex.set_password(args.password)
# Login # Login
if firegex.login(args.password): if firegex.login(args.password):
puts("Sucessfully logged in ✔", color=colors.green) puts("Sucessfully logged in ✔", color=colors.green)
@ -64,11 +76,17 @@ if __name__ == "__main__":
puts("Test Failed: Unknown response or wrong passowrd ✗", color=colors.red) puts("Test Failed: Unknown response or wrong passowrd ✗", color=colors.red)
exit(1) exit(1)
# Create server # Create server. When testing TLS, the backend itself must also speak TLS: nginx's
# loopback clear_port leg always re-encrypts before forwarding to the real destination
# (this mirrors a CTF challenge service that natively speaks TLS).
backend_cert, backend_key = generate_self_signed_cert_key("127.0.0.1") if args.tls else (None, None)
server = (TcpServer if args.proto == "tcp" else UdpServer)( server = (TcpServer if args.proto == "tcp" else UdpServer)(
args.port, ipv6=args.ipv6 args.port, ipv6=args.ipv6, **({"tls_cert": backend_cert, "tls_key": backend_key} if args.tls else {})
) )
tls_stream_id = None
tls_ssl_port = None
service_id = None
def exit_test(code): def exit_test(code):
if service_id: if service_id:
server.stop() server.stop()
@ -76,7 +94,14 @@ if __name__ == "__main__":
puts("Sucessfully deleted service ✔", color=colors.green) puts("Sucessfully deleted service ✔", color=colors.green)
else: else:
puts("Test Failed: Coulnd't delete serivce ✗", color=colors.red) puts("Test Failed: Coulnd't delete serivce ✗", color=colors.red)
exit_test(1) exit(1)
if tls_stream_id:
firegex.tls_stop_stream(tls_stream_id)
if firegex.tls_delete_stream(tls_stream_id):
puts("Sucessfully deleted TLS stream ✔", color=colors.green)
else:
puts("Test Failed: Couldn't delete TLS stream ✗", color=colors.red)
exit(1)
exit(code) exit(code)
srvs = firegex.nfregex_get_services() srvs = firegex.nfregex_get_services()
@ -84,8 +109,33 @@ if __name__ == "__main__":
if ele["name"] == args.service_name: if ele["name"] == args.service_name:
firegex.nfregex_delete_service(ele["service_id"]) firegex.nfregex_delete_service(ele["service_id"])
if args.tls:
streams = firegex.tls_get_streams()
for s in streams:
if s["port"] == args.port:
firegex.tls_stop_stream(s["id"])
firegex.tls_delete_stream(s["id"])
cert, key = generate_self_signed_cert_key("localhost")
tls_stream_id = firegex.tls_add_stream("TLS Stream", "::1" if args.ipv6 else "127.0.0.1", args.port, cert, key)
if tls_stream_id:
puts(f"Sucessfully created TLS stream {tls_stream_id} ✔", color=colors.green)
else:
puts("Test Failed: Failed to create TLS stream ✗", color=colors.red)
exit_test(1)
if firegex.tls_start_stream(tls_stream_id):
puts("Sucessfully started TLS stream ✔", color=colors.green)
else:
puts("Test Failed: Failed to start TLS stream ✗", color=colors.red)
exit_test(1)
tls_ssl_port = firegex.tls_get_stream(tls_stream_id)["ssl_port"]
service_id = firegex.nfregex_add_service( service_id = firegex.nfregex_add_service(
args.service_name, args.port, args.proto, "::1" if args.ipv6 else "127.0.0.1" args.service_name, args.port, args.proto, "::1" if args.ipv6 else "127.0.0.1",
target_type="tls" if args.tls else "flow",
tls_stream_id=tls_stream_id if args.tls else None
) )
if service_id: if service_id:
puts(f"Sucessfully created service {service_id} ✔", color=colors.green) puts(f"Sucessfully created service {service_id} ✔", color=colors.green)
@ -101,8 +151,18 @@ if __name__ == "__main__":
server.start() server.start()
time.sleep(0.5) time.sleep(0.5)
def send_check(data):
"""Sends data and checks it was echoed back unmodified. When testing TLS,
routes through a real TLS handshake against the stream's public ssl_port
(so the check actually exercises decrypt -> filter -> re-encrypt), instead
of talking to the backend directly."""
if args.tls:
return tls_connect_send_recv(tls_ssl_port, args.ipv6, data) == data
return server.sendCheckData(data)
try: try:
if server.sendCheckData(secrets.token_bytes(432)): if send_check(secrets.token_bytes(432)):
puts("Successfully tested first proxy with no regex ✔", color=colors.green) puts("Successfully tested first proxy with no regex ✔", color=colors.green)
else: else:
puts("Test Failed: Data was corrupted ", color=colors.red) puts("Test Failed: Data was corrupted ", color=colors.red)
@ -121,6 +181,8 @@ if __name__ == "__main__":
puts(f"Test Failed: Couldn't add the regex {str(secret)} ✗", color=colors.red) puts(f"Test Failed: Couldn't add the regex {str(secret)} ✗", color=colors.red)
exit_test(1) exit_test(1)
# Check if regex is present in the service # Check if regex is present in the service
n_blocked = 0 n_blocked = 0
@ -136,7 +198,7 @@ if __name__ == "__main__":
if r["regex"] == secret: if r["regex"] == secret:
# Test the regex # Test the regex
s = regex.upper() if upper else regex s = regex.upper() if upper else regex
if not server.sendCheckData( if not send_check(
secrets.token_bytes(40) + s + secrets.token_bytes(40) secrets.token_bytes(40) + s + secrets.token_bytes(40)
): ):
puts( puts(
@ -191,7 +253,7 @@ if __name__ == "__main__":
puts("Test Failed: The regex wasn't found ✗", color=colors.red) puts("Test Failed: The regex wasn't found ✗", color=colors.red)
exit_test(1) exit_test(1)
else: else:
if server.sendCheckData( if send_check(
secrets.token_bytes(40) secrets.token_bytes(40)
+ base64.b64decode(regex) + base64.b64decode(regex)
+ secrets.token_bytes(40) + secrets.token_bytes(40)
@ -342,32 +404,74 @@ if __name__ == "__main__":
puts("Test Failed: Coulnd't rename service ✗", color=colors.red) puts("Test Failed: Coulnd't rename service ✗", color=colors.red)
exit_test(1) exit_test(1)
# Change settings # Change settings (skipped in --tls mode: the service's own port/ip/proto no longer
opposite_proto = "udp" if args.proto == "tcp" else "tcp" # drive traffic interception once linked to a TLS stream, see resolve_target())
if firegex.nfregex_settings_service( if not args.tls:
service_id, opposite_proto = "udp" if args.proto == "tcp" else "tcp"
1338, if firegex.nfregex_settings_service(
opposite_proto, service_id,
"::dead:beef" if args.ipv6 else "123.123.123.123", 1338,
True, "::dead:beef" if args.ipv6 else "123.123.123.123",
): True,
srv_updated = firegex.nfregex_get_service(service_id) proto=opposite_proto,
if (
srv_updated["port"] == 1338
and srv_updated["proto"] == opposite_proto
and ("::dead:beef" if args.ipv6 else "123.123.123.123")
in srv_updated["ip_int"]
and srv_updated["fail_open"]
): ):
puts("Sucessfully changed service settings ✔", color=colors.green) srv_updated = firegex.nfregex_get_service(service_id)
if (
srv_updated["port"] == 1338
and srv_updated["proto"] == opposite_proto
and ("::dead:beef" if args.ipv6 else "123.123.123.123")
in srv_updated["ip_int"]
and srv_updated["fail_open"]
):
puts("Sucessfully changed service settings ✔", color=colors.green)
else:
puts(
"Test Failed: Service settings weren't updated correctly ✗",
color=colors.red,
)
exit_test(1)
else: else:
puts( puts("Test Failed: Coulnd't change service settings ✗", color=colors.red)
"Test Failed: Service settings weren't updated correctly ✗", exit_test(1)
color=colors.red,
) if args.tls:
# Stopping the TLS stream should cascade-stop the dependent service
if firegex.tls_stop_stream(tls_stream_id):
time.sleep(1)
srv_after_stop = firegex.nfregex_get_service(service_id)
if srv_after_stop["status"] == "stop":
puts("TLS stream stop correctly cascaded to stop the dependent service ✔", color=colors.green)
else:
puts("Test Failed: Dependent service wasn't cascade-stopped ✗", color=colors.red)
exit_test(1)
else:
puts("Test Failed: Coulnd't stop the TLS stream ✗", color=colors.red)
exit_test(1)
# Starting the dependent service should cascade-reactivate the TLS stream
if firegex.nfregex_start_service(service_id):
time.sleep(1)
stream_after_start = firegex.tls_get_stream(tls_stream_id)
if stream_after_start["status"] == "active":
puts("Starting the service correctly cascaded to reactivate the TLS stream ✔", color=colors.green)
else:
puts("Test Failed: TLS stream wasn't cascade-reactivated ✗", color=colors.red)
exit_test(1)
else:
puts("Test Failed: Coulnd't start the service ✗", color=colors.red)
exit_test(1)
if send_check(secrets.token_bytes(64)):
puts("Traffic flows correctly after the cascade reactivation ✔", color=colors.green)
else:
puts("Test Failed: Traffic didn't flow correctly after the cascade reactivation ✗", color=colors.red)
exit_test(1)
# Deleting a still-referenced TLS stream must be rejected
if not firegex.tls_delete_stream(tls_stream_id):
puts("Correctly rejected deletion of a TLS stream still in use ✔", color=colors.green)
else:
puts("Test Failed: Deleting an in-use TLS stream should have failed ✗", color=colors.red)
exit_test(1) exit_test(1)
else:
puts("Test Failed: Coulnd't change service settings ✗", color=colors.red)
exit_test(1)
exit_test(0) exit_test(0)

View File

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

View File

@ -68,6 +68,7 @@ if __name__ == "__main__":
args.port + 1, ipv6=args.ipv6, proxy_port=args.port args.port + 1, ipv6=args.ipv6, proxy_port=args.port
) )
service_id = None
def exit_test(code): def exit_test(code):
if service_id: if service_id:
server.stop() server.stop()
@ -75,7 +76,7 @@ if __name__ == "__main__":
puts("Sucessfully deleted service ✔", color=colors.green) puts("Sucessfully deleted service ✔", color=colors.green)
else: else:
puts("Test Failed: Coulnd't delete serivce ✗", color=colors.red) puts("Test Failed: Coulnd't delete serivce ✗", color=colors.red)
exit_test(1) exit(1)
exit(code) exit(code)
srvs = firegex.ph_get_services() srvs = firegex.ph_get_services()

View File

@ -2,3 +2,6 @@ iperf3
requests requests
matplotlib matplotlib
numpy numpy
cryptography
psutil
pydantic

View File

@ -3,21 +3,30 @@
cd "$(dirname "$0")" cd "$(dirname "$0")"
TMP=$1 TMP=$1
PASSWORD=${TMP:=testpassword} if [[ "$TMP" == "--set-pass" ]]; then
PASSWORD="testpassword"
else
PASSWORD=${TMP:-testpassword}
fi
ERROR=0 ERROR=0
pip3 install -r requirements.txt pip3 install -r requirements.txt
echo "Running Nginx config unit test"
python3 nginx_test.py || ERROR=1
until curl --output /dev/null --silent --fail http://127.0.0.1:4444/api/status; do until curl --output /dev/null --silent --fail http://127.0.0.1:4444/api/status; do
printf '.' printf '.'
sleep 1 sleep 1
done done
if [[ "$TMP" == "--set-pass" ]]; then
curl -X POST http://127.0.0.1:4444/api/set-password -H "Content-Type: application/json" -d "{\"password\": \"$PASSWORD\"}" -s
echo ""
fi
echo "Running standard API test" echo "Running standard API test"
python3 api_test.py -p $PASSWORD || ERROR=1 python3 api_test.py -p $PASSWORD || ERROR=1
echo "Running TLS Decrypt stream CRUD/edit/cascade test"
python3 tls_test.py -p $PASSWORD || ERROR=1
echo "Running TLS Decrypt stream CRUD/edit/cascade test ipv6"
python3 tls_test.py -p $PASSWORD -6 || ERROR=1
echo "Running Netfilter Regex TCP ipv4" echo "Running Netfilter Regex TCP ipv4"
python3 nfregex_test.py -p $PASSWORD -m tcp || ERROR=1 python3 nfregex_test.py -p $PASSWORD -m tcp || ERROR=1
echo "Running Netfilter Regex TCP ipv6" echo "Running Netfilter Regex TCP ipv6"
@ -38,6 +47,15 @@ echo "Running Netfilter Proxy ipv4"
python3 nfproxy_test.py -p $PASSWORD || ERROR=1 python3 nfproxy_test.py -p $PASSWORD || ERROR=1
echo "Running Netfilter Proxy ipv6" echo "Running Netfilter Proxy ipv6"
python3 nfproxy_test.py -p $PASSWORD -6 || ERROR=1 python3 nfproxy_test.py -p $PASSWORD -6 || ERROR=1
echo "Running Netfilter Proxy ipv4 TLS"
python3 nfproxy_test.py -p $PASSWORD --tls || ERROR=1
echo "Running Netfilter Proxy ipv6 TLS"
python3 nfproxy_test.py -p $PASSWORD -6 --tls || ERROR=1
echo "Running Netfilter Regex TCP ipv4 TLS"
python3 nfregex_test.py -p $PASSWORD -m tcp --tls || ERROR=1
echo "Running Netfilter Regex TCP ipv6 TLS"
python3 nfregex_test.py -p $PASSWORD -m tcp -6 --tls || ERROR=1
exit $ERROR exit $ERROR

186
tests/tls_test.py Normal file
View File

@ -0,0 +1,186 @@
#!/usr/bin/env python3
from utils.colors import colors, puts, sep
from utils.firegexapi import FiregexAPI
from utils.tcpserver import TcpServer
from utils.tls_helpers import generate_self_signed_cert_key, tls_connect_send_recv
import argparse
import secrets
import time
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"--address", "-a", type=str, required=False,
help="Address of firegex backend", default="http://127.0.0.1:4444/",
)
parser.add_argument(
"--password", "-p", type=str, required=True, help="Firegex password"
)
parser.add_argument(
"--ipv6", "-6", action="store_true", help="Test Ipv6", default=False
)
parser.add_argument(
"--port", "-P", type=int, required=False,
help="Base port of the test stream", default=15443,
)
args = parser.parse_args()
sep()
puts("Testing TLS stream CRUD/edit/cascade will start on ", color=colors.cyan, end="")
puts(f"{args.address}", color=colors.yellow)
firegex = FiregexAPI(args.address)
if firegex.login(args.password):
puts("Sucessfully logged in ✔", color=colors.green)
else:
puts("Test Failed: Unknown response or wrong password ✗", color=colors.red)
exit(1)
def exit_test(code):
exit(code)
host = "::1" if args.ipv6 else "127.0.0.1"
port_a = args.port
port_b = args.port + 1
service_name = "TLS CRUD Test Service"
# Cleanup leftovers from a previous failed run
for ele in firegex.nfregex_get_services():
if ele["name"] == service_name:
firegex.nfregex_delete_service(ele["service_id"])
for s in firegex.tls_get_streams():
if s["port"] in (port_a, port_b):
firegex.tls_stop_stream(s["id"])
firegex.tls_delete_stream(s["id"])
# 1. Create
cert_a, key_a = generate_self_signed_cert_key("localhost")
stream_id = firegex.tls_add_stream("TLS CRUD Stream", host, port_a, cert_a, key_a)
if stream_id:
puts(f"Sucessfully created TLS stream {stream_id} ✔", color=colors.green)
else:
puts("Test Failed: Couldn't create TLS stream ✗", color=colors.red)
exit_test(1)
# 2. List / Get
stream = firegex.tls_get_stream(stream_id)
if stream and stream["ip_int"] and stream["port"] == port_a:
puts("Sucessfully fetched the stream via GET /tls/streams ✔", color=colors.green)
else:
puts("Test Failed: Stream not found in listing ✗", color=colors.red)
firegex.tls_delete_stream(stream_id)
exit_test(1)
# 3. Start + a real TLS handshake against the public ssl_port. The backend behind the
# stream must itself speak TLS since nginx's clear_port leg always re-encrypts to it.
backend_cert, backend_key = generate_self_signed_cert_key("127.0.0.1")
backend = TcpServer(port_a, ipv6=args.ipv6, tls_cert=backend_cert, tls_key=backend_key)
backend.start()
time.sleep(0.5)
if firegex.tls_start_stream(stream_id):
puts("Sucessfully started TLS stream ✔", color=colors.green)
else:
puts("Test Failed: Couldn't start TLS stream ✗", color=colors.red)
backend.stop()
firegex.tls_delete_stream(stream_id)
exit_test(1)
ssl_port = firegex.tls_get_stream(stream_id)["ssl_port"]
data = secrets.token_bytes(64)
if tls_connect_send_recv(ssl_port, args.ipv6, data) == data:
puts("Successfully performed a real TLS handshake and got the decrypted echo back ✔", color=colors.green)
else:
puts("Test Failed: TLS handshake / decrypt round-trip failed ✗", color=colors.red)
backend.stop()
firegex.tls_stop_stream(stream_id)
firegex.tls_delete_stream(stream_id)
exit_test(1)
# 4. Create a dependent nfregex service linked to the stream
service_id = firegex.nfregex_add_service(
service_name, port_a, "tcp", host, target_type="tls", tls_stream_id=stream_id
)
if service_id:
puts(f"Sucessfully created dependent nfregex service {service_id} ✔", color=colors.green)
else:
puts("Test Failed: Couldn't create dependent service ✗", color=colors.red)
backend.stop()
firegex.tls_stop_stream(stream_id)
firegex.tls_delete_stream(stream_id)
exit_test(1)
# 5. The (fixed) delete-guard must now correctly reject deletion while in use
if not firegex.tls_delete_stream(stream_id):
puts("Correctly rejected deletion of a TLS stream still in use ✔", color=colors.green)
else:
puts("Test Failed: Deleting an in-use TLS stream should have failed ✗", color=colors.red)
exit_test(1)
# 6. Edit: move the stream to a new ip/port/cert and confirm the dependent service follows
backend.stop()
backend = TcpServer(port_b, ipv6=args.ipv6, tls_cert=backend_cert, tls_key=backend_key)
backend.start()
time.sleep(0.5)
cert_b, key_b = generate_self_signed_cert_key("localhost")
if firegex.tls_edit_stream(stream_id, ip_int=host, port=port_b, cert=cert_b, key=key_b):
puts("Sucessfully edited the TLS stream's address/cert ✔", color=colors.green)
else:
puts("Test Failed: Couldn't edit the TLS stream ✗", color=colors.red)
backend.stop()
exit_test(1)
time.sleep(1)
updated_stream = firegex.tls_get_stream(stream_id)
updated_service = firegex.nfregex_get_service(service_id)
if updated_stream["port"] == port_b and updated_service["port"] == port_b:
puts("The dependent service's mirrored ip/port followed the stream edit ✔", color=colors.green)
else:
puts("Test Failed: The dependent service wasn't kept in sync with the stream edit ✗", color=colors.red)
exit_test(1)
new_ssl_port = updated_stream["ssl_port"]
data2 = secrets.token_bytes(64)
if tls_connect_send_recv(new_ssl_port, args.ipv6, data2) == data2:
puts("TLS decryption still works correctly at the new address/port after the edit ✔", color=colors.green)
else:
puts("Test Failed: TLS decryption broke after editing the stream's address ✗", color=colors.red)
exit_test(1)
# 7. Cascade: stopping the stream should stop the dependent service
if firegex.tls_stop_stream(stream_id):
time.sleep(1)
if firegex.nfregex_get_service(service_id)["status"] == "stop":
puts("Stopping the stream correctly cascaded to stop the dependent service ✔", color=colors.green)
else:
puts("Test Failed: Dependent service wasn't cascade-stopped ✗", color=colors.red)
exit_test(1)
else:
puts("Test Failed: Couldn't stop the TLS stream ✗", color=colors.red)
exit_test(1)
# 8. Cascade: starting the dependent service should reactivate the stream
if firegex.nfregex_start_service(service_id):
time.sleep(1)
if firegex.tls_get_stream(stream_id)["status"] == "active":
puts("Starting the service correctly cascaded to reactivate the TLS stream ✔", color=colors.green)
else:
puts("Test Failed: TLS stream wasn't cascade-reactivated ✗", color=colors.red)
exit_test(1)
else:
puts("Test Failed: Couldn't start the dependent service ✗", color=colors.red)
exit_test(1)
# Cleanup
firegex.nfregex_delete_service(service_id)
backend.stop()
firegex.tls_stop_stream(stream_id)
if firegex.tls_delete_stream(stream_id):
puts("Sucessfully cleaned up TLS CRUD test ✔", color=colors.green)
else:
puts("Test Failed: Couldn't delete the TLS stream during cleanup ✗", color=colors.red)
exit_test(1)
exit_test(0)

View File

@ -79,6 +79,14 @@ class FiregexAPI:
def reset(self, delete: bool): def reset(self, delete: bool):
self.s.post(f"{self.address}api/reset", json={"delete":delete}) self.s.post(f"{self.address}api/reset", json={"delete":delete})
def export_backup(self):
req = self.s.get(f"{self.address}api/export")
return req.json()
def import_backup(self, backup: dict):
req = self.s.post(f"{self.address}api/import", json=backup)
return verify(req)
def nfregex_get_services(self): def nfregex_get_services(self):
req = self.s.get(f"{self.address}api/nfregex/services") req = self.s.get(f"{self.address}api/nfregex/services")
return req.json() return req.json()
@ -103,10 +111,6 @@ class FiregexAPI:
req = self.s.put(f"{self.address}api/nfregex/services/{service_id}/rename" , json={"name":newname}) req = self.s.put(f"{self.address}api/nfregex/services/{service_id}/rename" , json={"name":newname})
return verify(req) return verify(req)
def nfregex_settings_service(self,service_id: str, port: int, proto: str, ip_int: str, fail_open: bool):
req = self.s.put(f"{self.address}api/nfregex/services/{service_id}/settings" , json={"port":port, "proto":proto, "ip_int":ip_int, "fail_open":fail_open})
return verify(req)
def nfregex_get_service_regexes(self,service_id: str): def nfregex_get_service_regexes(self,service_id: str):
req = self.s.get(f"{self.address}api/nfregex/services/{service_id}/regexes") req = self.s.get(f"{self.address}api/nfregex/services/{service_id}/regexes")
data = req.json() data = req.json()
@ -131,18 +135,25 @@ class FiregexAPI:
req = self.s.post(f"{self.address}api/nfregex/regexes/{regex_id}/disable") req = self.s.post(f"{self.address}api/nfregex/regexes/{regex_id}/disable")
return verify(req) return verify(req)
def nfregex_add_regex(self, service_id: str, regex: str, mode: str, active: bool, is_case_sensitive: bool): def nfregex_add_regex(self, service_id: str, regex: str, mode: str = "B", active: bool = True, is_case_sensitive: bool = True):
if isinstance(regex, str): if isinstance(regex, str):
regex = regex.encode() regex = regex.encode()
req = self.s.post(f"{self.address}api/nfregex/regexes", req = self.s.post(f"{self.address}api/nfregex/regexes",
json={"service_id": service_id, "regex": base64.b64encode(regex).decode(), "mode": mode, "active": active, "is_case_sensitive": is_case_sensitive}) json={"service_id": service_id, "regex": base64.b64encode(regex).decode(), "mode": mode, "active": active, "is_case_sensitive": is_case_sensitive})
return verify(req) return verify(req)
def nfregex_add_service(self, name: str, port: int, proto: str, ip_int: str, fail_open: bool = False): def nfregex_add_service(self, name: str, port: int, proto: str, ip_int: str, fail_open: bool = False, target_type: str = "flow", tls_stream_id: str | None = None):
req = self.s.post(f"{self.address}api/nfregex/services" , payload = {"name": name, "port": port, "proto": proto, "ip_int": ip_int, "fail_open": fail_open, "target_type": target_type, "tls_stream_id": tls_stream_id}
json={"name":name,"port":port, "proto": proto, "ip_int": ip_int, "fail_open": fail_open}) req = self.s.post(f"{self.address}api/nfregex/services", json=payload)
return req.json()["service_id"] if verify(req) else False return req.json()["service_id"] if verify(req) else False
def nfregex_settings_service(self, service_id: str, port: int, ip_int: str, fail_open: bool, proto: str | None = None, target_type: str = "flow", tls_stream_id: str | None = None):
payload = {"port": port, "ip_int": ip_int, "fail_open": fail_open, "target_type": target_type, "tls_stream_id": tls_stream_id}
if proto is not None:
payload["proto"] = proto
req = self.s.put(f"{self.address}api/nfregex/services/{service_id}/settings", json=payload)
return verify(req)
def nfregex_get_metrics(self): def nfregex_get_metrics(self):
req = self.s.get(f"{self.address}api/nfregex/metrics") req = self.s.get(f"{self.address}api/nfregex/metrics")
return req.text return req.text
@ -205,8 +216,11 @@ class FiregexAPI:
req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/rename" , json={"name":newname}) req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/rename" , json={"name":newname})
return verify(req) return verify(req)
def nfproxy_settings_service(self,service_id: str, port: int, ip_int: str, fail_open: bool): def nfproxy_settings_service(self,service_id: str, port: int, ip_int: str, fail_open: bool, proto: str | None = None):
req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/settings" , json={"port":port, "ip_int":ip_int, "fail_open":fail_open}) payload = {"port":port, "ip_int":ip_int, "fail_open":fail_open}
if proto is not None:
payload["proto"] = proto
req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/settings" , json=payload)
return verify(req) return verify(req)
def nfproxy_get_service_pyfilters(self,service_id: str): def nfproxy_get_service_pyfilters(self,service_id: str):
@ -225,9 +239,9 @@ class FiregexAPI:
req = self.s.post(f"{self.address}api/nfproxy/services/{service_id}/pyfilters/{filter_name}/disable") req = self.s.post(f"{self.address}api/nfproxy/services/{service_id}/pyfilters/{filter_name}/disable")
return verify(req) return verify(req)
def nfproxy_add_service(self, name: str, port: int, proto: str, ip_int: str, fail_open: bool = False): def nfproxy_add_service(self, name: str, port: int, proto: str, ip_int: str, fail_open: bool = False, target_type: str = "flow", tls_stream_id: str | None = None):
req = self.s.post(f"{self.address}api/nfproxy/services" , payload = {"name": name, "port": port, "proto": proto, "ip_int": ip_int, "fail_open": fail_open, "target_type": target_type, "tls_stream_id": tls_stream_id}
json={"name":name,"port":port, "proto": proto, "ip_int": ip_int, "fail_open": fail_open}) req = self.s.post(f"{self.address}api/nfproxy/services", json=payload)
return req.json()["service_id"] if verify(req) else False return req.json()["service_id"] if verify(req) else False
def nfproxy_get_code(self, service_id: str): def nfproxy_get_code(self, service_id: str):
@ -237,3 +251,36 @@ class FiregexAPI:
def nfproxy_set_code(self, service_id: str, code: str): def nfproxy_set_code(self, service_id: str, code: str):
req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/code", json={"code":code}) req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/code", json={"code":code})
return verify(req) return verify(req)
#TLS
def tls_get_streams(self):
req = self.s.get(f"{self.address}api/tls/streams")
return req.json()
def tls_add_stream(self, name: str, ip_int: str, port: int, cert: str, key: str):
payload = {"name": name, "ip_int": ip_int, "port": port, "cert": cert, "key": key}
req = self.s.post(f"{self.address}api/tls/streams", json=payload)
return req.json()["stream_id"] if verify(req) else False
def tls_delete_stream(self, stream_id: str):
req = self.s.delete(f"{self.address}api/tls/streams/{stream_id}")
return verify(req)
def tls_start_stream(self, stream_id: str):
req = self.s.post(f"{self.address}api/tls/streams/{stream_id}/start")
return verify(req)
def tls_stop_stream(self, stream_id: str):
req = self.s.post(f"{self.address}api/tls/streams/{stream_id}/stop")
return verify(req)
def tls_edit_stream(self, stream_id: str, name: str | None = None, ip_int: str | None = None, port: int | None = None, cert: str | None = None, key: str | None = None):
payload = {k: v for k, v in {"name": name, "ip_int": ip_int, "port": port, "cert": cert, "key": key}.items() if v is not None}
req = self.s.put(f"{self.address}api/tls/streams/{stream_id}", json=payload)
return verify(req)
def tls_get_stream(self, stream_id: str):
for stream in self.tls_get_streams():
if stream["id"] == stream_id:
return stream
return None

View File

@ -1,18 +1,37 @@
import queue import queue
from multiprocessing import Process, Queue from multiprocessing import Process, Queue
import socket import socket
import ssl
import tempfile
import traceback import traceback
def _start_tcp_server(port, server_queue: Queue, ipv6, verbose): def _start_tcp_server(port, server_queue: Queue, ipv6, verbose, tls_cert=None, tls_key=None):
sock = socket.socket( sock = socket.socket(
socket.AF_INET6 if ipv6 else socket.AF_INET, socket.SOCK_STREAM socket.AF_INET6 if ipv6 else socket.AF_INET, socket.SOCK_STREAM
) )
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("::1" if ipv6 else "127.0.0.1", port)) sock.bind(("::1" if ipv6 else "127.0.0.1", port))
sock.listen(8) sock.listen(8)
tls_context = None
if tls_cert and tls_key:
tls_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
with tempfile.NamedTemporaryFile(mode="w", suffix=".crt", delete=False) as cert_file, \
tempfile.NamedTemporaryFile(mode="w", suffix=".key", delete=False) as key_file:
cert_file.write(tls_cert)
key_file.write(tls_key)
cert_path, key_path = cert_file.name, key_file.name
tls_context.load_cert_chain(cert_path, key_path)
while True: while True:
connection, address = sock.accept() connection, address = sock.accept()
if tls_context:
try:
connection = tls_context.wrap_socket(connection, server_side=True)
except ssl.SSLError:
connection.close()
continue
while True: while True:
try: try:
buf = connection.recv(4096) buf = connection.recv(4096)
@ -40,26 +59,35 @@ def _start_tcp_server(port, server_queue: Queue, ipv6, verbose):
class TcpServer: class TcpServer:
def __init__(self, port, ipv6, proxy_port=None, verbose=False): def __init__(self, port, ipv6, proxy_port=None, verbose=False, tls_cert=None, tls_key=None):
self.proxy_port = proxy_port self.proxy_port = proxy_port
self.ipv6 = ipv6 self.ipv6 = ipv6
self.port = port self.port = port
self.verbose = verbose self.verbose = verbose
self.tls_cert = tls_cert
self.tls_key = tls_key
self._server_data_queue = Queue() self._server_data_queue = Queue()
self._regen_process() self._regen_process()
def _regen_process(self): def _regen_process(self):
self.server = Process( self.server = Process(
target=_start_tcp_server, target=_start_tcp_server,
args=[self.port, self._server_data_queue, self.ipv6, self.verbose], args=[self.port, self._server_data_queue, self.ipv6, self.verbose, self.tls_cert, self.tls_key],
) )
def start(self): def start(self):
self.server.start() self.server.start()
def stop(self): def stop(self):
self.server.terminate() if self.server and self.server.is_alive():
self.server.join() try:
self.server.terminate()
self.server.join()
import time
time.sleep(0.1)
except AttributeError:
pass
self.server = None
self._regen_process() self._regen_process()
def connect_client(self): def connect_client(self):

View File

@ -0,0 +1,69 @@
import datetime
import ipaddress
import socket
import ssl
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
def generate_self_signed_cert_key(common_name: str = "127.0.0.1") -> tuple[str, str]:
"""Generates a self-signed X.509 certificate and RSA private key in 100% pure Python using cryptography."""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=365))
.add_extension(
x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]),
critical=False,
)
.sign(key, hashes.SHA256())
)
key_pem = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
).decode("utf-8")
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
return cert_pem, key_pem
def send_decrypted_http_request(port: int, ipv6: bool, path: str = "/secret-tls-check") -> bytes | None:
"""Sends a sample HTTP request to a target port and returns received response bytes."""
sock = socket.socket(socket.AF_INET6 if ipv6 else socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
try:
sock.connect(("::1" if ipv6 else "127.0.0.1", port))
http_payload = f"GET {path} HTTP/1.1\r\nHost: localhost\r\nUser-Agent: Firegex-TLSTest\r\n\r\n".encode()
sock.sendall(http_payload)
data = sock.recv(4096)
sock.close()
return data
except Exception:
return None
def tls_connect_send_recv(port: int, ipv6: bool, data: bytes, timeout: float = 3.0) -> bytes | None:
"""Performs a real TLS handshake against a stream's public ssl_port (trusting any
self-signed cert), sends raw bytes and returns the raw response, or None if the
connection was refused/reset/timed out (e.g. because a filter blocked it)."""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
host = "::1" if ipv6 else "127.0.0.1"
try:
with socket.create_connection((host, port), timeout=timeout) as raw_sock:
with ctx.wrap_socket(raw_sock) as tls_sock:
tls_sock.sendall(data)
tls_sock.settimeout(timeout)
try:
return tls_sock.recv(65536)
except socket.timeout:
return b""
except (OSError, ssl.SSLError):
return None

View File

@ -49,8 +49,15 @@ class UdpServer:
self.server.start() self.server.start()
def stop(self): def stop(self):
self.server.terminate() if self.server:
self.server.join() try:
self.server.terminate()
self.server.join()
import time
time.sleep(0.1)
except AttributeError:
pass
self.server = None
self._regen_process() self._regen_process()
def connect_client(self): def connect_client(self):