Add optional allow-list support for mtls-bridge paths

This commit is contained in:
beatz174-bit
2026-04-14 12:07:17 +10:00
parent 361d2dc87b
commit 27c5c3f631
4 changed files with 106 additions and 116 deletions
+2 -1
View File
@@ -69,5 +69,6 @@ MTLS_BRIDGE_LOG_LEVEL=INFO
MTLS_BRIDGE_TIMEOUT=5 MTLS_BRIDGE_TIMEOUT=5
MTLS_BRIDGE_CLIENT_KEY=/certs/clients/office-pc/office-pc.key MTLS_BRIDGE_CLIENT_KEY=/certs/clients/office-pc/office-pc.key
MTLS_BRIDGE_CLIENT_CERT=/certs/clients/office-pc/office-pc.crt MTLS_BRIDGE_CLIENT_CERT=/certs/clients/office-pc/office-pc.crt
MTLS_BRIDGE_TARGET_URL=http://node-red:1880/docker-update-lockouts/clear MTLS_BRIDGE_TARGET_URL=http://node-red:1880
MTLS_BRIDGE_ALLOWED_PATHS_FILE=
MTLS_BRIDGE_CORS_ALLOW_ORIGIN=https://grafana.lan.ddnsgeek.com MTLS_BRIDGE_CORS_ALLOW_ORIGIN=https://grafana.lan.ddnsgeek.com
+28 -8
View File
@@ -5,24 +5,33 @@ Internal HTTP-to-mTLS bridge for services that cannot present client certificate
## How it works ## How it works
1. Accepts plain HTTP requests inside the Docker network. 1. Accepts plain HTTP requests inside the Docker network.
2. Forwards requests to an HTTPS upstream. 2. Forwards requests to an upstream base URL.
3. Presents a client certificate/key pair for mTLS authentication. 3. Preserves the incoming request path/method/body/query string.
4. Presents a client certificate/key pair for mTLS authentication.
## Environment variables ## Environment variables
- `TARGET_URL` (required): HTTPS upstream base URL. - `TARGET_URL` (required): upstream base URL (for example `http://node-red:1880`).
- `CLIENT_CERT` (default `/certs/client.crt`): client certificate path. - `CLIENT_CERT` (default `/certs/client.crt`): client certificate path.
- `CLIENT_KEY` (default `/certs/client.key`): client private key 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. - `UPSTREAM_CA_CERT` (optional, alias: `CA_CERT`): CA bundle path to verify upstream TLS. Use `false`/`0`/`no` to disable verification.
- `TIMEOUT` (default `5`): request timeout in seconds. - `TIMEOUT` (default `5`): request timeout in seconds.
- `LOG_LEVEL` (default `INFO`): Python logging level. - `LOG_LEVEL` (default `INFO`): Python logging level.
- `HEALTH_ENDPOINT` (default `/_mtls_bridge/health`): local container health endpoint path.
- `ALLOWED_PATHS_FILE` (optional): file path containing one allowed endpoint path per line (for example `/health`). Blank lines and `#` comments are ignored. If unset, all paths are allowed.
- `MTLS_BRIDGE_BASIC_AUTH_USERS` (required for Traefik auth): value for `traefik.http.middlewares.*.basicauth.users` (e.g. `user:$$apr1$$...`). - `MTLS_BRIDGE_BASIC_AUTH_USERS` (required for Traefik auth): value for `traefik.http.middlewares.*.basicauth.users` (e.g. `user:$$apr1$$...`).
- `MTLS_BRIDGE_CORS_ALLOW_ORIGIN` (default `https://grafana.lan.ddnsgeek.com`): origin allowed for browser-based panel actions. - `MTLS_BRIDGE_CORS_ALLOW_ORIGIN` (default `https://grafana.lan.ddnsgeek.com`): origin allowed for browser-based panel actions.
## Endpoints ## Endpoints
- `GET /health` returns `200 OK` for container health checks. - `GET /_mtls_bridge/health` returns `200 OK` for container health checks.
- `/*` proxies requests to `${TARGET_URL}` with method/body/headers preserved. - `/*` proxies requests to `${TARGET_URL}/*` with method/body/headers/query string preserved (subject to optional allow-list checks).
Examples with `TARGET_URL=http://node-red:1880`:
- `https://mtls-bridge.../docker-update-lockouts/clear` -> `http://node-red:1880/docker-update-lockouts/clear`
- `https://mtls-bridge.../health` -> `http://node-red:1880/health`
- `https://mtls-bridge.../uptime-kuma` -> `http://node-red:1880/uptime-kuma`
## Compose integration ## Compose integration
@@ -35,6 +44,17 @@ This repository includes `monitoring/mtls-bridge/docker-compose.yml`:
## Example test ## Example test
```bash ```bash
curl http://mtls-bridge:8080/health curl http://mtls-bridge:8080/_mtls_bridge/health
curl -X POST http://mtls-bridge:8080 curl -X POST http://mtls-bridge:8080/docker-update-lockouts/clear
``` ```
## Allow-list file example
```text
# one path per line
/docker-update-lockouts/clear
/health
/uptime-kuma
```
When `ALLOWED_PATHS_FILE` is set, any path not listed returns `403 Endpoint not allowed`.
+74 -106
View File
@@ -1,6 +1,7 @@
import logging import logging
import os import os
import time import time
from urllib.parse import urljoin
import requests import requests
from flask import Flask, Response, g, request from flask import Flask, Response, g, request
@@ -15,23 +16,39 @@ logger = logging.getLogger("mtls-bridge")
logging.getLogger("werkzeug").setLevel(logging.WARNING) logging.getLogger("werkzeug").setLevel(logging.WARNING)
# Config via env # Config via env
TARGET_URL = os.environ.get("TARGET_URL") TARGET_URL = (os.environ.get("TARGET_URL") or "").strip()
CLIENT_CERT = os.environ.get("CLIENT_CERT", "/certs/client.crt") CLIENT_CERT = os.environ.get("CLIENT_CERT", "/certs/client.crt")
CLIENT_KEY = os.environ.get("CLIENT_KEY", "/certs/client.key") CLIENT_KEY = os.environ.get("CLIENT_KEY", "/certs/client.key")
UPSTREAM_CA_CERT = os.environ.get("UPSTREAM_CA_CERT", os.environ.get("CA_CERT", "")).strip() UPSTREAM_CA_CERT = os.environ.get("UPSTREAM_CA_CERT", os.environ.get("CA_CERT", "")).strip()
# Backward-compat alias: keep CA_CERT defined so legacy code paths/log statements don't crash.
CA_CERT = UPSTREAM_CA_CERT
TIMEOUT = int(os.environ.get("TIMEOUT", "5")) TIMEOUT = int(os.environ.get("TIMEOUT", "5"))
HEALTH_ENDPOINT = os.environ.get("HEALTH_ENDPOINT", "/_mtls_bridge/health")
ALLOWED_PATHS_FILE = (os.environ.get("ALLOWED_PATHS_FILE") or "").strip()
logger.info(
"mtls-bridge starting target_url=%s timeout=%ss cert=%s key=%s ca=%s log_level=%s", def normalize_path(path: str) -> str:
TARGET_URL, if not path or path == "/":
TIMEOUT, return "/"
CLIENT_CERT, return f"/{path.lstrip('/')}"
CLIENT_KEY,
CA_CERT,
os.environ.get("LOG_LEVEL", "INFO"), def load_allowed_paths() -> set[str]:
) if not ALLOWED_PATHS_FILE:
return set()
if not os.path.exists(ALLOWED_PATHS_FILE):
logger.warning("ALLOWED_PATHS_FILE does not exist: %s (allow-list disabled)", ALLOWED_PATHS_FILE)
return set()
allowed_paths = set()
with open(ALLOWED_PATHS_FILE, encoding="utf-8") as f:
for line in f:
entry = line.strip()
if not entry or entry.startswith("#"):
continue
allowed_paths.add(normalize_path(entry))
logger.info("loaded %s allowed path(s) from %s", len(allowed_paths), ALLOWED_PATHS_FILE)
return allowed_paths
def get_verify_setting(): def get_verify_setting():
@@ -54,95 +71,47 @@ def get_verify_setting():
VERIFY_SETTING = get_verify_setting() VERIFY_SETTING = get_verify_setting()
ALLOWED_PATHS = load_allowed_paths()
logger.info(
"mtls-bridge starting target_url=%s timeout=%ss cert=%s key=%s verify=%s log_level=%s",
TARGET_URL,
TIMEOUT,
CLIENT_CERT,
CLIENT_KEY,
VERIFY_SETTING,
os.environ.get("LOG_LEVEL", "INFO"),
)
def get_verify_setting():
if not UPSTREAM_CA_CERT:
return True
lowered = UPSTREAM_CA_CERT.lower()
if lowered in {"false", "0", "no"}:
logger.warning("TLS verification for upstream is disabled via UPSTREAM_CA_CERT=%s", UPSTREAM_CA_CERT)
return False
if not os.path.exists(UPSTREAM_CA_CERT):
logger.warning(
"Configured UPSTREAM_CA_CERT path does not exist: %s (falling back to system CA bundle)",
UPSTREAM_CA_CERT,
)
return True
return UPSTREAM_CA_CERT
VERIFY_SETTING = get_verify_setting()
logger.info(
"mtls-bridge starting target_url=%s timeout=%ss cert=%s key=%s verify=%s log_level=%s",
TARGET_URL,
TIMEOUT,
CLIENT_CERT,
CLIENT_KEY,
VERIFY_SETTING,
os.environ.get("LOG_LEVEL", "INFO"),
)
if TARGET_URL and TARGET_URL.lower().startswith("http://"): if TARGET_URL and TARGET_URL.lower().startswith("http://"):
logger.warning( logger.warning("TARGET_URL uses http:// (plaintext): %s", TARGET_URL)
"TARGET_URL uses http://; upstream may redirect to https:// and change request behavior: %s",
TARGET_URL,
)
def get_verify_setting():
if not UPSTREAM_CA_CERT:
return True
lowered = UPSTREAM_CA_CERT.lower()
if lowered in {"false", "0", "no"}:
logger.warning("TLS verification for upstream is disabled via UPSTREAM_CA_CERT=%s", UPSTREAM_CA_CERT)
return False
if not os.path.exists(UPSTREAM_CA_CERT):
logger.warning(
"Configured UPSTREAM_CA_CERT path does not exist: %s (falling back to system CA bundle)",
UPSTREAM_CA_CERT,
)
return True
return UPSTREAM_CA_CERT
VERIFY_SETTING = get_verify_setting()
logger.info( logger.info(
"mtls-bridge starting target_url=%s timeout=%ss cert=%s key=%s verify=%s log_level=%s", "mtls-bridge starting target_url=%s timeout=%ss cert=%s key=%s verify=%s health_endpoint=%s allow_list_file=%s allow_list_entries=%s log_level=%s",
TARGET_URL, TARGET_URL,
TIMEOUT, TIMEOUT,
CLIENT_CERT, CLIENT_CERT,
CLIENT_KEY, CLIENT_KEY,
VERIFY_SETTING, VERIFY_SETTING,
HEALTH_ENDPOINT,
ALLOWED_PATHS_FILE,
len(ALLOWED_PATHS),
os.environ.get("LOG_LEVEL", "INFO"), os.environ.get("LOG_LEVEL", "INFO"),
) )
if TARGET_URL and TARGET_URL.lower().startswith("http://"):
logger.warning( def build_upstream_url(path: str) -> str:
"TARGET_URL uses http://; upstream may redirect to https:// and change request behavior: %s", """Map incoming path directly onto TARGET_URL origin/base path."""
TARGET_URL, if not TARGET_URL:
) raise ValueError("TARGET_URL is not set")
normalized_target = TARGET_URL.rstrip("/") + "/"
normalized_path = path.lstrip("/")
upstream_url = urljoin(normalized_target, normalized_path)
if request.query_string:
upstream_url = f"{upstream_url}?{request.query_string.decode('utf-8', 'ignore')}"
return upstream_url
@app.route("/health", methods=["GET"]) def is_path_allowed(request_path: str) -> bool:
if not ALLOWED_PATHS:
return True
return request_path in ALLOWED_PATHS
@app.route(HEALTH_ENDPOINT, methods=["GET"])
def health(): def health():
logger.debug("healthcheck request from %s", request.remote_addr) logger.debug("healthcheck request from %s", request.remote_addr)
return "OK", 200 return "OK", 200
@@ -156,7 +125,7 @@ def before_request():
@app.after_request @app.after_request
def after_request(response): def after_request(response):
elapsed_ms = int((time.time() - g.request_start) * 1000) elapsed_ms = int((time.time() - g.request_start) * 1000)
if request.path != "/health": if request.path != HEALTH_ENDPOINT:
logger.info( logger.info(
"request complete method=%s path=%s status=%s elapsed_ms=%s", "request complete method=%s path=%s status=%s elapsed_ms=%s",
request.method, request.method,
@@ -179,7 +148,7 @@ def after_request(response):
provide_automatic_options=False, provide_automatic_options=False,
) )
def proxy(path): def proxy(path):
request_path = f"/{path}" if path else "/" request_path = normalize_path(path)
request_size = len(request.get_data(cache=True)) request_size = len(request.get_data(cache=True))
logger.info( logger.info(
"incoming request method=%s path=%s query=%s remote=%s bytes=%s", "incoming request method=%s path=%s query=%s remote=%s bytes=%s",
@@ -190,50 +159,49 @@ def proxy(path):
request_size, request_size,
) )
if not TARGET_URL: if not is_path_allowed(request_path):
logger.error("TARGET_URL is not set; cannot proxy request") logger.warning("request blocked by allow-list path=%s", request_path)
return Response("TARGET_URL is not set", status=500) return Response("Endpoint not allowed", status=403)
try: try:
url = f"{TARGET_URL.rstrip('/')}/{path}".rstrip("/") upstream_url = build_upstream_url(path)
start_time = time.time()
headers = {k: v for k, v in request.headers if k.lower() != "host"} headers = {k: v for k, v in request.headers if k.lower() != "host"}
headers["X-Forwarded-By"] = "mtls-bridge" headers["X-Forwarded-By"] = "mtls-bridge"
logger.debug("forwarding request to upstream url=%s headers=%s", url, headers) start_time = time.time()
resp = requests.request( resp = requests.request(
method=request.method, method=request.method,
url=url, url=upstream_url,
headers=headers, headers=headers,
data=request.get_data(cache=True), data=request.get_data(cache=True),
cookies=request.cookies, cookies=request.cookies,
cert=(CLIENT_CERT, CLIENT_KEY), cert=(CLIENT_CERT, CLIENT_KEY),
verify=VERIFY_SETTING, verify=VERIFY_SETTING,
timeout=TIMEOUT, timeout=TIMEOUT,
allow_redirects=False,
) )
elapsed_ms = int((time.time() - start_time) * 1000) elapsed_ms = int((time.time() - start_time) * 1000)
logger.info( logger.info(
"upstream response status=%s url=%s elapsed_ms=%s response_bytes=%s", "upstream response status=%s url=%s elapsed_ms=%s response_bytes=%s",
resp.status_code, resp.status_code,
url, upstream_url,
elapsed_ms, elapsed_ms,
len(resp.content), len(resp.content),
) )
excluded_headers = ["content-encoding", "content-length", "transfer-encoding", "connection"] excluded_headers = {"content-encoding", "content-length", "transfer-encoding", "connection"}
response_headers = [ response_headers = [(k, v) for k, v in resp.headers.items() if k.lower() not in excluded_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) return Response(resp.content, resp.status_code, response_headers)
except Exception as e: except ValueError as exc:
logger.error("proxy request failed: %s", exc)
return Response(str(exc), status=500)
except Exception as exc: # noqa: BLE001
logger.exception("proxy request failed") logger.exception("proxy request failed")
return Response(str(e), status=500) return Response(str(exc), status=500)
if __name__ == "__main__": if __name__ == "__main__":
+2 -1
View File
@@ -13,6 +13,7 @@ services:
- TIMEOUT=${MTLS_BRIDGE_TIMEOUT} - TIMEOUT=${MTLS_BRIDGE_TIMEOUT}
- LOG_LEVEL=${MTLS_BRIDGE_LOG_LEVEL:-INFO} - LOG_LEVEL=${MTLS_BRIDGE_LOG_LEVEL:-INFO}
- UPSTREAM_CA_CERT=${MTLS_BRIDGE_UPSTREAM_CA_CERT:-} - UPSTREAM_CA_CERT=${MTLS_BRIDGE_UPSTREAM_CA_CERT:-}
- ALLOWED_PATHS_FILE=${MTLS_BRIDGE_ALLOWED_PATHS_FILE:-}
volumes: volumes:
- ${PROJECT_ROOT}/core/traefik/certs:/certs:ro - ${PROJECT_ROOT}/core/traefik/certs:/certs:ro
labels: labels:
@@ -38,7 +39,7 @@ services:
- "traefik.http.services.mtls-bridge.loadbalancer.server.port=8080" - "traefik.http.services.mtls-bridge.loadbalancer.server.port=8080"
- "traefik.docker.network=core_traefik" - "traefik.docker.network=core_traefik"
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=3).read()"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/_mtls_bridge/health', timeout=3).read()"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3