2 Commits

Author SHA1 Message Date
hsiegeln
3a1fe5f2c7 docs+ci: own security headers at Cloudflare, drop dead .htaccess path
All checks were successful
ci / build-test (push) Successful in 3m33s
Hetzner Webhosting L runs Apache with AllowOverride None on the
user docroot, so file-based .htaccess is silently ignored — directives
in public/.htaccess never applied. Confirmed via direct-origin tests:
neither Header, Rewrite, nor FilesMatch fired regardless of the file
being present and readable.

The only origin-side override path on this tier is konsoleH's per-
directory Serverkonfiguration UI, which writes to a separate Apache
config file outside the user's filesystem (and thus outside any
deploy pipeline).

Make the architecture honest:
- Delete public/.htaccess (dead code Apache never reads).
- Remove the "Copy .htaccess into dist" CI step (now a no-op).
- Update deploy.yml header comment to point at Cloudflare for headers.
- Update OPERATOR-CHECKLIST.md §1 with the three Webhosting-L gotchas:
  port 222 for SSH, SFTP_PATH must match the actual vhost docroot
  (default is bare public_html/), and AllowOverride None.
- Update §5 to reflect manual workflow_dispatch (no auto-deploy on
  push) and 5-header expectation.
- Update README.md deploy section likewise.

Headers (HSTS, CSP, XFO, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy) are now owned by Cloudflare Transform Rules,
documented in OPERATOR-CHECKLIST.md §2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:04:09 +02:00
hsiegeln
d6851cd5aa Merge branch 'feat/initial-build' into main
All checks were successful
ci / build-test (push) Successful in 4m2s
Merge build+deploy jobs, switch to manual trigger only.
2026-04-24 21:24:44 +02:00
4 changed files with 35 additions and 82 deletions

View File

@@ -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

View File

@@ -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/<login>/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.

View File

@@ -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.

View File

@@ -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.
<FilesMatch "^\.|\.(env|ini|log|sh|bak|sql|git)$">
Require all denied
</FilesMatch>
# Prevent MIME sniffing, clickjacking, etc. (primary copy also comes from Astro middleware
# and Cloudflare Transform Rules — these apply if either layer is bypassed).
<IfModule mod_headers.c>
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.
<FilesMatch "\.(css|js|woff2|svg|png|jpg|jpeg|webp|ico)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "\.html$">
Header set Cache-Control "public, max-age=3600, must-revalidate"
</FilesMatch>
# Remove Server header leak where possible.
Header unset X-Powered-By
</IfModule>
# Compression (Hetzner supports mod_deflate).
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml text/plain
</IfModule>
# Custom error pages (optional — fall back to default if not present).
ErrorDocument 404 /404.html
ErrorDocument 403 /404.html