diff --git a/.github/workflows/deploy-logic.yml b/.github/workflows/deploy-logic.yml index 6f0ced37..07fad32b 100644 --- a/.github/workflows/deploy-logic.yml +++ b/.github/workflows/deploy-logic.yml @@ -34,6 +34,9 @@ jobs: deploy: name: Deploy Logic runs-on: ubuntu-latest + concurrency: + group: deploy-${{ inputs.target_environment }} + cancel-in-progress: false environment: name: ${{ inputs.target_environment }} @@ -43,15 +46,24 @@ jobs: - name: Deploy via SSH uses: appleboy/ssh-action@v1 + env: + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + OIDC_CLIENT_ID: ${{ secrets.OIDC_CLIENT_ID }} + OIDC_CLIENT_SECRET: ${{ secrets.OIDC_CLIENT_SECRET }} with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USERNAME }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SSH_PORT || 22 }} command_timeout: 60m + envs: DB_PASSWORD,OIDC_CLIENT_ID,OIDC_CLIENT_SECRET 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" @@ -61,10 +73,6 @@ jobs: echo "Deployment Branch: [$BRANCH_NAME]" echo "Setting up environment variables ..." - export DB_PASSWORD=${{ secrets.DB_PASSWORD }} - - export OIDC_CLIENT_ID=${{ secrets.OIDC_CLIENT_ID }} - export OIDC_CLIENT_SECRET=${{ secrets.OIDC_CLIENT_SECRET }} if [ "${{ inputs.target_environment }}" = "Production" ]; then export VITE_BASE_API_URL="https://acc.biodata.ceitec.cz:443/api/v1" @@ -89,13 +97,23 @@ jobs: fi sudo git checkout -B "$BRANCH_NAME" "origin/$BRANCH_NAME" sudo git reset --hard "origin/$BRANCH_NAME" - sudo git submodule update --recursive + sudo git submodule update --init --recursive cd $DEPLOY_DIR echo "Changed directory to $(pwd)" 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 always 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 +132,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/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 7177b47f..5c918826 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -19,12 +19,12 @@ jobs: uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '26.10.0' - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 12.8.1 diff --git a/.gitmodules b/.gitmodules index c814afe9..16b5698b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "ChargeFW2"] path = ChargeFW2 - url = https://github.com/sb-ncbr/ChargeFW2.git + url = git@github.com:sb-ncbr/ChargeFW2.git diff --git a/ChargeFW2 b/ChargeFW2 index fd3be550..ce2baa0b 160000 --- a/ChargeFW2 +++ b/ChargeFW2 @@ -1 +1 @@ -Subproject commit fd3be5504380f39d790939f7bab613ae5bc028d4 +Subproject commit ce2baa0b8258ab3a1dba1eb65fbebd6c9d3d03c6 diff --git a/docs/deployment/README.md b/docs/deployment/README.md index b2845dcd..5b2d3ec9 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -1,20 +1,200 @@ # 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. + +## 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/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). diff --git a/src/deployment/docker-compose.prod.yml b/src/deployment/docker-compose.prod.yml index d224011d..efbc880c 100644 --- a/src/deployment/docker-compose.prod.yml +++ b/src/deployment/docker-compose.prod.yml @@ -1,7 +1,7 @@ services: nginx: container_name: acc-nginx - image: nginx + image: nginx:1.31.6 ports: - "80:80" - "443:443" @@ -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/docker-compose.yml b/src/deployment/docker-compose.yml index 7c887338..5ca39fa1 100644 --- a/src/deployment/docker-compose.yml +++ b/src/deployment/docker-compose.yml @@ -1,7 +1,7 @@ services: nginx: container_name: acc-nginx - image: nginx + image: nginx:1.31.6 volumes: - ./nginx/nginx.local.conf:/etc/nginx/nginx.conf:Z - ./nginx/snippets:/etc/nginx/snippets:Z 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 50661a7b..8c4db169 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,14 +24,13 @@ 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 ## - access_log /var/log/nginx/access.log; + include /etc/nginx/snippets/access_log.conf; ## # Gzip 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,11 +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/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -101,11 +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/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -113,23 +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/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; 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/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..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,11 +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/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -48,11 +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/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -60,57 +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/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; 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/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; 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 fb7b1f19..eb47184e 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,14 +24,13 @@ 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 ## - access_log /var/log/nginx/access.log; + include /etc/nginx/snippets/access_log.conf; ## # Gzip 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,11 +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/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -111,11 +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/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; } @@ -123,23 +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/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; 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/cors_headers.conf; - include /etc/nginx/snippets/options_handling.conf; include /etc/nginx/snippets/proxy_params.conf; - } } } diff --git a/src/deployment/nginx/snippets/access_log.conf b/src/deployment/nginx/snippets/access_log.conf new file mode 100644 index 00000000..7db490ce --- /dev/null +++ b/src/deployment/nginx/snippets/access_log.conf @@ -0,0 +1,9 @@ +# 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",' + '"acc_client":"$http_x_acc_client"}'; + +access_log /var/log/nginx/access.log usage; 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 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