diff --git a/.github/workflows/pypi-publish-fgex.yml b/.github/workflows/pypi-publish-fgex.yml deleted file mode 100644 index 5a6e509..0000000 --- a/.github/workflows/pypi-publish-fgex.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml deleted file mode 100644 index 0f03adc..0000000 --- a/.github/workflows/pypi-publish.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/release.yml similarity index 76% rename from .github/workflows/docker-image.yml rename to .github/workflows/release.yml index 86ee84a..b785580 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 2ad56db..82c5647 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ **/npm-debug.log* **/yarn-debug.log* **/yarn-error.log* +.claude \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f883c89 --- /dev/null +++ b/CLAUDE.md @@ -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 -m tcp [--ipv6] [--tls] +python3 nfproxy_test.py -p [--ipv6] [--tls] +python3 ph_test.py -p -m tcp|udp [--ipv6] +python3 tls_test.py -p [--ipv6] +python3 api_test.py -p +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/` 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/.py` — FastAPI endpoints + its own SQLite DB, via `utils.sqlite.SQLite`. +- `backend/modules//models.py` — plain `Service`/filter model, `from_dict`. +- `backend/modules//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//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//` (routes) + `components//` (ServiceRow, AddEditService, `utils.ts` with query key + API wrapper) per module, mirroring the backend split. Detail drill-down lives at `pages//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. diff --git a/Dockerfile b/Dockerfile index a9474a8..a90e776 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 7798b8a..111b461 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/backend/app.py b/backend/app.py index c764a78..af9ce58 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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) diff --git a/backend/binsrc/classes/nfqueue.cpp b/backend/binsrc/classes/nfqueue.cpp index c2239ad..4db983c 100644 --- a/backend/binsrc/classes/nfqueue.cpp +++ b/backend/binsrc/classes/nfqueue.cpp @@ -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(); diff --git a/backend/binsrc/nfproxy.cpp b/backend/binsrc/nfproxy.cpp index cbb6c38..613b922 100644 --- a/backend/binsrc/nfproxy.cpp +++ b/backend/binsrc/nfproxy.cpp @@ -1,3 +1,4 @@ +#define DEBUG #define PY_SSIZE_T_CLEAN #include diff --git a/backend/binsrc/pyproxy/pyproxy.cpp b/backend/binsrc/pyproxy/pyproxy.cpp index 5cf1e35..8169f66 100644 --- a/backend/binsrc/pyproxy/pyproxy.cpp +++ b/backend/binsrc/pyproxy/pyproxy.cpp @@ -97,7 +97,7 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue { pyq->pkt->drop();// This is needed because the callback has to take the updated pkt pointer! } - void filter_action(NfQueue::PktRequest* pkt, Stream& stream, const string& data){ + void filter_action(NfQueue::PktRequest* 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 { 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 { } - 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 diff --git a/backend/binsrc/pyproxy/stream_ctx.cpp b/backend/binsrc/pyproxy/stream_ctx.cpp index 9c249bc..aac781d 100644 --- a/backend/binsrc/pyproxy/stream_ctx.cpp +++ b/backend/binsrc/pyproxy/stream_ctx.cpp @@ -105,7 +105,8 @@ struct pyfilter_ctx { py_filter_response handle_packet( NfQueue::PktRequest* 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 matching_map; struct stream_ctx { matching_map streams_ctx; + NfQueue::tcp_ack_map tcp_ack_ctx; void clean_stream_by_id(stream_id sid){ diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index e95b9bc..9cad0e3 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -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 diff --git a/backend/modules/nfproxy/firegex.py b/backend/modules/nfproxy/firegex.py index 9ef3fef..74c4fbf 100644 --- a/backend/modules/nfproxy/firegex.py +++ b/backend/modules/nfproxy/firegex.py @@ -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 diff --git a/backend/modules/nfproxy/firewall.py b/backend/modules/nfproxy/firewall.py index 045f9ab..04bcdd6 100644 --- a/backend/modules/nfproxy/firewall.py +++ b/backend/modules/nfproxy/firewall.py @@ -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: diff --git a/backend/modules/nfproxy/models.py b/backend/modules/nfproxy/models.py index 140f47f..19880db 100644 --- a/backend/modules/nfproxy/models.py +++ b/backend/modules/nfproxy/models.py @@ -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): diff --git a/backend/modules/nfproxy/nftables.py b/backend/modules/nfproxy/nftables.py index 6aa7c71..9f80d47 100644 --- a/backend/modules/nfproxy/nftables.py +++ b/backend/modules/nfproxy/nftables.py @@ -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, diff --git a/backend/modules/nfregex/firegex.py b/backend/modules/nfregex/firegex.py index 3d62666..d73b80f 100644 --- a/backend/modules/nfregex/firegex.py +++ b/backend/modules/nfregex/firegex.py @@ -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() diff --git a/backend/modules/nfregex/firewall.py b/backend/modules/nfregex/firewall.py index ec9231e..6ae3d58 100644 --- a/backend/modules/nfregex/firewall.py +++ b/backend/modules/nfregex/firewall.py @@ -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: diff --git a/backend/modules/nfregex/models.py b/backend/modules/nfregex/models.py index c06daa6..d2fa3d2 100644 --- a/backend/modules/nfregex/models.py +++ b/backend/modules/nfregex/models.py @@ -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): diff --git a/backend/modules/nfregex/nftables.py b/backend/modules/nfregex/nftables.py index baaab10..997fc8a 100644 --- a/backend/modules/nfregex/nftables.py +++ b/backend/modules/nfregex/nftables.py @@ -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) \ No newline at end of file diff --git a/backend/modules/porthijack/firewall.py b/backend/modules/porthijack/firewall.py index 03004b2..23c59a3 100644 --- a/backend/modules/porthijack/firewall.py +++ b/backend/modules/porthijack/firewall.py @@ -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): diff --git a/backend/modules/tls/__init__.py b/backend/modules/tls/__init__.py new file mode 100644 index 0000000..c85f177 --- /dev/null +++ b/backend/modules/tls/__init__.py @@ -0,0 +1 @@ +# TLS Decrypt Module diff --git a/backend/modules/tls/manager.py b/backend/modules/tls/manager.py new file mode 100644 index 0000000..6d9d087 --- /dev/null +++ b/backend/modules/tls/manager.py @@ -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';") diff --git a/backend/modules/tls/nftables.py b/backend/modules/tls/nftables.py new file mode 100644 index 0000000..3a307a5 --- /dev/null +++ b/backend/modules/tls/nftables.py @@ -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() diff --git a/backend/modules/tls/nginx.py b/backend/modules/tls/nginx.py new file mode 100644 index 0000000..5c210e4 --- /dev/null +++ b/backend/modules/tls/nginx.py @@ -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) diff --git a/backend/modules/tls/service.py b/backend/modules/tls/service.py new file mode 100644 index 0000000..1ad73fb --- /dev/null +++ b/backend/modules/tls/service.py @@ -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 diff --git a/backend/routers/firewall.py b/backend/routers/firewall.py index a16560c..c463e87 100644 --- a/backend/routers/firewall.py +++ b/backend/routers/firewall.py @@ -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) diff --git a/backend/routers/nfproxy.py b/backend/routers/nfproxy.py index 4cd1076..83763cc 100644 --- a/backend/routers/nfproxy.py +++ b/backend/routers/nfproxy.py @@ -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() diff --git a/backend/routers/nfregex.py b/backend/routers/nfregex.py index 66bc2d0..25b0bf2 100644 --- a/backend/routers/nfregex.py +++ b/backend/routers/nfregex.py @@ -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'} diff --git a/backend/routers/porthijack.py b/backend/routers/porthijack.py index ced2c10..1c434f5 100644 --- a/backend/routers/porthijack.py +++ b/backend/routers/porthijack.py @@ -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) diff --git a/backend/routers/tls.py b/backend/routers/tls.py new file mode 100644 index 0000000..5c2544e --- /dev/null +++ b/backend/routers/tls.py @@ -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"} diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py index 650c6bb..57d16a0 100644 --- a/backend/utils/__init__.py +++ b/backend/utils/__init__.py @@ -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 diff --git a/backend/utils/sqlite.py b/backend/utils/sqlite.py index 430dbd6..6c03ca2 100644 --- a/backend/utils/sqlite.py +++ b/backend/utils/sqlite.py @@ -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 diff --git a/docs/Firegex_Screenshot.png b/docs/Firegex_Screenshot.png index 2532aba..06fa7ed 100644 Binary files a/docs/Firegex_Screenshot.png and b/docs/Firegex_Screenshot.png differ diff --git a/fgex-lib/firegex/nfproxy/__init__.py b/fgex-lib/firegex/nfproxy/__init__.py index ae5d3e8..67fc9e2 100644 --- a/fgex-lib/firegex/nfproxy/__init__.py +++ b/fgex-lib/firegex/nfproxy/__init__.py @@ -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" ] \ No newline at end of file diff --git a/fgex-lib/firegex/nfproxy/models/__init__.py b/fgex-lib/firegex/nfproxy/models/__init__.py index c990fcf..c5dffb2 100644 --- a/fgex-lib/firegex/nfproxy/models/__init__.py +++ b/fgex-lib/firegex/nfproxy/models/__init__.py @@ -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", ] + diff --git a/fgex-lib/firegex/nfproxy/models/http.py b/fgex-lib/firegex/nfproxy/models/http.py index 305f694..7967ef8 100644 --- a/fgex-lib/firegex/nfproxy/models/http.py +++ b/fgex-lib/firegex/nfproxy/models/http.py @@ -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"" + + +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"" + class HttpRequestHeader(HttpRequest): """ HTTP Request Header handler diff --git a/frontend/bun.lock b/frontend/bun.lock index d34be36..7d376c8 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -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=="], diff --git a/frontend/package.json b/frontend/package.json index fe134d8..4e277cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 01ad226..05e49b9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 }) => ( + + + + Firegex logo + [Fi]*regex + + {children} + + +) + +const StatusCard = ({ title, description, color, children }: { title: string, description: string, color: string, children?: React.ReactNode }) => ( + + + + + + + {title} + {description} + {children} + + + +) + function App() { const [loading, setLoading] = useState(true); - const [systemStatus, setSystemStatus] = useState({status:"", loggined:false}) - const [reqError, setReqError] = useState() - const [error, setError] = useState() + const [systemStatus, setSystemStatus] = useState({ status: "", loggined: false }) + const [reqError, setReqError] = useState() + const [error, setError] = useState() 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 - }else if (reqError){ - return - Error launching Firegex! 🔥 - - Error communicating with backend - - Error: {reqError} - - - - }else if (systemStatus.status === "init"){ - - const submitRequest = async (values:PasswordSend) => { + if (loading) { + return + } else if (reqError) { + return + {reqError} + + + } 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 - Setup: Choose the password for access to the firewall 🔒 - -
- - - - - - - {error?<> - } color="red" onClose={()=>{setError(null)}}> - Error: {error} - :null} -
- }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 - Welcome to Firegex 🔥 + return + Choose the password for access to the firewall 🔒 - Before you use the firewall, insert the password 🔒 +
+ + + + + + {error && <> + + } color="red" onClose={() => { setError(null) }}> + Error: {error} + } +
+ } 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 + Insert the password to unlock the firewall 🔒 -
- - - - - - - {error?<> - } color="red" onClose={()=>{setError(null)}}> - Error: {error} - :null} -
- }else if (systemStatus.status === "run" && systemStatus.loggined){ +
+ + + + + + {error && <> + + } color="red" onClose={() => { setError(null) }}> + Error: {error} + } + + } else if (systemStatus.status === "run" && systemStatus.loggined) { return - }else{ - return - Error launching Firegex! 🔥 - - Error communicating with backend - + } else { + return } } -const PageRouting = ({ getStatus }:{ getStatus:()=>void }) => { +import TLSDecrypt from './pages/TLSDecrypt'; + +const PageRouting = ({ getStatus }: { getStatus: () => void }) => { return - }> + }> + } /> } > } /> @@ -177,9 +200,9 @@ const PageRouting = ({ getStatus }:{ getStatus:()=>void }) => { } /> } /> - } /> - - + } /> + + } diff --git a/frontend/src/components/AddNewRegex.tsx b/frontend/src/components/AddNewRegex.tsx index 5437ec5..cbff378 100644 --- a/frontend/src/components/AddNewRegex.tsx +++ b/frontend/src/components/AddNewRegex.tsx @@ -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 -
+ return + - - - - - - ({ + 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} + /> + ) : ( + <> + + + + + TLS Certificate (PEM format) {isCreatingNew && form.values.tls_enabled && *} + handleFileUpload(file, 'tls_cert')} accept=".pem,.crt,.cer,.txt,*"> + {(props) => } + + +