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
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
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=<lan-ip> [IP='<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 \

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
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`):

View File

@ -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 <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/).
CONFLUENCE_WEB_FRONTEND_DIST_DIR=./frontend
# 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
# 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 \
--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[@]}"