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:
release:
@ -10,17 +10,33 @@ env:
IMAGE_NAME: ${{ github.repository }}
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:
needs: run_tests
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
arch: amd64
run_tests: true
- os: ubuntu-24.04-arm
arch: arm64
run_tests: true
permissions:
contents: read
packages: write
@ -33,14 +49,6 @@ jobs:
id: lowercase
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
id: buildx
uses: docker/setup-buildx-action@master
@ -51,23 +59,23 @@ jobs:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}
- 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" backend/utils/__init__.py;
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 and push Docker image
uses: docker/build-push-action@v5
with:
@ -102,43 +110,43 @@ jobs:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract tag name
id: tag
run: echo TAG_NAME=$(echo $GITHUB_REF | cut -d / -f 3) >> $GITHUB_OUTPUT
- name: Create and push multi-platform manifest
run: |
# Create manifest list for specific tag
docker manifest create ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }} \
--amend ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }}-amd64 \
--amend ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }}-arm64
# Annotate the manifest with architecture info
docker manifest annotate ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }} \
${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }}-amd64 \
--arch amd64 --os linux
docker manifest annotate ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }} \
${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }}-arm64 \
--arch arm64 --os linux
docker manifest push ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }}
# Create manifest list for latest tag
docker manifest create ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest \
--amend ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest-amd64 \
--amend ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest-arm64
# Annotate the latest manifest with architecture info
docker manifest annotate ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest \
${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest-amd64 \
--arch amd64 --os linux
docker manifest annotate ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest \
${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest-arm64 \
--arch arm64 --os linux
docker manifest push ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest
create-rootfs-assets:
@ -208,3 +216,64 @@ jobs:
echo "Assets uploaded successfully!"
env:
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*
**/yarn-debug.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 asyncio
import logging
import base64
from fastapi import FastAPI, HTTPException, Depends, APIRouter
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
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.loader import frontend_deploy, load_routers
from utils.models import ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel
from utils.certs import CERTIFICATES_SCHEMA
from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware
import socketio
@ -19,7 +19,7 @@ from socketio.exceptions import ConnectionRefusedError
import hashlib
# DB init
db = SQLite('db/firegex.db', CERTIFICATES_SCHEMA)
db = SQLite('db/firegex.db')
sysctl = SysctlManager({
"net.ipv4.conf.all.forwarding": True,
"net.ipv6.conf.all.forwarding": True,
@ -219,6 +219,62 @@ async def reset_firegex(form: ResetRequest):
await refresh_frontend()
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)
frontend_deploy(app)

View File

@ -1,3 +1,4 @@
#define DEBUG
#define PY_SSIZE_T_CLEAN
#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!
}
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);
pyfilter_ctx* stream_match;
if (stream_search == sctx.streams_ctx.end()){
@ -129,7 +129,7 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
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){
case PyFilterResponse::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->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
static void on_client_data(Stream& stream, PyProxyQueue* pyq) {
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
static void on_server_data(Stream& stream, PyProxyQueue* pyq) {
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

View File

@ -105,7 +105,8 @@ struct pyfilter_ctx {
py_filter_response handle_packet(
NfQueue::PktRequest<PyProxyQueue>* pkt,
const string& data
const string& data,
bool is_client
){
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, "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, "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_tcp", PyBool_FromLong(pkt->l4_proto == NfQueue::L4Proto::TCP));
@ -243,6 +244,7 @@ typedef map<stream_id, pyfilter_ctx*> matching_map;
struct stream_ctx {
matching_map streams_ctx;
NfQueue::tcp_ack_map tcp_ack_ctx;
void clean_stream_by_id(stream_id sid){

View File

@ -6,7 +6,6 @@ import traceback
from fastapi import HTTPException
import time
from utils import run_func
from utils import DEBUG
from utils import nicenessify
nft = FiregexTables()
@ -65,8 +64,6 @@ class FiregexInterceptor:
while True:
try:
out_data = (await self.process.stdout.read(1024*10)).decode(errors="ignore")
if DEBUG:
print(out_data, end="")
except asyncio.exceptions.LimitOverrunError:
self.outstrem_buffer = ""
continue
@ -137,7 +134,6 @@ class FiregexInterceptor:
raise HTTPException(status_code=500, detail="Can't read from nfq client") from e
if line.startswith("BLOCKED "):
filter_name = line.split()[1]
print("BLOCKED", filter_name)
async with self.filter_map_lock:
if filter_name in self.filter_map:
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.nftables import FiregexTables, FiregexFilter
from modules.nfproxy.models import Service, PyFilter
from modules.nfproxy.nginx import sync_nginx_state
from utils.sqlite import SQLite
from utils.sqlite import SQLite
from utils import run_func
from utils.certs import CertsDB, ip_parse as certs_ip_parse
class STATUS:
STOP = "stop"
@ -51,16 +50,17 @@ class ServiceManager:
def __update_status_db(self, status):
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:
if to == STATUS.STOP:
await self.stop()
await self.stop(persist=persist)
if to == STATUS.ACTIVE:
await self.restart()
def _set_status(self,status):
def _set_status(self,status,persist:bool=True):
self.status = status
self.__update_status_db(status)
if persist:
self.__update_status_db(status)
def read_outstrem_buffer(self):
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)
await self._update_filters_from_db()
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)
if self.interceptor:
await self.interceptor.stop()
self.interceptor = None
self._set_status(STATUS.STOP)
if self.srv.tls_enabled:
sync_nginx_state()
self._set_status(STATUS.STOP,persist=persist)
async def restart(self):
await self.stop()
@ -94,17 +90,7 @@ class ServiceManager:
async with self.lock:
await self._update_filters_from_db()
async def update_tls_config(self):
async with self.lock:
srv_dict = self.db.query("SELECT * FROM services WHERE service_id = ?;", self.srv.id)[0]
if srv_dict.get("tls_enabled"):
cert, key = CertsDB().get_cert_and_key(srv_dict["ip_int"], srv_dict["port"])
srv_dict["tls_cert"] = cert
srv_dict["tls_key"] = key
self.srv = Service.from_dict(srv_dict)
if self.status == STATUS.ACTIVE:
await self.restart()
sync_nginx_state()
class FirewallManager:
def __init__(self, db:SQLite, outstream_func=None, exception_func=None):
@ -116,13 +102,16 @@ class FirewallManager:
async def close(self):
for key in list(self.service_table.keys()):
await self.remove(key)
sync_nginx_state()
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 with self.lock:
async def remove(self,srv_id,persist:bool=True):
async with self.lock:
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]
async def init(self):
@ -132,20 +121,13 @@ class FirewallManager:
async def reload(self):
async with self.lock:
services = self.db.query('SELECT * FROM services;')
tls_services = [s for s in services if s.get("tls_enabled") and s["service_id"] not in self.service_table]
certs_map = CertsDB().get_multiple_certs_and_keys(tls_services) if tls_services else {}
for srv in services:
if srv["service_id"] in self.service_table:
continue
if srv.get("tls_enabled"):
cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None))
srv["tls_cert"] = cert
srv["tls_key"] = key
srv_obj = Service.from_dict(srv)
self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db, outstream_func=self.outstream_function, exception_func=self.exception_function)
await self.service_table[srv_obj.id].next(srv_obj.status)
sync_nginx_state()
def get(self,srv_id) -> ServiceManager:
if srv_id in self.service_table:

View File

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

View File

@ -1,6 +1,6 @@
from modules.nfproxy.models import Service
from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json
from modules.nfproxy.nginx import get_tls_ports
from modules.tls.manager import get_tls_ports, TLSManager
def convert_protocol_to_l4(proto:str):
if proto == "tcp":
@ -10,6 +10,20 @@ def convert_protocol_to_l4(proto:str):
else:
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:
def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int):
self.id = id
@ -19,18 +33,20 @@ class FiregexFilter:
self.ip_int = str(ip_int)
def __eq__(self, o: object) -> bool:
if isinstance(o, FiregexFilter) or isinstance(o, Service):
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)
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)
if isinstance(o, Service):
return self.matches(o)
return False
def matches(self, srv: Service) -> bool:
if self.port == srv.port and ip_parse(self.ip_int) == ip_parse(srv.ip_int):
return True
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port)
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1"
if ip_parse(self.ip_int) == ip_parse(loopback_ip) and self.port in (ssl_port, clear_port):
return True
return False
if self.proto != convert_protocol_to_l4(srv.proto):
return False
target = resolve_target(srv)
if target is None:
return False
target_ip, target_port = target
return self.port == target_port and ip_parse(self.ip_int) == ip_parse(target_ip)
class FiregexTables(NFTableManager):
input_chain = "nfproxy_input"
@ -56,103 +72,58 @@ class FiregexTables(NFTableManager):
"hook":"postrouting",
"prio":108,
"policy":"accept"
}}},
{"add":{"chain":{ #NAT chain for DNAT
"family":"inet",
"table":self.table_name,
"name":self.nat_chain,
"type":"nat",
"hook":"prerouting",
"prio":-100,
"policy":"accept"
}}}
],[
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
])
def add(self, srv:Service, queue_range):
for ele in self.get():
if ele.matches(srv):
return
target = resolve_target(srv)
if target is None:
return
target_ip, target_port = target
init, end = queue_range
if init > end:
init, end = end, init
if srv.tls_enabled:
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port)
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1"
self.cmd(
{ "insert":{ "rule": { # Redirect TLS traffic to local Nginx SSL proxy
"family": "inet",
"table": self.table_name,
"chain": self.nat_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}},
{'match': {'left': {'payload': {'protocol': 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)}}
self.cmd(
{ "insert":{ "rule": { # Send non-TLS outbound traffic to NFQUEUE (Client -> Server)
"family": "inet",
"table": self.table_name,
"chain": self.output_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": "dport"}}, "op": "==", "right": int(target_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 (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]:
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:
ip_int = None
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.models import Regex, Service
from utils.sqlite import SQLite
from modules.nfproxy.nginx import sync_nginx_state
from utils.certs import CertsDB, ip_parse as certs_ip_parse
class STATUS:
STOP = "stop"
@ -45,19 +43,20 @@ class ServiceManager:
def __update_status_db(self, status):
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:
if to == STATUS.STOP:
await self.stop()
await self.stop(persist=persist)
if to == STATUS.ACTIVE:
await self.restart()
def _stats_updater(self,filter:RegexFilter):
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.__update_status_db(status)
if persist:
self.__update_status_db(status)
async def start(self):
if not self.interceptor:
@ -65,17 +64,13 @@ class ServiceManager:
self.interceptor = await FiregexInterceptor.start(self.srv)
await self._update_filters_from_db()
self._set_status(STATUS.ACTIVE)
if self.srv.tls_enabled:
sync_nginx_state()
async def stop(self):
async def stop(self,persist:bool=True):
nft.delete(self.srv)
if self.interceptor:
await self.interceptor.stop()
self.interceptor = None
self._set_status(STATUS.STOP)
if self.srv.tls_enabled:
sync_nginx_state()
self._set_status(STATUS.STOP,persist=persist)
async def restart(self):
await self.stop()
@ -85,17 +80,7 @@ class ServiceManager:
async with self.lock:
await self._update_filters_from_db()
async def update_tls_config(self):
async with self.lock:
srv_dict = self.db.query("SELECT * FROM services WHERE service_id = ?;", self.srv.id)[0]
if srv_dict.get("tls_enabled"):
cert, key = CertsDB().get_cert_and_key(srv_dict["ip_int"], srv_dict["port"])
srv_dict["tls_cert"] = cert
srv_dict["tls_key"] = key
self.srv = Service.from_dict(srv_dict)
if self.status == STATUS.ACTIVE:
await self.restart()
sync_nginx_state()
class FirewallManager:
def __init__(self, db:SQLite):
@ -105,13 +90,16 @@ class FirewallManager:
async def close(self):
for key in list(self.service_table.keys()):
await self.remove(key)
sync_nginx_state()
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 with self.lock:
async def remove(self,srv_id,persist:bool=True):
async with self.lock:
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]
async def init(self):
@ -121,20 +109,13 @@ class FirewallManager:
async def reload(self):
async with self.lock:
services = self.db.query('SELECT * FROM services;')
tls_services = [s for s in services if s.get("tls_enabled") and s["service_id"] not in self.service_table]
certs_map = CertsDB().get_multiple_certs_and_keys(tls_services) if tls_services else {}
for srv in services:
if srv["service_id"] in self.service_table:
continue
if srv.get("tls_enabled"):
cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None))
srv["tls_cert"] = cert
srv["tls_key"] = key
srv_obj = Service.from_dict(srv)
self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db)
await self.service_table[srv_obj.id].next(srv_obj.status)
sync_nginx_state()
def get(self,srv_id) -> ServiceManager:
if srv_id in self.service_table:

View File

@ -1,7 +1,7 @@
import base64
class Service:
def __init__(self, service_id: str, status: str, port: int, name: str, proto: str, ip_int: str, fail_open: bool, 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.status = status
self.port = port
@ -9,7 +9,8 @@ class Service:
self.proto = proto
self.ip_int = ip_int
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_key = tls_key

View File

@ -1,6 +1,20 @@
from modules.nfregex.models import Service
from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json
from modules.nfproxy.nginx import get_tls_ports
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:
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)
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)
if isinstance(o, Service):
return self.matches(o)
return False
def matches(self, srv: Service) -> bool:
if self.port == srv.port and ip_parse(self.ip_int) == ip_parse(srv.ip_int):
return True
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port)
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1"
if ip_parse(self.ip_int) == ip_parse(loopback_ip) and self.port in (ssl_port, clear_port):
return True
return False
if self.proto != str(srv.proto):
return False
target = resolve_target(srv)
if target is None:
return False
target_ip, target_port = target
return self.port == target_port and ip_parse(self.ip_int) == ip_parse(target_ip)
class FiregexTables(NFTableManager):
input_chain = "nfregex_input"
@ -48,103 +64,58 @@ class FiregexTables(NFTableManager):
"hook":"postrouting",
"prio":107,
"policy":"accept"
}}},
{"add":{"chain":{
"family":"inet",
"table":self.table_name,
"name":self.nat_chain,
"type":"nat",
"hook":"prerouting",
"prio":-100,
"policy":"accept"
}}}
],[
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}},
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
{"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
])
def add(self, srv:Service, queue_range):
for ele in self.get():
if ele.matches(srv):
return
target = resolve_target(srv)
if target is None:
return
target_ip, target_port = target
init, end = queue_range
if init > end:
init, end = end, init
if srv.tls_enabled:
ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port)
loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1"
self.cmd(
{ "insert":{ "rule": { # Redirect TLS traffic to local Nginx SSL proxy
"family": "inet",
"table": self.table_name,
"chain": self.nat_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}},
{'match': {'left': {'payload': {'protocol': str(srv.proto), 'field': 'dport'}}, 'op': '==', 'right': int(srv.port)}},
{'match': {'left': {'meta': {'key': 'iifname'}}, 'op': '!=', 'right': 'lo'}},
{'dnat': {'family': ip_family(srv.ip_int), 'addr': loopback_ip, 'port': int(ssl_port)}}
self.cmd(
{ "insert":{ "rule": {
"family": "inet",
"table": self.table_name,
"chain": self.output_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}},
{'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "sport"}}, "op": "==", "right": int(target_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(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]:
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:
ip_int = None
if isinstance(filter["expr"][0]["match"]["right"],str):

View File

@ -22,20 +22,21 @@ class ServiceManager:
nft.add(self.srv)
self._set_status(True)
async def disable(self):
async def disable(self,persist:bool=True):
if self.active:
async with self.lock:
nft.delete(self.srv)
self._set_status(False)
self._set_status(False,persist=persist)
async def refresh(self, srv:Service):
self.srv = srv
if self.active:
await self.restart()
def _set_status(self,active):
def _set_status(self,active,persist:bool=True):
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):
await self.disable()
@ -49,12 +50,16 @@ class FirewallManager:
async def close(self):
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 with self.lock:
async def remove(self,srv_id,persist:bool=True):
async with self.lock:
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]
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 os
import glob
from utils.sqlite import SQLite
from utils.certs import CertsDB, get_tls_ports, ip_parse as certs_ip_parse
from modules.tls.manager import TLSManager
NGINX_CONF_PATH = "/tmp/firegex_nginx.conf"
NGINX_PID_PATH = "/tmp/firegex_nginx.pid"
NGINX_LOG_PATH = "/tmp/firegex_nginx_error.log"
def _get_active_tls_services(db_name: str) -> list[dict]:
db = SQLite(db_name)
db.connect()
try:
return db.query("SELECT * FROM services WHERE status = 'active' AND tls_enabled = 1;")
except Exception:
return []
finally:
db.disconnect()
def update_nginx() -> None:
manager = TLSManager()
streams = manager.get_active_streams()
def update_nginx(services: list[dict]) -> None:
if not services:
if not streams:
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
@ -32,17 +24,18 @@ def update_nginx(services: list[dict]) -> None:
return
servers_config = []
for srv in services:
srv_id = srv["service_id"]
for srv in streams:
srv_id = srv["id"]
cert_path = f"/tmp/firegex_cert_{srv_id}.crt"
key_path = f"/tmp/firegex_key_{srv_id}.key"
with open(cert_path, "w") as f:
f.write(srv.get("tls_cert") or "")
f.write(srv.get("cert") or "")
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]
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)
if res.returncode != 0:
subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-e", NGINX_LOG_PATH], check=True)
def sync_nginx_state() -> None:
services = _get_active_tls_services('db/nft-pyfilters.db') + _get_active_tls_services('db/nft-regex.db')
# Batch-resolve certificates using CertsDB
certs_map = CertsDB().get_multiple_certs_and_keys(services)
for srv in services:
cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None))
srv["tls_cert"] = cert
srv["tls_key"] = key
update_nginx(services)

View File

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

View File

@ -8,25 +8,22 @@ from modules.nfregex.nftables import FiregexTables
from modules.nfregex.firewall import STATUS, FirewallManager
from utils.sqlite import SQLite
from utils import ip_parse, refactor_name, socketio_emit, PortType
from utils.certs import CertsDB, populate_services_tls_config
from utils.models import ResetRequest, StatusMessageModel, TLSConfigForm
from modules.tls.service import activate_stream
from utils.models import ResetRequest, StatusMessageModel
from modules.nfregex.firegex import test_regex_validity
class ServiceModel(BaseModel):
status: str
service_id: str
port: PortType
port: PortType|None = None
name: str
proto: str
ip_int: str
ip_int: str|None = None
n_regex: int
n_packets: int
fail_open: bool
tls_enabled: bool = False
tls_cert: str|None = None
tls_key: str|None = None
ssl_port: int|None = None
clear_port: int|None = None
target_type: str
tls_stream_id: str|None = None
class RenameForm(BaseModel):
name:str
@ -36,6 +33,8 @@ class SettingsForm(BaseModel):
proto: str|None = None
ip_int: str|None = None
fail_open: bool|None = None
target_type: str|None = None
tls_stream_id: str|None = None
class RegexModel(BaseModel):
regex:str
@ -55,13 +54,12 @@ class RegexAddForm(BaseModel):
class ServiceAddForm(BaseModel):
name: str
port: PortType
port: PortType|None = None
proto: str
ip_int: str
ip_int: str|None = None
fail_open: bool = False
tls_enabled: bool = False
tls_cert: str | None = None
tls_key: str | None = None
target_type: str = "flow"
tls_stream_id: str|None = None
class ServiceAddResponse(BaseModel):
status:str
@ -73,12 +71,13 @@ db = SQLite('db/nft-regex.db', {
'services': {
'service_id': 'VARCHAR(100) PRIMARY KEY',
'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',
'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',
'tls_enabled': 'BOOLEAN NOT NULL CHECK (tls_enabled IN (0, 1)) DEFAULT 0',
},
'regexes': {
'regex': 'TEXT NOT NULL',
@ -123,10 +122,8 @@ async def startup():
print("WARNING cannot start firewall:", e)
async def shutdown():
db.backup()
await firewall.close()
db.disconnect()
db.restore()
def gen_service_id():
while True:
@ -149,13 +146,14 @@ async def get_service_list():
s.proto proto,
s.ip_int ip_int,
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,
COALESCE(SUM(r.blocked_packets),0) n_packets
FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id
GROUP BY s.service_id;
""")
return populate_services_tls_config(res)
return res
@app.get('/services/{service_id}', response_model=ServiceModel)
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.ip_int ip_int,
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,
COALESCE(SUM(r.blocked_packets),0) n_packets
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)
if len(res) == 0:
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)
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)
async def service_start(service_id: str):
"""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 refresh_frontend()
return {'status': 'ok'}
@ -196,9 +199,6 @@ async def service_start(service_id: str):
@app.delete('/services/{service_id}', response_model=StatusMessageModel)
async def service_delete(service_id: str):
"""Request the deletion of a specific service"""
srv_res = db.query('SELECT ip_int, port FROM services WHERE service_id = ?;', service_id)
if srv_res:
CertsDB().delete_cert_and_key(srv_res[0]["ip_int"], srv_res[0]["port"])
db.query('DELETE FROM services WHERE service_id = ?;', service_id)
db.query('DELETE FROM regexes WHERE service_id = ?;', service_id)
await firewall.remove(service_id)
@ -222,7 +222,7 @@ async def service_rename(service_id: str, form: RenameForm):
async def service_settings(service_id: str, form: SettingsForm):
"""Request to change the settings of a specific service (will cause a restart)"""
srv_check = db.query("SELECT proto, ip_int, port, tls_enabled FROM services WHERE service_id = ?;", service_id)
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:
raise HTTPException(status_code=404, detail="Service not found")
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"]:
raise HTTPException(status_code=400, detail="Invalid protocol")
if old_srv["tls_enabled"]:
new_proto = form.proto if form.proto is not None else old_srv["proto"]
if new_proto != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
if form.port is not None and (form.port < 1 or form.port > 65535):
raise HTTPException(status_code=400, detail="Invalid port")
@ -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_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 = []
values = []
@ -256,29 +264,19 @@ async def service_settings(service_id: str, form: SettingsForm):
if len(keys) == 0:
raise HTTPException(status_code=400, detail="No settings to change provided")
if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]):
cert, key = CertsDB().get_cert_and_key(old_srv["ip_int"], old_srv["port"])
if cert and key:
CertsDB().upsert_cert_and_key(new_ip, new_port, cert, key)
CertsDB().delete_cert_and_key(old_srv["ip_int"], old_srv["port"])
try:
db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id)
except sqlite3.IntegrityError:
# If migration was performed, rollback CertsDB change (upsert old, delete new)
if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]):
cert, key = CertsDB().get_cert_and_key(new_ip, new_port)
if cert and key:
CertsDB().upsert_cert_and_key(old_srv["ip_int"], old_srv["port"], cert, key)
CertsDB().delete_cert_and_key(new_ip, new_port)
raise HTTPException(status_code=400, detail="A service with these settings already exists")
old_status = firewall.get(service_id).status
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.get(service_id).next(old_status)
await refresh_frontend()
return {'status': 'ok'}
@ -357,54 +355,33 @@ async def add_new_regex(form: RegexAddForm):
@app.post('/services', response_model=ServiceAddResponse)
async def add_new_service(form: ServiceAddForm):
"""Add a new service"""
try:
form.ip_int = ip_parse(form.ip_int)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid address")
if form.target_type == "flow":
if form.ip_int is None or form.port is None:
raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
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"]:
raise HTTPException(status_code=400, detail="Invalid protocol")
if form.tls_enabled:
if form.proto != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
if not form.tls_cert or not form.tls_key:
raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled")
CertsDB().upsert_cert_and_key(form.ip_int, form.port, form.tls_cert, form.tls_key)
if form.target_type == "tls" and form.proto != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
srv_id = None
try:
srv_id = gen_service_id()
db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, tls_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, int(form.tls_enabled))
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, form.target_type, form.tls_stream_id)
except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="This type of service already exists")
await firewall.reload()
await refresh_frontend()
return {'status': 'ok', 'service_id': srv_id}
@app.put('/services/{service_id}/tls-config', response_model=StatusMessageModel)
async def update_service_tls(service_id: str, form: TLSConfigForm):
"""Update TLS configuration for a service"""
srv_res = db.query("SELECT ip_int, port, proto FROM services WHERE service_id = ?;", service_id)
if len(srv_res) == 0:
raise HTTPException(status_code=404, detail="Service not found")
srv = srv_res[0]
if form.tls_enabled:
if srv["proto"] != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
if not form.tls_cert or not form.tls_key:
raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled")
CertsDB().upsert_cert_and_key(srv["ip_int"], srv["port"], form.tls_cert, form.tls_key)
db.query("""
UPDATE services
SET tls_enabled = ?
WHERE service_id = ?;
""", int(form.tls_enabled), service_id)
await firewall.get(service_id).update_tls_config()
await refresh_frontend()
return {'status': 'ok'}
@app.get('/metrics', response_class = PlainTextResponse)
async def metrics():
@ -428,3 +405,52 @@ async def metrics():
metrics.append(f'firegex_blocked_packets{{{props}}} {stat["blocked_packets"]}')
metrics.append(f'firegex_active{{{props}}} {int(stat["active"] and stat["status"] == "active")}')
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()
async def shutdown():
db.backup()
await firewall.close()
db.disconnect()
db.restore()
async def refresh_frontend(additional:list[str]=[]):
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
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
sid_list:set = set()
@ -25,9 +28,12 @@ ROUTERS_DIR = os.path.join(ROOT_DIR,"routers")
ON_DOCKER = "DOCKER" in sys.argv
DEBUG = "DEBUG" in sys.argv
NORELOAD = "NORELOAD" in sys.argv
FIREGEX_PORT = int(os.getenv("PORT","4444"))
FIREGEX_HOST = os.getenv("HOST","0.0.0.0")
FIREGEX_SOCKET_DIR = os.getenv("SOCKET_DIR", None)
_port_env = os.getenv("PORT", "4444")
FIREGEX_PORT = int(_port_env) if _port_env and _port_env != "None" else 4444
_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
JWT_ALGORITHM: str = "HS256"
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

@ -24,9 +24,4 @@ class IpInterface(BaseModel):
name: str
class ResetRequest(BaseModel):
delete:bool
class TLSConfigForm(BaseModel):
tls_enabled: bool
tls_cert: str|None = None
tls_key: str|None = None
delete:bool

View File

@ -125,3 +125,44 @@ class SQLite():
def set(self, 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",
"dependencies": {
"@hello-pangea/dnd": "latest",
"@mantine/code-highlight": "latest",
"@mantine/core": "latest",
"@mantine/form": "latest",
"@mantine/hooks": "latest",
"@mantine/modals": "latest",
"@mantine/notifications": "latest",
"@tanstack/react-query": "latest",
"@vitejs/plugin-react-swc": "latest",
"buffer": "latest",
"react": "latest",
"react-dom": "latest",
"react-icons": "latest",
"react-router": "latest",
"shiki": "latest",
"socket.io-client": "latest",
"typescript": "latest",
"zustand": "latest",
"@hello-pangea/dnd": "^18.0.1",
"@mantine/code-highlight": "^9.3.2",
"@mantine/core": "^9.3.2",
"@mantine/form": "^9.3.2",
"@mantine/hooks": "^9.3.2",
"@mantine/modals": "^9.3.2",
"@mantine/notifications": "^9.3.2",
"@monaco-editor/react": "^4.7.0",
"@tanstack/react-query": "^5.101.0",
"@vitejs/plugin-react-swc": "^4.3.1",
"buffer": "^6.0.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-icons": "^5.6.0",
"react-router": "^8.0.1",
"shiki": "^4.2.0",
"socket.io-client": "^4.8.3",
"typescript": "^6.0.3",
"zustand": "^5.0.14",
},
"devDependencies": {
"@tanstack/react-query-devtools": "latest",
"@types/jest": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"@vitejs/plugin-react": "latest",
"vite": "latest",
"vite-plugin-svgr": "latest",
"vite-tsconfig-paths": "latest",
"@tanstack/react-query-devtools": "^5.101.0",
"@types/jest": "^30.0.0",
"@types/node": "^26.0.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"vite": "^8.0.16",
"vite-plugin-svgr": "^5.2.0",
},
},
},
@ -126,6 +126,10 @@
"@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=="],
"@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/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/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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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-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=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],

View File

@ -11,6 +11,7 @@
"@mantine/hooks": "^9.3.2",
"@mantine/modals": "^9.3.2",
"@mantine/notifications": "^9.3.2",
"@monaco-editor/react": "^4.7.0",
"@tanstack/react-query": "^5.101.0",
"@vitejs/plugin-react-swc": "^4.3.1",
"buffer": "^6.0.3",
@ -39,7 +40,6 @@
"@vitejs/plugin-react": "^6.0.2",
"vite": "^8.0.16",
"vite-plugin-svgr": "^5.2.0",
"vite-tsconfig-paths": "^6.1.1",
"@types/jest": "^30.0.0",
"@types/node": "^26.0.0",
"@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 { useEffect, useState } from 'react';
import { ImCross } from 'react-icons/im';
import { MdCloudOff } from 'react-icons/md';
import { Outlet, Route, Routes } from 'react-router';
import MainLayout from './components/MainLayout';
import { PasswordSend, ServerStatusResponse } from './js/models';
@ -15,158 +16,177 @@ import NFProxy from './pages/NFProxy';
import ServiceDetailsNFProxy from './pages/NFProxy/ServiceDetails';
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() {
const [loading, setLoading] = useState(true);
const [systemStatus, setSystemStatus] = useState<ServerStatusResponse>({status:"", loggined:false})
const [reqError, setReqError] = useState<undefined|string>()
const [error, setError] = useState<string|null>()
const [systemStatus, setSystemStatus] = useState<ServerStatusResponse>({ status: "", loggined: false })
const [reqError, setReqError] = useState<undefined | string>()
const [error, setError] = useState<string | null>()
const [loadinBtn, setLoadingBtn] = useState(false);
const queryClient = useQueryClient()
const { access_token } = useAuthStore()
useEffect(()=>{
useEffect(() => {
socketio.auth = { token: access_token || "" }
socketio.connect()
getStatus()
socketio.on("update", (data) => {
queryClient.invalidateQueries({ queryKey: data })
queryClient.invalidateQueries({ queryKey: data })
})
socketio.on("connect_error", (err) => {
if (access_token){
errorNotify("Socket.Io connection failed! ",`Error message: [${err.message}]`)
if (access_token) {
errorNotify("Socket.Io connection failed! ", `Error message: [${err.message}]`)
}
getStatus()
});
return () => {
socketio.off("update")
socketio.off("connect_error")
socketio.disconnect()
}
},[access_token])
return () => {
socketio.off("update")
socketio.off("connect_error")
socketio.disconnect()
}
}, [access_token])
const getStatus = () =>{
getstatus().then( res =>{
const getStatus = () => {
getstatus().then(res => {
setSystemStatus(res)
setReqError(undefined)
}).catch(err=>{
}).catch(err => {
setReqError(err.toString())
setTimeout(getStatus, 500)
}).finally( ()=>setLoading(false) )
}).finally(() => setLoading(false))
}
useEffect(()=>{
useEffect(() => {
getStatus()
},[])
}, [])
const form = useForm({
initialValues: {
password:"",
password: "",
},
validate:{
validate: {
password: (value) => value !== "" ? null : "Password is required",
}
})
if (loading){
return <LoadingOverlay visible/>
}else if (reqError){
return <Box className='center-flex-row' style={{padding:"100px"}}>
<Title order={1} style={{textAlign:"center"}}>Error launching Firegex! 🔥</Title>
<Space h="md" />
<Title order={4} style={{textAlign:"center"}}>Error communicating with backend</Title>
<Space h="md" />
Error: {reqError}
<Space h="xl" />
<Loader />
</Box>
}else if (systemStatus.status === "init"){
const submitRequest = async (values:PasswordSend) => {
if (loading) {
return <LoadingOverlay visible />
} else if (reqError) {
return <StatusCard title="Can't reach Firegex 🔥" description="The backend isn't responding. Retrying automatically..." color="red">
<Code block style={{ width: "100%", wordBreak: "break-word", backgroundColor: 'var(--secondary_color)' }}>{reqError}</Code>
<Loader size="sm" />
</StatusCard>
} else if (systemStatus.status === "init") {
const submitRequest = async (values: PasswordSend) => {
setLoadingBtn(true)
await setpassword(values).then(res => {
if(!res){
setSystemStatus({loggined:true, status:"run"})
}else{
if (!res) {
setSystemStatus({ loggined: true, status: "run" })
} else {
setError(res)
}
}).catch( err => setError(err.toString()))
}).catch(err => setError(err.toString()))
setLoadingBtn(false)
form.reset()
}
return <Box className='center-flex-row' style={{padding:"100px"}}>
<Title order={3} style={{textAlign:"center"}}>Setup: Choose the password for access to the firewall 🔒</Title>
<Space h="xl" />
<form onSubmit={form.onSubmit(submitRequest)} style={{width:"80%"}}>
<PasswordInput
label="Password"
placeholder="$3cr3t"
{...form.getInputProps('password')}
/>
<Group align="right" mt="md">
<Button loading={loadinBtn} type="submit">Set Password</Button>
</Group>
</form>
<Space h="xl" />
{error?<>
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}>
Error: {error}
</Notification><Space h="md" /></>:null}
</Box>
}else if (systemStatus.status === "run" && !systemStatus.loggined){
const submitRequest = async (values:PasswordSend) => {
setLoadingBtn(true)
await login(values).then(res => {
if(!res){
queryClient.invalidateQueries()
setSystemStatus({...systemStatus, loggined:true})
}else{
setError("Login failed")
}
}).catch( err => setError(err.toString()))
setLoadingBtn(false)
form.reset()
}
return <Box className='center-flex-row' style={{padding:"100px"}}>
<Title order={2} style={{textAlign:"center"}}>Welcome to Firegex 🔥</Title>
return <AuthShell>
<Title order={5} ta="center" style={{ color: 'var(--text-secondary)', fontWeight: 400 }}>Choose the password for access to the firewall 🔒</Title>
<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%" }}>
<PasswordInput
label="Password"
placeholder="$3cr3t"
{...form.getInputProps('password')}
/>
<Group mt="xl">
<Button loading={loadinBtn} type="submit" variant="light" color="cyan" fullWidth>Set Password</Button>
</Group>
</form>
{error && <>
<Space h="lg" />
<Notification icon={<ImCross size={14} />} color="red" onClose={() => { setError(null) }}>
Error: {error}
</Notification></>}
</AuthShell>
} else if (systemStatus.status === "run" && !systemStatus.loggined) {
const submitRequest = async (values: PasswordSend) => {
setLoadingBtn(true)
await login(values).then(res => {
if (!res) {
queryClient.invalidateQueries()
setSystemStatus({ ...systemStatus, loggined: true })
} else {
setError("Login failed")
}
}).catch(err => setError(err.toString()))
setLoadingBtn(false)
form.reset()
}
return <AuthShell>
<Title order={5} ta="center" style={{ color: 'var(--text-secondary)', fontWeight: 400 }}>Insert the password to unlock the firewall 🔒</Title>
<Space h="xl" />
<form onSubmit={form.onSubmit(submitRequest)} style={{width:"80%"}}>
<PasswordInput
label="Password"
placeholder="$3cr3t"
{...form.getInputProps('password')}
/>
<Group align="right" mt="md">
<Button loading={loadinBtn} type="submit">Login</Button>
</Group>
</form>
<Space h="xl" />
{error?<>
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}>
Error: {error}
</Notification><Space h="md" /></>:null}
</Box>
}else if (systemStatus.status === "run" && systemStatus.loggined){
<form onSubmit={form.onSubmit(submitRequest)} style={{ width: "100%" }}>
<PasswordInput
label="Password"
placeholder="$3cr3t"
{...form.getInputProps('password')}
/>
<Group mt="xl">
<Button loading={loadinBtn} type="submit" variant="light" color="cyan" fullWidth>Login</Button>
</Group>
</form>
{error && <>
<Space h="lg" />
<Notification icon={<ImCross size={14} />} color="red" onClose={() => { setError(null) }}>
Error: {error}
</Notification></>}
</AuthShell>
} else if (systemStatus.status === "run" && systemStatus.loggined) {
return <PageRouting getStatus={getStatus} />
}else{
return <Box className='center-flex-row' style={{padding:"100px"}}>
<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>
} else {
return <StatusCard title="Unexpected error 🔥" description="Firegex returned an unexpected status. Try reloading the page." color="orange" />
}
}
const PageRouting = ({ getStatus }:{ getStatus:()=>void }) => {
import TLSDecrypt from './pages/TLSDecrypt';
const PageRouting = ({ getStatus }: { getStatus: () => void }) => {
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=":srv" element={<ServiceDetailsNFRegex />} />
</Route>
@ -175,9 +195,9 @@ const PageRouting = ({ getStatus }:{ getStatus:()=>void }) => {
</Route>
<Route path="firewall" element={<Firewall />} />
<Route path="porthijack" element={<PortHijack />} />
<Route path="*" element={<HomeRedirector />} />
</Route>
</Routes>
<Route path="*" element={<HomeRedirector />} />
</Route>
</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 { useState } from 'react';
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>
<form onSubmit={form.onSubmit(submitRequest)}>
return <Modal size="lg" title="Add a new regex filter" opened={opened} onClose={close} closeOnClickOutside={false} centered>
<form onSubmit={form.onSubmit(submitRequest)}>
<TextInput
label="Regex"
placeholder="[A-Z0-9]{31}="
size="md"
data-autofocus
{...form.getInputProps('regex')}
/>
<Space h="md" />
<Switch
label="Case insensitive"
{...form.getInputProps('is_case_insensitive', { type: 'checkbox' })}
/>
<Space h="md" />
<Switch
label="Deactivate"
{...form.getInputProps('deactive', { type: 'checkbox' })}
/>
<Space h="md" />
<Select
data={[
{ value: 'C', label: 'Client -> Server' },
{ value: 'S', label: 'Server -> Client' },
{ value: 'B', label: 'Both (Client <-> Server)' },
]}
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>
<Space h="xl" />
<Group justify="space-between" align="center">
<Box>
<Text fw={500} size="sm">Packet Direction</Text>
<Text size="xs" c="dimmed">Choose which traffic this regex should filter</Text>
</Box>
<SegmentedControl
value={form.values.mode}
onChange={(val) => form.setFieldValue('mode', val)}
data={[
{ label: 'Client → Server', value: 'C' },
{ label: 'Bidirectional', value: 'B' },
{ label: 'Server → Client', value: 'S' },
]}
color="cyan"
size="sm"
/>
</Group>
<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>
<Space h="md" />
{error?<>
<Space h="md" />
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}>
Error: {error}
</Notification><Space h="md" /></>:null}
</Notification></>:null}
</form>
</Modal>
}
export default AddNewRegex;

View File

@ -1,27 +1,26 @@
import { SegmentedControl, SegmentedControlProps } from "@mantine/core";
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
return <SegmentedControl
data={[
{
value: RuleMode.IN,
label: isFilterTable?'IN':'PREROUTING',
label: isFilterTable ? 'IN' : 'PREROUTING',
},
...(isFilterTable?[{
...(isFilterTable ? [{
value: RuleMode.FORWARD,
label: 'FWD',
}]:[]),
}] : []),
{
value: RuleMode.OUT,
label: isFilterTable?'OUT':'POSTROUTING',
label: isFilterTable ? 'OUT' : 'POSTROUTING',
}
]}
size={props.size?props.size:"xs"}
size={props.size ? props.size : "xs"}
{...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 { 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 { ImCross } from "react-icons/im"
import { nfproxy, Service } from './utils';
import PortAndInterface from '../PortAndInterface';
import { IoMdInformationCircleOutline } from "react-icons/io";
import { TbNetwork, TbShieldLock, TbSettings, TbTarget } from "react-icons/tb";
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 }) {
@ -20,7 +26,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
proto:edit?.proto??"tcp",
fail_open: edit?.fail_open??false,
autostart: true,
tls_enabled: false,
tls_enabled: edit?.target_type === 'tls',
tls_stream_id: edit?.tls_stream_id ?? undefined,
tls_cert: "",
tls_key: ""
}
@ -32,8 +39,6 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
port: (value) => (value>0 && value<65536) ? null : "Invalid port",
proto: (value) => ["tcp","http"].includes(value) ? null : "Invalid protocol",
ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address",
tls_cert: (value, values) => !edit && values.tls_enabled && !value ? "Certificate is required when TLS is enabled" : null,
tls_key: (value, values) => !edit && values.tls_enabled && !value ? "Private key is required when TLS is enabled" : null,
}
})
@ -55,10 +60,34 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
const [submitLoading, setSubmitLoading] = useState(false)
const [error, setError] = useState<string|null>(null)
const submitRequest = (values: ServiceAddForm) => {
const submitRequest = async (values: ServiceAddForm) => {
setSubmitLoading(true)
let streamId = values.tls_stream_id;
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){
setSubmitLoading(false)
close();
@ -69,15 +98,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
setError("Request Failed! [ "+err+" ]")
})
}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 = {
name: values.name,
port: values.port,
proto: values.proto,
ip_int: values.ip_int,
fail_open: values.fail_open,
tls_enabled: values.tls_enabled,
tls_cert: values.tls_enabled ? values.tls_cert : null,
tls_key: values.tls_enabled ? values.tls_key : null,
target_type: values.tls_enabled ? "tls" : "flow",
tls_stream_id: streamId,
}
nfproxy.servicesadd(payload).then( res => {
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>
<form onSubmit={form.onSubmit(submitRequest)}>
{!edit?<TextInput
label="Service name"
placeholder="Challenge 01"
{...form.getInputProps('name')}
/>:null}
<Space h="md" />
<PortAndInterface form={form} int_name="ip_int" port_name="port" label={"Public IP Interface and port (ipv4/ipv6 + CIDR allowed)"} />
<Space h="md" />
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)}>
<Tabs defaultValue="general" color="cyan" variant="outline" radius="md">
<Tabs.List>
<Tabs.Tab value="general" leftSection={<TbSettings size={16} />}>
General
</Tabs.Tab>
<Tabs.Tab value="advanced" leftSection={<TbShieldLock size={16} />}>
Advanced
</Tabs.Tab>
</Tabs.List>
<Box className='center-flex'>
<Box>
{!edit?<Switch
label="Auto-Start Service"
{...form.getInputProps('autostart', { type: 'checkbox' })}
/>:null}
<Space h="sm" />
<Group gap="xs" wrap="nowrap" align="center">
<Switch
label="Enable fail-open nfqueue"
{...form.getInputProps('fail_open', { type: 'checkbox' })}
<Tabs.Panel value="general" pt="xl" pb="md">
{!edit ? (
<TextInput
label="Service name"
placeholder="Challenge 01"
size="md"
{...form.getInputProps('name')}
/>
<Tooltip
label={<>
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 />
</>}
zIndex={1000000}
) : null}
<Space h="md" />
<Title order={6} mb="xs">Protocol</Title>
<SegmentedControl
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
variant="subtle"
color="gray"
size="sm"
onClick={(e: any) => { e.preventDefault(); e.stopPropagation(); }}
>
<IoMdInformationCircleOutline size={18} />
</ActionIcon>
</Tooltip>
<Group wrap="nowrap">
<TbNetwork size={32} color={!form.values.tls_enabled ? "var(--accent-color)" : "gray"} />
<div>
<Text fw={600}>Direct Flow</Text>
<Text size="xs" c="dimmed">Forward traffic directly without decryption</Text>
</div>
</Group>
</UnstyledButton>
<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>
</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">
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button>
</Group>
<Tabs.Panel value="advanced" pt="xl" pb="md">
<Card withBorder padding="lg" radius="md" bg="transparent">
{!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}
<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?<>
{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}
</Notification><Space h="md" />
</>:null}
</Notification>
</> : 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>
</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 { FaPlay, FaStop } from 'react-icons/fa';
import { FaPlay, FaStop, FaTrash } from 'react-icons/fa';
import { nfproxy, Service, serviceQueryKey } from '../utils';
import { MdDoubleArrow, MdOutlineArrowForwardIos } from "react-icons/md"
import YesNoModal from '../../YesNoModal';
import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../../js/utils';
import { BsTrashFill } from 'react-icons/bs';
import { BiRename } from 'react-icons/bi'
import { BiRename } from 'react-icons/bi';
import RenameForm from './RenameForm';
import { MenuDropDownWithButton } from '../../MainLayout';
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 { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../AddEditService';
import { FaPencilAlt } from "react-icons/fa";
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 }) {
@ -31,7 +32,6 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
const [deleteModal, setDeleteModal] = useState(false)
const [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const isMedium = isMediumScreen()
const stopService = async () => {
@ -79,79 +79,87 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
}
return <>
<Box className='firegex__nfregex__rowbox'>
<Box className="firegex__nfregex__row" style={{width:"100%", flexDirection: isMedium?"row":"column"}}>
<Box>
<Box className="center-flex" style={{ justifyContent: "flex-start" }}>
<MdDoubleArrow size={30} style={{color: "white"}}/>
<Title className="firegex__nfregex__name" ml="xs">
{service.name}
</Title>
<Card
withBorder
radius="md"
p="md"
w="100%"
bg="transparent"
className="firegex__clickable_row"
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 className="center-flex" style={{ gap: 8, marginTop: 15, justifyContent: "flex-start" }}>
<Badge color={status_color} radius="md" size="lg" variant="filled">{service.status}</Badge>
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}>
:{service.port}
</Badge>
{service.tls_enabled && (
<Badge color="green" radius="md" size="lg" variant="light">
<TbShieldLock size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
TLS (lo:{service.clear_port})
<Box>
<Group gap="xs" align="center">
<Text fw={600} size="md">{service.name}</Text>
<Badge color={status_color} variant="light" size="xs" radius="sm">
{service.status.toUpperCase()}
</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>
{isMedium?null:<Space w="xl" />}
</Box>
</Group>
<Box className={isMedium?"center-flex":"center-flex-row"}>
<Box className="center-flex-row">
<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">
<Group gap="xs" wrap="nowrap">
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
<ExceptionWarning service_id={service.service_id} />
<Space w="sm"/>
<MenuDropDownWithButton>
<Menu.Item><b>Edit service</b></Menu.Item>
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
<Menu.Item leftSection={<TbShieldLock size={18} />} onClick={()=>setTlsModal(true)}>Configure TLS</Menu.Item>
<Divider />
<Menu.Label><b>Danger zone</b></Menu.Label>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
</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>
{service.status === "stop" ? (
<Button variant="default" size="xs" leftSection={<FaPlay size={10} />} onClick={startService} loading={buttonLoading}>
Start
</Button>
) : (
<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>
</Tooltip>
</Group>
</Group>
</Card>
<YesNoModal
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! ⚠️`}
@ -169,12 +177,5 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
onClose={()=>setEditModal(false)}
edit={service}
/>
<TLSConfigModal
opened={tlsModal}
onClose={() => setTlsModal(false)}
service={service}
updateTlsConfig={nfproxy.updatetlsconfig}
queryKey={serviceQueryKey}
/>
</>
}

View File

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

View File

@ -13,11 +13,8 @@ export type Service = {
edited_packets:number,
blocked_packets:number,
fail_open:boolean,
tls_enabled:boolean,
tls_cert?:string,
tls_key?:string,
ssl_port?:number,
clear_port?:number,
target_type:string,
tls_stream_id:string|null,
}
export type TLSConfig = {
@ -33,15 +30,17 @@ export type ServiceAddForm = {
proto:string,
ip_int:string,
fail_open: boolean,
tls_enabled?: boolean,
tls_cert?: string | null,
tls_key?: string | null,
target_type?: string,
tls_stream_id?: string | null,
}
export type ServiceSettings = {
port?:number,
proto?:string,
ip_int?:string,
fail_open?: boolean,
target_type?: string,
tls_stream_id?: string | null,
}
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 { 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 { ImCross } from "react-icons/im"
import { nfregex, Service } from './utils';
import PortAndInterface from '../PortAndInterface';
import { IoMdInformationCircleOutline } from "react-icons/io";
import { TbNetwork, TbShieldLock, TbSettings, TbTarget } from "react-icons/tb";
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 }) {
@ -20,7 +26,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
proto:edit?.proto??"tcp",
fail_open: edit?.fail_open??false,
autostart: true,
tls_enabled: false,
tls_enabled: edit?.target_type === 'tls',
tls_stream_id: edit?.tls_stream_id ?? undefined,
tls_cert: "",
tls_key: ""
}
@ -32,8 +39,6 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
port: (value) => (value>0 && value<65536) ? null : "Invalid port",
proto: (value) => ["tcp","udp"].includes(value) ? null : "Invalid protocol",
ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address",
tls_cert: (value, values) => !edit && values.tls_enabled && !value ? "Certificate is required when TLS is enabled" : null,
tls_key: (value, values) => !edit && values.tls_enabled && !value ? "Private key is required when TLS is enabled" : null,
}
})
@ -55,10 +60,34 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
const [submitLoading, setSubmitLoading] = useState(false)
const [error, setError] = useState<string|null>(null)
const submitRequest = (values: ServiceAddForm) => {
const submitRequest = async (values: ServiceAddForm) => {
setSubmitLoading(true)
let streamId = values.tls_stream_id;
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){
setSubmitLoading(false)
close();
@ -69,15 +98,36 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
setError("Request Failed! [ "+err+" ]")
})
}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 = {
name: values.name,
port: values.port,
proto: values.proto,
ip_int: values.ip_int,
fail_open: values.fail_open,
tls_enabled: values.tls_enabled,
tls_cert: values.tls_enabled ? values.tls_cert : null,
tls_key: values.tls_enabled ? values.tls_key : null,
target_type: values.tls_enabled ? "tls" : "flow",
tls_stream_id: streamId,
}
nfregex.servicesadd(payload).then( res => {
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>
<form onSubmit={form.onSubmit(submitRequest)}>
{!edit?<TextInput
label="Service name"
placeholder="Challenge 01"
{...form.getInputProps('name')}
/>:null}
<Space h="md" />
<PortAndInterface form={form} int_name="ip_int" port_name="port" label={"Public IP Interface and port (ipv4/ipv6 + CIDR allowed)"} />
<Space h="md" />
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)}>
<Tabs defaultValue="general" color="cyan" variant="outline" radius="md">
<Tabs.List>
<Tabs.Tab value="general" leftSection={<TbSettings size={16} />}>
General
</Tabs.Tab>
<Tabs.Tab value="advanced" leftSection={<TbShieldLock size={16} />}>
Advanced
</Tabs.Tab>
</Tabs.List>
<Box className='center-flex'>
<Box>
{!edit?<Switch
label="Auto-Start Service"
{...form.getInputProps('autostart', { type: 'checkbox' })}
/>:null}
<Space h="sm" />
<Group gap="xs" wrap="nowrap" align="center">
<Switch
label="Enable fail-open nfqueue"
{...form.getInputProps('fail_open', { type: 'checkbox' })}
<Tabs.Panel value="general" pt="xl" pb="md">
{!edit ? (
<TextInput
label="Service name"
placeholder="Challenge 01"
size="md"
{...form.getInputProps('name')}
/>
<Tooltip
label={<>
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 />
</>}
zIndex={1000000}
) : null}
<Space h="md" />
<Title order={6} mb="xs">Protocol</Title>
<SegmentedControl
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
variant="subtle"
color="gray"
size="sm"
onClick={(e: any) => { e.preventDefault(); e.stopPropagation(); }}
>
<IoMdInformationCircleOutline size={18} />
</ActionIcon>
</Tooltip>
<Group wrap="nowrap">
<TbNetwork size={32} color={!form.values.tls_enabled ? "var(--accent-color)" : "gray"} />
<div>
<Text fw={600}>Direct Flow</Text>
<Text size="xs" c="dimmed">Forward traffic directly without decryption</Text>
</div>
</Group>
</UnstyledButton>
<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>
</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">
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button>
</Group>
<Tabs.Panel value="advanced" pt="xl" pb="md">
<Card withBorder padding="lg" radius="md" bg="transparent">
{!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}
<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?<>
{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}
</Notification><Space h="md" />
</>:null}
</Notification>
</> : 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>
</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 { FaPlay, FaStop } from 'react-icons/fa';
import { FaPlay, FaStop, FaTrash } from 'react-icons/fa';
import { nfregex, Service, serviceQueryKey } from '../utils';
import { MdDoubleArrow, MdOutlineArrowForwardIos } from "react-icons/md"
import YesNoModal from '../../YesNoModal';
import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../../js/utils';
import { BsTrashFill } from 'react-icons/bs';
import { BiRename } from 'react-icons/bi'
import { BiRename } from 'react-icons/bi';
import RenameForm from './RenameForm';
import { MenuDropDownWithButton } from '../../MainLayout';
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 AddEditService from '../AddEditService';
import { TbShieldLock } from "react-icons/tb";
import TLSConfigModal from '../../TLSConfigModal';
import { TbShieldLock, TbHexagon } from "react-icons/tb";
import { MdChevronRight } from "react-icons/md";
import TLSAssociationBadge from '../../TLSAssociationBadge';
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 [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const isMedium = isMediumScreen()
const stopService = async () => {
@ -78,75 +76,86 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
}
return <>
<Box className='firegex__nfregex__rowbox'>
<Box className="firegex__nfregex__row" style={{width:"100%", flexDirection: isMedium?"row":"column"}}>
<Box>
<Box className="center-flex" style={{ justifyContent: "flex-start" }}>
<MdDoubleArrow size={30} style={{color: "white"}}/>
<Title className="firegex__nfregex__name" ml="xs">
{service.name}
</Title>
<Card
withBorder
radius="md"
p="md"
w="100%"
bg="transparent"
className="firegex__clickable_row"
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 className="center-flex" style={{ gap: 8, marginTop: 15, justifyContent: "flex-start" }}>
<Badge color={status_color} radius="md" size="lg" variant="filled">{service.status}</Badge>
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}>
:{service.port}
</Badge>
{service.tls_enabled && (
<Badge color="green" radius="md" size="lg" variant="light">
<TbShieldLock size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
TLS (lo:{service.clear_port})
<Box>
<Group gap="xs" align="center">
<Text fw={600} size="md">{service.name}</Text>
<Badge color={status_color} variant="light" size="xs" radius="sm">
{service.status.toUpperCase()}
</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>
{isMedium?null:<Space w="xl" />}
</Box>
</Group>
<Box className={isMedium?"center-flex":"center-flex-row"}>
<Box className="center-flex-row">
<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.n_packets}</Badge>
<Space w="xs" />
<Badge color="violet" radius="sm" size="md" variant="filled"><VscRegex style={{ marginBottom: -2}} size={13} /> {service.n_regex}</Badge>
<Group gap="xs" wrap="nowrap">
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
{service.status === "stop" ? (
<Button variant="default" size="xs" leftSection={<FaPlay size={10} />} onClick={startService} loading={buttonLoading}>
Start
</Button>
) : (
<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>
{isMedium?<Space w="xl" />:<Space h="lg" />}
<Box className="center-flex">
<MenuDropDownWithButton>
<Menu.Item><b>Edit service</b></Menu.Item>
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
<Menu.Item leftSection={<TbShieldLock size={18} />} onClick={()=>setTlsModal(true)}>Configure TLS</Menu.Item>
<Divider />
<Menu.Label><b>Danger zone</b></Menu.Label>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
</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>
</Tooltip>
</Group>
</Group>
</Card>
<YesNoModal
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! ⚠️`}
@ -164,12 +173,5 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
onClose={()=>setEditModal(false)}
edit={service}
/>
<TLSConfigModal
opened={tlsModal}
onClose={() => setTlsModal(false)}
service={service}
updateTlsConfig={nfregex.updatetlsconfig}
queryKey={serviceQueryKey}
/>
</>
}

View File

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

View File

@ -2,11 +2,13 @@ import { Divider, Group, MantineColor, ScrollArea, Text, ThemeIcon, Title, Unsty
import { useState } from "react";
import { TbPlugConnected } from "react-icons/tb";
import { useNavigate } from "react-router";
import { useQueryClient } from "@tanstack/react-query";
import { GrDirections } from "react-icons/gr";
import { PiWallLight } from "react-icons/pi";
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 { MdDownload, MdUpload } from "react-icons/md";
function NavBarButton({ navigate, closeNav, name, icon, color, disabled, onClick }:
{ 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() {
const [toggle, setToggleState] = useState(false);
const { navOpened, closeNav } = useNavbarStore()
const queryClient = useQueryClient()
return <AppShell.Navbar p="md" hidden={!navOpened}>
<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="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} />} />
{/* <Box px="xs" mt="lg">
<Title order={5}>Experimental Features 🧪</Title>
<NavBarButton navigate="tls-decrypt" closeNav={closeNav} name="TLS Decryption" color="cyan" icon={<TbPlugConnected size={19} />} />
<Box px="xs" mt="lg">
<Title order={5}>Backup & Restore</Title>
</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>
</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 { useState } from 'react';
import { okNotify, regex_ipv6_no_cidr, regex_ipv4_no_cidr } from '../../js/utils';
import { ImCross } from "react-icons/im"
import { porthijack } from './utils';
import PortAndInterface from '../PortAndInterface';
import { TbSettings, TbShieldLock } from 'react-icons/tb';
type ServiceAddForm = {
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>
<form onSubmit={form.onSubmit(submitRequest)}>
<TextInput
label="Service name"
placeholder="Challenge 01"
{...form.getInputProps('name')}
/>
<Space h="md" />
<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)" />
<Space h="md" />
return <Modal size="xl" title="Add a new service" opened={opened} onClose={close} closeOnClickOutside={false} centered>
<form onSubmit={form.onSubmit(submitRequest)}>
<Tabs defaultValue="general" color="cyan" variant="outline" radius="md">
<Tabs.List>
<Tabs.Tab value="general" leftSection={<TbSettings size={16} />}>
General
</Tabs.Tab>
<Tabs.Tab value="advanced" leftSection={<TbShieldLock size={16} />}>
Advanced
</Tabs.Tab>
</Tabs.List>
<Box className='center-flex'>
<Switch
label="Auto-Start Service"
{...form.getInputProps('autostart', { type: 'checkbox' })}
/>
<Box className="flex-spacer" />
<SegmentedControl
data={[
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
]}
{...form.getInputProps('proto')}
/>
</Box>
<Tabs.Panel value="general" pt="xl" pb="md">
<TextInput
label="Service name"
placeholder="Challenge 01"
size="md"
{...form.getInputProps('name')}
/>
<Space h="md" />
<Title order={6} mb="xs">Protocol</Title>
<SegmentedControl
fullWidth
size="md"
color="cyan"
data={[
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
]}
{...form.getInputProps('proto')}
/>
<Space h="xl" />
<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>
<Group justify='flex-end' mt="md" mb="sm">
<Button loading={submitLoading} type="submit">Add Service</Button>
<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>
{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 { FaPlay, FaStop } from 'react-icons/fa';
import { FaPlay, FaStop, FaTrash } from 'react-icons/fa';
import { porthijack, Service } from '../utils';
import YesNoModal from '../../YesNoModal';
import { errorNotify, isMediumScreen, okNotify } from '../../../js/utils';
import { BsArrowRepeat, BsTrashFill } from 'react-icons/bs';
import { BiRename } from 'react-icons/bi'
import { BiRename } from 'react-icons/bi';
import RenameForm from './RenameForm';
import ChangeDestination from './ChangeDestination';
import { useForm } from '@mantine/form';
import { MenuDropDownWithButton } from '../../MainLayout';
import { MdDoubleArrow } from "react-icons/md";
import { TbHexagon } from "react-icons/tb";
export default function ServiceRow({ service }:{ service:Service }) {
@ -69,67 +69,80 @@ export default function ServiceRow({ service }:{ service:Service }) {
}
return <>
<Box className='firegex__nfregex__rowbox'>
<Box className="firegex__nfregex__row" style={{width:"100%", flexDirection: isMedium?"row":"column"}}>
<Box>
<Box className="center-flex" style={{ justifyContent: "flex-start" }}>
<MdDoubleArrow size={30} style={{color: "white"}}/>
<Title className="firegex__nfregex__name" ml="xs">
{service.name}
</Title>
<Card
withBorder
radius="md"
p="md"
w="100%"
bg="transparent"
style={{
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 className="center-flex" style={{ gap: 8, marginTop: 15, justifyContent: "flex-start" }}>
<Badge color={status_color} radius="md" size="md" variant="filled">{service.active?"ENABLED":"DISABLED"}</Badge>
<Badge color={service.proto === "tcp"?"cyan":"orange"} radius="md" size="md" variant="filled">
{service.proto}
</Badge>
<Box>
<Group gap="xs" align="center">
<Text fw={600} size="md">{service.name}</Text>
<Badge color={status_color} variant="light" size="xs" radius="sm">
{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>
{isMedium?null:<Space w="xl" />}
</Box>
</Group>
<Box className={isMedium?"center-flex":"center-flex-row"}>
<Box className="center-flex-row">
<Badge color="lime" radius="sm" size="lg" variant="filled">
FROM {service.ip_src} :{service.public_port}
</Badge>
<Space h="sm" />
<Badge color="blue" radius="sm" size="lg" variant="filled">
<Box className="center-flex">
TO {service.ip_dst} :{service.proxy_port}
</Box>
</Badge>
</Box>
{isMedium?<Space w="xl" />:<Space h="lg" />}
<Box className="center-flex">
<MenuDropDownWithButton>
<Menu.Label><b>Rename service</b></Menu.Label>
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
<Menu.Label><b>Change destination</b></Menu.Label>
<Menu.Item leftSection={<BsArrowRepeat size={18} />} onClick={()=>setChangeDestModal(true)}>Change hijacking destination</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.active}
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={service.active}>
<FaPlay size="20px" />
</ActionIcon>
</Tooltip>
</Box>
</Box>
</Box>
</Box>
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
{service.active ? (
<Button variant="default" size="xs" leftSection={<FaStop size={10} />} onClick={stopService} loading={buttonLoading}>
Stop
</Button>
) : (
<Button variant="default" size="xs" leftSection={<FaPlay size={10} />} onClick={startService} loading={buttonLoading}>
Start
</Button>
)}
<Menu>
<Menu.Target>
<Button variant="default" size="xs" leftSection={<BsArrowRepeat 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={<BsArrowRepeat size={14} />} onClick={()=>setChangeDestModal(true)}>Change hijacking destination</Menu.Item>
</Menu.Dropdown>
</Menu>
<Button variant="default" size="xs" leftSection={<FaTrash size={10} />} onClick={() => setDeleteModal(true)}>
Delete
</Button>
</Group>
</Group>
</Card>
<YesNoModal
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 { PyFilter } from '../../js/models';
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}`))
}
return <Box my="sm" display="flex" style={{alignItems:"center"}}>
<Box className="firegex__regexview__pyfilter_text" style={{ width: "100%", alignItems: "center"}} display="flex" >
<Badge size="sm" radius="lg" mr="sm" color={filterInfo.active?"lime":"red"} variant="filled" />
{filterInfo.name}
<Box className='flex-spacer' />
<Space w="xs" />
{isMedium?<>
<Badge size="md" radius="md" color="yellow" variant="filled"><FaFilter style={{ marginBottom: -2, marginRight: 2}} /> {filterInfo.blocked_packets}</Badge>
<Space w="xs" />
<Badge size="md" radius="md" color="orange" variant="filled"><FaPencilAlt style={{ marginBottom: -1, marginRight: 2}} /> {filterInfo.edited_packets}</Badge>
<Space w="lg" />
</>:null}
<Tooltip label={filterInfo.active?"Deactivate":"Activate"} zIndex={0} color={filterInfo.active?"orange":"teal"}>
<ActionIcon color={filterInfo.active?"orange":"teal"} onClick={changeRegexStatus} size="lg" radius="md" variant="filled">
{filterInfo.active?<FaPause size="20px" />:<FaPlay size="20px" />}</ActionIcon>
</Tooltip>
</Box>
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>
<Group gap="xs" wrap="nowrap">
<Badge size="sm" color="yellow" variant="light" leftSection={<FaFilter size={10} style={{ marginTop: 2 }} />}>
{filterInfo.blocked_packets}
</Badge>
<Badge size="sm" color="orange" variant="light" leftSection={<FaPencilAlt size={10} style={{ marginTop: 2 }} />}>
{filterInfo.edited_packets}
</Badge>
<Space w="xs" />
<Tooltip label={filterInfo.active ? "Deactivate" : "Activate"} color={filterInfo.active ? "orange" : "teal"}>
<ActionIcon
color={filterInfo.active ? "orange" : "teal"}
onClick={changeRegexStatus}
size="lg"
radius="md"
variant="light"
>
{filterInfo.active ? <FaPause size={16} /> : <FaPlay size={16} />}
</ActionIcon>
</Tooltip>
</Group>
</Group>
</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 { RegexFilter } from '../../js/models';
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}`))
}
return <Box className="firegex__regexview__box">
<Box>
<Box className='center-flex' style={{width: "100%"}}>
<Box className="firegex__regexview__outer_regex_text">
<Text className="firegex__regexview__regex_text" onClick={()=>{
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">
<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)
okNotify("Regex copied to clipboard!",`The regex '${regex_expr}' has been copied to the clipboard!`)
}}>{regex_expr}</Text>
</Box>
<Space w="xs" />
<Tooltip label={regexInfo.active?"Deactivate":"Activate"} zIndex={0} color={regexInfo.active?"orange":"teal"}>
<ActionIcon color={regexInfo.active?"orange":"teal"} onClick={changeRegexStatus} size="xl" radius="md" variant="filled"
>{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>
okNotify("Regex copied to clipboard!", `The regex '${regex_expr}' has been copied to the clipboard!`)
}}
>
{regex_expr}
</Code>
</Tooltip>
</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;

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 { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { getapi, regex_ipv4, regex_ipv6 } from '../js/utils';
import PortAndInterface from './PortAndInterface';
import { MdAdd } from 'react-icons/md';
interface TLSServiceFieldsProps {
form: UseFormReturnType<any>;
@ -9,70 +12,107 @@ interface TLSServiceFieldsProps {
}
export default function TLSServiceFields({ form, disabled }: TLSServiceFieldsProps) {
useEffect(() => {
if (!form.values.tls_enabled || !form.values.ip_int || !form.values.port) {
return;
}
const { data: streams, isLoading } = useQuery<any[]>({
queryKey: ["tls_streams"],
queryFn: () => getapi("tls/streams")
});
const isIpValid = form.values.ip_int.match(regex_ipv4) || form.values.ip_int.match(regex_ipv6);
const isPortValid = form.values.port > 0 && form.values.port < 65536;
if (!isIpValid || !isPortValid) {
return;
}
const debounceTimer = setTimeout(() => {
getapi(`certificates?ip_int=${encodeURIComponent(form.values.ip_int)}&port=${form.values.port}`)
.then(res => {
if (res && res.cert) {
form.setFieldValue('tls_cert', res.cert);
}
if (res && res.key) {
form.setFieldValue('tls_key', res.key);
}
})
.catch(err => {
console.error("Failed to fetch certificate", err);
});
}, 500);
return () => clearTimeout(debounceTimer);
}, [form.values.tls_enabled, form.values.ip_int, form.values.port]);
const [isCreatingNew, setIsCreatingNew] = useState(false);
useEffect(() => {
if (disabled && form.values.tls_enabled) {
form.setFieldValue('tls_enabled', false);
// Don't auto-pick/overwrite the stream selection while editing an existing
// 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 (
<>
<Space h="md" />
<Switch
label="Enable TLS Decryption / Encryption"
description="Decrypt and encrypt incoming and outgoing traffic using Nginx stream proxy"
disabled={disabled}
{...form.getInputProps('tls_enabled', { type: 'checkbox' })}
/>
{form.values.tls_enabled && (
<Box pt="md">
<Group justify="space-between" align="center" mb="sm">
<Title order={6}>TLS Decryption Target</Title>
<Button size="xs" variant="light" leftSection={<MdAdd />} onClick={() => setIsCreatingNew(!isCreatingNew)} disabled={disabled}>
{isCreatingNew ? "Select Existing" : "Create New"}
</Button>
</Group>
<Space h="sm" />
{!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" />
<Textarea
label="TLS Certificate (PEM format)"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
minRows={4}
styles={{ input: { fontFamily: 'monospace' } }}
{...form.getInputProps('tls_cert')}
<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
placeholder="-----BEGIN CERTIFICATE-----..."
minRows={4}
styles={{ input: { fontFamily: 'monospace' } }}
{...form.getInputProps('tls_cert')}
required={isCreatingNew && form.values.tls_enabled}
/>
<Space h="md" />
<Textarea
label="TLS Private Key (PEM format)"
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
minRows={4}
styles={{ input: { fontFamily: 'monospace' } }}
{...form.getInputProps('tls_key')}
<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
placeholder="-----BEGIN PRIVATE KEY-----..."
minRows={4}
styles={{ input: { fontFamily: 'monospace' } }}
{...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=Lato&display=swap');
@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');
:root{
--primary_color: #242a33;
--secondary_color: #1A1B1E;
--third_color:#25262b;
--fourth_color: #25262b99;
--primary_color: #161618;
--secondary_color: #0a0a0c;
--third_color: #1e1e20;
--fourth_color: #2c2c2f;
--accent-color: #00e5ff;
--accent-hover: #00b8cc;
--text-primary: #f5f5f5;
--text-secondary: #9aa0a6;
--mantine-color-body: var(--secondary_color) !important;
--mantine-font-family: 'Inter', sans-serif !important;
}
body {
margin: 0;
font-family: 'Lato', sans-serif;
font-family: 'Inter', sans-serif;
background-color: var(--secondary_color);
color: var(--text-primary);
}
.center-flex{
@ -132,19 +138,20 @@ body {
}
.firegex__porthijack__servicerow__name{
font-family: 'Hanken Grotesk', sans-serif;
font-size: 1.8em;
font-weight: bolder;
font-weight: 700;
margin-right: 10px;
margin-bottom: 13px;
color:#FFF;
color: var(--text-primary);
}
.firegex__porthijack__servicerow__portInput *{
color: #FFF;
color: var(--accent-color);
font-family: 'JetBrains Mono', monospace;
font-weight: bold;
font-size: 1em;
margin-top: -1px;
text-decoration: underline;
}
.firegex__nfregex__row{
@ -155,13 +162,21 @@ body {
display: flex;
justify-content: space-between;
align-items: center;
background-color: transparent;
border-radius: 12px;
margin-bottom: 10px;
transition: all 0.2s ease;
}
.firegex__nfregex__row:hover{
}
.firegex__nfregex__name{
font-family: 'Hanken Grotesk', sans-serif;
font-size: 1.6em;
font-weight: bolder;
font-weight: 700;
text-transform: capitalize;
color:#FFF;
color: var(--text-primary);
max-width: 300px;
overflow: hidden;
margin-left: -20px;
@ -175,12 +190,13 @@ body {
.firegex__navbar__unstyled_button {
display: block;
width: 100%;
padding: var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-dark-0);
padding: 12px 16px;
border-radius: 8px;
color: var(--text-secondary);
background-color: transparent;
opacity: 1;
font-weight: 500;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.firegex__navbar__unstyled_button.disabled {
@ -188,16 +204,20 @@ body {
}
.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 {
background-color: var(--mantine-color-dark-6);
background-color: var(--third_color);
color: var(--text-primary);
}
.firegex__footer{
margin-top: 50px;
background-color: var(--primary_color);
border-top: 1px solid var(--third_color);
display: flex;
justify-content: center;
align-items: center;
@ -227,9 +247,29 @@ body {
align-items: center;
justify-content: center;
display: flex;
background-color: var(--fourth_color);
border-radius: 10px;
border: #444 3px solid;
background-color: transparent;
border-radius: 8px;
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) {

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(
<QueryClientProvider client={queryClient}>
<MantineProvider defaultColorScheme='dark'>
<MantineProvider defaultColorScheme='dark' theme={myTheme}>
<CodeHighlightAdapterProvider adapter={customShikiAdapter}>
<Notifications />
<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 { errorNotify, okNotify } from '../../js/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>
<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" />
<Switch label="Allow loopback to communicate with itself" checked={settings.allow_loopback} onChange={v => setSettings({...settings, allow_loopback:v.target.checked})}/>
<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">
<Group align="right">
<Button loading={submitLoading} onClick={submitRequest}>Save Setting</Button>
</Group>
</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 { BsPlusLg, BsTrashFill } from "react-icons/bs"
import { rem } from '@mantine/core';
@ -25,196 +25,186 @@ import { PiWallLight } from "react-icons/pi";
export const Firewall = () => {
const [currentPolicy, setCurrentPolicy] = useState<ActionType>(ActionType.ACCEPT)
const queryClient = useQueryClient()
const rules = firewallRulesQuery()
const [state, handlers] = useListState<Rule & {rule_id:string}>([]);
const [enableFwModal, setEnableFwModal] = useState(false)
const [applyChangeModal, setApplyChangeModal] = useState(false)
const [settingsModal, setSettingsModal] = useState(false)
const theme = useMantineTheme();
const isMedium = useMediaQuery(`(min-width: 950px)`)??true
const isSmall = useMediaQuery(`(max-width: 600px)`)??false
const [currentPolicy, setCurrentPolicy] = useState<ActionType>(ActionType.ACCEPT)
const queryClient = useQueryClient()
const rules = firewallRulesQuery()
const [state, handlers] = useListState<Rule & { rule_id: string }>([]);
const [enableFwModal, setEnableFwModal] = useState(false)
const [applyChangeModal, setApplyChangeModal] = useState(false)
const [settingsModal, setSettingsModal] = useState(false)
const theme = useMantineTheme();
const isMedium = useMediaQuery(`(min-width: 950px)`) ?? true
const [updateMevalueinternal, internalUpdateme] = useState(false)
const updateMe = () => {
internalUpdateme(!updateMevalueinternal)
const [updateMevalueinternal, internalUpdateme] = useState(false)
const updateMe = () => {
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(()=> {
if(rules.isError)
errorNotify("Firewall Update failed!", getErrorMessage(rules.error))
},[rules.isError])
const fwEnabled = rules.data?.enabled ?? false
const valuesChanged = JSON.stringify(rules.data?.rules) != JSON.stringify(state.map(v => {
const { rule_id, ...rest } = v
return rest
})) || rules.data?.policy != currentPolicy
const [selectedTab, setSelectedTab] = useState<NFTables>(NFTables.FILTER)
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})))))
const enableFirewall = () => {
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 = () => {
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 valuesChanged = JSON.stringify(rules.data?.rules) != JSON.stringify(state.map(v => {
const {rule_id, ...rest} = v
return rest
})) || rules.data?.policy != currentPolicy
const [selectedTab, setSelectedTab] = useState<NFTables>(NFTables.FILTER)
const enableFirewall = () => {
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 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 applyChanges = () => {
if (fwEnabled && rules.data?.policy == ActionType.ACCEPT && [ActionType.REJECT, ActionType.DROP].includes(currentPolicy)){
setApplyChangeModal(true)
}else{
applyChangesRaw()
}
}
const proto_any = item.proto == Protocol.ANY
const disabletab = {
port_box: proto_any,
src_port: !srcPortEnabled || proto_any,
dst_port: !dstPortEnabled || proto_any
}
const condDiv = (val:React.ReactNode, cond:boolean) => {
if (cond)
return <Box>{val}</Box>
else
return val
}
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()
}
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%"}}>
return <Card
ref={provided.innerRef}
{...provided.draggableProps}
withBorder
radius="md"
p="md"
mb="md"
w="100%"
bg="transparent"
style={{ borderColor: 'var(--fourth_color)', transition: 'border-color 0.2s ease' }}
>
<Group wrap="nowrap" align="flex-start" gap="sm">
<Box {...provided.dragHandleProps} style={{ display: 'flex', alignItems: 'center', height: rem(36) }}>
<TbGripVertical style={{ width: rem(20), height: rem(20), color: 'var(--text-secondary)' }} />
</Box>
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Group wrap="nowrap" gap="sm">
<Switch
checked={item.active}
onChange={() =>{
onChange={() => {
item.active = !item.active
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>
<Space w="sm" />
<TextInput defaultValue={item.name} onChange={(v)=>{item.name = v.target.value;updateMe()}} style={{width:"100%"}}/>
</Box>
<Space h="sm" />
<Box className="center-flex" style={{width:"97%"}}>
<Box style={{width:"100%"}}>
<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 }} />
</Group>
<Group wrap={isMedium ? "nowrap" : "wrap"} align="flex-start" gap="md">
<Stack gap="xs" style={{ flex: 1, minWidth: isMedium ? 0 : "100%" }}>
<InterfaceInput
initialCustomInterfaces={[...src_custom_int, ...customInt]}
value={item.src}
onChange={v => ip_setter(item, v, {src:true})}
onChange={v => ip_setter(item, v, { src: true })}
includeInterfaceNames
/>
<Space h="sm" />
<Box className="center-flex" style={{width:"100%"}}>
<OnOffButton value={srcPortEnabled} onClick={() =>{
const value = !srcPortEnabled
setSrcPortEnabled(value)
if (!value){
item.port_src_from = 1
item.port_src_to = 65535
updateMe()
}else{
port_range_setter(item, srcPortValue, {src:true})
}
}} size="lg" disabled={disabletab.port_box} style={additionalStyle.port_box} variant="light" />
<Space w="xs" />
<PortRangeInput
onChange={v => port_range_setter(item, v.target.value, {src:true})}
value={srcPortValue}
disabled={disabletab.src_port}
error={!disabletab.src_port && !srcPortValue}
style={{width:"100%", ...additionalStyle.src_port}}
/>
</Box>
</Box>
<Space w="lg" />
<LuArrowBigRightDash size={100} />
<Space w="lg" />
<Box style={{width:"100%"}}>
<Group gap="xs" wrap="nowrap">
<OnOffButton value={srcPortEnabled} onClick={() => {
const value = !srcPortEnabled
setSrcPortEnabled(value)
if (!value) {
item.port_src_from = 1
item.port_src_to = 65535
updateMe()
} else {
port_range_setter(item, srcPortValue, { src: true })
}
}} size="lg" disabled={disabletab.port_box} variant="light" />
<PortRangeInput
onChange={v => port_range_setter(item, v.target.value, { src: true })}
value={srcPortValue}
disabled={disabletab.src_port}
error={!disabletab.src_port && !srcPortValue}
style={{ flex: 1 }}
/>
</Group>
</Stack>
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', alignSelf: isMedium ? 'stretch' : 'center', padding: isMedium ? '28px 0 0' : '4px 0' }}>
<LuArrowBigRightDash size={22} color="var(--text-secondary)" style={isMedium ? undefined : { transform: 'rotate(90deg)' }} />
</Box>
<Stack gap="xs" style={{ flex: 1, minWidth: isMedium ? 0 : "100%" }}>
<InterfaceInput
initialCustomInterfaces={[...dst_custom_int, ...customInt]}
defaultValue={item.dst}
onChange={v => ip_setter(item, v, {dst:true})}
onChange={v => ip_setter(item, v, { dst: true })}
includeInterfaceNames
/>
<Space h="sm" />
<Box className="center-flex" style={{width:"100%"}}>
<OnOffButton value={dstPortEnabled} onClick={() =>{
<Group gap="xs" wrap="nowrap">
<OnOffButton value={dstPortEnabled} onClick={() => {
const value = !dstPortEnabled
setDstPortEnabled(value)
if (!value){
if (!value) {
item.port_dst_from = 1
item.port_dst_to = 65535
updateMe()
}else{
port_range_setter(item, dstPortValue, {dst:true})
} else {
port_range_setter(item, dstPortValue, { dst: true })
}
}} size="lg" disabled={disabletab.port_box} style={additionalStyle.port_box} variant="light" />
<Space w="xs" />
}} size="lg" disabled={disabletab.port_box} variant="light" />
<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}
disabled={disabletab.dst_port}
error={!disabletab.dst_port && !dstPortValue}
style={{width:"100%", ...additionalStyle.dst_port}}
style={{ flex: 1 }}
/>
</Box>
</Box>
</Box>
</Box>
{!isMedium?<Space h="sm" />:null}
<Box className={isMedium?"center-flex-row":"center-flex"} style={isMedium?{}:{width:"100%", justifyContent:"space-around"}}>
{condDiv(<>{condDiv(<ModeSelector
value={item.mode}
onChange={(value)=>{item.mode = value as RuleMode;updateMe()}}
style={{width:"100%"}}
table={item.table}
/>, !isMedium)}
<Space h="xs" />
{condDiv(<ProtocolSelector
value={item.proto}
onChange={(value)=>{item.proto = value as Protocol;updateMe()}}
style={{width:"100%"}}
/>, !isMedium)}</>, isSmall)}
<Space h="xs" />
{condDiv(<ActionTypeSelector
value={item.action}
onChange={(value)=>{item.action = value as ActionType;updateMe()}}
style={{width:"100%"}}
/>, !isMedium)}
</Box>
</Box>
</Box>
<Space h="md" />
<Divider />
<Space h="md" />
</Box>
}}
</Draggable>
)).filter(v => v);
</Group>
</Stack>
</Group>
<Group gap="xs" wrap="wrap">
<ModeSelector
value={item.mode}
onChange={(value) => { item.mode = value as RuleMode; updateMe() }}
style={{ flexGrow: 1, minWidth: rem(160) }}
table={item.table}
/>
<ProtocolSelector
value={item.proto}
onChange={(value) => { item.proto = value as Protocol; updateMe() }}
style={{ flexGrow: 1, minWidth: rem(160) }}
/>
<ActionTypeSelector
value={item.action}
onChange={(value) => { item.action = value as ActionType; updateMe() }}
style={{ flexGrow: 1, minWidth: rem(160) }}
/>
</Group>
</Stack>
</Group>
</Card>
}}
</Draggable>
)).filter(v => v);
return <>
<Space h="sm" />
<LoadingOverlay visible={rules.isLoading} />
<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>
{isMedium?<Box className='flex-spacer' />:<Space h="sm" />}
<Box className='center-flex'>
Enabled: <Space w="sm" /> <Switch checked={fwEnabled} onChange={switchState} />
<Space w="sm" />
Policy:
<Space w="xs" />
<ActionTypeSelector
value={currentPolicy}
onChange={(value)=>setCurrentPolicy(value as ActionType)}
/>
</Box>
{isMedium?<Box className='flex-spacer' />:<Space h="sm" />}
<Box className='center-flex'>
<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>
<Space w="md" />
<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>
</Tooltip>
<Space w="xs" />
<Tooltip label="Refresh" position='bottom' color="indigo">
<ActionIcon color="indigo" onClick={()=>queryClient.invalidateQueries({ queryKey: ["firewall"] })} size="lg" radius="md" variant="filled"
loading={rules.isFetching}><TbReload size={18} /></ActionIcon>
</Tooltip>
<Space w="xs" />
<Tooltip label="Settings" position='bottom' color="cyan">
<ActionIcon color="cyan" onClick={()=>setSettingsModal(true)} size="lg" radius="md" variant="filled"><IoSettingsSharp size={18} /></ActionIcon>
</Tooltip>
<Space w="xs" />
<Tooltip label="Apply" position='bottom' color="grape">
<ActionIcon color="grape" onClick={applyChanges} size="lg" radius="md" variant="filled" disabled={!valuesChanged}>
<TiTick size={22} /></ActionIcon>
</Tooltip>
</Box>
</Box>
return <>
<Space h="sm" />
<LoadingOverlay visible={rules.isLoading} />
<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>
{isMedium ? <Box className='flex-spacer' /> : <Space h="sm" />}
<Box className='center-flex'>
Enabled: <Space w="sm" /> <Switch checked={fwEnabled} onChange={switchState} />
<Space w="sm" />
Policy:
<Space w="xs" />
<ActionTypeSelector
value={currentPolicy}
onChange={(value) => setCurrentPolicy(value as ActionType)}
/>
</Box>
{isMedium ? <Box className='flex-spacer' /> : <Space h="sm" />}
<Box className='center-flex'>
<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>
<Space w="md" />
<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>
</Tooltip>
<Space w="xs" />
<Tooltip label="Refresh" position='bottom' color="indigo">
<ActionIcon color="indigo" onClick={() => queryClient.invalidateQueries({ queryKey: ["firewall"] })} size="lg" radius="md" variant="filled"
loading={rules.isFetching}><TbReload size={18} /></ActionIcon>
</Tooltip>
<Space w="xs" />
<Tooltip label="Settings" position='bottom' color="cyan">
<ActionIcon color="cyan" onClick={() => setSettingsModal(true)} size="lg" radius="md" variant="filled"><IoSettingsSharp size={18} /></ActionIcon>
</Tooltip>
<Space w="xs" />
<Tooltip label="Apply" position='bottom' color="grape">
<ActionIcon color="grape" onClick={applyChanges} size="lg" radius="md" variant="filled" disabled={!valuesChanged}>
<TiTick size={22} /></ActionIcon>
</Tooltip>
</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" />
<Divider />
<Space h="md"/>
<Tabs variant="pills" value={selectedTab} onChange={(v)=>setSelectedTab(v==NFTables.MANGLE?NFTables.MANGLE:NFTables.FILTER)} style={{ display:"flex", justifyContent:"center", alignItems:"center"}}>
<Box mr="md">Filtering Table:</Box>
<Tabs.List>
<Tabs.Tab value={NFTables.FILTER}>
Filter
</Tabs.Tab>
<Tabs.Tab value={NFTables.MANGLE}>
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>
<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>
</>}
<YesNoModal
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! ⚠️`}
onClose={()=>setEnableFwModal(false) }
action={enableFirewall}
opened={enableFwModal}
/>
<YesNoModal
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! ⚠️`}
onClose={() => setEnableFwModal(false)}
action={enableFirewall}
opened={enableFwModal}
/>
<YesNoModal
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! ⚠️`}
onClose={()=>setApplyChangeModal(false) }
action={applyChangesRaw}
opened={applyChangeModal}
/>
<YesNoModal
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! ⚠️`}
onClose={() => setApplyChangeModal(false)}
action={applyChangesRaw}
opened={applyChangeModal}
/>
<SettingsModal
opened={settingsModal}
onClose={()=>setSettingsModal(false)}
/>
<SettingsModal
opened={settingsModal}
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 { Badge, Divider, Menu } from '@mantine/core';
import { useEffect, useState } from 'react';
@ -16,7 +16,7 @@ import { FaArrowLeft } from "react-icons/fa";
import { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../../components/NFProxy/AddEditService';
import PyFilterView from '../../components/PyFilterView';
import { TbPlugConnected, TbShieldLock } from 'react-icons/tb';
import { TbPlugConnected } from 'react-icons/tb';
import { CodeHighlight } from '@mantine/code-highlight';
import { FaPython } from "react-icons/fa";
import { FiFileText } from "react-icons/fi";
@ -24,36 +24,35 @@ import { ModalLog } from '../../components/ModalLog';
import { useListState } from '@mantine/hooks';
import { ExceptionWarning } from '../../components/NFProxy/ExceptionWarning';
import { DocsButton } from '../../components/DocsButton';
import TLSConfigModal from '../../components/TLSConfigModal';
import TLSAssociationBadge from '../../components/TLSAssociationBadge';
export default function ServiceDetailsNFProxy() {
const {srv} = useParams()
const { srv } = useParams()
const services = nfproxyServiceQuery()
const serviceInfo = services.data?.find(s => s.service_id == srv)
const filtersList = nfproxyServicePyfiltersQuery(srv??"")
const filtersList = nfproxyServicePyfiltersQuery(srv ?? "")
const [deleteModal, setDeleteModal] = useState(false)
const [renameModal, setRenameModal] = useState(false)
const [editModal, setEditModal] = useState(false)
const [tlsModal, setTlsModal] = useState(false)
const [buttonLoading, setButtonLoading] = useState(false)
const queryClient = useQueryClient()
const filterCode = nfproxyServiceFilterCodeQuery(srv??"")
const filterCode = nfproxyServiceFilterCodeQuery(srv ?? "")
const navigate = useNavigate()
const isMedium = isMediumScreen()
const [openLogModal, setOpenLogModal] = useState(false)
const [logData, logDataSetters] = useListState<string>([]);
useEffect(()=>{
if (srv){
if (openLogModal){
useEffect(() => {
if (srv) {
if (openLogModal) {
logDataSetters.setState([])
socketio.emit("nfproxy-outstream-join", { service: srv });
socketio.on(`nfproxy-outstream-${srv}`, (data) => {
logDataSetters.append(data)
});
}else{
} else {
socketio.emit("nfproxy-outstream-leave", { service: srv });
socketio.off(`nfproxy-outstream-${srv}`);
logDataSetters.setState([])
@ -70,7 +69,7 @@ export default function ServiceDetailsNFProxy() {
if (!srv || !serviceInfo || filtersList.isError) return <Navigate to="/" replace />
let status_color = "gray";
switch(serviceInfo.status){
switch (serviceInfo.status) {
case "stop": status_color = "red"; break;
case "active": status_color = "teal"; break;
}
@ -78,174 +77,168 @@ export default function ServiceDetailsNFProxy() {
const startService = async () => {
setButtonLoading(true)
await nfproxy.servicestart(serviceInfo.service_id).then(res => {
if(!res){
okNotify(`Service ${serviceInfo.name} started successfully!`,`The service on ${serviceInfo.port} has been started!`)
if (!res) {
okNotify(`Service ${serviceInfo.name} started successfully!`, `The service on ${serviceInfo.port} has been started!`)
queryClient.invalidateQueries({ queryKey: serviceQueryKey })
}else{
errorNotify(`An error as occurred during the starting of the service ${serviceInfo.port}`,`Error: ${res}`)
} else {
errorNotify(`An error as occurred during the starting of the service ${serviceInfo.port}`, `Error: ${res}`)
}
}).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)
}
const deleteService = () => {
nfproxy.servicedelete(serviceInfo.service_id).then(res => {
if (!res){
okNotify("Service delete complete!",`The service ${serviceInfo.name} has been deleted!`)
if (!res) {
okNotify("Service delete complete!", `The service ${serviceInfo.name} has been deleted!`)
queryClient.invalidateQueries({ queryKey: serviceQueryKey })
}else
errorNotify("An error occurred while deleting a service",`Error: ${res}`)
} else
errorNotify("An error occurred while deleting a service", `Error: ${res}`)
}).catch(err => {
errorNotify("An error occurred while deleting a service",`Error: ${err}`)
errorNotify("An error occurred while deleting a service", `Error: ${err}`)
})
}
const stopService = async () => {
setButtonLoading(true)
await nfproxy.servicestop(serviceInfo.service_id).then(res => {
if(!res){
okNotify(`Service ${serviceInfo.name} stopped successfully!`,`The service on ${serviceInfo.port} has been stopped!`)
if (!res) {
okNotify(`Service ${serviceInfo.name} stopped successfully!`, `The service on ${serviceInfo.port} has been stopped!`)
queryClient.invalidateQueries({ queryKey: serviceQueryKey })
}else{
errorNotify(`An error as occurred during the stopping of the service ${serviceInfo.port}`,`Error: ${res}`)
} else {
errorNotify(`An error as occurred during the stopping of the service ${serviceInfo.port}`, `Error: ${res}`)
}
}).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);
}
return <>
<Space h="sm" />
<LoadingOverlay visible={filtersList.isLoading} />
<Box className={isMedium?'center-flex':'center-flex-row'} style={{ justifyContent: "space-between"}} px="md" mt="lg">
<Box>
<Title order={1}>
<Box className="center-flex">
<MdDoubleArrow /><Space w="sm" />{serviceInfo.name}
</Box>
</Title>
</Box>
{isMedium?null:<Space h="md" />}
<Box className='center-flex'>
<ExceptionWarning service_id={srv} />
<Space w="sm" />
<Badge color={status_color} radius="md" size="xl" variant="filled" mr="sm">
{serviceInfo.status}
</Badge>
<Badge size="xl" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" mr="sm">
:{serviceInfo.port}
</Badge>
{serviceInfo.tls_enabled && (
<Badge color="green" size="xl" variant="light" radius="md" mr="sm">
<TbShieldLock size={16} style={{ marginRight: 6, verticalAlign: 'middle' }} />
TLS (lo:{serviceInfo.clear_port})
</Badge>
)}
<MenuDropDownWithButton>
<Menu.Item><b>Edit service</b></Menu.Item>
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
<Menu.Item leftSection={<TbShieldLock size={18} />} onClick={()=>setTlsModal(true)}>Configure TLS</Menu.Item>
<Divider />
<Menu.Label><b>Danger zone</b></Menu.Label>
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
</MenuDropDownWithButton>
<Space w="md"/>
<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>
<Card
withBorder
shadow="md"
radius="md"
p="xl"
mb="xl"
bg="var(--third_color)"
style={{ borderColor: 'var(--fourth_color)', overflow: "visible" }} // Allows dropdowns to overflow outside
>
<Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"} gap="xl">
<Box>
<Group gap="xs" wrap="nowrap" align="center">
<Tooltip label="Go back" position="top">
<ActionIcon color="cyan" variant="subtle" radius="md" onClick={() => navigate("/nfproxy")}>
<FaArrowLeft size={16} />
</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">
<ExceptionWarning service_id={srv} />
<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>
{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" />
{filterCode.data?<>
<Title order={3} style={{textAlign:"center"}} className="center-flex"><FaPython style={{ marginBottom: -3 }} size={30} /><Space w="xs" />Filter code</Title>
<CodeHighlight code={filterCode.data} language="python" mt="lg" />
</>: null}
<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>
{(!filtersList.data || filtersList.data.length == 0)?<>
<Space h="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>
<Space h="xs" />
<Title className='center-flex' style={{textAlign:"center"}} order={3}>Read the documentation for more information<Space w="sm" /><DocsButton doc='nfproxy'/></Title>
<Space h="xs" />
<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>
</>:<>{filtersList.data?.map( (filterInfo) => <PyFilterView filterInfo={filterInfo} key={filterInfo.name}/>)}</>
<Group gap="sm" justify="flex-end" style={{ flexGrow: 1 }}>
<Tooltip label={serviceInfo.status === "stop" ? "Cannot stop" : "Stop service"} position="bottom" color="red">
<ActionIcon color="red" loading={buttonLoading} onClick={stopService} size="xl" radius="md" variant="light" disabled={serviceInfo.status === "stop"}>
<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>
{filterCode.data ? <>
<Group justify="center" gap="xs" mt="xl">
<ThemeIcon radius="md" size="md" variant="light" color="yellow"><FaPython size={16} /></ThemeIcon>
<Title order={3}>Filter code</Title>
</Group>
<Card withBorder radius="md" p={0} bg="transparent" style={{ borderColor: 'var(--fourth_color)', marginTop: '20px' }}>
<CodeHighlight code={filterCode.data} language="python" style={{ backgroundColor: 'transparent' }} />
</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
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! ⚠️`}
onClose={()=>setDeleteModal(false) }
onClose={() => setDeleteModal(false)}
action={deleteService}
opened={deleteModal}
/>
<RenameForm
onClose={()=>setRenameModal(false)}
onClose={() => setRenameModal(false)}
opened={renameModal}
service={serviceInfo}
/>
<AddEditService
opened={editModal}
onClose={()=>setEditModal(false)}
onClose={() => setEditModal(false)}
edit={serviceInfo}
/>
<TLSConfigModal
opened={tlsModal}
onClose={() => setTlsModal(false)}
service={serviceInfo}
updateTlsConfig={nfproxy.updatetlsconfig}
queryKey={serviceQueryKey}
/>
<ModalLog
opened={openLogModal}
close={()=>setOpenLogModal(false)}
close={() => setOpenLogModal(false)}
title={`Logs for service ${serviceInfo.name}`}
data={logData.join("")}
/>

View File

@ -6,12 +6,12 @@ import ServiceRow from '../../components/NFProxy/ServiceRow';
import { errorNotify, getErrorMessage, isMediumScreen } from '../../js/utils';
import AddEditService from '../../components/NFProxy/AddEditService';
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 { FaFilter, FaPencilAlt, FaServer } from 'react-icons/fa';
import { MdUploadFile } from "react-icons/md";
import { notifications } from '@mantine/notifications';
import { useFileDialog } from '@mantine/hooks';
import { EditFilterModal } from '../../components/NFProxy/EditFilterModal';
import { CodeHighlight } from '@mantine/code-highlight';
import { DocsButton } from '../../components/DocsButton';
@ -24,69 +24,8 @@ export default function NFProxy({ children }: { children: any }) {
const queryClient = useQueryClient()
const isMedium = isMediumScreen()
const services = nfproxyServiceQuery()
const fileDialog = useFileDialog({
accept: ".py",
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])
const [editorOpen, setEditorOpen] = useState(false);
// File upload logic has been moved to EditFilterModal
useEffect(()=> {
if(services.isError)
@ -103,21 +42,21 @@ export default function NFProxy({ children }: { children: any }) {
<Box className='center-flex' >
{isMedium?"General stats:":null}
<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" />
<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" />
<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" />
<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" />
</Box>
{isMedium?null:<Space h="md" />}
<Box className='center-flex' >
{ srv?
<Tooltip label="Upload a new filter code" position='bottom' color="blue">
<ActionIcon color="blue" size="lg" radius="md" variant="filled" onClick={fileDialog.open}>
<MdUploadFile size={18} />
<Tooltip label="Edit python filter code" position='bottom' color="blue">
<ActionIcon color="blue" size="lg" radius="md" variant="filled" onClick={() => setEditorOpen(true)}>
<TbCode size={18} />
</ActionIcon>
</Tooltip>
: <Tooltip label="Add a new service" position='bottom' color="blue">
@ -136,8 +75,8 @@ export default function NFProxy({ children }: { children: any }) {
<DocsButton doc="nfproxy" />
</Box>
</Box>
<Space h="md" />
<Box className="center-flex-row" style={{gap: 20}}>
<Space h="xl" />
<Box className="center-flex-row" style={{ gap: 12, width: "100%" }}>
{srv?null:<>
<LoadingOverlay visible={services.isLoading} />
{(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?
<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 RegexView from '../../components/RegexView';
import AddNewRegex from '../../components/AddNewRegex';
@ -8,9 +8,9 @@ import { Badge, Divider, Menu } from '@mantine/core';
import { useState } from 'react';
import { FaFilter, FaPlay, FaStop } from 'react-icons/fa';
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 { 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 { BiRename } from 'react-icons/bi'
import RenameForm from '../../components/NFRegex/ServiceRow/RenameForm';
@ -20,6 +20,7 @@ import { FaArrowLeft } from "react-icons/fa";
import { VscRegex } from 'react-icons/vsc';
import { IoSettingsSharp } from 'react-icons/io5';
import AddEditService from '../../components/NFRegex/AddEditService';
import TLSAssociationBadge from '../../components/TLSAssociationBadge';
export default function ServiceDetailsNFRegex() {
@ -89,88 +90,132 @@ export default function ServiceDetailsNFRegex() {
}
return <>
<Space h="sm" />
<LoadingOverlay visible={regexesList.isLoading} />
<Box className={isMedium?'center-flex':'center-flex-row'} style={{ justifyContent: "space-between"}} px="md" mt="lg">
<Box>
<Title order={1}>
<Box className="center-flex">
<MdDoubleArrow /><Space w="sm" />{serviceInfo.name}
</Box>
</Title>
</Box>
{isMedium?null:<Space h="md" />}
<Box className='center-flex'>
<Badge color={status_color} radius="md" size="xl" variant="filled" mr="sm">
{serviceInfo.status}
</Badge>
<Badge size="xl" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" mr="sm">
:{serviceInfo.port}
</Badge>
<Card
withBorder
shadow="md"
radius="md"
p="xl"
mb="xl"
bg="var(--third_color)"
style={{ borderColor: 'var(--fourth_color)', overflow: "visible" }} // Allows dropdowns to overflow outside
>
<Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"} gap="xl">
<Box>
<Group gap="xs" wrap="nowrap" align="center">
<Tooltip label="Go back" position="top">
<ActionIcon color="cyan" variant="subtle" radius="md" onClick={() => navigate("/nfregex")}>
<FaArrowLeft size={16} />
</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>
<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.n_packets}
</Badge>
<Badge color="violet" radius="sm" size="lg" variant="light" leftSection={<VscRegex size={12} style={{ marginTop: 2 }} />}>
{serviceInfo.n_regex}
</Badge>
</Group>
<MenuDropDownWithButton>
<Menu.Item><b>Edit service</b></Menu.Item>
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
<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>
</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="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2}} /> {serviceInfo.n_packets}</Badge>
<Space w="xs" />
<Badge color="violet" radius="sm" size="md" variant="filled"><VscRegex style={{ marginBottom: -2}} size={13} /> {serviceInfo.n_regex}</Badge>
<Group gap="sm" justify="flex-end" style={{ flexGrow: 1 }}>
<Tooltip label={serviceInfo.status === "stop" ? "Cannot stop" : "Stop service"} position="bottom" color="red">
<ActionIcon color="red" loading={buttonLoading} onClick={stopService} size="xl" radius="md" variant="light" disabled={serviceInfo.status === "stop"}>
<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>
<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>
{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>
</Group>
</Card>
{(!regexesList.data || regexesList.data.length == 0) ? (
<Stack align="center" gap="xl" py="xl">
<Title order={3} ta="center">No regex found for this service! Add one by clicking the "+" buttons</Title>
<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>
<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" />
{(!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>
</Stack>
) : (
<Grid mt="xs">
{regexesList.data?.map( (regexInfo) => <Grid.Col key={regexInfo.id} span={{ lg:6, xs: 12 }}><RegexView regexInfo={regexInfo} /></Grid.Col>)}
</Grid>
}
)}
{srv?<AddNewRegex opened={open} onClose={() => {setOpen(false);}} service={srv} />:null}
<YesNoModal

View File

@ -38,11 +38,11 @@ function NFRegex({ children }: { children: any }) {
<Box className='center-flex' >
{isMedium?"General stats:":null}
<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" />
<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" />
<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" />
</Box>
{isMedium?null:<Space h="md" />}
@ -64,8 +64,8 @@ function NFRegex({ children }: { children: any }) {
<DocsButton doc="nfregex" />
</Box>
</Box>
<Space h="md" />
<Box className="center-flex-row" style={{gap: 20}}>
<Space h="xl" />
<Box className="center-flex-row" style={{ gap: 12, width: "100%" }}>
{srv?null:<>
<LoadingOverlay visible={services.isLoading} />
{(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" />
</Box>
</Box>
<Space h="md" />
<Box className="center-flex-row" style={{gap: 20}}>
<Space h="xl" />
<Box className="center-flex-row" style={{ gap: 12, width: "100%" }}>
<LoadingOverlay visible={services.isLoading} />
{(services.data && services.data.length > 0) ?services.data.map( srv => <ServiceRow service={srv} key={srv.service_id} />):<>
<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 viteTsconfigPaths from 'vite-tsconfig-paths';
import svgrPlugin from 'vite-plugin-svgr';
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), viteTsconfigPaths(), svgrPlugin()],
plugins: [react(), svgrPlugin()],
resolve: {
tsconfigPaths: true,
},
})

87
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('--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('--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)
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()
# Use config values as fallback, but allow command line to override
if "port" not in args or args.port < 1:
args.port = config["port"]
if "host" not in args:
args.host = config["host"]
if "socket_dir" not in args:
args.socket_dir = config["socket_dir"]
if "command" in args and args.command != "config":
if "port" not in args or args.port is None or args.port < 1:
args.port = config["port"]
if "host" not in args or args.host is None:
args.host = config["host"]
if "socket_dir" not in args or args.socket_dir is None:
args.socket_dir = config["socket_dir"]
# Save configuration if values were specified via command line and differ from config
config_changed = False
@ -873,12 +875,12 @@ def run_standalone():
puts("Starting as daemon...", color=colors.green)
try:
# Start process in background
out_file = open('/tmp/app.log', 'w')
process = subprocess.Popen(
chroot_cmd,
shell=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdout=out_file,
stderr=out_file,
stdin=subprocess.DEVNULL,
preexec_fn=os.setsid # Create new session
)
@ -963,6 +965,69 @@ def handle_config_command(args):
config_changed = True
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 save_config(config):
puts(f"Configuration saved to {g.configfile}", color=colors.green)

View File

@ -121,6 +121,28 @@ if __name__ == "__main__":
)
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)
for interface in firegex.get_interfaces():
puts(

View File

@ -2,6 +2,7 @@
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
@ -42,6 +43,9 @@ if __name__ == "__main__":
parser.add_argument(
"--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()
sep()
@ -458,14 +462,14 @@ if __name__ == "__main__":
X-TeSt: {secret.decode()}
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
Host: localhost
X-TeSt: NotTheSecret
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"""
from firegex.nfproxy.models import HttpRequest
@ -624,14 +628,14 @@ if __name__ == "__main__":
X-TeSt: {secret.decode()}
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
Host: localhost
X-TeSt: NotTheSecret
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"""
from firegex.nfproxy.models import HttpResponse
@ -856,9 +860,9 @@ if __name__ == "__main__":
puts("Test Failed: Coulnd't rename service ✗", color=colors.red)
exit_test(1)
# Change settings
# Change settings, including proto (exercises the proto -> l4_proto conversion path)
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)
if (
@ -866,6 +870,7 @@ if __name__ == "__main__":
and ("::dead:beef" if args.ipv6 else "123.123.123.123")
in srv_updated["ip_int"]
and srv_updated["fail_open"]
and srv_updated["proto"] == "tcp"
):
puts("Sucessfully changed service settings ✔", color=colors.green)
else:
@ -878,4 +883,126 @@ if __name__ == "__main__":
puts("Test Failed: Coulnd't change service settings ✗", color=colors.red)
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)

View File

@ -3,6 +3,7 @@ from utils.colors import colors, puts, sep
from utils.firegexapi import FiregexAPI
from utils.tcpserver import TcpServer
from utils.udpserver import UdpServer
from utils.tls_helpers import generate_self_signed_cert_key, tls_connect_send_recv
import argparse
import secrets
import base64
@ -49,14 +50,25 @@ if __name__ == "__main__":
help="Select the protocol",
default="tcp",
)
parser.add_argument(
"--tls", "-t", action="store_true", help="Test TLS Decrypt", default=False
)
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()
puts("Testing will start on ", color=colors.cyan, end="")
puts(f"{args.address}", color=colors.yellow)
firegex = FiregexAPI(args.address)
status = firegex.status()
if status.get("status") == "init":
firegex.set_password(args.password)
# Login
if firegex.login(args.password):
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)
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)(
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):
if service_id:
server.stop()
@ -76,7 +94,14 @@ if __name__ == "__main__":
puts("Sucessfully deleted service ✔", color=colors.green)
else:
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)
srvs = firegex.nfregex_get_services()
@ -84,8 +109,33 @@ if __name__ == "__main__":
if ele["name"] == args.service_name:
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(
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:
puts(f"Sucessfully created service {service_id} ✔", color=colors.green)
@ -101,8 +151,18 @@ if __name__ == "__main__":
server.start()
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:
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)
else:
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)
exit_test(1)
# Check if regex is present in the service
n_blocked = 0
@ -136,7 +198,7 @@ if __name__ == "__main__":
if r["regex"] == secret:
# Test the 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)
):
puts(
@ -191,7 +253,7 @@ if __name__ == "__main__":
puts("Test Failed: The regex wasn't found ✗", color=colors.red)
exit_test(1)
else:
if server.sendCheckData(
if send_check(
secrets.token_bytes(40)
+ base64.b64decode(regex)
+ secrets.token_bytes(40)
@ -342,32 +404,74 @@ if __name__ == "__main__":
puts("Test Failed: Coulnd't rename service ✗", color=colors.red)
exit_test(1)
# Change settings
opposite_proto = "udp" if args.proto == "tcp" else "tcp"
if firegex.nfregex_settings_service(
service_id,
1338,
opposite_proto,
"::dead:beef" if args.ipv6 else "123.123.123.123",
True,
):
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"]
# Change settings (skipped in --tls mode: the service's own port/ip/proto no longer
# drive traffic interception once linked to a TLS stream, see resolve_target())
if not args.tls:
opposite_proto = "udp" if args.proto == "tcp" else "tcp"
if firegex.nfregex_settings_service(
service_id,
1338,
"::dead:beef" if args.ipv6 else "123.123.123.123",
True,
proto=opposite_proto,
):
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:
puts(
"Test Failed: Service settings weren't updated correctly ✗",
color=colors.red,
)
puts("Test Failed: Coulnd't change service settings ✗", color=colors.red)
exit_test(1)
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)
else:
puts("Test Failed: Coulnd't change service settings ✗", color=colors.red)
exit_test(1)
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
)
service_id = None
def exit_test(code):
if service_id:
server.stop()
@ -75,7 +76,7 @@ if __name__ == "__main__":
puts("Sucessfully deleted service ✔", color=colors.green)
else:
puts("Test Failed: Coulnd't delete serivce ✗", color=colors.red)
exit_test(1)
exit(1)
exit(code)
srvs = firegex.ph_get_services()

View File

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

View File

@ -3,21 +3,30 @@
cd "$(dirname "$0")"
TMP=$1
PASSWORD=${TMP:=testpassword}
if [[ "$TMP" == "--set-pass" ]]; then
PASSWORD="testpassword"
else
PASSWORD=${TMP:-testpassword}
fi
ERROR=0
pip3 install -r requirements.txt
echo "Running Nginx config unit test"
python3 nginx_test.py || ERROR=1
until curl --output /dev/null --silent --fail http://127.0.0.1:4444/api/status; do
printf '.'
sleep 1
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"
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"
python3 nfregex_test.py -p $PASSWORD -m tcp || ERROR=1
echo "Running Netfilter Regex TCP ipv6"
@ -38,6 +47,15 @@ echo "Running Netfilter Proxy ipv4"
python3 nfproxy_test.py -p $PASSWORD || ERROR=1
echo "Running Netfilter Proxy ipv6"
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

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):
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):
req = self.s.get(f"{self.address}api/nfregex/services")
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})
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):
req = self.s.get(f"{self.address}api/nfregex/services/{service_id}/regexes")
data = req.json()
@ -131,17 +135,24 @@ class FiregexAPI:
req = self.s.post(f"{self.address}api/nfregex/regexes/{regex_id}/disable")
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):
regex = regex.encode()
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})
return verify(req)
def nfregex_add_service(self, name: str, port: int, proto: str, ip_int: str, fail_open: bool = False):
req = self.s.post(f"{self.address}api/nfregex/services" ,
json={"name":name,"port":port, "proto": proto, "ip_int": ip_int, "fail_open": fail_open})
return req.json()["service_id"] if verify(req) else 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):
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}
req = self.s.post(f"{self.address}api/nfregex/services", json=payload)
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):
req = self.s.get(f"{self.address}api/nfregex/metrics")
@ -205,8 +216,11 @@ class FiregexAPI:
req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/rename" , json={"name":newname})
return verify(req)
def nfproxy_settings_service(self,service_id: str, port: int, ip_int: str, fail_open: bool):
req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/settings" , json={"port":port, "ip_int":ip_int, "fail_open":fail_open})
def nfproxy_settings_service(self,service_id: str, port: int, ip_int: str, fail_open: bool, proto: str | None = None):
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)
def nfproxy_get_service_pyfilters(self,service_id: str):
@ -225,10 +239,10 @@ class FiregexAPI:
req = self.s.post(f"{self.address}api/nfproxy/services/{service_id}/pyfilters/{filter_name}/disable")
return verify(req)
def nfproxy_add_service(self, name: str, port: int, proto: str, ip_int: str, fail_open: bool = False):
req = self.s.post(f"{self.address}api/nfproxy/services" ,
json={"name":name,"port":port, "proto": proto, "ip_int": ip_int, "fail_open": fail_open})
return req.json()["service_id"] if verify(req) else 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):
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}
req = self.s.post(f"{self.address}api/nfproxy/services", json=payload)
return req.json()["service_id"] if verify(req) else False
def nfproxy_get_code(self, service_id: str):
req = self.s.get(f"{self.address}api/nfproxy/services/{service_id}/code")
@ -236,4 +250,37 @@ class FiregexAPI:
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})
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
from multiprocessing import Process, Queue
import socket
import ssl
import tempfile
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(
socket.AF_INET6 if ipv6 else socket.AF_INET, socket.SOCK_STREAM
)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("::1" if ipv6 else "127.0.0.1", port))
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:
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:
try:
buf = connection.recv(4096)
@ -40,26 +59,35 @@ def _start_tcp_server(port, server_queue: Queue, ipv6, verbose):
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.ipv6 = ipv6
self.port = port
self.verbose = verbose
self.tls_cert = tls_cert
self.tls_key = tls_key
self._server_data_queue = Queue()
self._regen_process()
def _regen_process(self):
self.server = Process(
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):
self.server.start()
def stop(self):
self.server.terminate()
self.server.join()
if self.server and self.server.is_alive():
try:
self.server.terminate()
self.server.join()
import time
time.sleep(0.1)
except AttributeError:
pass
self.server = None
self._regen_process()
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()
def stop(self):
self.server.terminate()
self.server.join()
if self.server:
try:
self.server.terminate()
self.server.join()
import time
time.sleep(0.1)
except AttributeError:
pass
self.server = None
self._regen_process()
def connect_client(self):