feat(files): add Filestash player file share at files.${BASE_DOMAIN}
One shared login (htpasswd) for all players, writable. Config is rendered from a template and mounted read-only, so git stays authoritative — the admin console loads but cannot save, by design. Filestash's OIDC/SAML middlewares are enterprise-only, so Keycloak SSO is out; FILES_AUTH keeps htpasswd/passthrough switchable for later. Auth-off (passthrough) is only valid once the host is off the public internet — render-config.sh warns loudly when rendering it. Three traps, all found by testing against the pinned image: - the `local` backend is admin-gated: without LOCAL_BACKEND_SECRET in the connection params every login fails with "backend error - Not Allowed" - the htpasswd plugin only accepts $2a$ bcrypt, so a $2y$ hash from `htpasswd -B` fails every login silently — use `openssl passwd -6`. render-config.sh rejects the wrong format rather than shipping it - APPLICATION_URL/ADMIN_PASSWORD env make filestash rewrite config.json at boot, which fails on the :ro mount — both live in the rendered config Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
48
.env.example
48
.env.example
@@ -49,6 +49,52 @@ CADDY_HTTP_BIND=127.0.0.1
|
|||||||
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
|
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
|
||||||
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
|
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
|
||||||
|
|
||||||
|
# ── files.${BASE_DOMAIN} — Filestash player file share ────────────────
|
||||||
|
# Screenshots / schematics / maps. ONE shared login for all players, writable.
|
||||||
|
# Full design + gotchas: plan/20-files.md.
|
||||||
|
|
||||||
|
# Host path holding the shared files. Keep it OUTSIDE the repo — guest-writable
|
||||||
|
# data must not live in a git tree we `git pull` into.
|
||||||
|
FILES_DATA_DIR=/srv/ulicraft-files
|
||||||
|
|
||||||
|
# Literal mount flag for FILES_DATA_DIR: rw | ro. `ro` makes the share
|
||||||
|
# read-only at the KERNEL, regardless of what the Filestash UI thinks. It is a
|
||||||
|
# mount flag rather than a boolean because compose has no conditionals — the
|
||||||
|
# value is substituted straight into the volume string.
|
||||||
|
FILES_MOUNT_MODE=rw
|
||||||
|
|
||||||
|
# Auth middleware (rendered into config.json by render-config.sh):
|
||||||
|
# htpasswd one shared user/pass — the only internet-safe value
|
||||||
|
# passthrough NO LOGIN. Anyone reaching the vhost is in.
|
||||||
|
# none alias of passthrough
|
||||||
|
# passthrough/none on a WRITABLE, internet-reachable share is an open upload
|
||||||
|
# relay (malware/phishing hosted on your domain, under your cert, with your
|
||||||
|
# bandwidth — and a domain blocklisting would take auth. and the apex down
|
||||||
|
# with it). Only set it once this host is unreachable from the internet.
|
||||||
|
FILES_AUTH=htpasswd
|
||||||
|
|
||||||
|
# The shared player credential (FILES_AUTH=htpasswd).
|
||||||
|
# FILES_PASSWORD_HASH: openssl passwd -6 '<password>'
|
||||||
|
# MUST be $6$ (sha512). The htpasswd plugin's bcrypt branch only accepts $2a$,
|
||||||
|
# so a $2y$ hash from `htpasswd -B` fails EVERY login, silently.
|
||||||
|
# render-config.sh rejects the wrong format rather than shipping it.
|
||||||
|
FILES_USER=uli
|
||||||
|
FILES_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# Filestash /admin console password — bcrypt, and /admin IS publicly exposed, so
|
||||||
|
# use a 20+ char generated password. It is the only credential guarding config.
|
||||||
|
# docker run --rm caddy:alpine caddy hash-password --plaintext '<password>'
|
||||||
|
FILES_ADMIN_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# Session key (openssl rand -hex 16). Must be non-empty: an empty secret_key
|
||||||
|
# makes filestash rewrite config.json at boot, which fails on the :ro mount.
|
||||||
|
FILES_SECRET_KEY=
|
||||||
|
|
||||||
|
# Unlocks the local storage backend, which filestash admin-gates: it refuses to
|
||||||
|
# init unless the connection params carry this secret. config.json supplies it
|
||||||
|
# server-side; players never see it. openssl rand -hex 24.
|
||||||
|
FILES_LOCAL_SECRET=
|
||||||
|
|
||||||
# Only needed if using CurseForge-exclusive mods:
|
# Only needed if using CurseForge-exclusive mods:
|
||||||
# CF_API_KEY=your-curseforge-api-key
|
# CF_API_KEY=your-curseforge-api-key
|
||||||
|
|
||||||
@@ -56,7 +102,7 @@ DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distri
|
|||||||
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
|
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
|
||||||
LE_EMAIL=you@example.com
|
LE_EMAIL=you@example.com
|
||||||
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
||||||
LE_SUBDOMAINS=auth distribution www avatar status
|
LE_SUBDOMAINS=auth distribution www avatar status files
|
||||||
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
|
# 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_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
|
||||||
OVH_END_POINT=ovh-eu
|
OVH_END_POINT=ovh-eu
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,6 +6,9 @@ runtime/authlib-injector.jar
|
|||||||
# rendered configs (from tooling/render-config.sh)
|
# rendered configs (from tooling/render-config.sh)
|
||||||
drasl/config/config.toml
|
drasl/config/config.toml
|
||||||
nmsr/config.toml
|
nmsr/config.toml
|
||||||
|
# holds the admin bcrypt hash, the shared player hash, the session key and the
|
||||||
|
# local-backend secret — only the .tmpl is tracked
|
||||||
|
filestash/config.json
|
||||||
|
|
||||||
# server mod subset synced from the distribution repo by
|
# server mod subset synced from the distribution repo by
|
||||||
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -32,6 +32,7 @@ Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880
|
|||||||
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
|
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
|
||||||
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
|
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
|
||||||
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
||||||
|
files.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable)
|
||||||
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
|
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
|
||||||
|
|
||||||
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
||||||
@@ -105,6 +106,17 @@ automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
|
|||||||
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
|
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
|
||||||
classify/sync time. See `plan/04-mods.md`.
|
classify/sync time. See `plan/04-mods.md`.
|
||||||
|
|
||||||
|
### Filestash (files.) — three traps
|
||||||
|
Full list in `plan/20-files.md`; the ones that cost real time:
|
||||||
|
- The `local` backend is **admin-gated**: without `LOCAL_BACKEND_SECRET` in the
|
||||||
|
connection params, every login fails with `backend error - Not Allowed` (looks
|
||||||
|
like bad credentials, isn't).
|
||||||
|
- `FILES_PASSWORD_HASH` must be **`$6$`** (`openssl passwd -6`). A `$2y$` bcrypt
|
||||||
|
from `htpasswd -B` fails every login silently — the plugin only matches `$2a$`.
|
||||||
|
- `config.json` is rendered + mounted `:ro` (the admin console can't save, on
|
||||||
|
purpose). Single-file bind mount ⇒ **`--force-recreate` after re-rendering**,
|
||||||
|
never `restart`.
|
||||||
|
|
||||||
### Memory sizing
|
### Memory sizing
|
||||||
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
||||||
`USE_AIKAR_FLAGS: TRUE`.
|
`USE_AIKAR_FLAGS: TRUE`.
|
||||||
|
|||||||
10
caddy/conf.d/60-files.caddy
Normal file
10
caddy/conf.d/60-files.caddy
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Files — Filestash player file share (filestash service). Shared login,
|
||||||
|
# writable. See plan/20-files.md.
|
||||||
|
#
|
||||||
|
# Filestash blocks any request whose Host header doesn't match its configured
|
||||||
|
# `general.host` (https://files.${BASE_DOMAIN}), with "only traffic from X is
|
||||||
|
# allowed". The host nginx forwards the original Host, and caddy passes it
|
||||||
|
# through untouched — do NOT rewrite it here or every request 403s.
|
||||||
|
http://files.{$BASE_DOMAIN} {
|
||||||
|
reverse_proxy filestash:8334
|
||||||
|
}
|
||||||
@@ -159,6 +159,7 @@ services:
|
|||||||
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
|
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
|
||||||
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
|
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
|
||||||
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
|
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
|
||||||
|
- ./caddy/conf.d/60-files.caddy:/etc/caddy/conf.d/60-files.caddy:ro
|
||||||
- ./www:/srv/www:ro
|
- ./www:/srv/www:ro
|
||||||
- ./launcher:/srv/launcher:ro
|
- ./launcher:/srv/launcher:ro
|
||||||
# Static site from ANOTHER repo (absolute host path in .env).
|
# Static site from ANOTHER repo (absolute host path in .env).
|
||||||
@@ -213,6 +214,38 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mcnet
|
- mcnet
|
||||||
|
|
||||||
|
# Player file share (screenshots, schematics, maps) at files.${BASE_DOMAIN}.
|
||||||
|
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. See
|
||||||
|
# plan/20-files.md — it documents every non-obvious bit of this service.
|
||||||
|
#
|
||||||
|
# Config is rendered read-only from filestash/config.json.tmpl: filestash has
|
||||||
|
# no env-var config, and its admin console writes config.json back at runtime,
|
||||||
|
# so pinning it :ro is what keeps git authoritative. The console still loads —
|
||||||
|
# it just cannot save. That is intentional, not a bug.
|
||||||
|
filestash:
|
||||||
|
image: machines/filestash@sha256:8cb09d42470328a02aec6e3861f912addf8a9d54ad63d9c965bcb48df5ad36b1
|
||||||
|
container_name: filestash
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# The `local` storage backend is admin-gated in filestash: it refuses to
|
||||||
|
# init unless the connection params carry this secret (or the admin
|
||||||
|
# password). config.json's attribute_mapping supplies it server-side, so
|
||||||
|
# players never see it. Without this, every login dies on "Not Allowed".
|
||||||
|
LOCAL_BACKEND_SECRET: ${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset in .env}
|
||||||
|
# Do NOT set APPLICATION_URL / ADMIN_PASSWORD here: either one makes
|
||||||
|
# filestash rewrite config.json at boot, which fails on the :ro mount.
|
||||||
|
# Both live in the rendered config instead (general.host / auth.admin).
|
||||||
|
volumes:
|
||||||
|
# /app/data/state holds config + sqlite + search index + logs, so the
|
||||||
|
# DIRECTORY must stay writable. Only config.json is pinned read-only.
|
||||||
|
- filestash_state:/app/data/state
|
||||||
|
- ./filestash/config.json:/app/data/state/config/config.json:ro
|
||||||
|
# The share itself. FILES_MOUNT_MODE is the literal mount flag (ro|rw) —
|
||||||
|
# `ro` enforces a read-only share at the KERNEL, whatever the UI thinks.
|
||||||
|
- ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/data/files:${FILES_MOUNT_MODE:-ro}
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
# Status monitoring + public status page. caddy proxies status. -> :3001.
|
# Status monitoring + public status page. caddy proxies status. -> :3001.
|
||||||
# Joins mcnet so monitors probe the stack by internal service name.
|
# Joins mcnet so monitors probe the stack by internal service name.
|
||||||
uptime-kuma:
|
uptime-kuma:
|
||||||
@@ -228,6 +261,7 @@ volumes:
|
|||||||
drasl_state:
|
drasl_state:
|
||||||
mc_data:
|
mc_data:
|
||||||
kuma_data:
|
kuma_data:
|
||||||
|
filestash_state:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
mcnet:
|
mcnet:
|
||||||
|
|||||||
30
filestash/config.json.tmpl
Normal file
30
filestash/config.json.tmpl
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"general": {
|
||||||
|
"name": "Ulicraft Files",
|
||||||
|
"host": "https://files.${BASE_DOMAIN}",
|
||||||
|
"secret_key": "${FILES_SECRET_KEY}",
|
||||||
|
"editor": "base",
|
||||||
|
"display_hidden": false,
|
||||||
|
"upload_button": true,
|
||||||
|
"fork_button": false
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"admin": "${FILES_ADMIN_PASSWORD_HASH}"
|
||||||
|
},
|
||||||
|
"middleware": {
|
||||||
|
"identity_provider": {
|
||||||
|
"type": "${FILES_IDP_TYPE}",
|
||||||
|
"params": "${FILES_IDP_PARAMS}"
|
||||||
|
},
|
||||||
|
"attribute_mapping": {
|
||||||
|
"related_backend": "files",
|
||||||
|
"params": "{\"files\":{\"type\":\"local\",\"path\":\"/data/files/\",\"password\":\"${FILES_LOCAL_SECRET}\"}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"label": "files",
|
||||||
|
"type": "local"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -26,11 +26,15 @@ upstream ulicraft_caddy {
|
|||||||
server 127.0.0.1:${CADDY_HTTP_PORT};
|
server 127.0.0.1:${CADDY_HTTP_PORT};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Brute-force throttle for the publicly-exposed Filestash /admin console (it is
|
||||||
|
# the only credential on files.), applied in its location block below.
|
||||||
|
limit_req_zone $binary_remote_addr zone=files_admin:10m rate=6r/m;
|
||||||
|
|
||||||
# HTTP -> HTTPS for every name.
|
# HTTP -> HTTPS for every name.
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN};
|
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} files.${BASE_DOMAIN};
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +129,44 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Filestash player file share. Uploads need a real body limit (nginx defaults to
|
||||||
|
# 1m, which rejects almost anything worth sharing) and streaming rather than
|
||||||
|
# spooling — a 2g upload buffered to disk would write it twice on a host whose
|
||||||
|
# disk is already the shared failure domain for Minecraft + Drasl + backups.
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name files.${BASE_DOMAIN};
|
||||||
|
|
||||||
|
ssl_certificate ${APP_DIR}/certs/files.${BASE_DOMAIN}/cert.pem;
|
||||||
|
ssl_certificate_key ${APP_DIR}/certs/files.${BASE_DOMAIN}/key.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
client_max_body_size 2g; # uploads; other vhosts stay at 4m
|
||||||
|
proxy_request_buffering off; # stream uploads, don't spool them to disk
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
|
||||||
|
# /admin is reachable on purpose, so throttle it: it is the only password
|
||||||
|
# on this vhost. 6 req/min, small burst.
|
||||||
|
location /admin {
|
||||||
|
limit_req zone=files_admin burst=3 nodelay;
|
||||||
|
proxy_pass http://ulicraft_caddy;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://ulicraft_caddy;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl http2;
|
listen [::]:443 ssl http2;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
| `auth.${BASE_DOMAIN}` | `00-core.caddy` | `drasl:25585` |
|
| `auth.${BASE_DOMAIN}` | `00-core.caddy` | `drasl:25585` |
|
||||||
| `avatar.${BASE_DOMAIN}` | `40-avatar.caddy` | `nmsr:8080` |
|
| `avatar.${BASE_DOMAIN}` | `40-avatar.caddy` | `nmsr:8080` |
|
||||||
| `status.${BASE_DOMAIN}` | `50-status.caddy` | `uptime-kuma:3001` |
|
| `status.${BASE_DOMAIN}` | `50-status.caddy` | `uptime-kuma:3001` |
|
||||||
|
| `files.${BASE_DOMAIN}` | `60-files.caddy` | `filestash:8334` |
|
||||||
| `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) |
|
| `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -58,6 +59,29 @@ base `https://auth.${BASE_DOMAIN}/drasl/api/v2` (`site.draslApiUrl`):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Files — `files.${BASE_DOMAIN}` (Filestash player share)
|
||||||
|
|
||||||
|
`reverse_proxy filestash:8334`. Screenshots / schematics / maps. **One shared
|
||||||
|
login** for every player (`FILES_AUTH=htpasswd`), writable. Full design:
|
||||||
|
`plan/20-files.md`.
|
||||||
|
|
||||||
|
- `/` — SPA. `GET /api/session/auth/?label=files` renders the login form;
|
||||||
|
`POST` the same URL with `user`/`password` authenticates.
|
||||||
|
- `/api/*` — JSON API. Requires the header `X-Requested-With: XmlHttpRequest`
|
||||||
|
(the SPA sends it); without it Filestash's intrusion detection 403s the
|
||||||
|
request. Relevant when curling it by hand.
|
||||||
|
- `/admin` — admin console, own bcrypt password (`auth.admin`). Deliberately
|
||||||
|
public; nginx throttles it (`limit_req`, 6r/m). It **cannot save** — the
|
||||||
|
config is a read-only mount.
|
||||||
|
- **`?label=files` matters.** The attribute mapping is keyed by the connection
|
||||||
|
label; auth without it yields `backend error - Not Allowed`.
|
||||||
|
|
||||||
|
Filestash blocks any request whose `Host` doesn't match its `general.host`
|
||||||
|
(`https://files.${BASE_DOMAIN}`) — nginx forwards the original Host and caddy
|
||||||
|
passes it through, so don't rewrite it anywhere in the chain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Auth / Drasl — `auth.${BASE_DOMAIN}`
|
## Auth / Drasl — `auth.${BASE_DOMAIN}`
|
||||||
|
|
||||||
`reverse_proxy drasl:25585`. One service exposes three surfaces:
|
`reverse_proxy drasl:25585`. One service exposes three surfaces:
|
||||||
|
|||||||
180
plan/20-files.md
Normal file
180
plan/20-files.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# 20 — Filestash (`files.${BASE_DOMAIN}`)
|
||||||
|
|
||||||
|
> Web file share for player media (screenshots, schematics, maps, guides).
|
||||||
|
> One [Filestash](https://github.com/mickael-kerjean/filestash) container behind
|
||||||
|
> caddy, **one shared username/password for everybody**, **writable**.
|
||||||
|
> Config is rendered read-only from a template, like drasl/nmsr.
|
||||||
|
>
|
||||||
|
> **Not** a backup browser, **not** a mod mirror. See "What goes in it".
|
||||||
|
>
|
||||||
|
> Everything below was verified against the real image (digest pinned in
|
||||||
|
> compose) and against the upstream source, not inferred from the docs — the
|
||||||
|
> docs omit most of it.
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet ── host nginx (TLS, LE cert for files.) ──► caddy ──► filestash:8334
|
||||||
|
│
|
||||||
|
${FILES_DATA_DIR} ──┘ (bind mount)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Thing | Value |
|
||||||
|
|---|---|
|
||||||
|
| Vhost | `files.${BASE_DOMAIN}` |
|
||||||
|
| Image | `machines/filestash` **pinned by digest** (upstream ships only `latest`) |
|
||||||
|
| Container port | `8334`, runs as **uid/gid 1000** (`filestash`) |
|
||||||
|
| Auth | htpasswd, **one shared account**, `FILES_USER` / `FILES_PASSWORD_HASH` |
|
||||||
|
| Access | read **+ write** for anyone with the password |
|
||||||
|
| Data | `${FILES_DATA_DIR}` on the host, **outside the repo** |
|
||||||
|
| State | named volume `filestash_state` → `/app/data/state` |
|
||||||
|
| Config | `filestash/config.json` rendered from `.tmpl`, mounted `:ro` |
|
||||||
|
| Backups | **none** — outside mc-backup's scope. Treat as disposable. |
|
||||||
|
| Quota | none — eyeballed |
|
||||||
|
|
||||||
|
## Decisions (settled — don't relitigate)
|
||||||
|
|
||||||
|
**Auth is htpasswd with one shared credential, not SSO.** Filestash's OIDC and
|
||||||
|
SAML middlewares are **enterprise-only**; the community build compiles in
|
||||||
|
`htpasswd`, `ldap`, `passthrough`, `wordpress`, `local` only (verified in
|
||||||
|
`server/plugin/index.go`). Keycloak was considered and dropped — it would need an
|
||||||
|
oauth2-proxy sidecar in front + `passthrough`. That path stays open but is not
|
||||||
|
built.
|
||||||
|
|
||||||
|
**One instance, everything behind the password.** No anonymous read tier.
|
||||||
|
Filestash's auth middleware is **global to the instance**, so "anonymous read +
|
||||||
|
authenticated write" is impossible in one container (that's the enterprise
|
||||||
|
RBAC). A two-instance split was designed and rejected as not worth it.
|
||||||
|
|
||||||
|
**`FILES_AUTH=none` is gated on the host being internal-only.** The off-switch
|
||||||
|
exists for when this server moves behind a LAN/VPN. **Do not set it while
|
||||||
|
`files.` is internet-reachable** — auth-off on a *writable* public share is an
|
||||||
|
open upload relay: someone parks malware or a phishing kit on your domain, under
|
||||||
|
your LE cert, and the abuse mail, the bandwidth, and a possible domain
|
||||||
|
blocklisting (which would take `auth.` and the apex down with it, breaking logins
|
||||||
|
and the launcher for every player) all land on you. `render-config.sh` prints a
|
||||||
|
loud warning when it renders this mode.
|
||||||
|
|
||||||
|
**Shared credential ⇒ no audit trail, no per-person revocation.** You will never
|
||||||
|
know who deleted a folder; rotating means re-telling everyone. Fine for a
|
||||||
|
friends' media share — but nothing here is backed up, so treat the share as
|
||||||
|
disposable and keep anything you'd miss elsewhere.
|
||||||
|
|
||||||
|
## What goes in it
|
||||||
|
|
||||||
|
| Content | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| Screenshots, schematics, maps, guides | ✅ the point |
|
||||||
|
| World downloads / `./backups/*.tgz` | ❌ contains `playerdata/`, `stats/`, the full map — every player's inventory and coords, handed to anyone with the shared password |
|
||||||
|
| Mods / jars | ❌ redistribution; `distribution.${BASE_DOMAIN}` already serves the modset to launchers |
|
||||||
|
|
||||||
|
## `.env`
|
||||||
|
|
||||||
|
See `.env.example` for the annotated block. Summary:
|
||||||
|
|
||||||
|
| Var | What |
|
||||||
|
|---|---|
|
||||||
|
| `FILES_DATA_DIR` | host path of the share, outside the repo |
|
||||||
|
| `FILES_MOUNT_MODE` | literal mount flag, `rw` \| `ro` |
|
||||||
|
| `FILES_AUTH` | `htpasswd` \| `passthrough` \| `none` |
|
||||||
|
| `FILES_USER` | shared login name |
|
||||||
|
| `FILES_PASSWORD_HASH` | **`$6$`**, from `openssl passwd -6 '<pw>'` |
|
||||||
|
| `FILES_ADMIN_PASSWORD_HASH` | bcrypt, from `docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>'` |
|
||||||
|
| `FILES_SECRET_KEY` | `openssl rand -hex 16`, must be non-empty |
|
||||||
|
| `FILES_LOCAL_SECRET` | `openssl rand -hex 24`, unlocks the local backend |
|
||||||
|
| `LE_SUBDOMAINS` | must include `files` |
|
||||||
|
|
||||||
|
`filestash/config.json` is **rendered and gitignored** — it holds every hash and
|
||||||
|
both secrets. Only the `.tmpl` is tracked.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tooling/render-config.sh # -> filestash/config.json (validates the JSON)
|
||||||
|
tooling/issue-letsencrypt.sh # picks up `files` from LE_SUBDOMAINS
|
||||||
|
tooling/render-nginx.sh --install # adds the files. vhost + reloads nginx
|
||||||
|
mkdir -p "$FILES_DATA_DIR" # uid 1000 must be able to write it
|
||||||
|
docker compose up -d filestash caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add an Uptime Kuma HTTP monitor for `https://files.${BASE_DOMAIN}` (expect
|
||||||
|
`200` — the login page is a 200) and put it on the status page with the others.
|
||||||
|
|
||||||
|
## Gotchas (all of these were hit for real)
|
||||||
|
|
||||||
|
### The `local` backend is admin-gated — this is the one that wastes an evening
|
||||||
|
`plg_backend_local.Init()` **refuses to start** unless the connection params
|
||||||
|
carry `$LOCAL_BACKEND_SECRET` or the bcrypt admin password. Miss it and every
|
||||||
|
login dies at `backend error - Not Allowed`, which reads like a credentials
|
||||||
|
problem and isn't. `config.json`'s `attribute_mapping` injects the secret
|
||||||
|
server-side (Filestash calls this the "facade pattern"), so players never see it.
|
||||||
|
`FILES_LOCAL_SECRET` must be **identical** in the compose env and the rendered
|
||||||
|
config — `render-config.sh` renders both from the same var.
|
||||||
|
|
||||||
|
### `$2y$` bcrypt silently fails every login
|
||||||
|
The htpasswd plugin's bcrypt branch matches on the literal prefix **`$2a$`**.
|
||||||
|
`htpasswd -B` emits `$2y$`, which falls through to `return false` — so every
|
||||||
|
login fails with correct credentials and no useful log line. Use
|
||||||
|
**`openssl passwd -6`** (`$6$`, sha512) for `FILES_PASSWORD_HASH`.
|
||||||
|
`render-config.sh` hard-fails on a `$2y$`/`$2b$` hash rather than shipping a
|
||||||
|
config nobody can log into. (The *admin* password is checked by Go's bcrypt
|
||||||
|
directly, which does accept `$2y$` — but `caddy hash-password` gives `$2a$`
|
||||||
|
anyway, so just use that for both.)
|
||||||
|
|
||||||
|
### `general.host` must match the incoming `Host` header
|
||||||
|
Filestash blocks every non-`/admin/` request whose `Host` differs from its
|
||||||
|
configured `general.host`, with *"only traffic from X is allowed"*. It's a 403
|
||||||
|
that looks like an auth failure. nginx forwards the original Host and caddy
|
||||||
|
passes it through — don't rewrite it anywhere. If `files.` starts 403ing after a
|
||||||
|
domain change, this is why.
|
||||||
|
|
||||||
|
### `config.json` is a single-file bind mount → stale inode
|
||||||
|
Same shape as the caddy `conf.d` gotcha. The mount pins the **inode**, so
|
||||||
|
re-rendering (or a `git pull`) writes a *new* file while the container keeps
|
||||||
|
reading the *old* one.
|
||||||
|
|
||||||
|
**After re-rendering: `docker compose up -d --force-recreate filestash`.**
|
||||||
|
`restart` is not enough. Rotating the shared password needs a recreate too.
|
||||||
|
|
||||||
|
### Don't set `APPLICATION_URL` or `ADMIN_PASSWORD` env
|
||||||
|
Either one makes Filestash **rewrite `config.json` at boot**, which fails against
|
||||||
|
the `:ro` mount. Both live in the rendered config instead (`general.host` /
|
||||||
|
`auth.admin`). `general.secret_key` must likewise be non-empty, or Filestash
|
||||||
|
generates one and tries to save that. With all three set in the file, Filestash
|
||||||
|
never attempts a boot write and the `:ro` mount is clean — you get one harmless
|
||||||
|
`cannot chmod config file` WARN in the log, and nothing else.
|
||||||
|
|
||||||
|
### The admin console cannot save
|
||||||
|
`/admin` loads and its login works, but `config.json` is `:ro`, so every save
|
||||||
|
silently fails. That's the deliberate trade for keeping git authoritative
|
||||||
|
(drasl/nmsr never write their configs; Filestash does, which is exactly why it
|
||||||
|
needs pinning). To change config: edit the `.tmpl`, re-render, recreate. To
|
||||||
|
re-derive the schema after an upstream upgrade: run the image locally against a
|
||||||
|
*writable* state volume, click it into shape, `docker cp` the result out.
|
||||||
|
|
||||||
|
### The API needs `X-Requested-With: XmlHttpRequest`
|
||||||
|
The SPA sends it; anything else (curl, a naive monitor) trips intrusion
|
||||||
|
detection and gets a 403 that looks like an auth bug. Only matters when probing
|
||||||
|
by hand — the Kuma monitor hits `/`, which is fine.
|
||||||
|
|
||||||
|
### Disk is the shared failure domain
|
||||||
|
`FILES_DATA_DIR` grows without a quota, driven by whoever has the password. A
|
||||||
|
full disk on `cochi` does not just break `files.` — it takes **Minecraft, Drasl,
|
||||||
|
and mc-backup** down with it. Eyeballing is the accepted plan. Revisit (quota,
|
||||||
|
separate partition, or a Kuma disk monitor) if the share sees real use.
|
||||||
|
|
||||||
|
### Pinned digest, not `latest`
|
||||||
|
`machines/filestash` publishes essentially only `latest`. Unpinned, any
|
||||||
|
`docker compose pull` silently ships a new upstream build of an internet-facing,
|
||||||
|
writable file manager. Pin the digest; upgrade deliberately — same discipline as
|
||||||
|
`NEOFORGE_VERSION`.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- **Keycloak SSO** — needs oauth2-proxy + `FILES_AUTH=passthrough`. Not built.
|
||||||
|
- **Anonymous access** (`FILES_AUTH=none`) — only once the host is internal-only.
|
||||||
|
- **Landing page link** — deliberately none on day one; `plan/18-landing-rework.md`
|
||||||
|
is mid-rework, so revisit with WS5 (footer) rather than merging into a moving
|
||||||
|
design.
|
||||||
|
- **Per-user accounts / roles** — htpasswd RBAC is enterprise-only.
|
||||||
|
- **Backups / quota** — none.
|
||||||
@@ -29,6 +29,60 @@ render() { # $1=template $2=output
|
|||||||
render drasl/config/config.toml.tmpl drasl/config/config.toml
|
render drasl/config/config.toml.tmpl drasl/config/config.toml
|
||||||
render nmsr/config.toml.tmpl nmsr/config.toml
|
render nmsr/config.toml.tmpl nmsr/config.toml
|
||||||
|
|
||||||
|
# ── filestash (files.${BASE_DOMAIN}) ────────────────────────────────────
|
||||||
|
# Filestash has NO env-var config: config.json is the source of truth and the
|
||||||
|
# admin console writes it back at runtime. We render it and mount it read-only,
|
||||||
|
# so git stays authoritative (the console renders but cannot save — by design).
|
||||||
|
#
|
||||||
|
# FILES_AUTH picks the identity_provider block. The params fields are JSON
|
||||||
|
# *strings* holding escaped JSON (that's Filestash's schema, not a typo) —
|
||||||
|
# built here rather than in the template because the shape differs per mode.
|
||||||
|
: "${FILES_AUTH:=htpasswd}"
|
||||||
|
: "${FILES_SECRET_KEY:?FILES_SECRET_KEY unset (openssl rand -hex 16)}"
|
||||||
|
: "${FILES_ADMIN_PASSWORD_HASH:?FILES_ADMIN_PASSWORD_HASH unset (docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>')}"
|
||||||
|
: "${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset (openssl rand -hex 24) — unlocks the admin-gated local backend}"
|
||||||
|
|
||||||
|
case "$FILES_AUTH" in
|
||||||
|
htpasswd)
|
||||||
|
: "${FILES_USER:?FILES_USER unset (shared login for players)}"
|
||||||
|
: "${FILES_PASSWORD_HASH:?FILES_PASSWORD_HASH unset (openssl passwd -6 '<pw>')}"
|
||||||
|
# The htpasswd plugin's bcrypt branch only accepts $2a$ — a $2y$ hash (what
|
||||||
|
# `htpasswd -B` emits) silently fails every login. $6$ (openssl passwd -6)
|
||||||
|
# is the safe, explicitly-supported format. Halt rather than ship a config
|
||||||
|
# nobody can log into.
|
||||||
|
case "$FILES_PASSWORD_HASH" in
|
||||||
|
'$6$'*|'$5$'*|'$2a$'*|'$apr1$'*|'$1$'*) ;;
|
||||||
|
'$2y$'*|'$2b$'*)
|
||||||
|
echo "FILES_PASSWORD_HASH is bcrypt '${FILES_PASSWORD_HASH:0:4}' — the htpasswd plugin only accepts \$2a\$." >&2
|
||||||
|
echo "Use: openssl passwd -6 '<password>'" >&2
|
||||||
|
exit 1 ;;
|
||||||
|
*) echo "FILES_PASSWORD_HASH is not a recognised hash (want \$6\$… from: openssl passwd -6 '<password>')" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
FILES_IDP_TYPE="htpasswd"
|
||||||
|
FILES_IDP_PARAMS="{\\\"type\\\":\\\"htpasswd\\\",\\\"users\\\":\\\"${FILES_USER}:${FILES_PASSWORD_HASH}\\\"}"
|
||||||
|
;;
|
||||||
|
passthrough|none)
|
||||||
|
# NO LOGIN — anyone who reaches the vhost gets in. Only safe once this host
|
||||||
|
# is NOT reachable from the internet (see plan/20-files.md).
|
||||||
|
echo "!! FILES_AUTH=$FILES_AUTH — filestash will have NO authentication." >&2
|
||||||
|
echo "!! Only valid if files.${BASE_DOMAIN} is unreachable from the internet." >&2
|
||||||
|
FILES_IDP_TYPE="passthrough"
|
||||||
|
FILES_IDP_PARAMS="{\\\"type\\\":\\\"passthrough\\\",\\\"strategy\\\":\\\"direct\\\"}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "FILES_AUTH must be 'htpasswd', 'passthrough' or 'none' (got '$FILES_AUTH')" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
export FILES_IDP_TYPE FILES_IDP_PARAMS
|
||||||
|
|
||||||
|
envsubst '${BASE_DOMAIN} ${FILES_IDP_TYPE} ${FILES_IDP_PARAMS} ${FILES_ADMIN_PASSWORD_HASH} ${FILES_SECRET_KEY} ${FILES_LOCAL_SECRET}' \
|
||||||
|
< filestash/config.json.tmpl > filestash/config.json
|
||||||
|
|
||||||
|
# The hashes contain '$' and the params fields are nested JSON — one bad escape
|
||||||
|
# yields a config filestash silently ignores. Fail loudly instead.
|
||||||
|
python3 -c 'import json,sys; json.load(open("filestash/config.json"))' \
|
||||||
|
|| { echo "filestash/config.json is not valid JSON — refusing to ship it" >&2; exit 1; }
|
||||||
|
echo "rendered filestash/config.json (auth=$FILES_AUTH)"
|
||||||
|
|
||||||
# Server mods are no longer rendered here — they come from the distribution repo
|
# Server mods are no longer rendered here — they come from the distribution repo
|
||||||
# via tooling/sync-server-mods.sh (run separately in the deploy flow). See
|
# via tooling/sync-server-mods.sh (run separately in the deploy flow). See
|
||||||
# plan/04-mods.md.
|
# plan/04-mods.md.
|
||||||
|
|||||||
Reference in New Issue
Block a user