Merge pull request #33 from Sogoss/feat/tls-support

Add transparent TLS proxying and certificate management for NFProxy and NFRegex
This commit is contained in:
Domingo Dirutigliano 2026-07-23 13:43:16 +02:00 committed by GitHub
commit 7881da7128
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 4277 additions and 1627 deletions

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

87
CLAUDE.md Normal file
View File

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

View File

@ -16,7 +16,7 @@ RUN bun run build
FROM --platform=$TARGETARCH quay.io/fedora/fedora:43 AS base FROM --platform=$TARGETARCH quay.io/fedora/fedora:43 AS base
RUN dnf -y update && dnf install -y python3.14 libnetfilter_queue \ RUN dnf -y update && dnf install -y python3.14 libnetfilter_queue \
libnfnetlink libmnl libcap-ng-utils nftables \ libnfnetlink libmnl libcap-ng-utils nftables \
vectorscan libtins python3-nftables libpcap && dnf clean all vectorscan libtins python3-nftables libpcap nginx nginx-mod-stream && dnf clean all
RUN mkdir -p /execute/modules RUN mkdir -p /execute/modules
WORKDIR /execute WORKDIR /execute

View File

@ -4,6 +4,7 @@ import utils
import os import os
import asyncio import asyncio
import logging import logging
import base64
from fastapi import FastAPI, HTTPException, Depends, APIRouter from fastapi import FastAPI, HTTPException, Depends, APIRouter
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import jwt from jose import jwt
@ -218,6 +219,62 @@ async def reset_firegex(form: ResetRequest):
await refresh_frontend() await refresh_frontend()
return {'status': 'ok'} return {'status': 'ok'}
@api.get('/export')
async def export_db():
"""Export all configuration databases as JSON"""
dbs = {}
if not os.path.exists('db'):
return dbs
for f in os.listdir('db'):
if f.endswith('.db'):
temp_db = SQLite(os.path.join('db', f))
dbs[f] = temp_db.dump()
# Export nfproxy filters
if os.path.exists('db/nfproxy_filters'):
dbs['nfproxy_filters'] = {}
for f in os.listdir('db/nfproxy_filters'):
if f.endswith('.py'):
with open(os.path.join('db/nfproxy_filters', f), 'rb') as script_file:
dbs['nfproxy_filters'][f] = base64.b64encode(script_file.read()).decode('utf-8')
return dbs
@api.post('/import', response_model=StatusMessageModel)
async def import_db(data: dict):
"""Import all configuration databases from JSON"""
if not os.path.exists('db'):
os.makedirs('db')
# Backups never contain the password/secret (export_db strips them), so preserve
# the current session's own values instead of losing them on import.
current_password = db.get("password")
current_secret = db.get("secret")
for db_file, db_data in data.items():
if db_file.endswith('.db'):
temp_db = SQLite(os.path.join('db', db_file))
# Just load the data, we assume schemas exist or will be matched
# We don't delete the whole file to preserve DB_VERSION and schemas
temp_db.load(db_data)
elif db_file == 'nfproxy_filters':
if not os.path.exists('db/nfproxy_filters'):
os.makedirs('db/nfproxy_filters')
for f, script_content in db_data.items():
if f.endswith('.py'):
with open(os.path.join('db/nfproxy_filters', f), 'wb') as script_file:
script_file.write(base64.b64decode(script_content))
if current_password is not None:
db.put("password", current_password)
if current_secret is not None:
db.put("secret", current_secret)
# Restart the application state
await shutdown_main()
await startup_main()
return {'status': 'ok'}
app.include_router(api) app.include_router(api)
frontend_deploy(app) frontend_deploy(app)

View File

@ -451,6 +451,7 @@ class NfQueue {
// error code taken from the linux kernel: // error code taken from the linux kernel:
// https://elixir.bootlin.com/linux/v5.18.12/source/include/linux/errno.h#L27 // https://elixir.bootlin.com/linux/v5.18.12/source/include/linux/errno.h#L27
#define ENOTSUPP 524 /* Operation is not supported */ #define ENOTSUPP 524 /* Operation is not supported */
#define EOPNOTSUPP 95 /* Operation not supported on transport endpoint */
if (error_msg->error != -ENOTSUPP && error_msg->error != -EOPNOTSUPP) { if (error_msg->error != -ENOTSUPP && error_msg->error != -EOPNOTSUPP) {
_clear(); _clear();

View File

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

View File

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

View File

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

View File

@ -9,6 +9,15 @@ if [ -n "$SOCKET_DIR" ]; then
chmod 755 "$SOCKET_DIR" chmod 755 "$SOCKET_DIR"
fi fi
# Set host sysctls while running as root.
if [ -d "/sys_host" ]; then
echo "[*] Setting host sysctls..."
echo 1 > /sys_host/net.ipv4.conf.all.forwarding 2>/dev/null || true
echo 1 > /sys_host/net.ipv6.conf.all.forwarding 2>/dev/null || true
echo 1 > /sys_host/net.ipv4.conf.all.route_localnet 2>/dev/null || true
echo 1 > /sys_host/net.ipv4.ip_forward 2>/dev/null || true
fi
echo "[*] Attempting to start with capabilities..." echo "[*] Attempting to start with capabilities..."
if capsh --caps="cap_net_admin,cap_setpcap,cap_setuid,cap_setgid,cap_sys_nice+eip" \ if capsh --caps="cap_net_admin,cap_setpcap,cap_setuid,cap_setgid,cap_sys_nice+eip" \
@ -21,7 +30,8 @@ then
--keep=1 \ --keep=1 \
--user=nobody \ --user=nobody \
--addamb=cap_net_admin,cap_sys_nice \ --addamb=cap_net_admin,cap_sys_nice \
-- -c "python3 /execute/app.py DOCKER" --shell=/usr/bin/python3 \
-- /execute/app.py DOCKER
else else
echo "[!] capsh failed, running with root user" echo "[!] capsh failed, running with root user"
exec python3 /execute/app.py DOCKER exec python3 /execute/app.py DOCKER

View File

@ -6,7 +6,6 @@ import traceback
from fastapi import HTTPException from fastapi import HTTPException
import time import time
from utils import run_func from utils import run_func
from utils import DEBUG
from utils import nicenessify from utils import nicenessify
nft = FiregexTables() nft = FiregexTables()
@ -65,8 +64,6 @@ class FiregexInterceptor:
while True: while True:
try: try:
out_data = (await self.process.stdout.read(1024*10)).decode(errors="ignore") out_data = (await self.process.stdout.read(1024*10)).decode(errors="ignore")
if DEBUG:
print(out_data, end="")
except asyncio.exceptions.LimitOverrunError: except asyncio.exceptions.LimitOverrunError:
self.outstrem_buffer = "" self.outstrem_buffer = ""
continue continue
@ -90,11 +87,11 @@ class FiregexInterceptor:
proxy_binary_path, stdin=asyncio.subprocess.DEVNULL, proxy_binary_path, stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, stderr=asyncio.subprocess.STDOUT,
env={ env=dict(os.environ, **{
"NTHREADS": os.getenv("NTHREADS","1"), "NTHREADS": os.getenv("NTHREADS","1"),
"FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0", "FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0",
"FIREGEX_NFPROXY_SOCK": self.sock_path "FIREGEX_NFPROXY_SOCK": self.sock_path
}, }),
) )
nicenessify(-10, self.process.pid) nicenessify(-10, self.process.pid)
self.outstrem_task = asyncio.create_task(self._stream_handler()) self.outstrem_task = asyncio.create_task(self._stream_handler())
@ -137,7 +134,6 @@ class FiregexInterceptor:
raise HTTPException(status_code=500, detail="Can't read from nfq client") from e raise HTTPException(status_code=500, detail="Can't read from nfq client") from e
if line.startswith("BLOCKED "): if line.startswith("BLOCKED "):
filter_name = line.split()[1] filter_name = line.split()[1]
print("BLOCKED", filter_name)
async with self.filter_map_lock: async with self.filter_map_lock:
if filter_name in self.filter_map: if filter_name in self.filter_map:
self.filter_map[filter_name].blocked_packets+=1 self.filter_map[filter_name].blocked_packets+=1

View File

@ -3,6 +3,7 @@ from modules.nfproxy.firegex import FiregexInterceptor
from modules.nfproxy.nftables import FiregexTables, FiregexFilter from modules.nfproxy.nftables import FiregexTables, FiregexFilter
from modules.nfproxy.models import Service, PyFilter from modules.nfproxy.models import Service, PyFilter
from utils.sqlite import SQLite from utils.sqlite import SQLite
from utils.sqlite import SQLite
from utils import run_func from utils import run_func
class STATUS: class STATUS:
@ -49,16 +50,17 @@ class ServiceManager:
def __update_status_db(self, status): def __update_status_db(self, status):
self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id) self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id)
async def next(self,to): async def next(self,to,persist:bool=True):
async with self.lock: async with self.lock:
if to == STATUS.STOP: if to == STATUS.STOP:
await self.stop() await self.stop(persist=persist)
if to == STATUS.ACTIVE: if to == STATUS.ACTIVE:
await self.restart() await self.restart()
def _set_status(self,status): def _set_status(self,status,persist:bool=True):
self.status = status self.status = status
self.__update_status_db(status) if persist:
self.__update_status_db(status)
def read_outstrem_buffer(self): def read_outstrem_buffer(self):
if self.interceptor: if self.interceptor:
@ -73,12 +75,12 @@ class ServiceManager:
await self._update_filters_from_db() await self._update_filters_from_db()
self._set_status(STATUS.ACTIVE) self._set_status(STATUS.ACTIVE)
async def stop(self): async def stop(self,persist:bool=True):
nft.delete(self.srv) nft.delete(self.srv)
if self.interceptor: if self.interceptor:
await self.interceptor.stop() await self.interceptor.stop()
self.interceptor = None self.interceptor = None
self._set_status(STATUS.STOP) self._set_status(STATUS.STOP,persist=persist)
async def restart(self): async def restart(self):
await self.stop() await self.stop()
@ -88,6 +90,8 @@ class ServiceManager:
async with self.lock: async with self.lock:
await self._update_filters_from_db() await self._update_filters_from_db()
class FirewallManager: class FirewallManager:
def __init__(self, db:SQLite, outstream_func=None, exception_func=None): def __init__(self, db:SQLite, outstream_func=None, exception_func=None):
self.db = db self.db = db
@ -98,12 +102,16 @@ class FirewallManager:
async def close(self): async def close(self):
for key in list(self.service_table.keys()): for key in list(self.service_table.keys()):
await self.remove(key) try:
await self.remove(key, persist=False)
except Exception:
# Don't let one broken service block shutdown of the others
self.service_table.pop(key, None)
async def remove(self,srv_id): async def remove(self,srv_id,persist:bool=True):
async with self.lock: async with self.lock:
if srv_id in self.service_table: if srv_id in self.service_table:
await self.service_table[srv_id].next(STATUS.STOP) await self.service_table[srv_id].next(STATUS.STOP,persist=persist)
del self.service_table[srv_id] del self.service_table[srv_id]
async def init(self): async def init(self):
@ -112,12 +120,14 @@ class FirewallManager:
async def reload(self): async def reload(self):
async with self.lock: async with self.lock:
for srv in self.db.query('SELECT * FROM services;'): services = self.db.query('SELECT * FROM services;')
srv = Service.from_dict(srv)
if srv.id in self.service_table: for srv in services:
if srv["service_id"] in self.service_table:
continue continue
self.service_table[srv.id] = ServiceManager(srv, self.db, outstream_func=self.outstream_function, exception_func=self.exception_function) srv_obj = Service.from_dict(srv)
await self.service_table[srv.id].next(srv.status) 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)
def get(self,srv_id) -> ServiceManager: def get(self,srv_id) -> ServiceManager:
if srv_id in self.service_table: if srv_id in self.service_table:

View File

@ -3,11 +3,15 @@ class Service:
self, self,
service_id: str, service_id: str,
status: str, status: str,
port: int, port: int | None,
name: str, name: str,
proto: str, proto: str,
ip_int: str, ip_int: str | None,
fail_open: bool, fail_open: bool,
target_type: str = "flow",
tls_stream_id: str | None = None,
tls_cert: str | None = None,
tls_key: str | None = None,
**other, **other,
): ):
self.id = service_id self.id = service_id
@ -17,6 +21,10 @@ class Service:
self.proto = proto self.proto = proto
self.ip_int = ip_int self.ip_int = ip_int
self.fail_open = fail_open self.fail_open = fail_open
self.target_type = target_type
self.tls_stream_id = tls_stream_id
self.tls_cert = tls_cert
self.tls_key = tls_key
@classmethod @classmethod
def from_dict(cls, var: dict): def from_dict(cls, var: dict):

View File

@ -1,5 +1,6 @@
from modules.nfproxy.models import Service from modules.nfproxy.models import Service
from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json
from modules.tls.manager import get_tls_ports, TLSManager
def convert_protocol_to_l4(proto:str): def convert_protocol_to_l4(proto:str):
if proto == "tcp": if proto == "tcp":
@ -9,6 +10,20 @@ def convert_protocol_to_l4(proto:str):
else: else:
raise Exception("Invalid protocol") raise Exception("Invalid protocol")
def resolve_target(srv: Service) -> tuple[str, int] | None:
"""Returns the (ip_int, port) a service's intercept rule should actually match.
For target_type=='tls' this is the loopback clear_port where nginx exposes the
decrypted plaintext, not the service's own (real backend) ip_int/port. Returns
None if the linked TLS stream can't be resolved (e.g. it was deleted)."""
if srv.target_type == "tls":
tls_stream = TLSManager().get_stream(srv.tls_stream_id)
if not tls_stream:
return None
_, clear_port = get_tls_ports(tls_stream["ip_int"], tls_stream["port"])
loopback_ip = "::1" if ip_family(tls_stream["ip_int"]) == "ip6" else "127.0.0.1"
return loopback_ip, clear_port
return srv.ip_int, srv.port
class FiregexFilter: class FiregexFilter:
def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int): def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int):
self.id = id self.id = id
@ -18,13 +33,25 @@ class FiregexFilter:
self.ip_int = str(ip_int) self.ip_int = str(ip_int)
def __eq__(self, o: object) -> bool: def __eq__(self, o: object) -> bool:
if isinstance(o, FiregexFilter) or isinstance(o, Service): if isinstance(o, FiregexFilter):
return self.port == o.port and self.proto == convert_protocol_to_l4(o.proto) and ip_parse(self.ip_int) == ip_parse(o.ip_int) return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int)
if isinstance(o, Service):
return self.matches(o)
return False return False
def matches(self, srv: Service) -> bool:
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): class FiregexTables(NFTableManager):
input_chain = "nfproxy_input" input_chain = "nfproxy_input"
output_chain = "nfproxy_output" output_chain = "nfproxy_output"
nat_chain = "nfproxy_nat"
def __init__(self): def __init__(self):
super().__init__([ super().__init__([
@ -54,33 +81,39 @@ class FiregexTables(NFTableManager):
]) ])
def add(self, srv:Service, queue_range): def add(self, srv:Service, queue_range):
for ele in self.get(): for ele in self.get():
if ele.__eq__(srv): if ele.matches(srv):
return return
target = resolve_target(srv)
if target is None:
return
target_ip, target_port = target
init, end = queue_range init, end = queue_range
if init > end: if init > end:
init, end = end, init init, end = end, init
self.cmd( self.cmd(
{ "insert":{ "rule": { { "insert":{ "rule": { # Send non-TLS outbound traffic to NFQUEUE (Client -> Server)
"family": "inet", "family": "inet",
"table": self.table_name, "table": self.table_name,
"chain": self.output_chain, "chain": self.output_chain,
"expr": [ "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': 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(srv.port)}}, {'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}}, {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}},
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
] ]
}}}, }}},
{"insert":{"rule":{ {"insert":{"rule":{ # Send non-TLS inbound traffic to NFQUEUE (Server -> Client)
"family": "inet", "family": "inet",
"table": self.table_name, "table": self.table_name,
"chain": self.input_chain, "chain": self.input_chain,
"expr": [ "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': 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(srv.port)}}, {'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}}, {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}},
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
] ]
@ -91,23 +124,26 @@ class FiregexTables(NFTableManager):
def get(self) -> list[FiregexFilter]: def get(self) -> list[FiregexFilter]:
res = [] res = []
for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]): for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]):
ip_int = None try:
if isinstance(filter["expr"][0]["match"]["right"],str): ip_int = None
ip_int = str(ip_parse(filter["expr"][0]["match"]["right"])) if isinstance(filter["expr"][0]["match"]["right"],str):
else: ip_int = str(ip_parse(filter["expr"][0]["match"]["right"]))
ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}' else:
res.append(FiregexFilter( ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}'
target=filter["chain"], res.append(FiregexFilter(
id=int(filter["handle"]), target=filter["chain"],
proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"], id=int(filter["handle"]),
port=filter["expr"][1]["match"]["right"], proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"],
ip_int=ip_int port=filter["expr"][1]["match"]["right"],
)) ip_int=ip_int
))
except (KeyError, TypeError, IndexError):
pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip
return res return res
def delete(self, srv:Service): def delete(self, srv:Service):
for filter in self.get(): for filter in self.get():
if filter.__eq__(srv): if filter.matches(srv):
self.cmd({ "delete":{ "rule": { self.cmd({ "delete":{ "rule": {
"family": "inet", "family": "inet",
"table": self.table_name, "table": self.table_name,

View File

@ -101,11 +101,11 @@ class FiregexInterceptor:
self.process = await asyncio.create_subprocess_exec( self.process = await asyncio.create_subprocess_exec(
proxy_binary_path, proxy_binary_path,
stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
env={ env=dict(os.environ, **{
"MATCH_MODE": "stream" if self.srv.proto == "tcp" else "block", "MATCH_MODE": "stream" if self.srv.proto == "tcp" else "block",
"NTHREADS": os.getenv("NTHREADS","1"), "NTHREADS": os.getenv("NTHREADS","1"),
"FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0", "FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0",
}, }),
) )
nicenessify(-10, self.process.pid) nicenessify(-10, self.process.pid)
line_fut = self.process.stdout.readuntil() line_fut = self.process.stdout.readuntil()

View File

@ -43,19 +43,20 @@ class ServiceManager:
def __update_status_db(self, status): def __update_status_db(self, status):
self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id) self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id)
async def next(self,to): async def next(self,to,persist:bool=True):
async with self.lock: async with self.lock:
if to == STATUS.STOP: if to == STATUS.STOP:
await self.stop() await self.stop(persist=persist)
if to == STATUS.ACTIVE: if to == STATUS.ACTIVE:
await self.restart() await self.restart()
def _stats_updater(self,filter:RegexFilter): def _stats_updater(self,filter:RegexFilter):
self.db.query("UPDATE regexes SET blocked_packets = ? WHERE regex_id = ?;", filter.blocked, filter.id) self.db.query("UPDATE regexes SET blocked_packets = ? WHERE regex_id = ?;", filter.blocked, filter.id)
def _set_status(self,status): def _set_status(self,status,persist:bool=True):
self.status = status self.status = status
self.__update_status_db(status) if persist:
self.__update_status_db(status)
async def start(self): async def start(self):
if not self.interceptor: if not self.interceptor:
@ -64,12 +65,12 @@ class ServiceManager:
await self._update_filters_from_db() await self._update_filters_from_db()
self._set_status(STATUS.ACTIVE) self._set_status(STATUS.ACTIVE)
async def stop(self): async def stop(self,persist:bool=True):
nft.delete(self.srv) nft.delete(self.srv)
if self.interceptor: if self.interceptor:
await self.interceptor.stop() await self.interceptor.stop()
self.interceptor = None self.interceptor = None
self._set_status(STATUS.STOP) self._set_status(STATUS.STOP,persist=persist)
async def restart(self): async def restart(self):
await self.stop() await self.stop()
@ -79,6 +80,8 @@ class ServiceManager:
async with self.lock: async with self.lock:
await self._update_filters_from_db() await self._update_filters_from_db()
class FirewallManager: class FirewallManager:
def __init__(self, db:SQLite): def __init__(self, db:SQLite):
self.db = db self.db = db
@ -87,12 +90,16 @@ class FirewallManager:
async def close(self): async def close(self):
for key in list(self.service_table.keys()): for key in list(self.service_table.keys()):
await self.remove(key) try:
await self.remove(key, persist=False)
except Exception:
# Don't let one broken service block shutdown of the others
self.service_table.pop(key, None)
async def remove(self,srv_id): async def remove(self,srv_id,persist:bool=True):
async with self.lock: async with self.lock:
if srv_id in self.service_table: if srv_id in self.service_table:
await self.service_table[srv_id].next(STATUS.STOP) await self.service_table[srv_id].next(STATUS.STOP,persist=persist)
del self.service_table[srv_id] del self.service_table[srv_id]
async def init(self): async def init(self):
@ -101,12 +108,14 @@ class FirewallManager:
async def reload(self): async def reload(self):
async with self.lock: async with self.lock:
for srv in self.db.query('SELECT * FROM services;'): services = self.db.query('SELECT * FROM services;')
srv = Service.from_dict(srv)
if srv.id in self.service_table: for srv in services:
if srv["service_id"] in self.service_table:
continue continue
self.service_table[srv.id] = ServiceManager(srv, self.db) srv_obj = Service.from_dict(srv)
await self.service_table[srv.id].next(srv.status) self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db)
await self.service_table[srv_obj.id].next(srv_obj.status)
def get(self,srv_id) -> ServiceManager: def get(self,srv_id) -> ServiceManager:
if srv_id in self.service_table: if srv_id in self.service_table:

View File

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

View File

@ -1,5 +1,20 @@
from modules.nfregex.models import Service from modules.nfregex.models import Service
from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json
from modules.tls.manager import get_tls_ports, TLSManager
def resolve_target(srv: Service) -> tuple[str, int] | None:
"""Returns the (ip_int, port) a service's intercept rule should actually match.
For target_type=='tls' this is the loopback clear_port where nginx exposes the
decrypted plaintext, not the service's own (real backend) ip_int/port. Returns
None if the linked TLS stream can't be resolved (e.g. it was deleted)."""
if srv.target_type == "tls":
tls_stream = TLSManager().get_stream(srv.tls_stream_id)
if not tls_stream:
return None
_, clear_port = get_tls_ports(tls_stream["ip_int"], tls_stream["port"])
loopback_ip = "::1" if ip_family(tls_stream["ip_int"]) == "ip6" else "127.0.0.1"
return loopback_ip, clear_port
return srv.ip_int, srv.port
class FiregexFilter: class FiregexFilter:
def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int): def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int):
@ -10,13 +25,25 @@ class FiregexFilter:
self.ip_int = str(ip_int) self.ip_int = str(ip_int)
def __eq__(self, o: object) -> bool: def __eq__(self, o: object) -> bool:
if isinstance(o, FiregexFilter) or isinstance(o, Service): if isinstance(o, FiregexFilter):
return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int) return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int)
if isinstance(o, Service):
return self.matches(o)
return False return False
def matches(self, srv: Service) -> bool:
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): class FiregexTables(NFTableManager):
input_chain = "nfregex_input" input_chain = "nfregex_input"
output_chain = "nfregex_output" output_chain = "nfregex_output"
nat_chain = "nfregex_nat"
def __init__(self): def __init__(self):
super().__init__([ super().__init__([
@ -46,22 +73,28 @@ class FiregexTables(NFTableManager):
]) ])
def add(self, srv:Service, queue_range): def add(self, srv:Service, queue_range):
for ele in self.get(): for ele in self.get():
if ele.__eq__(srv): if ele.matches(srv):
return return
target = resolve_target(srv)
if target is None:
return
target_ip, target_port = target
init, end = queue_range init, end = queue_range
if init > end: if init > end:
init, end = end, init init, end = end, init
self.cmd( self.cmd(
{ "insert":{ "rule": { { "insert":{ "rule": {
"family": "inet", "family": "inet",
"table": self.table_name, "table": self.table_name,
"chain": self.output_chain, "chain": self.output_chain,
"expr": [ "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': 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(srv.port)}}, {'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "sport"}}, "op": "==", "right": int(target_port)}},
{"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}},
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
] ]
@ -71,8 +104,8 @@ class FiregexTables(NFTableManager):
"table": self.table_name, "table": self.table_name,
"chain": self.input_chain, "chain": self.input_chain,
"expr": [ "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': 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(srv.port)}}, {'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "dport"}}, "op": "==", "right": int(target_port)}},
{"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}},
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
] ]
@ -83,27 +116,33 @@ class FiregexTables(NFTableManager):
def get(self) -> list[FiregexFilter]: def get(self) -> list[FiregexFilter]:
res = [] res = []
for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]): for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]):
ip_int = None try:
if isinstance(filter["expr"][0]["match"]["right"],str): ip_int = None
ip_int = str(ip_parse(filter["expr"][0]["match"]["right"])) if isinstance(filter["expr"][0]["match"]["right"],str):
else: ip_int = str(ip_parse(filter["expr"][0]["match"]["right"]))
ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}' else:
res.append(FiregexFilter( ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}'
target=filter["chain"], res.append(FiregexFilter(
id=int(filter["handle"]), target=filter["chain"],
proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"], id=int(filter["handle"]),
port=filter["expr"][1]["match"]["right"], proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"],
ip_int=ip_int port=filter["expr"][1]["match"]["right"],
)) ip_int=ip_int
))
except (KeyError, TypeError, IndexError):
pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip
return res return res
def delete(self, srv:Service): def delete(self, srv:Service):
delete_cmds = []
for filter in self.get(): for filter in self.get():
if filter.__eq__(srv): if filter.matches(srv):
self.cmd({ "delete":{ "rule": { delete_cmds.append({ "delete":{ "rule": {
"family": "inet", "family": "inet",
"table": self.table_name, "table": self.table_name,
"chain": filter.target, "chain": filter.target,
"handle": filter.id "handle": filter.id
}}}) }}})
if delete_cmds:
self.cmd(*delete_cmds)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,43 @@
from utils import NFTableManager, nftables_int_to_json, ip_family
from modules.tls.manager import TLSManager
class TLSTables(NFTableManager):
nat_chain = "tls_nat"
def __init__(self):
super().__init__([
{"add":{"chain":{ #NAT chain for DNAT
"family":"inet",
"table":self.table_name,
"name":self.nat_chain,
"type":"nat",
"hook":"prerouting",
"prio":-100,
"policy":"accept"
}}}
],[
{"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}},
])
def reload(self):
self.init()
manager = TLSManager()
streams = manager.get_active_streams()
cmds = []
for srv in streams:
loopback_ip = "::1" if ip_family(srv["ip_int"]) == "ip6" else "127.0.0.1"
cmds.append({"insert":{ "rule": { # Redirect TLS traffic to local Nginx SSL proxy
"family": "inet",
"table": self.table_name,
"chain": self.nat_chain,
"expr": [
{'match': {'left': {'payload': {'protocol': ip_family(srv["ip_int"]), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv["ip_int"])}},
{'match': {'left': {'payload': {'protocol': 'tcp', 'field': 'dport'}}, 'op': '==', 'right': int(srv["port"])}},
{'match': {'left': {'meta': {'key': 'iifname'}}, 'op': '!=', 'right': 'lo'}},
{'dnat': {'family': ip_family(srv["ip_int"]), 'addr': loopback_ip, 'port': int(srv["ssl_port"])}}
]
}}})
if cmds:
self.cmd(*cmds)
tls_firewall = TLSTables()

View File

@ -0,0 +1,83 @@
import subprocess
import os
import glob
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 update_nginx() -> None:
manager = TLSManager()
streams = manager.get_active_streams()
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
for pattern in ("/tmp/firegex_cert_*", "/tmp/firegex_key_*", NGINX_CONF_PATH, NGINX_PID_PATH, NGINX_LOG_PATH):
for path in (glob.glob(pattern) if "*" in pattern else [pattern]):
try:
os.remove(path)
except OSError:
pass
return
servers_config = []
for srv in 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("cert") or "")
with open(key_path, "w") as f:
f.write(srv.get("key") or "")
ssl_port = srv["ssl_port"]
clear_port = srv["clear_port"]
ip_addr = srv["ip_int"].split("/")[0]
is_ipv6 = ":" in ip_addr
loopback_ip = "[::1]" if is_ipv6 else "127.0.0.1"
dest_ip = f"[{ip_addr}]" if is_ipv6 else ip_addr
servers_config.append(
f" server {{\n"
f" listen {loopback_ip}:{ssl_port} ssl;\n"
f" proxy_pass {loopback_ip}:{clear_port};\n"
f" ssl_certificate {cert_path};\n"
f" ssl_certificate_key {key_path};\n"
f" ssl_protocols TLSv1.2 TLSv1.3;\n"
f" }}\n"
f" server {{\n"
f" listen {loopback_ip}:{clear_port};\n"
f" proxy_pass {dest_ip}:{srv['port']};\n"
f" proxy_ssl on;\n"
f" proxy_ssl_verify off;\n"
f" proxy_ssl_protocols TLSv1.2 TLSv1.3;\n"
f" }}"
)
config_content = ""
if os.path.isdir("/usr/share/nginx/modules"):
config_content += "include /usr/share/nginx/modules/*.conf;\n"
config_content += (
f"pid {NGINX_PID_PATH};\n"
f"error_log {NGINX_LOG_PATH} info;\n\n"
f"events {{\n"
f" worker_connections 1024;\n"
f"}}\n\n"
f"stream {{\n"
+ "\n".join(servers_config) + "\n"
f"}}\n"
)
with open(NGINX_CONF_PATH, "w") as f:
f.write(config_content)
# Try to reload. If Nginx is not running, start it.
res = subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-s", "reload", "-e", NGINX_LOG_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if res.returncode != 0:
subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-e", NGINX_LOG_PATH], check=True)

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

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

View File

@ -6,6 +6,7 @@ from modules.nfproxy.nftables import FiregexTables
from modules.nfproxy.firewall import STATUS, FirewallManager from modules.nfproxy.firewall import STATUS, FirewallManager
from utils.sqlite import SQLite from utils.sqlite import SQLite
from utils import ip_parse, refactor_name, socketio_emit, PortType from utils import ip_parse, refactor_name, socketio_emit, PortType
from modules.tls.service import activate_stream
from utils.models import ResetRequest, StatusMessageModel from utils.models import ResetRequest, StatusMessageModel
import os import os
from firegex.nfproxy.internals import get_filter_names from firegex.nfproxy.internals import get_filter_names
@ -19,22 +20,27 @@ import utils
class ServiceModel(BaseModel): class ServiceModel(BaseModel):
service_id: str service_id: str
status: str status: str
port: PortType port: PortType|None = None
name: str name: str
proto: str proto: str
ip_int: str ip_int: str|None = None
n_filters: int n_filters: int
edited_packets: int edited_packets: int
blocked_packets: int blocked_packets: int
fail_open: bool fail_open: bool
target_type: str
tls_stream_id: str|None = None
class RenameForm(BaseModel): class RenameForm(BaseModel):
name:str name:str
class SettingsForm(BaseModel): class SettingsForm(BaseModel):
port: PortType|None = None port: PortType|None = None
proto: str|None = None
ip_int: str|None = None ip_int: str|None = None
fail_open: bool|None = None fail_open: bool|None = None
target_type: str|None = None
tls_stream_id: str|None = None
class PyFilterModel(BaseModel): class PyFilterModel(BaseModel):
name: str name: str
@ -45,10 +51,12 @@ class PyFilterModel(BaseModel):
class ServiceAddForm(BaseModel): class ServiceAddForm(BaseModel):
name: str name: str
port: PortType port: PortType|None = None
proto: str proto: str
ip_int: str ip_int: str|None = None
fail_open: bool = True fail_open: bool = True
target_type: str = "flow"
tls_stream_id: str|None = None
class ServiceAddResponse(BaseModel): class ServiceAddResponse(BaseModel):
status:str status:str
@ -63,11 +71,13 @@ db = SQLite('db/nft-pyfilters.db', {
'services': { 'services': {
'service_id': 'VARCHAR(100) PRIMARY KEY', 'service_id': 'VARCHAR(100) PRIMARY KEY',
'status': 'VARCHAR(100) NOT NULL', 'status': 'VARCHAR(100) NOT NULL',
'port': 'INT NOT NULL CHECK(port > 0 and port < 65536)', 'target_type': 'VARCHAR(10) NOT NULL CHECK(target_type IN ("flow", "tls")) DEFAULT "flow"',
'tls_stream_id': 'VARCHAR(100)',
'port': 'INT CHECK(port > 0 and port < 65536)',
'name': 'VARCHAR(100) NOT NULL UNIQUE', 'name': 'VARCHAR(100) NOT NULL UNIQUE',
'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "http"))', 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "http"))',
'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_proto IN ("tcp", "udp"))', 'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_proto IN ("tcp", "udp"))',
'ip_int': 'VARCHAR(100) NOT NULL', 'ip_int': 'VARCHAR(100)',
'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1', 'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1',
}, },
'pyfilter': { 'pyfilter': {
@ -115,10 +125,8 @@ async def startup():
utils.socketio.on("nfproxy-exception-leave", leave_exception) utils.socketio.on("nfproxy-exception-leave", leave_exception)
async def shutdown(): async def shutdown():
db.backup()
await firewall.close() await firewall.close()
db.disconnect() db.disconnect()
db.restore()
def gen_service_id(): def gen_service_id():
while True: while True:
@ -138,7 +146,7 @@ firewall = FirewallManager(db, outstream_func=outstream_func, exception_func=exc
@app.get('/services', response_model=list[ServiceModel]) @app.get('/services', response_model=list[ServiceModel])
async def get_service_list(): async def get_service_list():
"""Get the list of existent firegex services""" """Get the list of existent firegex services"""
return db.query(""" res = db.query("""
SELECT SELECT
s.service_id service_id, s.service_id service_id,
s.status status, s.status status,
@ -147,12 +155,15 @@ async def get_service_list():
s.proto proto, s.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.target_type target_type,
s.tls_stream_id tls_stream_id,
COUNT(f.name) n_filters, COUNT(f.name) n_filters,
COALESCE(SUM(f.blocked_packets),0) blocked_packets, COALESCE(SUM(f.blocked_packets),0) blocked_packets,
COALESCE(SUM(f.edited_packets),0) edited_packets COALESCE(SUM(f.edited_packets),0) edited_packets
FROM services s LEFT JOIN pyfilter f ON s.service_id = f.service_id FROM services s LEFT JOIN pyfilter f ON s.service_id = f.service_id
GROUP BY s.service_id; GROUP BY s.service_id;
""") """)
return res
@app.get('/services/{service_id}', response_model=ServiceModel) @app.get('/services/{service_id}', response_model=ServiceModel)
async def get_service_by_id(service_id: str): async def get_service_by_id(service_id: str):
@ -166,6 +177,8 @@ async def get_service_by_id(service_id: str):
s.proto proto, s.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.target_type target_type,
s.tls_stream_id tls_stream_id,
COUNT(f.name) n_filters, COUNT(f.name) n_filters,
COALESCE(SUM(f.blocked_packets),0) blocked_packets, COALESCE(SUM(f.blocked_packets),0) blocked_packets,
COALESCE(SUM(f.edited_packets),0) edited_packets COALESCE(SUM(f.edited_packets),0) edited_packets
@ -186,6 +199,10 @@ async def service_stop(service_id: str):
@app.post('/services/{service_id}/start', response_model=StatusMessageModel) @app.post('/services/{service_id}/start', response_model=StatusMessageModel)
async def service_start(service_id: str): async def service_start(service_id: str):
"""Request the start of a specific service""" """Request the start of a specific service"""
srv = db.query("SELECT target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id)
if srv and srv[0]["target_type"] == "tls":
if not await activate_stream(srv[0]["tls_stream_id"]):
raise HTTPException(status_code=400, detail="Linked TLS stream not found")
await firewall.get(service_id).next(STATUS.ACTIVE) await firewall.get(service_id).next(STATUS.ACTIVE)
await refresh_frontend() await refresh_frontend()
return {'status': 'ok'} return {'status': 'ok'}
@ -218,6 +235,11 @@ async def service_rename(service_id: str, form: RenameForm):
async def service_settings(service_id: str, form: SettingsForm): async def service_settings(service_id: str, form: SettingsForm):
"""Request to change the settings of a specific service (will cause a restart)""" """Request to change the settings of a specific service (will cause a restart)"""
srv_check = db.query("SELECT ip_int, port, 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]
if form.port is not None and (form.port < 1 or form.port > 65535): if form.port is not None and (form.port < 1 or form.port > 65535):
raise HTTPException(status_code=400, detail="Invalid port") raise HTTPException(status_code=400, detail="Invalid port")
@ -226,6 +248,17 @@ async def service_settings(service_id: str, form: SettingsForm):
form.ip_int = ip_parse(form.ip_int) form.ip_int = ip_parse(form.ip_int)
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid address") raise HTTPException(status_code=400, detail="Invalid address")
new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"]
new_port = form.port if form.port is not None else old_srv["port"]
new_target_type = form.target_type if form.target_type is not None else old_srv["target_type"]
new_tls_stream_id = form.tls_stream_id if form.tls_stream_id is not None else old_srv["tls_stream_id"]
if new_target_type == "tls" and not new_tls_stream_id:
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
if new_target_type == "flow" and (new_ip is None or new_port is None):
raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
keys = [] keys = []
values = [] values = []
@ -233,10 +266,13 @@ async def service_settings(service_id: str, form: SettingsForm):
for key, value in form.model_dump(exclude_none=True).items(): for key, value in form.model_dump(exclude_none=True).items():
keys.append(key) keys.append(key)
values.append(value) values.append(value)
if key == "proto":
keys.append("l4_proto")
values.append(convert_protocol_to_l4(value))
if len(keys) == 0: if len(keys) == 0:
raise HTTPException(status_code=400, detail="No settings to change provided") raise HTTPException(status_code=400, detail="No settings to change provided")
try: try:
db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id)
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
@ -244,12 +280,15 @@ async def service_settings(service_id: str, form: SettingsForm):
old_status = firewall.get(service_id).status old_status = firewall.get(service_id).status
await firewall.remove(service_id) await firewall.remove(service_id)
if old_status == STATUS.ACTIVE and new_target_type == "tls":
await activate_stream(new_tls_stream_id)
await firewall.reload() await firewall.reload()
await firewall.get(service_id).next(old_status) await firewall.get(service_id).next(old_status)
await refresh_frontend() await refresh_frontend()
return {'status': 'ok'} return {'status': 'ok'}
@app.get('/services/{service_id}/pyfilters', response_model=list[PyFilterModel]) @app.get('/services/{service_id}/pyfilters', response_model=list[PyFilterModel])
async def get_service_pyfilter_list(service_id: str): async def get_service_pyfilter_list(service_id: str):
"""Get the list of the pyfilters of a service""" """Get the list of the pyfilters of a service"""
@ -296,17 +335,25 @@ async def pyfilter_disable(service_id: str, filter_name: str):
@app.post('/services', response_model=ServiceAddResponse) @app.post('/services', response_model=ServiceAddResponse)
async def add_new_service(form: ServiceAddForm): async def add_new_service(form: ServiceAddForm):
"""Add a new service""" """Add a new service"""
try: if form.target_type == "flow":
form.ip_int = ip_parse(form.ip_int) if form.ip_int is None or form.port is None:
except ValueError: raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
raise HTTPException(status_code=400, detail="Invalid address") try:
form.ip_int = ip_parse(form.ip_int)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid address")
elif form.target_type == "tls":
if not form.tls_stream_id:
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
if form.proto not in ["tcp", "http"]: if form.proto not in ["tcp", "http"]:
raise HTTPException(status_code=400, detail="Invalid protocol") raise HTTPException(status_code=400, detail="Invalid protocol")
srv_id = None srv_id = None
try: try:
srv_id = gen_service_id() srv_id = gen_service_id()
db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, l4_proto) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", db.query("INSERT INTO services (service_id, name, port, status, proto, ip_int, fail_open, l4_proto, 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)) srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, convert_protocol_to_l4(form.proto), form.target_type, form.tls_stream_id)
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="This type of service already exists") raise HTTPException(status_code=400, detail="This type of service already exists")
await firewall.reload() await firewall.reload()

View File

@ -8,19 +8,22 @@ from modules.nfregex.nftables import FiregexTables
from modules.nfregex.firewall import STATUS, FirewallManager from modules.nfregex.firewall import STATUS, FirewallManager
from utils.sqlite import SQLite from utils.sqlite import SQLite
from utils import ip_parse, refactor_name, socketio_emit, PortType from utils import ip_parse, refactor_name, socketio_emit, PortType
from modules.tls.service import activate_stream
from utils.models import ResetRequest, StatusMessageModel from utils.models import ResetRequest, StatusMessageModel
from modules.nfregex.firegex import test_regex_validity from modules.nfregex.firegex import test_regex_validity
class ServiceModel(BaseModel): class ServiceModel(BaseModel):
status: str status: str
service_id: str service_id: str
port: PortType port: PortType|None = None
name: str name: str
proto: str proto: str
ip_int: str ip_int: str|None = None
n_regex: int n_regex: int
n_packets: int n_packets: int
fail_open: bool fail_open: bool
target_type: str
tls_stream_id: str|None = None
class RenameForm(BaseModel): class RenameForm(BaseModel):
name:str name:str
@ -30,6 +33,8 @@ class SettingsForm(BaseModel):
proto: str|None = None proto: str|None = None
ip_int: str|None = None ip_int: str|None = None
fail_open: bool|None = None fail_open: bool|None = None
target_type: str|None = None
tls_stream_id: str|None = None
class RegexModel(BaseModel): class RegexModel(BaseModel):
regex:str regex:str
@ -49,10 +54,12 @@ class RegexAddForm(BaseModel):
class ServiceAddForm(BaseModel): class ServiceAddForm(BaseModel):
name: str name: str
port: PortType port: PortType|None = None
proto: str proto: str
ip_int: str ip_int: str|None = None
fail_open: bool = False fail_open: bool = False
target_type: str = "flow"
tls_stream_id: str|None = None
class ServiceAddResponse(BaseModel): class ServiceAddResponse(BaseModel):
status:str status:str
@ -64,11 +71,13 @@ db = SQLite('db/nft-regex.db', {
'services': { 'services': {
'service_id': 'VARCHAR(100) PRIMARY KEY', 'service_id': 'VARCHAR(100) PRIMARY KEY',
'status': 'VARCHAR(100) NOT NULL', 'status': 'VARCHAR(100) NOT NULL',
'port': 'INT NOT NULL CHECK(port > 0 and port < 65536)', 'target_type': 'VARCHAR(10) NOT NULL CHECK(target_type IN ("flow", "tls")) DEFAULT "flow"',
'tls_stream_id': 'VARCHAR(100)',
'port': 'INT CHECK(port > 0 and port < 65536)',
'name': 'VARCHAR(100) NOT NULL UNIQUE', 'name': 'VARCHAR(100) NOT NULL UNIQUE',
'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))', 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))',
'ip_int': 'VARCHAR(100) NOT NULL', 'ip_int': 'VARCHAR(100)',
'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1' 'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1',
}, },
'regexes': { 'regexes': {
'regex': 'TEXT NOT NULL', 'regex': 'TEXT NOT NULL',
@ -113,10 +122,8 @@ async def startup():
print("WARNING cannot start firewall:", e) print("WARNING cannot start firewall:", e)
async def shutdown(): async def shutdown():
db.backup()
await firewall.close() await firewall.close()
db.disconnect() db.disconnect()
db.restore()
def gen_service_id(): def gen_service_id():
while True: while True:
@ -130,7 +137,7 @@ firewall = FirewallManager(db)
@app.get('/services', response_model=list[ServiceModel]) @app.get('/services', response_model=list[ServiceModel])
async def get_service_list(): async def get_service_list():
"""Get the list of existent firegex services""" """Get the list of existent firegex services"""
return db.query(""" res = db.query("""
SELECT SELECT
s.service_id service_id, s.service_id service_id,
s.status status, s.status status,
@ -139,11 +146,14 @@ async def get_service_list():
s.proto proto, s.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.target_type target_type,
s.tls_stream_id tls_stream_id,
COUNT(r.regex_id) n_regex, COUNT(r.regex_id) n_regex,
COALESCE(SUM(r.blocked_packets),0) n_packets COALESCE(SUM(r.blocked_packets),0) n_packets
FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id
GROUP BY s.service_id; GROUP BY s.service_id;
""") """)
return res
@app.get('/services/{service_id}', response_model=ServiceModel) @app.get('/services/{service_id}', response_model=ServiceModel)
async def get_service_by_id(service_id: str): async def get_service_by_id(service_id: str):
@ -157,6 +167,8 @@ async def get_service_by_id(service_id: str):
s.proto proto, s.proto proto,
s.ip_int ip_int, s.ip_int ip_int,
s.fail_open fail_open, s.fail_open fail_open,
s.target_type target_type,
s.tls_stream_id tls_stream_id,
COUNT(r.regex_id) n_regex, COUNT(r.regex_id) n_regex,
COALESCE(SUM(r.blocked_packets),0) n_packets COALESCE(SUM(r.blocked_packets),0) n_packets
FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id
@ -176,6 +188,10 @@ async def service_stop(service_id: str):
@app.post('/services/{service_id}/start', response_model=StatusMessageModel) @app.post('/services/{service_id}/start', response_model=StatusMessageModel)
async def service_start(service_id: str): async def service_start(service_id: str):
"""Request the start of a specific service""" """Request the start of a specific service"""
srv = db.query("SELECT target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id)
if srv and srv[0]["target_type"] == "tls":
if not await activate_stream(srv[0]["tls_stream_id"]):
raise HTTPException(status_code=400, detail="Linked TLS stream not found")
await firewall.get(service_id).next(STATUS.ACTIVE) await firewall.get(service_id).next(STATUS.ACTIVE)
await refresh_frontend() await refresh_frontend()
return {'status': 'ok'} return {'status': 'ok'}
@ -206,9 +222,14 @@ async def service_rename(service_id: str, form: RenameForm):
async def service_settings(service_id: str, form: SettingsForm): async def service_settings(service_id: str, form: SettingsForm):
"""Request to change the settings of a specific service (will cause a restart)""" """Request to change the settings of a specific service (will cause a restart)"""
srv_check = db.query("SELECT proto, ip_int, port, 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]
if form.proto is not None and form.proto not in ["tcp", "udp"]: if form.proto is not None and form.proto not in ["tcp", "udp"]:
raise HTTPException(status_code=400, detail="Invalid protocol") raise HTTPException(status_code=400, detail="Invalid protocol")
if form.port is not None and (form.port < 1 or form.port > 65535): if form.port is not None and (form.port < 1 or form.port > 65535):
raise HTTPException(status_code=400, detail="Invalid port") raise HTTPException(status_code=400, detail="Invalid port")
@ -217,6 +238,22 @@ async def service_settings(service_id: str, form: SettingsForm):
form.ip_int = ip_parse(form.ip_int) form.ip_int = ip_parse(form.ip_int)
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid address") raise HTTPException(status_code=400, detail="Invalid address")
new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"]
new_port = form.port if form.port is not None else old_srv["port"]
new_proto = form.proto if form.proto is not None else old_srv["proto"]
new_target_type = form.target_type if form.target_type is not None else old_srv["target_type"]
new_tls_stream_id = form.tls_stream_id if form.tls_stream_id is not None else old_srv["tls_stream_id"]
if new_target_type == "tls":
if new_proto != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
if not new_tls_stream_id:
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
if new_target_type == "flow" and (new_ip is None or new_port is None):
raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
keys = [] keys = []
values = [] values = []
@ -227,7 +264,7 @@ async def service_settings(service_id: str, form: SettingsForm):
if len(keys) == 0: if len(keys) == 0:
raise HTTPException(status_code=400, detail="No settings to change provided") raise HTTPException(status_code=400, detail="No settings to change provided")
try: try:
db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id)
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
@ -235,9 +272,11 @@ async def service_settings(service_id: str, form: SettingsForm):
old_status = firewall.get(service_id).status old_status = firewall.get(service_id).status
await firewall.remove(service_id) await firewall.remove(service_id)
if old_status == STATUS.ACTIVE and new_target_type == "tls":
await activate_stream(new_tls_stream_id)
await firewall.reload() await firewall.reload()
await firewall.get(service_id).next(old_status) await firewall.get(service_id).next(old_status)
await refresh_frontend() await refresh_frontend()
return {'status': 'ok'} return {'status': 'ok'}
@ -316,23 +355,34 @@ async def add_new_regex(form: RegexAddForm):
@app.post('/services', response_model=ServiceAddResponse) @app.post('/services', response_model=ServiceAddResponse)
async def add_new_service(form: ServiceAddForm): async def add_new_service(form: ServiceAddForm):
"""Add a new service""" """Add a new service"""
try: if form.target_type == "flow":
form.ip_int = ip_parse(form.ip_int) if form.ip_int is None or form.port is None:
except ValueError: raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
raise HTTPException(status_code=400, detail="Invalid address") try:
form.ip_int = ip_parse(form.ip_int)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid address")
elif form.target_type == "tls":
if not form.tls_stream_id:
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
if form.proto not in ["tcp", "udp"]: if form.proto not in ["tcp", "udp"]:
raise HTTPException(status_code=400, detail="Invalid protocol") raise HTTPException(status_code=400, detail="Invalid protocol")
if form.target_type == "tls" and form.proto != "tcp":
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
srv_id = None srv_id = None
try: try:
srv_id = gen_service_id() srv_id = gen_service_id()
db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open) VALUES (?, ?, ?, ?, ?, ?, ?)", db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, target_type, tls_stream_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open) srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, form.target_type, form.tls_stream_id)
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="This type of service already exists") raise HTTPException(status_code=400, detail="This type of service already exists")
await firewall.reload() await firewall.reload()
await refresh_frontend() await refresh_frontend()
return {'status': 'ok', 'service_id': srv_id} return {'status': 'ok', 'service_id': srv_id}
@app.get('/metrics', response_class = PlainTextResponse) @app.get('/metrics', response_class = PlainTextResponse)
async def metrics(): async def metrics():
"""Aggregate metrics""" """Aggregate metrics"""
@ -355,3 +405,52 @@ async def metrics():
metrics.append(f'firegex_blocked_packets{{{props}}} {stat["blocked_packets"]}') metrics.append(f'firegex_blocked_packets{{{props}}} {stat["blocked_packets"]}')
metrics.append(f'firegex_active{{{props}}} {int(stat["active"] and stat["status"] == "active")}') metrics.append(f'firegex_active{{{props}}} {int(stat["active"] and stat["status"] == "active")}')
return "\n".join(metrics) return "\n".join(metrics)
@app.get('/services/{service_id}/export')
async def export_service_regexes(service_id: str):
"""Export all regexes for a specific service as JSON"""
if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', service_id)) == 0:
raise HTTPException(status_code=404, detail="Service not found")
regexes = db.query("SELECT regex, mode, is_case_sensitive, active FROM regexes WHERE service_id = ?;", service_id)
return {"regexes": regexes}
@app.post('/services/{service_id}/import', response_model=StatusMessageModel)
async def import_service_regexes(service_id: str, data: dict):
"""Import regexes for a specific service from JSON"""
if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', service_id)) == 0:
raise HTTPException(status_code=404, detail="Service not found")
regexes = data.get("regexes", [])
if not isinstance(regexes, list):
raise HTTPException(status_code=400, detail="Invalid format")
for r in regexes:
regex = r.get("regex")
mode = r.get("mode")
is_case_sensitive = r.get("is_case_sensitive", False)
active = r.get("active", True)
if not regex or not mode:
continue
try:
db.query('INSERT INTO regexes (regex, mode, service_id, is_case_sensitive, active) VALUES (?, ?, ?, ?, ?);',
regex, mode, service_id, int(is_case_sensitive), int(active))
# Since we inserted it directly, we need to notify the firewall to update its rules if it's active
if active and firewall.get(service_id).status == STATUS.ACTIVE:
# Easiest way to sync is to restart the service rule engine
await firewall.get(service_id).next(STATUS.ACTIVE)
except sqlite3.IntegrityError:
# Skip duplicates
pass
# Force reload of all rules for this service
old_status = firewall.get(service_id).status
await firewall.remove(service_id)
await firewall.reload()
await firewall.get(service_id).next(old_status)
await refresh_frontend()
return {'status': 'ok'}

View File

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

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

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

View File

@ -15,7 +15,10 @@ from pathlib import Path
from fastapi import HTTPException, status from fastapi import HTTPException, status
LOCALHOST_IP = socket.gethostbyname(os.getenv("LOCALHOST_IP","127.0.0.1")) _localhost_env = os.getenv("LOCALHOST_IP", "127.0.0.1")
if _localhost_env == "None":
_localhost_env = "127.0.0.1"
LOCALHOST_IP = socket.gethostbyname(_localhost_env)
socketio:AsyncServer = None socketio:AsyncServer = None
sid_list:set = set() sid_list:set = set()
@ -25,9 +28,12 @@ ROUTERS_DIR = os.path.join(ROOT_DIR,"routers")
ON_DOCKER = "DOCKER" in sys.argv ON_DOCKER = "DOCKER" in sys.argv
DEBUG = "DEBUG" in sys.argv DEBUG = "DEBUG" in sys.argv
NORELOAD = "NORELOAD" in sys.argv NORELOAD = "NORELOAD" in sys.argv
FIREGEX_PORT = int(os.getenv("PORT","4444")) _port_env = os.getenv("PORT", "4444")
FIREGEX_HOST = os.getenv("HOST","0.0.0.0") FIREGEX_PORT = int(_port_env) if _port_env and _port_env != "None" else 4444
FIREGEX_SOCKET_DIR = os.getenv("SOCKET_DIR", None) _host_env = os.getenv("HOST", "0.0.0.0")
FIREGEX_HOST = _host_env if _host_env and _host_env != "None" else "0.0.0.0"
_socket_dir_env = os.getenv("SOCKET_DIR", None)
FIREGEX_SOCKET_DIR = None if _socket_dir_env == "None" else _socket_dir_env
FIREGEX_SOCKET = os.path.join(FIREGEX_SOCKET_DIR, "firegex.sock") if FIREGEX_SOCKET_DIR else None FIREGEX_SOCKET = os.path.join(FIREGEX_SOCKET_DIR, "firegex.sock") if FIREGEX_SOCKET_DIR else None
JWT_ALGORITHM: str = "HS256" JWT_ALGORITHM: str = "HS256"
def _get_version(): def _get_version():
@ -305,6 +311,8 @@ def nicenessify(priority:int, pid:int|None=None):
ps = psutil.Process(pid) ps = psutil.Process(pid)
if os.name == 'posix': if os.name == 'posix':
ps.nice(priority) ps.nice(priority)
except (psutil.AccessDenied, PermissionError) as e:
print(f"Permission denied setting priority (pid={pid}, priority={priority}): {e}")
except Exception as e: except Exception as e:
print(f"Error setting priority: {e} {traceback.format_exc()}") print(f"Error setting priority: {e} {traceback.format_exc()}")
pass pass

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 298 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,20 +13,34 @@ export type Service = {
edited_packets:number, edited_packets:number,
blocked_packets:number, blocked_packets:number,
fail_open:boolean, fail_open:boolean,
target_type:string,
tls_stream_id:string|null,
} }
export type TLSConfig = {
tls_enabled: boolean,
tls_cert: string | null,
tls_key: string | null,
}
export type ServiceAddForm = { export type ServiceAddForm = {
name:string, name:string,
port:number, port:number,
proto:string, proto:string,
ip_int:string, ip_int:string,
fail_open: boolean, fail_open: boolean,
target_type?: string,
tls_stream_id?: string | null,
} }
export type ServiceSettings = { export type ServiceSettings = {
port?:number, port?:number,
proto?:string,
ip_int?:string, ip_int?:string,
fail_open?: boolean, fail_open?: boolean,
target_type?: string,
tls_stream_id?: string | null,
} }
export type ServiceAddResponse = { export type ServiceAddResponse = {
@ -88,6 +102,10 @@ export const nfproxy = {
const { status } = await putapi(`nfproxy/services/${service_id}/settings`,data) as ServerResponse; const { status } = await putapi(`nfproxy/services/${service_id}/settings`,data) as ServerResponse;
return status === "ok"?undefined:status return status === "ok"?undefined:status
}, },
updatetlsconfig: async (service_id:string, data:TLSConfig) => {
const { status } = await putapi(`nfproxy/services/${service_id}/tls-config`,data) as ServerResponse;
return status === "ok"?undefined:status
},
getpyfilterscode: async (service_id:string) => { getpyfilterscode: async (service_id:string) => {
return await getapi(`nfproxy/services/${service_id}/code`) as string; return await getapi(`nfproxy/services/${service_id}/code`) as string;
}, },

View File

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

View File

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

View File

@ -13,6 +13,8 @@ export type Service = {
n_packets:number, n_packets:number,
n_regex:number, n_regex:number,
fail_open:boolean, fail_open:boolean,
target_type:string,
tls_stream_id:string|null,
} }
export type ServiceAddForm = { export type ServiceAddForm = {
@ -21,6 +23,8 @@ export type ServiceAddForm = {
proto:string, proto:string,
ip_int:string, ip_int:string,
fail_open: boolean, fail_open: boolean,
target_type?: string,
tls_stream_id?: string | null,
} }
export type ServiceSettings = { export type ServiceSettings = {
@ -28,6 +32,8 @@ export type ServiceSettings = {
proto?:string, proto?:string,
ip_int?:string, ip_int?:string,
fail_open?: boolean, fail_open?: boolean,
target_type?: string,
tls_stream_id?: string | null,
} }
export type ServiceAddResponse = { export type ServiceAddResponse = {
@ -92,4 +98,8 @@ export const nfregex = {
const { status } = await putapi(`nfregex/services/${service_id}/settings`,data) as ServerResponse; const { status } = await putapi(`nfregex/services/${service_id}/settings`,data) as ServerResponse;
return status === "ok"?undefined:status return status === "ok"?undefined:status
}, },
updatetlsconfig: async (service_id: string, data: { tls_enabled: boolean, tls_cert: string | null, tls_key: string | null }) => {
const { status } = await putapi(`nfregex/services/${service_id}/tls-config`, data) as ServerResponse;
return status === "ok" ? undefined : status;
},
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,118 @@
import { Box, Button, Select, Space, TextInput, Textarea, Title, Group, FileButton, Text } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
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>;
disabled?: boolean;
}
export default function TLSServiceFields({ form, disabled }: TLSServiceFieldsProps) {
const { data: streams, isLoading } = useQuery<any[]>({
queryKey: ["tls_streams"],
queryFn: () => getapi("tls/streams")
});
const [isCreatingNew, setIsCreatingNew] = useState(false);
useEffect(() => {
// 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);
}
}
}, [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 (
<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}
/>
) : (
<>
<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="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=Hanken+Grotesk:wght@400;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Lato&display=swap');
:root{ :root{
--primary_color: #242a33; --primary_color: #161618;
--secondary_color: #1A1B1E; --secondary_color: #0a0a0c;
--third_color:#25262b; --third_color: #1e1e20;
--fourth_color: #25262b99; --fourth_color: #2c2c2f;
--accent-color: #00e5ff;
--accent-hover: #00b8cc;
--text-primary: #f5f5f5;
--text-secondary: #9aa0a6;
--mantine-color-body: var(--secondary_color) !important; --mantine-color-body: var(--secondary_color) !important;
--mantine-font-family: 'Inter', sans-serif !important;
} }
body { body {
margin: 0; margin: 0;
font-family: 'Lato', sans-serif; font-family: 'Inter', sans-serif;
background-color: var(--secondary_color);
color: var(--text-primary);
} }
.center-flex{ .center-flex{
@ -132,19 +138,20 @@ body {
} }
.firegex__porthijack__servicerow__name{ .firegex__porthijack__servicerow__name{
font-family: 'Hanken Grotesk', sans-serif;
font-size: 1.8em; font-size: 1.8em;
font-weight: bolder; font-weight: 700;
margin-right: 10px; margin-right: 10px;
margin-bottom: 13px; margin-bottom: 13px;
color:#FFF; color: var(--text-primary);
} }
.firegex__porthijack__servicerow__portInput *{ .firegex__porthijack__servicerow__portInput *{
color: #FFF; color: var(--accent-color);
font-family: 'JetBrains Mono', monospace;
font-weight: bold; font-weight: bold;
font-size: 1em; font-size: 1em;
margin-top: -1px; margin-top: -1px;
text-decoration: underline;
} }
.firegex__nfregex__row{ .firegex__nfregex__row{
@ -155,13 +162,21 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background-color: transparent;
border-radius: 12px;
margin-bottom: 10px;
transition: all 0.2s ease;
}
.firegex__nfregex__row:hover{
} }
.firegex__nfregex__name{ .firegex__nfregex__name{
font-family: 'Hanken Grotesk', sans-serif;
font-size: 1.6em; font-size: 1.6em;
font-weight: bolder; font-weight: 700;
text-transform: capitalize; text-transform: capitalize;
color:#FFF; color: var(--text-primary);
max-width: 300px; max-width: 300px;
overflow: hidden; overflow: hidden;
margin-left: -20px; margin-left: -20px;
@ -175,12 +190,13 @@ body {
.firegex__navbar__unstyled_button { .firegex__navbar__unstyled_button {
display: block; display: block;
width: 100%; width: 100%;
padding: var(--mantine-spacing-xs); padding: 12px 16px;
border-radius: var(--mantine-radius-sm); border-radius: 8px;
color: var(--mantine-color-dark-0); color: var(--text-secondary);
background-color: transparent; background-color: transparent;
font-weight: 500;
opacity: 1; transition: all 0.2s ease;
border: 1px solid transparent;
} }
.firegex__navbar__unstyled_button.disabled { .firegex__navbar__unstyled_button.disabled {
@ -188,16 +204,20 @@ body {
} }
.firegex__navbar__unstyled_button.selected{ .firegex__navbar__unstyled_button.selected{
background-color: var(--mantine-color-dark-6); background-color: var(--third_color);
color: var(--accent-color);
border-color: var(--fourth_color);
} }
.firegex__navbar__unstyled_button:hover { .firegex__navbar__unstyled_button:hover {
background-color: var(--mantine-color-dark-6); background-color: var(--third_color);
color: var(--text-primary);
} }
.firegex__footer{ .firegex__footer{
margin-top: 50px; margin-top: 50px;
background-color: var(--primary_color); background-color: var(--primary_color);
border-top: 1px solid var(--third_color);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -227,9 +247,29 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
display: flex; display: flex;
background-color: var(--fourth_color); background-color: transparent;
border-radius: 10px; border-radius: 8px;
border: #444 3px solid; border: 1px solid var(--fourth_color);
margin-bottom: 16px;
}
.mantine-CodeHighlight-root, .mantine-CodeHighlight-pre {
background-color: transparent !important;
}
.firegex__clickable_row {
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.firegex__clickable_row:hover {
border-color: var(--accent-color);
background-color: rgba(255, 255, 255, 0.03);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
.firegex__clickable_row:active {
background-color: rgba(255, 255, 255, 0.05);
} }
@media (max-width: 599px) { @media (max-width: 599px) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

186
tests/tls_test.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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