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:
parent
2dd2323f79
commit
c853fff995
2
.gitignore
vendored
2
.gitignore
vendored
@ -18,3 +18,5 @@ report/
|
|||||||
|
|
||||||
# deployment secrets / local configuration
|
# deployment secrets / local configuration
|
||||||
deploy/*.env
|
deploy/*.env
|
||||||
|
# private TLS keys and certificates
|
||||||
|
deploy/tls/
|
||||||
|
|||||||
7
Makefile
7
Makefile
@ -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 \
|
||||||
|
|||||||
46
README.md
46
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
|
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`):
|
||||||
|
|||||||
@ -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
106
scripts/make-tls.sh
Executable 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
|
||||||
@ -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[@]}"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user