mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 16:31:00 +00:00
Merge pull request #35 from Pwnzer0tt1/dev-tmp
Integrating edits on main to dev branch
This commit is contained in:
commit
f06c389161
46
.github/workflows/pypi-publish-fgex.yml
vendored
46
.github/workflows/pypi-publish-fgex.yml
vendored
@ -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 }}
|
||||
47
.github/workflows/pypi-publish.yml
vendored
47
.github/workflows/pypi-publish.yml
vendored
@ -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 }}
|
||||
@ -1,4 +1,4 @@
|
||||
name: Create and publish Docker images
|
||||
name: Test, build and publish release artifacts
|
||||
|
||||
on:
|
||||
release:
|
||||
@ -10,17 +10,33 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Gate for every publish target below: nothing gets pushed to Docker/PyPI unless this passes.
|
||||
run_tests:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and run firegex
|
||||
run: python3 run.py start -P testpassword
|
||||
|
||||
- name: Run tests
|
||||
run: sudo apt-get install -y iperf3 && cd tests && ./run_tests.sh
|
||||
|
||||
docker_build:
|
||||
needs: run_tests
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: amd64
|
||||
run_tests: true
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
run_tests: true
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@ -33,14 +49,6 @@ jobs:
|
||||
id: lowercase
|
||||
run: echo "image_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and run firegex
|
||||
if: matrix.run_tests
|
||||
run: python3 run.py start -P testpassword
|
||||
|
||||
- name: Run tests
|
||||
if: matrix.run_tests
|
||||
run: sudo apt-get install -y iperf3 && cd tests && ./run_tests.sh
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@master
|
||||
@ -51,23 +59,23 @@ jobs:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}
|
||||
|
||||
|
||||
- name: Extract tag name
|
||||
id: tag
|
||||
run: echo TAG_NAME=$(echo $GITHUB_REF | cut -d / -f 3) >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Update version in setup.py
|
||||
run: >-
|
||||
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" backend/utils/__init__.py;
|
||||
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/setup.py;
|
||||
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/firegex/__init__.py;
|
||||
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@ -102,43 +110,43 @@ jobs:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: Extract tag name
|
||||
id: tag
|
||||
run: echo TAG_NAME=$(echo $GITHUB_REF | cut -d / -f 3) >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Create and push multi-platform manifest
|
||||
run: |
|
||||
# Create manifest list for specific tag
|
||||
docker manifest create ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }} \
|
||||
--amend ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }}-amd64 \
|
||||
--amend ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }}-arm64
|
||||
|
||||
|
||||
# Annotate the manifest with architecture info
|
||||
docker manifest annotate ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }} \
|
||||
${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }}-amd64 \
|
||||
--arch amd64 --os linux
|
||||
|
||||
|
||||
docker manifest annotate ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }} \
|
||||
${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }}-arm64 \
|
||||
--arch arm64 --os linux
|
||||
|
||||
|
||||
docker manifest push ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:${{ steps.tag.outputs.TAG_NAME }}
|
||||
|
||||
|
||||
# Create manifest list for latest tag
|
||||
docker manifest create ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest \
|
||||
--amend ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest-amd64 \
|
||||
--amend ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest-arm64
|
||||
|
||||
|
||||
# Annotate the latest manifest with architecture info
|
||||
docker manifest annotate ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest \
|
||||
${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest-amd64 \
|
||||
--arch amd64 --os linux
|
||||
|
||||
|
||||
docker manifest annotate ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest \
|
||||
${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest-arm64 \
|
||||
--arch arm64 --os linux
|
||||
|
||||
|
||||
docker manifest push ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}:latest
|
||||
|
||||
create-rootfs-assets:
|
||||
@ -208,3 +216,64 @@ jobs:
|
||||
echo "Assets uploaded successfully!"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
pypi_publish_firegex:
|
||||
needs: run_tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build
|
||||
- name: Extract tag name
|
||||
id: tag
|
||||
run: echo TAG_NAME=$(echo $GITHUB_REF | cut -d / -f 3) >> $GITHUB_OUTPUT
|
||||
- name: Update version in setup.py
|
||||
run: >-
|
||||
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/setup.py;
|
||||
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/firegex/__init__.py;
|
||||
- name: Build package
|
||||
run: cd fgex-lib && python -m build && mv ./dist ../
|
||||
- name: Publish package
|
||||
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
|
||||
pypi_publish_fgex_alias:
|
||||
needs: run_tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build
|
||||
- name: Extract tag name
|
||||
id: tag
|
||||
run: echo TAG_NAME=$(echo $GITHUB_REF | cut -d / -f 3) >> $GITHUB_OUTPUT
|
||||
- name: Update version in setup.py
|
||||
run: >-
|
||||
sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/fgex-pip/setup.py;
|
||||
- name: Build package
|
||||
run: cd fgex-lib/fgex-pip && python -m build && mv ./dist ../../
|
||||
- name: Publish package
|
||||
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN_FGEX }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -50,3 +50,4 @@
|
||||
**/npm-debug.log*
|
||||
**/yarn-debug.log*
|
||||
**/yarn-error.log*
|
||||
.claude
|
||||
87
CLAUDE.md
Normal file
87
CLAUDE.md
Normal 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.
|
||||
@ -16,7 +16,7 @@ RUN bun run build
|
||||
FROM --platform=$TARGETARCH quay.io/fedora/fedora:43 AS base
|
||||
RUN dnf -y update && dnf install -y python3.14 libnetfilter_queue \
|
||||
libnfnetlink libmnl libcap-ng-utils nftables \
|
||||
vectorscan libtins python3-nftables libpcap && dnf clean all
|
||||
vectorscan libtins python3-nftables libpcap nginx nginx-mod-stream && dnf clean all
|
||||
|
||||
RUN mkdir -p /execute/modules
|
||||
WORKDIR /execute
|
||||
|
||||
@ -26,7 +26,7 @@ python3 run.py start --prebuilt
|
||||
Without the `--prebuilt` flag, it will build the docker image from source, which may take longer.
|
||||
|
||||
### Standalone Mode
|
||||
If Docker is not available or you're running in a rootless environment, Firegex can run in standalone mode. The one-command installer above also works for standalone mode and will automatically detect and use standalone mode when Docker is unavailable.
|
||||
If Docker is not available or you're running it in a rootless environment, Firegex can run in standalone mode. The one-command installer above also works for standalone mode and will automatically detect and use standalone mode when Docker is unavailable or in rootless mode.
|
||||
|
||||
```bash
|
||||
sh <(curl -sLf https://pwnzer0tt1.it/firegex.sh)
|
||||
|
||||
@ -4,6 +4,7 @@ import utils
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import base64
|
||||
from fastapi import FastAPI, HTTPException, Depends, APIRouter
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from jose import jwt
|
||||
@ -270,6 +271,62 @@ async def reset_firegex(form: ResetRequest):
|
||||
await refresh_frontend()
|
||||
return {'status': 'ok'}
|
||||
|
||||
@api.get('/export')
|
||||
async def export_db():
|
||||
"""Export all configuration databases as JSON"""
|
||||
dbs = {}
|
||||
if not os.path.exists('db'):
|
||||
return dbs
|
||||
for f in os.listdir('db'):
|
||||
if f.endswith('.db'):
|
||||
temp_db = SQLite(os.path.join('db', f))
|
||||
dbs[f] = temp_db.dump()
|
||||
|
||||
# Export nfproxy filters
|
||||
if os.path.exists('db/nfproxy_filters'):
|
||||
dbs['nfproxy_filters'] = {}
|
||||
for f in os.listdir('db/nfproxy_filters'):
|
||||
if f.endswith('.py'):
|
||||
with open(os.path.join('db/nfproxy_filters', f), 'rb') as script_file:
|
||||
dbs['nfproxy_filters'][f] = base64.b64encode(script_file.read()).decode('utf-8')
|
||||
return dbs
|
||||
|
||||
@api.post('/import', response_model=StatusMessageModel)
|
||||
async def import_db(data: dict):
|
||||
"""Import all configuration databases from JSON"""
|
||||
if not os.path.exists('db'):
|
||||
os.makedirs('db')
|
||||
|
||||
# Backups never contain the password/secret (export_db strips them), so preserve
|
||||
# the current session's own values instead of losing them on import.
|
||||
current_password = db.get("password")
|
||||
current_secret = db.get("secret")
|
||||
|
||||
for db_file, db_data in data.items():
|
||||
if db_file.endswith('.db'):
|
||||
temp_db = SQLite(os.path.join('db', db_file))
|
||||
# Just load the data, we assume schemas exist or will be matched
|
||||
# We don't delete the whole file to preserve DB_VERSION and schemas
|
||||
temp_db.load(db_data)
|
||||
elif db_file == 'nfproxy_filters':
|
||||
if not os.path.exists('db/nfproxy_filters'):
|
||||
os.makedirs('db/nfproxy_filters')
|
||||
for f, script_content in db_data.items():
|
||||
if f.endswith('.py'):
|
||||
with open(os.path.join('db/nfproxy_filters', f), 'wb') as script_file:
|
||||
script_file.write(base64.b64decode(script_content))
|
||||
|
||||
if current_password is not None:
|
||||
db.put("password", current_password)
|
||||
if current_secret is not None:
|
||||
db.put("secret", current_secret)
|
||||
|
||||
# Restart the application state
|
||||
await shutdown_main()
|
||||
await startup_main()
|
||||
|
||||
return {'status': 'ok'}
|
||||
|
||||
app.include_router(api)
|
||||
frontend_deploy(app)
|
||||
|
||||
|
||||
@ -451,6 +451,7 @@ class NfQueue {
|
||||
// error code taken from the linux kernel:
|
||||
// https://elixir.bootlin.com/linux/v5.18.12/source/include/linux/errno.h#L27
|
||||
#define ENOTSUPP 524 /* Operation is not supported */
|
||||
#define EOPNOTSUPP 95 /* Operation not supported on transport endpoint */
|
||||
|
||||
if (error_msg->error != -ENOTSUPP && error_msg->error != -EOPNOTSUPP) {
|
||||
_clear();
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
#define DEBUG
|
||||
#define PY_SSIZE_T_CLEAN
|
||||
#include <Python.h>
|
||||
|
||||
|
||||
@ -97,7 +97,7 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
|
||||
pyq->pkt->drop();// This is needed because the callback has to take the updated pkt pointer!
|
||||
}
|
||||
|
||||
void filter_action(NfQueue::PktRequest<PyProxyQueue>* pkt, Stream& stream, const string& data){
|
||||
void filter_action(NfQueue::PktRequest<PyProxyQueue>* pkt, Stream& stream, const string& data, bool is_client){
|
||||
auto stream_search = sctx.streams_ctx.find(pkt->sid);
|
||||
pyfilter_ctx* stream_match;
|
||||
if (stream_search == sctx.streams_ctx.end()){
|
||||
@ -129,7 +129,7 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
|
||||
stream_match = stream_search->second;
|
||||
}
|
||||
|
||||
auto result = stream_match->handle_packet(pkt, data);
|
||||
auto result = stream_match->handle_packet(pkt, data, is_client);
|
||||
switch(result.action){
|
||||
case PyFilterResponse::ACCEPT:
|
||||
return pkt->accept();
|
||||
@ -169,21 +169,21 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
|
||||
}
|
||||
|
||||
|
||||
static void on_data_recv(Stream& stream, PyProxyQueue* pyq, const string& data) {
|
||||
static void on_data_recv(Stream& stream, PyProxyQueue* pyq, const string& data, bool is_client) {
|
||||
pyq->pkt->fix_data_payload();
|
||||
pyq->filter_action(pyq->pkt, stream, data); //Only here the rebuilt_tcp_data is set
|
||||
pyq->filter_action(pyq->pkt, stream, data, is_client); //Only here the rebuilt_tcp_data is set
|
||||
}
|
||||
|
||||
//Input data filtering
|
||||
static void on_client_data(Stream& stream, PyProxyQueue* pyq) {
|
||||
auto data = stream.client_payload();
|
||||
on_data_recv(stream, pyq, string((char*)data.data(), data.size()));
|
||||
on_data_recv(stream, pyq, string((char*)data.data(), data.size()), true);
|
||||
}
|
||||
|
||||
//Server data filtering
|
||||
static void on_server_data(Stream& stream, PyProxyQueue* pyq) {
|
||||
auto data = stream.server_payload();
|
||||
on_data_recv(stream, pyq, string((char*)data.data(), data.size()));
|
||||
on_data_recv(stream, pyq, string((char*)data.data(), data.size()), false);
|
||||
}
|
||||
|
||||
// A stream was terminated. The second argument is the reason why it was terminated
|
||||
|
||||
@ -105,7 +105,8 @@ struct pyfilter_ctx {
|
||||
|
||||
py_filter_response handle_packet(
|
||||
NfQueue::PktRequest<PyProxyQueue>* pkt,
|
||||
const string& data
|
||||
const string& data,
|
||||
bool is_client
|
||||
){
|
||||
PyObject * packet_info = PyDict_New();
|
||||
|
||||
@ -113,7 +114,7 @@ struct pyfilter_ctx {
|
||||
set_item_to_dict(packet_info, "data", PyBytes_FromStringAndSize(data.c_str(), data.size()));
|
||||
set_item_to_dict(packet_info, "l4_size", PyLong_FromLong(pkt->data_size()));
|
||||
set_item_to_dict(packet_info, "raw_packet", PyBytes_FromStringAndSize(pkt->packet.c_str(), pkt->packet.size()));
|
||||
set_item_to_dict(packet_info, "is_input", PyBool_FromLong(pkt->is_input));
|
||||
set_item_to_dict(packet_info, "is_input", PyBool_FromLong(is_client));
|
||||
set_item_to_dict(packet_info, "is_ipv6", PyBool_FromLong(pkt->is_ipv6));
|
||||
set_item_to_dict(packet_info, "is_tcp", PyBool_FromLong(pkt->l4_proto == NfQueue::L4Proto::TCP));
|
||||
|
||||
@ -243,6 +244,7 @@ typedef map<stream_id, pyfilter_ctx*> matching_map;
|
||||
struct stream_ctx {
|
||||
|
||||
matching_map streams_ctx;
|
||||
|
||||
NfQueue::tcp_ack_map tcp_ack_ctx;
|
||||
|
||||
void clean_stream_by_id(stream_id sid){
|
||||
|
||||
@ -9,6 +9,15 @@ if [ -n "$SOCKET_DIR" ]; then
|
||||
chmod 755 "$SOCKET_DIR"
|
||||
fi
|
||||
|
||||
# Set host sysctls while running as root.
|
||||
if [ -d "/sys_host" ]; then
|
||||
echo "[*] Setting host sysctls..."
|
||||
echo 1 > /sys_host/net.ipv4.conf.all.forwarding 2>/dev/null || true
|
||||
echo 1 > /sys_host/net.ipv6.conf.all.forwarding 2>/dev/null || true
|
||||
echo 1 > /sys_host/net.ipv4.conf.all.route_localnet 2>/dev/null || true
|
||||
echo 1 > /sys_host/net.ipv4.ip_forward 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "[*] Attempting to start with capabilities..."
|
||||
|
||||
if capsh --caps="cap_net_admin,cap_setpcap,cap_setuid,cap_setgid,cap_sys_nice+eip" \
|
||||
@ -21,7 +30,8 @@ then
|
||||
--keep=1 \
|
||||
--user=nobody \
|
||||
--addamb=cap_net_admin,cap_sys_nice \
|
||||
-- -c "python3 /execute/app.py DOCKER"
|
||||
--shell=/usr/bin/python3 \
|
||||
-- /execute/app.py DOCKER
|
||||
else
|
||||
echo "[!] capsh failed, running with root user"
|
||||
exec python3 /execute/app.py DOCKER
|
||||
|
||||
@ -6,7 +6,6 @@ import traceback
|
||||
from fastapi import HTTPException
|
||||
import time
|
||||
from utils import run_func
|
||||
from utils import DEBUG
|
||||
from utils import nicenessify
|
||||
|
||||
nft = FiregexTables()
|
||||
@ -65,8 +64,6 @@ class FiregexInterceptor:
|
||||
while True:
|
||||
try:
|
||||
out_data = (await self.process.stdout.read(1024*10)).decode(errors="ignore")
|
||||
if DEBUG:
|
||||
print(out_data, end="")
|
||||
except asyncio.exceptions.LimitOverrunError:
|
||||
self.outstrem_buffer = ""
|
||||
continue
|
||||
@ -90,11 +87,11 @@ class FiregexInterceptor:
|
||||
proxy_binary_path, stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
env={
|
||||
env=dict(os.environ, **{
|
||||
"NTHREADS": os.getenv("NTHREADS","1"),
|
||||
"FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0",
|
||||
"FIREGEX_NFPROXY_SOCK": self.sock_path
|
||||
},
|
||||
}),
|
||||
)
|
||||
nicenessify(-10, self.process.pid)
|
||||
self.outstrem_task = asyncio.create_task(self._stream_handler())
|
||||
@ -137,7 +134,6 @@ class FiregexInterceptor:
|
||||
raise HTTPException(status_code=500, detail="Can't read from nfq client") from e
|
||||
if line.startswith("BLOCKED "):
|
||||
filter_name = line.split()[1]
|
||||
print("BLOCKED", filter_name)
|
||||
async with self.filter_map_lock:
|
||||
if filter_name in self.filter_map:
|
||||
self.filter_map[filter_name].blocked_packets+=1
|
||||
|
||||
@ -3,6 +3,7 @@ from modules.nfproxy.firegex import FiregexInterceptor
|
||||
from modules.nfproxy.nftables import FiregexTables, FiregexFilter
|
||||
from modules.nfproxy.models import Service, PyFilter
|
||||
from utils.sqlite import SQLite
|
||||
from utils.sqlite import SQLite
|
||||
from utils import run_func
|
||||
|
||||
class STATUS:
|
||||
@ -49,16 +50,17 @@ class ServiceManager:
|
||||
def __update_status_db(self, status):
|
||||
self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id)
|
||||
|
||||
async def next(self,to):
|
||||
async def next(self,to,persist:bool=True):
|
||||
async with self.lock:
|
||||
if to == STATUS.STOP:
|
||||
await self.stop()
|
||||
await self.stop(persist=persist)
|
||||
if to == STATUS.ACTIVE:
|
||||
await self.restart()
|
||||
|
||||
def _set_status(self,status):
|
||||
def _set_status(self,status,persist:bool=True):
|
||||
self.status = status
|
||||
self.__update_status_db(status)
|
||||
if persist:
|
||||
self.__update_status_db(status)
|
||||
|
||||
def read_outstrem_buffer(self):
|
||||
if self.interceptor:
|
||||
@ -73,12 +75,12 @@ class ServiceManager:
|
||||
await self._update_filters_from_db()
|
||||
self._set_status(STATUS.ACTIVE)
|
||||
|
||||
async def stop(self):
|
||||
async def stop(self,persist:bool=True):
|
||||
nft.delete(self.srv)
|
||||
if self.interceptor:
|
||||
await self.interceptor.stop()
|
||||
self.interceptor = None
|
||||
self._set_status(STATUS.STOP)
|
||||
self._set_status(STATUS.STOP,persist=persist)
|
||||
|
||||
async def restart(self):
|
||||
await self.stop()
|
||||
@ -88,6 +90,8 @@ class ServiceManager:
|
||||
async with self.lock:
|
||||
await self._update_filters_from_db()
|
||||
|
||||
|
||||
|
||||
class FirewallManager:
|
||||
def __init__(self, db:SQLite, outstream_func=None, exception_func=None):
|
||||
self.db = db
|
||||
@ -98,12 +102,16 @@ class FirewallManager:
|
||||
|
||||
async def close(self):
|
||||
for key in list(self.service_table.keys()):
|
||||
await self.remove(key)
|
||||
try:
|
||||
await self.remove(key, persist=False)
|
||||
except Exception:
|
||||
# Don't let one broken service block shutdown of the others
|
||||
self.service_table.pop(key, None)
|
||||
|
||||
async def remove(self,srv_id):
|
||||
async with self.lock:
|
||||
async def remove(self,srv_id,persist:bool=True):
|
||||
async with self.lock:
|
||||
if srv_id in self.service_table:
|
||||
await self.service_table[srv_id].next(STATUS.STOP)
|
||||
await self.service_table[srv_id].next(STATUS.STOP,persist=persist)
|
||||
del self.service_table[srv_id]
|
||||
|
||||
async def init(self):
|
||||
@ -112,12 +120,14 @@ class FirewallManager:
|
||||
|
||||
async def reload(self):
|
||||
async with self.lock:
|
||||
for srv in self.db.query('SELECT * FROM services;'):
|
||||
srv = Service.from_dict(srv)
|
||||
if srv.id in self.service_table:
|
||||
services = self.db.query('SELECT * FROM services;')
|
||||
|
||||
for srv in services:
|
||||
if srv["service_id"] in self.service_table:
|
||||
continue
|
||||
self.service_table[srv.id] = ServiceManager(srv, self.db, outstream_func=self.outstream_function, exception_func=self.exception_function)
|
||||
await self.service_table[srv.id].next(srv.status)
|
||||
srv_obj = Service.from_dict(srv)
|
||||
self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db, outstream_func=self.outstream_function, exception_func=self.exception_function)
|
||||
await self.service_table[srv_obj.id].next(srv_obj.status)
|
||||
|
||||
def get(self,srv_id) -> ServiceManager:
|
||||
if srv_id in self.service_table:
|
||||
|
||||
@ -3,11 +3,15 @@ class Service:
|
||||
self,
|
||||
service_id: str,
|
||||
status: str,
|
||||
port: int,
|
||||
port: int | None,
|
||||
name: str,
|
||||
proto: str,
|
||||
ip_int: str,
|
||||
ip_int: str | None,
|
||||
fail_open: bool,
|
||||
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
|
||||
@ -17,6 +21,10 @@ class Service:
|
||||
self.proto = proto
|
||||
self.ip_int = ip_int
|
||||
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
|
||||
def from_dict(cls, var: dict):
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from modules.nfproxy.models import Service
|
||||
from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json
|
||||
from modules.tls.manager import get_tls_ports, TLSManager
|
||||
|
||||
def convert_protocol_to_l4(proto:str):
|
||||
if proto == "tcp":
|
||||
@ -9,6 +10,20 @@ def convert_protocol_to_l4(proto:str):
|
||||
else:
|
||||
raise Exception("Invalid protocol")
|
||||
|
||||
def resolve_target(srv: Service) -> tuple[str, int] | None:
|
||||
"""Returns the (ip_int, port) a service's intercept rule should actually match.
|
||||
For target_type=='tls' this is the loopback clear_port where nginx exposes the
|
||||
decrypted plaintext, not the service's own (real backend) ip_int/port. Returns
|
||||
None if the linked TLS stream can't be resolved (e.g. it was deleted)."""
|
||||
if srv.target_type == "tls":
|
||||
tls_stream = TLSManager().get_stream(srv.tls_stream_id)
|
||||
if not tls_stream:
|
||||
return None
|
||||
_, clear_port = get_tls_ports(tls_stream["ip_int"], tls_stream["port"])
|
||||
loopback_ip = "::1" if ip_family(tls_stream["ip_int"]) == "ip6" else "127.0.0.1"
|
||||
return loopback_ip, clear_port
|
||||
return srv.ip_int, srv.port
|
||||
|
||||
class FiregexFilter:
|
||||
def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int):
|
||||
self.id = id
|
||||
@ -18,13 +33,25 @@ class FiregexFilter:
|
||||
self.ip_int = str(ip_int)
|
||||
|
||||
def __eq__(self, o: object) -> bool:
|
||||
if isinstance(o, FiregexFilter) or isinstance(o, Service):
|
||||
return self.port == o.port and self.proto == convert_protocol_to_l4(o.proto) and ip_parse(self.ip_int) == ip_parse(o.ip_int)
|
||||
if isinstance(o, FiregexFilter):
|
||||
return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int)
|
||||
if isinstance(o, Service):
|
||||
return self.matches(o)
|
||||
return False
|
||||
|
||||
def matches(self, srv: Service) -> bool:
|
||||
if self.proto != convert_protocol_to_l4(srv.proto):
|
||||
return False
|
||||
target = resolve_target(srv)
|
||||
if target is None:
|
||||
return False
|
||||
target_ip, target_port = target
|
||||
return self.port == target_port and ip_parse(self.ip_int) == ip_parse(target_ip)
|
||||
|
||||
class FiregexTables(NFTableManager):
|
||||
input_chain = "nfproxy_input"
|
||||
output_chain = "nfproxy_output"
|
||||
nat_chain = "nfproxy_nat"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__([
|
||||
@ -54,33 +81,39 @@ class FiregexTables(NFTableManager):
|
||||
])
|
||||
|
||||
def add(self, srv:Service, queue_range):
|
||||
|
||||
|
||||
for ele in self.get():
|
||||
if ele.__eq__(srv):
|
||||
if ele.matches(srv):
|
||||
return
|
||||
|
||||
|
||||
target = resolve_target(srv)
|
||||
if target is None:
|
||||
return
|
||||
target_ip, target_port = target
|
||||
|
||||
init, end = queue_range
|
||||
if init > end:
|
||||
init, end = end, init
|
||||
|
||||
self.cmd(
|
||||
{ "insert":{ "rule": {
|
||||
{ "insert":{ "rule": { # Send non-TLS outbound traffic to NFQUEUE (Client -> Server)
|
||||
"family": "inet",
|
||||
"table": self.table_name,
|
||||
"chain": self.output_chain,
|
||||
"expr": [
|
||||
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}},
|
||||
{'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "sport"}}, "op": "==", "right": int(srv.port)}},
|
||||
{'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}},
|
||||
{'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "dport"}}, "op": "==", "right": int(target_port)}},
|
||||
{"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}},
|
||||
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
|
||||
]
|
||||
}}},
|
||||
{"insert":{"rule":{
|
||||
{"insert":{"rule":{ # Send non-TLS inbound traffic to NFQUEUE (Server -> Client)
|
||||
"family": "inet",
|
||||
"table": self.table_name,
|
||||
"chain": self.input_chain,
|
||||
"expr": [
|
||||
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}},
|
||||
{'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "dport"}}, "op": "==", "right": int(srv.port)}},
|
||||
{'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}},
|
||||
{'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "sport"}}, "op": "==", "right": int(target_port)}},
|
||||
{"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}},
|
||||
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
|
||||
]
|
||||
@ -91,23 +124,26 @@ class FiregexTables(NFTableManager):
|
||||
def get(self) -> list[FiregexFilter]:
|
||||
res = []
|
||||
for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]):
|
||||
ip_int = None
|
||||
if isinstance(filter["expr"][0]["match"]["right"],str):
|
||||
ip_int = str(ip_parse(filter["expr"][0]["match"]["right"]))
|
||||
else:
|
||||
ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}'
|
||||
res.append(FiregexFilter(
|
||||
target=filter["chain"],
|
||||
id=int(filter["handle"]),
|
||||
proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"],
|
||||
port=filter["expr"][1]["match"]["right"],
|
||||
ip_int=ip_int
|
||||
))
|
||||
try:
|
||||
ip_int = None
|
||||
if isinstance(filter["expr"][0]["match"]["right"],str):
|
||||
ip_int = str(ip_parse(filter["expr"][0]["match"]["right"]))
|
||||
else:
|
||||
ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}'
|
||||
res.append(FiregexFilter(
|
||||
target=filter["chain"],
|
||||
id=int(filter["handle"]),
|
||||
proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"],
|
||||
port=filter["expr"][1]["match"]["right"],
|
||||
ip_int=ip_int
|
||||
))
|
||||
except (KeyError, TypeError, IndexError):
|
||||
pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip
|
||||
return res
|
||||
|
||||
def delete(self, srv:Service):
|
||||
for filter in self.get():
|
||||
if filter.__eq__(srv):
|
||||
if filter.matches(srv):
|
||||
self.cmd({ "delete":{ "rule": {
|
||||
"family": "inet",
|
||||
"table": self.table_name,
|
||||
|
||||
@ -101,11 +101,11 @@ class FiregexInterceptor:
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
proxy_binary_path,
|
||||
stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
|
||||
env={
|
||||
env=dict(os.environ, **{
|
||||
"MATCH_MODE": "stream" if self.srv.proto == "tcp" else "block",
|
||||
"NTHREADS": os.getenv("NTHREADS","1"),
|
||||
"FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0",
|
||||
},
|
||||
}),
|
||||
)
|
||||
nicenessify(-10, self.process.pid)
|
||||
line_fut = self.process.stdout.readuntil()
|
||||
|
||||
@ -43,19 +43,20 @@ class ServiceManager:
|
||||
def __update_status_db(self, status):
|
||||
self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id)
|
||||
|
||||
async def next(self,to):
|
||||
async def next(self,to,persist:bool=True):
|
||||
async with self.lock:
|
||||
if to == STATUS.STOP:
|
||||
await self.stop()
|
||||
await self.stop(persist=persist)
|
||||
if to == STATUS.ACTIVE:
|
||||
await self.restart()
|
||||
|
||||
def _stats_updater(self,filter:RegexFilter):
|
||||
self.db.query("UPDATE regexes SET blocked_packets = ? WHERE regex_id = ?;", filter.blocked, filter.id)
|
||||
|
||||
def _set_status(self,status):
|
||||
def _set_status(self,status,persist:bool=True):
|
||||
self.status = status
|
||||
self.__update_status_db(status)
|
||||
if persist:
|
||||
self.__update_status_db(status)
|
||||
|
||||
async def start(self):
|
||||
if not self.interceptor:
|
||||
@ -64,12 +65,12 @@ class ServiceManager:
|
||||
await self._update_filters_from_db()
|
||||
self._set_status(STATUS.ACTIVE)
|
||||
|
||||
async def stop(self):
|
||||
async def stop(self,persist:bool=True):
|
||||
nft.delete(self.srv)
|
||||
if self.interceptor:
|
||||
await self.interceptor.stop()
|
||||
self.interceptor = None
|
||||
self._set_status(STATUS.STOP)
|
||||
self._set_status(STATUS.STOP,persist=persist)
|
||||
|
||||
async def restart(self):
|
||||
await self.stop()
|
||||
@ -79,6 +80,8 @@ class ServiceManager:
|
||||
async with self.lock:
|
||||
await self._update_filters_from_db()
|
||||
|
||||
|
||||
|
||||
class FirewallManager:
|
||||
def __init__(self, db:SQLite):
|
||||
self.db = db
|
||||
@ -87,12 +90,16 @@ class FirewallManager:
|
||||
|
||||
async def close(self):
|
||||
for key in list(self.service_table.keys()):
|
||||
await self.remove(key)
|
||||
try:
|
||||
await self.remove(key, persist=False)
|
||||
except Exception:
|
||||
# Don't let one broken service block shutdown of the others
|
||||
self.service_table.pop(key, None)
|
||||
|
||||
async def remove(self,srv_id):
|
||||
async with self.lock:
|
||||
async def remove(self,srv_id,persist:bool=True):
|
||||
async with self.lock:
|
||||
if srv_id in self.service_table:
|
||||
await self.service_table[srv_id].next(STATUS.STOP)
|
||||
await self.service_table[srv_id].next(STATUS.STOP,persist=persist)
|
||||
del self.service_table[srv_id]
|
||||
|
||||
async def init(self):
|
||||
@ -101,12 +108,14 @@ class FirewallManager:
|
||||
|
||||
async def reload(self):
|
||||
async with self.lock:
|
||||
for srv in self.db.query('SELECT * FROM services;'):
|
||||
srv = Service.from_dict(srv)
|
||||
if srv.id in self.service_table:
|
||||
services = self.db.query('SELECT * FROM services;')
|
||||
|
||||
for srv in services:
|
||||
if srv["service_id"] in self.service_table:
|
||||
continue
|
||||
self.service_table[srv.id] = ServiceManager(srv, self.db)
|
||||
await self.service_table[srv.id].next(srv.status)
|
||||
srv_obj = Service.from_dict(srv)
|
||||
self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db)
|
||||
await self.service_table[srv_obj.id].next(srv_obj.status)
|
||||
|
||||
def get(self,srv_id) -> ServiceManager:
|
||||
if srv_id in self.service_table:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import base64
|
||||
|
||||
class Service:
|
||||
def __init__(self, service_id: str, status: str, port: int, name: str, proto: str, ip_int: str, fail_open: bool, **other):
|
||||
def __init__(self, service_id: str, status: str, port: int | None, name: str, proto: str, ip_int: str | None, fail_open: bool, target_type: str = "flow", tls_stream_id: str | None = None, tls_cert: str | None = None, tls_key: str | None = None, **other):
|
||||
self.id = service_id
|
||||
self.status = status
|
||||
self.port = port
|
||||
@ -9,6 +9,10 @@ class Service:
|
||||
self.proto = proto
|
||||
self.ip_int = ip_int
|
||||
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
|
||||
def from_dict(cls, var: dict):
|
||||
|
||||
@ -1,5 +1,20 @@
|
||||
from modules.nfregex.models import Service
|
||||
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:
|
||||
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)
|
||||
|
||||
def __eq__(self, o: object) -> bool:
|
||||
if isinstance(o, FiregexFilter) or isinstance(o, Service):
|
||||
if isinstance(o, FiregexFilter):
|
||||
return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int)
|
||||
if isinstance(o, Service):
|
||||
return self.matches(o)
|
||||
return False
|
||||
|
||||
def matches(self, srv: Service) -> bool:
|
||||
if self.proto != str(srv.proto):
|
||||
return False
|
||||
target = resolve_target(srv)
|
||||
if target is None:
|
||||
return False
|
||||
target_ip, target_port = target
|
||||
return self.port == target_port and ip_parse(self.ip_int) == ip_parse(target_ip)
|
||||
|
||||
class FiregexTables(NFTableManager):
|
||||
input_chain = "nfregex_input"
|
||||
output_chain = "nfregex_output"
|
||||
nat_chain = "nfregex_nat"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__([
|
||||
@ -46,22 +73,28 @@ class FiregexTables(NFTableManager):
|
||||
])
|
||||
|
||||
def add(self, srv:Service, queue_range):
|
||||
|
||||
|
||||
for ele in self.get():
|
||||
if ele.__eq__(srv):
|
||||
if ele.matches(srv):
|
||||
return
|
||||
|
||||
|
||||
target = resolve_target(srv)
|
||||
if target is None:
|
||||
return
|
||||
target_ip, target_port = target
|
||||
|
||||
init, end = queue_range
|
||||
if init > end:
|
||||
init, end = end, init
|
||||
|
||||
self.cmd(
|
||||
{ "insert":{ "rule": {
|
||||
"family": "inet",
|
||||
"table": self.table_name,
|
||||
"chain": self.output_chain,
|
||||
"expr": [
|
||||
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}},
|
||||
{'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "sport"}}, "op": "==", "right": int(srv.port)}},
|
||||
{'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}},
|
||||
{'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "sport"}}, "op": "==", "right": int(target_port)}},
|
||||
{"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}},
|
||||
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
|
||||
]
|
||||
@ -71,8 +104,8 @@ class FiregexTables(NFTableManager):
|
||||
"table": self.table_name,
|
||||
"chain": self.input_chain,
|
||||
"expr": [
|
||||
{'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}},
|
||||
{'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "dport"}}, "op": "==", "right": int(srv.port)}},
|
||||
{'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}},
|
||||
{'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "dport"}}, "op": "==", "right": int(target_port)}},
|
||||
{"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}},
|
||||
{"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}}
|
||||
]
|
||||
@ -83,27 +116,33 @@ class FiregexTables(NFTableManager):
|
||||
def get(self) -> list[FiregexFilter]:
|
||||
res = []
|
||||
for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]):
|
||||
ip_int = None
|
||||
if isinstance(filter["expr"][0]["match"]["right"],str):
|
||||
ip_int = str(ip_parse(filter["expr"][0]["match"]["right"]))
|
||||
else:
|
||||
ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}'
|
||||
res.append(FiregexFilter(
|
||||
target=filter["chain"],
|
||||
id=int(filter["handle"]),
|
||||
proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"],
|
||||
port=filter["expr"][1]["match"]["right"],
|
||||
ip_int=ip_int
|
||||
))
|
||||
try:
|
||||
ip_int = None
|
||||
if isinstance(filter["expr"][0]["match"]["right"],str):
|
||||
ip_int = str(ip_parse(filter["expr"][0]["match"]["right"]))
|
||||
else:
|
||||
ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}'
|
||||
res.append(FiregexFilter(
|
||||
target=filter["chain"],
|
||||
id=int(filter["handle"]),
|
||||
proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"],
|
||||
port=filter["expr"][1]["match"]["right"],
|
||||
ip_int=ip_int
|
||||
))
|
||||
except (KeyError, TypeError, IndexError):
|
||||
pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip
|
||||
return res
|
||||
|
||||
def delete(self, srv:Service):
|
||||
delete_cmds = []
|
||||
for filter in self.get():
|
||||
if filter.__eq__(srv):
|
||||
self.cmd({ "delete":{ "rule": {
|
||||
if filter.matches(srv):
|
||||
delete_cmds.append({ "delete":{ "rule": {
|
||||
"family": "inet",
|
||||
"table": self.table_name,
|
||||
"chain": filter.target,
|
||||
"handle": filter.id
|
||||
}}})
|
||||
if delete_cmds:
|
||||
self.cmd(*delete_cmds)
|
||||
|
||||
@ -22,20 +22,21 @@ class ServiceManager:
|
||||
nft.add(self.srv)
|
||||
self._set_status(True)
|
||||
|
||||
async def disable(self):
|
||||
async def disable(self,persist:bool=True):
|
||||
if self.active:
|
||||
async with self.lock:
|
||||
nft.delete(self.srv)
|
||||
self._set_status(False)
|
||||
self._set_status(False,persist=persist)
|
||||
|
||||
async def refresh(self, srv:Service):
|
||||
self.srv = srv
|
||||
if self.active:
|
||||
await self.restart()
|
||||
|
||||
def _set_status(self,active):
|
||||
|
||||
def _set_status(self,active,persist:bool=True):
|
||||
self.active = active
|
||||
self.db.query("UPDATE services SET active = ? WHERE service_id = ?;", active, self.srv.service_id)
|
||||
if persist:
|
||||
self.db.query("UPDATE services SET active = ? WHERE service_id = ?;", active, self.srv.service_id)
|
||||
|
||||
async def restart(self):
|
||||
await self.disable()
|
||||
@ -49,12 +50,16 @@ class FirewallManager:
|
||||
|
||||
async def close(self):
|
||||
for key in list(self.service_table.keys()):
|
||||
await self.remove(key)
|
||||
try:
|
||||
await self.remove(key, persist=False)
|
||||
except Exception:
|
||||
# Don't let one broken service block shutdown of the others
|
||||
self.service_table.pop(key, None)
|
||||
|
||||
async def remove(self,srv_id):
|
||||
async with self.lock:
|
||||
async def remove(self,srv_id,persist:bool=True):
|
||||
async with self.lock:
|
||||
if srv_id in self.service_table:
|
||||
await self.service_table[srv_id].disable()
|
||||
await self.service_table[srv_id].disable(persist=persist)
|
||||
del self.service_table[srv_id]
|
||||
|
||||
async def init(self):
|
||||
|
||||
1
backend/modules/tls/__init__.py
Normal file
1
backend/modules/tls/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# TLS Decrypt Module
|
||||
92
backend/modules/tls/manager.py
Normal file
92
backend/modules/tls/manager.py
Normal 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';")
|
||||
43
backend/modules/tls/nftables.py
Normal file
43
backend/modules/tls/nftables.py
Normal 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()
|
||||
83
backend/modules/tls/nginx.py
Normal file
83
backend/modules/tls/nginx.py
Normal 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)
|
||||
22
backend/modules/tls/service.py
Normal file
22
backend/modules/tls/service.py
Normal 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
|
||||
@ -50,12 +50,9 @@ async def startup():
|
||||
await firewall.init()
|
||||
|
||||
async def shutdown():
|
||||
keep_rules = firewall.keep_rules
|
||||
db.backup()
|
||||
if not keep_rules:
|
||||
if not firewall.keep_rules:
|
||||
await firewall.close()
|
||||
db.disconnect()
|
||||
db.restore()
|
||||
|
||||
async def refresh_frontend(additional:list[str]=[]):
|
||||
await socketio_emit(["firewall"]+additional)
|
||||
|
||||
@ -6,6 +6,7 @@ from modules.nfproxy.nftables import FiregexTables
|
||||
from modules.nfproxy.firewall import STATUS, FirewallManager, ServiceNotFoundException
|
||||
from utils.sqlite import SQLite
|
||||
from utils import ip_parse, refactor_name, socketio_emit, PortType
|
||||
from modules.tls.service import activate_stream
|
||||
from utils.models import ResetRequest, StatusMessageModel
|
||||
import os
|
||||
from firegex.nfproxy.internals import get_filter_names
|
||||
@ -19,22 +20,27 @@ import utils
|
||||
class ServiceModel(BaseModel):
|
||||
service_id: str
|
||||
status: str
|
||||
port: PortType
|
||||
port: PortType|None = None
|
||||
name: str
|
||||
proto: str
|
||||
ip_int: str
|
||||
ip_int: str|None = None
|
||||
n_filters: int
|
||||
edited_packets: int
|
||||
blocked_packets: int
|
||||
fail_open: bool
|
||||
target_type: str
|
||||
tls_stream_id: str|None = None
|
||||
|
||||
class RenameForm(BaseModel):
|
||||
name:str
|
||||
|
||||
class SettingsForm(BaseModel):
|
||||
port: PortType|None = None
|
||||
proto: str|None = None
|
||||
ip_int: str|None = None
|
||||
fail_open: bool|None = None
|
||||
target_type: str|None = None
|
||||
tls_stream_id: str|None = None
|
||||
|
||||
class PyFilterModel(BaseModel):
|
||||
name: str
|
||||
@ -45,10 +51,12 @@ class PyFilterModel(BaseModel):
|
||||
|
||||
class ServiceAddForm(BaseModel):
|
||||
name: str
|
||||
port: PortType
|
||||
port: PortType|None = None
|
||||
proto: str
|
||||
ip_int: str
|
||||
ip_int: str|None = None
|
||||
fail_open: bool = True
|
||||
target_type: str = "flow"
|
||||
tls_stream_id: str|None = None
|
||||
|
||||
class ServiceAddResponse(BaseModel):
|
||||
status:str
|
||||
@ -63,11 +71,13 @@ db = SQLite('db/nft-pyfilters.db', {
|
||||
'services': {
|
||||
'service_id': 'VARCHAR(100) PRIMARY KEY',
|
||||
'status': 'VARCHAR(100) NOT NULL',
|
||||
'port': 'INT NOT NULL CHECK(port > 0 and port < 65536)',
|
||||
'target_type': 'VARCHAR(10) NOT NULL CHECK(target_type IN ("flow", "tls")) DEFAULT "flow"',
|
||||
'tls_stream_id': 'VARCHAR(100)',
|
||||
'port': 'INT CHECK(port > 0 and port < 65536)',
|
||||
'name': 'VARCHAR(100) NOT NULL UNIQUE',
|
||||
'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "http"))',
|
||||
'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_proto IN ("tcp", "udp"))',
|
||||
'ip_int': 'VARCHAR(100) NOT NULL',
|
||||
'ip_int': 'VARCHAR(100)',
|
||||
'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1',
|
||||
},
|
||||
'pyfilter': {
|
||||
@ -115,10 +125,8 @@ async def startup():
|
||||
utils.socketio.on("nfproxy-exception-leave", leave_exception)
|
||||
|
||||
async def shutdown():
|
||||
db.backup()
|
||||
await firewall.close()
|
||||
db.disconnect()
|
||||
db.restore()
|
||||
|
||||
def gen_service_id():
|
||||
while True:
|
||||
@ -138,7 +146,7 @@ firewall = FirewallManager(db, outstream_func=outstream_func, exception_func=exc
|
||||
@app.get('/services', response_model=list[ServiceModel])
|
||||
async def get_service_list():
|
||||
"""Get the list of existent firegex services"""
|
||||
return db.query("""
|
||||
res = db.query("""
|
||||
SELECT
|
||||
s.service_id service_id,
|
||||
s.status status,
|
||||
@ -147,12 +155,15 @@ async def get_service_list():
|
||||
s.proto proto,
|
||||
s.ip_int ip_int,
|
||||
s.fail_open fail_open,
|
||||
s.target_type target_type,
|
||||
s.tls_stream_id tls_stream_id,
|
||||
COUNT(f.name) n_filters,
|
||||
COALESCE(SUM(f.blocked_packets),0) blocked_packets,
|
||||
COALESCE(SUM(f.edited_packets),0) edited_packets
|
||||
FROM services s LEFT JOIN pyfilter f ON s.service_id = f.service_id
|
||||
GROUP BY s.service_id;
|
||||
""")
|
||||
return res
|
||||
|
||||
@app.get('/services/{service_id}', response_model=ServiceModel)
|
||||
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.ip_int ip_int,
|
||||
s.fail_open fail_open,
|
||||
s.target_type target_type,
|
||||
s.tls_stream_id tls_stream_id,
|
||||
COUNT(f.name) n_filters,
|
||||
COALESCE(SUM(f.blocked_packets),0) blocked_packets,
|
||||
COALESCE(SUM(f.edited_packets),0) edited_packets
|
||||
@ -186,6 +199,10 @@ async def service_stop(service_id: str):
|
||||
@app.post('/services/{service_id}/start', response_model=StatusMessageModel)
|
||||
async def service_start(service_id: str):
|
||||
"""Request the start of a specific service"""
|
||||
srv = db.query("SELECT target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id)
|
||||
if srv and srv[0]["target_type"] == "tls":
|
||||
if not await activate_stream(srv[0]["tls_stream_id"]):
|
||||
raise HTTPException(status_code=400, detail="Linked TLS stream not found")
|
||||
await firewall.get(service_id).next(STATUS.ACTIVE)
|
||||
await refresh_frontend()
|
||||
return {'status': 'ok'}
|
||||
@ -218,6 +235,11 @@ async def service_rename(service_id: str, form: RenameForm):
|
||||
async def service_settings(service_id: str, form: SettingsForm):
|
||||
"""Request to change the settings of a specific service (will cause a restart)"""
|
||||
|
||||
srv_check = db.query("SELECT ip_int, port, 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):
|
||||
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)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid address")
|
||||
|
||||
new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"]
|
||||
new_port = form.port if form.port is not None else old_srv["port"]
|
||||
|
||||
new_target_type = form.target_type if form.target_type is not None else old_srv["target_type"]
|
||||
new_tls_stream_id = form.tls_stream_id if form.tls_stream_id is not None else old_srv["tls_stream_id"]
|
||||
|
||||
if new_target_type == "tls" and not new_tls_stream_id:
|
||||
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
|
||||
if new_target_type == "flow" and (new_ip is None or new_port is None):
|
||||
raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
|
||||
|
||||
keys = []
|
||||
values = []
|
||||
@ -233,10 +266,13 @@ async def service_settings(service_id: str, form: SettingsForm):
|
||||
for key, value in form.model_dump(exclude_none=True).items():
|
||||
keys.append(key)
|
||||
values.append(value)
|
||||
if key == "proto":
|
||||
keys.append("l4_proto")
|
||||
values.append(convert_protocol_to_l4(value))
|
||||
|
||||
if len(keys) == 0:
|
||||
raise HTTPException(status_code=400, detail="No settings to change provided")
|
||||
|
||||
|
||||
try:
|
||||
db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id)
|
||||
except sqlite3.IntegrityError:
|
||||
@ -244,12 +280,15 @@ async def service_settings(service_id: str, form: SettingsForm):
|
||||
|
||||
old_status = firewall.get(service_id).status
|
||||
await firewall.remove(service_id)
|
||||
if old_status == STATUS.ACTIVE and new_target_type == "tls":
|
||||
await activate_stream(new_tls_stream_id)
|
||||
await firewall.reload()
|
||||
await firewall.get(service_id).next(old_status)
|
||||
|
||||
|
||||
await refresh_frontend()
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
@app.get('/services/{service_id}/pyfilters', response_model=list[PyFilterModel])
|
||||
async def get_service_pyfilter_list(service_id: str):
|
||||
"""Get the list of the pyfilters of a service"""
|
||||
@ -296,17 +335,25 @@ async def pyfilter_disable(service_id: str, filter_name: str):
|
||||
@app.post('/services', response_model=ServiceAddResponse)
|
||||
async def add_new_service(form: ServiceAddForm):
|
||||
"""Add a new service"""
|
||||
try:
|
||||
form.ip_int = ip_parse(form.ip_int)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid address")
|
||||
if form.target_type == "flow":
|
||||
if form.ip_int is None or form.port is None:
|
||||
raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
|
||||
try:
|
||||
form.ip_int = ip_parse(form.ip_int)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid address")
|
||||
elif form.target_type == "tls":
|
||||
if not form.tls_stream_id:
|
||||
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
|
||||
|
||||
if form.proto not in ["tcp", "http"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid protocol")
|
||||
|
||||
srv_id = None
|
||||
try:
|
||||
srv_id = gen_service_id()
|
||||
db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, l4_proto) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, convert_protocol_to_l4(form.proto))
|
||||
db.query("INSERT INTO services (service_id, name, port, status, proto, ip_int, fail_open, l4_proto, target_type, tls_stream_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, convert_protocol_to_l4(form.proto), form.target_type, form.tls_stream_id)
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(status_code=400, detail="This type of service already exists")
|
||||
await firewall.reload()
|
||||
|
||||
@ -8,19 +8,22 @@ from modules.nfregex.nftables import FiregexTables
|
||||
from modules.nfregex.firewall import STATUS, FirewallManager
|
||||
from utils.sqlite import SQLite
|
||||
from utils import ip_parse, refactor_name, socketio_emit, PortType
|
||||
from modules.tls.service import activate_stream
|
||||
from utils.models import ResetRequest, StatusMessageModel
|
||||
from modules.nfregex.firegex import test_regex_validity
|
||||
|
||||
class ServiceModel(BaseModel):
|
||||
status: str
|
||||
service_id: str
|
||||
port: PortType
|
||||
port: PortType|None = None
|
||||
name: str
|
||||
proto: str
|
||||
ip_int: str
|
||||
ip_int: str|None = None
|
||||
n_regex: int
|
||||
n_packets: int
|
||||
fail_open: bool
|
||||
target_type: str
|
||||
tls_stream_id: str|None = None
|
||||
|
||||
class RenameForm(BaseModel):
|
||||
name:str
|
||||
@ -30,6 +33,8 @@ class SettingsForm(BaseModel):
|
||||
proto: str|None = None
|
||||
ip_int: str|None = None
|
||||
fail_open: bool|None = None
|
||||
target_type: str|None = None
|
||||
tls_stream_id: str|None = None
|
||||
|
||||
class RegexModel(BaseModel):
|
||||
regex:str
|
||||
@ -49,10 +54,12 @@ class RegexAddForm(BaseModel):
|
||||
|
||||
class ServiceAddForm(BaseModel):
|
||||
name: str
|
||||
port: PortType
|
||||
port: PortType|None = None
|
||||
proto: str
|
||||
ip_int: str
|
||||
ip_int: str|None = None
|
||||
fail_open: bool = False
|
||||
target_type: str = "flow"
|
||||
tls_stream_id: str|None = None
|
||||
|
||||
class ServiceAddResponse(BaseModel):
|
||||
status:str
|
||||
@ -64,11 +71,13 @@ db = SQLite('db/nft-regex.db', {
|
||||
'services': {
|
||||
'service_id': 'VARCHAR(100) PRIMARY KEY',
|
||||
'status': 'VARCHAR(100) NOT NULL',
|
||||
'port': 'INT NOT NULL CHECK(port > 0 and port < 65536)',
|
||||
'target_type': 'VARCHAR(10) NOT NULL CHECK(target_type IN ("flow", "tls")) DEFAULT "flow"',
|
||||
'tls_stream_id': 'VARCHAR(100)',
|
||||
'port': 'INT CHECK(port > 0 and port < 65536)',
|
||||
'name': 'VARCHAR(100) NOT NULL UNIQUE',
|
||||
'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))',
|
||||
'ip_int': 'VARCHAR(100) NOT NULL',
|
||||
'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1'
|
||||
'ip_int': 'VARCHAR(100)',
|
||||
'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1',
|
||||
},
|
||||
'regexes': {
|
||||
'regex': 'TEXT NOT NULL',
|
||||
@ -113,10 +122,8 @@ async def startup():
|
||||
print("WARNING cannot start firewall:", e)
|
||||
|
||||
async def shutdown():
|
||||
db.backup()
|
||||
await firewall.close()
|
||||
db.disconnect()
|
||||
db.restore()
|
||||
|
||||
def gen_service_id():
|
||||
while True:
|
||||
@ -130,7 +137,7 @@ firewall = FirewallManager(db)
|
||||
@app.get('/services', response_model=list[ServiceModel])
|
||||
async def get_service_list():
|
||||
"""Get the list of existent firegex services"""
|
||||
return db.query("""
|
||||
res = db.query("""
|
||||
SELECT
|
||||
s.service_id service_id,
|
||||
s.status status,
|
||||
@ -139,11 +146,14 @@ async def get_service_list():
|
||||
s.proto proto,
|
||||
s.ip_int ip_int,
|
||||
s.fail_open fail_open,
|
||||
s.target_type target_type,
|
||||
s.tls_stream_id tls_stream_id,
|
||||
COUNT(r.regex_id) n_regex,
|
||||
COALESCE(SUM(r.blocked_packets),0) n_packets
|
||||
FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id
|
||||
GROUP BY s.service_id;
|
||||
""")
|
||||
return res
|
||||
|
||||
@app.get('/services/{service_id}', response_model=ServiceModel)
|
||||
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.ip_int ip_int,
|
||||
s.fail_open fail_open,
|
||||
s.target_type target_type,
|
||||
s.tls_stream_id tls_stream_id,
|
||||
COUNT(r.regex_id) n_regex,
|
||||
COALESCE(SUM(r.blocked_packets),0) n_packets
|
||||
FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id
|
||||
@ -176,6 +188,10 @@ async def service_stop(service_id: str):
|
||||
@app.post('/services/{service_id}/start', response_model=StatusMessageModel)
|
||||
async def service_start(service_id: str):
|
||||
"""Request the start of a specific service"""
|
||||
srv = db.query("SELECT target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id)
|
||||
if srv and srv[0]["target_type"] == "tls":
|
||||
if not await activate_stream(srv[0]["tls_stream_id"]):
|
||||
raise HTTPException(status_code=400, detail="Linked TLS stream not found")
|
||||
await firewall.get(service_id).next(STATUS.ACTIVE)
|
||||
await refresh_frontend()
|
||||
return {'status': 'ok'}
|
||||
@ -206,9 +222,14 @@ async def service_rename(service_id: str, form: RenameForm):
|
||||
async def service_settings(service_id: str, form: SettingsForm):
|
||||
"""Request to change the settings of a specific service (will cause a restart)"""
|
||||
|
||||
srv_check = db.query("SELECT proto, ip_int, port, 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"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid protocol")
|
||||
|
||||
|
||||
if form.port is not None and (form.port < 1 or form.port > 65535):
|
||||
raise HTTPException(status_code=400, detail="Invalid port")
|
||||
|
||||
@ -217,6 +238,22 @@ async def service_settings(service_id: str, form: SettingsForm):
|
||||
form.ip_int = ip_parse(form.ip_int)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid address")
|
||||
|
||||
new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"]
|
||||
new_port = form.port if form.port is not None else old_srv["port"]
|
||||
new_proto = form.proto if form.proto is not None else old_srv["proto"]
|
||||
|
||||
new_target_type = form.target_type if form.target_type is not None else old_srv["target_type"]
|
||||
new_tls_stream_id = form.tls_stream_id if form.tls_stream_id is not None else old_srv["tls_stream_id"]
|
||||
|
||||
if new_target_type == "tls":
|
||||
if new_proto != "tcp":
|
||||
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
|
||||
if not new_tls_stream_id:
|
||||
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
|
||||
|
||||
if new_target_type == "flow" and (new_ip is None or new_port is None):
|
||||
raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
|
||||
|
||||
keys = []
|
||||
values = []
|
||||
@ -227,7 +264,7 @@ async def service_settings(service_id: str, form: SettingsForm):
|
||||
|
||||
if len(keys) == 0:
|
||||
raise HTTPException(status_code=400, detail="No settings to change provided")
|
||||
|
||||
|
||||
try:
|
||||
db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id)
|
||||
except sqlite3.IntegrityError:
|
||||
@ -235,9 +272,11 @@ async def service_settings(service_id: str, form: SettingsForm):
|
||||
|
||||
old_status = firewall.get(service_id).status
|
||||
await firewall.remove(service_id)
|
||||
if old_status == STATUS.ACTIVE and new_target_type == "tls":
|
||||
await activate_stream(new_tls_stream_id)
|
||||
await firewall.reload()
|
||||
await firewall.get(service_id).next(old_status)
|
||||
|
||||
|
||||
await refresh_frontend()
|
||||
return {'status': 'ok'}
|
||||
|
||||
@ -316,23 +355,34 @@ async def add_new_regex(form: RegexAddForm):
|
||||
@app.post('/services', response_model=ServiceAddResponse)
|
||||
async def add_new_service(form: ServiceAddForm):
|
||||
"""Add a new service"""
|
||||
try:
|
||||
form.ip_int = ip_parse(form.ip_int)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid address")
|
||||
if form.target_type == "flow":
|
||||
if form.ip_int is None or form.port is None:
|
||||
raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow")
|
||||
try:
|
||||
form.ip_int = ip_parse(form.ip_int)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid address")
|
||||
elif form.target_type == "tls":
|
||||
if not form.tls_stream_id:
|
||||
raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls")
|
||||
|
||||
if form.proto not in ["tcp", "udp"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid protocol")
|
||||
if form.target_type == "tls" and form.proto != "tcp":
|
||||
raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol")
|
||||
|
||||
srv_id = None
|
||||
try:
|
||||
srv_id = gen_service_id()
|
||||
db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open)
|
||||
db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, target_type, tls_stream_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, form.target_type, form.tls_stream_id)
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(status_code=400, detail="This type of service already exists")
|
||||
await firewall.reload()
|
||||
await refresh_frontend()
|
||||
return {'status': 'ok', 'service_id': srv_id}
|
||||
|
||||
|
||||
@app.get('/metrics', response_class = PlainTextResponse)
|
||||
async def metrics():
|
||||
"""Aggregate metrics"""
|
||||
@ -355,3 +405,52 @@ async def metrics():
|
||||
metrics.append(f'firegex_blocked_packets{{{props}}} {stat["blocked_packets"]}')
|
||||
metrics.append(f'firegex_active{{{props}}} {int(stat["active"] and stat["status"] == "active")}')
|
||||
return "\n".join(metrics)
|
||||
|
||||
@app.get('/services/{service_id}/export')
|
||||
async def export_service_regexes(service_id: str):
|
||||
"""Export all regexes for a specific service as JSON"""
|
||||
if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', service_id)) == 0:
|
||||
raise HTTPException(status_code=404, detail="Service not found")
|
||||
|
||||
regexes = db.query("SELECT regex, mode, is_case_sensitive, active FROM regexes WHERE service_id = ?;", service_id)
|
||||
return {"regexes": regexes}
|
||||
|
||||
@app.post('/services/{service_id}/import', response_model=StatusMessageModel)
|
||||
async def import_service_regexes(service_id: str, data: dict):
|
||||
"""Import regexes for a specific service from JSON"""
|
||||
if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', service_id)) == 0:
|
||||
raise HTTPException(status_code=404, detail="Service not found")
|
||||
|
||||
regexes = data.get("regexes", [])
|
||||
if not isinstance(regexes, list):
|
||||
raise HTTPException(status_code=400, detail="Invalid format")
|
||||
|
||||
for r in regexes:
|
||||
regex = r.get("regex")
|
||||
mode = r.get("mode")
|
||||
is_case_sensitive = r.get("is_case_sensitive", False)
|
||||
active = r.get("active", True)
|
||||
|
||||
if not regex or not mode:
|
||||
continue
|
||||
|
||||
try:
|
||||
db.query('INSERT INTO regexes (regex, mode, service_id, is_case_sensitive, active) VALUES (?, ?, ?, ?, ?);',
|
||||
regex, mode, service_id, int(is_case_sensitive), int(active))
|
||||
|
||||
# Since we inserted it directly, we need to notify the firewall to update its rules if it's active
|
||||
if active and firewall.get(service_id).status == STATUS.ACTIVE:
|
||||
# Easiest way to sync is to restart the service rule engine
|
||||
await firewall.get(service_id).next(STATUS.ACTIVE)
|
||||
except sqlite3.IntegrityError:
|
||||
# Skip duplicates
|
||||
pass
|
||||
|
||||
# Force reload of all rules for this service
|
||||
old_status = firewall.get(service_id).status
|
||||
await firewall.remove(service_id)
|
||||
await firewall.reload()
|
||||
await firewall.get(service_id).next(old_status)
|
||||
|
||||
await refresh_frontend()
|
||||
return {'status': 'ok'}
|
||||
|
||||
@ -67,10 +67,8 @@ async def startup():
|
||||
await firewall.init()
|
||||
|
||||
async def shutdown():
|
||||
db.backup()
|
||||
await firewall.close()
|
||||
db.disconnect()
|
||||
db.restore()
|
||||
|
||||
async def refresh_frontend(additional:list[str]=[]):
|
||||
await socketio_emit(["porthijack"]+additional)
|
||||
|
||||
177
backend/routers/tls.py
Normal file
177
backend/routers/tls.py
Normal 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"}
|
||||
@ -15,7 +15,10 @@ from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
LOCALHOST_IP = socket.gethostbyname(os.getenv("LOCALHOST_IP","127.0.0.1"))
|
||||
_localhost_env = os.getenv("LOCALHOST_IP", "127.0.0.1")
|
||||
if _localhost_env == "None":
|
||||
_localhost_env = "127.0.0.1"
|
||||
LOCALHOST_IP = socket.gethostbyname(_localhost_env)
|
||||
|
||||
socketio:AsyncServer = None
|
||||
sid_list:set = set()
|
||||
@ -25,9 +28,12 @@ ROUTERS_DIR = os.path.join(ROOT_DIR,"routers")
|
||||
ON_DOCKER = "DOCKER" in sys.argv
|
||||
DEBUG = "DEBUG" in sys.argv
|
||||
NORELOAD = "NORELOAD" in sys.argv
|
||||
FIREGEX_PORT = int(os.getenv("PORT","4444"))
|
||||
FIREGEX_HOST = os.getenv("HOST","0.0.0.0")
|
||||
FIREGEX_SOCKET_DIR = os.getenv("SOCKET_DIR", None)
|
||||
_port_env = os.getenv("PORT", "4444")
|
||||
FIREGEX_PORT = int(_port_env) if _port_env and _port_env != "None" else 4444
|
||||
_host_env = os.getenv("HOST", "0.0.0.0")
|
||||
FIREGEX_HOST = _host_env if _host_env and _host_env != "None" else "0.0.0.0"
|
||||
_socket_dir_env = os.getenv("SOCKET_DIR", None)
|
||||
FIREGEX_SOCKET_DIR = None if _socket_dir_env == "None" else _socket_dir_env
|
||||
FIREGEX_SOCKET = os.path.join(FIREGEX_SOCKET_DIR, "firegex.sock") if FIREGEX_SOCKET_DIR else None
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
def _get_version():
|
||||
@ -305,6 +311,8 @@ def nicenessify(priority:int, pid:int|None=None):
|
||||
ps = psutil.Process(pid)
|
||||
if os.name == 'posix':
|
||||
ps.nice(priority)
|
||||
except (psutil.AccessDenied, PermissionError) as e:
|
||||
print(f"Permission denied setting priority (pid={pid}, priority={priority}): {e}")
|
||||
except Exception as e:
|
||||
print(f"Error setting priority: {e} {traceback.format_exc()}")
|
||||
pass
|
||||
|
||||
@ -125,3 +125,44 @@ class SQLite():
|
||||
|
||||
def set(self, key, value):
|
||||
return self.put(key, value)
|
||||
|
||||
def dump(self) -> dict:
|
||||
self.connect()
|
||||
res = {}
|
||||
tables = self.query("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
for table in tables:
|
||||
tname = table["name"]
|
||||
if tname == 'keys_values':
|
||||
res[tname] = self.query(f"SELECT * FROM {tname} WHERE key NOT IN ('password', 'secret');")
|
||||
else:
|
||||
res[tname] = self.query(f"SELECT * FROM {tname};")
|
||||
return res
|
||||
|
||||
def load(self, data: dict):
|
||||
self.connect()
|
||||
cur = self.conn.cursor()
|
||||
try:
|
||||
cur.execute("BEGIN")
|
||||
for table_name, rows in data.items():
|
||||
# Verify table exists to prevent SQL injection or schema mismatches
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (table_name,))
|
||||
if len(cur.fetchall()) == 0:
|
||||
continue
|
||||
cur.execute(f"DELETE FROM {table_name};")
|
||||
if rows:
|
||||
cols = list(rows[0].keys())
|
||||
placeholders = ", ".join(["?"] * len(cols))
|
||||
col_names = ", ".join(cols)
|
||||
query = f"INSERT INTO {table_name} ({col_names}) VALUES ({placeholders})"
|
||||
for row in rows:
|
||||
cur.execute(query, [row[c] for c in cols])
|
||||
cur.execute("COMMIT")
|
||||
except Exception as e:
|
||||
cur.execute("ROLLBACK")
|
||||
raise e
|
||||
finally:
|
||||
cur.close()
|
||||
try:
|
||||
self.conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 298 KiB |
@ -1,5 +1,13 @@
|
||||
import functools
|
||||
from firegex.nfproxy.models import RawPacket, TCPInputStream, TCPOutputStream, TCPClientStream, TCPServerStream
|
||||
from firegex.nfproxy.models import (
|
||||
RawPacket,
|
||||
TCPInputStream,
|
||||
TCPOutputStream,
|
||||
TCPClientStream,
|
||||
TCPServerStream,
|
||||
HttpHistory,
|
||||
HttpStreamHistory,
|
||||
)
|
||||
from firegex.nfproxy.internals.models import Action, FullStreamAction
|
||||
|
||||
ACCEPT = Action.ACCEPT
|
||||
@ -35,7 +43,8 @@ def clear_pyfilter_registry():
|
||||
pyfilter.registry.clear()
|
||||
|
||||
__all__ = [
|
||||
"ACCEPT", "DROP", "REJECT", "UNSTABLE_MANGLE"
|
||||
"ACCEPT", "DROP", "REJECT", "UNSTABLE_MANGLE",
|
||||
"Action", "FullStreamAction", "pyfilter",
|
||||
"RawPacket", "TCPInputStream", "TCPOutputStream", "TCPClientStream", "TCPServerStream"
|
||||
"RawPacket", "TCPInputStream", "TCPOutputStream", "TCPClientStream", "TCPServerStream",
|
||||
"HttpHistory", "HttpStreamHistory"
|
||||
]
|
||||
@ -11,6 +11,8 @@ from firegex.nfproxy.models.http import (
|
||||
HttpResponseHeader,
|
||||
HttpFullRequest,
|
||||
HttpFullResponse,
|
||||
HttpHistory,
|
||||
HttpStreamHistory,
|
||||
)
|
||||
from firegex.nfproxy.internals.data import RawPacket
|
||||
from enum import Enum
|
||||
@ -31,6 +33,8 @@ type_annotations_associations = {
|
||||
HttpResponseHeader: HttpResponseHeader._fetch_packet,
|
||||
HttpFullRequest: HttpFullRequest._fetch_packet,
|
||||
HttpFullResponse: HttpFullResponse._fetch_packet,
|
||||
HttpHistory: HttpHistory._fetch_packet,
|
||||
HttpStreamHistory: HttpStreamHistory._fetch_packet,
|
||||
},
|
||||
}
|
||||
|
||||
@ -52,5 +56,8 @@ __all__ = [
|
||||
"HttpResponseHeader",
|
||||
"HttpFullRequest",
|
||||
"HttpFullResponse",
|
||||
"HttpHistory",
|
||||
"HttpStreamHistory",
|
||||
"Protocols",
|
||||
]
|
||||
|
||||
|
||||
@ -48,6 +48,7 @@ class InternalHTTPMessage:
|
||||
ws_stream: list[Frame] = field(default_factory=list) # Decoded websocket stream
|
||||
upgrading_to_h2: bool = field(default=False)
|
||||
upgrading_to_ws: bool = field(default=False)
|
||||
added_to_history: bool = field(default=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -393,6 +394,61 @@ class InternalHttpResponse(InternalCallbackHandler, pyllhttp.Response):
|
||||
return False
|
||||
|
||||
|
||||
class HttpHistory:
|
||||
"""
|
||||
HTTP History handler for pyfilters.
|
||||
Provides access to completed previous requests and responses in the current TCP stream.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
requests: list["HttpFullRequest"] | None = None,
|
||||
responses: list["HttpFullResponse"] | None = None,
|
||||
):
|
||||
self._requests = list(requests) if requests is not None else []
|
||||
self._responses = list(responses) if responses is not None else []
|
||||
|
||||
@property
|
||||
def requests(self) -> list["HttpFullRequest"]:
|
||||
"""List of previous completed HTTP requests"""
|
||||
return self._requests.copy()
|
||||
|
||||
@property
|
||||
def responses(self) -> list["HttpFullResponse"]:
|
||||
"""List of previous completed HTTP responses"""
|
||||
return self._responses.copy()
|
||||
|
||||
@classmethod
|
||||
def _fetch_packet(cls, internal_data: DataStreamCtx):
|
||||
if (
|
||||
internal_data.current_pkt is None
|
||||
or internal_data.current_pkt.is_tcp is False
|
||||
):
|
||||
raise NotReadyToRun()
|
||||
|
||||
for key in ("http_module", "http_full", "http_header"):
|
||||
obj = internal_data.call_mem.get(f"_fetched_obj_{key}")
|
||||
if obj is not None:
|
||||
if isinstance(obj, list) and obj:
|
||||
return [item.history for item in obj]
|
||||
elif hasattr(obj, "history"):
|
||||
return obj.history
|
||||
|
||||
req_history_deque = internal_data.data_handler_context.get(
|
||||
"http_history_requests", deque()
|
||||
)
|
||||
resp_history_deque = internal_data.data_handler_context.get(
|
||||
"http_history_responses", deque()
|
||||
)
|
||||
return HttpHistory(list(req_history_deque), list(resp_history_deque))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<HttpHistory requests={len(self._requests)} responses={len(self._responses)}>"
|
||||
|
||||
|
||||
HttpStreamHistory = HttpHistory
|
||||
|
||||
|
||||
class InternalBasicHttpMetaClass:
|
||||
"""Internal class to handle HTTP requests and responses"""
|
||||
|
||||
@ -404,15 +460,23 @@ class InternalBasicHttpMetaClass:
|
||||
self._parser = parser
|
||||
self.raised_error = False
|
||||
self._message: InternalHTTPMessage | None = msg
|
||||
self._history: HttpHistory | None = None
|
||||
self._contructor_hook()
|
||||
|
||||
def _contructor_hook(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def history(self) -> HttpHistory:
|
||||
"""HTTP History for the current stream connection"""
|
||||
if self._history is None:
|
||||
return HttpHistory([], [])
|
||||
return self._history
|
||||
|
||||
@property
|
||||
def total_size(self) -> int:
|
||||
"""Total size of the stream"""
|
||||
return self._parser.total_size
|
||||
"""Total size of the message"""
|
||||
return self._message.total_size
|
||||
|
||||
@property
|
||||
def url(self) -> str | None:
|
||||
@ -462,7 +526,7 @@ class InternalBasicHttpMetaClass:
|
||||
@property
|
||||
def should_upgrade(self) -> bool:
|
||||
"""If the message should upgrade"""
|
||||
return self._parser.should_upgrade
|
||||
return self._message.should_upgrade
|
||||
|
||||
@property
|
||||
def content_length(self) -> int | None:
|
||||
@ -493,6 +557,10 @@ class InternalBasicHttpMetaClass:
|
||||
"""Get a header from the message without caring about the case"""
|
||||
return self._message.lheaders.get(header.lower(), default)
|
||||
|
||||
@classmethod
|
||||
def _should_release_message_headers(cls) -> bool:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _before_fetch_callable_checks(internal_data: DataStreamCtx) -> bool:
|
||||
raise NotImplementedError()
|
||||
@ -521,6 +589,9 @@ class InternalBasicHttpMetaClass:
|
||||
parser: InternalHttpRequest | InternalHttpResponse = ParserType()
|
||||
internal_data.data_handler_context[parser_key] = parser
|
||||
|
||||
parser.release_message_headers = cls._should_release_message_headers()
|
||||
|
||||
|
||||
if not internal_data.call_mem.get(
|
||||
cls._parser_class(), False
|
||||
): # Need to parse HTTP
|
||||
@ -620,10 +691,55 @@ class InternalBasicHttpMetaClass:
|
||||
|
||||
if messages_to_call == 0:
|
||||
raise NotReadyToRun()
|
||||
elif messages_to_call == 1:
|
||||
return cls(parser, messages_tosend[0])
|
||||
|
||||
return [cls(parser, ele) for ele in messages_tosend]
|
||||
raw_max = internal_data.filter_glob.get("FGEX_MAX_HISTORY_SIZE", 100)
|
||||
try:
|
||||
max_history = max(0, int(raw_max))
|
||||
except (ValueError, TypeError):
|
||||
max_history = 100
|
||||
|
||||
req_history_deque: deque = (
|
||||
internal_data.data_handler_context.setdefault(
|
||||
"http_history_requests", deque(maxlen=max_history)
|
||||
)
|
||||
)
|
||||
if req_history_deque.maxlen != max_history:
|
||||
req_history_deque = deque(req_history_deque, maxlen=max_history)
|
||||
internal_data.data_handler_context["http_history_requests"] = req_history_deque
|
||||
|
||||
resp_history_deque: deque = (
|
||||
internal_data.data_handler_context.setdefault(
|
||||
"http_history_responses", deque(maxlen=max_history)
|
||||
)
|
||||
)
|
||||
if resp_history_deque.maxlen != max_history:
|
||||
resp_history_deque = deque(resp_history_deque, maxlen=max_history)
|
||||
internal_data.data_handler_context["http_history_responses"] = resp_history_deque
|
||||
|
||||
built_instances = []
|
||||
for msg in messages_tosend:
|
||||
history_snapshot = HttpHistory(
|
||||
list(req_history_deque), list(resp_history_deque)
|
||||
)
|
||||
instance = cls(parser, msg)
|
||||
instance._history = history_snapshot
|
||||
built_instances.append(instance)
|
||||
|
||||
if msg.message_complete and not msg.added_to_history:
|
||||
msg.added_to_history = True
|
||||
if internal_data.current_pkt.is_input:
|
||||
req_history_deque.append(HttpFullRequest(parser, msg))
|
||||
else:
|
||||
resp_history_deque.append(HttpFullResponse(parser, msg))
|
||||
|
||||
if len(built_instances) == 1:
|
||||
res = built_instances[0]
|
||||
internal_data.call_mem[f"_fetched_obj_{cls._parser_class()}"] = res
|
||||
return res
|
||||
|
||||
internal_data.call_mem[f"_fetched_obj_{cls._parser_class()}"] = built_instances
|
||||
return built_instances
|
||||
|
||||
|
||||
|
||||
class HttpRequest(InternalBasicHttpMetaClass):
|
||||
@ -639,7 +755,7 @@ class HttpRequest(InternalBasicHttpMetaClass):
|
||||
@property
|
||||
def method(self) -> bytes:
|
||||
"""Method of the request"""
|
||||
return self._parser.msg.method
|
||||
return self._message.method
|
||||
|
||||
@staticmethod
|
||||
def _parser_class() -> str:
|
||||
@ -662,7 +778,8 @@ class HttpResponse(InternalBasicHttpMetaClass):
|
||||
@property
|
||||
def status_code(self) -> int:
|
||||
"""Status code of the response"""
|
||||
return self._parser.msg.status
|
||||
return self._message.status
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _parser_class() -> str:
|
||||
@ -678,6 +795,10 @@ class HttpFullRequest(HttpRequest):
|
||||
This data handler will be called when the request data is complete
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _should_release_message_headers(cls) -> bool:
|
||||
return False
|
||||
|
||||
def _contructor_hook(self):
|
||||
self._parser.release_message_headers = False
|
||||
|
||||
@ -695,6 +816,10 @@ class HttpFullResponse(HttpResponse):
|
||||
This data handler will be called when the response data is complete
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _should_release_message_headers(cls) -> bool:
|
||||
return False
|
||||
|
||||
def _contructor_hook(self):
|
||||
self._parser.release_message_headers = False
|
||||
|
||||
@ -706,6 +831,7 @@ class HttpFullResponse(HttpResponse):
|
||||
return f"<HttpFullResponse status_code={self.status_code} url={self.url} headers={self.headers} body=[{0 if not self.body else len(self.body)} bytes] http_version={self.http_version} keep_alive={self.keep_alive} should_upgrade={self.should_upgrade} headers_complete={self.headers_complete} message_complete={self.message_complete} content_length={self.content_length} stream={self.stream} ws_stream={self.ws_stream}>"
|
||||
|
||||
|
||||
|
||||
class HttpRequestHeader(HttpRequest):
|
||||
"""
|
||||
HTTP Request Header handler
|
||||
|
||||
@ -5,35 +5,35 @@
|
||||
"": {
|
||||
"name": "firegex-frontend",
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "latest",
|
||||
"@mantine/code-highlight": "latest",
|
||||
"@mantine/core": "latest",
|
||||
"@mantine/form": "latest",
|
||||
"@mantine/hooks": "latest",
|
||||
"@mantine/modals": "latest",
|
||||
"@mantine/notifications": "latest",
|
||||
"@tanstack/react-query": "latest",
|
||||
"@vitejs/plugin-react-swc": "latest",
|
||||
"buffer": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-icons": "latest",
|
||||
"react-router": "latest",
|
||||
"shiki": "latest",
|
||||
"socket.io-client": "latest",
|
||||
"typescript": "latest",
|
||||
"zustand": "latest",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@mantine/code-highlight": "^9.3.2",
|
||||
"@mantine/core": "^9.3.2",
|
||||
"@mantine/form": "^9.3.2",
|
||||
"@mantine/hooks": "^9.3.2",
|
||||
"@mantine/modals": "^9.3.2",
|
||||
"@mantine/notifications": "^9.3.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@vitejs/plugin-react-swc": "^4.3.1",
|
||||
"buffer": "^6.0.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router": "^8.0.1",
|
||||
"shiki": "^4.2.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"zustand": "^5.0.14",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-query-devtools": "latest",
|
||||
"@types/jest": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"vite": "latest",
|
||||
"vite-plugin-svgr": "latest",
|
||||
"vite-tsconfig-paths": "latest",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -126,6 +126,10 @@
|
||||
|
||||
"@mantine/store": ["@mantine/store@9.3.2", "", { "peerDependencies": { "react": "^19.2.0" } }, "sha512-uNmvjkwfUHZJ5bvzQroLFvubuUAHjBM0rh69RBRsUCG5FR5K4f8y4SX8+lch0wS6UJuuvodsSIK8H5jvZuFSgQ=="],
|
||||
|
||||
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
|
||||
|
||||
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
|
||||
@ -272,6 +276,8 @@
|
||||
|
||||
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
@ -342,6 +348,8 @@
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="],
|
||||
|
||||
"dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.90", "", {}, "sha512-C3PN4aydfW91Natdyd449Kw+BzhLmof6tzy5W1pFC5SpQxVXT+oyiyOG9AgYYSN9OdA/ik3YkCrpwqI8ug5Tug=="],
|
||||
@ -374,8 +382,6 @@
|
||||
|
||||
"globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
|
||||
|
||||
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
@ -448,6 +454,8 @@
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
@ -460,6 +468,8 @@
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.14", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ=="],
|
||||
@ -554,6 +564,8 @@
|
||||
|
||||
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
|
||||
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
@ -570,8 +582,6 @@
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"tsconfck": ["tsconfck@3.1.4", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
|
||||
@ -606,8 +616,6 @@
|
||||
|
||||
"vite-plugin-svgr": ["vite-plugin-svgr@5.2.0", "", { "dependencies": { "@rollup/pluginutils": "^5.3.0", "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0" }, "peerDependencies": { "vite": ">=3.0.0" } }, "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ=="],
|
||||
|
||||
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
|
||||
|
||||
"ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
"@mantine/hooks": "^9.3.2",
|
||||
"@mantine/modals": "^9.3.2",
|
||||
"@mantine/notifications": "^9.3.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@vitejs/plugin-react-swc": "^4.3.1",
|
||||
"buffer": "^6.0.3",
|
||||
@ -39,7 +40,6 @@
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { Button, Group, Loader, LoadingOverlay, Notification, Space, PasswordInput, Title, Box } from '@mantine/core';
|
||||
import { Button, Card, Group, Image, Loader, LoadingOverlay, Notification, Space, Stack, PasswordInput, Title, Text, Code, ThemeIcon, Box } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ImCross } from 'react-icons/im';
|
||||
import { MdCloudOff } from 'react-icons/md';
|
||||
import { Outlet, Route, Routes } from 'react-router';
|
||||
import MainLayout from './components/MainLayout';
|
||||
import { PasswordSend, ServerStatusResponse } from './js/models';
|
||||
@ -15,160 +16,182 @@ import NFProxy from './pages/NFProxy';
|
||||
import ServiceDetailsNFProxy from './pages/NFProxy/ServiceDetails';
|
||||
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() {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [systemStatus, setSystemStatus] = useState<ServerStatusResponse>({status:"", loggined:false})
|
||||
const [reqError, setReqError] = useState<undefined|string>()
|
||||
const [error, setError] = useState<string|null>()
|
||||
const [systemStatus, setSystemStatus] = useState<ServerStatusResponse>({ status: "", loggined: false })
|
||||
const [reqError, setReqError] = useState<undefined | string>()
|
||||
const [error, setError] = useState<string | null>()
|
||||
const [loadinBtn, setLoadingBtn] = useState(false);
|
||||
const queryClient = useQueryClient()
|
||||
const { access_token } = useAuthStore()
|
||||
const { setVersion } = useSystemStore()
|
||||
|
||||
useEffect(()=>{
|
||||
useEffect(() => {
|
||||
socketio.auth = { token: access_token || "" }
|
||||
socketio.connect()
|
||||
getStatus()
|
||||
socketio.on("update", (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: data })
|
||||
queryClient.invalidateQueries({ queryKey: data })
|
||||
})
|
||||
socketio.on("connect_error", (err) => {
|
||||
if (access_token){
|
||||
errorNotify("Socket.Io connection failed! ",`Error message: [${err.message}]`)
|
||||
if (access_token) {
|
||||
errorNotify("Socket.Io connection failed! ", `Error message: [${err.message}]`)
|
||||
}
|
||||
getStatus()
|
||||
});
|
||||
return () => {
|
||||
socketio.off("update")
|
||||
socketio.off("connect_error")
|
||||
socketio.disconnect()
|
||||
}
|
||||
},[access_token])
|
||||
return () => {
|
||||
socketio.off("update")
|
||||
socketio.off("connect_error")
|
||||
socketio.disconnect()
|
||||
}
|
||||
}, [access_token])
|
||||
|
||||
const getStatus = () =>{
|
||||
getstatus().then( res =>{
|
||||
const getStatus = () => {
|
||||
getstatus().then(res => {
|
||||
setSystemStatus(res)
|
||||
setVersion(res.version || "unknown")
|
||||
if (!res.loggined && useAuthStore.getState().getAccessToken()) {
|
||||
useAuthStore.getState().clearAccessToken()
|
||||
}
|
||||
setReqError(undefined)
|
||||
}).catch(err=>{
|
||||
}).catch(err => {
|
||||
setReqError(err.toString())
|
||||
setTimeout(getStatus, 500)
|
||||
}).finally( ()=>setLoading(false) )
|
||||
}).finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(()=>{
|
||||
useEffect(() => {
|
||||
getStatus()
|
||||
},[])
|
||||
}, [])
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
password:"",
|
||||
password: "",
|
||||
},
|
||||
validate:{
|
||||
validate: {
|
||||
password: (value) => value !== "" ? null : "Password is required",
|
||||
}
|
||||
})
|
||||
|
||||
if (loading){
|
||||
return <LoadingOverlay visible/>
|
||||
}else if (reqError){
|
||||
return <Box className='center-flex-row' style={{padding:"100px"}}>
|
||||
<Title order={1} style={{textAlign:"center"}}>Error launching Firegex! 🔥</Title>
|
||||
<Space h="md" />
|
||||
<Title order={4} style={{textAlign:"center"}}>Error communicating with backend</Title>
|
||||
<Space h="md" />
|
||||
Error: {reqError}
|
||||
<Space h="xl" />
|
||||
<Loader />
|
||||
</Box>
|
||||
}else if (systemStatus.status === "init"){
|
||||
|
||||
const submitRequest = async (values:PasswordSend) => {
|
||||
if (loading) {
|
||||
return <LoadingOverlay visible />
|
||||
} else if (reqError) {
|
||||
return <StatusCard title="Can't reach Firegex 🔥" description="The backend isn't responding. Retrying automatically..." color="red">
|
||||
<Code block style={{ width: "100%", wordBreak: "break-word", backgroundColor: 'var(--secondary_color)' }}>{reqError}</Code>
|
||||
<Loader size="sm" />
|
||||
</StatusCard>
|
||||
} else if (systemStatus.status === "init") {
|
||||
|
||||
const submitRequest = async (values: PasswordSend) => {
|
||||
setLoadingBtn(true)
|
||||
await setpassword(values).then(res => {
|
||||
if(!res){
|
||||
setSystemStatus({loggined:true, status:"run"})
|
||||
}else{
|
||||
if (!res) {
|
||||
setSystemStatus({ loggined: true, status: "run" })
|
||||
} else {
|
||||
setError(res)
|
||||
}
|
||||
}).catch( err => setError(err.toString()))
|
||||
}).catch(err => setError(err.toString()))
|
||||
setLoadingBtn(false)
|
||||
form.reset()
|
||||
}
|
||||
|
||||
|
||||
return <Box className='center-flex-row' style={{padding:"100px"}}>
|
||||
<Title order={3} style={{textAlign:"center"}}>Setup: Choose the password for access to the firewall 🔒</Title>
|
||||
<Space h="xl" />
|
||||
<form onSubmit={form.onSubmit(submitRequest)} style={{width:"80%"}}>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="$3cr3t"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Group align="right" mt="md">
|
||||
<Button loading={loadinBtn} type="submit">Set Password</Button>
|
||||
</Group>
|
||||
</form>
|
||||
<Space h="xl" />
|
||||
{error?<>
|
||||
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}>
|
||||
Error: {error}
|
||||
</Notification><Space h="md" /></>:null}
|
||||
</Box>
|
||||
}else if (systemStatus.status === "run" && !systemStatus.loggined){
|
||||
const submitRequest = async (values:PasswordSend) => {
|
||||
setLoadingBtn(true)
|
||||
await login(values).then(res => {
|
||||
if(!res){
|
||||
queryClient.invalidateQueries()
|
||||
setSystemStatus({...systemStatus, loggined:true})
|
||||
}else{
|
||||
setError("Login failed")
|
||||
}
|
||||
}).catch( err => setError(err.toString()))
|
||||
setLoadingBtn(false)
|
||||
form.reset()
|
||||
}
|
||||
|
||||
|
||||
return <Box className='center-flex-row' style={{padding:"100px"}}>
|
||||
<Title order={2} style={{textAlign:"center"}}>Welcome to Firegex 🔥</Title>
|
||||
return <AuthShell>
|
||||
<Title order={5} ta="center" style={{ color: 'var(--text-secondary)', fontWeight: 400 }}>Choose the password for access to the firewall 🔒</Title>
|
||||
<Space h="xl" />
|
||||
<Title order={2} style={{textAlign:"center"}}>Before you use the firewall, insert the password 🔒</Title>
|
||||
<form onSubmit={form.onSubmit(submitRequest)} style={{ width: "100%" }}>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="$3cr3t"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Group mt="xl">
|
||||
<Button loading={loadinBtn} type="submit" variant="light" color="cyan" fullWidth>Set Password</Button>
|
||||
</Group>
|
||||
</form>
|
||||
{error && <>
|
||||
<Space h="lg" />
|
||||
<Notification icon={<ImCross size={14} />} color="red" onClose={() => { setError(null) }}>
|
||||
Error: {error}
|
||||
</Notification></>}
|
||||
</AuthShell>
|
||||
} else if (systemStatus.status === "run" && !systemStatus.loggined) {
|
||||
const submitRequest = async (values: PasswordSend) => {
|
||||
setLoadingBtn(true)
|
||||
await login(values).then(res => {
|
||||
if (!res) {
|
||||
queryClient.invalidateQueries()
|
||||
setSystemStatus({ ...systemStatus, loggined: true })
|
||||
} else {
|
||||
setError("Login failed")
|
||||
}
|
||||
}).catch(err => setError(err.toString()))
|
||||
setLoadingBtn(false)
|
||||
form.reset()
|
||||
}
|
||||
|
||||
|
||||
return <AuthShell>
|
||||
<Title order={5} ta="center" style={{ color: 'var(--text-secondary)', fontWeight: 400 }}>Insert the password to unlock the firewall 🔒</Title>
|
||||
<Space h="xl" />
|
||||
<form onSubmit={form.onSubmit(submitRequest)} style={{width:"80%"}}>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="$3cr3t"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Group align="right" mt="md">
|
||||
<Button loading={loadinBtn} type="submit">Login</Button>
|
||||
</Group>
|
||||
</form>
|
||||
<Space h="xl" />
|
||||
{error?<>
|
||||
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}>
|
||||
Error: {error}
|
||||
</Notification><Space h="md" /></>:null}
|
||||
</Box>
|
||||
}else if (systemStatus.status === "run" && systemStatus.loggined){
|
||||
<form onSubmit={form.onSubmit(submitRequest)} style={{ width: "100%" }}>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="$3cr3t"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Group mt="xl">
|
||||
<Button loading={loadinBtn} type="submit" variant="light" color="cyan" fullWidth>Login</Button>
|
||||
</Group>
|
||||
</form>
|
||||
{error && <>
|
||||
<Space h="lg" />
|
||||
<Notification icon={<ImCross size={14} />} color="red" onClose={() => { setError(null) }}>
|
||||
Error: {error}
|
||||
</Notification></>}
|
||||
</AuthShell>
|
||||
} else if (systemStatus.status === "run" && systemStatus.loggined) {
|
||||
return <PageRouting getStatus={getStatus} />
|
||||
}else{
|
||||
return <Box className='center-flex-row' style={{padding:"100px"}}>
|
||||
<Title order={1} style={{textAlign:"center"}}>Error launching Firegex! 🔥</Title>
|
||||
<Space h="md" />
|
||||
<Title order={4} style={{textAlign:"center"}}>Error communicating with backend</Title>
|
||||
</Box>
|
||||
} else {
|
||||
return <StatusCard title="Unexpected error 🔥" description="Firegex returned an unexpected status. Try reloading the page." color="orange" />
|
||||
}
|
||||
}
|
||||
|
||||
const PageRouting = ({ getStatus }:{ getStatus:()=>void }) => {
|
||||
import TLSDecrypt from './pages/TLSDecrypt';
|
||||
|
||||
const PageRouting = ({ getStatus }: { getStatus: () => void }) => {
|
||||
|
||||
return <Routes>
|
||||
<Route element={<MainLayout><Outlet /></MainLayout>}>
|
||||
<Route element={<MainLayout><Outlet /></MainLayout>}>
|
||||
<Route path="tls-decrypt" element={<TLSDecrypt />} />
|
||||
<Route path="nfregex" element={<NFRegex><Outlet /></NFRegex>} >
|
||||
<Route path=":srv" element={<ServiceDetailsNFRegex />} />
|
||||
</Route>
|
||||
@ -177,9 +200,9 @@ const PageRouting = ({ getStatus }:{ getStatus:()=>void }) => {
|
||||
</Route>
|
||||
<Route path="firewall" element={<Firewall />} />
|
||||
<Route path="porthijack" element={<PortHijack />} />
|
||||
<Route path="*" element={<HomeRedirector />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Route path="*" element={<HomeRedirector />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button, Group, Space, TextInput, Notification, Switch, Modal, Select } from '@mantine/core';
|
||||
import { Button, Group, Space, TextInput, Notification, Switch, Modal, Card, Text, Title, SegmentedControl, Box } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useState } from 'react';
|
||||
import { RegexAddForm } from '../js/models';
|
||||
@ -68,48 +68,73 @@ function AddNewRegex({ opened, onClose, service }:{ opened:boolean, onClose:()=>
|
||||
}
|
||||
|
||||
|
||||
return <Modal size="xl" title="Add a new regex filter" opened={opened} onClose={close} closeOnClickOutside={false} centered>
|
||||
<form onSubmit={form.onSubmit(submitRequest)}>
|
||||
return <Modal size="lg" title="Add a new regex filter" opened={opened} onClose={close} closeOnClickOutside={false} centered>
|
||||
<form onSubmit={form.onSubmit(submitRequest)}>
|
||||
<TextInput
|
||||
label="Regex"
|
||||
placeholder="[A-Z0-9]{31}="
|
||||
size="md"
|
||||
data-autofocus
|
||||
{...form.getInputProps('regex')}
|
||||
/>
|
||||
<Space h="md" />
|
||||
<Switch
|
||||
label="Case insensitive"
|
||||
{...form.getInputProps('is_case_insensitive', { type: 'checkbox' })}
|
||||
/>
|
||||
<Space h="md" />
|
||||
<Switch
|
||||
label="Deactivate"
|
||||
{...form.getInputProps('deactive', { type: 'checkbox' })}
|
||||
/>
|
||||
<Space h="md" />
|
||||
<Select
|
||||
data={[
|
||||
{ value: 'C', label: 'Client -> Server' },
|
||||
{ value: 'S', label: 'Server -> Client' },
|
||||
{ value: 'B', label: 'Both (Client <-> Server)' },
|
||||
]}
|
||||
label="Choose the source of the packets to filter"
|
||||
variant="filled"
|
||||
{...form.getInputProps('mode')}
|
||||
/>
|
||||
<Group align="right" mt="md">
|
||||
<Button loading={submitLoading} type="submit">Add Filter</Button>
|
||||
|
||||
<Space h="xl" />
|
||||
<Group justify="space-between" align="center">
|
||||
<Box>
|
||||
<Text fw={500} size="sm">Packet Direction</Text>
|
||||
<Text size="xs" c="dimmed">Choose which traffic this regex should filter</Text>
|
||||
</Box>
|
||||
<SegmentedControl
|
||||
value={form.values.mode}
|
||||
onChange={(val) => form.setFieldValue('mode', val)}
|
||||
data={[
|
||||
{ label: 'Client → Server', value: 'C' },
|
||||
{ label: 'Bidirectional', value: 'B' },
|
||||
{ label: 'Server → Client', value: 'S' },
|
||||
]}
|
||||
color="cyan"
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Space h="xl" />
|
||||
<Card withBorder radius="md" p="md" bg="transparent">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={500} size="sm">Case Insensitive</Text>
|
||||
<Switch
|
||||
color="cyan"
|
||||
{...form.getInputProps('is_case_insensitive', { type: 'checkbox' })}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Ignore letter casing when matching this regex against packets.
|
||||
</Text>
|
||||
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={500} size="sm">Start Deactivated</Text>
|
||||
<Switch
|
||||
color="orange"
|
||||
{...form.getInputProps('deactive', { type: 'checkbox' })}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Create the filter but keep it disabled initially.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="default" onClick={close}>Cancel</Button>
|
||||
<Button loading={submitLoading} type="submit" color="cyan">Add Filter</Button>
|
||||
</Group>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
{error?<>
|
||||
<Space h="md" />
|
||||
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}>
|
||||
Error: {error}
|
||||
</Notification><Space h="md" /></>:null}
|
||||
</Notification></>:null}
|
||||
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
}
|
||||
|
||||
export default AddNewRegex;
|
||||
|
||||
@ -1,27 +1,26 @@
|
||||
import { SegmentedControl, SegmentedControlProps } from "@mantine/core";
|
||||
import { RuleMode, Table } from "./utils";
|
||||
import { table } from "console";
|
||||
|
||||
|
||||
|
||||
export const ModeSelector = (props:Omit<SegmentedControlProps, "data"> & { table: Table }) => {
|
||||
export const ModeSelector = (props: Omit<SegmentedControlProps, "data"> & { table: Table }) => {
|
||||
const isFilterTable = props.table == Table.FILTER
|
||||
return <SegmentedControl
|
||||
data={[
|
||||
{
|
||||
value: RuleMode.IN,
|
||||
label: isFilterTable?'IN':'PREROUTING',
|
||||
label: isFilterTable ? 'IN' : 'PREROUTING',
|
||||
},
|
||||
...(isFilterTable?[{
|
||||
...(isFilterTable ? [{
|
||||
value: RuleMode.FORWARD,
|
||||
label: 'FWD',
|
||||
}]:[]),
|
||||
}] : []),
|
||||
{
|
||||
value: RuleMode.OUT,
|
||||
label: isFilterTable?'OUT':'POSTROUTING',
|
||||
label: isFilterTable ? 'OUT' : 'POSTROUTING',
|
||||
}
|
||||
]}
|
||||
size={props.size?props.size:"xs"}
|
||||
size={props.size ? props.size : "xs"}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
@ -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 { useEffect, useState } from 'react';
|
||||
import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils';
|
||||
import { okNotify, regex_ipv4, regex_ipv6, postapi } from '../../js/utils';
|
||||
import TLSServiceFields from '../TLSServiceFields';
|
||||
import { ImCross } from "react-icons/im"
|
||||
import { nfproxy, Service } from './utils';
|
||||
import PortAndInterface from '../PortAndInterface';
|
||||
import { IoMdInformationCircleOutline } from "react-icons/io";
|
||||
import { TbNetwork, TbShieldLock, TbSettings, TbTarget } from "react-icons/tb";
|
||||
import { ServiceAddForm as ServiceAddFormOriginal } from './utils';
|
||||
|
||||
type ServiceAddForm = ServiceAddFormOriginal & {autostart: boolean}
|
||||
type ServiceAddForm = ServiceAddFormOriginal & {
|
||||
autostart: boolean,
|
||||
tls_enabled: boolean,
|
||||
tls_cert: string,
|
||||
tls_key: string
|
||||
}
|
||||
|
||||
function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>void, edit?:Service }) {
|
||||
|
||||
@ -18,7 +25,11 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
ip_int:edit?.ip_int??"",
|
||||
proto:edit?.proto??"tcp",
|
||||
fail_open: edit?.fail_open??false,
|
||||
autostart: true
|
||||
autostart: true,
|
||||
tls_enabled: edit?.target_type === 'tls',
|
||||
tls_stream_id: edit?.tls_stream_id ?? undefined,
|
||||
tls_cert: "",
|
||||
tls_key: ""
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
@ -38,6 +49,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
}
|
||||
}, [opened])
|
||||
|
||||
|
||||
|
||||
const close = () =>{
|
||||
onClose()
|
||||
form.reset()
|
||||
@ -47,26 +60,81 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
const [submitLoading, setSubmitLoading] = useState(false)
|
||||
const [error, setError] = useState<string|null>(null)
|
||||
|
||||
const submitRequest = ({ name, port, autostart, proto, ip_int, fail_open }:ServiceAddForm) =>{
|
||||
const submitRequest = async (values: ServiceAddForm) => {
|
||||
setSubmitLoading(true)
|
||||
let streamId = values.tls_stream_id;
|
||||
|
||||
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){
|
||||
setSubmitLoading(false)
|
||||
close();
|
||||
okNotify(`Service ${name} settings updated`, `Successfully updated settings for service ${name}`)
|
||||
okNotify(`Service ${values.name} settings updated`, `Successfully updated settings for service ${values.name}`)
|
||||
}
|
||||
}).catch( err => {
|
||||
setSubmitLoading(false)
|
||||
setError("Request Failed! [ "+err+" ]")
|
||||
})
|
||||
}else{
|
||||
nfproxy.servicesadd({ name, port, proto, ip_int, fail_open }).then( res => {
|
||||
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){
|
||||
setSubmitLoading(false)
|
||||
close();
|
||||
if (autostart) nfproxy.servicestart(res.service_id)
|
||||
okNotify(`Service ${name} has been added`, `Successfully added service with port ${port}`)
|
||||
if (values.autostart) nfproxy.servicestart(res.service_id)
|
||||
okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`)
|
||||
}else{
|
||||
setSubmitLoading(false)
|
||||
setError("Invalid request! [ "+res.status+" ]")
|
||||
@ -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>
|
||||
<form onSubmit={form.onSubmit(submitRequest)}>
|
||||
{!edit?<TextInput
|
||||
label="Service name"
|
||||
placeholder="Challenge 01"
|
||||
{...form.getInputProps('name')}
|
||||
/>:null}
|
||||
<Space h="md" />
|
||||
<PortAndInterface form={form} int_name="ip_int" port_name="port" label={"Public IP Interface and port (ipv4/ipv6 + CIDR allowed)"} />
|
||||
<Space h="md" />
|
||||
return <Modal size="xl" title={edit?`Editing ${edit.name} service`:"Add a new service"} opened={opened} onClose={close} closeOnClickOutside={false} centered styles={{ body: { maxHeight: 'calc(100vh - 150px)', overflowY: 'auto' } }}>
|
||||
<form onSubmit={form.onSubmit(submitRequest)}>
|
||||
<Tabs defaultValue="general" color="cyan" variant="outline" radius="md">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="general" leftSection={<TbSettings size={16} />}>
|
||||
General
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="advanced" leftSection={<TbShieldLock size={16} />}>
|
||||
Advanced
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Box className='center-flex'>
|
||||
<Box>
|
||||
{!edit?<Switch
|
||||
label="Auto-Start Service"
|
||||
{...form.getInputProps('autostart', { type: 'checkbox' })}
|
||||
/>:null}
|
||||
<Space h="sm" />
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Switch
|
||||
label="Enable fail-open nfqueue"
|
||||
{...form.getInputProps('fail_open', { type: 'checkbox' })}
|
||||
<Tabs.Panel value="general" pt="xl" pb="md">
|
||||
{!edit ? (
|
||||
<TextInput
|
||||
label="Service name"
|
||||
placeholder="Challenge 01"
|
||||
size="md"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<Tooltip
|
||||
label={<>
|
||||
Firegex use internally nfqueue to handle packets<br />enabling this option will allow packets to pass through the firewall <br /> in case the filtering is too slow or too many traffic is coming<br />
|
||||
</>}
|
||||
zIndex={1000000}
|
||||
) : null}
|
||||
<Space h="md" />
|
||||
<Title order={6} mb="xs">Protocol</Title>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="md"
|
||||
color="cyan"
|
||||
data={[
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'HTTP', value: 'http' },
|
||||
]}
|
||||
{...form.getInputProps('proto')}
|
||||
/>
|
||||
<Space h="xl" />
|
||||
<Divider mb="xl" label="Target Setup" labelPosition="center" />
|
||||
|
||||
<Title order={6} mb="xs">Target Type</Title>
|
||||
<Group grow mb="xl">
|
||||
<UnstyledButton
|
||||
onClick={() => form.setFieldValue('tls_enabled', false)}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '12px',
|
||||
border: form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)',
|
||||
backgroundColor: form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={(e: any) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
>
|
||||
<IoMdInformationCircleOutline size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Group wrap="nowrap">
|
||||
<TbNetwork size={32} color={!form.values.tls_enabled ? "var(--accent-color)" : "gray"} />
|
||||
<div>
|
||||
<Text fw={600}>Direct Flow</Text>
|
||||
<Text size="xs" c="dimmed">Forward traffic directly without decryption</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
onClick={() => form.setFieldValue('tls_enabled', true)}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '12px',
|
||||
border: !form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)',
|
||||
backgroundColor: !form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
<TbShieldLock size={32} color={form.values.tls_enabled ? "var(--accent-color)" : "gray"} />
|
||||
<div>
|
||||
<Text fw={600}>TLS Decrypted</Text>
|
||||
<Text size="xs" c="dimmed">Decrypt TLS traffic for deep inspection</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
</Box>
|
||||
<Box className="flex-spacer"></Box>
|
||||
{edit?null:<SegmentedControl
|
||||
data={[
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'HTTP', value: 'http' },
|
||||
]}
|
||||
{...form.getInputProps('proto')}
|
||||
/>}
|
||||
</Box>
|
||||
|
||||
<Group justify='flex-end' mt="md" mb="sm">
|
||||
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button>
|
||||
</Group>
|
||||
{!form.values.tls_enabled ? (
|
||||
<PortAndInterface form={form} int_name="ip_int" port_name="port" label={"Public IP Interface and port (ipv4/ipv6 + CIDR allowed)"} />
|
||||
) : (
|
||||
<TLSServiceFields form={form} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{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" />
|
||||
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}>
|
||||
<Notification icon={<ImCross size={14} />} color="red" onClose={() => { setError(null) }}>
|
||||
Error: {error}
|
||||
</Notification><Space h="md" />
|
||||
</>:null}
|
||||
</Notification>
|
||||
</> : null}
|
||||
|
||||
<Group justify='flex-end' mt="xl" pt="md" style={{ borderTop: '1px solid var(--fourth_color)' }}>
|
||||
<Button variant="default" onClick={close}>Cancel</Button>
|
||||
<Button loading={submitLoading} type="submit" color="cyan" disabled={edit ? !form.isDirty() : false}>
|
||||
{edit ? "Save Changes" : "Create Service"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
125
frontend/src/components/NFProxy/EditFilterModal.tsx
Normal file
125
frontend/src/components/NFProxy/EditFilterModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 { FaPlay, FaStop } from 'react-icons/fa';
|
||||
import { FaPlay, FaStop, FaTrash } from 'react-icons/fa';
|
||||
import { nfproxy, Service, serviceQueryKey } from '../utils';
|
||||
import { MdDoubleArrow, MdOutlineArrowForwardIos } from "react-icons/md"
|
||||
import YesNoModal from '../../YesNoModal';
|
||||
import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../../js/utils';
|
||||
import { BsTrashFill } from 'react-icons/bs';
|
||||
import { BiRename } from 'react-icons/bi'
|
||||
import { BiRename } from 'react-icons/bi';
|
||||
import RenameForm from './RenameForm';
|
||||
import { MenuDropDownWithButton } from '../../MainLayout';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { TbPlugConnected } from "react-icons/tb";
|
||||
import { TbPlugConnected, TbShieldLock, TbHexagon } from "react-icons/tb";
|
||||
import { FaFilter } from "react-icons/fa";
|
||||
import { IoSettingsSharp } from 'react-icons/io5';
|
||||
import AddEditService from '../AddEditService';
|
||||
import { FaPencilAlt } from "react-icons/fa";
|
||||
import { ExceptionWarning } from '../ExceptionWarning';
|
||||
import { MdChevronRight } from "react-icons/md";
|
||||
import TLSAssociationBadge from '../../TLSAssociationBadge';
|
||||
|
||||
|
||||
export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) {
|
||||
|
||||
@ -77,72 +79,87 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
|
||||
}
|
||||
|
||||
return <>
|
||||
<Box className='firegex__nfregex__rowbox'>
|
||||
<Box className="firegex__nfregex__row" style={{width:"100%", flexDirection: isMedium?"row":"column"}}>
|
||||
<Box>
|
||||
<Box className="center-flex" style={{ justifyContent: "flex-start" }}>
|
||||
<MdDoubleArrow size={30} style={{color: "white"}}/>
|
||||
<Title className="firegex__nfregex__name" ml="xs">
|
||||
{service.name}
|
||||
</Title>
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
w="100%"
|
||||
bg="transparent"
|
||||
className="firegex__clickable_row"
|
||||
style={{ borderColor: 'var(--fourth_color)' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"}>
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<Box style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 8,
|
||||
backgroundColor: status_color === 'teal' ? 'rgba(32, 201, 151, 0.1)' : 'rgba(250, 82, 82, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: status_color === 'teal' ? 'var(--mantine-color-teal-filled)' : 'var(--mantine-color-red-filled)'
|
||||
}}>
|
||||
<TbHexagon size={24} />
|
||||
</Box>
|
||||
<Box className="center-flex" style={{ gap: 8, marginTop: 15, justifyContent: "flex-start" }}>
|
||||
<Badge color={status_color} radius="md" size="lg" variant="filled">{service.status}</Badge>
|
||||
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}>
|
||||
:{service.port}
|
||||
</Badge>
|
||||
<Box>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="md">{service.name}</Text>
|
||||
<Badge color={status_color} variant="light" size="xs" radius="sm">
|
||||
{service.status.toUpperCase()}
|
||||
</Badge>
|
||||
{service.target_type === 'tls' && <TLSAssociationBadge tlsStreamId={service.tls_stream_id} />}
|
||||
</Group>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
|
||||
PORT: :{service.port}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
|
||||
• {service.target_type === 'tls' ? 'decrypted traffic to ' : ''}{service.ip_int} ON {service.proto.toUpperCase()}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
{isMedium?null:<Space w="xl" />}
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Box className={isMedium?"center-flex":"center-flex-row"}>
|
||||
<Box className="center-flex-row">
|
||||
<Badge color={service.ip_int.match(regex_ipv4)?"cyan":"pink"} radius="sm" size="md" variant="filled">{service.ip_int} on {service.proto}</Badge>
|
||||
<Space h="xs" />
|
||||
<Box className='center-flex'>
|
||||
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2}} /> {service.blocked_packets}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge color="orange" radius="sm" size="md" variant="filled"><FaPencilAlt style={{ marginBottom: -2}} /> {service.edited_packets}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge color="violet" radius="sm" size="md" variant="filled"><TbPlugConnected style={{ marginBottom: -2}} size={13} /> {service.n_filters}</Badge>
|
||||
</Box>
|
||||
</Box>
|
||||
{isMedium?<Space w="xl" />:<Space h="lg" />}
|
||||
<Box className="center-flex">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
<ExceptionWarning service_id={service.service_id} />
|
||||
<Space w="sm"/>
|
||||
<MenuDropDownWithButton>
|
||||
<Menu.Item><b>Edit service</b></Menu.Item>
|
||||
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
|
||||
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Label><b>Danger zone</b></Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
|
||||
</MenuDropDownWithButton>
|
||||
<Space w="md"/>
|
||||
<Tooltip label="Stop service" zIndex={0} color="red">
|
||||
<ActionIcon color="red" loading={buttonLoading}
|
||||
onClick={stopService} size="xl" radius="md" variant="filled"
|
||||
disabled={service.status === "stop"}
|
||||
aria-describedby="tooltip-stop-id">
|
||||
<FaStop size="20px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="md"/>
|
||||
<Tooltip label="Start service" zIndex={0} color="teal">
|
||||
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading}
|
||||
variant="filled" disabled={!["stop","pause"].includes(service.status)?true:false}>
|
||||
<FaPlay size="20px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{isMedium?<Space w="xl" />:<Space w="md" />}
|
||||
{onClick?<Box className='firegex__service_forward_btn'>
|
||||
<MdOutlineArrowForwardIos onClick={onClick} style={{cursor:"pointer"}} size={25} />
|
||||
</Box>:null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{service.status === "stop" ? (
|
||||
<Button variant="default" size="xs" leftSection={<FaPlay size={10} />} onClick={startService} loading={buttonLoading}>
|
||||
Start
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="xs" leftSection={<FaStop size={10} />} onClick={stopService} loading={buttonLoading}>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<Button variant="default" size="xs" leftSection={<IoSettingsSharp size={10} />}>
|
||||
Options
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Actions</Menu.Label>
|
||||
<Menu.Item leftSection={<BiRename size={14} />} onClick={() => setRenameModal(true)}>Rename Service</Menu.Item>
|
||||
<Menu.Item leftSection={<IoSettingsSharp size={14} />} onClick={() => setEditModal(true)}>Settings</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<Button variant="default" size="xs" leftSection={<FaTrash size={10} />} onClick={() => setDeleteModal(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
<Tooltip label="View details" position="left">
|
||||
<Box style={{ display: 'flex', alignItems: 'center', color: 'var(--text-secondary)' }}>
|
||||
<MdChevronRight size={22} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
<YesNoModal
|
||||
title='Are you sure to delete this service?'
|
||||
description={`You are going to delete the service '${service.port}', causing the stopping of the firewall and deleting all the filters associated. This will cause the shutdown of your service! ⚠️`}
|
||||
|
||||
@ -17,7 +17,6 @@ export const UploadFilterModal = ({ opened, onClose, service }: { opened: boolea
|
||||
useEffect(() => {
|
||||
if (opened && file){
|
||||
file.bytes().then( code => {
|
||||
console.log(code.toString())
|
||||
setSubmitLoading(true)
|
||||
nfproxy.setpyfilterscode(service?.service_id??"",code.toString()).then( res => {
|
||||
if (!res){
|
||||
|
||||
@ -13,20 +13,34 @@ export type Service = {
|
||||
edited_packets:number,
|
||||
blocked_packets:number,
|
||||
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 = {
|
||||
name:string,
|
||||
port:number,
|
||||
proto:string,
|
||||
ip_int:string,
|
||||
fail_open: boolean,
|
||||
target_type?: string,
|
||||
tls_stream_id?: string | null,
|
||||
}
|
||||
|
||||
export type ServiceSettings = {
|
||||
port?:number,
|
||||
proto?:string,
|
||||
ip_int?:string,
|
||||
fail_open?: boolean,
|
||||
target_type?: string,
|
||||
tls_stream_id?: string | null,
|
||||
}
|
||||
|
||||
export type ServiceAddResponse = {
|
||||
@ -88,6 +102,10 @@ export const nfproxy = {
|
||||
const { status } = await putapi(`nfproxy/services/${service_id}/settings`,data) as ServerResponse;
|
||||
return status === "ok"?undefined:status
|
||||
},
|
||||
updatetlsconfig: async (service_id:string, data:TLSConfig) => {
|
||||
const { status } = await putapi(`nfproxy/services/${service_id}/tls-config`,data) as ServerResponse;
|
||||
return status === "ok"?undefined:status
|
||||
},
|
||||
getpyfilterscode: async (service_id:string) => {
|
||||
return await getapi(`nfproxy/services/${service_id}/code`) as string;
|
||||
},
|
||||
|
||||
@ -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 { useEffect, useState } from 'react';
|
||||
import { okNotify, regex_ipv4, regex_ipv6 } from '../../js/utils';
|
||||
import { okNotify, regex_ipv4, regex_ipv6, postapi } from '../../js/utils';
|
||||
import TLSServiceFields from '../TLSServiceFields';
|
||||
import { ImCross } from "react-icons/im"
|
||||
import { nfregex, Service } from './utils';
|
||||
import PortAndInterface from '../PortAndInterface';
|
||||
import { IoMdInformationCircleOutline } from "react-icons/io";
|
||||
import { TbNetwork, TbShieldLock, TbSettings, TbTarget } from "react-icons/tb";
|
||||
import { ServiceAddForm as ServiceAddFormOriginal } from './utils';
|
||||
|
||||
type ServiceAddForm = ServiceAddFormOriginal & {autostart: boolean}
|
||||
type ServiceAddForm = ServiceAddFormOriginal & {
|
||||
autostart: boolean,
|
||||
tls_enabled: boolean,
|
||||
tls_cert: string,
|
||||
tls_key: string
|
||||
}
|
||||
|
||||
function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>void, edit?:Service }) {
|
||||
|
||||
@ -18,7 +25,11 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
ip_int:edit?.ip_int??"",
|
||||
proto:edit?.proto??"tcp",
|
||||
fail_open: edit?.fail_open??false,
|
||||
autostart: true
|
||||
autostart: true,
|
||||
tls_enabled: edit?.target_type === 'tls',
|
||||
tls_stream_id: edit?.tls_stream_id ?? undefined,
|
||||
tls_cert: "",
|
||||
tls_key: ""
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
@ -38,6 +49,8 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
}
|
||||
}, [opened])
|
||||
|
||||
|
||||
|
||||
const close = () =>{
|
||||
onClose()
|
||||
form.reset()
|
||||
@ -47,26 +60,81 @@ function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>
|
||||
const [submitLoading, setSubmitLoading] = useState(false)
|
||||
const [error, setError] = useState<string|null>(null)
|
||||
|
||||
const submitRequest = ({ name, port, autostart, proto, ip_int, fail_open }:ServiceAddForm) =>{
|
||||
const submitRequest = async (values: ServiceAddForm) => {
|
||||
setSubmitLoading(true)
|
||||
let streamId = values.tls_stream_id;
|
||||
|
||||
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){
|
||||
setSubmitLoading(false)
|
||||
close();
|
||||
okNotify(`Service ${name} settings updated`, `Successfully updated settings for service ${name}`)
|
||||
okNotify(`Service ${values.name} settings updated`, `Successfully updated settings for service ${values.name}`)
|
||||
}
|
||||
}).catch( err => {
|
||||
setSubmitLoading(false)
|
||||
setError("Request Failed! [ "+err+" ]")
|
||||
})
|
||||
}else{
|
||||
nfregex.servicesadd({ name, port, proto, ip_int, fail_open }).then( res => {
|
||||
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){
|
||||
setSubmitLoading(false)
|
||||
close();
|
||||
if (autostart) nfregex.servicestart(res.service_id)
|
||||
okNotify(`Service ${name} has been added`, `Successfully added service with port ${port}`)
|
||||
if (values.autostart) nfregex.servicestart(res.service_id)
|
||||
okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`)
|
||||
}else{
|
||||
setSubmitLoading(false)
|
||||
setError("Invalid request! [ "+res.status+" ]")
|
||||
@ -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>
|
||||
<form onSubmit={form.onSubmit(submitRequest)}>
|
||||
{!edit?<TextInput
|
||||
label="Service name"
|
||||
placeholder="Challenge 01"
|
||||
{...form.getInputProps('name')}
|
||||
/>:null}
|
||||
<Space h="md" />
|
||||
<PortAndInterface form={form} int_name="ip_int" port_name="port" label={"Public IP Interface and port (ipv4/ipv6 + CIDR allowed)"} />
|
||||
<Space h="md" />
|
||||
return <Modal size="xl" title={edit?`Editing ${edit.name} service`:"Add a new service"} opened={opened} onClose={close} closeOnClickOutside={false} centered styles={{ body: { maxHeight: 'calc(100vh - 150px)', overflowY: 'auto' } }}>
|
||||
<form onSubmit={form.onSubmit(submitRequest)}>
|
||||
<Tabs defaultValue="general" color="cyan" variant="outline" radius="md">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="general" leftSection={<TbSettings size={16} />}>
|
||||
General
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="advanced" leftSection={<TbShieldLock size={16} />}>
|
||||
Advanced
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Box className='center-flex'>
|
||||
<Box>
|
||||
{!edit?<Switch
|
||||
label="Auto-Start Service"
|
||||
{...form.getInputProps('autostart', { type: 'checkbox' })}
|
||||
/>:null}
|
||||
<Space h="sm" />
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Switch
|
||||
label="Enable fail-open nfqueue"
|
||||
{...form.getInputProps('fail_open', { type: 'checkbox' })}
|
||||
<Tabs.Panel value="general" pt="xl" pb="md">
|
||||
{!edit ? (
|
||||
<TextInput
|
||||
label="Service name"
|
||||
placeholder="Challenge 01"
|
||||
size="md"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<Tooltip
|
||||
label={<>
|
||||
Firegex use internally nfqueue to handle packets<br />enabling this option will allow packets to pass through the firewall <br /> in case the filtering is too slow or too many traffic is coming<br />
|
||||
</>}
|
||||
zIndex={1000000}
|
||||
) : null}
|
||||
<Space h="md" />
|
||||
<Title order={6} mb="xs">Protocol</Title>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="md"
|
||||
color="cyan"
|
||||
data={[
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' },
|
||||
]}
|
||||
{...form.getInputProps('proto')}
|
||||
disabled={edit !== undefined}
|
||||
/>
|
||||
<Space h="xl" />
|
||||
<Divider mb="xl" label="Target Setup" labelPosition="center" />
|
||||
|
||||
<Title order={6} mb="xs">Target Type</Title>
|
||||
<Group grow mb="xl">
|
||||
<UnstyledButton
|
||||
onClick={() => form.setFieldValue('tls_enabled', false)}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '12px',
|
||||
border: form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)',
|
||||
backgroundColor: form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={(e: any) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
>
|
||||
<IoMdInformationCircleOutline size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Group wrap="nowrap">
|
||||
<TbNetwork size={32} color={!form.values.tls_enabled ? "var(--accent-color)" : "gray"} />
|
||||
<div>
|
||||
<Text fw={600}>Direct Flow</Text>
|
||||
<Text size="xs" c="dimmed">Forward traffic directly without decryption</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
onClick={() => { if (form.values.proto === 'tcp') form.setFieldValue('tls_enabled', true) }}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '12px',
|
||||
border: !form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)',
|
||||
backgroundColor: !form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)',
|
||||
opacity: form.values.proto !== 'tcp' && !form.values.tls_enabled ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: form.values.proto !== 'tcp' ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
<TbShieldLock size={32} color={form.values.tls_enabled ? "var(--accent-color)" : "gray"} />
|
||||
<div>
|
||||
<Text fw={600}>TLS Decrypted</Text>
|
||||
<Text size="xs" c="dimmed">Decrypt TLS traffic for deep inspection</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
</Box>
|
||||
<Box className="flex-spacer"></Box>
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' },
|
||||
]}
|
||||
{...form.getInputProps('proto')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group justify='flex-end' mt="md" mb="sm">
|
||||
<Button loading={submitLoading} type="submit" disabled={edit?!form.isDirty():false}>{edit?"Edit Service":"Add Service"}</Button>
|
||||
</Group>
|
||||
{!form.values.tls_enabled ? (
|
||||
<PortAndInterface form={form} int_name="ip_int" port_name="port" label={"Public IP Interface and port (ipv4/ipv6 + CIDR allowed)"} />
|
||||
) : (
|
||||
<TLSServiceFields form={form} disabled={form.values.proto !== 'tcp'} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{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" />
|
||||
<Notification icon={<ImCross size={14} />} color="red" onClose={()=>{setError(null)}}>
|
||||
<Notification icon={<ImCross size={14} />} color="red" onClose={() => { setError(null) }}>
|
||||
Error: {error}
|
||||
</Notification><Space h="md" />
|
||||
</>:null}
|
||||
</Notification>
|
||||
</> : null}
|
||||
|
||||
<Group justify='flex-end' mt="xl" pt="md" style={{ borderTop: '1px solid var(--fourth_color)' }}>
|
||||
<Button variant="default" onClick={close}>Cancel</Button>
|
||||
<Button loading={submitLoading} type="submit" color="cyan" disabled={edit ? !form.isDirty() : false}>
|
||||
{edit ? "Save Changes" : "Create Service"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@ -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 { FaPlay, FaStop } from 'react-icons/fa';
|
||||
import { FaPlay, FaStop, FaTrash } from 'react-icons/fa';
|
||||
import { nfregex, Service, serviceQueryKey } from '../utils';
|
||||
import { MdDoubleArrow, MdOutlineArrowForwardIos } from "react-icons/md"
|
||||
import YesNoModal from '../../YesNoModal';
|
||||
import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../../js/utils';
|
||||
import { BsTrashFill } from 'react-icons/bs';
|
||||
import { BiRename } from 'react-icons/bi'
|
||||
import { BiRename } from 'react-icons/bi';
|
||||
import RenameForm from './RenameForm';
|
||||
import { MenuDropDownWithButton } from '../../MainLayout';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { FaFilter } from "react-icons/fa";
|
||||
import { VscRegex } from "react-icons/vsc";
|
||||
import { IoSettingsSharp } from 'react-icons/io5';
|
||||
import AddEditService from '../AddEditService';
|
||||
import { TbShieldLock, 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 }) {
|
||||
|
||||
@ -75,68 +76,86 @@ export default function ServiceRow({ service, onClick }:{ service:Service, onCli
|
||||
}
|
||||
|
||||
return <>
|
||||
<Box className='firegex__nfregex__rowbox'>
|
||||
<Box className="firegex__nfregex__row" style={{width:"100%", flexDirection: isMedium?"row":"column"}}>
|
||||
<Box>
|
||||
<Box className="center-flex" style={{ justifyContent: "flex-start" }}>
|
||||
<MdDoubleArrow size={30} style={{color: "white"}}/>
|
||||
<Title className="firegex__nfregex__name" ml="xs">
|
||||
{service.name}
|
||||
</Title>
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
w="100%"
|
||||
bg="transparent"
|
||||
className="firegex__clickable_row"
|
||||
style={{ borderColor: 'var(--fourth_color)' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"}>
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<Box style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 8,
|
||||
backgroundColor: status_color === 'teal' ? 'rgba(32, 201, 151, 0.1)' : 'rgba(250, 82, 82, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: status_color === 'teal' ? 'var(--mantine-color-teal-filled)' : 'var(--mantine-color-red-filled)'
|
||||
}}>
|
||||
<TbHexagon size={24} />
|
||||
</Box>
|
||||
<Box className="center-flex" style={{ gap: 8, marginTop: 15, justifyContent: "flex-start" }}>
|
||||
<Badge color={status_color} radius="md" size="lg" variant="filled">{service.status}</Badge>
|
||||
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" style={{ fontSize: "110%" }}>
|
||||
:{service.port}
|
||||
</Badge>
|
||||
<Box>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="md">{service.name}</Text>
|
||||
<Badge color={status_color} variant="light" size="xs" radius="sm">
|
||||
{service.status.toUpperCase()}
|
||||
</Badge>
|
||||
{service.target_type === 'tls' && <TLSAssociationBadge tlsStreamId={service.tls_stream_id} />}
|
||||
</Group>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
|
||||
PORT: :{service.port}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
|
||||
• {service.target_type === 'tls' ? 'decrypted traffic to ' : ''}{service.ip_int} ON {service.proto.toUpperCase()}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
{isMedium?null:<Space w="xl" />}
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Box className={isMedium?"center-flex":"center-flex-row"}>
|
||||
<Box className="center-flex-row">
|
||||
<Badge color={service.ip_int.match(regex_ipv4)?"cyan":"pink"} radius="sm" size="md" variant="filled">{service.ip_int} on {service.proto}</Badge>
|
||||
<Space h="xs" />
|
||||
<Box className='center-flex'>
|
||||
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2}} /> {service.n_packets}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge color="violet" radius="sm" size="md" variant="filled"><VscRegex style={{ marginBottom: -2}} size={13} /> {service.n_regex}</Badge>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
{service.status === "stop" ? (
|
||||
<Button variant="default" size="xs" leftSection={<FaPlay size={10} />} onClick={startService} loading={buttonLoading}>
|
||||
Start
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="xs" leftSection={<FaStop size={10} />} onClick={stopService} loading={buttonLoading}>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<Button variant="default" size="xs" leftSection={<IoSettingsSharp size={10} />}>
|
||||
Options
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Actions</Menu.Label>
|
||||
<Menu.Item leftSection={<BiRename size={14} />} onClick={() => setRenameModal(true)}>Rename Service</Menu.Item>
|
||||
<Menu.Item leftSection={<IoSettingsSharp size={14} />} onClick={() => setEditModal(true)}>Settings</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<Button variant="default" size="xs" leftSection={<FaTrash size={10} />} onClick={() => setDeleteModal(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
<Tooltip label="View details" position="left">
|
||||
<Box style={{ display: 'flex', alignItems: 'center', color: 'var(--text-secondary)' }}>
|
||||
<MdChevronRight size={22} />
|
||||
</Box>
|
||||
</Box>
|
||||
{isMedium?<Space w="xl" />:<Space h="lg" />}
|
||||
<Box className="center-flex">
|
||||
<MenuDropDownWithButton>
|
||||
<Menu.Item><b>Edit service</b></Menu.Item>
|
||||
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
|
||||
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Label><b>Danger zone</b></Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
|
||||
</MenuDropDownWithButton>
|
||||
<Space w="md"/>
|
||||
<Tooltip label="Stop service" zIndex={0} color="red">
|
||||
<ActionIcon color="red" loading={buttonLoading}
|
||||
onClick={stopService} size="xl" radius="md" variant="filled"
|
||||
disabled={service.status === "stop"}
|
||||
aria-describedby="tooltip-stop-id">
|
||||
<FaStop size="20px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="md"/>
|
||||
<Tooltip label="Start service" zIndex={0} color="teal">
|
||||
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading}
|
||||
variant="filled" disabled={!["stop","pause"].includes(service.status)?true:false}>
|
||||
<FaPlay size="20px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{isMedium?<Space w="xl" />:<Space w="md" />}
|
||||
{onClick?<Box className='firegex__service_forward_btn'>
|
||||
<MdOutlineArrowForwardIos onClick={onClick} style={{cursor:"pointer"}} size={25} />
|
||||
</Box>:null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
<YesNoModal
|
||||
title='Are you sure to delete this service?'
|
||||
description={`You are going to delete the service '${service.port}', causing the stopping of the firewall and deleting all the regex associated. This will cause the shutdown of your service! ⚠️`}
|
||||
|
||||
@ -13,6 +13,8 @@ export type Service = {
|
||||
n_packets:number,
|
||||
n_regex:number,
|
||||
fail_open:boolean,
|
||||
target_type:string,
|
||||
tls_stream_id:string|null,
|
||||
}
|
||||
|
||||
export type ServiceAddForm = {
|
||||
@ -21,6 +23,8 @@ export type ServiceAddForm = {
|
||||
proto:string,
|
||||
ip_int:string,
|
||||
fail_open: boolean,
|
||||
target_type?: string,
|
||||
tls_stream_id?: string | null,
|
||||
}
|
||||
|
||||
export type ServiceSettings = {
|
||||
@ -28,6 +32,8 @@ export type ServiceSettings = {
|
||||
proto?:string,
|
||||
ip_int?:string,
|
||||
fail_open?: boolean,
|
||||
target_type?: string,
|
||||
tls_stream_id?: string | null,
|
||||
}
|
||||
|
||||
export type ServiceAddResponse = {
|
||||
@ -92,4 +98,8 @@ export const nfregex = {
|
||||
const { status } = await putapi(`nfregex/services/${service_id}/settings`,data) as ServerResponse;
|
||||
return status === "ok"?undefined:status
|
||||
},
|
||||
updatetlsconfig: async (service_id: string, data: { tls_enabled: boolean, tls_cert: string | null, tls_key: string | null }) => {
|
||||
const { status } = await putapi(`nfregex/services/${service_id}/tls-config`, data) as ServerResponse;
|
||||
return status === "ok" ? undefined : status;
|
||||
},
|
||||
}
|
||||
@ -2,11 +2,13 @@ import { Divider, Group, MantineColor, ScrollArea, Text, ThemeIcon, Title, Unsty
|
||||
import { useState } from "react";
|
||||
import { TbPlugConnected } from "react-icons/tb";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { GrDirections } from "react-icons/gr";
|
||||
import { PiWallLight } from "react-icons/pi";
|
||||
import { useNavbarStore } from "../../js/store";
|
||||
import { getMainPath } from "../../js/utils";
|
||||
import { getMainPath, getapi, postapi, okNotify, errorNotify } from "../../js/utils";
|
||||
import { BsRegex } from "react-icons/bs";
|
||||
import { MdDownload, MdUpload } from "react-icons/md";
|
||||
|
||||
function NavBarButton({ navigate, closeNav, name, icon, color, disabled, onClick }:
|
||||
{ navigate?: string, closeNav: () => void, name: string, icon: any, color: MantineColor, disabled?: boolean, onClick?: CallableFunction }) {
|
||||
@ -29,6 +31,7 @@ function NavBarButton({ navigate, closeNav, name, icon, color, disabled, onClick
|
||||
export default function NavBar() {
|
||||
const [toggle, setToggleState] = useState(false);
|
||||
const { navOpened, closeNav } = useNavbarStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return <AppShell.Navbar p="md" hidden={!navOpened}>
|
||||
<Box px="xs" mt="xs">
|
||||
@ -40,11 +43,48 @@ export default function NavBar() {
|
||||
<NavBarButton navigate="firewall" closeNav={closeNav} name="Firewall Rules" color="red" icon={<PiWallLight size={19} />} />
|
||||
<NavBarButton navigate="porthijack" closeNav={closeNav} name="Hijack Port to Proxy" color="blue" icon={<GrDirections size={19} />} />
|
||||
<NavBarButton navigate="nfproxy" closeNav={closeNav} name="Netfilter Proxy" color="lime" icon={<TbPlugConnected size={19} />} />
|
||||
{/* <Box px="xs" mt="lg">
|
||||
<Title order={5}>Experimental Features 🧪</Title>
|
||||
<NavBarButton navigate="tls-decrypt" closeNav={closeNav} name="TLS Decryption" color="cyan" icon={<TbPlugConnected size={19} />} />
|
||||
<Box px="xs" mt="lg">
|
||||
<Title order={5}>Backup & Restore</Title>
|
||||
</Box>
|
||||
<Text></Text>
|
||||
<Divider my="xs" /> */}
|
||||
<Divider my="xs" />
|
||||
<NavBarButton closeNav={closeNav} name="Export Backup" color="yellow" icon={<MdDownload size={19} />} onClick={() => {
|
||||
getapi('export').then(data => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `firegex_backup_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
okNotify("Backup Exported", "Your configuration backup has been downloaded.");
|
||||
}).catch(err => errorNotify("Export Failed", err.toString()));
|
||||
}} />
|
||||
<NavBarButton closeNav={closeNav} name="Import Backup" color="orange" icon={<MdUpload size={19} />} onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target?.result as string);
|
||||
postapi('import', data).then(() => {
|
||||
okNotify("Backup Imported", "Configuration has been restored successfully.");
|
||||
queryClient.invalidateQueries();
|
||||
}).catch(err => errorNotify("Import Failed", err.toString()));
|
||||
} catch (err: any) {
|
||||
errorNotify("Invalid JSON", err.toString());
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
input.click();
|
||||
}} />
|
||||
</Box>
|
||||
|
||||
</AppShell.Navbar>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { Button, Group, Space, TextInput, Notification, Modal, Switch, SegmentedControl, Box } from '@mantine/core';
|
||||
import { Button, Group, Space, TextInput, Notification, Modal, Switch, SegmentedControl, Box, Card, Divider, Tabs, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useState } from 'react';
|
||||
import { okNotify, regex_ipv6_no_cidr, regex_ipv4_no_cidr } from '../../js/utils';
|
||||
import { ImCross } from "react-icons/im"
|
||||
import { porthijack } from './utils';
|
||||
import PortAndInterface from '../PortAndInterface';
|
||||
import { TbSettings, TbShieldLock } from 'react-icons/tb';
|
||||
|
||||
type ServiceAddForm = {
|
||||
name:string,
|
||||
@ -66,36 +67,68 @@ function AddNewService({ opened, onClose }:{ opened:boolean, onClose:()=>void })
|
||||
}
|
||||
|
||||
|
||||
return <Modal size="xl" title="Add a new service" opened={opened} onClose={close} closeOnClickOutside={false} centered>
|
||||
<form onSubmit={form.onSubmit(submitRequest)}>
|
||||
<TextInput
|
||||
label="Service name"
|
||||
placeholder="Challenge 01"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<Space h="md" />
|
||||
<PortAndInterface form={form} int_name="ip_src" port_name="public_port" label="Public IP Address and port (ipv4/ipv6)" />
|
||||
<Space h="md" />
|
||||
<PortAndInterface form={form} int_name="ip_dst" port_name="proxy_port" label="Proxy/Internal IP Address and port (ipv4/ipv6)" />
|
||||
<Space h="md" />
|
||||
return <Modal size="xl" title="Add a new service" opened={opened} onClose={close} closeOnClickOutside={false} centered>
|
||||
<form onSubmit={form.onSubmit(submitRequest)}>
|
||||
<Tabs defaultValue="general" color="cyan" variant="outline" radius="md">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="general" leftSection={<TbSettings size={16} />}>
|
||||
General
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="advanced" leftSection={<TbShieldLock size={16} />}>
|
||||
Advanced
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Box className='center-flex'>
|
||||
<Switch
|
||||
label="Auto-Start Service"
|
||||
{...form.getInputProps('autostart', { type: 'checkbox' })}
|
||||
/>
|
||||
<Box className="flex-spacer" />
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' },
|
||||
]}
|
||||
{...form.getInputProps('proto')}
|
||||
/>
|
||||
</Box>
|
||||
<Tabs.Panel value="general" pt="xl" pb="md">
|
||||
<TextInput
|
||||
label="Service name"
|
||||
placeholder="Challenge 01"
|
||||
size="md"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<Space h="md" />
|
||||
<Title order={6} mb="xs">Protocol</Title>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="md"
|
||||
color="cyan"
|
||||
data={[
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' },
|
||||
]}
|
||||
{...form.getInputProps('proto')}
|
||||
/>
|
||||
|
||||
<Space h="xl" />
|
||||
<Divider mb="xl" label="Interface & Port Setup" labelPosition="center" />
|
||||
|
||||
<PortAndInterface form={form} int_name="ip_src" port_name="public_port" label="Public IP Address and port (ipv4/ipv6)" />
|
||||
<Space h="md" />
|
||||
<PortAndInterface form={form} int_name="ip_dst" port_name="proxy_port" label="Proxy/Internal IP Address and port (ipv4/ipv6)" />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Group justify='flex-end' mt="md" mb="sm">
|
||||
<Button loading={submitLoading} type="submit">Add Service</Button>
|
||||
<Tabs.Panel value="advanced" pt="xl" pb="md">
|
||||
<Card withBorder padding="lg" radius="md" bg="transparent">
|
||||
<Box>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Title order={6}>Auto-Start Service</Title>
|
||||
<Switch
|
||||
size="md"
|
||||
color="cyan"
|
||||
{...form.getInputProps('autostart', { type: 'checkbox' })}
|
||||
/>
|
||||
</Group>
|
||||
<Title order={6} size="sm" c="dimmed" style={{ fontWeight: "normal" }}>
|
||||
Automatically start this service as soon as it is created.
|
||||
</Title>
|
||||
</Box>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Group justify='flex-end' mt="xl" mb="sm">
|
||||
<Button variant="default" onClick={close}>Cancel</Button>
|
||||
<Button loading={submitLoading} type="submit" color="cyan">Add Service</Button>
|
||||
</Group>
|
||||
|
||||
{error?<>
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import { ActionIcon, Badge, Box, Divider, Menu, Space, Title, Tooltip } from '@mantine/core';
|
||||
import { ActionIcon, Badge, Box, Divider, Menu, Space, Title, Tooltip, Card, Group, Button, Text } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { FaPlay, FaStop } from 'react-icons/fa';
|
||||
import { FaPlay, FaStop, FaTrash } from 'react-icons/fa';
|
||||
import { porthijack, Service } from '../utils';
|
||||
import YesNoModal from '../../YesNoModal';
|
||||
import { errorNotify, isMediumScreen, okNotify } from '../../../js/utils';
|
||||
import { BsArrowRepeat, BsTrashFill } from 'react-icons/bs';
|
||||
import { BiRename } from 'react-icons/bi'
|
||||
import { BiRename } from 'react-icons/bi';
|
||||
import RenameForm from './RenameForm';
|
||||
import ChangeDestination from './ChangeDestination';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { MenuDropDownWithButton } from '../../MainLayout';
|
||||
import { MdDoubleArrow } from "react-icons/md";
|
||||
import { TbHexagon } from "react-icons/tb";
|
||||
|
||||
export default function ServiceRow({ service }:{ service:Service }) {
|
||||
|
||||
@ -69,67 +69,80 @@ export default function ServiceRow({ service }:{ service:Service }) {
|
||||
}
|
||||
|
||||
return <>
|
||||
<Box className='firegex__nfregex__rowbox'>
|
||||
<Box className="firegex__nfregex__row" style={{width:"100%", flexDirection: isMedium?"row":"column"}}>
|
||||
<Box>
|
||||
<Box className="center-flex" style={{ justifyContent: "flex-start" }}>
|
||||
<MdDoubleArrow size={30} style={{color: "white"}}/>
|
||||
<Title className="firegex__nfregex__name" ml="xs">
|
||||
{service.name}
|
||||
</Title>
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
w="100%"
|
||||
bg="transparent"
|
||||
style={{
|
||||
borderColor: 'var(--fourth_color)',
|
||||
transition: 'border-color 0.2s ease',
|
||||
'&:hover': { borderColor: 'var(--mantine-color-dark-4)' }
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"}>
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<Box style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 8,
|
||||
backgroundColor: status_color === 'teal' ? 'rgba(32, 201, 151, 0.1)' : 'rgba(250, 82, 82, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: status_color === 'teal' ? 'var(--mantine-color-teal-filled)' : 'var(--mantine-color-red-filled)'
|
||||
}}>
|
||||
<TbHexagon size={24} />
|
||||
</Box>
|
||||
<Box className="center-flex" style={{ gap: 8, marginTop: 15, justifyContent: "flex-start" }}>
|
||||
<Badge color={status_color} radius="md" size="md" variant="filled">{service.active?"ENABLED":"DISABLED"}</Badge>
|
||||
<Badge color={service.proto === "tcp"?"cyan":"orange"} radius="md" size="md" variant="filled">
|
||||
{service.proto}
|
||||
</Badge>
|
||||
<Box>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="md">{service.name}</Text>
|
||||
<Badge color={status_color} variant="light" size="xs" radius="sm">
|
||||
{service.active ? "ENABLED" : "DISABLED"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
|
||||
FROM {service.ip_src}:{service.public_port}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ letterSpacing: 0.5 }}>
|
||||
• TO {service.ip_dst}:{service.proxy_port} ON {service.proto.toUpperCase()}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
{isMedium?null:<Space w="xl" />}
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Box className={isMedium?"center-flex":"center-flex-row"}>
|
||||
<Box className="center-flex-row">
|
||||
<Badge color="lime" radius="sm" size="lg" variant="filled">
|
||||
FROM {service.ip_src} :{service.public_port}
|
||||
</Badge>
|
||||
<Space h="sm" />
|
||||
<Badge color="blue" radius="sm" size="lg" variant="filled">
|
||||
<Box className="center-flex">
|
||||
TO {service.ip_dst} :{service.proxy_port}
|
||||
</Box>
|
||||
</Badge>
|
||||
</Box>
|
||||
{isMedium?<Space w="xl" />:<Space h="lg" />}
|
||||
<Box className="center-flex">
|
||||
<MenuDropDownWithButton>
|
||||
<Menu.Label><b>Rename service</b></Menu.Label>
|
||||
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
|
||||
<Menu.Label><b>Change destination</b></Menu.Label>
|
||||
<Menu.Item leftSection={<BsArrowRepeat size={18} />} onClick={()=>setChangeDestModal(true)}>Change hijacking destination</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Label><b>Danger zone</b></Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
|
||||
</MenuDropDownWithButton>
|
||||
<Space w="md"/>
|
||||
<Tooltip label="Stop service" zIndex={0} color="red">
|
||||
<ActionIcon color="red" loading={buttonLoading}
|
||||
onClick={stopService} size="xl" radius="md" variant="filled"
|
||||
disabled={!service.active}
|
||||
aria-describedby="tooltip-stop-id">
|
||||
<FaStop size="20px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="md"/>
|
||||
<Tooltip label="Start service" zIndex={0} color="teal">
|
||||
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading}
|
||||
variant="filled" disabled={service.active}>
|
||||
<FaPlay size="20px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
{service.active ? (
|
||||
<Button variant="default" size="xs" leftSection={<FaStop size={10} />} onClick={stopService} loading={buttonLoading}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="xs" leftSection={<FaPlay size={10} />} onClick={startService} loading={buttonLoading}>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<Button variant="default" size="xs" leftSection={<BsArrowRepeat size={10} />}>
|
||||
Options
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Actions</Menu.Label>
|
||||
<Menu.Item leftSection={<BiRename size={14} />} onClick={()=>setRenameModal(true)}>Rename service</Menu.Item>
|
||||
<Menu.Item leftSection={<BsArrowRepeat size={14} />} onClick={()=>setChangeDestModal(true)}>Change hijacking destination</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<Button variant="default" size="xs" leftSection={<FaTrash size={10} />} onClick={() => setDeleteModal(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<YesNoModal
|
||||
title='Are you sure to delete this service?'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Text, Badge, Space, ActionIcon, Tooltip, Box } from '@mantine/core';
|
||||
import { Text, Badge, Space, ActionIcon, Tooltip, Box, Card, Group } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { PyFilter } from '../../js/models';
|
||||
import { errorNotify, isMediumScreen, okNotify } from '../../js/utils';
|
||||
@ -21,24 +21,43 @@ export default function PyFilterView({ filterInfo }:{ filterInfo:PyFilter }) {
|
||||
}).catch( err => errorNotify(`Filter ${filterInfo.name} ${filterInfo.active?"deactivation":"activation"} failed!`,`Error: ${err}`))
|
||||
}
|
||||
|
||||
return <Box my="sm" display="flex" style={{alignItems:"center"}}>
|
||||
|
||||
<Box className="firegex__regexview__pyfilter_text" style={{ width: "100%", alignItems: "center"}} display="flex" >
|
||||
<Badge size="sm" radius="lg" mr="sm" color={filterInfo.active?"lime":"red"} variant="filled" />
|
||||
{filterInfo.name}
|
||||
<Box className='flex-spacer' />
|
||||
<Space w="xs" />
|
||||
{isMedium?<>
|
||||
<Badge size="md" radius="md" color="yellow" variant="filled"><FaFilter style={{ marginBottom: -2, marginRight: 2}} /> {filterInfo.blocked_packets}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge size="md" radius="md" color="orange" variant="filled"><FaPencilAlt style={{ marginBottom: -1, marginRight: 2}} /> {filterInfo.edited_packets}</Badge>
|
||||
<Space w="lg" />
|
||||
</>:null}
|
||||
<Tooltip label={filterInfo.active?"Deactivate":"Activate"} zIndex={0} color={filterInfo.active?"orange":"teal"}>
|
||||
<ActionIcon color={filterInfo.active?"orange":"teal"} onClick={changeRegexStatus} size="lg" radius="md" variant="filled">
|
||||
{filterInfo.active?<FaPause size="20px" />:<FaPlay size="20px" />}</ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="sm"
|
||||
mb="sm"
|
||||
bg="transparent"
|
||||
style={{ borderColor: 'var(--fourth_color)', transition: 'border-color 0.2s ease' }}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" align="center">
|
||||
<Badge size="xs" circle color={filterInfo.active ? "lime" : "red"} variant="filled" />
|
||||
<Text fw={600} style={{ fontFamily: 'monospace' }}>{filterInfo.name}</Text>
|
||||
</Group>
|
||||
|
||||
</Box>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge size="sm" color="yellow" variant="light" leftSection={<FaFilter size={10} style={{ marginTop: 2 }} />}>
|
||||
{filterInfo.blocked_packets}
|
||||
</Badge>
|
||||
<Badge size="sm" color="orange" variant="light" leftSection={<FaPencilAlt size={10} style={{ marginTop: 2 }} />}>
|
||||
{filterInfo.edited_packets}
|
||||
</Badge>
|
||||
<Space w="xs" />
|
||||
<Tooltip label={filterInfo.active ? "Deactivate" : "Activate"} color={filterInfo.active ? "orange" : "teal"}>
|
||||
<ActionIcon
|
||||
color={filterInfo.active ? "orange" : "teal"}
|
||||
onClick={changeRegexStatus}
|
||||
size="lg"
|
||||
radius="md"
|
||||
variant="light"
|
||||
>
|
||||
{filterInfo.active ? <FaPause size={16} /> : <FaPlay size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Text, Title, Badge, Space, ActionIcon, Tooltip, Box } from '@mantine/core';
|
||||
import { Text, Title, Badge, Space, ActionIcon, Tooltip, Box, Card, Code, Group } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { RegexFilter } from '../../js/models';
|
||||
import { b64decode, errorNotify, isMediumScreen, okNotify } from '../../js/utils';
|
||||
@ -41,45 +41,93 @@ function RegexView({ regexInfo }:{ regexInfo:RegexFilter }) {
|
||||
}).catch( err => errorNotify(`Regex ${regex_expr} ${regexInfo.active?"deactivation":"activation"} failed!`,`Error: ${err}`))
|
||||
}
|
||||
|
||||
return <Box className="firegex__regexview__box">
|
||||
<Box>
|
||||
<Box className='center-flex' style={{width: "100%"}}>
|
||||
<Box className="firegex__regexview__outer_regex_text">
|
||||
<Text className="firegex__regexview__regex_text" onClick={()=>{
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="sm"
|
||||
mb="sm"
|
||||
bg="transparent"
|
||||
style={{ borderColor: 'var(--fourth_color)', transition: 'border-color 0.2s ease' }}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Box style={{ flexGrow: 1, overflow: 'hidden' }}>
|
||||
<Tooltip label="Click to copy regex" position="top-start">
|
||||
<Code
|
||||
block
|
||||
color="dark.8"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
fontSize: '1.1rem',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--fourth_color)',
|
||||
backgroundColor: 'rgba(0,0,0,0.2)'
|
||||
}}
|
||||
onClick={() => {
|
||||
clipboard.copy(regex_expr)
|
||||
okNotify("Regex copied to clipboard!",`The regex '${regex_expr}' has been copied to the clipboard!`)
|
||||
}}>{regex_expr}</Text>
|
||||
</Box>
|
||||
<Space w="xs" />
|
||||
<Tooltip label={regexInfo.active?"Deactivate":"Activate"} zIndex={0} color={regexInfo.active?"orange":"teal"}>
|
||||
<ActionIcon color={regexInfo.active?"orange":"teal"} onClick={changeRegexStatus} size="xl" radius="md" variant="filled"
|
||||
>{regexInfo.active?<FaPause size="20px" />:<FaPlay size="20px" />}</ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="xs" />
|
||||
<Tooltip label="Delete regex" zIndex={0} color="red" >
|
||||
<ActionIcon color="red" onClick={()=>setDeleteModal(true)} size="xl" radius="md" variant="filled">
|
||||
<BsTrashFill size={22} /></ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box display="flex" mt="sm" ml="xs">
|
||||
<Badge size="md" color="yellow" variant="filled"><FaFilter style={{ marginBottom: -2}} /> {regexInfo.n_packets}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge size="md" color={regexInfo.active?"lime":"red"} variant="filled">{regexInfo.active?"ACTIVE":"DISABLED"}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge size="md" color={regexInfo.is_case_sensitive?"grape":"pink"} variant="filled">{regexInfo.is_case_sensitive?"Strict":"Loose"}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge size="md" color="blue" variant="filled">{mode_string}</Badge>
|
||||
</Box>
|
||||
okNotify("Regex copied to clipboard!", `The regex '${regex_expr}' has been copied to the clipboard!`)
|
||||
}}
|
||||
>
|
||||
{regex_expr}
|
||||
</Code>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<YesNoModal
|
||||
title='Are you sure to delete this regex?'
|
||||
description={`You are going to delete the regex '${regex_expr}'.`}
|
||||
onClose={()=>setDeleteModal(false)}
|
||||
action={deleteRegex}
|
||||
opened={deleteModal}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
|
||||
<Group gap="sm" wrap="nowrap" ml="md">
|
||||
<Tooltip label={regexInfo.active ? "Deactivate" : "Activate"} color={regexInfo.active ? "orange" : "teal"}>
|
||||
<ActionIcon
|
||||
color={regexInfo.active ? "orange" : "teal"}
|
||||
onClick={changeRegexStatus}
|
||||
size="xl"
|
||||
radius="md"
|
||||
variant="light"
|
||||
>
|
||||
{regexInfo.active ? <FaPause size={18} /> : <FaPlay size={18} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete regex" color="red">
|
||||
<ActionIcon
|
||||
color="red"
|
||||
onClick={() => setDeleteModal(true)}
|
||||
size="xl"
|
||||
radius="md"
|
||||
variant="light"
|
||||
>
|
||||
<BsTrashFill size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" mt="md">
|
||||
<Badge size="md" color="yellow" variant="light" leftSection={<FaFilter size={12} style={{ marginTop: 2 }} />}>
|
||||
{regexInfo.n_packets} packets
|
||||
</Badge>
|
||||
<Badge size="md" color={regexInfo.active ? "lime" : "red"} variant="light">
|
||||
{regexInfo.active ? "ACTIVE" : "DISABLED"}
|
||||
</Badge>
|
||||
<Badge size="md" color={regexInfo.is_case_sensitive ? "grape" : "pink"} variant="light">
|
||||
{regexInfo.is_case_sensitive ? "Strict" : "Loose"}
|
||||
</Badge>
|
||||
<Badge size="md" color="blue" variant="light">
|
||||
{mode_string}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<YesNoModal
|
||||
title='Are you sure to delete this regex?'
|
||||
description={`You are going to delete the regex '${regex_expr}'.`}
|
||||
onClose={() => setDeleteModal(false)}
|
||||
action={deleteRegex}
|
||||
opened={deleteModal}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default RegexView;
|
||||
|
||||
38
frontend/src/components/TLSAssociationBadge.tsx
Normal file
38
frontend/src/components/TLSAssociationBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
frontend/src/components/TLSServiceFields.tsx
Normal file
118
frontend/src/components/TLSServiceFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,23 @@
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Lato&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap');
|
||||
|
||||
:root{
|
||||
--primary_color: #242a33;
|
||||
--secondary_color: #1A1B1E;
|
||||
--third_color:#25262b;
|
||||
--fourth_color: #25262b99;
|
||||
--primary_color: #161618;
|
||||
--secondary_color: #0a0a0c;
|
||||
--third_color: #1e1e20;
|
||||
--fourth_color: #2c2c2f;
|
||||
--accent-color: #00e5ff;
|
||||
--accent-hover: #00b8cc;
|
||||
--text-primary: #f5f5f5;
|
||||
--text-secondary: #9aa0a6;
|
||||
--mantine-color-body: var(--secondary_color) !important;
|
||||
--mantine-font-family: 'Inter', sans-serif !important;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Lato', sans-serif;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--secondary_color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.center-flex{
|
||||
@ -132,19 +138,20 @@ body {
|
||||
}
|
||||
|
||||
.firegex__porthijack__servicerow__name{
|
||||
font-family: 'Hanken Grotesk', sans-serif;
|
||||
font-size: 1.8em;
|
||||
font-weight: bolder;
|
||||
font-weight: 700;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 13px;
|
||||
color:#FFF;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.firegex__porthijack__servicerow__portInput *{
|
||||
color: #FFF;
|
||||
color: var(--accent-color);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
margin-top: -1px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.firegex__nfregex__row{
|
||||
@ -155,13 +162,21 @@ body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.firegex__nfregex__row:hover{
|
||||
}
|
||||
|
||||
.firegex__nfregex__name{
|
||||
font-family: 'Hanken Grotesk', sans-serif;
|
||||
font-size: 1.6em;
|
||||
font-weight: bolder;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
color:#FFF;
|
||||
color: var(--text-primary);
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
margin-left: -20px;
|
||||
@ -175,12 +190,13 @@ body {
|
||||
.firegex__navbar__unstyled_button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-xs);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
color: var(--mantine-color-dark-0);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
background-color: transparent;
|
||||
|
||||
opacity: 1;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.firegex__navbar__unstyled_button.disabled {
|
||||
@ -188,16 +204,20 @@ body {
|
||||
}
|
||||
|
||||
.firegex__navbar__unstyled_button.selected{
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
background-color: var(--third_color);
|
||||
color: var(--accent-color);
|
||||
border-color: var(--fourth_color);
|
||||
}
|
||||
|
||||
.firegex__navbar__unstyled_button:hover {
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
background-color: var(--third_color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.firegex__footer{
|
||||
margin-top: 50px;
|
||||
background-color: var(--primary_color);
|
||||
border-top: 1px solid var(--third_color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@ -227,9 +247,29 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
background-color: var(--fourth_color);
|
||||
border-radius: 10px;
|
||||
border: #444 3px solid;
|
||||
background-color: transparent;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--fourth_color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mantine-CodeHighlight-root, .mantine-CodeHighlight-pre {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.firegex__clickable_row {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.firegex__clickable_row:hover {
|
||||
border-color: var(--accent-color);
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.firegex__clickable_row:active {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
|
||||
@ -55,9 +55,72 @@ export const customShikiAdapter: CodeHighlightAdapter = {
|
||||
},
|
||||
};
|
||||
|
||||
import { createTheme } from '@mantine/core';
|
||||
|
||||
const myTheme = createTheme({
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
headings: { fontFamily: 'Hanken Grotesk, sans-serif' },
|
||||
primaryColor: 'cyan',
|
||||
colors: {
|
||||
dark: [
|
||||
'#f5f5f5', // 0: text
|
||||
'#9aa0a6', // 1: text-secondary
|
||||
'#5f6368', // 2: outline
|
||||
'#3c4043', // 3: outline-variant
|
||||
'#2c2c2f', // 4: surface-variant / fourth_color
|
||||
'#1e1e20', // 5: surface-container-high / third_color
|
||||
'#1a1a1c', // 6: surface-container
|
||||
'#161618', // 7: surface-container-low / primary_color
|
||||
'#0a0a0c', // 8: background / secondary_color
|
||||
'#050505', // 9: surface-container-lowest
|
||||
],
|
||||
cyan: [
|
||||
'#e0ffff',
|
||||
'#b3fbff',
|
||||
'#80f6ff',
|
||||
'#4df0ff',
|
||||
'#20ebff',
|
||||
'#00e5ff', // 5: surface-tint / accent
|
||||
'#00b8cc',
|
||||
'#008f9f',
|
||||
'#006975',
|
||||
'#004f59',
|
||||
]
|
||||
},
|
||||
components: {
|
||||
AppShell: {
|
||||
styles: {
|
||||
root: {
|
||||
backgroundColor: '#0a0a0c',
|
||||
},
|
||||
navbar: {
|
||||
backgroundColor: '#161618',
|
||||
borderRight: '1px solid #2c2c2f',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#161618',
|
||||
borderBottom: '1px solid #2c2c2f',
|
||||
}
|
||||
}
|
||||
},
|
||||
Card: {
|
||||
defaultProps: {
|
||||
bg: '#1e1e20',
|
||||
radius: 'md',
|
||||
withBorder: true,
|
||||
},
|
||||
styles: {
|
||||
root: {
|
||||
borderColor: '#2c2c2f',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider defaultColorScheme='dark'>
|
||||
<MantineProvider defaultColorScheme='dark' theme={myTheme}>
|
||||
<CodeHighlightAdapterProvider adapter={customShikiAdapter}>
|
||||
<Notifications />
|
||||
<BrowserRouter>
|
||||
|
||||
@ -32,6 +32,7 @@ export const socketio = import.meta.env.DEV?
|
||||
io("ws://"+DEV_IP_BACKEND, {
|
||||
path:"/sock/socket.io",
|
||||
transports: ['websocket'],
|
||||
autoConnect: false,
|
||||
auth: {
|
||||
token: useAuthStore.getState().getAccessToken()
|
||||
}
|
||||
@ -39,6 +40,7 @@ export const socketio = import.meta.env.DEV?
|
||||
io({
|
||||
path:"/sock/socket.io",
|
||||
transports: ['websocket'],
|
||||
autoConnect: false,
|
||||
auth: {
|
||||
token: useAuthStore.getState().getAccessToken()
|
||||
}
|
||||
@ -91,7 +93,10 @@ export async function genericapi(method:string,path:string,data:any = undefined,
|
||||
},
|
||||
body: data? (is_form ? (new URLSearchParams(data)).toString() : JSON.stringify(data)) : undefined
|
||||
}).then(res => {
|
||||
if(res.status === 401) window.location.reload()
|
||||
if(res.status === 401) {
|
||||
useAuthStore.getState().clearAccessToken();
|
||||
window.location.reload();
|
||||
}
|
||||
if(res.status === 406) resolve({status:"Wrong Password"})
|
||||
if(!res.ok){
|
||||
const errorDefault = res.statusText
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button, Group, Space, Modal, Switch } from '@mantine/core';
|
||||
import { Button, Card, Group, Space, Modal, Stack, Switch } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { errorNotify, okNotify } from '../../js/utils';
|
||||
import { FirewallSettings, firewall } from '../../components/Firewall/utils';
|
||||
@ -33,22 +33,20 @@ export function SettingsModal({ opened, onClose }:{ opened:boolean, onClose:()=>
|
||||
|
||||
return <Modal size="xl" title="Change firewall settings" opened={opened} onClose={onClose} closeOnClickOutside={false} centered>
|
||||
|
||||
<Switch label="Keep rules on firegex shutdown" checked={settings.keep_rules} onChange={v => setSettings({...settings, keep_rules:v.target.checked})}/>
|
||||
<Card withBorder radius="md" p="md" bg="transparent" style={{ borderColor: 'var(--fourth_color)' }}>
|
||||
<Stack gap="md">
|
||||
<Switch label="Keep rules on firegex shutdown" checked={settings.keep_rules} onChange={v => setSettings({...settings, keep_rules:v.target.checked})}/>
|
||||
<Switch label="Allow loopback to communicate with itself" checked={settings.allow_loopback} onChange={v => setSettings({...settings, allow_loopback:v.target.checked})}/>
|
||||
<Switch label="Allow established connection (essential to allow opening connection) (Dangerous to disable)" checked={settings.allow_established} onChange={v => setSettings({...settings, allow_established:v.target.checked})}/>
|
||||
<Switch label="Allow icmp packets" checked={settings.allow_icmp} onChange={v => setSettings({...settings, allow_icmp:v.target.checked})}/>
|
||||
<Switch label="Allow multicast DNS" checked={settings.multicast_dns} onChange={v => setSettings({...settings, multicast_dns:v.target.checked})}/>
|
||||
<Switch label="Allow UPnP protocol" checked={settings.allow_upnp} onChange={v => setSettings({...settings, allow_upnp:v.target.checked})}/>
|
||||
<Switch label="Drop invalid packet" checked={settings.drop_invalid} onChange={v => setSettings({...settings, drop_invalid:v.target.checked})}/>
|
||||
<Switch label="Allow DHCP" checked={settings.allow_dhcp} onChange={v => setSettings({...settings, allow_dhcp:v.target.checked})}/>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Space h="md" />
|
||||
<Switch label="Allow loopback to communicate with itself" checked={settings.allow_loopback} onChange={v => setSettings({...settings, allow_loopback:v.target.checked})}/>
|
||||
<Space h="md" />
|
||||
<Switch label="Allow established connection (essential to allow opening connection) (Dangerous to disable)" checked={settings.allow_established} onChange={v => setSettings({...settings, allow_established:v.target.checked})}/>
|
||||
<Space h="md" />
|
||||
<Switch label="Allow icmp packets" checked={settings.allow_icmp} onChange={v => setSettings({...settings, allow_icmp:v.target.checked})}/>
|
||||
<Space h="md" />
|
||||
<Switch label="Allow multicast DNS" checked={settings.multicast_dns} onChange={v => setSettings({...settings, multicast_dns:v.target.checked})}/>
|
||||
<Space h="md" />
|
||||
<Switch label="Allow UPnP protocol" checked={settings.allow_upnp} onChange={v => setSettings({...settings, allow_upnp:v.target.checked})}/>
|
||||
<Space h="md" />
|
||||
<Switch label="Drop invalid packet" checked={settings.drop_invalid} onChange={v => setSettings({...settings, drop_invalid:v.target.checked})}/>
|
||||
<Space h="md" />
|
||||
<Switch label="Allow DHCP" checked={settings.allow_dhcp} onChange={v => setSettings({...settings, allow_dhcp:v.target.checked})}/>
|
||||
<Group align="right" mt="md">
|
||||
<Group align="right">
|
||||
<Button loading={submitLoading} onClick={submitRequest}>Save Setting</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ActionIcon, Badge, Box, Divider, FloatingIndicator, LoadingOverlay, Space, Switch, Table, Tabs, TextInput, ThemeIcon, Title, Tooltip, useMantineTheme } from "@mantine/core"
|
||||
import { ActionIcon, Badge, Box, Card, Divider, FloatingIndicator, Group, LoadingOverlay, Space, Stack, Switch, Table, Tabs, TextInput, ThemeIcon, Title, Tooltip, useMantineTheme } from "@mantine/core"
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsPlusLg, BsTrashFill } from "react-icons/bs"
|
||||
import { rem } from '@mantine/core';
|
||||
@ -25,196 +25,186 @@ import { PiWallLight } from "react-icons/pi";
|
||||
|
||||
export const Firewall = () => {
|
||||
|
||||
const [currentPolicy, setCurrentPolicy] = useState<ActionType>(ActionType.ACCEPT)
|
||||
const queryClient = useQueryClient()
|
||||
const rules = firewallRulesQuery()
|
||||
const [state, handlers] = useListState<Rule & {rule_id:string}>([]);
|
||||
const [enableFwModal, setEnableFwModal] = useState(false)
|
||||
const [applyChangeModal, setApplyChangeModal] = useState(false)
|
||||
const [settingsModal, setSettingsModal] = useState(false)
|
||||
const theme = useMantineTheme();
|
||||
const isMedium = useMediaQuery(`(min-width: 950px)`)??true
|
||||
const isSmall = useMediaQuery(`(max-width: 600px)`)??false
|
||||
const [currentPolicy, setCurrentPolicy] = useState<ActionType>(ActionType.ACCEPT)
|
||||
const queryClient = useQueryClient()
|
||||
const rules = firewallRulesQuery()
|
||||
const [state, handlers] = useListState<Rule & { rule_id: string }>([]);
|
||||
const [enableFwModal, setEnableFwModal] = useState(false)
|
||||
const [applyChangeModal, setApplyChangeModal] = useState(false)
|
||||
const [settingsModal, setSettingsModal] = useState(false)
|
||||
const theme = useMantineTheme();
|
||||
const isMedium = useMediaQuery(`(min-width: 950px)`) ?? true
|
||||
|
||||
const [updateMevalueinternal, internalUpdateme] = useState(false)
|
||||
const updateMe = () => {
|
||||
internalUpdateme(!updateMevalueinternal)
|
||||
const [updateMevalueinternal, internalUpdateme] = useState(false)
|
||||
const updateMe = () => {
|
||||
internalUpdateme(!updateMevalueinternal)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (rules.isError)
|
||||
errorNotify("Firewall Update failed!", getErrorMessage(rules.error))
|
||||
}, [rules.isError])
|
||||
|
||||
useEffect(() => {
|
||||
if (!rules.isLoading && rules.isFetched && !rules.isFetching) {
|
||||
setCurrentPolicy(rules.data?.policy ?? ActionType.ACCEPT)
|
||||
handlers.setState(JSON.parse(JSON.stringify((rules.data?.rules ?? []).map(v => ({ rule_id: makeid(30), ...v })))))
|
||||
}
|
||||
}, [rules.isFetched, rules.isLoading, rules.isFetching])
|
||||
|
||||
useEffect(()=> {
|
||||
if(rules.isError)
|
||||
errorNotify("Firewall Update failed!", getErrorMessage(rules.error))
|
||||
},[rules.isError])
|
||||
const fwEnabled = rules.data?.enabled ?? false
|
||||
const valuesChanged = JSON.stringify(rules.data?.rules) != JSON.stringify(state.map(v => {
|
||||
const { rule_id, ...rest } = v
|
||||
return rest
|
||||
})) || rules.data?.policy != currentPolicy
|
||||
const [selectedTab, setSelectedTab] = useState<NFTables>(NFTables.FILTER)
|
||||
|
||||
useEffect(()=> {
|
||||
if(!rules.isLoading && rules.isFetched && !rules.isFetching){
|
||||
setCurrentPolicy(rules.data?.policy??ActionType.ACCEPT)
|
||||
handlers.setState(JSON.parse(JSON.stringify((rules.data?.rules??[]).map( v => ({rule_id: makeid(30), ...v})))))
|
||||
|
||||
const enableFirewall = () => {
|
||||
if (valuesChanged) {
|
||||
applyChangesRaw().then(() => enableFirewallRaw())
|
||||
} else {
|
||||
enableFirewallRaw()
|
||||
}
|
||||
}
|
||||
|
||||
const enableFirewallRaw = () => {
|
||||
return firewall.enable()
|
||||
.then(() => okNotify("Firewall enabled", "The firewall has been enabled"))
|
||||
.catch((e) => errorNotify("Firewall enable failed!", getErrorMessage(e)))
|
||||
}
|
||||
|
||||
const disableFirewallRaw = () => {
|
||||
return firewall.disable()
|
||||
.then(() => okNotify("Firewall disabled", "The firewall has been disabled"))
|
||||
.catch((e) => errorNotify("Firewall disable failed!", getErrorMessage(e)))
|
||||
}
|
||||
|
||||
const switchState = () => {
|
||||
if (fwEnabled)
|
||||
disableFirewallRaw()
|
||||
else
|
||||
if ([ActionType.REJECT, ActionType.DROP].includes(currentPolicy) || valuesChanged) {
|
||||
setEnableFwModal(true)
|
||||
} else {
|
||||
enableFirewall()
|
||||
}
|
||||
}
|
||||
|
||||
const applyChanges = () => {
|
||||
if (fwEnabled && rules.data?.policy == ActionType.ACCEPT && [ActionType.REJECT, ActionType.DROP].includes(currentPolicy)) {
|
||||
setApplyChangeModal(true)
|
||||
} else {
|
||||
applyChangesRaw()
|
||||
}
|
||||
}
|
||||
|
||||
const emptyRuleAdd = () => {
|
||||
handlers.insert(0, {
|
||||
rule_id: makeid(30),
|
||||
active: true,
|
||||
name: "Rule name",
|
||||
proto: Protocol.TCP,
|
||||
src: "",
|
||||
dst: "",
|
||||
port_src_from: 1,
|
||||
port_dst_from: 8080,
|
||||
port_src_to: 65535,
|
||||
port_dst_to: 8080,
|
||||
action: ActionType.ACCEPT,
|
||||
mode: RuleMode.IN,
|
||||
table: selectedTab
|
||||
})
|
||||
}
|
||||
|
||||
const parseRules = () => {
|
||||
return state
|
||||
}
|
||||
|
||||
const applyChangesRaw = () => {
|
||||
const parsedRules = parseRules()
|
||||
if (parsedRules === undefined) {
|
||||
errorNotify("Firewall rules apply failed!", "The firewall rules are not valid")
|
||||
return Promise.reject()
|
||||
} else {
|
||||
return firewall.ruleset({ rules: state, policy: currentPolicy })
|
||||
.then(() => okNotify("Firewall rules applied", "The firewall rules has been applied"))
|
||||
.catch((e) => errorNotify("Firewall rules apply failed!", getErrorMessage(e)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const items = state.map((item, index) => (
|
||||
item.table == selectedTab && <Draggable key={item.rule_id} index={index} draggableId={item.rule_id}>
|
||||
{(provided, snapshot) => {
|
||||
const customInt = [
|
||||
{ value: "0.0.0.0/0", netint: "ANY IPv4", label: "0.0.0.0/0" },
|
||||
{ value: "::/0", netint: "ANY IPv6", label: "::/0" },
|
||||
{ value: "", netint: "ANY", label: "ANY" }
|
||||
]
|
||||
const src_custom_int = customInt.map(v => v.value).includes(item.src) ? [] : [{ value: item.src, netint: "SELECTED", label: item.src }]
|
||||
const dst_custom_int = customInt.map(v => v.value).includes(item.dst) ? [] : [{ value: item.dst, netint: "SELECTED", label: item.dst }]
|
||||
const [srcPortEnabled, setSrcPortEnabled] = useState(item.port_src_from != 1 || item.port_src_to != 65535)
|
||||
const [dstPortEnabled, setDstPortEnabled] = useState(item.port_dst_from != 1 || item.port_dst_to != 65535)
|
||||
const [srcPortValue, setSrcPortValue] = useState(item.port_src_from == item.port_src_to ? `${item.port_src_from}` : `${item.port_src_from}-${item.port_src_to}`)
|
||||
const [dstPortValue, setDstPortValue] = useState(item.port_dst_from == item.port_dst_to ? `${item.port_dst_from}` : `${item.port_dst_from}-${item.port_dst_to}`)
|
||||
|
||||
const port_range_setter = (rule: Rule, v: string, { src = false, dst = false }: { src?: boolean, dst?: boolean }) => {
|
||||
const elements = v.split("-")
|
||||
const values = [elements[0] ? parseInt(elements[0]) : 0, elements[1] ? parseInt(elements[1]) : 0]
|
||||
values[1] = values[1] ? values[1] : values[0]
|
||||
if (src) {
|
||||
rule.port_src_from = values[0]
|
||||
rule.port_src_to = values[1]
|
||||
setSrcPortValue(v)
|
||||
}
|
||||
if (dst) {
|
||||
rule.port_dst_from = values[0]
|
||||
rule.port_dst_to = values[1]
|
||||
setDstPortValue(v)
|
||||
}
|
||||
updateMe()
|
||||
}
|
||||
},[rules.isFetched, rules.isLoading, rules.isFetching])
|
||||
|
||||
const fwEnabled = rules.data?.enabled??false
|
||||
const valuesChanged = JSON.stringify(rules.data?.rules) != JSON.stringify(state.map(v => {
|
||||
const {rule_id, ...rest} = v
|
||||
return rest
|
||||
})) || rules.data?.policy != currentPolicy
|
||||
const [selectedTab, setSelectedTab] = useState<NFTables>(NFTables.FILTER)
|
||||
|
||||
|
||||
const enableFirewall = () => {
|
||||
if (valuesChanged){
|
||||
applyChangesRaw().then(()=>enableFirewallRaw())
|
||||
}else{
|
||||
enableFirewallRaw()
|
||||
}
|
||||
}
|
||||
|
||||
const enableFirewallRaw = () => {
|
||||
return firewall.enable()
|
||||
.then(()=>okNotify("Firewall enabled", "The firewall has been enabled"))
|
||||
.catch((e)=>errorNotify("Firewall enable failed!", getErrorMessage(e)))
|
||||
}
|
||||
|
||||
const disableFirewallRaw = () => {
|
||||
return firewall.disable()
|
||||
.then(()=>okNotify("Firewall disabled", "The firewall has been disabled"))
|
||||
.catch((e)=>errorNotify("Firewall disable failed!", getErrorMessage(e)))
|
||||
}
|
||||
|
||||
const switchState = () => {
|
||||
if (fwEnabled)
|
||||
disableFirewallRaw()
|
||||
else
|
||||
if ([ActionType.REJECT, ActionType.DROP].includes(currentPolicy) || valuesChanged){
|
||||
setEnableFwModal(true)
|
||||
}else{
|
||||
enableFirewall()
|
||||
const ip_setter = (rule: Rule, v: string | null, { src = false, dst = false }: { src?: boolean, dst?: boolean }) => {
|
||||
const values = v ? v : ""
|
||||
if (src) {
|
||||
rule.src = values
|
||||
}
|
||||
if (dst) {
|
||||
rule.dst = values
|
||||
}
|
||||
updateMe()
|
||||
}
|
||||
}
|
||||
|
||||
const applyChanges = () => {
|
||||
if (fwEnabled && rules.data?.policy == ActionType.ACCEPT && [ActionType.REJECT, ActionType.DROP].includes(currentPolicy)){
|
||||
setApplyChangeModal(true)
|
||||
}else{
|
||||
applyChangesRaw()
|
||||
}
|
||||
}
|
||||
const proto_any = item.proto == Protocol.ANY
|
||||
const disabletab = {
|
||||
port_box: proto_any,
|
||||
src_port: !srcPortEnabled || proto_any,
|
||||
dst_port: !dstPortEnabled || proto_any
|
||||
}
|
||||
|
||||
const condDiv = (val:React.ReactNode, cond:boolean) => {
|
||||
if (cond)
|
||||
return <Box>{val}</Box>
|
||||
else
|
||||
return val
|
||||
}
|
||||
|
||||
const emptyRuleAdd = () => {
|
||||
handlers.insert(0,{
|
||||
rule_id: makeid(30),
|
||||
active: true,
|
||||
name: "Rule name",
|
||||
proto: Protocol.TCP,
|
||||
src: "",
|
||||
dst: "",
|
||||
port_src_from: 1,
|
||||
port_dst_from: 8080,
|
||||
port_src_to: 65535,
|
||||
port_dst_to: 8080,
|
||||
action: ActionType.ACCEPT,
|
||||
mode: RuleMode.IN,
|
||||
table: selectedTab
|
||||
})
|
||||
}
|
||||
|
||||
const parseRules = () => {
|
||||
return state
|
||||
}
|
||||
|
||||
const applyChangesRaw = () => {
|
||||
const parsedRules = parseRules()
|
||||
if (parsedRules === undefined){
|
||||
errorNotify("Firewall rules apply failed!", "The firewall rules are not valid")
|
||||
return Promise.reject()
|
||||
}else{
|
||||
return firewall.ruleset({rules:state, policy:currentPolicy})
|
||||
.then(()=>okNotify("Firewall rules applied", "The firewall rules has been applied"))
|
||||
.catch((e)=>errorNotify("Firewall rules apply failed!", getErrorMessage(e)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const items = state.map((item, index) => (
|
||||
item.table == selectedTab && <Draggable key={item.rule_id} index={index} draggableId={item.rule_id}>
|
||||
{(provided, snapshot) => {
|
||||
const customInt = [
|
||||
{ value: "0.0.0.0/0", netint: "ANY IPv4", label: "0.0.0.0/0" },
|
||||
{ value: "::/0", netint: "ANY IPv6", label: "::/0" },
|
||||
{ value: "", netint: "ANY", label: "ANY" }
|
||||
]
|
||||
const src_custom_int = customInt.map(v => v.value).includes(item.src)?[]:[{ value: item.src, netint: "SELECTED", label: item.src }]
|
||||
const dst_custom_int = customInt.map(v => v.value).includes(item.dst)?[]:[{ value: item.dst, netint: "SELECTED", label: item.dst }]
|
||||
const [srcPortEnabled, setSrcPortEnabled] = useState(item.port_src_from != 1 || item.port_src_to != 65535)
|
||||
const [dstPortEnabled, setDstPortEnabled] = useState(item.port_dst_from != 1 || item.port_dst_to != 65535)
|
||||
const [srcPortValue, setSrcPortValue] = useState(item.port_src_from==item.port_src_to?`${item.port_src_from}`:`${item.port_src_from}-${item.port_src_to}`)
|
||||
const [dstPortValue, setDstPortValue] = useState(item.port_dst_from==item.port_dst_to?`${item.port_dst_from}`:`${item.port_dst_from}-${item.port_dst_to}`)
|
||||
|
||||
const port_range_setter = (rule:Rule, v:string, {src=false, dst=false}:{src?:boolean, dst?:boolean}) => {
|
||||
const elements = v.split("-")
|
||||
const values = [elements[0]?parseInt(elements[0]):0, elements[1]?parseInt(elements[1]):0]
|
||||
values[1] = values[1]?values[1]:values[0]
|
||||
if (src){
|
||||
rule.port_src_from = values[0]
|
||||
rule.port_src_to = values[1]
|
||||
setSrcPortValue(v)
|
||||
}
|
||||
if (dst){
|
||||
rule.port_dst_from = values[0]
|
||||
rule.port_dst_to = values[1]
|
||||
setDstPortValue(v)
|
||||
}
|
||||
updateMe()
|
||||
}
|
||||
|
||||
const ip_setter = (rule:Rule, v:string|null, {src=false, dst=false}:{src?:boolean, dst?:boolean}) => {
|
||||
const values = v?v:""
|
||||
if (src){
|
||||
rule.src = values
|
||||
}
|
||||
if (dst){
|
||||
rule.dst = values
|
||||
}
|
||||
updateMe()
|
||||
}
|
||||
|
||||
const disable_style = { opacity:"0.4", cursor:"not-allowed" }
|
||||
const proto_any = item.proto == Protocol.ANY
|
||||
const disabletab = {
|
||||
port_box: proto_any,
|
||||
src_port: !srcPortEnabled || proto_any,
|
||||
dst_port: !dstPortEnabled || proto_any
|
||||
}
|
||||
|
||||
const additionalStyle = {
|
||||
port_box: disabletab.port_box?disable_style:{},
|
||||
src_port: disabletab.src_port?disable_style:{},
|
||||
dst_port: disabletab.dst_port?disable_style:{}
|
||||
}
|
||||
|
||||
return <Box
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<Box className='center-flex' style={{width:"100%"}}>
|
||||
<Box {...provided.dragHandleProps}>
|
||||
<TbGripVertical style={{ width: rem(30), height: rem(40) }} />
|
||||
</Box>
|
||||
<Space w="sm" />
|
||||
<Box className={isMedium?"center-flex":"center-flex-row"} style={{width:"100%"}}>
|
||||
<Box className="center-flex-row" style={{width:"100%"}}>
|
||||
<Box className="center-flex" style={{width:"97%"}}>
|
||||
return <Card
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
mb="md"
|
||||
w="100%"
|
||||
bg="transparent"
|
||||
style={{ borderColor: 'var(--fourth_color)', transition: 'border-color 0.2s ease' }}
|
||||
>
|
||||
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||
<Box {...provided.dragHandleProps} style={{ display: 'flex', alignItems: 'center', height: rem(36) }}>
|
||||
<TbGripVertical style={{ width: rem(20), height: rem(20), color: 'var(--text-secondary)' }} />
|
||||
</Box>
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Switch
|
||||
checked={item.active}
|
||||
onChange={() =>{
|
||||
onChange={() => {
|
||||
item.active = !item.active
|
||||
updateMe()
|
||||
}}
|
||||
@ -234,215 +224,207 @@ export const Firewall = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Space w="sm" />
|
||||
<ActionIcon color="red" onClick={()=>handlers.remove(index)} size="lg" radius="md" variant="filled"><BsTrashFill size={18} /></ActionIcon>
|
||||
<Space w="sm" />
|
||||
<TextInput defaultValue={item.name} onChange={(v)=>{item.name = v.target.value;updateMe()}} style={{width:"100%"}}/>
|
||||
</Box>
|
||||
<Space h="sm" />
|
||||
<Box className="center-flex" style={{width:"97%"}}>
|
||||
<Box style={{width:"100%"}}>
|
||||
<ActionIcon color="red" onClick={() => handlers.remove(index)} size="lg" radius="md" variant="filled"><BsTrashFill size={18} /></ActionIcon>
|
||||
<TextInput defaultValue={item.name} onChange={(v) => { item.name = v.target.value; updateMe() }} style={{ flex: 1 }} />
|
||||
</Group>
|
||||
|
||||
<Group wrap={isMedium ? "nowrap" : "wrap"} align="flex-start" gap="md">
|
||||
<Stack gap="xs" style={{ flex: 1, minWidth: isMedium ? 0 : "100%" }}>
|
||||
<InterfaceInput
|
||||
initialCustomInterfaces={[...src_custom_int, ...customInt]}
|
||||
value={item.src}
|
||||
onChange={v => ip_setter(item, v, {src:true})}
|
||||
onChange={v => ip_setter(item, v, { src: true })}
|
||||
includeInterfaceNames
|
||||
/>
|
||||
<Space h="sm" />
|
||||
<Box className="center-flex" style={{width:"100%"}}>
|
||||
<OnOffButton value={srcPortEnabled} onClick={() =>{
|
||||
const value = !srcPortEnabled
|
||||
setSrcPortEnabled(value)
|
||||
if (!value){
|
||||
item.port_src_from = 1
|
||||
item.port_src_to = 65535
|
||||
updateMe()
|
||||
}else{
|
||||
port_range_setter(item, srcPortValue, {src:true})
|
||||
}
|
||||
}} size="lg" disabled={disabletab.port_box} style={additionalStyle.port_box} variant="light" />
|
||||
<Space w="xs" />
|
||||
<PortRangeInput
|
||||
onChange={v => port_range_setter(item, v.target.value, {src:true})}
|
||||
value={srcPortValue}
|
||||
disabled={disabletab.src_port}
|
||||
error={!disabletab.src_port && !srcPortValue}
|
||||
style={{width:"100%", ...additionalStyle.src_port}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Space w="lg" />
|
||||
<LuArrowBigRightDash size={100} />
|
||||
<Space w="lg" />
|
||||
<Box style={{width:"100%"}}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<OnOffButton value={srcPortEnabled} onClick={() => {
|
||||
const value = !srcPortEnabled
|
||||
setSrcPortEnabled(value)
|
||||
if (!value) {
|
||||
item.port_src_from = 1
|
||||
item.port_src_to = 65535
|
||||
updateMe()
|
||||
} else {
|
||||
port_range_setter(item, srcPortValue, { src: true })
|
||||
}
|
||||
}} size="lg" disabled={disabletab.port_box} variant="light" />
|
||||
<PortRangeInput
|
||||
onChange={v => port_range_setter(item, v.target.value, { src: true })}
|
||||
value={srcPortValue}
|
||||
disabled={disabletab.src_port}
|
||||
error={!disabletab.src_port && !srcPortValue}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', alignSelf: isMedium ? 'stretch' : 'center', padding: isMedium ? '28px 0 0' : '4px 0' }}>
|
||||
<LuArrowBigRightDash size={22} color="var(--text-secondary)" style={isMedium ? undefined : { transform: 'rotate(90deg)' }} />
|
||||
</Box>
|
||||
|
||||
<Stack gap="xs" style={{ flex: 1, minWidth: isMedium ? 0 : "100%" }}>
|
||||
<InterfaceInput
|
||||
initialCustomInterfaces={[...dst_custom_int, ...customInt]}
|
||||
defaultValue={item.dst}
|
||||
onChange={v => ip_setter(item, v, {dst:true})}
|
||||
onChange={v => ip_setter(item, v, { dst: true })}
|
||||
includeInterfaceNames
|
||||
/>
|
||||
<Space h="sm" />
|
||||
<Box className="center-flex" style={{width:"100%"}}>
|
||||
<OnOffButton value={dstPortEnabled} onClick={() =>{
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<OnOffButton value={dstPortEnabled} onClick={() => {
|
||||
const value = !dstPortEnabled
|
||||
setDstPortEnabled(value)
|
||||
if (!value){
|
||||
if (!value) {
|
||||
item.port_dst_from = 1
|
||||
item.port_dst_to = 65535
|
||||
updateMe()
|
||||
}else{
|
||||
port_range_setter(item, dstPortValue, {dst:true})
|
||||
} else {
|
||||
port_range_setter(item, dstPortValue, { dst: true })
|
||||
}
|
||||
}} size="lg" disabled={disabletab.port_box} style={additionalStyle.port_box} variant="light" />
|
||||
<Space w="xs" />
|
||||
}} size="lg" disabled={disabletab.port_box} variant="light" />
|
||||
<PortRangeInput
|
||||
onChange={v => port_range_setter(item, v.target.value, {dst:true})}
|
||||
onChange={v => port_range_setter(item, v.target.value, { dst: true })}
|
||||
value={dstPortValue}
|
||||
disabled={disabletab.dst_port}
|
||||
error={!disabletab.dst_port && !dstPortValue}
|
||||
style={{width:"100%", ...additionalStyle.dst_port}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{!isMedium?<Space h="sm" />:null}
|
||||
<Box className={isMedium?"center-flex-row":"center-flex"} style={isMedium?{}:{width:"100%", justifyContent:"space-around"}}>
|
||||
{condDiv(<>{condDiv(<ModeSelector
|
||||
value={item.mode}
|
||||
onChange={(value)=>{item.mode = value as RuleMode;updateMe()}}
|
||||
style={{width:"100%"}}
|
||||
table={item.table}
|
||||
/>, !isMedium)}
|
||||
<Space h="xs" />
|
||||
{condDiv(<ProtocolSelector
|
||||
value={item.proto}
|
||||
onChange={(value)=>{item.proto = value as Protocol;updateMe()}}
|
||||
style={{width:"100%"}}
|
||||
/>, !isMedium)}</>, isSmall)}
|
||||
<Space h="xs" />
|
||||
{condDiv(<ActionTypeSelector
|
||||
value={item.action}
|
||||
onChange={(value)=>{item.action = value as ActionType;updateMe()}}
|
||||
style={{width:"100%"}}
|
||||
/>, !isMedium)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Space h="md" />
|
||||
<Divider />
|
||||
<Space h="md" />
|
||||
</Box>
|
||||
}}
|
||||
</Draggable>
|
||||
)).filter(v => v);
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<ModeSelector
|
||||
value={item.mode}
|
||||
onChange={(value) => { item.mode = value as RuleMode; updateMe() }}
|
||||
style={{ flexGrow: 1, minWidth: rem(160) }}
|
||||
table={item.table}
|
||||
/>
|
||||
<ProtocolSelector
|
||||
value={item.proto}
|
||||
onChange={(value) => { item.proto = value as Protocol; updateMe() }}
|
||||
style={{ flexGrow: 1, minWidth: rem(160) }}
|
||||
/>
|
||||
<ActionTypeSelector
|
||||
value={item.action}
|
||||
onChange={(value) => { item.action = value as ActionType; updateMe() }}
|
||||
style={{ flexGrow: 1, minWidth: rem(160) }}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
}}
|
||||
</Draggable>
|
||||
)).filter(v => v);
|
||||
|
||||
|
||||
return <>
|
||||
<Space h="sm" />
|
||||
<LoadingOverlay visible={rules.isLoading} />
|
||||
<Box className={isMedium?'center-flex':'center-flex-row'}>
|
||||
<Title order={5} className="center-flex"><ThemeIcon radius="md" size="md" variant='filled' color='red' ><PiWallLight size={20} /></ThemeIcon><Space w="xs" />Firewall Rules</Title>
|
||||
{isMedium?<Box className='flex-spacer' />:<Space h="sm" />}
|
||||
<Box className='center-flex'>
|
||||
Enabled: <Space w="sm" /> <Switch checked={fwEnabled} onChange={switchState} />
|
||||
<Space w="sm" />
|
||||
Policy:
|
||||
<Space w="xs" />
|
||||
<ActionTypeSelector
|
||||
value={currentPolicy}
|
||||
onChange={(value)=>setCurrentPolicy(value as ActionType)}
|
||||
/>
|
||||
</Box>
|
||||
{isMedium?<Box className='flex-spacer' />:<Space h="sm" />}
|
||||
<Box className='center-flex'>
|
||||
<Space w="xs" />
|
||||
<Badge size="md" radius="sm" color="green" variant="filled"><FaDirections style={{ marginBottom: -1, marginRight: 4}}/>Rules: {rules.isLoading?0:rules.data?.rules.length}</Badge>
|
||||
<Space w="md" />
|
||||
<Tooltip label="Add a new rule" position='bottom' color="blue">
|
||||
<ActionIcon color="blue" onClick={emptyRuleAdd} size="lg" radius="md" variant="filled"><BsPlusLg size={18} /></ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="xs" />
|
||||
<Tooltip label="Refresh" position='bottom' color="indigo">
|
||||
<ActionIcon color="indigo" onClick={()=>queryClient.invalidateQueries({ queryKey: ["firewall"] })} size="lg" radius="md" variant="filled"
|
||||
loading={rules.isFetching}><TbReload size={18} /></ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="xs" />
|
||||
<Tooltip label="Settings" position='bottom' color="cyan">
|
||||
<ActionIcon color="cyan" onClick={()=>setSettingsModal(true)} size="lg" radius="md" variant="filled"><IoSettingsSharp size={18} /></ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="xs" />
|
||||
<Tooltip label="Apply" position='bottom' color="grape">
|
||||
<ActionIcon color="grape" onClick={applyChanges} size="lg" radius="md" variant="filled" disabled={!valuesChanged}>
|
||||
<TiTick size={22} /></ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
return <>
|
||||
<Space h="sm" />
|
||||
<LoadingOverlay visible={rules.isLoading} />
|
||||
<Box className={isMedium ? 'center-flex' : 'center-flex-row'}>
|
||||
<Title order={5} className="center-flex"><ThemeIcon radius="md" size="md" variant='filled' color='red' ><PiWallLight size={20} /></ThemeIcon><Space w="xs" />Firewall Rules</Title>
|
||||
{isMedium ? <Box className='flex-spacer' /> : <Space h="sm" />}
|
||||
<Box className='center-flex'>
|
||||
Enabled: <Space w="sm" /> <Switch checked={fwEnabled} onChange={switchState} />
|
||||
<Space w="sm" />
|
||||
Policy:
|
||||
<Space w="xs" />
|
||||
<ActionTypeSelector
|
||||
value={currentPolicy}
|
||||
onChange={(value) => setCurrentPolicy(value as ActionType)}
|
||||
/>
|
||||
</Box>
|
||||
{isMedium ? <Box className='flex-spacer' /> : <Space h="sm" />}
|
||||
<Box className='center-flex'>
|
||||
<Space w="xs" />
|
||||
<Badge size="md" radius="sm" color="green" variant="filled"><FaDirections style={{ marginBottom: -1, marginRight: 4 }} />Rules: {rules.isLoading ? 0 : rules.data?.rules.length}</Badge>
|
||||
<Space w="md" />
|
||||
<Tooltip label="Add a new rule" position='bottom' color="blue">
|
||||
<ActionIcon color="blue" onClick={emptyRuleAdd} size="lg" radius="md" variant="filled"><BsPlusLg size={18} /></ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="xs" />
|
||||
<Tooltip label="Refresh" position='bottom' color="indigo">
|
||||
<ActionIcon color="indigo" onClick={() => queryClient.invalidateQueries({ queryKey: ["firewall"] })} size="lg" radius="md" variant="filled"
|
||||
loading={rules.isFetching}><TbReload size={18} /></ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="xs" />
|
||||
<Tooltip label="Settings" position='bottom' color="cyan">
|
||||
<ActionIcon color="cyan" onClick={() => setSettingsModal(true)} size="lg" radius="md" variant="filled"><IoSettingsSharp size={18} /></ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="xs" />
|
||||
<Tooltip label="Apply" position='bottom' color="grape">
|
||||
<ActionIcon color="grape" onClick={applyChanges} size="lg" radius="md" variant="filled" disabled={!valuesChanged}>
|
||||
<TiTick size={22} /></ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<Space h="xl" />
|
||||
<Divider />
|
||||
<Space h="md" />
|
||||
<Group justify="center" gap="sm">
|
||||
<Box>Filtering Table:</Box>
|
||||
<Tabs variant="pills" value={selectedTab} onChange={(v) => setSelectedTab(v == NFTables.MANGLE ? NFTables.MANGLE : NFTables.FILTER)}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value={NFTables.FILTER}>
|
||||
Filter
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value={NFTables.MANGLE}>
|
||||
Mangle
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</Group>
|
||||
{items.length > 0 ? <DragDropContext
|
||||
onDragEnd={({ destination, source }) =>
|
||||
handlers.reorder({ from: source.index, to: destination?.index || 0 })
|
||||
}
|
||||
>
|
||||
<Space h="md" />
|
||||
<Droppable droppableId="dnd-list" direction="vertical">
|
||||
{(provided) => (
|
||||
<Box {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{items}
|
||||
{provided.placeholder}
|
||||
</Box>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext> : <>
|
||||
<Box className='center-flex-row'>
|
||||
<Space h="xl" />
|
||||
<Divider />
|
||||
<Space h="md"/>
|
||||
<Tabs variant="pills" value={selectedTab} onChange={(v)=>setSelectedTab(v==NFTables.MANGLE?NFTables.MANGLE:NFTables.FILTER)} style={{ display:"flex", justifyContent:"center", alignItems:"center"}}>
|
||||
<Box mr="md">Filtering Table:</Box>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value={NFTables.FILTER}>
|
||||
Filter
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value={NFTables.MANGLE}>
|
||||
Mangle
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
{items.length > 0?<DragDropContext
|
||||
onDragEnd={({ destination, source }) =>
|
||||
handlers.reorder({ from: source.index, to: destination?.index || 0 })
|
||||
}
|
||||
>
|
||||
<Space h="md" />
|
||||
<Droppable droppableId="dnd-list" direction="vertical">
|
||||
{(provided) => (
|
||||
<Box {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{items}
|
||||
{provided.placeholder}
|
||||
</Box>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>:<>
|
||||
<Box className='center-flex-row'>
|
||||
<Space h="xl" />
|
||||
<Title className='center-flex' style={{textAlign:"center"}} order={3}>Firewall Rules allows you to use nftables but through a web interface</Title>
|
||||
<Space h="xs" />
|
||||
<Title className='center-flex' style={{textAlign:"center"}} order={5}>Add new rules, sort it and enable the firewall: be carefull, wrong rules could also drops out firegex access</Title>
|
||||
<Space h="lg" />
|
||||
<Box className='center-flex' style={{gap: 20}}>
|
||||
<Tooltip label="Add a new rule" color="blue">
|
||||
<ActionIcon color="blue" onClick={emptyRuleAdd} size="xl" radius="md" variant="filled">
|
||||
<BsPlusLg size="20px" /></ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Title className='center-flex' style={{ textAlign: "center" }} order={3}>Firewall Rules allows you to use nftables but through a web interface</Title>
|
||||
<Space h="xs" />
|
||||
<Title className='center-flex' style={{ textAlign: "center" }} order={5}>Add new rules, sort it and enable the firewall: be carefull, wrong rules could also drops out firegex access</Title>
|
||||
<Space h="lg" />
|
||||
<Box className='center-flex' style={{ gap: 20 }}>
|
||||
<Tooltip label="Add a new rule" color="blue">
|
||||
<ActionIcon color="blue" onClick={emptyRuleAdd} size="xl" radius="md" variant="filled">
|
||||
<BsPlusLg size="20px" /></ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</>}
|
||||
|
||||
<YesNoModal
|
||||
title='Are you sure to apply the changes to the firewall?'
|
||||
description={`If there is a malconfiguration you can lose the access to your server! ⚠️`}
|
||||
onClose={()=>setEnableFwModal(false) }
|
||||
action={enableFirewall}
|
||||
opened={enableFwModal}
|
||||
/>
|
||||
<YesNoModal
|
||||
title='Are you sure to apply the changes to the firewall?'
|
||||
description={`If there is a malconfiguration you can lose the access to your server! ⚠️`}
|
||||
onClose={() => setEnableFwModal(false)}
|
||||
action={enableFirewall}
|
||||
opened={enableFwModal}
|
||||
/>
|
||||
|
||||
<YesNoModal
|
||||
title='Are you sure to apply the changes to the firewall?'
|
||||
description={`If there is a malconfiguration you can lose the access to your server! ⚠️`}
|
||||
onClose={()=>setApplyChangeModal(false) }
|
||||
action={applyChangesRaw}
|
||||
opened={applyChangeModal}
|
||||
/>
|
||||
<YesNoModal
|
||||
title='Are you sure to apply the changes to the firewall?'
|
||||
description={`If there is a malconfiguration you can lose the access to your server! ⚠️`}
|
||||
onClose={() => setApplyChangeModal(false)}
|
||||
action={applyChangesRaw}
|
||||
opened={applyChangeModal}
|
||||
/>
|
||||
|
||||
<SettingsModal
|
||||
opened={settingsModal}
|
||||
onClose={()=>setSettingsModal(false)}
|
||||
/>
|
||||
<SettingsModal
|
||||
opened={settingsModal}
|
||||
onClose={() => setSettingsModal(false)}
|
||||
/>
|
||||
|
||||
|
||||
</>
|
||||
</>
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { ActionIcon, Box, Code, Grid, LoadingOverlay, Space, Title, Tooltip } from '@mantine/core';
|
||||
import { ActionIcon, Box, Code, LoadingOverlay, Space, Stack, Title, Tooltip, Card, Group, ThemeIcon } from '@mantine/core';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router';
|
||||
import { Badge, Divider, Menu } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
@ -24,34 +24,35 @@ import { ModalLog } from '../../components/ModalLog';
|
||||
import { useListState } from '@mantine/hooks';
|
||||
import { ExceptionWarning } from '../../components/NFProxy/ExceptionWarning';
|
||||
import { DocsButton } from '../../components/DocsButton';
|
||||
import TLSAssociationBadge from '../../components/TLSAssociationBadge';
|
||||
|
||||
export default function ServiceDetailsNFProxy() {
|
||||
|
||||
const {srv} = useParams()
|
||||
const { srv } = useParams()
|
||||
const services = nfproxyServiceQuery()
|
||||
const serviceInfo = services.data?.find(s => s.service_id == srv)
|
||||
const filtersList = nfproxyServicePyfiltersQuery(srv??"")
|
||||
const filtersList = nfproxyServicePyfiltersQuery(srv ?? "")
|
||||
const [deleteModal, setDeleteModal] = useState(false)
|
||||
const [renameModal, setRenameModal] = useState(false)
|
||||
const [editModal, setEditModal] = useState(false)
|
||||
const [buttonLoading, setButtonLoading] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const filterCode = nfproxyServiceFilterCodeQuery(srv??"")
|
||||
const filterCode = nfproxyServiceFilterCodeQuery(srv ?? "")
|
||||
const navigate = useNavigate()
|
||||
const isMedium = isMediumScreen()
|
||||
const [openLogModal, setOpenLogModal] = useState(false)
|
||||
const [logData, logDataSetters] = useListState<string>([]);
|
||||
|
||||
|
||||
useEffect(()=>{
|
||||
if (srv){
|
||||
if (openLogModal){
|
||||
|
||||
useEffect(() => {
|
||||
if (srv) {
|
||||
if (openLogModal) {
|
||||
logDataSetters.setState([])
|
||||
socketio.emit("nfproxy-outstream-join", { service: srv });
|
||||
socketio.on(`nfproxy-outstream-${srv}`, (data) => {
|
||||
logDataSetters.append(data)
|
||||
});
|
||||
}else{
|
||||
} else {
|
||||
socketio.emit("nfproxy-outstream-leave", { service: srv });
|
||||
socketio.off(`nfproxy-outstream-${srv}`);
|
||||
logDataSetters.setState([])
|
||||
@ -68,7 +69,7 @@ export default function ServiceDetailsNFProxy() {
|
||||
if (!srv || !serviceInfo || filtersList.isError) return <Navigate to="/" replace />
|
||||
|
||||
let status_color = "gray";
|
||||
switch(serviceInfo.status){
|
||||
switch (serviceInfo.status) {
|
||||
case "stop": status_color = "red"; break;
|
||||
case "active": status_color = "teal"; break;
|
||||
}
|
||||
@ -76,160 +77,168 @@ export default function ServiceDetailsNFProxy() {
|
||||
const startService = async () => {
|
||||
setButtonLoading(true)
|
||||
await nfproxy.servicestart(serviceInfo.service_id).then(res => {
|
||||
if(!res){
|
||||
okNotify(`Service ${serviceInfo.name} started successfully!`,`The service on ${serviceInfo.port} has been started!`)
|
||||
if (!res) {
|
||||
okNotify(`Service ${serviceInfo.name} started successfully!`, `The service on ${serviceInfo.port} has been started!`)
|
||||
queryClient.invalidateQueries({ queryKey: serviceQueryKey })
|
||||
}else{
|
||||
errorNotify(`An error as occurred during the starting of the service ${serviceInfo.port}`,`Error: ${res}`)
|
||||
} else {
|
||||
errorNotify(`An error as occurred during the starting of the service ${serviceInfo.port}`, `Error: ${res}`)
|
||||
}
|
||||
}).catch(err => {
|
||||
errorNotify(`An error as occurred during the starting of the service ${serviceInfo.port}`,`Error: ${err}`)
|
||||
errorNotify(`An error as occurred during the starting of the service ${serviceInfo.port}`, `Error: ${err}`)
|
||||
})
|
||||
setButtonLoading(false)
|
||||
}
|
||||
|
||||
const deleteService = () => {
|
||||
nfproxy.servicedelete(serviceInfo.service_id).then(res => {
|
||||
if (!res){
|
||||
okNotify("Service delete complete!",`The service ${serviceInfo.name} has been deleted!`)
|
||||
if (!res) {
|
||||
okNotify("Service delete complete!", `The service ${serviceInfo.name} has been deleted!`)
|
||||
queryClient.invalidateQueries({ queryKey: serviceQueryKey })
|
||||
}else
|
||||
errorNotify("An error occurred while deleting a service",`Error: ${res}`)
|
||||
} else
|
||||
errorNotify("An error occurred while deleting a service", `Error: ${res}`)
|
||||
}).catch(err => {
|
||||
errorNotify("An error occurred while deleting a service",`Error: ${err}`)
|
||||
errorNotify("An error occurred while deleting a service", `Error: ${err}`)
|
||||
})
|
||||
}
|
||||
|
||||
const stopService = async () => {
|
||||
setButtonLoading(true)
|
||||
|
||||
|
||||
await nfproxy.servicestop(serviceInfo.service_id).then(res => {
|
||||
if(!res){
|
||||
okNotify(`Service ${serviceInfo.name} stopped successfully!`,`The service on ${serviceInfo.port} has been stopped!`)
|
||||
if (!res) {
|
||||
okNotify(`Service ${serviceInfo.name} stopped successfully!`, `The service on ${serviceInfo.port} has been stopped!`)
|
||||
queryClient.invalidateQueries({ queryKey: serviceQueryKey })
|
||||
}else{
|
||||
errorNotify(`An error as occurred during the stopping of the service ${serviceInfo.port}`,`Error: ${res}`)
|
||||
} else {
|
||||
errorNotify(`An error as occurred during the stopping of the service ${serviceInfo.port}`, `Error: ${res}`)
|
||||
}
|
||||
}).catch(err => {
|
||||
errorNotify(`An error as occurred during the stopping of the service ${serviceInfo.port}`,`Error: ${err}`)
|
||||
errorNotify(`An error as occurred during the stopping of the service ${serviceInfo.port}`, `Error: ${err}`)
|
||||
})
|
||||
setButtonLoading(false);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Space h="sm" />
|
||||
<LoadingOverlay visible={filtersList.isLoading} />
|
||||
<Box className={isMedium?'center-flex':'center-flex-row'} style={{ justifyContent: "space-between"}} px="md" mt="lg">
|
||||
<Box>
|
||||
<Title order={1}>
|
||||
<Box className="center-flex">
|
||||
<MdDoubleArrow /><Space w="sm" />{serviceInfo.name}
|
||||
</Box>
|
||||
</Title>
|
||||
</Box>
|
||||
{isMedium?null:<Space h="md" />}
|
||||
<Box className='center-flex'>
|
||||
<ExceptionWarning service_id={srv} />
|
||||
<Space w="sm" />
|
||||
<Badge color={status_color} radius="md" size="xl" variant="filled" mr="sm">
|
||||
{serviceInfo.status}
|
||||
</Badge>
|
||||
<Badge size="xl" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" mr="sm">
|
||||
:{serviceInfo.port}
|
||||
</Badge>
|
||||
|
||||
<MenuDropDownWithButton>
|
||||
<Menu.Item><b>Edit service</b></Menu.Item>
|
||||
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
|
||||
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Label><b>Danger zone</b></Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
|
||||
</MenuDropDownWithButton>
|
||||
<Space w="md"/>
|
||||
<Tooltip label="Show logs" zIndex={0} color="cyan">
|
||||
<ActionIcon color="cyan" size="lg" radius="md" onClick={()=>setOpenLogModal(true)} loading={buttonLoading} variant="filled">
|
||||
<FiFileText size="20px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
{isMedium?null:<Space h="md" />}
|
||||
<Box className={isMedium?'center-flex':'center-flex-row'} style={{ justifyContent: "space-between"}} px="md" mt="lg">
|
||||
<Box className={isMedium?'center-flex':'center-flex-row'}>
|
||||
<Box className='center-flex'>
|
||||
<Badge color="orange" radius="sm" size="md" variant="filled"><FaPencilAlt style={{ marginBottom: -2}} /> {serviceInfo.edited_packets}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2}} /> {serviceInfo.blocked_packets}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge color="violet" radius="sm" size="md" variant="filled"><TbPlugConnected style={{ marginBottom: -2}} size={13} /> {serviceInfo.n_filters}</Badge>
|
||||
<Card
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius="md"
|
||||
p="xl"
|
||||
mb="xl"
|
||||
bg="var(--third_color)"
|
||||
style={{ borderColor: 'var(--fourth_color)', overflow: "visible" }} // Allows dropdowns to overflow outside
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"} gap="xl">
|
||||
<Box>
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Tooltip label="Go back" position="top">
|
||||
<ActionIcon color="cyan" variant="subtle" radius="md" onClick={() => navigate("/nfproxy")}>
|
||||
<FaArrowLeft size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<MdDoubleArrow size={24} style={{ color: "var(--text-secondary)" }} />
|
||||
<Title order={2} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{serviceInfo.name}
|
||||
</Title>
|
||||
</Group>
|
||||
<Group gap="sm" mt="md">
|
||||
<ExceptionWarning service_id={srv} />
|
||||
<Badge color={status_color} radius="sm" size="lg" variant="light">{serviceInfo.status}</Badge>
|
||||
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="sm">
|
||||
:{serviceInfo.port}
|
||||
</Badge>
|
||||
{serviceInfo.target_type === 'tls' && (
|
||||
<TLSAssociationBadge tlsStreamId={serviceInfo.tls_stream_id} size="lg" />
|
||||
)}
|
||||
<Badge color={serviceInfo.ip_int.match(regex_ipv4) ? "cyan" : "pink"} radius="sm" size="lg" variant="light">
|
||||
{serviceInfo.target_type === 'tls' ? 'decrypted traffic to ' : ''}{serviceInfo.ip_int} on {serviceInfo.proto}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Box>
|
||||
{isMedium?<Space w="xs" />:<Space h="xs" />}
|
||||
<Badge color={serviceInfo.ip_int.match(regex_ipv4)?"cyan":"pink"} radius="sm" size="md" variant="filled" mr="xs">{serviceInfo.ip_int} on {serviceInfo.proto}</Badge>
|
||||
</Box>
|
||||
{isMedium?null:<Space h="xl" />}
|
||||
<Box className='center-flex'>
|
||||
<Tooltip label="Go back" zIndex={0} color="cyan">
|
||||
<ActionIcon color="cyan"
|
||||
onClick={() => navigate("/")} size="xl" radius="md" variant="filled"
|
||||
aria-describedby="tooltip-back-id">
|
||||
<FaArrowLeft size="25px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="md"/>
|
||||
<Tooltip label="Stop service" zIndex={0} color="red">
|
||||
<ActionIcon color="red" loading={buttonLoading}
|
||||
onClick={stopService} size="xl" radius="md" variant="filled"
|
||||
disabled={serviceInfo.status === "stop"}
|
||||
aria-describedby="tooltip-stop-id">
|
||||
<FaStop size="20px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="md"/>
|
||||
<Tooltip label="Start service" zIndex={0} color="teal">
|
||||
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading}
|
||||
variant="filled" disabled={!["stop","pause"].includes(serviceInfo.status)?true:false}>
|
||||
<FaPlay size="20px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider my="xl" />
|
||||
|
||||
{filterCode.data?<>
|
||||
<Title order={3} style={{textAlign:"center"}} className="center-flex"><FaPython style={{ marginBottom: -3 }} size={30} /><Space w="xs" />Filter code</Title>
|
||||
<CodeHighlight code={filterCode.data} language="python" mt="lg" />
|
||||
</>: null}
|
||||
<Box style={{ flexGrow: 1, minWidth: isMedium ? 'auto' : '100%' }}>
|
||||
<Group gap="xl" justify={isMedium ? "flex-start" : "space-between"}>
|
||||
<Group gap="xs">
|
||||
<Badge color="yellow" radius="sm" size="lg" variant="light" leftSection={<FaFilter size={12} style={{ marginTop: 2 }} />}>
|
||||
{serviceInfo.blocked_packets}
|
||||
</Badge>
|
||||
<Badge color="orange" radius="sm" size="lg" variant="light" leftSection={<FaPencilAlt size={12} style={{ marginTop: 2 }} />}>
|
||||
{serviceInfo.edited_packets}
|
||||
</Badge>
|
||||
<Badge color="violet" radius="sm" size="lg" variant="light" leftSection={<TbPlugConnected size={12} style={{ marginTop: 2 }} />}>
|
||||
{serviceInfo.n_filters}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{(!filtersList.data || filtersList.data.length == 0)?<>
|
||||
<Space h="xl" />
|
||||
<Title className='center-flex' style={{textAlign:"center"}} order={3}>No filters found! Create some proxy filters, install the firegex client:<Space w="xs" /><Code mb={-4} >pip install -U fgex</Code></Title>
|
||||
<Space h="xs" />
|
||||
<Title className='center-flex' style={{textAlign:"center"}} order={3}>Read the documentation for more information<Space w="sm" /><DocsButton doc='nfproxy'/></Title>
|
||||
<Space h="xs" />
|
||||
<Title className='center-flex' style={{textAlign:"center"}} order={3}>Then create a new filter file with the following syntax and upload it here (using the button above)</Title>
|
||||
</>:<>{filtersList.data?.map( (filterInfo) => <PyFilterView filterInfo={filterInfo} key={filterInfo.name}/>)}</>
|
||||
<Group gap="sm" justify="flex-end" style={{ flexGrow: 1 }}>
|
||||
<Tooltip label={serviceInfo.status === "stop" ? "Cannot stop" : "Stop service"} position="bottom" color="red">
|
||||
<ActionIcon color="red" loading={buttonLoading} onClick={stopService} size="xl" radius="md" variant="light" disabled={serviceInfo.status === "stop"}>
|
||||
<FaStop size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={!["stop", "pause"].includes(serviceInfo.status) ? "Cannot start" : "Start service"} position="bottom" color="teal">
|
||||
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading} variant="light" disabled={!["stop", "pause"].includes(serviceInfo.status)}>
|
||||
<FaPlay size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Show logs" position="bottom" color="cyan">
|
||||
<ActionIcon color="cyan" size="xl" radius="md" onClick={() => setOpenLogModal(true)} variant="light">
|
||||
<FiFileText size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<MenuDropDownWithButton>
|
||||
<Menu.Label><b>Service Tools</b></Menu.Label>
|
||||
<Menu.Item leftSection={<IoSettingsSharp size={16} />} onClick={() => setEditModal(true)}>Service Settings</Menu.Item>
|
||||
<Menu.Item leftSection={<BiRename size={16} />} onClick={() => setRenameModal(true)}>Change Name</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Label><b>Danger zone</b></Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<BsTrashFill size={16} />} onClick={() => setDeleteModal(true)}>Delete Service</Menu.Item>
|
||||
</MenuDropDownWithButton>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{filterCode.data ? <>
|
||||
<Group justify="center" gap="xs" mt="xl">
|
||||
<ThemeIcon radius="md" size="md" variant="light" color="yellow"><FaPython size={16} /></ThemeIcon>
|
||||
<Title order={3}>Filter code</Title>
|
||||
</Group>
|
||||
<Card withBorder radius="md" p={0} bg="transparent" style={{ borderColor: 'var(--fourth_color)', marginTop: '20px' }}>
|
||||
<CodeHighlight code={filterCode.data} language="python" style={{ backgroundColor: 'transparent' }} />
|
||||
</Card>
|
||||
</> : null}
|
||||
|
||||
{(!filtersList.data || filtersList.data.length == 0) ? (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<Title order={3} ta="center">No filters found! Create some proxy filters, install the firegex client:<Space w="xs" /><Code mb={-4} >pip install -U fgex</Code></Title>
|
||||
<Title order={3} ta="center">Read the documentation for more information<Space w="sm" /><DocsButton doc='nfproxy' /></Title>
|
||||
<Title order={3} ta="center">Then create a new filter file with the following syntax and upload it here (using the button above)</Title>
|
||||
</Stack>
|
||||
) : <Box mt="xs">{filtersList.data?.map((filterInfo) => <PyFilterView filterInfo={filterInfo} key={filterInfo.name} />)}</Box>
|
||||
}
|
||||
<YesNoModal
|
||||
title='Are you sure to delete this service?'
|
||||
description={`You are going to delete the service '${serviceInfo.port}', causing the stopping of the firewall and deleting all the regex associated. This will cause the shutdown of your service! ⚠️`}
|
||||
onClose={()=>setDeleteModal(false) }
|
||||
onClose={() => setDeleteModal(false)}
|
||||
action={deleteService}
|
||||
opened={deleteModal}
|
||||
/>
|
||||
<RenameForm
|
||||
onClose={()=>setRenameModal(false)}
|
||||
onClose={() => setRenameModal(false)}
|
||||
opened={renameModal}
|
||||
service={serviceInfo}
|
||||
/>
|
||||
<AddEditService
|
||||
opened={editModal}
|
||||
onClose={()=>setEditModal(false)}
|
||||
onClose={() => setEditModal(false)}
|
||||
edit={serviceInfo}
|
||||
/>
|
||||
<ModalLog
|
||||
opened={openLogModal}
|
||||
close={()=>setOpenLogModal(false)}
|
||||
close={() => setOpenLogModal(false)}
|
||||
title={`Logs for service ${serviceInfo.name}`}
|
||||
data={logData.join("")}
|
||||
/>
|
||||
|
||||
@ -6,12 +6,12 @@ import ServiceRow from '../../components/NFProxy/ServiceRow';
|
||||
import { errorNotify, getErrorMessage, isMediumScreen } from '../../js/utils';
|
||||
import AddEditService from '../../components/NFProxy/AddEditService';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { TbPlugConnected, TbReload } from 'react-icons/tb';
|
||||
import { TbPlugConnected, TbReload, TbCode } from 'react-icons/tb';
|
||||
import { EXAMPLE_PYFILTER, nfproxy, nfproxyServiceQuery } from '../../components/NFProxy/utils';
|
||||
import { FaFilter, FaPencilAlt, FaServer } from 'react-icons/fa';
|
||||
import { MdUploadFile } from "react-icons/md";
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useFileDialog } from '@mantine/hooks';
|
||||
import { EditFilterModal } from '../../components/NFProxy/EditFilterModal';
|
||||
import { CodeHighlight } from '@mantine/code-highlight';
|
||||
import { DocsButton } from '../../components/DocsButton';
|
||||
|
||||
@ -24,69 +24,8 @@ export default function NFProxy({ children }: { children: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const isMedium = isMediumScreen()
|
||||
const services = nfproxyServiceQuery()
|
||||
const fileDialog = useFileDialog({
|
||||
accept: ".py",
|
||||
multiple: false,
|
||||
resetOnOpen: true,
|
||||
onChange: (files) => {
|
||||
if (files?.length??0 > 0)
|
||||
setFile(files![0])
|
||||
}
|
||||
});
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
useEffect(() => {
|
||||
if (!srv) return
|
||||
const service = services.data?.find(s => s.service_id === srv)
|
||||
if (!service) return
|
||||
if (file){
|
||||
console.log("Uploading code")
|
||||
const notify_id = notifications.show(
|
||||
{
|
||||
title: "Uploading code",
|
||||
message: `Uploading code for service ${service.name}`,
|
||||
color: "blue",
|
||||
icon: <MdUploadFile size={20} />,
|
||||
autoClose: false,
|
||||
loading: true,
|
||||
}
|
||||
)
|
||||
file.text()
|
||||
.then( code => nfproxy.setpyfilterscode(service?.service_id??"",code.toString()))
|
||||
.then( res => {
|
||||
if (!res){
|
||||
notifications.update({
|
||||
id: notify_id,
|
||||
title: "Code uploaded",
|
||||
message: `Successfully uploaded code for service ${service.name}`,
|
||||
color: "green",
|
||||
icon: <MdUploadFile size={20} />,
|
||||
autoClose: 5000,
|
||||
loading: false,
|
||||
})
|
||||
}else{
|
||||
notifications.update({
|
||||
id: notify_id,
|
||||
title: "Code upload failed",
|
||||
message: `Error: ${res}`,
|
||||
color: "red",
|
||||
icon: <MdUploadFile size={20} />,
|
||||
autoClose: 5000,
|
||||
loading: false,
|
||||
})
|
||||
}
|
||||
}).catch( err => {
|
||||
notifications.update({
|
||||
id: notify_id,
|
||||
title: "Code upload failed",
|
||||
message: `Error: ${err}`,
|
||||
color: "red",
|
||||
icon: <MdUploadFile size={20} />,
|
||||
autoClose: 5000,
|
||||
loading: false,
|
||||
})
|
||||
}).finally(()=>{setFile(null)})
|
||||
}
|
||||
}, [file])
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
// File upload logic has been moved to EditFilterModal
|
||||
|
||||
useEffect(()=> {
|
||||
if(services.isError)
|
||||
@ -103,21 +42,21 @@ export default function NFProxy({ children }: { children: any }) {
|
||||
<Box className='center-flex' >
|
||||
{isMedium?"General stats:":null}
|
||||
<Space w="xs" />
|
||||
<Badge size="md" radius="sm" color="green" variant="filled"><FaServer style={{ marginBottom: -1, marginRight: 4}} />Services: {services.isLoading?0:services.data?.length}</Badge>
|
||||
<Badge size="md" radius="sm" color="green" variant="filled"><FaServer style={{ marginBottom: -1, marginRight: 4}} />Services: {services.isLoading?0:Array.isArray(services.data)?services.data.length:0}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:services.data?.reduce((acc, s)=> acc+=s.blocked_packets, 0)}</Badge>
|
||||
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:Array.isArray(services.data)?services.data.reduce((acc, s)=> acc+=s.blocked_packets, 0):0}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge color="orange" radius="sm" size="md" variant="filled"><FaPencilAlt style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:services.data?.reduce((acc, s)=> acc+=s.edited_packets, 0)}</Badge>
|
||||
<Badge color="orange" radius="sm" size="md" variant="filled"><FaPencilAlt style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:Array.isArray(services.data)?services.data.reduce((acc, s)=> acc+=s.edited_packets, 0):0}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge size="md" radius="sm" color="violet" variant="filled"><TbPlugConnected style={{ marginBottom: -2, marginRight: 4}} size={13} />{services.isLoading?0:services.data?.reduce((acc, s)=> acc+=s.n_filters, 0)}</Badge>
|
||||
<Badge size="md" radius="sm" color="violet" variant="filled"><TbPlugConnected style={{ marginBottom: -2, marginRight: 4}} size={13} />{services.isLoading?0:Array.isArray(services.data)?services.data.reduce((acc, s)=> acc+=s.n_filters, 0):0}</Badge>
|
||||
<Space w="xs" />
|
||||
</Box>
|
||||
{isMedium?null:<Space h="md" />}
|
||||
<Box className='center-flex' >
|
||||
{ srv?
|
||||
<Tooltip label="Upload a new filter code" position='bottom' color="blue">
|
||||
<ActionIcon color="blue" size="lg" radius="md" variant="filled" onClick={fileDialog.open}>
|
||||
<MdUploadFile size={18} />
|
||||
<Tooltip label="Edit python filter code" position='bottom' color="blue">
|
||||
<ActionIcon color="blue" size="lg" radius="md" variant="filled" onClick={() => setEditorOpen(true)}>
|
||||
<TbCode size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
: <Tooltip label="Add a new service" position='bottom' color="blue">
|
||||
@ -136,8 +75,8 @@ export default function NFProxy({ children }: { children: any }) {
|
||||
<DocsButton doc="nfproxy" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Space h="md" />
|
||||
<Box className="center-flex-row" style={{gap: 20}}>
|
||||
<Space h="xl" />
|
||||
<Box className="center-flex-row" style={{ gap: 12, width: "100%" }}>
|
||||
{srv?null:<>
|
||||
<LoadingOverlay visible={services.isLoading} />
|
||||
{(services.data && services.data?.length > 0)?services.data.map( srv => <ServiceRow service={srv} key={srv.service_id} onClick={()=>{
|
||||
@ -167,6 +106,11 @@ export default function NFProxy({ children }: { children: any }) {
|
||||
{!srv?
|
||||
<AddEditService opened={open} onClose={closeModal} />:null
|
||||
}
|
||||
<EditFilterModal
|
||||
opened={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
service={services.data?.find(s => s.service_id === srv)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ActionIcon, Box, Grid, LoadingOverlay, Space, Title, Tooltip } from '@mantine/core';
|
||||
import { ActionIcon, Box, Grid, LoadingOverlay, Space, Stack, Title, Tooltip, Card, Group } from '@mantine/core';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router';
|
||||
import RegexView from '../../components/RegexView';
|
||||
import AddNewRegex from '../../components/AddNewRegex';
|
||||
@ -8,9 +8,9 @@ import { Badge, Divider, Menu } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { FaFilter, FaPlay, FaStop } from 'react-icons/fa';
|
||||
import { nfregex, serviceQueryKey } from '../../components/NFRegex/utils';
|
||||
import { MdDoubleArrow } from "react-icons/md"
|
||||
import { MdDoubleArrow, MdDownload, MdUpload } from "react-icons/md"
|
||||
import YesNoModal from '../../components/YesNoModal';
|
||||
import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../js/utils';
|
||||
import { errorNotify, isMediumScreen, okNotify, regex_ipv4, getapi, postapi } from '../../js/utils';
|
||||
import { BsTrashFill } from 'react-icons/bs';
|
||||
import { BiRename } from 'react-icons/bi'
|
||||
import RenameForm from '../../components/NFRegex/ServiceRow/RenameForm';
|
||||
@ -20,6 +20,7 @@ import { FaArrowLeft } from "react-icons/fa";
|
||||
import { VscRegex } from 'react-icons/vsc';
|
||||
import { IoSettingsSharp } from 'react-icons/io5';
|
||||
import AddEditService from '../../components/NFRegex/AddEditService';
|
||||
import TLSAssociationBadge from '../../components/TLSAssociationBadge';
|
||||
|
||||
export default function ServiceDetailsNFRegex() {
|
||||
|
||||
@ -89,88 +90,132 @@ export default function ServiceDetailsNFRegex() {
|
||||
}
|
||||
|
||||
return <>
|
||||
<Space h="sm" />
|
||||
<LoadingOverlay visible={regexesList.isLoading} />
|
||||
<Box className={isMedium?'center-flex':'center-flex-row'} style={{ justifyContent: "space-between"}} px="md" mt="lg">
|
||||
<Box>
|
||||
<Title order={1}>
|
||||
<Box className="center-flex">
|
||||
<MdDoubleArrow /><Space w="sm" />{serviceInfo.name}
|
||||
</Box>
|
||||
</Title>
|
||||
</Box>
|
||||
{isMedium?null:<Space h="md" />}
|
||||
<Box className='center-flex'>
|
||||
<Badge color={status_color} radius="md" size="xl" variant="filled" mr="sm">
|
||||
{serviceInfo.status}
|
||||
</Badge>
|
||||
<Badge size="xl" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="md" mr="sm">
|
||||
:{serviceInfo.port}
|
||||
</Badge>
|
||||
<Card
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius="md"
|
||||
p="xl"
|
||||
mb="xl"
|
||||
bg="var(--third_color)"
|
||||
style={{ borderColor: 'var(--fourth_color)', overflow: "visible" }} // Allows dropdowns to overflow outside
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap={isMedium ? "nowrap" : "wrap"} gap="xl">
|
||||
<Box>
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Tooltip label="Go back" position="top">
|
||||
<ActionIcon color="cyan" variant="subtle" radius="md" onClick={() => navigate("/nfregex")}>
|
||||
<FaArrowLeft size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<MdDoubleArrow size={24} style={{ color: "var(--text-secondary)" }} />
|
||||
<Title order={2} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{serviceInfo.name}
|
||||
</Title>
|
||||
</Group>
|
||||
<Group gap="sm" mt="md">
|
||||
<Badge color={status_color} radius="sm" size="lg" variant="light">{serviceInfo.status}</Badge>
|
||||
<Badge size="lg" gradient={{ from: 'indigo', to: 'cyan' }} variant="gradient" radius="sm">
|
||||
:{serviceInfo.port}
|
||||
</Badge>
|
||||
{serviceInfo.target_type === 'tls' && (
|
||||
<TLSAssociationBadge tlsStreamId={serviceInfo.tls_stream_id} size="lg" />
|
||||
)}
|
||||
<Badge color={serviceInfo.ip_int.match(regex_ipv4) ? "cyan" : "pink"} radius="sm" size="lg" variant="light">
|
||||
{serviceInfo.target_type === 'tls' ? 'decrypted traffic to ' : ''}{serviceInfo.ip_int} on {serviceInfo.proto}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box style={{ flexGrow: 1, minWidth: isMedium ? 'auto' : '100%' }}>
|
||||
<Group gap="xl" justify={isMedium ? "flex-start" : "space-between"}>
|
||||
<Group gap="xs">
|
||||
<Badge color="yellow" radius="sm" size="lg" variant="light" leftSection={<FaFilter size={12} style={{ marginTop: 2 }} />}>
|
||||
{serviceInfo.n_packets}
|
||||
</Badge>
|
||||
<Badge color="violet" radius="sm" size="lg" variant="light" leftSection={<VscRegex size={12} style={{ marginTop: 2 }} />}>
|
||||
{serviceInfo.n_regex}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<MenuDropDownWithButton>
|
||||
<Menu.Item><b>Edit service</b></Menu.Item>
|
||||
<Menu.Item leftSection={<IoSettingsSharp size={18} />} onClick={()=>setEditModal(true)}>Service Settings</Menu.Item>
|
||||
<Menu.Item leftSection={<BiRename size={18} />} onClick={()=>setRenameModal(true)}>Change service name</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Label><b>Danger zone</b></Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<BsTrashFill size={18} />} onClick={()=>setDeleteModal(true)}>Delete Service</Menu.Item>
|
||||
</MenuDropDownWithButton>
|
||||
</Box>
|
||||
</Box>
|
||||
{isMedium?null:<Space h="md" />}
|
||||
<Box className={isMedium?'center-flex':'center-flex-row'} style={{ justifyContent: "space-between"}} px="md" mt="lg">
|
||||
<Box className={isMedium?'center-flex':'center-flex-row'}>
|
||||
<Box className='center-flex'>
|
||||
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2}} /> {serviceInfo.n_packets}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge color="violet" radius="sm" size="md" variant="filled"><VscRegex style={{ marginBottom: -2}} size={13} /> {serviceInfo.n_regex}</Badge>
|
||||
<Group gap="sm" justify="flex-end" style={{ flexGrow: 1 }}>
|
||||
<Tooltip label={serviceInfo.status === "stop" ? "Cannot stop" : "Stop service"} position="bottom" color="red">
|
||||
<ActionIcon color="red" loading={buttonLoading} onClick={stopService} size="xl" radius="md" variant="light" disabled={serviceInfo.status === "stop"}>
|
||||
<FaStop size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={!["stop", "pause"].includes(serviceInfo.status) ? "Cannot start" : "Start service"} position="bottom" color="teal">
|
||||
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading} variant="light" disabled={!["stop", "pause"].includes(serviceInfo.status)}>
|
||||
<FaPlay size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<MenuDropDownWithButton>
|
||||
<Menu.Label><b>Service Tools</b></Menu.Label>
|
||||
<Menu.Item leftSection={<IoSettingsSharp size={16} />} onClick={() => setEditModal(true)}>Service Settings</Menu.Item>
|
||||
<Menu.Item leftSection={<BiRename size={16} />} onClick={() => setRenameModal(true)}>Change Name</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Label><b>Rules Management</b></Menu.Label>
|
||||
<Menu.Item leftSection={<MdDownload size={16} />} onClick={() => {
|
||||
getapi(`nfregex/services/${serviceInfo.service_id}/export`).then(data => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `firegex_nfregex_${serviceInfo.name}_backup.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
okNotify("Exported", "Regex rules have been exported.");
|
||||
}).catch(err => errorNotify("Export Failed", err.toString()));
|
||||
}}>Export Rules</Menu.Item>
|
||||
<Menu.Item leftSection={<MdUpload size={16} />} onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target?.result as string);
|
||||
postapi(`nfregex/services/${serviceInfo.service_id}/import`, data).then(() => {
|
||||
okNotify("Imported", "Regex rules have been imported successfully.");
|
||||
queryClient.invalidateQueries({ queryKey: serviceQueryKey });
|
||||
queryClient.invalidateQueries({ queryKey: ["nfregex-service-regexes", serviceInfo.service_id] });
|
||||
}).catch(err => errorNotify("Import Failed", err.toString()));
|
||||
} catch (err: any) {
|
||||
errorNotify("Invalid JSON", err.toString());
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
input.click();
|
||||
}}>Import Rules</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Label><b>Danger zone</b></Menu.Label>
|
||||
<Menu.Item color="red" leftSection={<BsTrashFill size={16} />} onClick={() => setDeleteModal(true)}>Delete Service</Menu.Item>
|
||||
</MenuDropDownWithButton>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
{isMedium?<Space w="xs" />:<Space h="xs" />}
|
||||
<Badge color={serviceInfo.ip_int.match(regex_ipv4)?"cyan":"pink"} radius="sm" size="md" variant="filled" mr="xs">{serviceInfo.ip_int} on {serviceInfo.proto}</Badge>
|
||||
</Box>
|
||||
{isMedium?null:<Space h="xl" />}
|
||||
<Box className='center-flex'>
|
||||
<Tooltip label="Go back" zIndex={0} color="cyan">
|
||||
<ActionIcon color="cyan"
|
||||
onClick={() => navigate("/")} size="xl" radius="md" variant="filled"
|
||||
aria-describedby="tooltip-back-id">
|
||||
<FaArrowLeft size="25px" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
{(!regexesList.data || regexesList.data.length == 0) ? (
|
||||
<Stack align="center" gap="xl" py="xl">
|
||||
<Title order={3} ta="center">No regex found for this service! Add one by clicking the "+" buttons</Title>
|
||||
<Tooltip label="Add a new regex" zIndex={0} color="blue">
|
||||
<ActionIcon color="blue" onClick={()=>setOpen(true)} size="xl" radius="md" variant="filled"
|
||||
aria-describedby="tooltip-AddRegex-id"><BsPlusLg size="20px" /></ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="md"/>
|
||||
<Tooltip label="Stop service" zIndex={0} color="red">
|
||||
<ActionIcon color="red" loading={buttonLoading}
|
||||
onClick={stopService} size="xl" radius="md" variant="filled"
|
||||
disabled={serviceInfo.status === "stop"}
|
||||
aria-describedby="tooltip-stop-id">
|
||||
<FaStop size="20px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Space w="md"/>
|
||||
<Tooltip label="Start service" zIndex={0} color="teal">
|
||||
<ActionIcon color="teal" size="xl" radius="md" onClick={startService} loading={buttonLoading}
|
||||
variant="filled" disabled={!["stop","pause"].includes(serviceInfo.status)?true:false}>
|
||||
<FaPlay size="20px" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider my="xl" />
|
||||
{(!regexesList.data || regexesList.data.length == 0)?<>
|
||||
<Space h="xl" />
|
||||
<Title className='center-flex' style={{textAlign:"center"}} order={3}>No regex found for this service! Add one by clicking the "+" buttons</Title>
|
||||
<Space h="xl" /> <Space h="xl" />
|
||||
<Box className='center-flex'>
|
||||
<Tooltip label="Add a new regex" zIndex={0} color="blue">
|
||||
<ActionIcon color="blue" onClick={()=>setOpen(true)} size="xl" radius="md" variant="filled"
|
||||
aria-describedby="tooltip-AddRegex-id"><BsPlusLg size="20px" /></ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</>:
|
||||
<Grid>
|
||||
</Stack>
|
||||
) : (
|
||||
<Grid mt="xs">
|
||||
{regexesList.data?.map( (regexInfo) => <Grid.Col key={regexInfo.id} span={{ lg:6, xs: 12 }}><RegexView regexInfo={regexInfo} /></Grid.Col>)}
|
||||
</Grid>
|
||||
}
|
||||
)}
|
||||
|
||||
{srv?<AddNewRegex opened={open} onClose={() => {setOpen(false);}} service={srv} />:null}
|
||||
<YesNoModal
|
||||
|
||||
@ -38,11 +38,11 @@ function NFRegex({ children }: { children: any }) {
|
||||
<Box className='center-flex' >
|
||||
{isMedium?"General stats:":null}
|
||||
<Space w="xs" />
|
||||
<Badge size="md" radius="sm" color="green" variant="filled"><FaServer style={{ marginBottom: -1, marginRight: 4}} />Services: {services.isLoading?0:services.data?.length}</Badge>
|
||||
<Badge size="md" radius="sm" color="green" variant="filled"><FaServer style={{ marginBottom: -1, marginRight: 4}} />Services: {services.isLoading?0:Array.isArray(services.data)?services.data.length:0}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:services.data?.reduce((acc, s)=> acc+=s.n_packets, 0)}</Badge>
|
||||
<Badge color="yellow" radius="sm" size="md" variant="filled"><FaFilter style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:Array.isArray(services.data)?services.data.reduce((acc, s)=> acc+=s.n_packets, 0):0}</Badge>
|
||||
<Space w="xs" />
|
||||
<Badge size="md" radius="sm" color="violet" variant="filled"><VscRegex style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:services.data?.reduce((acc, s)=> acc+=s.n_regex, 0)}</Badge>
|
||||
<Badge size="md" radius="sm" color="violet" variant="filled"><VscRegex style={{ marginBottom: -2, marginRight: 4}} />{services.isLoading?0:Array.isArray(services.data)?services.data.reduce((acc, s)=> acc+=s.n_regex, 0):0}</Badge>
|
||||
<Space w="xs" />
|
||||
</Box>
|
||||
{isMedium?null:<Space h="md" />}
|
||||
@ -64,8 +64,8 @@ function NFRegex({ children }: { children: any }) {
|
||||
<DocsButton doc="nfregex" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Space h="md" />
|
||||
<Box className="center-flex-row" style={{gap: 20}}>
|
||||
<Space h="xl" />
|
||||
<Box className="center-flex-row" style={{ gap: 12, width: "100%" }}>
|
||||
{srv?null:<>
|
||||
<LoadingOverlay visible={services.isLoading} />
|
||||
{(services.data && services.data?.length > 0)?services.data.map( srv => <ServiceRow service={srv} key={srv.service_id} onClick={()=>{
|
||||
|
||||
@ -47,8 +47,8 @@ function PortHijack() {
|
||||
<DocsButton doc="porthijack" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Space h="md" />
|
||||
<Box className="center-flex-row" style={{gap: 20}}>
|
||||
<Space h="xl" />
|
||||
<Box className="center-flex-row" style={{ gap: 12, width: "100%" }}>
|
||||
<LoadingOverlay visible={services.isLoading} />
|
||||
{(services.data && services.data.length > 0) ?services.data.map( srv => <ServiceRow service={srv} key={srv.service_id} />):<>
|
||||
<Box className='center-flex-row'>
|
||||
|
||||
232
frontend/src/pages/TLSDecrypt/index.tsx
Normal file
232
frontend/src/pages/TLSDecrypt/index.tsx
Normal 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;
|
||||
@ -1,10 +1,11 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import viteTsconfigPaths from 'vite-tsconfig-paths';
|
||||
import svgrPlugin from 'vite-plugin-svgr';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), viteTsconfigPaths(), svgrPlugin()],
|
||||
plugins: [react(), svgrPlugin()],
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
87
run.py
87
run.py
@ -191,6 +191,7 @@ def gen_args(args_to_parse: list[str]|None = None):
|
||||
parser_config.add_argument('--port', "-p", type=int, required=False, help='Set default port for web service')
|
||||
parser_config.add_argument('--host', required=False, help='Set default host IP address to bind the service to')
|
||||
parser_config.add_argument('--socket-dir', required=False, type=str, help=f'Listen on socket_dir/firegex.sock instead of TCP (default from config: {config["socket_dir"]})', default=config["socket_dir"])
|
||||
parser_config.add_argument('--password', required=False, type=str, nargs='?', const='', help='Change the password of the firewall (omit the value to be prompted for it interactively)')
|
||||
parser_config.add_argument('--allowed-ips', required=False, type=str, help=f'Comma-separated list of CIDR addresses allowed to contact firegex (default from config: {config.get("allowed_ips")})', default=config.get("allowed_ips"))
|
||||
parser_config.add_argument('--proxy-ip-header', required=False, type=str, help=f'Header name to read the client IP from (default from config: {config.get("proxy_ip_header")})', default=config.get("proxy_ip_header"))
|
||||
parser_config.add_argument('--show', required=False, action="store_true", help='Show current configuration', default=False)
|
||||
@ -222,14 +223,15 @@ def gen_args(args_to_parse: list[str]|None = None):
|
||||
args.threads = multiprocessing.cpu_count()
|
||||
|
||||
# Use config values as fallback, but allow command line to override
|
||||
if "port" not in args or args.port < 1:
|
||||
args.port = config["port"]
|
||||
|
||||
if "host" not in args:
|
||||
args.host = config["host"]
|
||||
|
||||
if "socket_dir" not in args:
|
||||
args.socket_dir = config["socket_dir"]
|
||||
if "command" in args and args.command != "config":
|
||||
if "port" not in args or args.port is None or args.port < 1:
|
||||
args.port = config["port"]
|
||||
|
||||
if "host" not in args or args.host is None:
|
||||
args.host = config["host"]
|
||||
|
||||
if "socket_dir" not in args or args.socket_dir is None:
|
||||
args.socket_dir = config["socket_dir"]
|
||||
|
||||
# Save configuration if values were specified via command line and differ from config
|
||||
config_changed = False
|
||||
@ -896,12 +898,12 @@ def run_standalone():
|
||||
puts("Starting as daemon...", color=colors.green)
|
||||
|
||||
try:
|
||||
# Start process in background
|
||||
out_file = open('/tmp/app.log', 'w')
|
||||
process = subprocess.Popen(
|
||||
chroot_cmd,
|
||||
shell=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=out_file,
|
||||
stderr=out_file,
|
||||
stdin=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid # Create new session
|
||||
)
|
||||
@ -986,6 +988,69 @@ def handle_config_command(args):
|
||||
config_changed = True
|
||||
puts(f"Socket dir set to: {args.socket_dir}", color=colors.green)
|
||||
|
||||
if hasattr(args, 'password') and args.password is not None:
|
||||
new_password = args.password
|
||||
if new_password == "":
|
||||
# No value was passed on the command line: prompt for it interactively
|
||||
# (masked input), so the new password never lands in shell history or `ps`.
|
||||
while True:
|
||||
puts("Insert a new password for firegex: ", end="", color=colors.yellow, is_bold=True, flush=True)
|
||||
new_password = getpass.getpass("")
|
||||
if len(new_password) < 8:
|
||||
puts("The password has to be at least 8 char long", color=colors.red, is_bold=True, flush=True)
|
||||
else:
|
||||
break
|
||||
puts("Confirm the password: ", end="", color=colors.yellow, is_bold=True, flush=True)
|
||||
if getpass.getpass("") != new_password:
|
||||
puts("Passwords don't match!", color=colors.red, is_bold=True, flush=True)
|
||||
exit(1)
|
||||
elif len(new_password) < 8:
|
||||
puts("Error: The password has to be at least 8 char long", color=colors.red)
|
||||
exit(1)
|
||||
|
||||
hashed = hash_psw(new_password)
|
||||
|
||||
if g.standalone_mode and os.path.isfile(os.path.join(g.rootfs_path, "execute/db/firegex.db")):
|
||||
import sqlite3
|
||||
db_path = os.path.join(g.rootfs_path, "execute/db/firegex.db")
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute('INSERT INTO keys_values (key, value) VALUES (?, ?)', ('password', hashed))
|
||||
except Exception:
|
||||
conn.execute('UPDATE keys_values SET value = ? WHERE key = ?', (hashed, 'password'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
puts("Password changed successfully! It will take effect immediately.", color=colors.green)
|
||||
except Exception as e:
|
||||
puts(f"Error changing password: {e}", color=colors.red)
|
||||
exit(1)
|
||||
elif volume_exists():
|
||||
# The hash is passed as a plain argv item (sys.argv[1]) rather than interpolated
|
||||
# into the -c source, so it never needs shell/Python-string escaping.
|
||||
py_code = (
|
||||
"import sqlite3, sys\n"
|
||||
"conn = sqlite3.connect('/execute/db/firegex.db')\n"
|
||||
"hashed = sys.argv[1]\n"
|
||||
"try:\n"
|
||||
" conn.execute('INSERT INTO keys_values (key, value) VALUES (?, ?)', ('password', hashed))\n"
|
||||
"except Exception:\n"
|
||||
" conn.execute('UPDATE keys_values SET value = ? WHERE key = ?', (hashed, 'password'))\n"
|
||||
"conn.commit()\n"
|
||||
"conn.close()\n"
|
||||
)
|
||||
try:
|
||||
subprocess.run(["docker", "exec", "firegex", "python3", "-c", py_code, hashed], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
puts("Password changed successfully! It will take effect immediately.", color=colors.green)
|
||||
except subprocess.CalledProcessError:
|
||||
puts("Error: Could not change password. Is Firegex running?", color=colors.red)
|
||||
exit(1)
|
||||
else:
|
||||
puts("Error: Firegex is not running and no standalone data found.", color=colors.red)
|
||||
exit(1)
|
||||
|
||||
config_changed = True
|
||||
|
||||
if config_changed:
|
||||
if save_config(config):
|
||||
puts(f"Configuration saved to {g.configfile}", color=colors.green)
|
||||
|
||||
@ -121,6 +121,28 @@ if __name__ == "__main__":
|
||||
)
|
||||
exit(1)
|
||||
|
||||
# Backups never contain the password/secret (see export_db), so importing one back
|
||||
# must not lock the current session out or force a re-login.
|
||||
backup = firegex.export_backup()
|
||||
if backup:
|
||||
puts("Sucessfully exported backup ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Couldn't export backup ✗", color=colors.red)
|
||||
exit(1)
|
||||
|
||||
if firegex.import_backup(backup):
|
||||
puts("Sucessfully imported backup ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Couldn't import backup ✗", color=colors.red)
|
||||
exit(1)
|
||||
|
||||
firegex3 = FiregexAPI(args.address)
|
||||
if firegex3.login(args.password):
|
||||
puts("Password survived the backup import ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Password was lost/changed after backup import ✗", color=colors.red)
|
||||
exit(1)
|
||||
|
||||
puts("List of available interfaces:", color=colors.yellow)
|
||||
for interface in firegex.get_interfaces():
|
||||
puts(
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
from utils.colors import colors, puts, sep
|
||||
from utils.firegexapi import FiregexAPI
|
||||
from utils.tcpserver import TcpServer
|
||||
from utils.tls_helpers import generate_self_signed_cert_key, tls_connect_send_recv
|
||||
import argparse
|
||||
import secrets
|
||||
import time
|
||||
@ -42,6 +43,9 @@ if __name__ == "__main__":
|
||||
parser.add_argument(
|
||||
"--verbose", "-V", action="store_true", help="Verbose output", default=False
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tls", "-t", action="store_true", help="Test TLS Decrypt", default=False
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
sep()
|
||||
@ -458,14 +462,14 @@ if __name__ == "__main__":
|
||||
X-TeSt: {secret.decode()}
|
||||
Content-Length: 15
|
||||
|
||||
A Friendly Body""").replace("\n", "\r\n")
|
||||
A Friendly Body""").replace("\n ", "\n").replace("\n", "\r\n")
|
||||
|
||||
REQUEST_BODY_TEST = textwrap.dedent(f"""POST / HTTP/1.1
|
||||
Host: localhost
|
||||
X-TeSt: NotTheSecret
|
||||
Content-Length: {len(secret.decode())}
|
||||
|
||||
{secret.decode()}""").replace("\n", "\r\n")
|
||||
{secret.decode()}""").replace("\n ", "\n").replace("\n", "\r\n")
|
||||
|
||||
HTTP_REQUEST_STREAM_TEST = textwrap.dedent(f"""
|
||||
from firegex.nfproxy.models import HttpRequest
|
||||
@ -624,14 +628,14 @@ if __name__ == "__main__":
|
||||
X-TeSt: {secret.decode()}
|
||||
Content-Length: 15
|
||||
|
||||
A Friendly Body""").replace("\n", "\r\n")
|
||||
A Friendly Body""").replace("\n ", "\n").replace("\n", "\r\n")
|
||||
|
||||
RESPONSE_BODY_TEST = textwrap.dedent(f"""HTTP/1.1 200 OK
|
||||
Host: localhost
|
||||
X-TeSt: NotTheSecret
|
||||
Content-Length: {len(secret.decode())}
|
||||
|
||||
{secret.decode()}""").replace("\n", "\r\n")
|
||||
{secret.decode()}""").replace("\n ", "\n").replace("\n", "\r\n")
|
||||
|
||||
HTTP_RESPONSE_STREAM_TEST = textwrap.dedent(f"""
|
||||
from firegex.nfproxy.models import HttpResponse
|
||||
@ -783,6 +787,52 @@ if __name__ == "__main__":
|
||||
|
||||
remove_filters()
|
||||
|
||||
HTTP_HISTORY_TEST_FILTER = textwrap.dedent("""
|
||||
from firegex.nfproxy.models import HttpFullRequest, HttpHistory
|
||||
from firegex.nfproxy import pyfilter, ACCEPT, REJECT
|
||||
|
||||
@pyfilter
|
||||
def history_filter_test(req: HttpFullRequest, history: HttpHistory):
|
||||
past_urls = [r.url for r in history.requests]
|
||||
if "/history_flag" in past_urls and req.url == "/history_secret":
|
||||
return REJECT
|
||||
return ACCEPT
|
||||
""")
|
||||
|
||||
if firegex.nfproxy_set_code(service_id, HTTP_HISTORY_TEST_FILTER):
|
||||
puts(
|
||||
"Successfully added filter for HttpHistory test ✔",
|
||||
color=colors.green,
|
||||
)
|
||||
else:
|
||||
puts("Test Failed: Couldn't add HttpHistory filter ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
server.connect_client()
|
||||
req1 = b"GET /history_flag HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"
|
||||
resp1 = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"
|
||||
server.send_packet(req1, server_reply=resp1)
|
||||
if not server.recv_packet():
|
||||
puts("Test Failed: /history_flag request was blocked unexpectedly ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
req2 = b"GET /history_secret HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"
|
||||
server.send_packet(req2)
|
||||
if not server.recv_packet():
|
||||
puts(
|
||||
"The HTTP request to /history_secret was successfully blocked based on HttpHistory ✔",
|
||||
color=colors.green,
|
||||
)
|
||||
else:
|
||||
puts(
|
||||
"Test Failed: The HTTP request to /history_secret wasn't blocked by HttpHistory filter ✗",
|
||||
color=colors.red,
|
||||
)
|
||||
exit_test(1)
|
||||
server.close_client()
|
||||
|
||||
remove_filters()
|
||||
|
||||
# Simulating requests is more complex due to websocket extensions handshake
|
||||
|
||||
WS_REQUEST_PARSING_TEST = b"GET /sock/?EIO=4&transport=websocket HTTP/1.1\r\nHost: localhost:8080\r\nConnection: Upgrade\r\nPragma: no-cache\r\nCache-Control: no-cache\r\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)\xac AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36\r\nUpgrade: websocket\r\nOrigin: http://localhost:8080\r\nSec-WebSocket-Version: 13\r\nAccept-Encoding: gzip, deflate, br, zstd\r\nAccept-Language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6,zh;q=0.5\r\nCookie: cookie-consent=true; _iub_cs-86405163=%7B%22timestamp%22%3A%222024-09-12T18%3A20%3A18.627Z%22%2C%22version%22%3A%221.65.1%22%2C%22purposes%22%3A%7B%221%22%3Atrue%2C%224%22%3Atrue%7D%2C%22id%22%3A86405163%2C%22cons%22%3A%7B%22rand%22%3A%222b09e6%22%7D%7D\r\nSec-WebSocket-Key: eE01O3/ZShPKsrykACLAaA==\r\nSec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n\r\n\xc1\x84#\x8a\xb2\xbb\x11\xbb\xb2\xbb"
|
||||
@ -856,9 +906,9 @@ if __name__ == "__main__":
|
||||
puts("Test Failed: Coulnd't rename service ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
# Change settings
|
||||
# Change settings, including proto (exercises the proto -> l4_proto conversion path)
|
||||
if firegex.nfproxy_settings_service(
|
||||
service_id, 1338, "::dead:beef" if args.ipv6 else "123.123.123.123", True
|
||||
service_id, 1338, "::dead:beef" if args.ipv6 else "123.123.123.123", True, proto="tcp"
|
||||
):
|
||||
srv_updated = firegex.nfproxy_get_service(service_id)
|
||||
if (
|
||||
@ -866,6 +916,7 @@ if __name__ == "__main__":
|
||||
and ("::dead:beef" if args.ipv6 else "123.123.123.123")
|
||||
in srv_updated["ip_int"]
|
||||
and srv_updated["fail_open"]
|
||||
and srv_updated["proto"] == "tcp"
|
||||
):
|
||||
puts("Sucessfully changed service settings ✔", color=colors.green)
|
||||
else:
|
||||
@ -878,4 +929,126 @@ if __name__ == "__main__":
|
||||
puts("Test Failed: Coulnd't change service settings ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
if args.tls:
|
||||
# Testing a target_type='tls' pyfilter service: the backend must also speak TLS
|
||||
# since nginx's loopback clear_port leg always re-encrypts to the real destination.
|
||||
tls_service_name = f"{args.service_name} TLS"
|
||||
tls_port = args.port + 10
|
||||
|
||||
for ele in firegex.nfproxy_get_services():
|
||||
if ele["name"] == tls_service_name or ele["port"] == tls_port:
|
||||
firegex.nfproxy_delete_service(ele["service_id"])
|
||||
for s in firegex.tls_get_streams():
|
||||
if s["port"] == tls_port:
|
||||
firegex.tls_stop_stream(s["id"])
|
||||
firegex.tls_delete_stream(s["id"])
|
||||
|
||||
backend_cert, backend_key = generate_self_signed_cert_key("127.0.0.1")
|
||||
tls_server = TcpServer(tls_port, ipv6=args.ipv6, tls_cert=backend_cert, tls_key=backend_key)
|
||||
tls_server.start()
|
||||
time.sleep(0.5)
|
||||
|
||||
stream_cert, stream_key = generate_self_signed_cert_key("localhost")
|
||||
tls_stream_id = firegex.tls_add_stream(
|
||||
tls_service_name + "_Stream", "::1" if args.ipv6 else "127.0.0.1", tls_port, stream_cert, stream_key
|
||||
)
|
||||
if tls_stream_id:
|
||||
puts(f"Sucessfully created TLS stream {tls_stream_id} ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Couldn't create TLS stream ✗", color=colors.red)
|
||||
tls_server.stop()
|
||||
exit_test(1)
|
||||
|
||||
if firegex.tls_start_stream(tls_stream_id):
|
||||
puts("Sucessfully started TLS stream ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Couldn't start TLS stream ✗", color=colors.red)
|
||||
tls_server.stop()
|
||||
firegex.tls_delete_stream(tls_stream_id)
|
||||
exit_test(1)
|
||||
|
||||
tls_ssl_port = firegex.tls_get_stream(tls_stream_id)["ssl_port"]
|
||||
|
||||
tls_srv_id = firegex.nfproxy_add_service(
|
||||
tls_service_name, tls_port, "tcp", "::1" if args.ipv6 else "127.0.0.1",
|
||||
target_type="tls", tls_stream_id=tls_stream_id
|
||||
)
|
||||
if tls_srv_id:
|
||||
puts(f"Sucessfully created TLS service {tls_srv_id} ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Couldn't create TLS service ✗", color=colors.red)
|
||||
tls_server.stop()
|
||||
firegex.tls_delete_stream(tls_stream_id)
|
||||
exit_test(1)
|
||||
|
||||
if firegex.nfproxy_start_service(tls_srv_id):
|
||||
puts("Sucessfully started TLS service ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Couldn't start TLS service ✗", color=colors.red)
|
||||
firegex.nfproxy_delete_service(tls_srv_id)
|
||||
tls_server.stop()
|
||||
firegex.tls_delete_stream(tls_stream_id)
|
||||
exit_test(1)
|
||||
|
||||
tls_secret = bytes(secrets.token_hex(16).encode())
|
||||
if firegex.nfproxy_set_code(tls_srv_id, get_vedict_test(tls_secret.decode(), "REJECT")):
|
||||
puts(f"Sucessfully added TLS filter for {str(tls_secret)} ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Couldn't add TLS filter ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
time.sleep(0.5)
|
||||
blocked_response = tls_connect_send_recv(
|
||||
tls_ssl_port, args.ipv6, secrets.token_bytes(40) + tls_secret + secrets.token_bytes(40)
|
||||
)
|
||||
if not blocked_response:
|
||||
puts("The malicious request over TLS was successfully blocked ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: The malicious request over TLS wasn't blocked ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
clean_data = secrets.token_bytes(64)
|
||||
if tls_connect_send_recv(tls_ssl_port, args.ipv6, clean_data) == clean_data:
|
||||
puts("Clean traffic over TLS passed through correctly ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Clean traffic over TLS wasn't echoed back correctly ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
# Cascade: stopping the TLS stream should stop the dependent service
|
||||
if firegex.tls_stop_stream(tls_stream_id):
|
||||
time.sleep(1)
|
||||
if firegex.nfproxy_get_service(tls_srv_id)["status"] == "stop":
|
||||
puts("TLS stream stop correctly cascaded to stop the dependent service ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Dependent service wasn't cascade-stopped ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
else:
|
||||
puts("Test Failed: Couldn't stop the TLS stream ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
# Cascade: starting the dependent service should reactivate the TLS stream
|
||||
if firegex.nfproxy_start_service(tls_srv_id):
|
||||
time.sleep(1)
|
||||
if firegex.tls_get_stream(tls_stream_id)["status"] == "active":
|
||||
puts("Starting the service correctly cascaded to reactivate the TLS stream ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: TLS stream wasn't cascade-reactivated ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
else:
|
||||
puts("Test Failed: Couldn't start the TLS service ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
# Deleting a still-referenced TLS stream must be rejected
|
||||
if not firegex.tls_delete_stream(tls_stream_id):
|
||||
puts("Correctly rejected deletion of a TLS stream still in use ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Deleting an in-use TLS stream should have failed ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
firegex.nfproxy_delete_service(tls_srv_id)
|
||||
tls_server.stop()
|
||||
firegex.tls_stop_stream(tls_stream_id)
|
||||
firegex.tls_delete_stream(tls_stream_id)
|
||||
puts("Sucessfully cleaned up TLS test service ✔", color=colors.green)
|
||||
|
||||
exit_test(0)
|
||||
|
||||
@ -3,6 +3,7 @@ from utils.colors import colors, puts, sep
|
||||
from utils.firegexapi import FiregexAPI
|
||||
from utils.tcpserver import TcpServer
|
||||
from utils.udpserver import UdpServer
|
||||
from utils.tls_helpers import generate_self_signed_cert_key, tls_connect_send_recv
|
||||
import argparse
|
||||
import secrets
|
||||
import base64
|
||||
@ -49,14 +50,25 @@ if __name__ == "__main__":
|
||||
help="Select the protocol",
|
||||
default="tcp",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tls", "-t", action="store_true", help="Test TLS Decrypt", default=False
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.tls and args.proto != "tcp":
|
||||
puts("TLS is only supported with TCP protocol.", color=colors.red)
|
||||
exit(1)
|
||||
|
||||
sep()
|
||||
puts("Testing will start on ", color=colors.cyan, end="")
|
||||
puts(f"{args.address}", color=colors.yellow)
|
||||
|
||||
firegex = FiregexAPI(args.address)
|
||||
|
||||
status = firegex.status()
|
||||
if status.get("status") == "init":
|
||||
firegex.set_password(args.password)
|
||||
|
||||
# Login
|
||||
if firegex.login(args.password):
|
||||
puts("Sucessfully logged in ✔", color=colors.green)
|
||||
@ -64,11 +76,17 @@ if __name__ == "__main__":
|
||||
puts("Test Failed: Unknown response or wrong passowrd ✗", color=colors.red)
|
||||
exit(1)
|
||||
|
||||
# Create server
|
||||
# Create server. When testing TLS, the backend itself must also speak TLS: nginx's
|
||||
# loopback clear_port leg always re-encrypts before forwarding to the real destination
|
||||
# (this mirrors a CTF challenge service that natively speaks TLS).
|
||||
backend_cert, backend_key = generate_self_signed_cert_key("127.0.0.1") if args.tls else (None, None)
|
||||
server = (TcpServer if args.proto == "tcp" else UdpServer)(
|
||||
args.port, ipv6=args.ipv6
|
||||
args.port, ipv6=args.ipv6, **({"tls_cert": backend_cert, "tls_key": backend_key} if args.tls else {})
|
||||
)
|
||||
|
||||
tls_stream_id = None
|
||||
tls_ssl_port = None
|
||||
service_id = None
|
||||
def exit_test(code):
|
||||
if service_id:
|
||||
server.stop()
|
||||
@ -76,7 +94,14 @@ if __name__ == "__main__":
|
||||
puts("Sucessfully deleted service ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Coulnd't delete serivce ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
exit(1)
|
||||
if tls_stream_id:
|
||||
firegex.tls_stop_stream(tls_stream_id)
|
||||
if firegex.tls_delete_stream(tls_stream_id):
|
||||
puts("Sucessfully deleted TLS stream ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Couldn't delete TLS stream ✗", color=colors.red)
|
||||
exit(1)
|
||||
exit(code)
|
||||
|
||||
srvs = firegex.nfregex_get_services()
|
||||
@ -84,8 +109,33 @@ if __name__ == "__main__":
|
||||
if ele["name"] == args.service_name:
|
||||
firegex.nfregex_delete_service(ele["service_id"])
|
||||
|
||||
if args.tls:
|
||||
streams = firegex.tls_get_streams()
|
||||
for s in streams:
|
||||
if s["port"] == args.port:
|
||||
firegex.tls_stop_stream(s["id"])
|
||||
firegex.tls_delete_stream(s["id"])
|
||||
|
||||
cert, key = generate_self_signed_cert_key("localhost")
|
||||
tls_stream_id = firegex.tls_add_stream("TLS Stream", "::1" if args.ipv6 else "127.0.0.1", args.port, cert, key)
|
||||
if tls_stream_id:
|
||||
puts(f"Sucessfully created TLS stream {tls_stream_id} ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Failed to create TLS stream ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
if firegex.tls_start_stream(tls_stream_id):
|
||||
puts("Sucessfully started TLS stream ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Failed to start TLS stream ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
tls_ssl_port = firegex.tls_get_stream(tls_stream_id)["ssl_port"]
|
||||
|
||||
service_id = firegex.nfregex_add_service(
|
||||
args.service_name, args.port, args.proto, "::1" if args.ipv6 else "127.0.0.1"
|
||||
args.service_name, args.port, args.proto, "::1" if args.ipv6 else "127.0.0.1",
|
||||
target_type="tls" if args.tls else "flow",
|
||||
tls_stream_id=tls_stream_id if args.tls else None
|
||||
)
|
||||
if service_id:
|
||||
puts(f"Sucessfully created service {service_id} ✔", color=colors.green)
|
||||
@ -101,8 +151,18 @@ if __name__ == "__main__":
|
||||
|
||||
server.start()
|
||||
time.sleep(0.5)
|
||||
|
||||
def send_check(data):
|
||||
"""Sends data and checks it was echoed back unmodified. When testing TLS,
|
||||
routes through a real TLS handshake against the stream's public ssl_port
|
||||
(so the check actually exercises decrypt -> filter -> re-encrypt), instead
|
||||
of talking to the backend directly."""
|
||||
if args.tls:
|
||||
return tls_connect_send_recv(tls_ssl_port, args.ipv6, data) == data
|
||||
return server.sendCheckData(data)
|
||||
|
||||
try:
|
||||
if server.sendCheckData(secrets.token_bytes(432)):
|
||||
if send_check(secrets.token_bytes(432)):
|
||||
puts("Successfully tested first proxy with no regex ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Data was corrupted ", color=colors.red)
|
||||
@ -121,6 +181,8 @@ if __name__ == "__main__":
|
||||
puts(f"Test Failed: Couldn't add the regex {str(secret)} ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
|
||||
|
||||
# Check if regex is present in the service
|
||||
n_blocked = 0
|
||||
|
||||
@ -136,7 +198,7 @@ if __name__ == "__main__":
|
||||
if r["regex"] == secret:
|
||||
# Test the regex
|
||||
s = regex.upper() if upper else regex
|
||||
if not server.sendCheckData(
|
||||
if not send_check(
|
||||
secrets.token_bytes(40) + s + secrets.token_bytes(40)
|
||||
):
|
||||
puts(
|
||||
@ -191,7 +253,7 @@ if __name__ == "__main__":
|
||||
puts("Test Failed: The regex wasn't found ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
else:
|
||||
if server.sendCheckData(
|
||||
if send_check(
|
||||
secrets.token_bytes(40)
|
||||
+ base64.b64decode(regex)
|
||||
+ secrets.token_bytes(40)
|
||||
@ -342,32 +404,74 @@ if __name__ == "__main__":
|
||||
puts("Test Failed: Coulnd't rename service ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
# Change settings
|
||||
opposite_proto = "udp" if args.proto == "tcp" else "tcp"
|
||||
if firegex.nfregex_settings_service(
|
||||
service_id,
|
||||
1338,
|
||||
opposite_proto,
|
||||
"::dead:beef" if args.ipv6 else "123.123.123.123",
|
||||
True,
|
||||
):
|
||||
srv_updated = firegex.nfregex_get_service(service_id)
|
||||
if (
|
||||
srv_updated["port"] == 1338
|
||||
and srv_updated["proto"] == opposite_proto
|
||||
and ("::dead:beef" if args.ipv6 else "123.123.123.123")
|
||||
in srv_updated["ip_int"]
|
||||
and srv_updated["fail_open"]
|
||||
# Change settings (skipped in --tls mode: the service's own port/ip/proto no longer
|
||||
# drive traffic interception once linked to a TLS stream, see resolve_target())
|
||||
if not args.tls:
|
||||
opposite_proto = "udp" if args.proto == "tcp" else "tcp"
|
||||
if firegex.nfregex_settings_service(
|
||||
service_id,
|
||||
1338,
|
||||
"::dead:beef" if args.ipv6 else "123.123.123.123",
|
||||
True,
|
||||
proto=opposite_proto,
|
||||
):
|
||||
puts("Sucessfully changed service settings ✔", color=colors.green)
|
||||
srv_updated = firegex.nfregex_get_service(service_id)
|
||||
if (
|
||||
srv_updated["port"] == 1338
|
||||
and srv_updated["proto"] == opposite_proto
|
||||
and ("::dead:beef" if args.ipv6 else "123.123.123.123")
|
||||
in srv_updated["ip_int"]
|
||||
and srv_updated["fail_open"]
|
||||
):
|
||||
puts("Sucessfully changed service settings ✔", color=colors.green)
|
||||
else:
|
||||
puts(
|
||||
"Test Failed: Service settings weren't updated correctly ✗",
|
||||
color=colors.red,
|
||||
)
|
||||
exit_test(1)
|
||||
else:
|
||||
puts(
|
||||
"Test Failed: Service settings weren't updated correctly ✗",
|
||||
color=colors.red,
|
||||
)
|
||||
puts("Test Failed: Coulnd't change service settings ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
if args.tls:
|
||||
# Stopping the TLS stream should cascade-stop the dependent service
|
||||
if firegex.tls_stop_stream(tls_stream_id):
|
||||
time.sleep(1)
|
||||
srv_after_stop = firegex.nfregex_get_service(service_id)
|
||||
if srv_after_stop["status"] == "stop":
|
||||
puts("TLS stream stop correctly cascaded to stop the dependent service ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Dependent service wasn't cascade-stopped ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
else:
|
||||
puts("Test Failed: Coulnd't stop the TLS stream ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
# Starting the dependent service should cascade-reactivate the TLS stream
|
||||
if firegex.nfregex_start_service(service_id):
|
||||
time.sleep(1)
|
||||
stream_after_start = firegex.tls_get_stream(tls_stream_id)
|
||||
if stream_after_start["status"] == "active":
|
||||
puts("Starting the service correctly cascaded to reactivate the TLS stream ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: TLS stream wasn't cascade-reactivated ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
else:
|
||||
puts("Test Failed: Coulnd't start the service ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
if send_check(secrets.token_bytes(64)):
|
||||
puts("Traffic flows correctly after the cascade reactivation ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Traffic didn't flow correctly after the cascade reactivation ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
# Deleting a still-referenced TLS stream must be rejected
|
||||
if not firegex.tls_delete_stream(tls_stream_id):
|
||||
puts("Correctly rejected deletion of a TLS stream still in use ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Deleting an in-use TLS stream should have failed ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
else:
|
||||
puts("Test Failed: Coulnd't change service settings ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
|
||||
exit_test(0)
|
||||
|
||||
@ -68,6 +68,7 @@ if __name__ == "__main__":
|
||||
args.port + 1, ipv6=args.ipv6, proxy_port=args.port
|
||||
)
|
||||
|
||||
service_id = None
|
||||
def exit_test(code):
|
||||
if service_id:
|
||||
server.stop()
|
||||
@ -75,7 +76,7 @@ if __name__ == "__main__":
|
||||
puts("Sucessfully deleted service ✔", color=colors.green)
|
||||
else:
|
||||
puts("Test Failed: Coulnd't delete serivce ✗", color=colors.red)
|
||||
exit_test(1)
|
||||
exit(1)
|
||||
exit(code)
|
||||
|
||||
srvs = firegex.ph_get_services()
|
||||
|
||||
@ -2,3 +2,6 @@ iperf3
|
||||
requests
|
||||
matplotlib
|
||||
numpy
|
||||
cryptography
|
||||
psutil
|
||||
pydantic
|
||||
|
||||
@ -3,7 +3,11 @@
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TMP=$1
|
||||
PASSWORD=${TMP:=testpassword}
|
||||
if [[ "$TMP" == "--set-pass" ]]; then
|
||||
PASSWORD="testpassword"
|
||||
else
|
||||
PASSWORD=${TMP:-testpassword}
|
||||
fi
|
||||
ERROR=0
|
||||
|
||||
pip3 install -r requirements.txt
|
||||
@ -12,9 +16,17 @@ until curl --output /dev/null --silent --fail http://127.0.0.1:4444/api/status;
|
||||
printf '.'
|
||||
sleep 1
|
||||
done
|
||||
if [[ "$TMP" == "--set-pass" ]]; then
|
||||
curl -X POST http://127.0.0.1:4444/api/set-password -H "Content-Type: application/json" -d "{\"password\": \"$PASSWORD\"}" -s
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Running standard API test"
|
||||
python3 api_test.py -p $PASSWORD || ERROR=1
|
||||
echo "Running TLS Decrypt stream CRUD/edit/cascade test"
|
||||
python3 tls_test.py -p $PASSWORD || ERROR=1
|
||||
echo "Running TLS Decrypt stream CRUD/edit/cascade test ipv6"
|
||||
python3 tls_test.py -p $PASSWORD -6 || ERROR=1
|
||||
echo "Running Netfilter Regex TCP ipv4"
|
||||
python3 nfregex_test.py -p $PASSWORD -m tcp || ERROR=1
|
||||
echo "Running Netfilter Regex TCP ipv6"
|
||||
@ -35,6 +47,15 @@ echo "Running Netfilter Proxy ipv4"
|
||||
python3 nfproxy_test.py -p $PASSWORD || ERROR=1
|
||||
echo "Running Netfilter Proxy ipv6"
|
||||
python3 nfproxy_test.py -p $PASSWORD -6 || ERROR=1
|
||||
echo "Running Netfilter Proxy ipv4 TLS"
|
||||
python3 nfproxy_test.py -p $PASSWORD --tls || ERROR=1
|
||||
echo "Running Netfilter Proxy ipv6 TLS"
|
||||
python3 nfproxy_test.py -p $PASSWORD -6 --tls || ERROR=1
|
||||
|
||||
echo "Running Netfilter Regex TCP ipv4 TLS"
|
||||
python3 nfregex_test.py -p $PASSWORD -m tcp --tls || ERROR=1
|
||||
echo "Running Netfilter Regex TCP ipv6 TLS"
|
||||
python3 nfregex_test.py -p $PASSWORD -m tcp -6 --tls || ERROR=1
|
||||
|
||||
exit $ERROR
|
||||
|
||||
|
||||
344
tests/stress_test_history.py
Executable file
344
tests/stress_test_history.py
Executable file
@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Firegex HttpHistory Real-Time Stress Test.
|
||||
Connects to Firegex API, deploys an nfproxy HTTP service with HttpHistory filter enabled,
|
||||
starts an HTTP 200 OK backend server and continuous HTTP client requests.
|
||||
Measures real-time memory usage (Firegex cpproxy / local process) and request throughput until stopped (Ctrl+C).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
import argparse
|
||||
import traceback
|
||||
from typing import Optional, List
|
||||
|
||||
# Add project paths
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../fgex-lib")))
|
||||
|
||||
try:
|
||||
import psutil
|
||||
HAS_PSUTIL = True
|
||||
except ImportError:
|
||||
HAS_PSUTIL = False
|
||||
|
||||
from tests.utils.colors import colors, puts, sep
|
||||
from tests.utils.firegexapi import FiregexAPI
|
||||
|
||||
HTTP_STRESS_FILTER_CODE = """
|
||||
from firegex.nfproxy.models import HttpFullRequest, HttpFullResponse, HttpHistory
|
||||
from firegex.nfproxy import pyfilter, ACCEPT
|
||||
|
||||
req_count = 0
|
||||
resp_count = 0
|
||||
|
||||
@pyfilter
|
||||
def history_stress_req(req: HttpFullRequest, history: HttpHistory):
|
||||
global req_count
|
||||
req_count += 1
|
||||
_ = len(history.requests)
|
||||
_ = len(history.responses)
|
||||
return ACCEPT
|
||||
|
||||
@pyfilter
|
||||
def history_stress_resp(resp: HttpFullResponse, history: HttpHistory):
|
||||
global resp_count
|
||||
resp_count += 1
|
||||
_ = len(history.requests)
|
||||
_ = len(history.responses)
|
||||
return ACCEPT
|
||||
"""
|
||||
|
||||
|
||||
def start_http_backend_server(port: int, ipv6: bool = False):
|
||||
"""Starts a multithreaded HTTP backend server that returns valid HTTP 200 OK responses."""
|
||||
server_sock = socket.socket(socket.AF_INET6 if ipv6 else socket.AF_INET, socket.SOCK_STREAM)
|
||||
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server_sock.bind(("::1" if ipv6 else "127.0.0.1", port))
|
||||
server_sock.listen(128)
|
||||
server_sock.settimeout(0.5)
|
||||
|
||||
running = True
|
||||
|
||||
def handle_client(conn):
|
||||
conn.settimeout(2.0)
|
||||
resp = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 12\r\nConnection: keep-alive\r\n\r\nHello World!"
|
||||
try:
|
||||
while running:
|
||||
buf = conn.recv(4096)
|
||||
if not buf:
|
||||
break
|
||||
conn.sendall(resp)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def server_loop():
|
||||
while running:
|
||||
try:
|
||||
conn, _ = server_sock.accept()
|
||||
t = threading.Thread(target=handle_client, args=(conn,), daemon=True)
|
||||
t.start()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception:
|
||||
break
|
||||
try:
|
||||
server_sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
t = threading.Thread(target=server_loop, daemon=True)
|
||||
t.start()
|
||||
|
||||
def stop_server():
|
||||
nonlocal running
|
||||
running = False
|
||||
try:
|
||||
server_sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return stop_server
|
||||
|
||||
|
||||
def find_cpproxy_process():
|
||||
"""Finds cpproxy process using psutil if running."""
|
||||
if not HAS_PSUTIL:
|
||||
return None
|
||||
try:
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
||||
name = proc.info['name'] or ''
|
||||
cmdline = ' '.join(proc.info['cmdline'] or [])
|
||||
if 'cpproxy' in name or 'cpproxy' in cmdline:
|
||||
return proc
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_memory_mb(proc=None) -> float:
|
||||
"""Returns memory usage in MB for a given process or current process."""
|
||||
try:
|
||||
if proc:
|
||||
return proc.memory_info().rss / (1024 * 1024)
|
||||
if HAS_PSUTIL:
|
||||
return psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024)
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def create_client_socket(port: int, ipv6: bool = False) -> socket.socket:
|
||||
s = socket.socket(socket.AF_INET6 if ipv6 else socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(2.0)
|
||||
s.connect(("::1" if ipv6 else "127.0.0.1", port))
|
||||
return s
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Firegex HttpHistory Stress Test")
|
||||
parser.add_argument("--address", "-a", type=str, default="http://127.0.0.1:4444/", help="Firegex API address (default: http://127.0.0.1:4444/)")
|
||||
parser.add_argument("--password", "-p", type=str, required=True, help="Firegex admin password")
|
||||
parser.add_argument("--service-name", "-n", type=str, default="StressTestHistory", help="Service name (default: StressTestHistory)")
|
||||
parser.add_argument("--port", "-P", type=int, default=1337, help="Service port (default: 1337)")
|
||||
parser.add_argument("--ipv6", "-6", action="store_true", default=False, help="Use IPv6")
|
||||
parser.add_argument("--connections", "-c", type=int, default=4, help="Number of concurrent client connections (default: 4)")
|
||||
args = parser.parse_args()
|
||||
|
||||
sep()
|
||||
puts("🔥 Firegex HttpHistory Real-Time Stress Test 🔥", color=colors.cyan, is_bold=True)
|
||||
puts(f"Firegex API: {args.address}", color=colors.yellow)
|
||||
puts(f"Target Port: {args.port}", color=colors.yellow)
|
||||
puts(f"Connections: {args.connections}", color=colors.yellow)
|
||||
puts("Press Ctrl+C at any time to stop the stress test.", color=colors.magenta, is_bold=True)
|
||||
sep()
|
||||
|
||||
firegex = FiregexAPI(args.address)
|
||||
|
||||
# 1. Login to Firegex
|
||||
puts("Logging in to Firegex API...", color=colors.cyan)
|
||||
if firegex.login(args.password):
|
||||
puts("Successfully logged in ✔", color=colors.green)
|
||||
else:
|
||||
puts("Error: Login failed! Check password or address ✗", color=colors.red)
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Cleanup existing service if any
|
||||
for srv in firegex.nfproxy_get_services():
|
||||
if srv.get("name") == args.service_name or srv.get("port") == args.port:
|
||||
puts(f"Cleaning up existing service ID {srv['service_id']}...", color=colors.yellow)
|
||||
firegex.nfproxy_delete_service(srv["service_id"])
|
||||
|
||||
# 3. Create service in Firegex
|
||||
ip_int = "::1" if args.ipv6 else "127.0.0.1"
|
||||
service_id = firegex.nfproxy_add_service(args.service_name, args.port, "http", ip_int, fail_open=True)
|
||||
if not service_id:
|
||||
puts("Error: Failed to create nfproxy service in Firegex ✗", color=colors.red)
|
||||
sys.exit(1)
|
||||
puts(f"Created nfproxy service ID: {service_id} ✔", color=colors.green)
|
||||
|
||||
def cleanup_and_exit(code=0):
|
||||
puts("\nCleaning up Firegex service...", color=colors.yellow)
|
||||
try:
|
||||
firegex.nfproxy_stop_service(service_id)
|
||||
firegex.nfproxy_delete_service(service_id)
|
||||
puts("Successfully cleaned up Firegex service ✔", color=colors.green)
|
||||
except Exception as e:
|
||||
puts(f"Error during cleanup: {e}", color=colors.red)
|
||||
sys.exit(code)
|
||||
|
||||
# 4. Upload HttpHistory filter code
|
||||
puts("Deploying HttpHistory pyfilter code to Firegex...", color=colors.cyan)
|
||||
if not firegex.nfproxy_set_code(service_id, HTTP_STRESS_FILTER_CODE):
|
||||
puts("Error: Failed to upload pyfilter code ✗", color=colors.red)
|
||||
cleanup_and_exit(1)
|
||||
puts("Successfully deployed HttpHistory filter ✔", color=colors.green)
|
||||
|
||||
# 5. Start Firegex Service
|
||||
puts("Starting Firegex service...", color=colors.cyan)
|
||||
if not firegex.nfproxy_start_service(service_id):
|
||||
puts("Error: Failed to start Firegex service ✗", color=colors.red)
|
||||
cleanup_and_exit(1)
|
||||
puts("Firegex service started successfully ✔", color=colors.green)
|
||||
|
||||
# 6. Start HTTP Backend Server
|
||||
stop_backend_server = start_http_backend_server(args.port, ipv6=args.ipv6)
|
||||
puts(f"HTTP Backend server listening on port {args.port} ✔", color=colors.green)
|
||||
time.sleep(0.5)
|
||||
|
||||
# Find cpproxy process if available
|
||||
cpproxy_proc = find_cpproxy_process()
|
||||
if cpproxy_proc:
|
||||
puts(f"Detected Firegex cpproxy process (PID {cpproxy_proc.pid}) ✔", color=colors.green)
|
||||
|
||||
# 7. Connect client sockets
|
||||
running = True
|
||||
|
||||
def sig_handler(sig, frame):
|
||||
nonlocal running
|
||||
running = False
|
||||
|
||||
signal.signal(signal.SIGINT, sig_handler)
|
||||
signal.signal(signal.SIGTERM, sig_handler)
|
||||
|
||||
client_socks: List[Optional[socket.socket]] = []
|
||||
for _ in range(args.connections):
|
||||
try:
|
||||
client_socks.append(create_client_socket(args.port, args.ipv6))
|
||||
except Exception:
|
||||
client_socks.append(None)
|
||||
|
||||
puts(f"Initialized {len(client_socks)} client socket slot(s) ✔", color=colors.green)
|
||||
puts("\n🚀 Spamming HTTP requests continuously. Press Ctrl+C to stop...\n", color=colors.cyan, is_bold=True)
|
||||
|
||||
req_index = 0
|
||||
total_requests = 0
|
||||
start_time = time.time()
|
||||
last_print_time = start_time
|
||||
last_req_count = 0
|
||||
peak_script_mem = get_memory_mb()
|
||||
peak_cpproxy_mem = get_memory_mb(cpproxy_proc) if cpproxy_proc else 0.0
|
||||
|
||||
try:
|
||||
while running:
|
||||
for i in range(len(client_socks)):
|
||||
if not running:
|
||||
break
|
||||
|
||||
s = client_socks[i]
|
||||
if s is None:
|
||||
try:
|
||||
s = create_client_socket(args.port, args.ipv6)
|
||||
client_socks[i] = s
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
req_index += 1
|
||||
req_bytes = f"GET /stress_req_{req_index} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: keep-alive\r\nUser-Agent: FiregexStressClient\r\n\r\n".encode()
|
||||
|
||||
try:
|
||||
s.sendall(req_bytes)
|
||||
resp = s.recv(4096)
|
||||
if resp and b"200 OK" in resp:
|
||||
total_requests += 1
|
||||
else:
|
||||
raise ConnectionResetError("Invalid or empty HTTP response")
|
||||
except Exception:
|
||||
# Safely close broken socket and mark slot for reconnection
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
client_socks[i] = None
|
||||
|
||||
curr_time = time.time()
|
||||
elapsed_print = curr_time - last_print_time
|
||||
|
||||
if elapsed_print >= 1.0 or not running:
|
||||
script_mem = get_memory_mb()
|
||||
cpproxy_mem = get_memory_mb(cpproxy_proc) if cpproxy_proc else 0.0
|
||||
|
||||
if script_mem > peak_script_mem:
|
||||
peak_script_mem = script_mem
|
||||
if cpproxy_mem > peak_cpproxy_mem:
|
||||
peak_cpproxy_mem = cpproxy_mem
|
||||
|
||||
batch_reqs = total_requests - last_req_count
|
||||
reqs_per_sec = batch_reqs / elapsed_print if elapsed_print > 0 else 0
|
||||
total_elapsed = curr_time - start_time
|
||||
|
||||
mem_str = f"Script RSS: {script_mem:.1f}MB"
|
||||
if cpproxy_proc:
|
||||
mem_str += f" | cpproxy RSS: {cpproxy_mem:.1f}MB (Peak: {peak_cpproxy_mem:.1f}MB)"
|
||||
|
||||
sys.stdout.write(
|
||||
f"\r\033[K[{time.strftime('%H:%M:%S')}] Elapsed: {total_elapsed:.1f}s | "
|
||||
f"Reqs: {total_requests:,} | "
|
||||
f"Speed: {reqs_per_sec:,.0f} req/s | "
|
||||
f"{mem_str}"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
last_print_time = curr_time
|
||||
last_req_count = total_requests
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
end_time = time.time()
|
||||
duration = end_time - start_time
|
||||
|
||||
# Close client sockets
|
||||
for s in client_socks:
|
||||
if s:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stop_backend_server()
|
||||
|
||||
print() # New line
|
||||
sep()
|
||||
puts("🛑 Stress Test Interrupted by User", color=colors.yellow, is_bold=True)
|
||||
puts(f"Total Time Elapsed: {duration:.2f} seconds")
|
||||
puts(f"Total HTTP Reqs: {total_requests:,}")
|
||||
puts(f"Average Throughput: {total_requests / max(duration, 0.001):,.0f} req/sec")
|
||||
if cpproxy_proc:
|
||||
puts(f"cpproxy Peak Memory: {peak_cpproxy_mem:.2f} MB", color=colors.cyan, is_bold=True)
|
||||
sep()
|
||||
|
||||
cleanup_and_exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
299
tests/test_http_history.py
Normal file
299
tests/test_http_history.py
Normal file
@ -0,0 +1,299 @@
|
||||
from firegex.nfproxy import pyfilter, ACCEPT, clear_pyfilter_registry
|
||||
from firegex.nfproxy.models import (
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
HttpFullRequest,
|
||||
HttpFullResponse,
|
||||
HttpHistory,
|
||||
HttpStreamHistory,
|
||||
)
|
||||
from firegex.nfproxy.internals import compile, handle_packet
|
||||
|
||||
|
||||
def create_packet_info(payload: bytes, is_input: bool):
|
||||
return {
|
||||
"data": payload,
|
||||
"raw_packet": b"\x00" * 40 + payload,
|
||||
"is_input": is_input,
|
||||
"is_ipv6": False,
|
||||
"is_tcp": True,
|
||||
"l4_size": len(payload),
|
||||
}
|
||||
|
||||
|
||||
def test_http_history_model():
|
||||
req1 = None
|
||||
res1 = None
|
||||
history_obj = HttpHistory(requests=[req1], responses=[res1])
|
||||
assert history_obj.requests == [req1]
|
||||
assert history_obj.responses == [res1]
|
||||
assert "HttpHistory" in repr(history_obj)
|
||||
|
||||
|
||||
def test_http_history_stream_execution():
|
||||
glob = {}
|
||||
clear_pyfilter_registry()
|
||||
|
||||
code = """
|
||||
from firegex.nfproxy import pyfilter, ACCEPT
|
||||
from firegex.nfproxy.models import HttpFullRequest, HttpFullResponse, HttpHistory
|
||||
|
||||
recorded_history = []
|
||||
|
||||
@pyfilter
|
||||
def filter_req(req: HttpFullRequest, history: HttpHistory):
|
||||
req_urls = [r.url for r in history.requests]
|
||||
resp_statuses = [r.status_code for r in history.responses]
|
||||
recorded_history.append(("req", req.url, req_urls, resp_statuses))
|
||||
return ACCEPT
|
||||
|
||||
@pyfilter
|
||||
def filter_resp(resp: HttpFullResponse, history: HttpHistory):
|
||||
req_urls = [r.url for r in history.requests]
|
||||
resp_statuses = [r.status_code for r in history.responses]
|
||||
recorded_history.append(("resp", resp.status_code, req_urls, resp_statuses))
|
||||
return ACCEPT
|
||||
"""
|
||||
|
||||
glob["__firegex_pyfilter_enabled"] = ["filter_req", "filter_resp"]
|
||||
glob["__firegex_proto"] = "http"
|
||||
exec(code, glob, glob)
|
||||
compile(glob)
|
||||
|
||||
# 1. First Request
|
||||
req1_data = b"GET /first HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
glob["__firegex_packet_info"] = create_packet_info(req1_data, is_input=True)
|
||||
handle_packet(glob)
|
||||
|
||||
# 2. First Response
|
||||
resp1_data = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"
|
||||
glob["__firegex_packet_info"] = create_packet_info(resp1_data, is_input=False)
|
||||
handle_packet(glob)
|
||||
|
||||
# 3. Second Request
|
||||
req2_data = b"GET /second HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
glob["__firegex_packet_info"] = create_packet_info(req2_data, is_input=True)
|
||||
handle_packet(glob)
|
||||
|
||||
# 4. Second Response
|
||||
resp2_data = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"
|
||||
glob["__firegex_packet_info"] = create_packet_info(resp2_data, is_input=False)
|
||||
handle_packet(glob)
|
||||
|
||||
recorded = glob["recorded_history"]
|
||||
|
||||
# 1. First Request: history is empty
|
||||
assert recorded[0] == ("req", "/first", [], [])
|
||||
|
||||
# 2. First Response: past requests contains /first
|
||||
assert recorded[1] == ("resp", "OK", ["/first"], [])
|
||||
|
||||
# 3. Second Request: past requests contains [/first], past responses contains [OK]
|
||||
assert recorded[2] == ("req", "/second", ["/first"], ["OK"])
|
||||
|
||||
# 4. Second Response: past requests contains [/first, /second], past responses contains [OK]
|
||||
assert recorded[3] == ("resp", "Not Found", ["/first", "/second"], ["OK"])
|
||||
|
||||
|
||||
def test_http_history_attribute_on_request():
|
||||
glob = {}
|
||||
clear_pyfilter_registry()
|
||||
|
||||
code = """
|
||||
from firegex.nfproxy import pyfilter, ACCEPT
|
||||
from firegex.nfproxy.models import HttpFullRequest, HttpFullResponse
|
||||
|
||||
history_lengths = []
|
||||
|
||||
@pyfilter
|
||||
def filter_req(req: HttpFullRequest):
|
||||
history_lengths.append((len(req.history.requests), len(req.history.responses)))
|
||||
return ACCEPT
|
||||
|
||||
@pyfilter
|
||||
def filter_resp(resp: HttpFullResponse):
|
||||
history_lengths.append((len(resp.history.requests), len(resp.history.responses)))
|
||||
return ACCEPT
|
||||
"""
|
||||
|
||||
glob["__firegex_pyfilter_enabled"] = ["filter_req", "filter_resp"]
|
||||
glob["__firegex_proto"] = "http"
|
||||
exec(code, glob, glob)
|
||||
compile(glob)
|
||||
|
||||
# Req 1
|
||||
glob["__firegex_packet_info"] = create_packet_info(b"GET /a HTTP/1.1\r\nHost: a\r\n\r\n", is_input=True)
|
||||
handle_packet(glob)
|
||||
|
||||
# Resp 1
|
||||
glob["__firegex_packet_info"] = create_packet_info(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", is_input=False)
|
||||
handle_packet(glob)
|
||||
|
||||
# Req 2
|
||||
glob["__firegex_packet_info"] = create_packet_info(b"GET /b HTTP/1.1\r\nHost: b\r\n\r\n", is_input=True)
|
||||
handle_packet(glob)
|
||||
|
||||
lengths = glob["history_lengths"]
|
||||
assert lengths[0] == (0, 0)
|
||||
assert lengths[1] == (1, 0)
|
||||
assert lengths[2] == (1, 1)
|
||||
|
||||
|
||||
def test_http_history_invalid_max_size():
|
||||
glob = {}
|
||||
clear_pyfilter_registry()
|
||||
|
||||
code = """
|
||||
from firegex.nfproxy import pyfilter, ACCEPT
|
||||
from firegex.nfproxy.models import HttpFullRequest
|
||||
|
||||
@pyfilter
|
||||
def filter_req(req: HttpFullRequest):
|
||||
return ACCEPT
|
||||
"""
|
||||
|
||||
glob["__firegex_pyfilter_enabled"] = ["filter_req"]
|
||||
glob["__firegex_proto"] = "http"
|
||||
glob["FGEX_MAX_HISTORY_SIZE"] = "invalid_number"
|
||||
exec(code, glob, glob)
|
||||
compile(glob)
|
||||
|
||||
glob["__firegex_packet_info"] = create_packet_info(b"GET /test HTTP/1.1\r\nHost: test\r\n\r\n", is_input=True)
|
||||
# Should not raise ValueError, should default to 100
|
||||
handle_packet(glob)
|
||||
|
||||
|
||||
def test_http_history_parameter_order():
|
||||
glob = {}
|
||||
clear_pyfilter_registry()
|
||||
|
||||
code = """
|
||||
from firegex.nfproxy import pyfilter, ACCEPT
|
||||
from firegex.nfproxy.models import HttpFullRequest, HttpHistory
|
||||
|
||||
calls_log = []
|
||||
|
||||
@pyfilter
|
||||
def filter_history_first(history: HttpHistory, req: HttpFullRequest):
|
||||
calls_log.append(("history_first", req.url, len(history.requests)))
|
||||
return ACCEPT
|
||||
"""
|
||||
|
||||
glob["__firegex_pyfilter_enabled"] = ["filter_history_first"]
|
||||
glob["__firegex_proto"] = "http"
|
||||
exec(code, glob, glob)
|
||||
compile(glob)
|
||||
|
||||
glob["__firegex_packet_info"] = create_packet_info(b"GET /first HTTP/1.1\r\nHost: test\r\n\r\n", is_input=True)
|
||||
handle_packet(glob)
|
||||
|
||||
calls = glob["calls_log"]
|
||||
# Must be called EXACTLY ONCE with 0 history elements for the first request
|
||||
assert len(calls) == 1
|
||||
assert calls[0] == ("history_first", "/first", 0)
|
||||
|
||||
|
||||
def test_http_history_property_immutability():
|
||||
glob = {}
|
||||
clear_pyfilter_registry()
|
||||
immutability_log = []
|
||||
|
||||
code = """
|
||||
from firegex.nfproxy import pyfilter, ACCEPT
|
||||
from firegex.nfproxy.models import HttpFullRequest, HttpHistory
|
||||
|
||||
@pyfilter
|
||||
def filter_req(req: HttpFullRequest, history: HttpHistory):
|
||||
if len(history.requests) > 0:
|
||||
past = history.requests[0]
|
||||
immutability_log.append({
|
||||
"past_size": past.total_size,
|
||||
"past_url": past.url,
|
||||
"curr_size": req.total_size,
|
||||
"curr_url": req.url,
|
||||
})
|
||||
return ACCEPT
|
||||
"""
|
||||
|
||||
glob["__firegex_pyfilter_enabled"] = ["filter_req"]
|
||||
glob["__firegex_proto"] = "http"
|
||||
glob["immutability_log"] = immutability_log
|
||||
exec(code, glob, glob)
|
||||
compile(glob)
|
||||
|
||||
# 1. First short request
|
||||
req1_data = b"GET /short HTTP/1.1\r\nHost: test\r\n\r\n"
|
||||
glob["__firegex_packet_info"] = create_packet_info(req1_data, is_input=True)
|
||||
handle_packet(glob)
|
||||
|
||||
# 2. Second longer request
|
||||
req2_data = b"POST /large_path HTTP/1.1\r\nHost: test\r\nContent-Length: 5\r\n\r\nHELLO"
|
||||
glob["__firegex_packet_info"] = create_packet_info(req2_data, is_input=True)
|
||||
handle_packet(glob)
|
||||
|
||||
entries = glob["immutability_log"]
|
||||
assert len(entries) == 1
|
||||
# Historical request 1 size must equal 14 (/short), current size 39 (/large_path)
|
||||
assert entries[0]["past_size"] == 14
|
||||
assert entries[0]["past_url"] == "/short"
|
||||
assert entries[0]["curr_size"] == 39
|
||||
assert entries[0]["curr_url"] == "/large_path"
|
||||
|
||||
|
||||
def test_http_history_negative_max_size():
|
||||
glob = {}
|
||||
clear_pyfilter_registry()
|
||||
|
||||
code = """
|
||||
from firegex.nfproxy import pyfilter, ACCEPT
|
||||
from firegex.nfproxy.models import HttpFullRequest
|
||||
|
||||
@pyfilter
|
||||
def filter_req(req: HttpFullRequest):
|
||||
return ACCEPT
|
||||
"""
|
||||
|
||||
glob["__firegex_pyfilter_enabled"] = ["filter_req"]
|
||||
glob["__firegex_proto"] = "http"
|
||||
glob["FGEX_MAX_HISTORY_SIZE"] = "-5"
|
||||
exec(code, glob, glob)
|
||||
compile(glob)
|
||||
|
||||
glob["__firegex_packet_info"] = create_packet_info(b"GET /test HTTP/1.1\r\nHost: test\r\n\r\n", is_input=True)
|
||||
# Should not raise ValueError for maxlen=-5, should clamp to 0
|
||||
handle_packet(glob)
|
||||
|
||||
|
||||
def test_http_history_nfproxy_import():
|
||||
from firegex.nfproxy import HttpHistory as HH1, HttpStreamHistory as HH2
|
||||
assert HH1 is HttpHistory
|
||||
assert HH2 is HttpStreamHistory
|
||||
|
||||
|
||||
def test_http_history_list_mutation():
|
||||
history_obj = HttpHistory(requests=["dummy_req"], responses=["dummy_resp"])
|
||||
reqs = history_obj.requests
|
||||
reqs.clear()
|
||||
assert len(history_obj.requests) == 1
|
||||
assert history_obj.requests == ["dummy_req"]
|
||||
|
||||
resps = history_obj.responses
|
||||
resps.append("extra_resp")
|
||||
assert len(history_obj.responses) == 1
|
||||
assert history_obj.responses == ["dummy_resp"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_http_history_model()
|
||||
test_http_history_stream_execution()
|
||||
test_http_history_attribute_on_request()
|
||||
test_http_history_invalid_max_size()
|
||||
test_http_history_parameter_order()
|
||||
test_http_history_property_immutability()
|
||||
test_http_history_negative_max_size()
|
||||
test_http_history_nfproxy_import()
|
||||
test_http_history_list_mutation()
|
||||
print("All HTTP history unit tests passed successfully!")
|
||||
|
||||
|
||||
|
||||
186
tests/tls_test.py
Normal file
186
tests/tls_test.py
Normal 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)
|
||||
@ -79,6 +79,14 @@ class FiregexAPI:
|
||||
def reset(self, delete: bool):
|
||||
self.s.post(f"{self.address}api/reset", json={"delete":delete})
|
||||
|
||||
def export_backup(self):
|
||||
req = self.s.get(f"{self.address}api/export")
|
||||
return req.json()
|
||||
|
||||
def import_backup(self, backup: dict):
|
||||
req = self.s.post(f"{self.address}api/import", json=backup)
|
||||
return verify(req)
|
||||
|
||||
def nfregex_get_services(self):
|
||||
req = self.s.get(f"{self.address}api/nfregex/services")
|
||||
return req.json()
|
||||
@ -103,10 +111,6 @@ class FiregexAPI:
|
||||
req = self.s.put(f"{self.address}api/nfregex/services/{service_id}/rename" , json={"name":newname})
|
||||
return verify(req)
|
||||
|
||||
def nfregex_settings_service(self,service_id: str, port: int, proto: str, ip_int: str, fail_open: bool):
|
||||
req = self.s.put(f"{self.address}api/nfregex/services/{service_id}/settings" , json={"port":port, "proto":proto, "ip_int":ip_int, "fail_open":fail_open})
|
||||
return verify(req)
|
||||
|
||||
def nfregex_get_service_regexes(self,service_id: str):
|
||||
req = self.s.get(f"{self.address}api/nfregex/services/{service_id}/regexes")
|
||||
data = req.json()
|
||||
@ -131,17 +135,24 @@ class FiregexAPI:
|
||||
req = self.s.post(f"{self.address}api/nfregex/regexes/{regex_id}/disable")
|
||||
return verify(req)
|
||||
|
||||
def nfregex_add_regex(self, service_id: str, regex: str, mode: str, active: bool, is_case_sensitive: bool):
|
||||
def nfregex_add_regex(self, service_id: str, regex: str, mode: str = "B", active: bool = True, is_case_sensitive: bool = True):
|
||||
if isinstance(regex, str):
|
||||
regex = regex.encode()
|
||||
req = self.s.post(f"{self.address}api/nfregex/regexes",
|
||||
json={"service_id": service_id, "regex": base64.b64encode(regex).decode(), "mode": mode, "active": active, "is_case_sensitive": is_case_sensitive})
|
||||
return verify(req)
|
||||
|
||||
def nfregex_add_service(self, name: str, port: int, proto: str, ip_int: str, fail_open: bool = False):
|
||||
req = self.s.post(f"{self.address}api/nfregex/services" ,
|
||||
json={"name":name,"port":port, "proto": proto, "ip_int": ip_int, "fail_open": fail_open})
|
||||
return req.json()["service_id"] if verify(req) else False
|
||||
def nfregex_add_service(self, name: str, port: int, proto: str, ip_int: str, fail_open: bool = False, target_type: str = "flow", tls_stream_id: str | None = None):
|
||||
payload = {"name": name, "port": port, "proto": proto, "ip_int": ip_int, "fail_open": fail_open, "target_type": target_type, "tls_stream_id": tls_stream_id}
|
||||
req = self.s.post(f"{self.address}api/nfregex/services", json=payload)
|
||||
return req.json()["service_id"] if verify(req) else False
|
||||
|
||||
def nfregex_settings_service(self, service_id: str, port: int, ip_int: str, fail_open: bool, proto: str | None = None, target_type: str = "flow", tls_stream_id: str | None = None):
|
||||
payload = {"port": port, "ip_int": ip_int, "fail_open": fail_open, "target_type": target_type, "tls_stream_id": tls_stream_id}
|
||||
if proto is not None:
|
||||
payload["proto"] = proto
|
||||
req = self.s.put(f"{self.address}api/nfregex/services/{service_id}/settings", json=payload)
|
||||
return verify(req)
|
||||
|
||||
def nfregex_get_metrics(self):
|
||||
req = self.s.get(f"{self.address}api/nfregex/metrics")
|
||||
@ -205,8 +216,11 @@ class FiregexAPI:
|
||||
req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/rename" , json={"name":newname})
|
||||
return verify(req)
|
||||
|
||||
def nfproxy_settings_service(self,service_id: str, port: int, ip_int: str, fail_open: bool):
|
||||
req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/settings" , json={"port":port, "ip_int":ip_int, "fail_open":fail_open})
|
||||
def nfproxy_settings_service(self,service_id: str, port: int, ip_int: str, fail_open: bool, proto: str | None = None):
|
||||
payload = {"port":port, "ip_int":ip_int, "fail_open":fail_open}
|
||||
if proto is not None:
|
||||
payload["proto"] = proto
|
||||
req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/settings" , json=payload)
|
||||
return verify(req)
|
||||
|
||||
def nfproxy_get_service_pyfilters(self,service_id: str):
|
||||
@ -225,10 +239,10 @@ class FiregexAPI:
|
||||
req = self.s.post(f"{self.address}api/nfproxy/services/{service_id}/pyfilters/{filter_name}/disable")
|
||||
return verify(req)
|
||||
|
||||
def nfproxy_add_service(self, name: str, port: int, proto: str, ip_int: str, fail_open: bool = False):
|
||||
req = self.s.post(f"{self.address}api/nfproxy/services" ,
|
||||
json={"name":name,"port":port, "proto": proto, "ip_int": ip_int, "fail_open": fail_open})
|
||||
return req.json()["service_id"] if verify(req) else False
|
||||
def nfproxy_add_service(self, name: str, port: int, proto: str, ip_int: str, fail_open: bool = False, target_type: str = "flow", tls_stream_id: str | None = None):
|
||||
payload = {"name": name, "port": port, "proto": proto, "ip_int": ip_int, "fail_open": fail_open, "target_type": target_type, "tls_stream_id": tls_stream_id}
|
||||
req = self.s.post(f"{self.address}api/nfproxy/services", json=payload)
|
||||
return req.json()["service_id"] if verify(req) else False
|
||||
|
||||
def nfproxy_get_code(self, service_id: str):
|
||||
req = self.s.get(f"{self.address}api/nfproxy/services/{service_id}/code")
|
||||
@ -236,4 +250,37 @@ class FiregexAPI:
|
||||
|
||||
def nfproxy_set_code(self, service_id: str, code: str):
|
||||
req = self.s.put(f"{self.address}api/nfproxy/services/{service_id}/code", json={"code":code})
|
||||
return verify(req)
|
||||
return verify(req)
|
||||
|
||||
#TLS
|
||||
def tls_get_streams(self):
|
||||
req = self.s.get(f"{self.address}api/tls/streams")
|
||||
return req.json()
|
||||
|
||||
def tls_add_stream(self, name: str, ip_int: str, port: int, cert: str, key: str):
|
||||
payload = {"name": name, "ip_int": ip_int, "port": port, "cert": cert, "key": key}
|
||||
req = self.s.post(f"{self.address}api/tls/streams", json=payload)
|
||||
return req.json()["stream_id"] if verify(req) else False
|
||||
|
||||
def tls_delete_stream(self, stream_id: str):
|
||||
req = self.s.delete(f"{self.address}api/tls/streams/{stream_id}")
|
||||
return verify(req)
|
||||
|
||||
def tls_start_stream(self, stream_id: str):
|
||||
req = self.s.post(f"{self.address}api/tls/streams/{stream_id}/start")
|
||||
return verify(req)
|
||||
|
||||
def tls_stop_stream(self, stream_id: str):
|
||||
req = self.s.post(f"{self.address}api/tls/streams/{stream_id}/stop")
|
||||
return verify(req)
|
||||
|
||||
def tls_edit_stream(self, stream_id: str, name: str | None = None, ip_int: str | None = None, port: int | None = None, cert: str | None = None, key: str | None = None):
|
||||
payload = {k: v for k, v in {"name": name, "ip_int": ip_int, "port": port, "cert": cert, "key": key}.items() if v is not None}
|
||||
req = self.s.put(f"{self.address}api/tls/streams/{stream_id}", json=payload)
|
||||
return verify(req)
|
||||
|
||||
def tls_get_stream(self, stream_id: str):
|
||||
for stream in self.tls_get_streams():
|
||||
if stream["id"] == stream_id:
|
||||
return stream
|
||||
return None
|
||||
@ -1,18 +1,37 @@
|
||||
import queue
|
||||
from multiprocessing import Process, Queue
|
||||
import socket
|
||||
import ssl
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
|
||||
def _start_tcp_server(port, server_queue: Queue, ipv6, verbose):
|
||||
def _start_tcp_server(port, server_queue: Queue, ipv6, verbose, tls_cert=None, tls_key=None):
|
||||
sock = socket.socket(
|
||||
socket.AF_INET6 if ipv6 else socket.AF_INET, socket.SOCK_STREAM
|
||||
)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("::1" if ipv6 else "127.0.0.1", port))
|
||||
sock.listen(8)
|
||||
|
||||
tls_context = None
|
||||
if tls_cert and tls_key:
|
||||
tls_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".crt", delete=False) as cert_file, \
|
||||
tempfile.NamedTemporaryFile(mode="w", suffix=".key", delete=False) as key_file:
|
||||
cert_file.write(tls_cert)
|
||||
key_file.write(tls_key)
|
||||
cert_path, key_path = cert_file.name, key_file.name
|
||||
tls_context.load_cert_chain(cert_path, key_path)
|
||||
|
||||
while True:
|
||||
connection, address = sock.accept()
|
||||
if tls_context:
|
||||
try:
|
||||
connection = tls_context.wrap_socket(connection, server_side=True)
|
||||
except ssl.SSLError:
|
||||
connection.close()
|
||||
continue
|
||||
while True:
|
||||
try:
|
||||
buf = connection.recv(4096)
|
||||
@ -40,26 +59,35 @@ def _start_tcp_server(port, server_queue: Queue, ipv6, verbose):
|
||||
|
||||
|
||||
class TcpServer:
|
||||
def __init__(self, port, ipv6, proxy_port=None, verbose=False):
|
||||
def __init__(self, port, ipv6, proxy_port=None, verbose=False, tls_cert=None, tls_key=None):
|
||||
self.proxy_port = proxy_port
|
||||
self.ipv6 = ipv6
|
||||
self.port = port
|
||||
self.verbose = verbose
|
||||
self.tls_cert = tls_cert
|
||||
self.tls_key = tls_key
|
||||
self._server_data_queue = Queue()
|
||||
self._regen_process()
|
||||
|
||||
def _regen_process(self):
|
||||
self.server = Process(
|
||||
target=_start_tcp_server,
|
||||
args=[self.port, self._server_data_queue, self.ipv6, self.verbose],
|
||||
args=[self.port, self._server_data_queue, self.ipv6, self.verbose, self.tls_cert, self.tls_key],
|
||||
)
|
||||
|
||||
def start(self):
|
||||
self.server.start()
|
||||
|
||||
def stop(self):
|
||||
self.server.terminate()
|
||||
self.server.join()
|
||||
if self.server and self.server.is_alive():
|
||||
try:
|
||||
self.server.terminate()
|
||||
self.server.join()
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
except AttributeError:
|
||||
pass
|
||||
self.server = None
|
||||
self._regen_process()
|
||||
|
||||
def connect_client(self):
|
||||
|
||||
69
tests/utils/tls_helpers.py
Normal file
69
tests/utils/tls_helpers.py
Normal 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
|
||||
@ -49,8 +49,15 @@ class UdpServer:
|
||||
self.server.start()
|
||||
|
||||
def stop(self):
|
||||
self.server.terminate()
|
||||
self.server.join()
|
||||
if self.server:
|
||||
try:
|
||||
self.server.terminate()
|
||||
self.server.join()
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
except AttributeError:
|
||||
pass
|
||||
self.server = None
|
||||
self._regen_process()
|
||||
|
||||
def connect_client(self):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user