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.
This commit is contained in:
Artur Mukhamadiev 2026-09-15 00:01:24 +03:00
parent 2dd2323f79
commit c853fff995
6 changed files with 182 additions and 7 deletions

2
.gitignore vendored
View File

@ -18,3 +18,5 @@ report/
# deployment secrets / local configuration # deployment secrets / local configuration
deploy/*.env deploy/*.env
# private TLS keys and certificates
deploy/tls/

View File

@ -6,7 +6,7 @@ IMAGE ?= confluence-pi-agent:rev1
FAKE_IMAGE ?= confluence-fake-agent:test FAKE_IMAGE ?= confluence-fake-agent:test
ENV_FILE ?= deploy/confluence-web.env 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: install:
$(PYTHON) -m pip install -r requirements.txt $(PYTHON) -m pip install -r requirements.txt
@ -53,6 +53,11 @@ image-checks:
run: run:
scripts/run-backend.sh $(ENV_FILE) 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=<lan-ip> [IP='<ip> <ip>']"; exit 2; }
scripts/make-tls.sh $(IP)
## Network-free UI development against explicit fakes (no Docker). ## Network-free UI development against explicit fakes (no Docker).
run-dev: run-dev:
CONFLUENCE_WEB_DEV_MODE=true CONFLUENCE_WEB_FRONTEND_DIST_DIR=$(CURDIR)/frontend \ CONFLUENCE_WEB_DEV_MODE=true CONFLUENCE_WEB_FRONTEND_DIST_DIR=$(CURDIR)/frontend \

View File

@ -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 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. 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 Version 1 is a small-team deployment: one backend worker, one query at a time
(a second query gets `busy`), bound to loopback. Do not bind it to a network (a second query gets `busy`), loopback by default. The application has no login
interface without an authenticating reverse proxy in front; if you add one, it of its own; anyone who can reach the port can run queries with their own PAT.
must accept `6*16 MiB + 64 KiB` request bodies, pass client aborts through Never bind it to a network interface over plain HTTP, because every request
promptly, avoid buffering bodies to disk, and allow at least 200 s per 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`, Network-free UI development against explicit fakes (no Docker, PAT `dev-pat`,
URL `https://approved.example.com`): URL `https://approved.example.com`):

View File

@ -33,8 +33,14 @@ CONFLUENCE_WEB_CONTAINER_LABEL_VALUE=confluence-web-local
#CONFLUENCE_WEB_DOCKER_HOST=unix:///run/user/1000/docker.sock #CONFLUENCE_WEB_DOCKER_HOST=unix:///run/user/1000/docker.sock
# --- HTTP / storage --- # --- 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_HOST=127.0.0.1
CONFLUENCE_WEB_BIND_PORT=8000 CONFLUENCE_WEB_BIND_PORT=8000
# Optional TLS termination by Uvicorn. Generate with: scripts/make-tls.sh <lan-ip>
# (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/). # Absolute path to the frontend tree (index.html, css/, js/, vendor/).
CONFLUENCE_WEB_FRONTEND_DIST_DIR=./frontend CONFLUENCE_WEB_FRONTEND_DIST_DIR=./frontend
# Private artifact storage (created 0700; purged on startup). # Private artifact storage (created 0700; purged on startup).

106
scripts/make-tls.sh Executable file
View File

@ -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 <ip> [<ip>...] (default output dir: deploy/tls)
# TLS_DIR=/path scripts/make-tls.sh <ip>
#
# 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 <ip> [<ip>...]" >&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" <<EOF
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid
subjectAltName=${san}
EOF
if [[ -e "$TLS_DIR/ca.key" ]]; then
echo "reusing existing CA $TLS_DIR/ca.crt (server certificate rotation)"
else
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout "$TLS_DIR/ca.key" -out "$TLS_DIR/ca.crt" -days "$CA_DAYS" \
-subj "/CN=Confluence Research internal CA ($1)" \
-config <(printf '[req]\ndistinguished_name=dn\n[dn]\n') \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "nameConstraints=critical,${permitted},permitted;DNS:.invalid" >/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 <<EOF
Created in $TLS_DIR:
ca.crt distribute this one file; import as a trusted root (constrained to: $*)
ca.key keep private; needed only to issue a new server.crt
server.crt server certificate for: $*
server.key server private key
Enable in the env file:
CONFLUENCE_WEB_BIND_HOST=$1
CONFLUENCE_WEB_TLS_CERT=$TLS_DIR/server.crt
CONFLUENCE_WEB_TLS_KEY=$TLS_DIR/server.key
EOF

View File

@ -36,6 +36,26 @@ if [[ "${CONFLUENCE_WEB_DEV_MODE:-false}" != "true" ]]; then
fi fi
fi fi
# Optional TLS termination by Uvicorn (see scripts/make-tls.sh). Both or neither.
TLS_ARGS=()
if [[ -n "${CONFLUENCE_WEB_TLS_CERT:-}" || -n "${CONFLUENCE_WEB_TLS_KEY:-}" ]]; then
if [[ -z "${CONFLUENCE_WEB_TLS_CERT:-}" || -z "${CONFLUENCE_WEB_TLS_KEY:-}" ]]; then
echo "CONFLUENCE_WEB_TLS_CERT and CONFLUENCE_WEB_TLS_KEY must be set together" >&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 \ 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}" \ --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[@]}"