From cd47fe324e415a7e19b6cb4a946d6b69a59c0cde Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 13 Apr 2026 13:18:40 +1000 Subject: [PATCH] Add internal mTLS bridge service for monitoring stack --- monitoring/mtls-bridge/Dockerfile | 12 ++++ monitoring/mtls-bridge/README.md | 38 +++++++++++++ monitoring/mtls-bridge/app.py | 69 +++++++++++++++++++++++ monitoring/mtls-bridge/docker-compose.yml | 18 ++++++ monitoring/mtls-bridge/requirements.txt | 2 + 5 files changed, 139 insertions(+) create mode 100644 monitoring/mtls-bridge/Dockerfile create mode 100644 monitoring/mtls-bridge/README.md create mode 100644 monitoring/mtls-bridge/app.py create mode 100644 monitoring/mtls-bridge/docker-compose.yml create mode 100644 monitoring/mtls-bridge/requirements.txt diff --git a/monitoring/mtls-bridge/Dockerfile b/monitoring/mtls-bridge/Dockerfile new file mode 100644 index 0000000..cef343b --- /dev/null +++ b/monitoring/mtls-bridge/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 8080 + +CMD ["python", "app.py"] diff --git a/monitoring/mtls-bridge/README.md b/monitoring/mtls-bridge/README.md new file mode 100644 index 0000000..59f62cc --- /dev/null +++ b/monitoring/mtls-bridge/README.md @@ -0,0 +1,38 @@ +# mTLS Bridge Service + +Internal HTTP-to-mTLS bridge for services that cannot present client certificates directly (for example, Grafana webhooks). + +## How it works + +1. Accepts plain HTTP requests inside the Docker network. +2. Forwards requests to an HTTPS upstream. +3. Presents a client certificate/key pair for mTLS authentication. + +## Environment variables + +- `TARGET_URL` (required): HTTPS upstream base URL. +- `CLIENT_CERT` (default `/certs/client.crt`): client certificate path. +- `CLIENT_KEY` (default `/certs/client.key`): client private key path. +- `CA_CERT` (default `/certs/ca.crt`): CA certificate bundle used to verify upstream TLS. +- `TIMEOUT` (default `5`): request timeout in seconds. +- `LOG_LEVEL` (default `INFO`): Python logging level. + +## Endpoints + +- `GET /health` returns `200 OK` for container health checks. +- `/*` proxies requests to `${TARGET_URL}` with method/body/headers preserved. + +## Compose integration + +This repository includes `monitoring/mtls-bridge/docker-compose.yml`: + +- No public port exposure. +- Read-only cert mount (`${PROJECT_ROOT}/core/traefik/certs:/certs:ro`). +- Joined to internal monitoring/traefik networks. + +## Example test + +```bash +curl http://mtls-bridge:8080/health +curl -X POST http://mtls-bridge:8080 +``` diff --git a/monitoring/mtls-bridge/app.py b/monitoring/mtls-bridge/app.py new file mode 100644 index 0000000..d195262 --- /dev/null +++ b/monitoring/mtls-bridge/app.py @@ -0,0 +1,69 @@ +import logging +import os + +import requests +from flask import Flask, Response, request + +app = Flask(__name__) + +logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(message)s", +) +logger = logging.getLogger("mtls-bridge") + +# Config via env +TARGET_URL = os.environ.get("TARGET_URL") +CLIENT_CERT = os.environ.get("CLIENT_CERT", "/certs/client.crt") +CLIENT_KEY = os.environ.get("CLIENT_KEY", "/certs/client.key") +CA_CERT = os.environ.get("CA_CERT", "/certs/ca.crt") +TIMEOUT = int(os.environ.get("TIMEOUT", "5")) + + +@app.route("/health", methods=["GET"]) +def health(): + return "OK", 200 + + +@app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) +@app.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) +def proxy(path): + logger.info("request method=%s path=/%s", request.method, path) + + if not TARGET_URL: + return Response("TARGET_URL is not set", status=500) + + try: + url = f"{TARGET_URL.rstrip('/')}/{path}".rstrip("/") + headers = {k: v for k, v in request.headers if k.lower() != "host"} + headers["X-Forwarded-By"] = "mtls-bridge" + + resp = requests.request( + method=request.method, + url=url, + headers=headers, + data=request.get_data(), + cookies=request.cookies, + cert=(CLIENT_CERT, CLIENT_KEY), + verify=CA_CERT, + timeout=TIMEOUT, + ) + + logger.info("upstream status=%s url=%s", resp.status_code, url) + + excluded_headers = ["content-encoding", "content-length", "transfer-encoding", "connection"] + response_headers = [ + (k, v) + for k, v in resp.raw.headers.items() + if k.lower() not in excluded_headers + ] + + return Response(resp.content, resp.status_code, response_headers) + + except Exception as e: + logger.exception("proxy request failed") + return Response(str(e), status=500) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080) diff --git a/monitoring/mtls-bridge/docker-compose.yml b/monitoring/mtls-bridge/docker-compose.yml new file mode 100644 index 0000000..64c28d8 --- /dev/null +++ b/monitoring/mtls-bridge/docker-compose.yml @@ -0,0 +1,18 @@ +services: + mtls-bridge: + profiles: ["monitoring", "all", "mtls-bridge"] + build: + context: ${PROJECT_ROOT}/monitoring/mtls-bridge + container_name: mtls-bridge + restart: unless-stopped + environment: + - TARGET_URL=https://node-red.lan.ddnsgeek.com/docker-update-lockouts/clear + - CLIENT_CERT=/certs/client.crt + - CLIENT_KEY=/certs/client.key + - CA_CERT=/certs/ca.crt + - TIMEOUT=5 + volumes: + - ${PROJECT_ROOT}/core/traefik/certs:/certs:ro + networks: + - monitor + - traefik diff --git a/monitoring/mtls-bridge/requirements.txt b/monitoring/mtls-bridge/requirements.txt new file mode 100644 index 0000000..30692b7 --- /dev/null +++ b/monitoring/mtls-bridge/requirements.txt @@ -0,0 +1,2 @@ +flask +requests