diff --git a/.env.example b/.env.example index c7cfae9..4a19923 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ BROKER_DOMAIN=broker.example.com GITEA_DOMAIN=git.example.com -LETSENCRYPT_EMAIL=admin@example.com +GITEA_PUBLIC_URL=http://git.example.com:8680/ +LETSENCRYPT_EMAIL= HTTP_PORT=8680 HTTPS_PORT=8643 @@ -9,25 +10,28 @@ BROKER_DIRECT_PORT=8080 GITEA_DIRECT_PORT=3000 GITEA_DIRECT_ROOT_URL=http://localhost:3000/ +# none: HTTP-only testing before DNS and port forwarding are ready. # letsencrypt: manage a certificate automatically with Certbot. # custom: read the named certificate files from ./config/ssl. -SSL_MODE=letsencrypt +SSL_MODE=none SSL_CERTIFICATE_DIR=./config/letsencrypt/live/broker.example.com SSL_CERTIFICATE_FILE=fullchain.pem SSL_CERTIFICATE_KEY_FILE=privkey.pem 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 -TAPM_GITEA_URL=https://git.example.com -TAPM_GITEA_CLIENT_ID=replace-after-creating-the-gitea-oauth-app -TAPM_GITEA_CLIENT_SECRET=replace-after-creating-the-gitea-oauth-app +TAPM_PUBLIC_URL=http://broker.example.com:8680 +TAPM_GITEA_URL=http://git.example.com:8680 +# These values are intentionally blank during initial Gitea bootstrap. +# Run ./manage.sh configure-broker before starting the broker. +TAPM_GITEA_CLIENT_ID= +TAPM_GITEA_CLIENT_SECRET= TAPM_GITEA_PACKAGE_OWNER=TAI -TAPM_GITEA_PACKAGE_USERNAME=tapm-packages -TAPM_GITEA_PACKAGE_TOKEN=replace-with-read-package-token -TAPM_GITEA_PACKAGE_WRITE_USERNAME=tapm-publisher -TAPM_GITEA_PACKAGE_WRITE_TOKEN=replace-with-write-package-token +TAPM_GITEA_PACKAGE_USERNAME= +TAPM_GITEA_PACKAGE_TOKEN= +TAPM_GITEA_PACKAGE_WRITE_USERNAME= +TAPM_GITEA_PACKAGE_WRITE_TOKEN= TAPM_ALLOWED_GITEA_USERS=taiadmin -TAPM_COOKIE_SECRET=replace-with-output-of-openssl-rand-base64-48 +TAPM_COOKIE_SECRET= TAPM_DEFAULT_DURATION=3h TAPM_DEFAULT_HOST_LIMIT=3 TAPM_MAX_HOST_LIMIT=25 diff --git a/README.md b/README.md index a6ec52c..37fe86c 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,36 @@ See [docs/deployment.md](docs/deployment.md) for the installation, Gitea setup, TLS bootstrap, repository move, renewal, backup, and update procedures. The ProxMenu integration contract is in [docs/client-api.md](docs/client-api.md). +## Guided installation + +After cloning the repository, run: + +```sh +./install.sh +``` + +The installer defaults to `SSL_MODE=none`, writes a mode-`0600` `.env`, creates +the runtime directories and Docker network, and starts Gitea behind the +HTTP-only Nginx test configuration. It does not invent Gitea OAuth or package +credentials. After creating those resources in Gitea, run +`./manage.sh configure-broker` and `./manage.sh start`. + +Once public DNS and TCP 80/443 forwarding are ready, switch the same persistent +deployment to Let's Encrypt: + +```sh +./manage.sh set-ssl letsencrypt +./manage.sh bootstrap +./manage.sh start +``` + ## Local checks ```sh GOCACHE=/tmp/tapm-go-cache go test ./... docker compose --env-file .env.example config +docker compose --env-file .env.example \ + -f compose.yaml -f compose.http.yaml config docker compose --env-file .env.example \ -f compose.yaml -f compose.tls.yaml -f compose.direct.yaml config docker build -t tai/tapm-deployment-broker:local . diff --git a/compose.http.yaml b/compose.http.yaml new file mode 100644 index 0000000..7701eb1 --- /dev/null +++ b/compose.http.yaml @@ -0,0 +1,14 @@ +# Plain-HTTP testing mode. Do not expose this mode to the public Internet. +services: + broker: + environment: + TAPM_ALLOW_INSECURE_HTTP: "true" + + gitea: + environment: + GITEA__server__ROOT_URL: ${GITEA_PUBLIC_URL} + GITEA__session__COOKIE_SECURE: "false" + + nginx: + volumes: + - ./deploy/nginx/templates/http.conf.template:/etc/nginx/templates/default.conf.template:ro diff --git a/compose.tls.yaml b/compose.tls.yaml index 20e261b..437736b 100644 --- a/compose.tls.yaml +++ b/compose.tls.yaml @@ -1,5 +1,7 @@ services: nginx: + ports: + - "${HTTPS_PORT:-8643}:443" 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 0d28352..8e657c7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -51,7 +51,7 @@ services: GITEA__database__DB_TYPE: sqlite3 GITEA__database__PATH: /var/lib/gitea/data/gitea.db GITEA__server__DOMAIN: ${GITEA_DOMAIN} - GITEA__server__ROOT_URL: https://${GITEA_DOMAIN}/ + GITEA__server__ROOT_URL: ${GITEA_PUBLIC_URL} GITEA__server__HTTP_ADDR: 0.0.0.0 GITEA__server__HTTP_PORT: "3000" GITEA__server__DISABLE_SSH: "true" @@ -78,7 +78,6 @@ services: - edge ports: - "${HTTP_PORT:-8680}:80" - - "${HTTPS_PORT:-8643}:443" volumes: - ./deploy/nginx/templates/bootstrap.conf.template:/etc/nginx/templates/default.conf.template:ro - ./config/certbot-webroot:/var/www/certbot:ro diff --git a/deploy/nginx/templates/http.conf.template b/deploy/nginx/templates/http.conf.template new file mode 100644 index 0000000..6479dea --- /dev/null +++ b/deploy/nginx/templates/http.conf.template @@ -0,0 +1,83 @@ +# Testing-only HTTP reverse proxy. Production should use tls.conf.template. +server_tokens off; +reset_timedout_connection on; +client_header_timeout 15s; +client_body_timeout 60s; +keepalive_timeout 30s; +send_timeout 60s; + +limit_conn_zone $binary_remote_addr zone=per_address_connections:10m; +limit_req_zone $binary_remote_addr zone=broker_requests:10m rate=20r/s; +map $uri $gitea_login_client { + default ""; + "/user/login" $binary_remote_addr; +} +limit_req_zone $gitea_login_client zone=gitea_login_requests:10m rate=10r/m; + +resolver 127.0.0.11 valid=30s; + +upstream gitea_backend { + zone gitea_backend 64k; + server gitea:3000 resolve; +} + +server { + listen 80 default_server; + server_name _; + return 444; +} + +server { + listen 80; + server_name ${BROKER_DOMAIN}; + + client_max_body_size 1100m; + limit_conn per_address_connections 30; + limit_req zone=broker_requests burst=40 nodelay; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + location / { + # Resolve at request time so Gitea-only bootstrap works before the + # broker container exists. Requests return 502 until broker setup ends. + set $broker_backend_url http://broker:8080; + proxy_pass $broker_backend_url; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto http; + proxy_connect_timeout 30s; + proxy_send_timeout 30m; + proxy_read_timeout 30m; + proxy_request_buffering off; + proxy_buffering off; + } +} + +server { + listen 80; + server_name ${GITEA_DOMAIN}; + + client_max_body_size 1100m; + limit_conn per_address_connections 50; + limit_req zone=gitea_login_requests burst=10 nodelay; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + location / { + proxy_pass http://gitea_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto http; + proxy_read_timeout 300s; + } +} diff --git a/docs/deployment.md b/docs/deployment.md index c043014..bf750be 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -11,6 +11,11 @@ One Compose project manages: - Nginx, terminating TLS for the broker and Gitea - Certbot, as an on-demand utility for certificate issuance and renewal +The same deployment supports an explicitly insecure HTTP testing phase before +public DNS and port forwarding are ready. HTTP mode changes only routing, URL +schemes, and cookie security; it does not create separate databases or move +runtime data. + In production, only the configured Nginx HTTP and HTTPS ports are published. They default to 8680 and 8643. Inside Docker, Nginx listens on ports 80 and 443, the broker on 8080, and Gitea is explicitly pinned to `0.0.0.0:3000`. Gitea @@ -48,20 +53,29 @@ sudo mkdir -p /YOUR/INSTALL/PARENT sudo git clone GITEA-REPOSITORY-URL /YOUR/INSTALL/PARENT/TA-Deployment-Access sudo chown -R 1000:1000 /YOUR/INSTALL/PARENT/TA-Deployment-Access cd /YOUR/INSTALL/PARENT/TA-Deployment-Access -cp .env.example .env -chmod 600 .env +./install.sh ``` -Set `BROKER_DOMAIN`, `GITEA_DOMAIN`, and `LETSENCRYPT_EMAIL` first. `HTTP_PORT` -and `HTTPS_PORT` control the VM-side Docker bindings and default to 8680 and -8643. The datacenter edge should therefore forward public ports 80 and 443 to -VM ports 8680 and 8643. Replace all example domains in the TAPM variables. -Generate the cookie secret with: +The guided installer validates both hostnames and Docker ports, generates the +broker cookie secret, writes `.env` with mode `0600`, prepares persistent +storage, and starts only Gitea and Nginx. It defaults to `SSL_MODE=none`, so no +certificate or public port forwarding is required. + +With the default `HTTP_PORT=8680`, test URLs include that port: ```sh -openssl rand -base64 48 +http://BROKER_DOMAIN:8680 +http://GITEA_DOMAIN:8680 ``` +Both names must resolve to the Docker VM from the test workstation. Temporary +hosts-file records are sufficient. Do not forward this HTTP test listener to +the public Internet. + +For a manual installation instead, copy `.env.example` to `.env`, replace its +example hostnames, generate `TAPM_COOKIE_SECRET` with +`openssl rand -base64 48`, and run `./manage.sh bootstrap`. + Runtime state is deliberately visible below the checkout: ```text @@ -78,15 +92,49 @@ config/ The contents are ignored by Git. They must never be committed. -## 3. Bootstrap Gitea and TLS +## 3. Configure Gitea and the broker + +The installer has already bootstrapped Gitea. Create the initial administrator: ```sh -./manage.sh bootstrap +./manage.sh create-admin ``` -This prepares the runtime directories, creates the private Docker network, -starts Gitea and the HTTP-only Nginx configuration, obtains a certificate when -using Let's Encrypt, and switches Nginx to TLS. +Sign in at the Gitea URL printed by the command and change the temporary +password. Create the `TAI` organization, then create: + +1. `tapm-packages`, with a read-only package token. +2. `tapm-publisher`, with a write package token. +3. An OAuth2 application. In HTTP testing mode its callback is + `http://BROKER_DOMAIN:HTTP_PORT/auth/callback`. + +Save the resulting values without echoing secrets to the terminal: + +```sh +./manage.sh configure-broker +./manage.sh start +``` + +The broker is deliberately not started during the first bootstrap. Blank +OAuth/package values are valid for Gitea-only setup, while `start` refuses to +launch the broker until all required credentials are present. + +## 4. Enable TLS when forwarding is ready + +After public DNS resolves and the datacenter edge forwards public TCP 80 and +443 to the configured VM ports, run: + +```sh +./manage.sh set-ssl letsencrypt +./manage.sh bootstrap +./manage.sh start +``` + +`set-ssl` changes all public URLs to HTTPS and removes the temporary `:8680` +test port. `bootstrap` obtains the certificate and switches Nginx to TLS. +`start` recreates Gitea and the broker with secure cookies. Existing SQLite +databases, repositories, packages, installation identities, and secrets remain +in place. For an administrator-provided certificate instead, place the certificate and key in `config/ssl/` and set: @@ -98,46 +146,27 @@ SSL_CERTIFICATE_FILE=fullchain.pem SSL_CERTIFICATE_KEY_FILE=privkey.pem ``` -The certificate must cover both `BROKER_DOMAIN` and `GITEA_DOMAIN`. With custom +The certificate must cover both `BROKER_DOMAIN` and `GITEA_DOMAIN`. Run +`./manage.sh set-ssl custom` before bootstrap. 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: - -```sh -./manage.sh create-admin -``` - -No Gitea login is hard-coded or stored in `.env`. The command prompts for the -administrator username and email, generates a temporary password, and requires -it to be changed at first login. The initial web installer is intentionally -locked and public registration is disabled. - -Sign in to Gitea at `https://GITEA_DOMAIN`, create the `TAI` organization, and -create: - -1. `tapm-packages`, with a read-only package token. -2. `tapm-publisher`, with a write package token. -3. An OAuth2 application whose callback is - `https://BROKER_DOMAIN/auth/callback`. - -Place those token and OAuth values in `.env`. The broker cannot start until all -required credentials are present. - -## 4. Start the complete deployment +## 5. Start and inspect the complete deployment ```sh ./manage.sh start ./manage.sh status -curl --fail "https://BROKER_DOMAIN/health/ready" -curl --fail "https://GITEA_DOMAIN/api/healthz" +curl --fail "http://BROKER_DOMAIN:HTTP_PORT/health/ready" # SSL_MODE=none +curl --fail "http://GITEA_DOMAIN:HTTP_PORT/api/healthz" # SSL_MODE=none ``` The broker automatically applies SQLite schema migrations before starting. Gitea uses its own SQLite database at `config/gitea/data/data/gitea.db`. +After TLS is enabled, use the equivalent `https://` URLs without the temporary +HTTP test port. For local testing that bypasses Nginx, set `BROKER_DIRECT_PORT` and `GITEA_DIRECT_PORT` in `.env`. Set `GITEA_DIRECT_ROOT_URL` to the exact URL the @@ -164,10 +193,9 @@ production URL and secure-cookie policy with: ```sh ./manage.sh start -docker compose -f compose.yaml -f compose.tls.yaml up -d --force-recreate broker gitea ``` -## 5. Move repositories +## 6. Move repositories For each existing repository, create an empty matching repository in the new Gitea and mirror all refs: @@ -183,7 +211,7 @@ module path only if the hostname embedded in the module path is also changing. Move the deployment repository last so this checkout remains updateable during the transition. -## 6. Renewal and backups +## 7. Renewal and backups When `SSL_MODE=letsencrypt`, run renewal twice daily from root's crontab: @@ -201,7 +229,7 @@ The backup briefly stops both SQLite writers. Copy `config/backups/` to storage outside the VM. A backup left only on this VM does not protect against VM or datacenter loss. Also back up `.env` through a secrets-aware system. -## 7. Updates +## 8. Updates ```sh cd /YOUR/INSTALL/PARENT/TA-Deployment-Access diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..be3ca58 --- /dev/null +++ b/install.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +DEPLOY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +cd "$DEPLOY_ROOT" + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +prompt_default() { + local prompt="$1" + local fallback="$2" + local value + + printf '%s [%s]: ' "$prompt" "$fallback" >&2 + read -r value + printf '%s' "${value:-$fallback}" +} + +valid_hostname() { + [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ && + "$1" != *..* ]] +} + +valid_port() { + [[ "$1" =~ ^[0-9]+$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )) +} + +[[ $# -eq 0 ]] || fail "usage: ./install.sh" +[[ ! -e .env ]] || + fail ".env already exists; use ./manage.sh instead of overwriting this deployment" + +for command_name in docker openssl; do + command -v "$command_name" >/dev/null 2>&1 || + fail "$command_name is required" +done +docker compose version >/dev/null 2>&1 || + fail "Docker Compose v2 is required" +docker info >/dev/null 2>&1 || + fail "the Docker daemon is unavailable or this user cannot access it" + +printf '\nTAPM Deployment Access installer\n' +printf 'This creates the initial environment and starts Gitea behind Nginx.\n' +printf 'The broker starts only after its Gitea OAuth and package credentials are configured.\n\n' + +broker_domain="$(prompt_default 'Broker hostname' 'tapm.example.com')" +gitea_domain="$(prompt_default 'Gitea hostname' 'git.example.com')" +valid_hostname "$broker_domain" || fail "invalid broker hostname" +valid_hostname "$gitea_domain" || fail "invalid Gitea hostname" +[[ "$broker_domain" != "$gitea_domain" ]] || + fail "broker and Gitea hostnames must be different" + +http_port="$(prompt_default 'VM port mapped to Nginx HTTP port 80' '8680')" +https_port="$(prompt_default 'VM port mapped to Nginx HTTPS port 443' '8643')" +valid_port "$http_port" || fail "invalid HTTP port" +valid_port "$https_port" || fail "invalid HTTPS port" +[[ "$http_port" != "$https_port" ]] || fail "HTTP and HTTPS ports must differ" + +ssl_mode="$(prompt_default 'SSL mode (none, letsencrypt, or custom)' 'none')" +case "$ssl_mode" in +none) + public_scheme=http + public_port_suffix=":${http_port}" + letsencrypt_email='' + certificate_dir="./config/letsencrypt/live/${broker_domain}" + ;; +letsencrypt) + public_scheme=https + public_port_suffix='' + printf 'Let'\''s Encrypt account email: ' + read -r letsencrypt_email + [[ "$letsencrypt_email" == *@* ]] || fail "a valid email is required" + certificate_dir="./config/letsencrypt/live/${broker_domain}" + ;; +custom) + public_scheme=https + public_port_suffix='' + letsencrypt_email='' + certificate_dir='./config/ssl' + ;; +*) + fail "SSL mode must be none, letsencrypt, or custom" + ;; +esac + +cookie_secret="$(openssl rand -base64 48)" +umask 077 +{ + printf 'BROKER_DOMAIN=%s\n' "$broker_domain" + printf 'GITEA_DOMAIN=%s\n' "$gitea_domain" + printf 'GITEA_PUBLIC_URL=%s://%s%s/\n' "$public_scheme" "$gitea_domain" "$public_port_suffix" + printf 'LETSENCRYPT_EMAIL=%s\n' "$letsencrypt_email" + printf 'HTTP_PORT=%s\n' "$http_port" + printf 'HTTPS_PORT=%s\n\n' "$https_port" + printf 'BROKER_DIRECT_PORT=8080\n' + printf 'GITEA_DIRECT_PORT=3000\n' + printf 'GITEA_DIRECT_ROOT_URL=http://localhost:3000/\n\n' + printf 'SSL_MODE=%s\n' "$ssl_mode" + printf 'SSL_CERTIFICATE_DIR=%s\n' "$certificate_dir" + printf 'SSL_CERTIFICATE_FILE=fullchain.pem\n' + printf 'SSL_CERTIFICATE_KEY_FILE=privkey.pem\n\n' + printf 'TAPM_DATABASE_DSN=file:/data/tapm.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)\n' + printf 'TAPM_PUBLIC_URL=%s://%s%s\n' "$public_scheme" "$broker_domain" "$public_port_suffix" + printf 'TAPM_GITEA_URL=%s://%s%s\n' "$public_scheme" "$gitea_domain" "$public_port_suffix" + printf 'TAPM_GITEA_CLIENT_ID=\n' + printf 'TAPM_GITEA_CLIENT_SECRET=\n' + printf 'TAPM_GITEA_PACKAGE_OWNER=TAI\n' + printf 'TAPM_GITEA_PACKAGE_USERNAME=\n' + printf 'TAPM_GITEA_PACKAGE_TOKEN=\n' + printf 'TAPM_GITEA_PACKAGE_WRITE_USERNAME=\n' + printf 'TAPM_GITEA_PACKAGE_WRITE_TOKEN=\n' + printf 'TAPM_ALLOWED_GITEA_USERS=taiadmin\n' + printf 'TAPM_COOKIE_SECRET=%s\n' "$cookie_secret" + printf 'TAPM_DEFAULT_DURATION=3h\n' + printf 'TAPM_DEFAULT_HOST_LIMIT=3\n' + printf 'TAPM_MAX_HOST_LIMIT=25\n' + printf 'TAPM_MAX_UPLOAD_BYTES=1073741824\n' + printf 'TAPM_TRUST_PROXY_HEADERS=true\n' + printf 'TAPM_DISPLAY_TIME_ZONE=America/Chicago\n' +} >.env +chmod 600 .env + +printf '\nInitial configuration saved to %s/.env.\n' "$DEPLOY_ROOT" +if [[ "$ssl_mode" == "custom" ]]; then + printf 'Place the certificate and key in config/ssl, then run ./manage.sh bootstrap.\n' + printf 'Gitea has not been started yet.\n' + exit 0 +fi +"${DEPLOY_ROOT}/manage.sh" bootstrap + +printf '\nInitial Gitea deployment is running.\n' +printf 'Gitea URL: %s://%s%s\n' "$public_scheme" "$gitea_domain" "$public_port_suffix" +if [[ "$ssl_mode" == "none" ]]; then + printf 'This is HTTP-only testing mode. Do not expose it publicly.\n' + printf 'Make both hostnames resolve to this VM or its test-forwarding address.\n' +fi +printf '\nNext steps:\n' +printf ' 1. Run ./manage.sh create-admin\n' +printf ' 2. Sign in to Gitea and create the organization, package users/tokens,\n' +printf ' and OAuth application documented in docs/deployment.md.\n' +printf ' 3. Run ./manage.sh configure-broker\n' +printf ' 4. Run ./manage.sh start\n' +if [[ "$ssl_mode" == "none" ]]; then + printf ' 5. Later, after DNS and public 80/443 forwarding are ready, run:\n' + printf ' ./manage.sh set-ssl letsencrypt\n' + printf ' ./manage.sh bootstrap\n' + printf ' ./manage.sh start\n' +fi diff --git a/internal/app/auth.go b/internal/app/auth.go index 0fcf0da..643774f 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -39,7 +39,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { Path: "/auth", MaxAge: 600, HttpOnly: true, - Secure: true, + Secure: s.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, }) @@ -78,7 +78,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { Path: "/auth", MaxAge: -1, HttpOnly: true, - Secure: true, + Secure: s.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, }) @@ -134,7 +134,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { Expires: expiresAt, MaxAge: int(time.Until(expiresAt).Seconds()), HttpOnly: true, - Secure: true, + Secure: s.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, }) _ = s.audit(r.Context(), "login_succeeded", user.Login, nil, "", "", s.clientIP(r), "") @@ -280,7 +280,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { Path: "/", MaxAge: -1, HttpOnly: true, - Secure: true, + Secure: s.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, }) http.Redirect(w, r, "/", http.StatusSeeOther) diff --git a/internal/app/config.go b/internal/app/config.go index 60a6d6c..4772f42 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -24,6 +24,7 @@ type Config struct { GiteaWriteToken string AllowedGiteaUsers map[string]struct{} CookieSecret []byte + CookieSecure bool DefaultDuration time.Duration DefaultHostLimit int MaxHostLimit int @@ -50,14 +51,21 @@ func LoadConfig() (Config, error) { cfg.GiteaWriteToken = os.Getenv("TAPM_GITEA_PACKAGE_WRITE_TOKEN") cfg.CookieSecret = []byte(os.Getenv("TAPM_COOKIE_SECRET")) - cfg.PublicURL, err = parseAbsoluteURL("TAPM_PUBLIC_URL") + allowInsecureHTTP, err := strconv.ParseBool( + envDefault("TAPM_ALLOW_INSECURE_HTTP", "false"), + ) + if err != nil { + return cfg, fmt.Errorf("TAPM_ALLOW_INSECURE_HTTP: %w", err) + } + cfg.PublicURL, err = parseAbsoluteURL("TAPM_PUBLIC_URL", allowInsecureHTTP) if err != nil { return cfg, err } - cfg.GiteaURL, err = parseAbsoluteURL("TAPM_GITEA_URL") + cfg.GiteaURL, err = parseAbsoluteURL("TAPM_GITEA_URL", allowInsecureHTTP) if err != nil { return cfg, err } + cfg.CookieSecure = cfg.PublicURL.Scheme == "https" cfg.DefaultDuration, err = time.ParseDuration( envDefault("TAPM_DEFAULT_DURATION", "3h"), @@ -128,10 +136,17 @@ func LoadConfig() (Config, error) { return cfg, nil } -func parseAbsoluteURL(name string) (*url.URL, error) { +func parseAbsoluteURL(name string, allowInsecureHTTP bool) (*url.URL, error) { raw := os.Getenv(name) parsed, err := url.Parse(raw) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + validScheme := parsed != nil && parsed.Scheme == "https" + if allowInsecureHTTP && parsed != nil && parsed.Scheme == "http" { + validScheme = true + } + if err != nil || !validScheme || parsed.Host == "" { + if allowInsecureHTTP { + return nil, fmt.Errorf("%s must be an absolute HTTP or HTTPS URL", name) + } return nil, fmt.Errorf("%s must be an absolute HTTPS URL", name) } return parsed, nil diff --git a/internal/app/config_test.go b/internal/app/config_test.go new file mode 100644 index 0000000..95369c3 --- /dev/null +++ b/internal/app/config_test.go @@ -0,0 +1,50 @@ +package app + +import ( + "strings" + "testing" +) + +func setRequiredConfigEnvironment(t *testing.T, scheme string) { + t.Helper() + t.Setenv("TAPM_PUBLIC_URL", scheme+"://broker.example.com") + t.Setenv("TAPM_GITEA_URL", scheme+"://git.example.com") + t.Setenv("TAPM_GITEA_CLIENT_ID", "client-id") + t.Setenv("TAPM_GITEA_CLIENT_SECRET", "client-secret") + t.Setenv("TAPM_GITEA_PACKAGE_USERNAME", "reader") + t.Setenv("TAPM_GITEA_PACKAGE_TOKEN", "reader-token") + t.Setenv("TAPM_GITEA_PACKAGE_WRITE_USERNAME", "writer") + t.Setenv("TAPM_GITEA_PACKAGE_WRITE_TOKEN", "writer-token") + t.Setenv("TAPM_COOKIE_SECRET", "0123456789abcdef0123456789abcdef") +} + +func TestConfigRejectsHTTPByDefault(t *testing.T) { + setRequiredConfigEnvironment(t, "http") + _, err := LoadConfig() + if err == nil || !strings.Contains(err.Error(), "absolute HTTPS URL") { + t.Fatalf("LoadConfig() error = %v, want HTTPS requirement", err) + } +} + +func TestConfigAllowsExplicitHTTPTestingMode(t *testing.T) { + setRequiredConfigEnvironment(t, "http") + t.Setenv("TAPM_ALLOW_INSECURE_HTTP", "true") + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.CookieSecure { + t.Fatal("HTTP testing mode unexpectedly enabled secure cookies") + } +} + +func TestHTTPSConfigUsesSecureCookies(t *testing.T) { + setRequiredConfigEnvironment(t, "https") + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if !cfg.CookieSecure { + t.Fatal("HTTPS mode did not enable secure cookies") + } +} diff --git a/manage.sh b/manage.sh index f86a769..c6dc30a 100755 --- a/manage.sh +++ b/manage.sh @@ -4,6 +4,7 @@ set -Eeuo pipefail DEPLOY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" cd "$DEPLOY_ROOT" +declare -a COMPOSE_FILES=(-f compose.yaml) fail() { printf 'ERROR: %s\n' "$*" >&2 @@ -26,10 +27,77 @@ read_env_setting() { printf '%s' "$value" } +set_env_setting() { + local key="$1" + local value="$2" + local temporary_file + + temporary_file="$(mktemp "${DEPLOY_ROOT}/.env.tmp.XXXXXX")" || + fail "unable to create temporary environment file" + awk -v key="$key" -v value="$value" ' + BEGIN { replaced = 0 } + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { + if (!replaced) { + print key "=" value + replaced = 1 + } + next + } + { print } + END { + if (!replaced) print key "=" value + } + ' .env >"$temporary_file" + chmod 600 "$temporary_file" + mv -f "$temporary_file" .env +} + +select_compose_stack() { + COMPOSE_FILES=(-f compose.yaml) + if [[ "$SSL_MODE" == "none" ]]; then + COMPOSE_FILES+=(-f compose.http.yaml) + else + COMPOSE_FILES+=(-f compose.tls.yaml) + fi +} + +compose_stack() { + docker compose "${COMPOSE_FILES[@]}" "$@" +} + +broker_config_ready() { + local key + local value + local -a required=( + TAPM_GITEA_CLIENT_ID + TAPM_GITEA_CLIENT_SECRET + TAPM_GITEA_PACKAGE_USERNAME + TAPM_GITEA_PACKAGE_TOKEN + TAPM_GITEA_PACKAGE_WRITE_USERNAME + TAPM_GITEA_PACKAGE_WRITE_TOKEN + TAPM_COOKIE_SECRET + ) + + for key in "${required[@]}"; do + value="$(read_env_setting "$key")" + if [[ -z "$value" || "$value" == replace-* ]]; then + printf 'Missing broker setting: %s\n' "$key" >&2 + return 1 + fi + done + value="$(read_env_setting TAPM_COOKIE_SECRET)" + if (( ${#value} < 32 )); then + printf 'TAPM_COOKIE_SECRET must contain at least 32 characters.\n' >&2 + return 1 + fi + return 0 +} + require_env() { [[ -f .env ]] || fail "copy .env.example to .env and configure it first" BROKER_DOMAIN="$(read_env_setting BROKER_DOMAIN)" GITEA_DOMAIN="$(read_env_setting GITEA_DOMAIN)" + GITEA_PUBLIC_URL="$(read_env_setting GITEA_PUBLIC_URL)" LETSENCRYPT_EMAIL="$(read_env_setting LETSENCRYPT_EMAIL)" SSL_MODE="$(read_env_setting SSL_MODE)" SSL_MODE="${SSL_MODE:-letsencrypt}" @@ -40,10 +108,35 @@ require_env() { SSL_CERTIFICATE_KEY_FILE="${SSL_CERTIFICATE_KEY_FILE:-privkey.pem}" : "${BROKER_DOMAIN:?BROKER_DOMAIN is required}" : "${GITEA_DOMAIN:?GITEA_DOMAIN is required}" + [[ "$BROKER_DOMAIN" != "$GITEA_DOMAIN" ]] || + fail "BROKER_DOMAIN and GITEA_DOMAIN must be different hostnames" + if [[ -z "$GITEA_PUBLIC_URL" ]]; then + if [[ "$SSL_MODE" == "none" ]]; then + http_port="$(read_env_setting HTTP_PORT)" + http_port="${http_port:-8680}" + GITEA_PUBLIC_URL="http://${GITEA_DOMAIN}:${http_port}/" + else + GITEA_PUBLIC_URL="https://${GITEA_DOMAIN}/" + fi + fi case "$SSL_MODE" in + none) + [[ "$(read_env_setting TAPM_PUBLIC_URL)" == http://* ]] || + fail "TAPM_PUBLIC_URL must use http:// when SSL_MODE=none" + [[ "$(read_env_setting TAPM_GITEA_URL)" == http://* ]] || + fail "TAPM_GITEA_URL must use http:// when SSL_MODE=none" + [[ "$GITEA_PUBLIC_URL" == http://* ]] || + fail "GITEA_PUBLIC_URL must use http:// when SSL_MODE=none" + ;; letsencrypt) : "${LETSENCRYPT_EMAIL:?LETSENCRYPT_EMAIL is required for SSL_MODE=letsencrypt}" SSL_CERTIFICATE_DIR="${SSL_CERTIFICATE_DIR:-./config/letsencrypt/live/${BROKER_DOMAIN}}" + [[ "$(read_env_setting TAPM_PUBLIC_URL)" == https://* ]] || + fail "TAPM_PUBLIC_URL must use https:// when TLS is enabled" + [[ "$(read_env_setting TAPM_GITEA_URL)" == https://* ]] || + fail "TAPM_GITEA_URL must use https:// when TLS is enabled" + [[ "$GITEA_PUBLIC_URL" == https://* ]] || + fail "GITEA_PUBLIC_URL must use https:// when TLS is enabled" ;; custom) SSL_CERTIFICATE_DIR="${SSL_CERTIFICATE_DIR:-./config/ssl}" @@ -51,12 +144,19 @@ require_env() { 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}" + [[ "$(read_env_setting TAPM_PUBLIC_URL)" == https://* ]] || + fail "TAPM_PUBLIC_URL must use https:// when TLS is enabled" + [[ "$(read_env_setting TAPM_GITEA_URL)" == https://* ]] || + fail "TAPM_GITEA_URL must use https:// when TLS is enabled" + [[ "$GITEA_PUBLIC_URL" == https://* ]] || + fail "GITEA_PUBLIC_URL must use https:// when TLS is enabled" ;; *) - fail "SSL_MODE must be letsencrypt or custom" + fail "SSL_MODE must be none, letsencrypt, or custom" ;; esac - export SSL_CERTIFICATE_DIR SSL_CERTIFICATE_FILE SSL_CERTIFICATE_KEY_FILE + export GITEA_PUBLIC_URL SSL_CERTIFICATE_DIR SSL_CERTIFICATE_FILE SSL_CERTIFICATE_KEY_FILE + select_compose_stack } prepare() { @@ -84,28 +184,35 @@ prepare) bootstrap) require_env prepare - docker compose up -d gitea nginx - if [[ "$SSL_MODE" == "letsencrypt" ]]; then + if [[ "$SSL_MODE" == "none" ]]; then + compose_stack up -d gitea nginx + elif [[ "$SSL_MODE" == "letsencrypt" ]]; then + docker compose up -d gitea 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" + compose_stack up -d gitea nginx + else + compose_stack up -d gitea nginx fi - docker compose -f compose.yaml -f compose.tls.yaml up -d nginx ;; start) require_env prepare - docker compose -f compose.yaml -f compose.tls.yaml up -d --build + broker_config_ready || + fail "broker configuration is incomplete; run ./manage.sh configure-broker" + compose_stack up -d --build ;; start-direct) require_env prepare + broker_config_ready || + fail "broker configuration is incomplete; run ./manage.sh configure-broker" docker compose \ - -f compose.yaml \ - -f compose.tls.yaml \ + "${COMPOSE_FILES[@]}" \ -f compose.direct.yaml \ up -d --build ;; @@ -129,15 +236,101 @@ create-admin) printf '\nGitea administrator created.\n' printf 'Username: %s\n' "$admin_username" printf 'Temporary password: %s\n' "$temporary_password" - printf 'Sign in at https://%s/ and change the password immediately.\n' \ - "$GITEA_DOMAIN" + printf 'Sign in at %s and change the password immediately.\n' \ + "$GITEA_PUBLIC_URL" + ;; +configure-broker) + require_env + command -v openssl >/dev/null 2>&1 || + fail "openssl is required to generate the cookie secret" + printf 'Create the Gitea OAuth application with this callback URL:\n' + printf ' %s/auth/callback\n\n' "$(read_env_setting TAPM_PUBLIC_URL)" + printf 'Gitea OAuth client ID: ' + read -r oauth_client_id + [[ -n "$oauth_client_id" ]] || fail "OAuth client ID is required" + printf 'Gitea OAuth client secret: ' + read -r -s oauth_client_secret + printf '\n' + [[ -n "$oauth_client_secret" ]] || fail "OAuth client secret is required" + printf 'Read-only package username [tapm-packages]: ' + read -r package_username + package_username="${package_username:-tapm-packages}" + printf 'Read-only package token: ' + read -r -s package_token + printf '\n' + [[ -n "$package_token" ]] || fail "read-only package token is required" + printf 'Package publisher username [tapm-publisher]: ' + read -r publisher_username + publisher_username="${publisher_username:-tapm-publisher}" + printf 'Package publisher token: ' + read -r -s publisher_token + printf '\n' + [[ -n "$publisher_token" ]] || fail "publisher token is required" + printf 'Allowed Gitea users [taiadmin]: ' + read -r allowed_users + allowed_users="${allowed_users:-taiadmin}" + cookie_secret="$(read_env_setting TAPM_COOKIE_SECRET)" + if (( ${#cookie_secret} < 32 )); then + cookie_secret="$(openssl rand -base64 48)" + fi + set_env_setting TAPM_GITEA_CLIENT_ID "$oauth_client_id" + set_env_setting TAPM_GITEA_CLIENT_SECRET "$oauth_client_secret" + set_env_setting TAPM_GITEA_PACKAGE_USERNAME "$package_username" + set_env_setting TAPM_GITEA_PACKAGE_TOKEN "$package_token" + set_env_setting TAPM_GITEA_PACKAGE_WRITE_USERNAME "$publisher_username" + set_env_setting TAPM_GITEA_PACKAGE_WRITE_TOKEN "$publisher_token" + set_env_setting TAPM_ALLOWED_GITEA_USERS "$allowed_users" + set_env_setting TAPM_COOKIE_SECRET "$cookie_secret" + printf 'Broker credentials saved to .env with mode 0600.\n' + printf 'Start the complete stack with ./manage.sh start\n' + ;; +set-ssl) + require_env + target_mode="${2:-}" + case "$target_mode" in + none) + http_port="$(read_env_setting HTTP_PORT)" + http_port="${http_port:-8680}" + set_env_setting SSL_MODE none + set_env_setting TAPM_PUBLIC_URL "http://${BROKER_DOMAIN}:${http_port}" + set_env_setting TAPM_GITEA_URL "http://${GITEA_DOMAIN}:${http_port}" + set_env_setting GITEA_PUBLIC_URL "http://${GITEA_DOMAIN}:${http_port}/" + printf 'HTTP testing mode configured. Apply it with ./manage.sh start\n' + ;; + letsencrypt) + email="$(read_env_setting LETSENCRYPT_EMAIL)" + if [[ -z "$email" ]]; then + printf 'Let'\''s Encrypt account email: ' + read -r email + [[ -n "$email" ]] || fail "an email is required for Let's Encrypt" + set_env_setting LETSENCRYPT_EMAIL "$email" + fi + set_env_setting SSL_MODE letsencrypt + set_env_setting SSL_CERTIFICATE_DIR "./config/letsencrypt/live/${BROKER_DOMAIN}" + set_env_setting TAPM_PUBLIC_URL "https://${BROKER_DOMAIN}" + set_env_setting TAPM_GITEA_URL "https://${GITEA_DOMAIN}" + set_env_setting GITEA_PUBLIC_URL "https://${GITEA_DOMAIN}/" + printf 'TLS URLs configured. Obtain the certificate with ./manage.sh bootstrap\n' + ;; + custom) + set_env_setting SSL_MODE custom + set_env_setting SSL_CERTIFICATE_DIR ./config/ssl + set_env_setting TAPM_PUBLIC_URL "https://${BROKER_DOMAIN}" + set_env_setting TAPM_GITEA_URL "https://${GITEA_DOMAIN}" + set_env_setting GITEA_PUBLIC_URL "https://${GITEA_DOMAIN}/" + printf 'Custom TLS mode configured. Place the certificate files, then run ./manage.sh bootstrap\n' + ;; + *) + fail "usage: ./manage.sh set-ssl {none|letsencrypt|custom}" + ;; + esac ;; 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 + compose_stack exec nginx nginx -s reload ;; backup) require_env @@ -149,14 +342,23 @@ backup) docker compose stop gitea cp -a config/broker "$destination/" cp -a config/gitea "$destination/" - docker compose -f compose.yaml -f compose.tls.yaml start gitea broker + compose_stack start gitea broker printf 'Backup created at %s\n' "$destination" ;; status) - docker compose -f compose.yaml -f compose.tls.yaml ps + require_env + compose_stack ps + ;; +broker-container-id) + require_env + compose_stack ps -q broker + ;; +broker-logs) + require_env + compose_stack logs --tail=100 broker ;; *) - printf 'Usage: %s {prepare|bootstrap|start|start-direct|create-admin|renew|backup|status}\n' "$0" + printf 'Usage: %s {prepare|bootstrap|configure-broker|set-ssl|start|start-direct|create-admin|renew|backup|status}\n' "$0" exit 2 ;; esac diff --git a/tests/test-deployment-scripts.sh b/tests/test-deployment-scripts.sh new file mode 100755 index 0000000..8231881 --- /dev/null +++ b/tests/test-deployment-scripts.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +TEST_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT +deployment="${test_dir}/deployment" +mock_bin="${test_dir}/bin" +mkdir -p "$deployment" "$mock_bin" +cp -R \ + "${TEST_ROOT}/install.sh" \ + "${TEST_ROOT}/manage.sh" \ + "${TEST_ROOT}/compose.yaml" \ + "${TEST_ROOT}/compose.http.yaml" \ + "${TEST_ROOT}/compose.tls.yaml" \ + "${TEST_ROOT}/compose.direct.yaml" \ + "${TEST_ROOT}/deploy" \ + "$deployment/" + +cat >"${mock_bin}/docker" <<'MOCK' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$MOCK_DOCKER_LOG" +case "$*" in +"compose version" | "info" | "network inspect tapm-edge") + exit 0 + ;; +esac +exit 0 +MOCK +chmod +x "${mock_bin}/docker" +export MOCK_DOCKER_LOG="${test_dir}/docker.log" +export PATH="${mock_bin}:${PATH}" + +printf '%s\n' \ + 'broker.test' \ + 'git.test' \ + '8780' \ + '8743' \ + '' | + "${deployment}/install.sh" >/dev/null + +assert_env() { + local expected="$1" + grep -Fx "$expected" "${deployment}/.env" >/dev/null || { + printf 'FAIL: missing environment line: %s\n' "$expected" >&2 + exit 1 + } +} + +assert_env 'SSL_MODE=none' +assert_env 'TAPM_PUBLIC_URL=http://broker.test:8780' +assert_env 'TAPM_GITEA_URL=http://git.test:8780' +assert_env 'GITEA_PUBLIC_URL=http://git.test:8780/' +grep -F -- '-f compose.yaml -f compose.http.yaml up -d gitea nginx' \ + "$MOCK_DOCKER_LOG" >/dev/null || { + printf 'FAIL: HTTP bootstrap did not select compose.http.yaml\n' >&2 + exit 1 +} +if stat -c '%a' "${deployment}/.env" >/dev/null 2>&1; then + env_mode="$(stat -c '%a' "${deployment}/.env")" +else + env_mode="$(stat -f '%Lp' "${deployment}/.env")" +fi +[[ "$env_mode" == 600 ]] || { + printf 'FAIL: .env mode is %s, want 600\n' "$env_mode" >&2 + exit 1 +} + +if "${deployment}/manage.sh" start >/dev/null 2>&1; then + printf 'FAIL: broker started without Gitea credentials\n' >&2 + exit 1 +fi + +printf '%s\n' 'admin@example.test' | + "${deployment}/manage.sh" set-ssl letsencrypt >/dev/null +assert_env 'SSL_MODE=letsencrypt' +assert_env 'TAPM_PUBLIC_URL=https://broker.test' +assert_env 'GITEA_PUBLIC_URL=https://git.test/' + +"${deployment}/manage.sh" set-ssl none >/dev/null +assert_env 'SSL_MODE=none' +assert_env 'TAPM_PUBLIC_URL=http://broker.test:8780' + +printf 'PASS: guided HTTP deployment and TLS transition\n' diff --git a/update.sh b/update.sh index 357bcac..e8f7c3a 100755 --- a/update.sh +++ b/update.sh @@ -57,7 +57,7 @@ printf 'Updating the local Gitea image and deployment stack...\n' docker compose --env-file .env pull gitea nginx "${DEPLOY_ROOT}/manage.sh" start -container_id="$(docker compose -f compose.yaml -f compose.tls.yaml ps -q broker)" +container_id="$("${DEPLOY_ROOT}/manage.sh" broker-container-id)" [[ -n "$container_id" ]] || fail "Docker Compose did not return the local broker container" @@ -71,13 +71,13 @@ for ((attempt = 1; attempt <= 75; attempt++)); do case "$status" in healthy) printf ' healthy.\n' - docker compose -f compose.yaml -f compose.tls.yaml ps + "${DEPLOY_ROOT}/manage.sh" status 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 + "${DEPLOY_ROOT}/manage.sh" broker-logs fail "the local broker failed its health check" ;; esac @@ -86,5 +86,5 @@ for ((attempt = 1; attempt <= 75; attempt++)); do done printf ' timed out.\n' -docker compose -f compose.yaml -f compose.tls.yaml logs --tail=100 broker +"${DEPLOY_ROOT}/manage.sh" broker-logs fail "the local broker did not become healthy within 150 seconds"