diff --git a/.env.example b/.env.example index 06b8112..50f00a9 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,19 @@ RCON_PASSWORD=change-me-to-something-random # Only needed if using CurseForge-exclusive mods: # CF_API_KEY=your-curseforge-api-key +# ── Let's Encrypt via OVH DNS-01 (tooling/issue-letsencrypt.sh) ─────── +# Only needed to issue real TLS certs. DNS-01 needs no inbound ports. +LE_EMAIL=you@example.com +# space-separated subdomains; apex ${BASE_DOMAIN} is always included +LE_SUBDOMAINS=auth pack +# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/ +# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it. +OVH_END_POINT=ovh-eu +OVH_AK= +OVH_AS= +OVH_CK= +# LE_STAGING=1 # use the untrusted staging CA for dry runs + # ── Blessing Skin auth variant (docker-compose.blessingskin.yml) ────── # Only needed when running the Blessing Skin override instead of Drasl. BS_DB_NAME=blessingskin diff --git a/.gitignore b/.gitignore index a67f3b0..621410a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ landing/dist/ # exported Caddy CA (per-deploy artifact) caddy/caddy-root-ca.crt + +# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh) +certs/ diff --git a/nginx/ulicraft.conf.tmpl b/nginx/ulicraft.conf.tmpl new file mode 100644 index 0000000..ef87894 --- /dev/null +++ b/nginx/ulicraft.conf.tmpl @@ -0,0 +1,83 @@ +# ────────────────────────────────────────────────────────────────── +# Ulicraft host nginx — TLS ingress with Let's Encrypt certs. +# ────────────────────────────────────────────────────────────────── +# Template. Render with BOTH vars then install: +# +# APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \ +# envsubst '$APP_DIR $BASE_DOMAIN' \ +# < nginx/ulicraft.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf +# sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/ +# sudo nginx -t && sudo systemctl reload nginx +# +# Prereqs: +# - certs issued: tooling/issue-letsencrypt.sh -> $APP_DIR/certs//{cert,key}.pem +# - the docker stack up with the nginx override (publishes drasl on 127.0.0.1:25585): +# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d +# - nginx's user (www-data) can READ $APP_DIR/{www,pack,custom,certs} +# ────────────────────────────────────────────────────────────────── + +# HTTP -> HTTPS for every name. +server { + listen 80; + listen [::]:80; + server_name ${BASE_DOMAIN} auth.${BASE_DOMAIN} pack.${BASE_DOMAIN}; + return 301 https://$host$request_uri; +} + +# Apex — static landing page. +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name ${BASE_DOMAIN}; + + ssl_certificate ${APP_DIR}/certs/${BASE_DOMAIN}/cert.pem; + ssl_certificate_key ${APP_DIR}/certs/${BASE_DOMAIN}/key.pem; + + root ${APP_DIR}/www; + index index.html; + location / { try_files $uri $uri/ =404; } +} + +# Pack — packwiz metadata (./pack) + custom jars (./custom at /custom/). +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name pack.${BASE_DOMAIN}; + + ssl_certificate ${APP_DIR}/certs/pack.${BASE_DOMAIN}/cert.pem; + ssl_certificate_key ${APP_DIR}/certs/pack.${BASE_DOMAIN}/key.pem; + + # Locally-hosted custom mod jars live outside ./pack; expose them at /custom/. + location /custom/ { + alias ${APP_DIR}/custom/; + autoindex on; + } + + location / { + root ${APP_DIR}/pack; + autoindex on; + } +} + +# Auth — reverse proxy to drasl (published on localhost by the nginx override). +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name auth.${BASE_DOMAIN}; + + ssl_certificate ${APP_DIR}/certs/auth.${BASE_DOMAIN}/cert.pem; + ssl_certificate_key ${APP_DIR}/certs/auth.${BASE_DOMAIN}/key.pem; + + client_max_body_size 4m; # skin uploads + + location / { + proxy_pass http://127.0.0.1:25585; + 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/plan/15-letsencrypt.md b/plan/15-letsencrypt.md new file mode 100644 index 0000000..745aa2b --- /dev/null +++ b/plan/15-letsencrypt.md @@ -0,0 +1,76 @@ +# TLS — Let's Encrypt (OVH DNS-01) + host nginx ingress + +Production serves real, publicly-trusted certs via the **host's nginx**. Caddy is +no longer the production ingress — it moved to its own compose file for the +LAN/air-gap path. + +## Two ingress paths + +| Path | Ingress | Compose | TLS | +|------|---------|---------|-----| +| Production | host **nginx** | `docker-compose.yml` + `docker-compose.nginx.yml` | Let's Encrypt (this doc) | +| LAN / air-gap | **caddy** container | `docker-compose.yml` + `docker-compose.caddy.yml` (+ static/mirror) | Caddy internal CA | + +`build-stack.sh` drives the caddy path (adds `docker-compose.caddy.yml` +automatically). The nginx path is a manual deploy (below). This supersedes the +ingress part of `plan/14-deploy.md` for production. + +## Certs: one per name, via OVH DNS-01 + +`tooling/issue-letsencrypt.sh` issues a **separate** cert for the apex and each +subdomain (`${BASE_DOMAIN}`, `auth.…`, `pack.…`) using acme.sh + the OVH DNS-01 +challenge — no inbound ports needed, so it works even on a LAN-only host. + +### One-time setup + +1. Install acme.sh: + ```bash + curl https://get.acme.sh | sh -s email=you@example.com + ``` +2. OVH API credentials → `.env` (see `.env.example` `OVH_*`, `LE_EMAIL`, + `LE_SUBDOMAINS`). Create a token at with + `GET/POST/PUT/DELETE` on `/domain/zone/*`, or leave `OVH_CK` blank and run the + script once — acme.sh prints an authorization URL; visit it, then paste the + consumer key into `OVH_CK` and re-run. + +### Issue + +```bash +tooling/issue-letsencrypt.sh # real certs +LE_STAGING=1 tooling/issue-letsencrypt.sh # dry run against the LE staging CA +``` + +Certs install to `certs//{cert.pem,key.pem}` (gitignored). acme.sh adds a +cron for renewal; `--reloadcmd` reloads nginx automatically on renew. Override +the reload with `RELOAD_CMD=...` if nginx isn't systemd-managed. + +## nginx + +1. Stack up with the nginx override (publishes drasl on `127.0.0.1:25585`): + ```bash + docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d + ``` +2. Render + install the vhost (`nginx/ulicraft.conf.tmpl`): + ```bash + APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \ + envsubst '$APP_DIR $BASE_DOMAIN' \ + < nginx/ulicraft.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf + sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/ + sudo nginx -t && sudo systemctl reload nginx + ``` + nginx then: apex + `pack.*` as static files (`www`, `pack`, `custom`), + `auth.*` → drasl. Ensure `www-data` can read `$APP_DIR/{www,pack,custom,certs}`. + +## DNS + +Point the public/party DNS for `${BASE_DOMAIN}`, `auth.${BASE_DOMAIN}`, +`pack.${BASE_DOMAIN}` at the host. The Minecraft container reaches them via +`extra_hosts: host-gateway` (set in `docker-compose.nginx.yml`) and uses HTTPS — +the JVM trusts Let's Encrypt with no CA import (the big win over the internal CA). + +## Why DNS-01 (not HTTP-01) + +`HOST_LAN_IP` shows a LAN host; HTTP-01/TLS-ALPN-01 need public inbound on 80/443. +DNS-01 only needs the OVH API, so it issues regardless of public reachability — +and `ulicraft.net` is a real OVH-managed domain, which LE requires (it never +issues for `.lan`/`.local`). diff --git a/tooling/issue-letsencrypt.sh b/tooling/issue-letsencrypt.sh new file mode 100755 index 0000000..80a002d --- /dev/null +++ b/tooling/issue-letsencrypt.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# issue-letsencrypt.sh — issue per-name Let's Encrypt certificates via the OVH +# DNS-01 challenge (acme.sh) and install them where Caddy serves them. +# +# Run MANUALLY on the host. One separate certificate per name: +# ${BASE_DOMAIN} + .${BASE_DOMAIN} for each sub in LE_SUBDOMAINS. +# Certs land in certs//{cert.pem,key.pem} (gitignored) and the host nginx +# is reloaded so it picks them up (see nginx/ulicraft.conf.tmpl). +# +# DNS-01 needs no inbound ports — works for a LAN-only host. It only needs OVH +# API credentials with DNS-record write access for the zone. +# +# .env vars required: +# BASE_DOMAIN e.g. ulicraft.net +# LE_EMAIL account email for Let's Encrypt +# OVH_AK OVH_AS OVH application key + secret +# OVH_CK OVH consumer key (obtained on first run — see below) +# OVH_END_POINT OVH API endpoint (e.g. ovh-eu) +# optional: +# LE_SUBDOMAINS space-separated (default: "auth pack") +# LE_STAGING=1 use the LE staging CA (untrusted, for dry runs) +# +# First-time OVH consumer key: set OVH_AK/OVH_AS/OVH_END_POINT, leave OVH_CK +# empty, run this once — acme.sh prints an authorization URL. Visit it, grant +# access, then copy the consumer key it shows into OVH_CK in .env and re-run. +# (Or create a token directly: https://eu.api.ovh.com/createToken/ with +# GET/POST/PUT/DELETE on /domain/zone/*.) +set -euo pipefail + +cd "$(dirname "$0")/.." # repo root + +log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; } + +# ---- config / preflight ---------------------------------------------------- +[ -f .env ] || die ".env not found (copy .env.example and fill it)" +# shellcheck disable=SC1091 +set -a; . ./.env; set +a +: "${BASE_DOMAIN:?BASE_DOMAIN unset in .env}" +: "${LE_EMAIL:?LE_EMAIL unset in .env}" +: "${OVH_AK:?OVH_AK unset in .env}" +: "${OVH_AS:?OVH_AS unset in .env}" +: "${OVH_END_POINT:?OVH_END_POINT unset in .env (e.g. ovh-eu)}" +# OVH_CK may be empty on the very first run (acme.sh will print the auth URL). +export OVH_AK OVH_AS OVH_END_POINT +export OVH_CK="${OVH_CK:-}" + +ACME="${ACME_SH:-$HOME/.acme.sh/acme.sh}" +[ -x "$ACME" ] || ACME="$(command -v acme.sh || true)" +[ -n "$ACME" ] && [ -x "$ACME" ] || die "acme.sh not found. Install: + curl https://get.acme.sh | sh -s email=${LE_EMAIL} +then re-run (or set ACME_SH=/path/to/acme.sh)." + +CERT_DIR="${CERT_DIR:-certs}" +read -r -a SUBDOMAINS <<< "${LE_SUBDOMAINS:-auth pack}" + +if [ "${LE_STAGING:-0}" = "1" ]; then + SERVER="letsencrypt_test"; warn "STAGING mode — certs will NOT be trusted by browsers" +else + SERVER="letsencrypt" +fi + +# Reload the host nginx after each install so new certs take effect. Override +# RELOAD_CMD if nginx runs elsewhere (e.g. a container or a non-systemd host). +RELOAD="${RELOAD_CMD:-sudo systemctl reload nginx} || true" + +# Full name list: apex + each subdomain. +domains=("$BASE_DOMAIN") +for s in "${SUBDOMAINS[@]}"; do domains+=("${s}.${BASE_DOMAIN}"); done + +log "CA=$SERVER names: ${domains[*]}" + +# Register the ACME account once (idempotent). +"$ACME" --register-account -m "$LE_EMAIL" --server "$SERVER" >/dev/null 2>&1 || true + +# ---- issue + install one cert per name -------------------------------------- +issued=0 +for d in "${domains[@]}"; do + log "issuing $d (OVH DNS-01)" + rc=0 + "$ACME" --issue --dns dns_ovh --server "$SERVER" --keylength ec-256 -d "$d" || rc=$? + # acme.sh: 0 = issued/renewed, 2 = skipped (cert still valid, not due). Both ok. + case "$rc" in + 0|2) ;; + *) die "acme.sh failed for $d (rc=$rc). If this is the first OVH run, follow + the consumer-key authorization URL printed above, set OVH_CK in .env, re-run." ;; + esac + + mkdir -p "$CERT_DIR/$d" + "$ACME" --install-cert -d "$d" --ecc \ + --key-file "$CERT_DIR/$d/key.pem" \ + --fullchain-file "$CERT_DIR/$d/cert.pem" \ + --reloadcmd "$RELOAD" + echo " installed -> $CERT_DIR/$d/{cert.pem,key.pem}" + issued=$((issued+1)) +done + +log "done — $issued certificate(s) under $CERT_DIR/" +echo " serve them: point nginx at certs// — see nginx/ulicraft.conf.tmpl" +echo " renewals: acme.sh installs a cron; --reloadcmd reloads nginx automatically."