From 4511d3a0a30c7c472cffd259e3b792a2d7474c4f Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sun, 26 Jul 2026 14:15:24 -0500 Subject: [PATCH] update --- .env.example | 7 ++ compose.tls.yaml | 1 + compose.yaml | 3 +- config/.gitignore | 2 + config/ssl/.gitkeep | 1 + deploy/nginx/templates/tls.conf.template | 8 +-- docs/deployment.md | 32 +++++++-- manage.sh | 42 +++++++++-- update.sh | 91 ++++++++++++++++++++++++ 9 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 config/ssl/.gitkeep create mode 100755 update.sh diff --git a/.env.example b/.env.example index b82ff7d..7fe4886 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,13 @@ HTTP_PORT=8680 HTTPS_PORT=8643 GITEA_SSH_PORT=2222 +# letsencrypt: manage a certificate automatically with Certbot. +# custom: read the named certificate files from ./config/ssl. +SSL_MODE=letsencrypt +SSL_CERTIFICATE_DIR=./config/letsencrypt/live/broker.example.com +SSL_CERTIFICATE_FILE=fullchain.pem +SSL_CERTIFICATE_KEY_FILE=privkey.pem + TAPM_LISTEN_ADDR=:8080 TAPM_DATABASE_DSN=file:/data/tapm.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL) TAPM_PUBLIC_URL=https://broker.example.com diff --git a/compose.tls.yaml b/compose.tls.yaml index 00a1c26..20e261b 100644 --- a/compose.tls.yaml +++ b/compose.tls.yaml @@ -2,3 +2,4 @@ services: nginx: volumes: - ./deploy/nginx/templates/tls.conf.template:/etc/nginx/templates/default.conf.template:ro + - ${SSL_CERTIFICATE_DIR:-./config/ssl}:/etc/nginx/ssl:ro diff --git a/compose.yaml b/compose.yaml index 3ed1b31..b18052a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -37,11 +37,12 @@ services: - "${HTTPS_PORT:-8643}:443" volumes: - ./deploy/nginx/templates/bootstrap.conf.template:/etc/nginx/templates/default.conf.template:ro - - ./config/letsencrypt:/etc/letsencrypt:ro - ./config/certbot-webroot:/var/www/certbot:ro environment: BROKER_DOMAIN: ${BROKER_DOMAIN} GITEA_DOMAIN: ${GITEA_DOMAIN} + SSL_CERTIFICATE_FILE: ${SSL_CERTIFICATE_FILE:-fullchain.pem} + SSL_CERTIFICATE_KEY_FILE: ${SSL_CERTIFICATE_KEY_FILE:-privkey.pem} security_opt: - no-new-privileges:true diff --git a/config/.gitignore b/config/.gitignore index f0159c4..e7a0e1c 100644 --- a/config/.gitignore +++ b/config/.gitignore @@ -11,6 +11,8 @@ !gitea/config/.gitkeep !letsencrypt/ !letsencrypt/.gitkeep +!ssl/ +!ssl/.gitkeep !certbot-webroot/ !certbot-webroot/.gitkeep !backups/ diff --git a/config/ssl/.gitkeep b/config/ssl/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/ssl/.gitkeep @@ -0,0 +1 @@ + diff --git a/deploy/nginx/templates/tls.conf.template b/deploy/nginx/templates/tls.conf.template index 0e29543..bdd45f4 100644 --- a/deploy/nginx/templates/tls.conf.template +++ b/deploy/nginx/templates/tls.conf.template @@ -28,8 +28,8 @@ server { http2 on; server_name ${BROKER_DOMAIN}; - ssl_certificate /etc/letsencrypt/live/${BROKER_DOMAIN}/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/${BROKER_DOMAIN}/privkey.pem; + ssl_certificate /etc/nginx/ssl/${SSL_CERTIFICATE_FILE}; + ssl_certificate_key /etc/nginx/ssl/${SSL_CERTIFICATE_KEY_FILE}; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_protocols TLSv1.2 TLSv1.3; @@ -56,8 +56,8 @@ server { http2 on; server_name ${GITEA_DOMAIN}; - ssl_certificate /etc/letsencrypt/live/${BROKER_DOMAIN}/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/${BROKER_DOMAIN}/privkey.pem; + ssl_certificate /etc/nginx/ssl/${SSL_CERTIFICATE_FILE}; + ssl_certificate_key /etc/nginx/ssl/${SSL_CERTIFICATE_KEY_FILE}; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_protocols TLSv1.2 TLSv1.3; diff --git a/docs/deployment.md b/docs/deployment.md index 1ea2512..4456511 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -57,6 +57,7 @@ config/ │ ├── config/ # app.ini and Gitea configuration │ └── data/ # repositories, packages, and gitea.db ├── letsencrypt/ # account data, certificate, and private key +├── ssl/ # optional administrator-provided certificate ├── certbot-webroot/ # HTTP-01 challenge files └── backups/ # local offline snapshots ``` @@ -70,8 +71,24 @@ The contents are ignored by Git. They must never be committed. ``` This prepares the runtime directories, creates the private Docker network, -starts Gitea and the HTTP-only Nginx configuration, obtains one certificate -covering both domains, and switches Nginx to TLS. +starts Gitea and the HTTP-only Nginx configuration, obtains a certificate when +using Let's Encrypt, and switches Nginx to TLS. + +For an administrator-provided certificate instead, place the certificate and +key in `config/ssl/` and set: + +```dotenv +SSL_MODE=custom +SSL_CERTIFICATE_DIR=./config/ssl +SSL_CERTIFICATE_FILE=fullchain.pem +SSL_CERTIFICATE_KEY_FILE=privkey.pem +``` + +The certificate must cover both `BROKER_DOMAIN` and `GITEA_DOMAIN`. With custom +mode, `bootstrap` skips Certbot and `renew` is intentionally unavailable; +replace the files through the certificate provider's process and restart or +reload Nginx. `SSL_CERTIFICATE_DIR` can point at another directory relative to +the repository or at an absolute host path. Create the initial Gitea administrator: @@ -122,7 +139,7 @@ the transition. ## 6. Renewal and backups -Run renewal twice daily from root's crontab: +When `SSL_MODE=letsencrypt`, run renewal twice daily from root's crontab: ```cron 17 3,15 * * * cd /YOUR/INSTALL/PARENT/TA-Deployment-Access && ./manage.sh renew @@ -142,10 +159,13 @@ datacenter loss. Also back up `.env` through a secrets-aware system. ```sh cd /YOUR/INSTALL/PARENT/TA-Deployment-Access -git pull --ff-only -docker compose --env-file .env -f deploy/gitea/compose.yaml pull -./manage.sh start +./update.sh ``` +The updater operates only on the current VM. It fast-forwards the checked-out +branch, pulls the pinned Gitea image, rebuilds and restarts the local stack, and +waits for the local broker to become healthy. It has no peer discovery, SSH, or +multi-node update behavior. + Pin image versions as supplied and review release notes before changing them. Never change Gitea between rootless and rootful image families in place. diff --git a/manage.sh b/manage.sh index 890f2ed..74c3b03 100755 --- a/manage.sh +++ b/manage.sh @@ -31,9 +31,32 @@ require_env() { BROKER_DOMAIN="$(read_env_setting BROKER_DOMAIN)" GITEA_DOMAIN="$(read_env_setting GITEA_DOMAIN)" LETSENCRYPT_EMAIL="$(read_env_setting LETSENCRYPT_EMAIL)" + SSL_MODE="$(read_env_setting SSL_MODE)" + SSL_MODE="${SSL_MODE:-letsencrypt}" + SSL_CERTIFICATE_DIR="$(read_env_setting SSL_CERTIFICATE_DIR)" + SSL_CERTIFICATE_FILE="$(read_env_setting SSL_CERTIFICATE_FILE)" + SSL_CERTIFICATE_KEY_FILE="$(read_env_setting SSL_CERTIFICATE_KEY_FILE)" + SSL_CERTIFICATE_FILE="${SSL_CERTIFICATE_FILE:-fullchain.pem}" + SSL_CERTIFICATE_KEY_FILE="${SSL_CERTIFICATE_KEY_FILE:-privkey.pem}" : "${BROKER_DOMAIN:?BROKER_DOMAIN is required}" : "${GITEA_DOMAIN:?GITEA_DOMAIN is required}" - : "${LETSENCRYPT_EMAIL:?LETSENCRYPT_EMAIL is required}" + case "$SSL_MODE" in + letsencrypt) + : "${LETSENCRYPT_EMAIL:?LETSENCRYPT_EMAIL is required for SSL_MODE=letsencrypt}" + SSL_CERTIFICATE_DIR="${SSL_CERTIFICATE_DIR:-./config/letsencrypt/live/${BROKER_DOMAIN}}" + ;; + custom) + SSL_CERTIFICATE_DIR="${SSL_CERTIFICATE_DIR:-./config/ssl}" + [[ -f "${SSL_CERTIFICATE_DIR}/${SSL_CERTIFICATE_FILE}" ]] || + fail "custom certificate was not found at ${SSL_CERTIFICATE_DIR}/${SSL_CERTIFICATE_FILE}" + [[ -f "${SSL_CERTIFICATE_DIR}/${SSL_CERTIFICATE_KEY_FILE}" ]] || + fail "custom certificate key was not found at ${SSL_CERTIFICATE_DIR}/${SSL_CERTIFICATE_KEY_FILE}" + ;; + *) + fail "SSL_MODE must be letsencrypt or custom" + ;; + esac + export SSL_CERTIFICATE_DIR SSL_CERTIFICATE_FILE SSL_CERTIFICATE_KEY_FILE } prepare() { @@ -42,6 +65,7 @@ prepare() { config/gitea/data \ config/gitea/config \ config/letsencrypt \ + config/ssl \ config/certbot-webroot \ config/backups chmod 700 config/broker config/gitea/data config/gitea/config config/backups @@ -66,12 +90,14 @@ bootstrap) prepare gitea_compose up -d docker compose up -d nginx - docker compose --profile tools run --rm certbot certonly \ - --webroot --webroot-path /var/www/certbot \ - --non-interactive --agree-tos \ - --email "$LETSENCRYPT_EMAIL" \ - --cert-name "$BROKER_DOMAIN" \ - -d "$BROKER_DOMAIN" -d "$GITEA_DOMAIN" + if [[ "$SSL_MODE" == "letsencrypt" ]]; then + docker compose --profile tools run --rm certbot certonly \ + --webroot --webroot-path /var/www/certbot \ + --non-interactive --agree-tos \ + --email "$LETSENCRYPT_EMAIL" \ + --cert-name "$BROKER_DOMAIN" \ + -d "$BROKER_DOMAIN" -d "$GITEA_DOMAIN" + fi docker compose -f compose.yaml -f compose.tls.yaml up -d nginx ;; start) @@ -82,6 +108,8 @@ start) ;; renew) require_env + [[ "$SSL_MODE" == "letsencrypt" ]] || + fail "automatic renewal is only available with SSL_MODE=letsencrypt" docker compose --profile tools run --rm certbot renew --quiet docker compose -f compose.yaml -f compose.tls.yaml exec nginx nginx -s reload ;; diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..997260e --- /dev/null +++ b/update.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +DEPLOY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +SCRIPT_PATH="${DEPLOY_ROOT}/$(basename -- "${BASH_SOURCE[0]}")" +cd "$DEPLOY_ROOT" + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +command -v git >/dev/null 2>&1 || fail "git is required" +command -v docker >/dev/null 2>&1 || fail "docker is required" +docker compose version >/dev/null 2>&1 || + fail "Docker Compose v2 is required" +[[ -f .env ]] || fail ".env was not found in ${DEPLOY_ROOT}" + +post_pull=0 +if [[ "${1:-}" == "--post-pull" ]]; then + post_pull=1 +elif [[ $# -ne 0 ]]; then + fail "usage: ${0} [--post-pull]" +fi + +if ((post_pull == 0)); then + branch="$(git symbolic-ref --quiet --short HEAD)" || + fail "the repository is in detached HEAD state" + upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)" || + fail "branch ${branch} does not have an upstream branch" + remote="$(git config --get "branch.${branch}.remote")" || + fail "branch ${branch} does not have a configured remote" + + git diff --quiet && git diff --cached --quiet || + fail "tracked local changes must be committed or stashed before updating" + + printf 'Checking %s against %s...\n' "$branch" "$upstream" + git fetch --quiet --prune "$remote" + + local_commit="$(git rev-parse HEAD)" + upstream_commit="$(git rev-parse '@{upstream}')" + if [[ "$local_commit" == "$upstream_commit" ]]; then + printf 'Deployment is already up to date.\n' + elif git merge-base --is-ancestor "$local_commit" "$upstream_commit"; then + git merge --quiet --ff-only "$upstream_commit" + # Restart using the newly pulled copy so changes to this script apply. + exec "$SCRIPT_PATH" --post-pull + elif git merge-base --is-ancestor "$upstream_commit" "$local_commit"; then + fail "local branch ${branch} is ahead of ${upstream}; refusing to update" + else + fail "local branch ${branch} has diverged from ${upstream}" + fi +fi + +printf 'Updating the local Gitea image and deployment stack...\n' +docker compose --env-file .env -f deploy/gitea/compose.yaml pull +"${DEPLOY_ROOT}/manage.sh" start + +container_id="$(docker compose -f compose.yaml -f compose.tls.yaml ps -q broker)" +[[ -n "$container_id" ]] || + fail "Docker Compose did not return the local broker container" + +printf 'Waiting for the local broker health check' +for ((attempt = 1; attempt <= 75; attempt++)); do + status="$( + docker inspect \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \ + "$container_id" + )" + case "$status" in + healthy) + printf ' healthy.\n' + docker compose -f compose.yaml -f compose.tls.yaml ps + docker compose --env-file .env -f deploy/gitea/compose.yaml ps + printf 'Local deployment updated successfully.\n' + exit 0 + ;; + exited | dead | unhealthy) + printf ' %s.\n' "$status" + docker compose -f compose.yaml -f compose.tls.yaml logs --tail=100 broker + fail "the local broker failed its health check" + ;; + esac + printf '.' + sleep 2 +done + +printf ' timed out.\n' +docker compose -f compose.yaml -f compose.tls.yaml logs --tail=100 broker +fail "the local broker did not become healthy within 150 seconds"