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/backend/app.py b/backend/app.py index 6cf5d14..3b2129e 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 @@ -11,7 +12,6 @@ from utils.sqlite import SQLite from utils import API_VERSION, FIREGEX_PORT, FIREGEX_HOST, FIREGEX_SOCKET, JWT_ALGORITHM, get_interfaces, socketio_emit, DEBUG, SysctlManager, NORELOAD from utils.loader import frontend_deploy, load_routers from utils.models import ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel -from utils.certs import CERTIFICATES_SCHEMA from contextlib import asynccontextmanager from fastapi.middleware.cors import CORSMiddleware import socketio @@ -19,7 +19,7 @@ from socketio.exceptions import ConnectionRefusedError import hashlib # DB init -db = SQLite('db/firegex.db', CERTIFICATES_SCHEMA) +db = SQLite('db/firegex.db') sysctl = SysctlManager({ "net.ipv4.conf.all.forwarding": True, "net.ipv6.conf.all.forwarding": True, @@ -219,6 +219,62 @@ async def reset_firegex(form: ResetRequest): await refresh_frontend() return {'status': 'ok'} +@api.get('/export') +async def export_db(): + """Export all configuration databases as JSON""" + dbs = {} + if not os.path.exists('db'): + return dbs + for f in os.listdir('db'): + if f.endswith('.db'): + temp_db = SQLite(os.path.join('db', f)) + dbs[f] = temp_db.dump() + + # Export nfproxy filters + if os.path.exists('db/nfproxy_filters'): + dbs['nfproxy_filters'] = {} + for f in os.listdir('db/nfproxy_filters'): + if f.endswith('.py'): + with open(os.path.join('db/nfproxy_filters', f), 'rb') as script_file: + dbs['nfproxy_filters'][f] = base64.b64encode(script_file.read()).decode('utf-8') + return dbs + +@api.post('/import', response_model=StatusMessageModel) +async def import_db(data: dict): + """Import all configuration databases from JSON""" + if not os.path.exists('db'): + os.makedirs('db') + + # Backups never contain the password/secret (export_db strips them), so preserve + # the current session's own values instead of losing them on import. + current_password = db.get("password") + current_secret = db.get("secret") + + for db_file, db_data in data.items(): + if db_file.endswith('.db'): + temp_db = SQLite(os.path.join('db', db_file)) + # Just load the data, we assume schemas exist or will be matched + # We don't delete the whole file to preserve DB_VERSION and schemas + temp_db.load(db_data) + elif db_file == 'nfproxy_filters': + if not os.path.exists('db/nfproxy_filters'): + os.makedirs('db/nfproxy_filters') + for f, script_content in db_data.items(): + if f.endswith('.py'): + with open(os.path.join('db/nfproxy_filters', f), 'wb') as script_file: + script_file.write(base64.b64decode(script_content)) + + if current_password is not None: + db.put("password", current_password) + if current_secret is not None: + db.put("secret", current_secret) + + # Restart the application state + await shutdown_main() + await startup_main() + + return {'status': 'ok'} + app.include_router(api) frontend_deploy(app) 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/modules/nfproxy/firegex.py b/backend/modules/nfproxy/firegex.py index 6999427..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 @@ -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 6f2cb5e..04bcdd6 100644 --- a/backend/modules/nfproxy/firewall.py +++ b/backend/modules/nfproxy/firewall.py @@ -2,10 +2,9 @@ import asyncio from modules.nfproxy.firegex import FiregexInterceptor from modules.nfproxy.nftables import FiregexTables, FiregexFilter from modules.nfproxy.models import Service, PyFilter -from modules.nfproxy.nginx import sync_nginx_state +from utils.sqlite import SQLite from utils.sqlite import SQLite from utils import run_func -from utils.certs import CertsDB, ip_parse as certs_ip_parse class STATUS: STOP = "stop" @@ -51,16 +50,17 @@ class ServiceManager: def __update_status_db(self, status): self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id) - async def next(self,to): + async def next(self,to,persist:bool=True): async with self.lock: if to == STATUS.STOP: - await self.stop() + await self.stop(persist=persist) if to == STATUS.ACTIVE: await self.restart() - def _set_status(self,status): + def _set_status(self,status,persist:bool=True): self.status = status - self.__update_status_db(status) + if persist: + self.__update_status_db(status) def read_outstrem_buffer(self): if self.interceptor: @@ -74,17 +74,13 @@ class ServiceManager: self.interceptor = await FiregexInterceptor.start(self.srv, outstream_func=self.outstream_function, exception_func=self.exception_function) await self._update_filters_from_db() self._set_status(STATUS.ACTIVE) - if self.srv.tls_enabled: - sync_nginx_state() - async def stop(self): + async def stop(self,persist:bool=True): nft.delete(self.srv) if self.interceptor: await self.interceptor.stop() self.interceptor = None - self._set_status(STATUS.STOP) - if self.srv.tls_enabled: - sync_nginx_state() + self._set_status(STATUS.STOP,persist=persist) async def restart(self): await self.stop() @@ -94,17 +90,7 @@ class ServiceManager: async with self.lock: await self._update_filters_from_db() - async def update_tls_config(self): - async with self.lock: - srv_dict = self.db.query("SELECT * FROM services WHERE service_id = ?;", self.srv.id)[0] - if srv_dict.get("tls_enabled"): - cert, key = CertsDB().get_cert_and_key(srv_dict["ip_int"], srv_dict["port"]) - srv_dict["tls_cert"] = cert - srv_dict["tls_key"] = key - self.srv = Service.from_dict(srv_dict) - if self.status == STATUS.ACTIVE: - await self.restart() - sync_nginx_state() + class FirewallManager: def __init__(self, db:SQLite, outstream_func=None, exception_func=None): @@ -116,13 +102,16 @@ class FirewallManager: async def close(self): for key in list(self.service_table.keys()): - await self.remove(key) - sync_nginx_state() + try: + await self.remove(key, persist=False) + except Exception: + # Don't let one broken service block shutdown of the others + self.service_table.pop(key, None) - async def remove(self,srv_id): - async with self.lock: + async def remove(self,srv_id,persist:bool=True): + async with self.lock: if srv_id in self.service_table: - await self.service_table[srv_id].next(STATUS.STOP) + await self.service_table[srv_id].next(STATUS.STOP,persist=persist) del self.service_table[srv_id] async def init(self): @@ -132,20 +121,13 @@ class FirewallManager: async def reload(self): async with self.lock: services = self.db.query('SELECT * FROM services;') - tls_services = [s for s in services if s.get("tls_enabled") and s["service_id"] not in self.service_table] - certs_map = CertsDB().get_multiple_certs_and_keys(tls_services) if tls_services else {} for srv in services: if srv["service_id"] in self.service_table: continue - if srv.get("tls_enabled"): - cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None)) - srv["tls_cert"] = cert - srv["tls_key"] = key srv_obj = Service.from_dict(srv) self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db, outstream_func=self.outstream_function, exception_func=self.exception_function) await self.service_table[srv_obj.id].next(srv_obj.status) - sync_nginx_state() def get(self,srv_id) -> ServiceManager: if srv_id in self.service_table: diff --git a/backend/modules/nfproxy/models.py b/backend/modules/nfproxy/models.py index ad6389e..19880db 100644 --- a/backend/modules/nfproxy/models.py +++ b/backend/modules/nfproxy/models.py @@ -3,12 +3,13 @@ class Service: self, service_id: str, status: str, - port: int, + port: int | None, name: str, proto: str, - ip_int: str, + ip_int: str | None, fail_open: bool, - tls_enabled: bool = False, + target_type: str = "flow", + tls_stream_id: str | None = None, tls_cert: str | None = None, tls_key: str | None = None, **other, @@ -20,7 +21,8 @@ class Service: self.proto = proto self.ip_int = ip_int self.fail_open = fail_open - self.tls_enabled = bool(tls_enabled) + self.target_type = target_type + self.tls_stream_id = tls_stream_id self.tls_cert = tls_cert self.tls_key = tls_key diff --git a/backend/modules/nfproxy/nftables.py b/backend/modules/nfproxy/nftables.py index 7f8a87e..9f80d47 100644 --- a/backend/modules/nfproxy/nftables.py +++ b/backend/modules/nfproxy/nftables.py @@ -1,6 +1,6 @@ from modules.nfproxy.models import Service from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json -from modules.nfproxy.nginx import get_tls_ports +from modules.tls.manager import get_tls_ports, TLSManager def convert_protocol_to_l4(proto:str): if proto == "tcp": @@ -10,6 +10,20 @@ def convert_protocol_to_l4(proto:str): else: raise Exception("Invalid protocol") +def resolve_target(srv: Service) -> tuple[str, int] | None: + """Returns the (ip_int, port) a service's intercept rule should actually match. + For target_type=='tls' this is the loopback clear_port where nginx exposes the + decrypted plaintext, not the service's own (real backend) ip_int/port. Returns + None if the linked TLS stream can't be resolved (e.g. it was deleted).""" + if srv.target_type == "tls": + tls_stream = TLSManager().get_stream(srv.tls_stream_id) + if not tls_stream: + return None + _, clear_port = get_tls_ports(tls_stream["ip_int"], tls_stream["port"]) + loopback_ip = "::1" if ip_family(tls_stream["ip_int"]) == "ip6" else "127.0.0.1" + return loopback_ip, clear_port + return srv.ip_int, srv.port + class FiregexFilter: def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int): self.id = id @@ -19,18 +33,20 @@ class FiregexFilter: self.ip_int = str(ip_int) def __eq__(self, o: object) -> bool: - if isinstance(o, FiregexFilter) or isinstance(o, Service): - return self.port == o.port and self.proto == convert_protocol_to_l4(o.proto) and ip_parse(self.ip_int) == ip_parse(o.ip_int) + if isinstance(o, FiregexFilter): + return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int) + if isinstance(o, Service): + return self.matches(o) return False def matches(self, srv: Service) -> bool: - if self.port == srv.port and ip_parse(self.ip_int) == ip_parse(srv.ip_int): - return True - ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port) - loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1" - if ip_parse(self.ip_int) == ip_parse(loopback_ip) and self.port in (ssl_port, clear_port): - return True - return False + if self.proto != convert_protocol_to_l4(srv.proto): + return False + target = resolve_target(srv) + if target is None: + return False + target_ip, target_port = target + return self.port == target_port and ip_parse(self.ip_int) == ip_parse(target_ip) class FiregexTables(NFTableManager): input_chain = "nfproxy_input" @@ -56,103 +72,58 @@ class FiregexTables(NFTableManager): "hook":"postrouting", "prio":108, "policy":"accept" - }}}, - {"add":{"chain":{ #NAT chain for DNAT - "family":"inet", - "table":self.table_name, - "name":self.nat_chain, - "type":"nat", - "hook":"prerouting", - "prio":-100, - "policy":"accept" }}} ],[ {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, - {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}}, - {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}}, ]) def add(self, srv:Service, queue_range): - + for ele in self.get(): if ele.matches(srv): return - + + target = resolve_target(srv) + if target is None: + return + target_ip, target_port = target + init, end = queue_range if init > end: init, end = end, init - if srv.tls_enabled: - ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port) - loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1" - self.cmd( - { "insert":{ "rule": { # Redirect TLS traffic to local Nginx SSL proxy - "family": "inet", - "table": self.table_name, - "chain": self.nat_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, - {'match': {'left': {'payload': {'protocol': convert_protocol_to_l4(str(srv.proto)), 'field': 'dport'}}, 'op': '==', 'right': int(srv.port)}}, - {'match': {'left': {'meta': {'key': 'iifname'}}, 'op': '!=', 'right': 'lo'}}, - {'dnat': {'family': ip_family(srv.ip_int), 'addr': loopback_ip, 'port': int(ssl_port)}} + self.cmd( + { "insert":{ "rule": { # Send non-TLS outbound traffic to NFQUEUE (Client -> Server) + "family": "inet", + "table": self.table_name, + "chain": self.output_chain, + "expr": [ + {'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}}, + {'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "dport"}}, "op": "==", "right": int(target_port)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + ] + }}}, + {"insert":{"rule":{ # Send non-TLS inbound traffic to NFQUEUE (Server -> Client) + "family": "inet", + "table": self.table_name, + "chain": self.input_chain, + "expr": [ + {'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}}, + {'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "sport"}}, "op": "==", "right": int(target_port)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} ] - }}}, - {"insert":{"rule":{ # Send cleartext inbound traffic to NFQUEUE - "family": "inet", - "table": self.table_name, - "chain": self.input_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': loopback_ip}}, - {'match': {'left': {'payload': {'protocol': convert_protocol_to_l4(str(srv.proto)), 'field': 'dport'}}, 'op': '==', 'right': int(clear_port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}}, - {"insert":{"rule":{ # Send cleartext outbound traffic to NFQUEUE - "family": "inet", - "table": self.table_name, - "chain": self.output_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': loopback_ip}}, - {'match': {'left': {'payload': {'protocol': convert_protocol_to_l4(str(srv.proto)), 'field': 'sport'}}, 'op': '==', 'right': int(clear_port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}} - ) - else: - self.cmd( - { "insert":{ "rule": { # Send non-TLS outbound traffic to NFQUEUE - "family": "inet", - "table": self.table_name, - "chain": self.output_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, - {'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "sport"}}, "op": "==", "right": int(srv.port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}}, - {"insert":{"rule":{ # Send non-TLS inbound traffic to NFQUEUE - "family": "inet", - "table": self.table_name, - "chain": self.input_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, - {'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "dport"}}, "op": "==", "right": int(srv.port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}} - ) + }}} + ) def get(self) -> list[FiregexFilter]: res = [] - for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain,self.nat_chain]): + for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]): try: ip_int = None if isinstance(filter["expr"][0]["match"]["right"],str): diff --git a/backend/modules/nfregex/firewall.py b/backend/modules/nfregex/firewall.py index 8ffec45..6ae3d58 100644 --- a/backend/modules/nfregex/firewall.py +++ b/backend/modules/nfregex/firewall.py @@ -3,8 +3,6 @@ from modules.nfregex.firegex import FiregexInterceptor, RegexFilter from modules.nfregex.nftables import FiregexTables, FiregexFilter from modules.nfregex.models import Regex, Service from utils.sqlite import SQLite -from modules.nfproxy.nginx import sync_nginx_state -from utils.certs import CertsDB, ip_parse as certs_ip_parse class STATUS: STOP = "stop" @@ -45,19 +43,20 @@ class ServiceManager: def __update_status_db(self, status): self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id) - async def next(self,to): + async def next(self,to,persist:bool=True): async with self.lock: if to == STATUS.STOP: - await self.stop() + await self.stop(persist=persist) if to == STATUS.ACTIVE: await self.restart() def _stats_updater(self,filter:RegexFilter): self.db.query("UPDATE regexes SET blocked_packets = ? WHERE regex_id = ?;", filter.blocked, filter.id) - def _set_status(self,status): + def _set_status(self,status,persist:bool=True): self.status = status - self.__update_status_db(status) + if persist: + self.__update_status_db(status) async def start(self): if not self.interceptor: @@ -65,17 +64,13 @@ class ServiceManager: self.interceptor = await FiregexInterceptor.start(self.srv) await self._update_filters_from_db() self._set_status(STATUS.ACTIVE) - if self.srv.tls_enabled: - sync_nginx_state() - async def stop(self): + async def stop(self,persist:bool=True): nft.delete(self.srv) if self.interceptor: await self.interceptor.stop() self.interceptor = None - self._set_status(STATUS.STOP) - if self.srv.tls_enabled: - sync_nginx_state() + self._set_status(STATUS.STOP,persist=persist) async def restart(self): await self.stop() @@ -85,17 +80,7 @@ class ServiceManager: async with self.lock: await self._update_filters_from_db() - async def update_tls_config(self): - async with self.lock: - srv_dict = self.db.query("SELECT * FROM services WHERE service_id = ?;", self.srv.id)[0] - if srv_dict.get("tls_enabled"): - cert, key = CertsDB().get_cert_and_key(srv_dict["ip_int"], srv_dict["port"]) - srv_dict["tls_cert"] = cert - srv_dict["tls_key"] = key - self.srv = Service.from_dict(srv_dict) - if self.status == STATUS.ACTIVE: - await self.restart() - sync_nginx_state() + class FirewallManager: def __init__(self, db:SQLite): @@ -105,13 +90,16 @@ class FirewallManager: async def close(self): for key in list(self.service_table.keys()): - await self.remove(key) - sync_nginx_state() + try: + await self.remove(key, persist=False) + except Exception: + # Don't let one broken service block shutdown of the others + self.service_table.pop(key, None) - async def remove(self,srv_id): - async with self.lock: + async def remove(self,srv_id,persist:bool=True): + async with self.lock: if srv_id in self.service_table: - await self.service_table[srv_id].next(STATUS.STOP) + await self.service_table[srv_id].next(STATUS.STOP,persist=persist) del self.service_table[srv_id] async def init(self): @@ -121,20 +109,13 @@ class FirewallManager: async def reload(self): async with self.lock: services = self.db.query('SELECT * FROM services;') - tls_services = [s for s in services if s.get("tls_enabled") and s["service_id"] not in self.service_table] - certs_map = CertsDB().get_multiple_certs_and_keys(tls_services) if tls_services else {} for srv in services: if srv["service_id"] in self.service_table: continue - if srv.get("tls_enabled"): - cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None)) - srv["tls_cert"] = cert - srv["tls_key"] = key srv_obj = Service.from_dict(srv) self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db) await self.service_table[srv_obj.id].next(srv_obj.status) - sync_nginx_state() def get(self,srv_id) -> ServiceManager: if srv_id in self.service_table: diff --git a/backend/modules/nfregex/models.py b/backend/modules/nfregex/models.py index 2bd8268..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, tls_enabled: bool = False, tls_cert: str | None = None, tls_key: str | None = None, **other): + def __init__(self, service_id: str, status: str, port: int | None, name: str, proto: str, ip_int: str | None, fail_open: bool, target_type: str = "flow", tls_stream_id: str | None = None, tls_cert: str | None = None, tls_key: str | None = None, **other): self.id = service_id self.status = status self.port = port @@ -9,7 +9,8 @@ class Service: self.proto = proto self.ip_int = ip_int self.fail_open = fail_open - self.tls_enabled = bool(tls_enabled) + self.target_type = target_type + self.tls_stream_id = tls_stream_id self.tls_cert = tls_cert self.tls_key = tls_key diff --git a/backend/modules/nfregex/nftables.py b/backend/modules/nfregex/nftables.py index f53aa3f..997fc8a 100644 --- a/backend/modules/nfregex/nftables.py +++ b/backend/modules/nfregex/nftables.py @@ -1,6 +1,20 @@ from modules.nfregex.models import Service from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json -from modules.nfproxy.nginx import get_tls_ports +from modules.tls.manager import get_tls_ports, TLSManager + +def resolve_target(srv: Service) -> tuple[str, int] | None: + """Returns the (ip_int, port) a service's intercept rule should actually match. + For target_type=='tls' this is the loopback clear_port where nginx exposes the + decrypted plaintext, not the service's own (real backend) ip_int/port. Returns + None if the linked TLS stream can't be resolved (e.g. it was deleted).""" + if srv.target_type == "tls": + tls_stream = TLSManager().get_stream(srv.tls_stream_id) + if not tls_stream: + return None + _, clear_port = get_tls_ports(tls_stream["ip_int"], tls_stream["port"]) + loopback_ip = "::1" if ip_family(tls_stream["ip_int"]) == "ip6" else "127.0.0.1" + return loopback_ip, clear_port + return srv.ip_int, srv.port class FiregexFilter: def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int): @@ -11,18 +25,20 @@ class FiregexFilter: self.ip_int = str(ip_int) def __eq__(self, o: object) -> bool: - if isinstance(o, FiregexFilter) or isinstance(o, Service): + if isinstance(o, FiregexFilter): return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int) + if isinstance(o, Service): + return self.matches(o) return False def matches(self, srv: Service) -> bool: - if self.port == srv.port and ip_parse(self.ip_int) == ip_parse(srv.ip_int): - return True - ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port) - loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1" - if ip_parse(self.ip_int) == ip_parse(loopback_ip) and self.port in (ssl_port, clear_port): - return True - return False + if self.proto != str(srv.proto): + return False + target = resolve_target(srv) + if target is None: + return False + target_ip, target_port = target + return self.port == target_port and ip_parse(self.ip_int) == ip_parse(target_ip) class FiregexTables(NFTableManager): input_chain = "nfregex_input" @@ -48,103 +64,58 @@ class FiregexTables(NFTableManager): "hook":"postrouting", "prio":107, "policy":"accept" - }}}, - {"add":{"chain":{ - "family":"inet", - "table":self.table_name, - "name":self.nat_chain, - "type":"nat", - "hook":"prerouting", - "prio":-100, - "policy":"accept" }}} ],[ {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, - {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}}, - {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}}, ]) def add(self, srv:Service, queue_range): - + for ele in self.get(): if ele.matches(srv): return - + + target = resolve_target(srv) + if target is None: + return + target_ip, target_port = target + init, end = queue_range if init > end: init, end = end, init - if srv.tls_enabled: - ssl_port, clear_port = get_tls_ports(srv.ip_int, srv.port) - loopback_ip = "::1" if ip_family(srv.ip_int) == "ip6" else "127.0.0.1" - self.cmd( - { "insert":{ "rule": { # Redirect TLS traffic to local Nginx SSL proxy - "family": "inet", - "table": self.table_name, - "chain": self.nat_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, - {'match': {'left': {'payload': {'protocol': str(srv.proto), 'field': 'dport'}}, 'op': '==', 'right': int(srv.port)}}, - {'match': {'left': {'meta': {'key': 'iifname'}}, 'op': '!=', 'right': 'lo'}}, - {'dnat': {'family': ip_family(srv.ip_int), 'addr': loopback_ip, 'port': int(ssl_port)}} + self.cmd( + { "insert":{ "rule": { + "family": "inet", + "table": self.table_name, + "chain": self.output_chain, + "expr": [ + {'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}}, + {'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "sport"}}, "op": "==", "right": int(target_port)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} + ] + }}}, + {"insert":{"rule":{ + "family": "inet", + "table": self.table_name, + "chain": self.input_chain, + "expr": [ + {'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}}, + {'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "dport"}}, "op": "==", "right": int(target_port)}}, + {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, + {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} ] - }}}, - {"insert":{"rule":{ # Send cleartext inbound traffic to NFQUEUE - "family": "inet", - "table": self.table_name, - "chain": self.input_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': loopback_ip}}, - {'match': {'left': {'payload': {'protocol': str(srv.proto), 'field': 'dport'}}, 'op': '==', 'right': int(clear_port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}}, - {"insert":{"rule":{ # Send cleartext outbound traffic to NFQUEUE - "family": "inet", - "table": self.table_name, - "chain": self.output_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': loopback_ip}}, - {'match': {'left': {'payload': {'protocol': str(srv.proto), 'field': 'sport'}}, 'op': '==', 'right': int(clear_port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}} - ) - else: - self.cmd( - { "insert":{ "rule": { - "family": "inet", - "table": self.table_name, - "chain": self.output_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, - {'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "sport"}}, "op": "==", "right": int(srv.port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}}, - {"insert":{"rule":{ - "family": "inet", - "table": self.table_name, - "chain": self.input_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_int), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv.ip_int)}}, - {'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "dport"}}, "op": "==", "right": int(srv.port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}} - ) + }}} + ) def get(self) -> list[FiregexFilter]: res = [] - for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain,self.nat_chain]): + for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]): try: ip_int = None if isinstance(filter["expr"][0]["match"]["right"],str): 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/nfproxy/nginx.py b/backend/modules/tls/nginx.py similarity index 70% rename from backend/modules/nfproxy/nginx.py rename to backend/modules/tls/nginx.py index c110590..5c210e4 100644 --- a/backend/modules/nfproxy/nginx.py +++ b/backend/modules/tls/nginx.py @@ -1,25 +1,17 @@ import subprocess import os import glob -from utils.sqlite import SQLite -from utils.certs import CertsDB, get_tls_ports, ip_parse as certs_ip_parse +from modules.tls.manager import TLSManager NGINX_CONF_PATH = "/tmp/firegex_nginx.conf" NGINX_PID_PATH = "/tmp/firegex_nginx.pid" NGINX_LOG_PATH = "/tmp/firegex_nginx_error.log" -def _get_active_tls_services(db_name: str) -> list[dict]: - db = SQLite(db_name) - db.connect() - try: - return db.query("SELECT * FROM services WHERE status = 'active' AND tls_enabled = 1;") - except Exception: - return [] - finally: - db.disconnect() +def update_nginx() -> None: + manager = TLSManager() + streams = manager.get_active_streams() -def update_nginx(services: list[dict]) -> None: - if not services: + if not streams: subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-s", "stop", "-e", NGINX_LOG_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # Clean up certificates, configuration, and pid @@ -32,17 +24,18 @@ def update_nginx(services: list[dict]) -> None: return servers_config = [] - for srv in services: - srv_id = srv["service_id"] + for srv in streams: + srv_id = srv["id"] cert_path = f"/tmp/firegex_cert_{srv_id}.crt" key_path = f"/tmp/firegex_key_{srv_id}.key" with open(cert_path, "w") as f: - f.write(srv.get("tls_cert") or "") + f.write(srv.get("cert") or "") with open(key_path, "w") as f: - f.write(srv.get("tls_key") or "") + f.write(srv.get("key") or "") - ssl_port, clear_port = get_tls_ports(srv["ip_int"], srv["port"]) + ssl_port = srv["ssl_port"] + clear_port = srv["clear_port"] ip_addr = srv["ip_int"].split("/")[0] is_ipv6 = ":" in ip_addr @@ -88,16 +81,3 @@ def update_nginx(services: list[dict]) -> None: res = subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-s", "reload", "-e", NGINX_LOG_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if res.returncode != 0: subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-e", NGINX_LOG_PATH], check=True) - -def sync_nginx_state() -> None: - services = _get_active_tls_services('db/nft-pyfilters.db') + _get_active_tls_services('db/nft-regex.db') - - # Batch-resolve certificates using CertsDB - certs_map = CertsDB().get_multiple_certs_and_keys(services) - for srv in services: - cert, key = certs_map.get((certs_ip_parse(srv["ip_int"]), srv["port"]), (None, None)) - srv["tls_cert"] = cert - srv["tls_key"] = key - - update_nginx(services) - 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/certificates.py b/backend/routers/certificates.py deleted file mode 100644 index 4a8ab85..0000000 --- a/backend/routers/certificates.py +++ /dev/null @@ -1,36 +0,0 @@ -from fastapi import APIRouter -from pydantic import BaseModel -from utils.models import StatusMessageModel -from utils.certs import CertsDB - -app = APIRouter() - -class CertificateModel(BaseModel): - ip_int: str - port: int - cert: str - key: str - -class CertificateResponseModel(BaseModel): - ip_int: str - port: int - cert: str | None = None - key: str | None = None - -@app.get("", response_model=CertificateResponseModel) -async def get_certificate(ip_int: str, port: int): - """Retrieve TLS certificate and key by ip_int and port""" - cert, key = CertsDB().get_cert_and_key(ip_int, port) - return {"ip_int": ip_int, "port": port, "cert": cert, "key": key} - -@app.post("", response_model=StatusMessageModel) -async def upsert_certificate(form: CertificateModel): - """Create or update TLS certificate and key""" - CertsDB().upsert_cert_and_key(form.ip_int, form.port, form.cert, form.key) - return {"status": "ok"} - -@app.delete("", response_model=StatusMessageModel) -async def delete_certificate(ip_int: str, port: int): - """Delete TLS certificate and key by ip_int and port""" - CertsDB().delete_cert_and_key(ip_int, port) - return {"status": "ok"} 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 ae44e76..79c13c7 100644 --- a/backend/routers/nfproxy.py +++ b/backend/routers/nfproxy.py @@ -6,8 +6,8 @@ from modules.nfproxy.nftables import FiregexTables from modules.nfproxy.firewall import STATUS, FirewallManager from utils.sqlite import SQLite from utils import ip_parse, refactor_name, socketio_emit, PortType -from utils.certs import CertsDB, populate_services_tls_config -from utils.models import ResetRequest, StatusMessageModel, TLSConfigForm +from modules.tls.service import activate_stream +from utils.models import ResetRequest, StatusMessageModel import os from firegex.nfproxy.internals import get_filter_names from fastapi.responses import PlainTextResponse @@ -20,27 +20,27 @@ import utils class ServiceModel(BaseModel): service_id: str status: str - port: PortType + port: PortType|None = None name: str proto: str - ip_int: str + ip_int: str|None = None n_filters: int edited_packets: int blocked_packets: int fail_open: bool - tls_enabled: bool - tls_cert: str|None = None - tls_key: str|None = None - ssl_port: int|None = None - clear_port: int|None = None + target_type: str + tls_stream_id: str|None = None class RenameForm(BaseModel): name:str class SettingsForm(BaseModel): port: PortType|None = None + proto: str|None = None ip_int: str|None = None fail_open: bool|None = None + target_type: str|None = None + tls_stream_id: str|None = None class PyFilterModel(BaseModel): name: str @@ -51,13 +51,12 @@ class PyFilterModel(BaseModel): class ServiceAddForm(BaseModel): name: str - port: PortType + port: PortType|None = None proto: str - ip_int: str + ip_int: str|None = None fail_open: bool = True - tls_enabled: bool = False - tls_cert: str | None = None - tls_key: str | None = None + target_type: str = "flow" + tls_stream_id: str|None = None class ServiceAddResponse(BaseModel): status:str @@ -72,13 +71,14 @@ db = SQLite('db/nft-pyfilters.db', { 'services': { 'service_id': 'VARCHAR(100) PRIMARY KEY', 'status': 'VARCHAR(100) NOT NULL', - 'port': 'INT NOT NULL CHECK(port > 0 and port < 65536)', + 'target_type': 'VARCHAR(10) NOT NULL CHECK(target_type IN ("flow", "tls")) DEFAULT "flow"', + 'tls_stream_id': 'VARCHAR(100)', + 'port': 'INT CHECK(port > 0 and port < 65536)', 'name': 'VARCHAR(100) NOT NULL UNIQUE', 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "http"))', 'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_proto IN ("tcp", "udp"))', - 'ip_int': 'VARCHAR(100) NOT NULL', + 'ip_int': 'VARCHAR(100)', 'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1', - 'tls_enabled': 'BOOLEAN NOT NULL CHECK (tls_enabled IN (0, 1)) DEFAULT 0', }, 'pyfilter': { 'name': 'VARCHAR(100) NOT NULL', @@ -125,10 +125,8 @@ async def startup(): utils.socketio.on("nfproxy-exception-leave", leave_exception) async def shutdown(): - db.backup() await firewall.close() db.disconnect() - db.restore() def gen_service_id(): while True: @@ -157,14 +155,15 @@ async def get_service_list(): s.proto proto, s.ip_int ip_int, s.fail_open fail_open, - s.tls_enabled tls_enabled, + s.target_type target_type, + s.tls_stream_id tls_stream_id, COUNT(f.name) n_filters, COALESCE(SUM(f.blocked_packets),0) blocked_packets, COALESCE(SUM(f.edited_packets),0) edited_packets FROM services s LEFT JOIN pyfilter f ON s.service_id = f.service_id GROUP BY s.service_id; """) - return populate_services_tls_config(res) + return res @app.get('/services/{service_id}', response_model=ServiceModel) async def get_service_by_id(service_id: str): @@ -178,7 +177,8 @@ async def get_service_by_id(service_id: str): s.proto proto, s.ip_int ip_int, s.fail_open fail_open, - s.tls_enabled tls_enabled, + s.target_type target_type, + s.tls_stream_id tls_stream_id, COUNT(f.name) n_filters, COALESCE(SUM(f.blocked_packets),0) blocked_packets, COALESCE(SUM(f.edited_packets),0) edited_packets @@ -187,7 +187,7 @@ async def get_service_by_id(service_id: str): """, service_id) if len(res) == 0: raise HTTPException(status_code=400, detail="This service does not exists!") - return populate_services_tls_config(res)[0] + return res[0] @app.post('/services/{service_id}/stop', response_model=StatusMessageModel) async def service_stop(service_id: str): @@ -199,6 +199,10 @@ async def service_stop(service_id: str): @app.post('/services/{service_id}/start', response_model=StatusMessageModel) async def service_start(service_id: str): """Request the start of a specific service""" + srv = db.query("SELECT target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id) + if srv and srv[0]["target_type"] == "tls": + if not await activate_stream(srv[0]["tls_stream_id"]): + raise HTTPException(status_code=400, detail="Linked TLS stream not found") await firewall.get(service_id).next(STATUS.ACTIVE) await refresh_frontend() return {'status': 'ok'} @@ -206,9 +210,6 @@ async def service_start(service_id: str): @app.delete('/services/{service_id}', response_model=StatusMessageModel) async def service_delete(service_id: str): """Request the deletion of a specific service""" - srv_res = db.query('SELECT ip_int, port FROM services WHERE service_id = ?;', service_id) - if srv_res: - CertsDB().delete_cert_and_key(srv_res[0]["ip_int"], srv_res[0]["port"]) db.query('DELETE FROM services WHERE service_id = ?;', service_id) db.query('DELETE FROM pyfilter WHERE service_id = ?;', service_id) if os.path.exists(f"db/nfproxy_filters/{service_id}.py"): @@ -234,7 +235,7 @@ async def service_rename(service_id: str, form: RenameForm): async def service_settings(service_id: str, form: SettingsForm): """Request to change the settings of a specific service (will cause a restart)""" - srv_check = db.query("SELECT ip_int, port, tls_enabled FROM services WHERE service_id = ?;", service_id) + srv_check = db.query("SELECT ip_int, port, target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id) if len(srv_check) == 0: raise HTTPException(status_code=404, detail="Service not found") old_srv = srv_check[0] @@ -247,67 +248,46 @@ async def service_settings(service_id: str, form: SettingsForm): form.ip_int = ip_parse(form.ip_int) except ValueError: raise HTTPException(status_code=400, detail="Invalid address") - + new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"] new_port = form.port if form.port is not None else old_srv["port"] + new_target_type = form.target_type if form.target_type is not None else old_srv["target_type"] + new_tls_stream_id = form.tls_stream_id if form.tls_stream_id is not None else old_srv["tls_stream_id"] + + if new_target_type == "tls" and not new_tls_stream_id: + raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls") + if new_target_type == "flow" and (new_ip is None or new_port is None): + raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow") + keys = [] values = [] for key, value in form.model_dump(exclude_none=True).items(): keys.append(key) values.append(value) + if key == "proto": + keys.append("l4_proto") + values.append(convert_protocol_to_l4(value)) if len(keys) == 0: raise HTTPException(status_code=400, detail="No settings to change provided") - - if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]): - cert, key = CertsDB().get_cert_and_key(old_srv["ip_int"], old_srv["port"]) - if cert and key: - CertsDB().upsert_cert_and_key(new_ip, new_port, cert, key) - CertsDB().delete_cert_and_key(old_srv["ip_int"], old_srv["port"]) try: db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) except sqlite3.IntegrityError: - # If migration was performed, rollback CertsDB change (upsert old, delete new) - if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]): - cert, key = CertsDB().get_cert_and_key(new_ip, new_port) - if cert and key: - CertsDB().upsert_cert_and_key(old_srv["ip_int"], old_srv["port"], cert, key) - CertsDB().delete_cert_and_key(new_ip, new_port) raise HTTPException(status_code=400, detail="A service with these settings already exists") old_status = firewall.get(service_id).status await firewall.remove(service_id) + if old_status == STATUS.ACTIVE and new_target_type == "tls": + await activate_stream(new_tls_stream_id) await firewall.reload() await firewall.get(service_id).next(old_status) - + await refresh_frontend() return {'status': 'ok'} -@app.put('/services/{service_id}/tls-config', response_model=StatusMessageModel) -async def update_service_tls(service_id: str, form: TLSConfigForm): - """Update TLS configuration for a service""" - srv_res = db.query("SELECT ip_int, port FROM services WHERE service_id = ?;", service_id) - if len(srv_res) == 0: - raise HTTPException(status_code=404, detail="Service not found") - srv = srv_res[0] - - if form.tls_enabled: - if not form.tls_cert or not form.tls_key: - raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled") - CertsDB().upsert_cert_and_key(srv["ip_int"], srv["port"], form.tls_cert, form.tls_key) - - db.query(""" - UPDATE services - SET tls_enabled = ? - WHERE service_id = ?; - """, int(form.tls_enabled), service_id) - - await firewall.get(service_id).update_tls_config() - await refresh_frontend() - return {'status': 'ok'} @app.get('/services/{service_id}/pyfilters', response_model=list[PyFilterModel]) async def get_service_pyfilter_list(service_id: str): @@ -355,22 +335,25 @@ async def pyfilter_disable(service_id: str, filter_name: str): @app.post('/services', response_model=ServiceAddResponse) async def add_new_service(form: ServiceAddForm): """Add a new service""" - try: - form.ip_int = ip_parse(form.ip_int) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid address") + if form.target_type == "flow": + if form.ip_int is None or form.port is None: + raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow") + try: + form.ip_int = ip_parse(form.ip_int) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid address") + elif form.target_type == "tls": + if not form.tls_stream_id: + raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls") + if form.proto not in ["tcp", "http"]: raise HTTPException(status_code=400, detail="Invalid protocol") - if form.tls_enabled: - if not form.tls_cert or not form.tls_key: - raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled") - CertsDB().upsert_cert_and_key(form.ip_int, form.port, form.tls_cert, form.tls_key) srv_id = None try: srv_id = gen_service_id() - db.query("INSERT INTO services (service_id, name, port, status, proto, ip_int, fail_open, l4_proto, tls_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, convert_protocol_to_l4(form.proto), int(form.tls_enabled)) + db.query("INSERT INTO services (service_id, name, port, status, proto, ip_int, fail_open, l4_proto, target_type, tls_stream_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, convert_protocol_to_l4(form.proto), form.target_type, form.tls_stream_id) except sqlite3.IntegrityError: raise HTTPException(status_code=400, detail="This type of service already exists") await firewall.reload() diff --git a/backend/routers/nfregex.py b/backend/routers/nfregex.py index f145682..25b0bf2 100644 --- a/backend/routers/nfregex.py +++ b/backend/routers/nfregex.py @@ -8,25 +8,22 @@ from modules.nfregex.nftables import FiregexTables from modules.nfregex.firewall import STATUS, FirewallManager from utils.sqlite import SQLite from utils import ip_parse, refactor_name, socketio_emit, PortType -from utils.certs import CertsDB, populate_services_tls_config -from utils.models import ResetRequest, StatusMessageModel, TLSConfigForm +from modules.tls.service import activate_stream +from utils.models import ResetRequest, StatusMessageModel from modules.nfregex.firegex import test_regex_validity class ServiceModel(BaseModel): status: str service_id: str - port: PortType + port: PortType|None = None name: str proto: str - ip_int: str + ip_int: str|None = None n_regex: int n_packets: int fail_open: bool - tls_enabled: bool = False - tls_cert: str|None = None - tls_key: str|None = None - ssl_port: int|None = None - clear_port: int|None = None + target_type: str + tls_stream_id: str|None = None class RenameForm(BaseModel): name:str @@ -36,6 +33,8 @@ class SettingsForm(BaseModel): proto: str|None = None ip_int: str|None = None fail_open: bool|None = None + target_type: str|None = None + tls_stream_id: str|None = None class RegexModel(BaseModel): regex:str @@ -55,13 +54,12 @@ class RegexAddForm(BaseModel): class ServiceAddForm(BaseModel): name: str - port: PortType + port: PortType|None = None proto: str - ip_int: str + ip_int: str|None = None fail_open: bool = False - tls_enabled: bool = False - tls_cert: str | None = None - tls_key: str | None = None + target_type: str = "flow" + tls_stream_id: str|None = None class ServiceAddResponse(BaseModel): status:str @@ -73,12 +71,13 @@ db = SQLite('db/nft-regex.db', { 'services': { 'service_id': 'VARCHAR(100) PRIMARY KEY', 'status': 'VARCHAR(100) NOT NULL', - 'port': 'INT NOT NULL CHECK(port > 0 and port < 65536)', + 'target_type': 'VARCHAR(10) NOT NULL CHECK(target_type IN ("flow", "tls")) DEFAULT "flow"', + 'tls_stream_id': 'VARCHAR(100)', + 'port': 'INT CHECK(port > 0 and port < 65536)', 'name': 'VARCHAR(100) NOT NULL UNIQUE', 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))', - 'ip_int': 'VARCHAR(100) NOT NULL', + 'ip_int': 'VARCHAR(100)', 'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1', - 'tls_enabled': 'BOOLEAN NOT NULL CHECK (tls_enabled IN (0, 1)) DEFAULT 0', }, 'regexes': { 'regex': 'TEXT NOT NULL', @@ -123,10 +122,8 @@ async def startup(): print("WARNING cannot start firewall:", e) async def shutdown(): - db.backup() await firewall.close() db.disconnect() - db.restore() def gen_service_id(): while True: @@ -149,13 +146,14 @@ async def get_service_list(): s.proto proto, s.ip_int ip_int, s.fail_open fail_open, - s.tls_enabled tls_enabled, + s.target_type target_type, + s.tls_stream_id tls_stream_id, COUNT(r.regex_id) n_regex, COALESCE(SUM(r.blocked_packets),0) n_packets FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id GROUP BY s.service_id; """) - return populate_services_tls_config(res) + return res @app.get('/services/{service_id}', response_model=ServiceModel) async def get_service_by_id(service_id: str): @@ -169,7 +167,8 @@ async def get_service_by_id(service_id: str): s.proto proto, s.ip_int ip_int, s.fail_open fail_open, - s.tls_enabled tls_enabled, + s.target_type target_type, + s.tls_stream_id tls_stream_id, COUNT(r.regex_id) n_regex, COALESCE(SUM(r.blocked_packets),0) n_packets FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id @@ -177,7 +176,7 @@ async def get_service_by_id(service_id: str): """, service_id) if len(res) == 0: raise HTTPException(status_code=400, detail="This service does not exists!") - return populate_services_tls_config(res)[0] + return res[0] @app.post('/services/{service_id}/stop', response_model=StatusMessageModel) async def service_stop(service_id: str): @@ -189,6 +188,10 @@ async def service_stop(service_id: str): @app.post('/services/{service_id}/start', response_model=StatusMessageModel) async def service_start(service_id: str): """Request the start of a specific service""" + srv = db.query("SELECT target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id) + if srv and srv[0]["target_type"] == "tls": + if not await activate_stream(srv[0]["tls_stream_id"]): + raise HTTPException(status_code=400, detail="Linked TLS stream not found") await firewall.get(service_id).next(STATUS.ACTIVE) await refresh_frontend() return {'status': 'ok'} @@ -196,9 +199,6 @@ async def service_start(service_id: str): @app.delete('/services/{service_id}', response_model=StatusMessageModel) async def service_delete(service_id: str): """Request the deletion of a specific service""" - srv_res = db.query('SELECT ip_int, port FROM services WHERE service_id = ?;', service_id) - if srv_res: - CertsDB().delete_cert_and_key(srv_res[0]["ip_int"], srv_res[0]["port"]) db.query('DELETE FROM services WHERE service_id = ?;', service_id) db.query('DELETE FROM regexes WHERE service_id = ?;', service_id) await firewall.remove(service_id) @@ -222,7 +222,7 @@ async def service_rename(service_id: str, form: RenameForm): async def service_settings(service_id: str, form: SettingsForm): """Request to change the settings of a specific service (will cause a restart)""" - srv_check = db.query("SELECT proto, ip_int, port, tls_enabled FROM services WHERE service_id = ?;", service_id) + srv_check = db.query("SELECT proto, ip_int, port, target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id) if len(srv_check) == 0: raise HTTPException(status_code=404, detail="Service not found") old_srv = srv_check[0] @@ -230,11 +230,6 @@ async def service_settings(service_id: str, form: SettingsForm): if form.proto is not None and form.proto not in ["tcp", "udp"]: raise HTTPException(status_code=400, detail="Invalid protocol") - if old_srv["tls_enabled"]: - new_proto = form.proto if form.proto is not None else old_srv["proto"] - if new_proto != "tcp": - raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol") - if form.port is not None and (form.port < 1 or form.port > 65535): raise HTTPException(status_code=400, detail="Invalid port") @@ -246,6 +241,19 @@ async def service_settings(service_id: str, form: SettingsForm): new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"] new_port = form.port if form.port is not None else old_srv["port"] + new_proto = form.proto if form.proto is not None else old_srv["proto"] + + new_target_type = form.target_type if form.target_type is not None else old_srv["target_type"] + new_tls_stream_id = form.tls_stream_id if form.tls_stream_id is not None else old_srv["tls_stream_id"] + + if new_target_type == "tls": + if new_proto != "tcp": + raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol") + if not new_tls_stream_id: + raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls") + + if new_target_type == "flow" and (new_ip is None or new_port is None): + raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow") keys = [] values = [] @@ -256,29 +264,19 @@ async def service_settings(service_id: str, form: SettingsForm): if len(keys) == 0: raise HTTPException(status_code=400, detail="No settings to change provided") - - if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]): - cert, key = CertsDB().get_cert_and_key(old_srv["ip_int"], old_srv["port"]) - if cert and key: - CertsDB().upsert_cert_and_key(new_ip, new_port, cert, key) - CertsDB().delete_cert_and_key(old_srv["ip_int"], old_srv["port"]) - + try: db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) except sqlite3.IntegrityError: - # If migration was performed, rollback CertsDB change (upsert old, delete new) - if old_srv["tls_enabled"] and (new_ip != old_srv["ip_int"] or new_port != old_srv["port"]): - cert, key = CertsDB().get_cert_and_key(new_ip, new_port) - if cert and key: - CertsDB().upsert_cert_and_key(old_srv["ip_int"], old_srv["port"], cert, key) - CertsDB().delete_cert_and_key(new_ip, new_port) raise HTTPException(status_code=400, detail="A service with these settings already exists") old_status = firewall.get(service_id).status await firewall.remove(service_id) + if old_status == STATUS.ACTIVE and new_target_type == "tls": + await activate_stream(new_tls_stream_id) await firewall.reload() await firewall.get(service_id).next(old_status) - + await refresh_frontend() return {'status': 'ok'} @@ -357,54 +355,33 @@ async def add_new_regex(form: RegexAddForm): @app.post('/services', response_model=ServiceAddResponse) async def add_new_service(form: ServiceAddForm): """Add a new service""" - try: - form.ip_int = ip_parse(form.ip_int) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid address") + if form.target_type == "flow": + if form.ip_int is None or form.port is None: + raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow") + try: + form.ip_int = ip_parse(form.ip_int) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid address") + elif form.target_type == "tls": + if not form.tls_stream_id: + raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls") + if form.proto not in ["tcp", "udp"]: raise HTTPException(status_code=400, detail="Invalid protocol") - if form.tls_enabled: - if form.proto != "tcp": - raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol") - if not form.tls_cert or not form.tls_key: - raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled") - CertsDB().upsert_cert_and_key(form.ip_int, form.port, form.tls_cert, form.tls_key) + if form.target_type == "tls" and form.proto != "tcp": + raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol") srv_id = None try: srv_id = gen_service_id() - db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, tls_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, int(form.tls_enabled)) + db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, target_type, tls_stream_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, form.target_type, form.tls_stream_id) except sqlite3.IntegrityError: raise HTTPException(status_code=400, detail="This type of service already exists") await firewall.reload() await refresh_frontend() return {'status': 'ok', 'service_id': srv_id} -@app.put('/services/{service_id}/tls-config', response_model=StatusMessageModel) -async def update_service_tls(service_id: str, form: TLSConfigForm): - """Update TLS configuration for a service""" - srv_res = db.query("SELECT ip_int, port, proto FROM services WHERE service_id = ?;", service_id) - if len(srv_res) == 0: - raise HTTPException(status_code=404, detail="Service not found") - srv = srv_res[0] - - if form.tls_enabled: - if srv["proto"] != "tcp": - raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol") - if not form.tls_cert or not form.tls_key: - raise HTTPException(status_code=400, detail="Cert and Key are required when TLS is enabled") - CertsDB().upsert_cert_and_key(srv["ip_int"], srv["port"], form.tls_cert, form.tls_key) - - db.query(""" - UPDATE services - SET tls_enabled = ? - WHERE service_id = ?; - """, int(form.tls_enabled), service_id) - - await firewall.get(service_id).update_tls_config() - await refresh_frontend() - return {'status': 'ok'} @app.get('/metrics', response_class = PlainTextResponse) async def metrics(): @@ -428,3 +405,52 @@ async def metrics(): metrics.append(f'firegex_blocked_packets{{{props}}} {stat["blocked_packets"]}') metrics.append(f'firegex_active{{{props}}} {int(stat["active"] and stat["status"] == "active")}') return "\n".join(metrics) + +@app.get('/services/{service_id}/export') +async def export_service_regexes(service_id: str): + """Export all regexes for a specific service as JSON""" + if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', service_id)) == 0: + raise HTTPException(status_code=404, detail="Service not found") + + regexes = db.query("SELECT regex, mode, is_case_sensitive, active FROM regexes WHERE service_id = ?;", service_id) + return {"regexes": regexes} + +@app.post('/services/{service_id}/import', response_model=StatusMessageModel) +async def import_service_regexes(service_id: str, data: dict): + """Import regexes for a specific service from JSON""" + if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', service_id)) == 0: + raise HTTPException(status_code=404, detail="Service not found") + + regexes = data.get("regexes", []) + if not isinstance(regexes, list): + raise HTTPException(status_code=400, detail="Invalid format") + + for r in regexes: + regex = r.get("regex") + mode = r.get("mode") + is_case_sensitive = r.get("is_case_sensitive", False) + active = r.get("active", True) + + if not regex or not mode: + continue + + try: + db.query('INSERT INTO regexes (regex, mode, service_id, is_case_sensitive, active) VALUES (?, ?, ?, ?, ?);', + regex, mode, service_id, int(is_case_sensitive), int(active)) + + # Since we inserted it directly, we need to notify the firewall to update its rules if it's active + if active and firewall.get(service_id).status == STATUS.ACTIVE: + # Easiest way to sync is to restart the service rule engine + await firewall.get(service_id).next(STATUS.ACTIVE) + except sqlite3.IntegrityError: + # Skip duplicates + pass + + # Force reload of all rules for this service + old_status = firewall.get(service_id).status + await firewall.remove(service_id) + await firewall.reload() + await firewall.get(service_id).next(old_status) + + await refresh_frontend() + return {'status': 'ok'} 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 087bbf0..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(): diff --git a/backend/utils/certs.py b/backend/utils/certs.py deleted file mode 100644 index ed77d66..0000000 --- a/backend/utils/certs.py +++ /dev/null @@ -1,106 +0,0 @@ -import hashlib -from ipaddress import ip_interface -from utils.sqlite import SQLite - -CERTIFICATES_SCHEMA = { - 'certificates': { - 'ip_int': 'VARCHAR(100) NOT NULL', - 'port': 'INT NOT NULL', - 'cert': 'TEXT NOT NULL', - 'key': 'TEXT NOT NULL', - 'PRIMARY KEY': '(ip_int, port)' - } -} - -_certs_db = SQLite('db/firegex.db', schema=CERTIFICATES_SCHEMA) - -def ip_parse(ip: str) -> str: - try: - return str(ip_interface(ip).network) - except Exception: - return ip - -def get_tls_ports(ip: str, port: int) -> tuple[int, int]: - ip_addr = ip.split("/")[0] - key = f"{ip_addr}:{port}" - h = int.from_bytes(hashlib.sha256(key.encode('utf-8')).digest()[:4], 'big') - ssl_port = 10000 + (h % 10000) - clear_port = 20000 + (h % 10000) - return ssl_port, clear_port - -class CertsDB: - def __init__(self, db_path='db/firegex.db'): - # Ignore db_path if it matches the default, otherwise instantiate SQLite - # (mostly for compatibility with tests if they pass custom paths) - if db_path == 'db/firegex.db': - self.db = _certs_db - else: - self.db = SQLite(db_path) - - if self.db.conn is None: - self.db.connect() - - def get_cert_and_key(self, ip_int: str, port: int) -> tuple[str | None, str | None]: - ip_int = ip_parse(ip_int) - res = self.db.query("SELECT cert, key FROM certificates WHERE ip_int = ? AND port = ?;", ip_int, port) - if res: - return res[0]["cert"], res[0]["key"] - return None, None - - def get_multiple_certs_and_keys(self, services: list[dict]) -> dict[tuple[str, int], tuple[str, str]]: - if not services: - return {} - conditions = [] - params = [] - for s in services: - conditions.append("(ip_int = ? AND port = ?)") - params.extend([ip_parse(s["ip_int"]), s["port"]]) - query_str = "SELECT ip_int, port, cert, key FROM certificates WHERE " + " OR ".join(conditions) + ";" - res = self.db.query(query_str, *params) - return {(ip_parse(row["ip_int"]), row["port"]): (row["cert"], row["key"]) for row in res} - - def upsert_cert_and_key(self, ip_int: str, port: int, cert: str, key: str) -> None: - ip_int = ip_parse(ip_int) - self.db.query("INSERT OR REPLACE INTO certificates (ip_int, port, cert, key) VALUES (?, ?, ?, ?);", ip_int, port, cert, key) - - def delete_cert_and_key(self, ip_int: str, port: int) -> None: - ip_int = ip_parse(ip_int) - - # Check if the certificate is still used by any service in regex DB - regex_db = SQLite('db/nft-regex.db') - regex_db.connect() - try: - regex_used = regex_db.query("SELECT 1 FROM services WHERE ip_int = ? AND port = ?;", ip_int, port) - except Exception: - regex_used = [] - finally: - regex_db.disconnect() - - # Check if the certificate is still used by any service in proxy DB - proxy_db = SQLite('db/nft-pyfilters.db') - proxy_db.connect() - try: - proxy_used = proxy_db.query("SELECT 1 FROM services WHERE ip_int = ? AND port = ?;", ip_int, port) - except Exception: - proxy_used = [] - finally: - proxy_db.disconnect() - - if not regex_used and not proxy_used: - self.db.query("DELETE FROM certificates WHERE ip_int = ? AND port = ?;", ip_int, port) - - -def populate_services_tls_config(services: list[dict]) -> list[dict]: - if not services: - return services - certs_map = CertsDB().get_multiple_certs_and_keys(services) - for srv in services: - if srv.get("tls_enabled"): - srv["ssl_port"], srv["clear_port"] = get_tls_ports(srv["ip_int"], srv["port"]) - cert, key = certs_map.get((ip_parse(srv["ip_int"]), srv["port"]), (None, None)) - srv["tls_cert"] = cert - srv["tls_key"] = key - else: - srv["ssl_port"], srv["clear_port"] = None, None - srv["tls_cert"], srv["tls_key"] = None, None - return services diff --git a/backend/utils/models.py b/backend/utils/models.py index db0f413..962c0b1 100644 --- a/backend/utils/models.py +++ b/backend/utils/models.py @@ -24,9 +24,4 @@ class IpInterface(BaseModel): name: str class ResetRequest(BaseModel): - delete:bool - -class TLSConfigForm(BaseModel): - tls_enabled: bool - tls_cert: str|None = None - tls_key: str|None = None \ No newline at end of file + delete:bool \ No newline at end of file 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/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 49ba445..8f884e2 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,158 +16,177 @@ import NFProxy from './pages/NFProxy'; import ServiceDetailsNFProxy from './pages/NFProxy/ServiceDetails'; import { useAuthStore } from './js/store'; +const AuthShell = ({ children }: { children: React.ReactNode }) => ( + + + + 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() - useEffect(()=>{ + useEffect(() => { socketio.auth = { token: access_token || "" } socketio.connect() getStatus() socketio.on("update", (data) => { - queryClient.invalidateQueries({ queryKey: data }) + queryClient.invalidateQueries({ queryKey: data }) }) socketio.on("connect_error", (err) => { - if (access_token){ - errorNotify("Socket.Io connection failed! ",`Error message: [${err.message}]`) + if (access_token) { + errorNotify("Socket.Io connection failed! ", `Error message: [${err.message}]`) } getStatus() }); - return () => { - socketio.off("update") - socketio.off("connect_error") - socketio.disconnect() - } - },[access_token]) + return () => { + socketio.off("update") + socketio.off("connect_error") + socketio.disconnect() + } + }, [access_token]) - const getStatus = () =>{ - getstatus().then( res =>{ + const getStatus = () => { + getstatus().then(res => { setSystemStatus(res) setReqError(undefined) - }).catch(err=>{ + }).catch(err => { setReqError(err.toString()) setTimeout(getStatus, 500) - }).finally( ()=>setLoading(false) ) + }).finally(() => setLoading(false)) } - useEffect(()=>{ + useEffect(() => { getStatus() - },[]) + }, []) const form = useForm({ initialValues: { - password:"", + password: "", }, - validate:{ + validate: { password: (value) => value !== "" ? null : "Password is required", } }) - if (loading){ - return - }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 - }> + }> + } /> } > } /> @@ -175,9 +195,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 + - - - - - -