From 70943014d85bd08ed0337a1fa61628ed33ae1b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Ra=C4=8Dek?= Date: Sat, 3 Oct 2026 07:01:37 +0200 Subject: [PATCH 01/11] Remove unused CORS --- src/deployment/nginx/nginx.dev.conf | 8 -------- src/deployment/nginx/nginx.local.conf | 8 -------- src/deployment/nginx/nginx.prod.conf | 8 -------- src/deployment/nginx/snippets/cors_headers.conf | 4 ---- src/deployment/nginx/snippets/options_handling.conf | 8 -------- 5 files changed, 36 deletions(-) delete mode 100644 src/deployment/nginx/snippets/cors_headers.conf delete mode 100644 src/deployment/nginx/snippets/options_handling.conf diff --git a/src/deployment/nginx/nginx.dev.conf b/src/deployment/nginx/nginx.dev.conf index 50661a7b..7a8168ba 100644 --- a/src/deployment/nginx/nginx.dev.conf +++ b/src/deployment/nginx/nginx.dev.conf @@ -92,8 +92,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -104,8 +102,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -116,8 +112,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -126,8 +120,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } diff --git a/src/deployment/nginx/nginx.local.conf b/src/deployment/nginx/nginx.local.conf index 68136fad..078d24d5 100644 --- a/src/deployment/nginx/nginx.local.conf +++ b/src/deployment/nginx/nginx.local.conf @@ -39,8 +39,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -51,8 +49,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -63,8 +59,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -74,8 +68,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } } diff --git a/src/deployment/nginx/nginx.prod.conf b/src/deployment/nginx/nginx.prod.conf index fb7b1f19..b4558d1d 100644 --- a/src/deployment/nginx/nginx.prod.conf +++ b/src/deployment/nginx/nginx.prod.conf @@ -102,8 +102,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -114,8 +112,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -126,8 +122,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -136,8 +130,6 @@ http { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } diff --git a/src/deployment/nginx/snippets/cors_headers.conf b/src/deployment/nginx/snippets/cors_headers.conf deleted file mode 100644 index bbc70374..00000000 --- a/src/deployment/nginx/snippets/cors_headers.conf +++ /dev/null @@ -1,4 +0,0 @@ -add_header 'Access-Control-Allow-Origin' "*" always; -add_header 'Access-Control-Allow-Credentials' 'true' always; -add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always; -add_header 'Access-Control-Allow-Headers' 'Accept, Authorization, Cache-Control, Content-Type, DNT, If-Modified-Since, Keep-Alive, Origin, User-Agent, X-Requested-With' always; \ No newline at end of file diff --git a/src/deployment/nginx/snippets/options_handling.conf b/src/deployment/nginx/snippets/options_handling.conf deleted file mode 100644 index f2018e29..00000000 --- a/src/deployment/nginx/snippets/options_handling.conf +++ /dev/null @@ -1,8 +0,0 @@ -if ($request_method = OPTIONS) { - add_header 'Access-Control-Allow-Origin' '*'; - add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, PUT, PATCH, OPTIONS'; - add_header 'Access-Control-Allow-Headers' 'Accept, Authorization, Cache-Control, Content-Type, DNT, If-Modified-Since, Keep-Alive, Origin, User-Agent, X-Requested-With' always; - add_header 'Content-Type' 'text/plain charset=UTF-8'; - add_header 'Content-Length' 0; # No body needed for 204 - return 204; -} \ No newline at end of file From 6cb0d842fed7e97a4976639dd3b1facb56937180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Ra=C4=8Dek?= Date: Sat, 3 Oct 2026 07:53:35 +0200 Subject: [PATCH 02/11] Remove unused directives --- src/deployment/nginx/nginx.dev.conf | 22 +---------- src/deployment/nginx/nginx.local.conf | 55 ++++----------------------- src/deployment/nginx/nginx.prod.conf | 22 +---------- 3 files changed, 10 insertions(+), 89 deletions(-) diff --git a/src/deployment/nginx/nginx.dev.conf b/src/deployment/nginx/nginx.dev.conf index 7a8168ba..49292740 100644 --- a/src/deployment/nginx/nginx.dev.conf +++ b/src/deployment/nginx/nginx.dev.conf @@ -17,11 +17,6 @@ http { sendfile on; tcp_nopush on; types_hash_max_size 2048; - # server_tokens off; - - # server_names_hash_bucket_size 64; - # server_name_in_redirect off; - include /etc/nginx/mime.types; default_type application/octet-stream; @@ -29,8 +24,7 @@ http { # SSL Settings ## - ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE - ssl_prefer_server_ciphers on; + ssl_protocols TLSv1.2 TLSv1.3; ## # Logging Settings @@ -47,7 +41,6 @@ http { resolver 127.0.0.11 valid=30s; client_max_body_size 260M; - client_body_buffer_size 260M; # Rate limiting limit_req_zone $binary_remote_addr zone=calc_limit:10m rate=10r/m; @@ -89,9 +82,6 @@ http { limit_req zone=calc_limit burst=10 nodelay; proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; } @@ -99,9 +89,6 @@ http { limit_req zone=upload_limit burst=10 nodelay; proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; } @@ -109,19 +96,12 @@ http { limit_req zone=api_limit burst=10 nodelay; proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; } location / { proxy_pass http://web:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; - } } } diff --git a/src/deployment/nginx/nginx.local.conf b/src/deployment/nginx/nginx.local.conf index 078d24d5..e9be4cae 100644 --- a/src/deployment/nginx/nginx.local.conf +++ b/src/deployment/nginx/nginx.local.conf @@ -10,6 +10,14 @@ events { } http { + sendfile on; + tcp_nopush on; + types_hash_max_size 2048; + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /var/log/nginx/access.log; + gzip on; + # Rate limiting limit_req_zone $binary_remote_addr zone=calc_limit:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=upload_limit:10m rate=10r/m; @@ -20,7 +28,6 @@ http { resolver 127.0.0.11 valid=30s; client_max_body_size 260M; - client_body_buffer_size 260M; server { listen 80; @@ -36,9 +43,6 @@ http { limit_req zone=calc_limit; proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; } @@ -46,9 +50,6 @@ http { limit_req zone=upload_limit; proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; } @@ -56,53 +57,13 @@ http { limit_req zone=api_limit burst=10; proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; - } location / { proxy_pass http://web:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; } } - ## - # Basic Settings - ## - - sendfile on; - tcp_nopush on; - types_hash_max_size 2048; - # server_tokens off; - - # server_names_hash_bucket_size 64; - # server_name_in_redirect off; - - include /etc/nginx/mime.types; - default_type application/octet-stream; - - ## - # SSL Settings - ## - - ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE - ssl_prefer_server_ciphers on; - - ## - # Logging Settings - ## - - access_log /var/log/nginx/access.log; - - ## - # Gzip Settings - ## - - gzip on; } diff --git a/src/deployment/nginx/nginx.prod.conf b/src/deployment/nginx/nginx.prod.conf index b4558d1d..9d70e31d 100644 --- a/src/deployment/nginx/nginx.prod.conf +++ b/src/deployment/nginx/nginx.prod.conf @@ -17,11 +17,6 @@ http { sendfile on; tcp_nopush on; types_hash_max_size 2048; - # server_tokens off; - - # server_names_hash_bucket_size 64; - # server_name_in_redirect off; - include /etc/nginx/mime.types; default_type application/octet-stream; @@ -29,8 +24,7 @@ http { # SSL Settings ## - ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE - ssl_prefer_server_ciphers on; + ssl_protocols TLSv1.2 TLSv1.3; ## # Logging Settings @@ -47,7 +41,6 @@ http { resolver 127.0.0.11 valid=30s; client_max_body_size 260M; - client_body_buffer_size 260M; # Rate limiting limit_req_zone $binary_remote_addr zone=calc_limit:10m rate=10r/m; @@ -99,9 +92,6 @@ http { limit_req zone=calc_limit burst=10 nodelay; proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; } @@ -109,9 +99,6 @@ http { limit_req zone=upload_limit burst=10 nodelay; proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; } @@ -119,19 +106,12 @@ http { limit_req zone=api_limit burst=10 nodelay; proxy_pass http://api:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; } location / { proxy_pass http://web:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - include /etc/nginx/snippets/proxy_params.conf; - } } } From bc4f7746a9abee131b6aabb6af8998e359cf61f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Ra=C4=8Dek?= Date: Sat, 3 Oct 2026 08:26:17 +0200 Subject: [PATCH 03/11] build: persist nginx logs and validate deployment prerequisites --- .github/workflows/deploy-logic.yml | 16 ++++++++++++++- src/deployment/docker-compose.prod.yml | 10 ++++++++++ src/deployment/nginx/logrotate | 20 +++++++++++++++++++ src/deployment/nginx/nginx.dev.conf | 2 +- src/deployment/nginx/nginx.prod.conf | 2 +- src/deployment/nginx/snippets/access_log.conf | 8 ++++++++ 6 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/deployment/nginx/logrotate create mode 100644 src/deployment/nginx/snippets/access_log.conf diff --git a/.github/workflows/deploy-logic.yml b/.github/workflows/deploy-logic.yml index 6f0ced37..afc63767 100644 --- a/.github/workflows/deploy-logic.yml +++ b/.github/workflows/deploy-logic.yml @@ -52,6 +52,10 @@ jobs: script: | set -e + # Serialize checkout changes and container/log migration on this host. + exec 9>"$HOME/.acc-deploy.lock" + flock -n 9 || { echo "Another ACC deployment is running."; exit 1; } + BRANCH_NAME="${{ inputs.branch_name }}" PROJECT_ROOT="/home/ubuntu/AtomicChargeCalculator" @@ -96,6 +100,16 @@ jobs: COMPOSE_FILES="-f docker-compose.yml -f docker-compose.prod.yml" + if [ "${{ inputs.deploy_nginx }}" == "true" ]; then + # One-time host setup and legacy log backup must precede migration. + if [ ! -d /var/log/acc/nginx ] || [ ! -f /etc/logrotate.d/acc-nginx ]; then + echo "Prepare /var/log/acc/nginx and install nginx/logrotate on the host before deploying." + exit 1 + fi + systemctl is-active --quiet logrotate.timer + docker compose $COMPOSE_FILES run --rm --no-deps --pull never nginx nginx -t + fi + if [ "${{ inputs.deploy_chargefw2 }}" == "true" ]; then echo "Deploying ChargeFW2 Base..." docker build -t chargefw2-base:acc -f $CHARGEFW2_DIR/Dockerfile $CHARGEFW2_DIR --no-cache @@ -114,7 +128,7 @@ jobs: if [ "${{ inputs.deploy_nginx }}" == "true" ]; then echo "Deploying Nginx..." - docker compose $COMPOSE_FILES up -d --no-deps --build --force-recreate nginx + docker compose $COMPOSE_FILES up -d --no-deps --pull never --force-recreate nginx fi echo "Pruning unused images..." diff --git a/src/deployment/docker-compose.prod.yml b/src/deployment/docker-compose.prod.yml index d224011d..b5244ccc 100644 --- a/src/deployment/docker-compose.prod.yml +++ b/src/deployment/docker-compose.prod.yml @@ -10,6 +10,16 @@ services: - /var/www/certbot:/var/www/certbot - /home/ubuntu/AtomicChargeCalculator/src/deployment/nginx/${NGINX_CONFIG_FILE:-nginx.prod.conf}:/etc/nginx/nginx.conf - /home/ubuntu/AtomicChargeCalculator/src/deployment/nginx/snippets:/etc/nginx/snippets + - type: bind + source: /var/log/acc/nginx + target: /var/log/nginx + bind: + create_host_path: false + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" networks: - acc-network restart: diff --git a/src/deployment/nginx/logrotate b/src/deployment/nginx/logrotate new file mode 100644 index 00000000..fb063b9b --- /dev/null +++ b/src/deployment/nginx/logrotate @@ -0,0 +1,20 @@ +/var/log/acc/nginx/access.log /var/log/acc/nginx/error.log { + monthly + rotate 24 + missingok + notifempty + compress + delaycompress + dateext + create 0640 root adm + su root adm + sharedscripts + postrotate + if /usr/bin/docker inspect acc-nginx >/dev/null 2>&1; then + running=$(/usr/bin/docker inspect --format '{{.State.Running}}' acc-nginx) || exit 1 + if [ "$running" = true ]; then + /usr/bin/docker kill --signal=USR1 acc-nginx + fi + fi + endscript +} diff --git a/src/deployment/nginx/nginx.dev.conf b/src/deployment/nginx/nginx.dev.conf index 49292740..8c4db169 100644 --- a/src/deployment/nginx/nginx.dev.conf +++ b/src/deployment/nginx/nginx.dev.conf @@ -30,7 +30,7 @@ http { # Logging Settings ## - access_log /var/log/nginx/access.log; + include /etc/nginx/snippets/access_log.conf; ## # Gzip Settings diff --git a/src/deployment/nginx/nginx.prod.conf b/src/deployment/nginx/nginx.prod.conf index 9d70e31d..eb47184e 100644 --- a/src/deployment/nginx/nginx.prod.conf +++ b/src/deployment/nginx/nginx.prod.conf @@ -30,7 +30,7 @@ http { # Logging Settings ## - access_log /var/log/nginx/access.log; + include /etc/nginx/snippets/access_log.conf; ## # Gzip Settings diff --git a/src/deployment/nginx/snippets/access_log.conf b/src/deployment/nginx/snippets/access_log.conf new file mode 100644 index 00000000..2ae537c4 --- /dev/null +++ b/src/deployment/nginx/snippets/access_log.conf @@ -0,0 +1,8 @@ +# Exclude query strings, cookies and authorization headers from usage logs. +log_format usage escape=json + '{"time":"$time_iso8601","ip":"$remote_addr","host":"$host",' + '"method":"$request_method","path":"$uri","status":$status,' + '"bytes":$body_bytes_sent,"request_time":$request_time,' + '"user_agent":"$http_user_agent","origin":"$http_origin"}'; + +access_log /var/log/nginx/access.log usage; From 777ef535c6ae48c41f06666a8e7ad088413a31a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Ra=C4=8Dek?= Date: Sat, 3 Oct 2026 08:26:27 +0200 Subject: [PATCH 04/11] docs: document Ubuntu deployment setup and maintenance --- docs/deployment/README.md | 183 +++++++++++++++++++++++-- docs/deployment/certificate-renewal.md | 80 ++++++++--- src/deployment/README.md | 71 ++++------ 3 files changed, 256 insertions(+), 78 deletions(-) diff --git a/docs/deployment/README.md b/docs/deployment/README.md index b2845dcd..c648d559 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -1,20 +1,175 @@ # Deployment -- [Automatic Certificate Renewal](./certificate-renewal.md) +The [Deploy (Manual) workflow](../../.github/workflows/deploy-manual.yml) updates an +existing installation over SSH. It does **not** provision a new server or start +PostgreSQL. Follow the bootstrap instructions before using it on a fresh host. -## Deploying to production -`Deploy to Production` GitHub Action provides a semi-automatic way to deploy new ACC III version, implemented [here](../../.github/workflows/deploy.yml). It connects to the server via SSH and rebuilds the configured containers using `docker compose`. It is triggered on `workflow_dispatch` (i.e., manual trigger). +## Prepare an Ubuntu LTS host -You can trigger the deploy workflow by going to *Actions -> Deploy to Production* and choosing which branch and configuration to use during the deployment: -![deploy trigger](./images/deploy-trigger.png) +These instructions assume an x86-64 Ubuntu LTS server with systemd, the SSH user +`ubuntu`, and one ACC installation per host. Paths and container names are fixed +in Compose and the workflow; selecting Development or Production does not isolate +two installations on one host. ChargeFW2 currently contains x86-64-specific paths. -### Configurations -- `all` - rebuild everything -- `api` - rebuild only backend (FastAPI) -- `api including chargefw2` - rebuild API container and ChargeFW2 -- `web` - rebuild only frontend -- `nginx` - rebuild only nginx container +1. Install Git, OpenSSH server, `ca-certificates`, `curl`, `util-linux` (for `flock`), + `logrotate`, and `certbot` using Ubuntu packages. Install Docker Engine and the + Compose v2/Buildx plugins using the [official Ubuntu instructions](https://docs.docker.com/engine/install/ubuntu/). +2. Enable Docker, SSH, and `logrotate.timer`. Grant `ubuntu` Docker access and + reconnect before testing `docker ps` and `docker compose version`. Docker access + is effectively root access. The workflow also requires noninteractive `sudo` + for its Git commands; verify this setup before deployment. +3. Configure SSH key access for GitHub Actions. Ensure the server can fetch the + repository and submodule, including when Git is invoked through `sudo`. +4. Point the configured DNS names at the host. Permit SSH and TCP 80/443; do not + run a host web server that occupies those ports. -### Secrets -Secrets are set in *Settings -> Secrets and variables -> Actions*: -![secrets](./images/secrets.png) \ No newline at end of file +**Network warning:** the current deployment Compose file also publishes database +port 5432, API port 8000, and frontend port 3000 on all interfaces. Restrict these +through the infrastructure firewall or appropriately reviewed Docker-aware rules. +Do not assume UFW alone blocks Docker-published ports. + +Clone the repository to `/home/ubuntu/AtomicChargeCalculator`, then run from that +checkout (replace the clone URL with the repository URL if creating it): + +```bash +git submodule update --init --recursive +sudo install -d -m 0755 /home/acc /var/log/acc /var/www/certbot +sudo install -d -m 0755 -o root -g adm /var/log/acc/nginx +sudo touch /var/log/acc/nginx/access.log /var/log/acc/nginx/error.log +sudo chgrp adm /var/log/acc/nginx/access.log /var/log/acc/nginx/error.log +sudo chmod 0640 /var/log/acc/nginx/access.log /var/log/acc/nginx/error.log +sudo install -m 0644 -o root -g root src/deployment/nginx/logrotate /etc/logrotate.d/acc-nginx +sudo systemctl enable --now logrotate.timer +sudo logrotate --debug /etc/logrotate.d/acc-nginx +``` + +Inspect existing directories before changing permissions on an established host. +The logrotate debug command does not change files or test the reopen hook. + +## Environment and secrets + +Configure GitHub environments named **Development** and **Production**, with the +appropriate deployment secrets: + +| Secret | Purpose | +| --- | --- | +| `SSH_HOST` | Target server | +| `SSH_USERNAME` | Deployment user, normally `ubuntu` | +| `SSH_PRIVATE_KEY` | Key authorized on that server | +| `SSH_PORT` | Optional; defaults to 22 | +| `DB_PASSWORD` | PostgreSQL password | +| `OIDC_CLIENT_ID` | Registered Life Science client | +| `OIDC_CLIENT_SECRET` | Life Science client secret | + +For manual bootstrap, securely export `DB_PASSWORD`, `OIDC_CLIENT_ID`, and +`OIDC_CLIENT_SECRET` in the shell as well. Do not commit credentials or include +them in shared terminal output. The workflow does not create a server `.env` file. +Current workflow exports interpolate secrets into shell commands, and the database +password is embedded in a connection URL. Shell-special characters and URL-reserved +characters are not safely supported by that implementation; review compatibility +before deployment rather than weakening credential strength. + +For **Development**, also export: + +```bash +export VITE_BASE_API_URL=https://acc-dev.biodata.ceitec.cz:443/api/v1 +export OIDC_BASE_URL=https://acc-dev.biodata.ceitec.cz/ +export OIDC_REDIRECT_URL=https://acc-dev.biodata.ceitec.cz/api/v1/auth/callback +export NGINX_CONFIG_FILE=nginx.dev.conf +``` + +For **Production**, use: + +```bash +export VITE_BASE_API_URL=https://acc.biodata.ceitec.cz:443/api/v1 +export OIDC_BASE_URL=https://acc.biodata.ceitec.cz/ +export OIDC_REDIRECT_URL=https://acc.biodata.ceitec.cz/api/v1/auth/callback +export NGINX_CONFIG_FILE=nginx.prod.conf +``` + +Register the matching callback with Life Science. The frontend API URL is baked +into its image at build time. Both deployments use the database name `postgres_prod` +and user `prod_user` on their respective hosts. Changing `DB_PASSWORD` does not +change the password in an already initialized PostgreSQL volume. + +## First deployment + +Obtain [initial TLS certificates](./certificate-renewal.md) before starting Nginx. +The production configuration needs a certificate covering both public hostnames. + +From the repository root, with the environment above set: + +```bash +docker build -t chargefw2-base:acc -f ChargeFW2/Dockerfile ChargeFW2 +cd src/deployment +docker compose -f docker-compose.yml -f docker-compose.prod.yml pull nginx +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d db +docker compose -f docker-compose.yml -f docker-compose.prod.yml ps db +``` + +Wait until PostgreSQL is healthy, then build and start the application: + +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build api web +docker logs --tail 100 acc-api +docker compose -f docker-compose.yml -f docker-compose.prod.yml run --rm --no-deps --pull never nginx nginx -t +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps --pull never nginx +``` + +Check API startup logs for successful database migrations and application startup; +a running container alone does not establish that migrations succeeded. Verify +HTTPS, API calls, example viewing, and login. Complete certificate renewal setup. + +Use **both** Compose files on hosted dev and production. Bare `docker compose` +loads the local-development override. Nginx preflight needs existing certificates, +host log setup, a locally available Nginx image, and running/resolvable `api` and +`web` services. Consequently, selecting every GitHub deployment option on an empty +host is not a substitute for this bootstrap. + +## Subsequent GitHub deployments + +Open **Actions > Deploy (Manual) > Run workflow**. Select the target environment, +the workflow revision, the branch to deploy, and the required checkboxes: + +- **Rebuild ChargeFW2:** builds the base image; also select **Deploy API** to use it. +- **Deploy API:** builds and recreates the API container. +- **Deploy Web:** builds and recreates the frontend container. +- **Deploy Nginx:** checks logging prerequisites and tests the candidate Nginx + configuration before service updates, then recreates Nginx. + +All checkboxes default to false. The [deployment logic](../../.github/workflows/deploy-logic.yml) +resets the server checkout to the selected branch; do not keep uncommitted work +there. A host lock rejects overlapping workflow deployments. Deployments are not +zero-downtime and API recreation can interrupt jobs. Use a maintenance window when +necessary. Nginx uses `--pull never`; explicitly pull a chosen image before deploying +an image update. Restarting a container does not apply changed Compose mounts. + +## Logging maintenance + +After deployment, verify that website requests appear as JSON in +`/var/log/acc/nginx/access.log`. Check that `logrotate.timer` remains active and +monitor available disk space. To test rotation, use the development server: + +```bash +sudo logrotate --force /etc/logrotate.d/acc-nginx +``` + +Make another request and confirm it appears in the replacement `access.log`. +This exercises the log reopen hook without restarting Nginx. Normal rotation is +scheduled automatically; forced rotation is only a verification step. + +## Persistence and retention + +- Nginx access/error logs live in host `/var/log/acc/nginx`; monthly rotation keeps + 24 archives and compresses older files. Empty files are skipped. This is not a + hard size limit; monitor disk space and copy historical logs off-server. +- Nginx stdout/stderr remains available through `docker logs`, bounded by Docker's + configured rotation. These diagnostics are removed with the container; + persistent access/error files are separate from Docker logs. +- API logs remain in host `/var/log/acc/logs.log`; the Nginx rotation rule does not + rotate that file. Calculation data remains under host `/home/acc`. +- PostgreSQL uses named volume `deployment_db-data` with the default project name. + Never use `docker compose down -v` or remove that volume to perform an update. + +Host logs and volumes survive container recreation, not host loss. Arrange and test +off-server backups separately. diff --git a/docs/deployment/certificate-renewal.md b/docs/deployment/certificate-renewal.md index 18c159bc..24ecca46 100644 --- a/docs/deployment/certificate-renewal.md +++ b/docs/deployment/certificate-renewal.md @@ -1,38 +1,76 @@ -# Certificate Renewal -The following doc describes a way to automatically renew SSL certificates using `certbot` and `systemd`. +# TLS Certificates -## Systemd Configuration -Certbot relies on `certbot.timer` and `certbot.service` services to automatically renew certificates. Verify that both are running on your system: +Certbot runs on the Ubuntu host. Nginx runs in Docker and mounts `/etc/letsencrypt` +and `/var/www/certbot`. Do not use `certbot --nginx` to configure this container. + +## Initial issuance + +Set DNS and allow inbound TCP 80 before issuance. On a fresh host, with port 80 +free, use standalone validation. For development: ```bash -$ sudo systemctl status certbot.timer -$ sudo systemctl status certbot.service +sudo certbot certonly --standalone --cert-name acc-dev.biodata.ceitec.cz -d acc-dev.biodata.ceitec.cz +``` -# List running certbot timers: -$ sudo systemctl list-timers | grep certbot +For production: + +```bash +sudo certbot certonly --standalone --cert-name acc2.ncbr.muni.cz -d acc2.ncbr.muni.cz -d acc.biodata.ceitec.cz ``` -### Webroot Configuration -So that we don't have to stop the nginx container to free up the port 80 (used by certbot for certificate renewal), we can configure it to use `webroot` authenticator instead. +Production Nginx uses the `acc2.ncbr.muni.cz` certificate directory for both names; +the certificate must cover both. Supply the requested contact information and agree +to the CA terms. Do not rerun standalone issuance against an existing busy server +without arranging port availability. Existing valid certificates need no reissue. -Use the configuration from [domain.conf](/deployment/certificates/renewal/domain.conf) in `/etc/letsencrypt/renewal/.conf` on your system. +Once certificates exist, finish the [first deployment](./README.md#first-deployment). -This will use the already running nginx for verification, instead of spinning up a temporary HTTP server in the case of `standalone` authenticator. +## Webroot renewal + +After Nginx is serving HTTP, switch each certificate to webroot renewal so future +renewals do not stop the website. With a Certbot version supporting `reconfigure`: -## Reloading Nginx -Once the certificate is renewed (deployed) we need to reload Nginx configuration. This can be achieved with the `deploy` certbot hook. Add the [reload-nginx.sh](/deployment/certificates/renewal-hooks/deploy/reload-nginx.sh) script to the `renewal-hooks` directory on your system: ```bash -$ cp deployment/certificates/renewal-hooks/deploy/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy -$ chmod +x /etc/letsencrypt/renewal-hooks/deploy +sudo certbot reconfigure --cert-name acc-dev.biodata.ceitec.cz --authenticator webroot --webroot-path /var/www/certbot ``` -> _Note:_ Don't forget to adjust the path to the `docker-compose.yml` file on your system, if it differs. +Use `acc2.ncbr.muni.cz` instead on production. Check `certbot reconfigure --help`; +if the Ubuntu package lacks this command, follow the installed version's documented +renewal-parameter procedure or upgrade Certbot. The repository +[domain.conf](../../src/deployment/certificates/renewal/domain.conf) shows renewal +parameters only: **do not overwrite the entire generated renewal file with it**. + +Nginx serves `/.well-known/acme-challenge/` from `/var/www/certbot` on HTTP port 80. +DNS and that port must remain reachable for renewal. + +## Reload hook and scheduler + +From the repository root: + +```bash +sudo install -d /etc/letsencrypt/renewal-hooks/deploy +sudo install -m 0755 src/deployment/certificates/renewal-hooks/deploy/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh +sudo systemctl enable --now certbot.timer +systemctl status certbot.timer +``` + +The hook discovers a running container whose name contains `nginx` and reloads its +configuration. It assumes one such container on the host. No Compose path needs +editing. `certbot.service` is a oneshot; it need not stay active between timer runs. + +## Verification + +```bash +sudo certbot renew --dry-run +``` -## Testing -To test if the setup works, you can force the certificate renewal: +A dry run checks renewal but does not normally execute deploy hooks. Separately, +after validating the running Nginx configuration, test the installed reload hook: ```bash -$ sudo certbot renew --cert-name --force-renew +docker exec acc-nginx nginx -t +sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ``` -If everything worked as expected, you should see the new certificate (e.g. when navigating to the page). \ No newline at end of file +Inspect command output for successful renewal and reload. Do not routinely force +real certificate renewals just to test setup; that can consume CA rate limits. diff --git a/src/deployment/README.md b/src/deployment/README.md index ce87a6f0..2c960fb7 100644 --- a/src/deployment/README.md +++ b/src/deployment/README.md @@ -1,59 +1,44 @@ # Deployment -How to use the *Deploy to Production* GitHub Action to deploy a new ACC III version is described [here](../../docs/deployment/README.md). -## Nginx +For hosted development or production, follow the +[Ubuntu host setup, bootstrap, and GitHub Actions guide](../../docs/deployment/README.md). +The deployment workflow updates an existing installation; it does not provision it. -`nginx.conf` provides a configuration that can be used to deploy ACC III using nginx. Reusable declarations are located in `./snippets` to avoid unnecessary duplicity. +## Local Docker setup -## SSL Configuration -A simple way is to use certbot (Let's Encrypt): +Run from the repository root: -```bash -$ certbot --nginx -d -``` - -## Setup Using Docker -A quicker way of running ACC III locally is to use `docker compose`. This will start up 3 containers for API, web and database. - -*Note:* If you are using Windows, you might encounter the following error during `api` container startup: -``` -exec /acc/entrypoint.sh: no such file or directory -``` - -This is because of line endings and can be fixed by running `dos2unix` on the `src/backend/entrypoint.sh` file. - -### Prepare base ChargeFW2 image -We first need to build the base ChargeFW2 image which is used by the api container: ```bash -$ cd ../../ -$ docker build -t chargefw2-base:local -f ./src/backend/Dockerfile.chargefw2 . +git submodule update --init --recursive +docker build -t chargefw2-base:acc -f ChargeFW2/Dockerfile ChargeFW2 +cd src/deployment +docker compose up -d --build ``` -### Start -Use `-d` if you want the containers to start in the background. +This starts four services: PostgreSQL, API, frontend, and Nginx. Access the UI through +`http://localhost:8080`. Local OIDC credentials and callback settings are placeholders; +working Life Science login needs a registered client and appropriate configuration. -```bash -$ cd ./src/deployment -$ docker compose up -d -``` +To stop the local stack without deleting database volumes: -### Stop ```bash -$ docker compose down +docker compose down ``` -### Production startup -In order to run compose in production mode, you need to specify which compose files should be used: +On Windows, an API startup error such as `exec /acc/entrypoint.sh: no such file or +directory` can result from CRLF line endings. Ensure `src/backend/entrypoint.sh` +uses Unix line endings. -```bash -$ docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d -``` +## Configuration files -*Notes*: -- This will not work locally as the frontend app will try to make requests to the production server. -- Environment variable `DB_PASSWORD` needs to be specified (any password). +- `docker-compose.yml`: base services and PostgreSQL volume. +- `docker-compose.override.yml`: automatically loaded local-development settings. +- `docker-compose.prod.yml`: explicitly selected for both hosted dev and production; + requires host directories, credentials, and certificates described in the guide. +- `nginx/nginx.local.conf`, `nginx/nginx.dev.conf`, `nginx/nginx.prod.conf`: Nginx + configurations with shared declarations under `nginx/snippets/`. +- `nginx/logrotate`: host rotation rule, installed as `/etc/logrotate.d/acc-nginx`. -### Compose Files -- `docker-compose.yml` base configuration. -- `docker-compose.override.yml` development configuration. Is used automatically. -- `docker-compose.prod.yml` production configuration. Needs to be explicitly specified (as described above) +For hosted deployments always specify `-f docker-compose.yml -f docker-compose.prod.yml`. +Do not use the host Certbot Nginx plugin to configure the container; follow the +[initial certificate and renewal instructions](../../docs/deployment/certificate-renewal.md). From 474a268059f23c25e06ee697b952901e7b3efba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Ra=C4=8Dek?= Date: Sat, 3 Oct 2026 09:49:54 +0200 Subject: [PATCH 05/11] Update usage tracking --- docs/deployment/README.md | 25 + src/deployment/nginx/snippets/access_log.conf | 3 +- src/frontend/acc/src/api/base.ts | 3 + utils/usage_stats.py | 602 ++++++++++++++++++ 4 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 utils/usage_stats.py diff --git a/docs/deployment/README.md b/docs/deployment/README.md index c648d559..5b2d3ec9 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -173,3 +173,28 @@ scheduled automatically; forced rotation is only a verification step. Host logs and volumes survive container recreation, not host loss. Arrange and test off-server backups separately. + +## Usage reports + +Generate a local report over SSH; the tool does not change the server. Examples: + +```bash +python3 utils/usage_stats.py --server prod --year 2026 --output /tmp/acc-2026 +python3 utils/usage_stats.py --server prod --database --start-date 2026-07-01 --end-date 2026-10-01 --output /tmp/acc-q3 +``` + +Use `--year` for a calendar year, or `--start-date` and exclusive `--end-date` for a +UTC interval. Add `--database` for method, molecules-per-submission, and molecule-size +summaries. The report is useful for checking web traffic, calculation activity, or the +mix of small and large inputs. Database dates mean record creation; method counts do +not distinguish cached results from new calculations. Large-input categories are not +chemical classifications, and actual computation runtimes are not recorded. +Nginx results distinguish all IPs from IPs on requests not flagged by the known-bot +heuristic; calculation submissions are shown separately. API logs lack user agents, +so bot-excluded API counts are unavailable. +New frontend requests carry a `web-app` marker; reports separate marked, unmarked, +and older requests with no marker. Unmarked does not necessarily mean scripted. + +Reports contain `report.txt`, `report.json`, and `monthly.csv`; raw IP addresses are +never included. Choose a new output directory for each run. See `--help` for offline +log inputs and other options. diff --git a/src/deployment/nginx/snippets/access_log.conf b/src/deployment/nginx/snippets/access_log.conf index 2ae537c4..7db490ce 100644 --- a/src/deployment/nginx/snippets/access_log.conf +++ b/src/deployment/nginx/snippets/access_log.conf @@ -3,6 +3,7 @@ log_format usage escape=json '{"time":"$time_iso8601","ip":"$remote_addr","host":"$host",' '"method":"$request_method","path":"$uri","status":$status,' '"bytes":$body_bytes_sent,"request_time":$request_time,' - '"user_agent":"$http_user_agent","origin":"$http_origin"}'; + '"user_agent":"$http_user_agent","origin":"$http_origin",' + '"acc_client":"$http_x_acc_client"}'; access_log /var/log/nginx/access.log usage; diff --git a/src/frontend/acc/src/api/base.ts b/src/frontend/acc/src/api/base.ts index f3158992..1ada1f9a 100644 --- a/src/frontend/acc/src/api/base.ts +++ b/src/frontend/acc/src/api/base.ts @@ -11,6 +11,9 @@ if (!baseApiUrl) { export const api = axios.create({ baseURL: baseApiUrl, withCredentials: true, + headers: { + "X-ACC-Client": "web-app", + }, }); export const DEFAULT_ERROR_MESSAGE = diff --git a/utils/usage_stats.py b/utils/usage_stats.py new file mode 100644 index 00000000..31d2e551 --- /dev/null +++ b/utils/usage_stats.py @@ -0,0 +1,602 @@ +#!/usr/bin/env python3 +"""Read-only, full-scan ACC usage reports using Python 3.9+ and OpenSSH. + +Example: python3 utils/usage_stats.py --server dev --year 2026 --output ./usage +Offline: add --nginx-log access.log --nginx-log access.log.1.gz --api-log logs.log +Raw IP addresses are never included in reports. +""" + +import argparse +import csv +import gzip +import ipaddress +import json +import re +import shlex +import subprocess +import sys +import zlib +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlsplit + + +SERVERS = {"dev": "acc-dev.biodata.ceitec.cz", "prod": "acc.biodata.ceitec.cz"} +DOCUMENTS = {"/", "/docs", "/setup", "/results", "/calculations", "/files"} +CATEGORIES = ("all", "website_document", "calculation_submission", "example_api", "other") +CLIENT_TYPES = ("web_app", "unmarked", "unknown") +COHORTS = {"website_without_submission": "website_document", + "examples_without_submission": "example_api"} +API_REQUEST = re.compile( + r"^(?P