From c853fff99517a8860d5f9785d8a7f04c9111046a Mon Sep 17 00:00:00 2001 From: Artur Mukhamadiev Date: Tue, 15 Sep 2026 00:01:24 +0300 Subject: [PATCH] deploy: optional Uvicorn TLS with a name-constrained private CA for IP-only networks scripts/make-tls.sh creates a CA whose critical nameConstraints permit only the listed IPv4 addresses (and the .invalid DNS subtree), plus an end-entity server certificate; run-backend.sh passes CONFLUENCE_WEB_TLS_CERT/KEY to uvicorn. --- .gitignore | 2 + Makefile | 7 +- README.md | 46 +++++++++++-- deploy/confluence-web.env.example | 6 ++ scripts/make-tls.sh | 106 ++++++++++++++++++++++++++++++ scripts/run-backend.sh | 22 ++++++- 6 files changed, 182 insertions(+), 7 deletions(-) create mode 100755 scripts/make-tls.sh diff --git a/.gitignore b/.gitignore index 3e5a334..63061bd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ report/ # deployment secrets / local configuration deploy/*.env +# private TLS keys and certificates +deploy/tls/ diff --git a/Makefile b/Makefile index 69ab773..1fa32ec 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ IMAGE ?= confluence-pi-agent:rev1 FAKE_IMAGE ?= confluence-fake-agent:test ENV_FILE ?= deploy/confluence-web.env -.PHONY: install check check-python check-agent check-frontend check-docker check-browser build-image build-fake-image image-checks run run-dev clean-containers +.PHONY: install check check-python check-agent check-frontend check-docker check-browser build-image build-fake-image image-checks run run-dev tls clean-containers install: $(PYTHON) -m pip install -r requirements.txt @@ -53,6 +53,11 @@ image-checks: run: scripts/run-backend.sh $(ENV_FILE) +## Name-constrained private CA + server certificate for IP-only TLS: make tls IP=172.26.10.20 +tls: + @test -n "$(IP)" || { echo "usage: make tls IP= [IP=' ']"; exit 2; } + scripts/make-tls.sh $(IP) + ## Network-free UI development against explicit fakes (no Docker). run-dev: CONFLUENCE_WEB_DEV_MODE=true CONFLUENCE_WEB_FRONTEND_DIST_DIR=$(CURDIR)/frontend \ diff --git a/README.md b/README.md index 25d3d2c..6d14075 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,47 @@ destination is reachable but cannot prove the PAT is valid; a wrong token then shows up as denied or missing pages. Credentials live only in browser memory and in the backend for the duration of a request; a page reload clears them. -Version 1 is a single-user deployment: one backend worker, one query at a time -(a second query gets `busy`), bound to loopback. Do not bind it to a network -interface without an authenticating reverse proxy in front; if you add one, it -must accept `6*16 MiB + 64 KiB` request bodies, pass client aborts through -promptly, avoid buffering bodies to disk, and allow at least 200 s per request. +Version 1 is a small-team deployment: one backend worker, one query at a time +(a second query gets `busy`), loopback by default. The application has no login +of its own; anyone who can reach the port can run queries with their own PAT. +Never bind it to a network interface over plain HTTP, because every request +carries the user's PAT. If you put a reverse proxy in front, it must accept +`6*16 MiB + 64 KiB` request bodies, pass client aborts through promptly, avoid +buffering bodies to disk, and allow at least 200 s per request. + +### Serving colleagues on an internal network (IP only, TLS) + +Uvicorn terminates TLS itself; no reverse proxy or DNS name is needed. The +certificate comes from a private CA that is **name-constrained** to the listed +IP addresses, so colleagues who import it are trusting it for those addresses +only. A certificate issued by the same CA for any hostname or any other address +is rejected by browsers and by OpenSSL (`permitted subtree violation`). + +```bash +make tls IP=172.26.10.20 # creates deploy/tls/{ca,server}.{key,crt}; git-ignored +``` + +Then in `deploy/confluence-web.env`: + +``` +CONFLUENCE_WEB_BIND_HOST=172.26.10.20 +CONFLUENCE_WEB_TLS_CERT=./deploy/tls/server.crt +CONFLUENCE_WEB_TLS_KEY=./deploy/tls/server.key +``` + +Restart with `make run` and open `https://172.26.10.20:8000/`. Under HTTPS the +session cookie gains the `Secure` flag automatically and the same-origin check +expects `https://` origins. + +Hand colleagues only `deploy/tls/ca.crt`, imported once as a trusted root +(Windows: double-click, "Trusted Root Certification Authorities"; macOS: +Keychain Access; Chrome on Linux: chrome://settings/certificates; Firefox: +Settings, Certificates, Authorities). They can read the constraint themselves in +the certificate details ("Name Constraints: Permitted IP ..."). Keep `ca.key` +and `server.key` on the server host. The server certificate is valid for 825 +days (the browser maximum); rotate it with the same CA by deleting +`deploy/tls/server.*` and re-running `make tls` with the CA files kept, or +rotate everything by deleting the directory. Network-free UI development against explicit fakes (no Docker, PAT `dev-pat`, URL `https://approved.example.com`): diff --git a/deploy/confluence-web.env.example b/deploy/confluence-web.env.example index 991a5ec..37fecb7 100644 --- a/deploy/confluence-web.env.example +++ b/deploy/confluence-web.env.example @@ -33,8 +33,14 @@ CONFLUENCE_WEB_CONTAINER_LABEL_VALUE=confluence-web-local #CONFLUENCE_WEB_DOCKER_HOST=unix:///run/user/1000/docker.sock # --- HTTP / storage --- +# Loopback only by default. To serve colleagues on an internal network, bind the +# host's LAN address AND enable TLS below (PATs must not cross the network in clear). CONFLUENCE_WEB_BIND_HOST=127.0.0.1 CONFLUENCE_WEB_BIND_PORT=8000 +# Optional TLS termination by Uvicorn. Generate with: scripts/make-tls.sh +# (name-constrained private CA; distribute deploy/tls/ca.crt to colleagues). +#CONFLUENCE_WEB_TLS_CERT=./deploy/tls/server.crt +#CONFLUENCE_WEB_TLS_KEY=./deploy/tls/server.key # Absolute path to the frontend tree (index.html, css/, js/, vendor/). CONFLUENCE_WEB_FRONTEND_DIST_DIR=./frontend # Private artifact storage (created 0700; purged on startup). diff --git a/scripts/make-tls.sh b/scripts/make-tls.sh new file mode 100755 index 0000000..1276c67 --- /dev/null +++ b/scripts/make-tls.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Create a name-constrained private CA and a server certificate for IP-only access. +# +# scripts/make-tls.sh [...] (default output dir: deploy/tls) +# TLS_DIR=/path scripts/make-tls.sh +# +# The CA carries a critical nameConstraints extension that permits only the listed +# IP addresses and no DNS names, so importing ca.crt as a trusted root lets a browser +# accept certificates from this CA for those addresses only. The server certificate +# is an end-entity certificate (CA:FALSE) with the addresses as subjectAltName. +# +# Distribute only ca.crt. Keep ca.key and server.key on the server host. +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 [...]" >&2 + exit 2 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TLS_DIR="${TLS_DIR:-$ROOT/deploy/tls}" +CA_DAYS="${CA_DAYS:-3650}" +LEAF_DAYS="${LEAF_DAYS:-825}" # Chrome and Safari reject server certificates valid for longer. + +for ip in "$@"; do + if [[ ! "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then + echo "only IPv4 addresses are supported: $ip" >&2 + exit 2 + fi +done + +if [[ -e "$TLS_DIR/server.key" || -e "$TLS_DIR/server.crt" ]]; then + echo "refusing to overwrite $TLS_DIR/server.*; delete them to rotate the server certificate" >&2 + exit 1 +fi +if [[ -e "$TLS_DIR/ca.key" && ! -e "$TLS_DIR/ca.crt" ]] || [[ ! -e "$TLS_DIR/ca.key" && -e "$TLS_DIR/ca.crt" ]]; then + echo "incomplete CA in $TLS_DIR (need both ca.key and ca.crt, or neither)" >&2 + exit 1 +fi + +umask 077 +mkdir -p "$TLS_DIR" + +permitted="" +san="" +for ip in "$@"; do + permitted+="permitted;IP:${ip}/255.255.255.255," + san+="IP:${ip}," +done +permitted="${permitted%,}" +san="${san%,}" + +# Only the listed addresses may appear in certificates from this CA. Any DNS name is +# forced to match the reserved .invalid domain, which no real host can, so this root +# can never vouch for a hostname. +cat > "$TLS_DIR/server.ext" </dev/null 2>&1 +fi + +openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ + -keyout "$TLS_DIR/server.key" -out "$TLS_DIR/server.csr" -subj "/CN=Confluence Research server ($1)" \ + -config <(printf '[req]\ndistinguished_name=dn\n[dn]\n') >/dev/null 2>&1 + +openssl x509 -req -in "$TLS_DIR/server.csr" -CA "$TLS_DIR/ca.crt" -CAkey "$TLS_DIR/ca.key" \ + -CAcreateserial -out "$TLS_DIR/server.crt" -days "$LEAF_DAYS" \ + -extfile "$TLS_DIR/server.ext" >/dev/null 2>&1 + +rm -f "$TLS_DIR/server.csr" "$TLS_DIR/ca.srl" "$TLS_DIR/server.ext" +chmod 0644 "$TLS_DIR/ca.crt" "$TLS_DIR/server.crt" + +# Fails if an existing CA does not permit one of the requested addresses. +if ! openssl verify -CAfile "$TLS_DIR/ca.crt" "$TLS_DIR/server.crt" >/dev/null; then + echo "server certificate does not verify under $TLS_DIR/ca.crt (CA name constraints do not cover $*?)" >&2 + rm -f "$TLS_DIR/server.key" "$TLS_DIR/server.crt" + exit 1 +fi + +cat <&2 + exit 1 + fi + # Relative paths resolve against the repository root, like the frontend directory. + [[ "$CONFLUENCE_WEB_TLS_CERT" != /* ]] && CONFLUENCE_WEB_TLS_CERT="$ROOT/${CONFLUENCE_WEB_TLS_CERT#./}" + [[ "$CONFLUENCE_WEB_TLS_KEY" != /* ]] && CONFLUENCE_WEB_TLS_KEY="$ROOT/${CONFLUENCE_WEB_TLS_KEY#./}" + for f in "$CONFLUENCE_WEB_TLS_CERT" "$CONFLUENCE_WEB_TLS_KEY"; do + if [[ ! -r "$f" ]]; then + echo "TLS file not readable: $f" >&2 + exit 1 + fi + done + TLS_ARGS=(--ssl-certfile "$CONFLUENCE_WEB_TLS_CERT" --ssl-keyfile "$CONFLUENCE_WEB_TLS_KEY") +fi + exec "$PYTHON" -m uvicorn backend.app:create_app --factory --workers 1 \ --host "${CONFLUENCE_WEB_BIND_HOST:-127.0.0.1}" --port "${CONFLUENCE_WEB_BIND_PORT:-8000}" \ - --no-server-header --timeout-keep-alive 5 --limit-concurrency 32 --app-dir "$ROOT" + --no-server-header --timeout-keep-alive 5 --limit-concurrency 32 --app-dir "$ROOT" \ + "${TLS_ARGS[@]}"