diff --git a/infra/README.md b/infra/README.md index 701528f..be44b39 100644 --- a/infra/README.md +++ b/infra/README.md @@ -40,3 +40,31 @@ Review `terraform plan` before every apply. The existing VPS must be imported ra - `ssh_public_key`: deploy SSH public key Keep real values in `infra/terraform/terraform.tfvars`, which is gitignored. + +## VPS Baseline + +Issue `#107` builds on the Terraform layer after DNS from issue `#106` has propagated. + +1. Copy the repository or the `infra/` directory to the VPS. +2. Export `DEPLOY_SSH_PUBLIC_KEY` as the deploy user's public SSH key, then run `infra/scripts/bootstrap-vps.sh` as `root`. +3. If you did not set `DEPLOY_SSH_PUBLIC_KEY`, manually append the deploy public key to `/home/deploy/.ssh/authorized_keys` after the script finishes. +4. Review and install the Nginx site configs from `infra/nginx/`. +5. Run `infra/scripts/issue-certs.sh` to request the initial certificates. +6. Verify renewal with `certbot renew --dry-run`. + +After setup, the VPS layout should look like: + +```text +/opt/station/ + docker-compose.prod.yml + .env.production + logs/ +``` + +The scripts here are designed to establish the baseline only: + +- `infra/scripts/bootstrap-vps.sh`: installs Docker, Nginx, Certbot, the `deploy` user, `/opt/station`, and swap. +- `infra/scripts/setup-swap.sh`: creates and enables a persistent 2 GB swap file. +- `infra/scripts/issue-certs.sh`: requests the initial Let's Encrypt certificates for Station domains. + +The Nginx configs in `infra/nginx/` are plain HTTP bootstrap configs. Certbot updates them with HTTPS and redirect blocks after certificates are issued. diff --git a/infra/nginx/api.drdnt.org.conf b/infra/nginx/api.drdnt.org.conf new file mode 100644 index 0000000..e1987e6 --- /dev/null +++ b/infra/nginx/api.drdnt.org.conf @@ -0,0 +1,13 @@ +server { + listen 80; + server_name api.drdnt.org; + + location / { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/infra/nginx/bot.drdnt.org.conf b/infra/nginx/bot.drdnt.org.conf new file mode 100644 index 0000000..06afca0 --- /dev/null +++ b/infra/nginx/bot.drdnt.org.conf @@ -0,0 +1,14 @@ +server { + listen 80; + server_name bot.drdnt.org; + + # Placeholder upstream until station-bot is deployed on the VPS. + location / { + proxy_pass http://127.0.0.1:3999; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/infra/nginx/station.drdnt.org.conf b/infra/nginx/station.drdnt.org.conf new file mode 100644 index 0000000..7b42bd1 --- /dev/null +++ b/infra/nginx/station.drdnt.org.conf @@ -0,0 +1,13 @@ +server { + listen 80; + server_name station.drdnt.org; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/infra/scripts/bootstrap-vps.sh b/infra/scripts/bootstrap-vps.sh new file mode 100755 index 0000000..75d08a2 --- /dev/null +++ b/infra/scripts/bootstrap-vps.sh @@ -0,0 +1,75 @@ +#!/bin/bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "Run this script as root." + exit 1 +fi + +DEPLOY_USER="deploy" +DEPLOY_HOME="/home/${DEPLOY_USER}" +STATION_ROOT="/opt/station" + +apt update +apt upgrade -y + +apt install -y ca-certificates curl gnupg lsb-release + +install -m 0755 -d /etc/apt/keyrings +if [ ! -f /etc/apt/keyrings/docker.asc ]; then + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc +fi + +ARCH="$(dpkg --print-architecture)" +CODENAME="$(. /etc/os-release && echo "${VERSION_CODENAME}")" +echo \ + "deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${CODENAME} stable" \ + > /etc/apt/sources.list.d/docker.list + +apt update +apt install -y \ + docker-ce \ + docker-ce-cli \ + containerd.io \ + docker-buildx-plugin \ + docker-compose-plugin \ + nginx \ + certbot \ + python3-certbot-nginx + +systemctl enable --now docker +systemctl enable --now nginx + +if ! id -u "${DEPLOY_USER}" >/dev/null 2>&1; then + useradd -m -s /bin/bash "${DEPLOY_USER}" +fi + +usermod -aG docker "${DEPLOY_USER}" + +install -d -m 700 -o "${DEPLOY_USER}" -g "${DEPLOY_USER}" "${DEPLOY_HOME}/.ssh" +touch "${DEPLOY_HOME}/.ssh/authorized_keys" +chmod 600 "${DEPLOY_HOME}/.ssh/authorized_keys" +chown "${DEPLOY_USER}:${DEPLOY_USER}" "${DEPLOY_HOME}/.ssh/authorized_keys" + +if [ -n "${DEPLOY_SSH_PUBLIC_KEY:-}" ]; then + if ! grep -Fqx "${DEPLOY_SSH_PUBLIC_KEY}" "${DEPLOY_HOME}/.ssh/authorized_keys"; then + echo "${DEPLOY_SSH_PUBLIC_KEY}" >> "${DEPLOY_HOME}/.ssh/authorized_keys" + chown "${DEPLOY_USER}:${DEPLOY_USER}" "${DEPLOY_HOME}/.ssh/authorized_keys" + fi +else + echo "DEPLOY_SSH_PUBLIC_KEY is not set. Add the deploy key to ${DEPLOY_HOME}/.ssh/authorized_keys manually." +fi + +install -d -m 755 -o "${DEPLOY_USER}" -g "${DEPLOY_USER}" "${STATION_ROOT}" +install -d -m 755 -o "${DEPLOY_USER}" -g "${DEPLOY_USER}" "${STATION_ROOT}/logs" + +bash "$(dirname "$0")/setup-swap.sh" + +echo +echo "Bootstrap complete." +echo "- Install Nginx configs from infra/nginx/ into /etc/nginx/sites-available/" +echo "- Enable the sites and reload Nginx." +echo "- Run infra/scripts/issue-certs.sh once DNS is live." +echo "- Confirm the deploy user can SSH and run Docker commands without sudo." diff --git a/infra/scripts/issue-certs.sh b/infra/scripts/issue-certs.sh new file mode 100755 index 0000000..d44aa99 --- /dev/null +++ b/infra/scripts/issue-certs.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "Run this script as root." + exit 1 +fi + +certbot --nginx \ + -d api.drdnt.org \ + -d station.drdnt.org \ + -d bot.drdnt.org + +certbot renew --dry-run diff --git a/infra/scripts/setup-swap.sh b/infra/scripts/setup-swap.sh new file mode 100755 index 0000000..0e8b0da --- /dev/null +++ b/infra/scripts/setup-swap.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "Run this script as root." + exit 1 +fi + +if swapon --show | grep -q '^/swapfile'; then + echo "/swapfile is already active." + exit 0 +fi + +if [ ! -f /swapfile ]; then + fallocate -l 2G /swapfile + chmod 600 /swapfile +fi + +if ! blkid -o value -s TYPE /swapfile 2>/dev/null | grep -qx 'swap'; then + mkswap /swapfile +fi + +swapon /swapfile + +if ! grep -q '^/swapfile none swap sw 0 0$' /etc/fstab; then + echo '/swapfile none swap sw 0 0' >> /etc/fstab +fi + +echo "Swap is active:" +swapon --show diff --git a/infra/tests/infrastructure.test.mjs b/infra/tests/infrastructure.test.mjs index d80f1ce..251eff3 100644 --- a/infra/tests/infrastructure.test.mjs +++ b/infra/tests/infrastructure.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -71,3 +72,97 @@ test('infra README documents terraform import and apply workflow', () => { assert.match(readme, /terraform plan/); assert.match(readme, /terraform apply/); }); + +test('bash scripts have valid shell syntax', () => { + if (process.platform === 'win32') { + return; + } + + const scripts = [ + path.join(infraRoot, 'scripts/bootstrap-vps.sh'), + path.join(infraRoot, 'scripts/setup-swap.sh'), + path.join(infraRoot, 'scripts/issue-certs.sh'), + ]; + + try { + for (const script of scripts) { + execFileSync('bash', ['-n', script]); + } + } catch (error) { + if (error && typeof error === 'object' && 'code' in error) { + const code = String(error.code); + if (code === 'ENOENT' || code === 'EPERM') { + return; + } + } + throw error; + } +}); + +test('bootstrap script provisions required VPS baseline steps', () => { + const script = readInfraFile('scripts/bootstrap-vps.sh'); + + assert.match(script, /apt update/); + assert.match(script, /apt upgrade -y/); + assert.match(script, /docker-ce/); + assert.match(script, /docker-compose-plugin/); + assert.match(script, /nginx/); + assert.match(script, /certbot/); + assert.match(script, /python3-certbot-nginx/); + assert.match(script, /useradd -m -s \/bin\/bash "\$\{DEPLOY_USER\}"/); + assert.match(script, /usermod -aG docker "\$\{DEPLOY_USER\}"/); + assert.match(script, /authorized_keys/); + assert.match(script, /\/opt\/station/); + assert.match(script, /bash "\$\(dirname "\$0"\)\/setup-swap\.sh"/); +}); + +test('swap script creates and persists a 2 GB swap file', () => { + const script = readInfraFile('scripts/setup-swap.sh'); + + assert.match(script, /fallocate -l 2G \/swapfile/); + assert.match(script, /chmod 600 \/swapfile/); + assert.match(script, /mkswap \/swapfile/); + assert.match(script, /swapon \/swapfile/); + assert.match(script, /\/swapfile none swap sw 0 0/); +}); + +test('cert issuance script requests all Station domains and verifies renewal', () => { + const script = readInfraFile('scripts/issue-certs.sh'); + + assert.match(script, /certbot --nginx/); + assert.match(script, /-d api\.drdnt\.org/); + assert.match(script, /-d station\.drdnt\.org/); + assert.match(script, /-d bot\.drdnt\.org/); + assert.match(script, /certbot renew --dry-run/); +}); + +test('nginx configs target the expected upstreams', () => { + const apiConfig = readInfraFile('nginx/api.drdnt.org.conf'); + const stationConfig = readInfraFile('nginx/station.drdnt.org.conf'); + const botConfig = readInfraFile('nginx/bot.drdnt.org.conf'); + + assert.match(apiConfig, /server_name api\.drdnt\.org;/); + assert.match(apiConfig, /proxy_pass http:\/\/127\.0\.0\.1:3001;/); + + assert.match(stationConfig, /server_name station\.drdnt\.org;/); + assert.match(stationConfig, /proxy_pass http:\/\/127\.0\.0\.1:3000;/); + + assert.match(botConfig, /server_name bot\.drdnt\.org;/); + assert.match(botConfig, /proxy_pass http:\/\/127\.0\.0\.1:3999;/); +}); + +test('infra scripts are executable on disk', () => { + if (process.platform === 'win32') { + return; + } + + const bootstrapMode = statSync( + path.join(infraRoot, 'scripts/bootstrap-vps.sh'), + ).mode; + const swapMode = statSync(path.join(infraRoot, 'scripts/setup-swap.sh')).mode; + const certMode = statSync(path.join(infraRoot, 'scripts/issue-certs.sh')).mode; + + assert.ok(bootstrapMode & 0o111); + assert.ok(swapMode & 0o111); + assert.ok(certMode & 0o111); +});