diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c3c70b1..f67beee 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -5,10 +5,15 @@ # (Actions → deploy → Run workflow). Does NOT auto-deploy on push to main — # merges to main must be explicitly promoted to production. # -# Build and deploy run in a single job so the built dist/ (including -# dotfiles like .htaccess) flows directly into rsync. An earlier split-job -# design was abandoned because actions/upload-artifact@v3 excludes dotfiles -# by default and the v4 client does not work on Gitea Actions / GHES. +# Build and deploy run in a single job; rsync uploads dist/ directly. No +# upload-artifact round-trip (v3 strips dotfiles, v4 isn't supported on Gitea). +# +# Security headers (HSTS, CSP, X-Frame-Options, etc.) are NOT set by this +# deploy. Hetzner Webhosting L runs Apache with AllowOverride None on the +# user docroot, so file-based .htaccess is silently ignored. All response +# headers are owned by Cloudflare Transform Rules — see OPERATOR-CHECKLIST.md +# §2 "Cloudflare". Apache config exposed via konsoleH UI is the only origin- +# side override path and is not managed from this repo. # # Runner: self-hosted arm64 Gitea runner. Adjust `runs-on` if your runner's # labels differ. Deploy target is Hetzner amd64 — arch mismatch is a non-issue @@ -54,15 +59,6 @@ jobs: - name: Build site run: npm run build - # Astro/Vite does not copy dotfiles from public/ into dist/, so .htaccess - # never reaches the deployed origin and Apache never sees the security - # headers it sets. Copy it explicitly. Fail if the source is missing - # rather than silently shipping a header-less site. - - name: Copy .htaccess into dist - run: | - test -f public/.htaccess - cp public/.htaccess dist/.htaccess - - name: Guard — no TBD markers may ship in built HTML run: | if grep -rlE '(TBD):' dist 2>/dev/null | grep -E '\.(html|svg)$'; then diff --git a/OPERATOR-CHECKLIST.md b/OPERATOR-CHECKLIST.md index dccd217..1405529 100644 --- a/OPERATOR-CHECKLIST.md +++ b/OPERATOR-CHECKLIST.md @@ -4,20 +4,25 @@ One-time setup that lives outside code. Do these before the first `main` merge t ## 1. Hetzner Webhosting L -- [ ] Provision Webhosting L plan. Note the SSH hostname and SFTP path. -- [ ] In the Hetzner control panel, **enable SSH access** for the main user. +- [ ] Provision Webhosting L plan. Note the SSH hostname (e.g. `wwwNNN.your-server.de`) and the user login. +- [ ] In konsoleH, **enable SSH access** for the user. +- [ ] In konsoleH → Domainverwaltung, register the production domain (`www.cameleer.io`) on this hosting and confirm what document root Apache uses for it. On Webhosting L, the Apache vhost docroot for the addon domain is typically the bare `~/public_html/` (NOT a subdirectory). The `SFTP_PATH` secret must match this exactly — wrong path = 404 from origin. - [ ] Generate an ed25519 SSH key pair locally (once): ```bash ssh-keygen -t ed25519 -f ~/.ssh/cameleer-website-deploy -C "cameleer-website CI" ``` -- [ ] Add the **public** key to `~/.ssh/authorized_keys` on the Hetzner account. -- [ ] Test SSH: `ssh -i ~/.ssh/cameleer-website-deploy user@hetzner-host "ls -la"`. -- [ ] Create a subdirectory for the site (typical path: `public_html/www.cameleer.io/`). -- [ ] Grab the SSH host key for pinning: +- [ ] Add the **public** key to `~/.ssh/authorized_keys` on the Hetzner account (or via konsoleH SSH-Schlüssel UI). +- [ ] Test SSH on **port 222** (Hetzner Webhosting splits SFTP=22 / SSH-shell=222; rsync needs 222): ```bash - ssh-keyscan -t ed25519 hetzner-host > hetzner-known-hosts.txt + ssh -p 222 -i ~/.ssh/cameleer-website-deploy user@wwwNNN.your-server.de "ls -la" ``` -- [ ] Install Let's Encrypt (or use Hetzner's built-in) for the origin hostname. Cloudflare Full (strict) requires a valid origin cert. +- [ ] Grab the SSH host key for pinning, also on **port 222**: + ```bash + ssh-keyscan -p 222 -t rsa,ed25519,ecdsa wwwNNN.your-server.de > hetzner-known-hosts.txt + ``` + Verify the fingerprint against what your manual SSH session displayed before saving the secret — `ssh-keyscan` doesn't authenticate. +- [ ] **Origin TLS:** Cloudflare Full (strict) requires a valid origin cert. Hetzner Webhosting auto-issues Let's Encrypt — confirm the cert is active in konsoleH → SSL → SSL-Zertifikate. +- [ ] **`.htaccess` caveat (important):** Hetzner Webhosting L runs Apache with `AllowOverride None` on the user docroot, so any `.htaccess` file you `rsync` is **silently ignored** by Apache. The only way to set Apache directives on this tier is via konsoleH → Einstellungen → Serverkonfiguration (per-directory wrench panel). This repo therefore owns no `.htaccess`; all response headers live in Cloudflare (see §2). The konsoleH `.htaccess` panel is left empty by default; defense-in-depth header copies there are optional and survive rsync deploys (different storage location). ## 2. Cloudflare (zone: cameleer.io) @@ -67,7 +72,7 @@ Add these under Repository settings → Actions → Secrets (or variables): |------|------|-------| | `SFTP_HOST` | secret | Hetzner SSH hostname | | `SFTP_USER` | secret | Hetzner SSH user | -| `SFTP_PATH` | secret | Absolute path to document root (e.g., `/usr/home/cameleer/public_html/www.cameleer.io`) | +| `SFTP_PATH` | secret | Absolute path to the Apache vhost docroot configured in konsoleH (typically `/usr/www/users//public_html`). Mismatch → 404 on origin. | | `SFTP_KEY` | secret | Contents of `~/.ssh/cameleer-website-deploy` (private key, PEM) | | `SFTP_KNOWN_HOSTS` | secret | Contents of `hetzner-known-hosts.txt` (captured via `ssh-keyscan`) | | `PUBLIC_AUTH_SIGNIN_URL` | secret | `https://auth.cameleer.io/sign-in` | @@ -87,10 +92,13 @@ workflows read them via the `${{ secrets.* }}` context. ## 5. First deploy +The `deploy` workflow is **manual-only** — it does NOT auto-fire on push to `main`. After merging, trigger it explicitly. + 1. Merge a PR to `main`. -2. Watch the Gitea Actions run: `build`, then `deploy`. -3. The workflow includes a post-deploy smoke check — if HSTS / CSP / XFO are missing from the live response, the deploy fails and must be debugged at the Cloudflare Transform Rule layer. -4. Manually verify: - - `curl -sI https://www.cameleer.io/` returns all six security headers. +2. In Gitea: **Actions → deploy → Run workflow** on `main`. +3. Watch the single `deploy` job (build + tests + rsync + smoke test in one step). +4. The workflow's post-deploy smoke check verifies HSTS / CSP / X-Frame-Options on the live response. If any fail, the deploy step exits non-zero — debug at the **Cloudflare Transform Rule** layer (§2 above), since headers no longer come from the origin. +5. Manually verify: + - `curl -sI https://www.cameleer.io/` returns all 5 security headers (HSTS, CSP, XFO, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). - `https://cameleer.io/` → `https://www.cameleer.io/` 301 redirect. - Open the site in an incognito window on desktop + mobile. diff --git a/README.md b/README.md index 6f6f0df..06899a2 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,11 @@ See `.env.example`. All are `PUBLIC_*` (build-time, embedded in HTML). ## Deployment -Push to `main` → Gitea Actions runs tests, builds, lints, then `rsync`s `dist/` to Hetzner over SSH (ed25519 key, host-key-pinned). Rollback is `git revert && git push`. +**Manual trigger only.** Merging to `main` does NOT auto-deploy. To ship: Gitea → **Actions → deploy → Run workflow** on `main`. The workflow runs tests, builds, then `rsync`s `dist/` to Hetzner over SSH (ed25519 key on port 222, host-key-pinned), and post-deploy curls the live site to verify security headers. + +Rollback: trigger the deploy workflow on the previous `main` commit (Actions UI lets you pick a ref). + +**Security headers** (HSTS, CSP, X-Frame-Options, etc.) are owned by **Cloudflare Transform Rules**, not by anything in this repo. Hetzner Webhosting L ignores file-based `.htaccess` (`AllowOverride None`), so origin-side header config is impossible from code. See `OPERATOR-CHECKLIST.md` §2. See [`OPERATOR-CHECKLIST.md`](./OPERATOR-CHECKLIST.md) for the one-time Hetzner + Cloudflare setup. diff --git a/public/.htaccess b/public/.htaccess deleted file mode 100644 index bff4fa0..0000000 --- a/public/.htaccess +++ /dev/null @@ -1,55 +0,0 @@ -# --------------------------------------------------------------- -# www.cameleer.io — Apache config at the Hetzner origin. -# Defense in depth: Cloudflare handles most of this at the edge; -# these rules make sure the origin is hardened even without the CDN. -# --------------------------------------------------------------- - -# Enable rewriting -RewriteEngine On - -# Force HTTPS — redundant with Cloudflare but belts-and-braces. -RewriteCond %{HTTPS} !=on -RewriteCond %{HTTP:X-Forwarded-Proto} !=https -RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] - -# Redirect apex -> www. -RewriteCond %{HTTP_HOST} ^cameleer\.io$ [NC] -RewriteRule ^(.*)$ https://www.cameleer.io/$1 [L,R=301] - -# Disable directory listings. -Options -Indexes - -# Block access to dotfiles and sensitive extensions that should never be here. - - Require all denied - - -# Prevent MIME sniffing, clickjacking, etc. (primary copy also comes from Astro middleware -# and Cloudflare Transform Rules — these apply if either layer is bypassed). - - Header always set X-Content-Type-Options "nosniff" - Header always set X-Frame-Options "DENY" - Header always set Referrer-Policy "strict-origin-when-cross-origin" - Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" - Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - - # Cache hashed build assets aggressively; HTML must be revalidated. - - Header set Cache-Control "public, max-age=31536000, immutable" - - - Header set Cache-Control "public, max-age=3600, must-revalidate" - - - # Remove Server header leak where possible. - Header unset X-Powered-By - - -# Compression (Hetzner supports mod_deflate). - - AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml text/plain - - -# Custom error pages (optional — fall back to default if not present). -ErrorDocument 404 /404.html -ErrorDocument 403 /404.html