Ich hatte "gleiches Haus" geschrieben und damit die Platte am Router als
halben Schritt abgetan. Falsch: der Rechner ist ein Windows-Root-Server im
Rechenzentrum, bedient von zu Hause ueber mRemoteNG. Ein Ziel beim Betreiber
daheim sind also zwei Gebaeude, zwei Netze, zwei Stromkreise, zwei Anbieter --
besser wird ein Sicherungsziel kaum.
Gemessen, was dafuer schon dasteht:
sshd Running, Automatic, 0.0.0.0:22 und [::]:22
Firewall "OpenSSH SSH Server (sshd)" -> Allow
sshd_config Subsystem sftp sftp-server.exe
je Nacht d4rk_media 335,9 MB + d4rkbot 0,9 MB = 336,9 MB
Auf der Serverseite fehlt nichts ausser einem Konto, das nur lesen darf. Der
Rest ist ein Skript auf dem Heimrechner, das HOLT statt zu bekommen -- ein
Ziel, in das der Server schreiben darf, ist auch eines, das ein uebernommener
Server loeschen kann.
Fund am Rande: in sshd_config steht kein PasswordAuthentication no. Der
OpenSSH-Standard ist yes -- auf Port 22, offen ins Internet, mit AllowGroups
einschliesslich administrators.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
63 KiB
d4rk_media — Roadmap
Eigener Medien-Dienst für alle FiveM-Ressourcen: Dateien annehmen, ablegen, ausliefern, in einem Dashboard verwalten.
Warum nicht Fivemanage Lite. Der Stack lief am Ende — PostgreSQL, MinIO,
Dashboard, alles grün. Was an ihm nicht zu reparieren war, ist die Qualität
der Oberfläche: http-util.ts wirft jeden Fehler als new Error(…), die
Hooks prüfen auf instanceof ApiError, und damit wird jeder Fehlschlag
lautlos verschluckt. Create organization gibt weder Erfolg noch Misserfolg
zurück — das Ergebnis waren 19 gleichnamige Organisationen, weil ein Knopf
ohne Rückmeldung wie ein kaputter Knopf aussieht. Löschen geht nicht, die
Route existiert nicht. Das ist kein Bedienfehler und keine fehlende Funktion,
das ist 0.1.0-beta.
Dazu kam: von dem, was Lite über eine Dateiablage hinaus kann — Mandanten, strukturiertes Logging, ClickHouse — haben wir nichts gebraucht und alles mitgeschleppt.
Der Zuschnitt
Ein Container. SQLite statt PostgreSQL, Dateien auf einer Platte statt
MinIO. Das ist nicht die kleine Lösung, sondern die, die man nicht pflegt:
kein zweiter Dienst, kein Passwort dazwischen, und eine Sicherung ist ein
cp -a über ein Volume. Wenn daraus je Millionen Zeilen werden, wird
server/src/db.ts ausgetauscht — die Aufrufer merken davon nichts.
Ein Name. Dashboard und API liegen an der Wurzel, die Dateien unter /f/:
https://fivemanage.d4rkst3r.de/ Dashboard und API
https://fivemanage.d4rkst3r.de/f/vehicles/adder.webp
Ein DNS-Eintrag, ein Host im Proxy. Aufgegeben ist damit eine Eigenschaft, die zwei Namen mitbrächten: die öffentliche Adresse trägt jetzt auch das Anmeldeformular.
Und der Name ist
fivemanage, obwohl hier lange das Gegenteil stand. Der ursprüngliche Satz war:fivecdnundfivemanagefallen weg, wir betreiben kein Fivemanage und tragen deshalb nicht dessen Namen. Das Argument stimmt immer noch — es ist nur billiger als das, was dagegen steht, und das ist nachgemessen: der Name war schon fertig verkabelt. DNS-Eintrag da, Host in NPM da, Let's-Encrypt-Zertifikat gültig bis 09.11.2026, und er zeigte aufhost.docker.internal:9101— einen Port, der frei wurde, als die Lite-Container gelöscht wurden. Unser Stack auf 9101 heißt: kein DNS-Eintrag, kein Klick in NPM, kein neues Zertifikat.Der Preis ist ein Name, der nach einem fremden Produkt klingt. Der Wechsel bleibt billig — die Adressen stehen nirgends in der Datenbank, sie werden bei jeder Antwort aus
PUBLIC_URLgebaut. Ein späterer Umzug aufmedia.d4rkst3r.deist ein DNS-Eintrag, ein NPM-Host und eine geänderte Variable.
Zwei Namen kann der Dienst weiterhin — FILES_HOST setzen, dann gibt es unter
diesem Namen ausschließlich Dateien und die URLs kommen ohne /f/ aus. Beides
ist gemessen. Der Wechsel ist billig: die Adressen stehen nirgends in der
Datenbank, sie werden bei jeder Antwort aus PUBLIC_URL gebaut.
Der Pfad bleibt in beiden Fällen der Schlüssel. vehicles/adder.webp
bleibt vehicles/adder.webp, die Vorlage im Handy also {model}.webp. Nur der
Teil davor ändert sich, und der steht in einer Variablen.
Der Pfad ist der Schlüssel. X-Path: vehicles/adder.webp legt die Datei
genau dort ab. Vorhersagbare URLs sind für das Fotostudio die ganze
Voraussetzung — bei Lite hätten wir die zurückgegebenen Adressen in einer
urls.json mitschleppen müssen.
Nicht nachgebaut wird das Logging. Genau dafür schleppt Fivemanage ClickHouse mit, und genau da ist der Wartungsaufwand echt. Wenn Logs kommen sollen, ist das ein eigenes Projekt mit eigener Entscheidung.
Stand
✅ Fertig — Server-Grundgerüst
server/src/config.ts Einstellungen, alle aus der Umgebung
server/src/db.ts SQLite: users, sessions, tokens, media
server/src/auth.ts scrypt, Sitzungen, Token-Hashes
server/src/storage.ts Pfadprüfung, atomares Schreiben, MIME
server/src/routes/upload.ts die API für Skripte
server/src/routes/dash.ts die API fürs Dashboard
server/src/index.ts Host-Weiche, Dateiauslieferung, Start
Die API für Skripte:
POST /api/upload Bearer-Token
X-Path vehicles/adder.webp (optional)
X-Encoding base64 (für Lua)
X-Overwrite false (Standard: ja)
→ { url, path, size, sha256, mime, replaced }
DELETE /api/media/<pfad> Token braucht can_delete
GET /api/exists/<pfad> für "nur fehlende" im Serienlauf
Drei Rumpfformen — multipart/form-data, Base64, rohe Bytes. Base64 ist für
FiveM: PerformHttpRequest schickt einen Lua-String, und im Fotostudio kommt
das Bild ohnehin als Data-URL aus dem NUI.
Die API fürs Dashboard: Anmeldung, Passwortwechsel, Medienliste mit Suche und Paginierung, Einzel- und Sammellöschung, Statistik nach Ordnern, Token-Verwaltung.
✅ Fertig — Schritt 1, der Smoke-Test
Gestartet, durchgemessen, repariert. Der Durchstich steht: anmelden → Token anlegen → Bild hochladen → unter der zurückgegebenen URL abrufen, Byte für Byte identisch, auch unter dem Dateihost.
So läuft er lokal:
cp server/.env.example server/.env
npm --prefix server ci && npm --prefix server run dev
Der Stolperstein, den es nicht mehr gibt. Anfangs war der Standard für
FILES_HOSTder Host ausPUBLIC_URL. Bei zwei Namen ist das richtig, bei einem tödlich: dann galt jede Anfrage als Anfrage an den Dateiwirt,/healthgab 404 und die Anmeldung 405 „hier gibt es nur Dateien". Nichts war kaputt, und niemand kommt darauf. Seit der Entscheidung für einen Namen ist der Ein-Namen-Betrieb der Standard und der zweite Name die Ansage — lokal läuft es damit ohne Sonderbehandlung. Welche Betriebsart gilt, sagt der Dienst in der zweiten Startzeile.
Was der Test gefunden hat — alles im Grundgerüst, alles repariert:
- Die Token-Prüfung hing an
'*'und galt damit auch für/api/dash/*, das daneben liegt. Das Dashboard bekam „Token fehlt oder ist unbekannt" auf die Anmeldung, obwohl es nie einen Token haben kann. Jetzt hängt sie an den drei eigenen Pfaden. DELETE /api/media/…undGET /api/exists/…sahen am Ziel vorbei.c.req.pathträgt den Einhängepunkt mit, dasreplace(/^\/media\//)schnitt ihn nicht weg — ausvehicles/adder.pngwurdeapi/media/vehicles/adder.png. Löschen fand nie etwas,existsmeldete immerfalse. Jetzt:pfad{.+}; Hono liefert den Parameter fertig dekodiert (nachgemessen), ein zweitesdecodeURIComponentwäre eine Dekodierung zu viel gewesen.- Verzeichnisdurchstieg in der SPA-Rückfallroute.
GET /..%5Cpackage.jsonhat unter Windows die Datei ausgeliefert: Hono reicht%5Cdurch,path.joinbehandelt den Backslash dort als Trenner, und eine Eindämmung gab es nicht. Unter Linux trägt genau dieser Angriff nicht — Glück, keine Abwehr. Jetzt dieselberesolve-Prüfung wie instorage.ts. - Die Auskunft „Oberfläche ist nicht gebaut" war unerreichbar.
createReadStreammeldet eine fehlende Datei asynchron; dastry/catchdarum fing nichts. Ergebnis war ein leerer 200 samt ENOENT im Log. Jetzt wird vorher nachgesehen. - CSS und JS kamen als
application/octet-stream— die MIME-Tabelle kennt nur Medientypen. Das Dashboard hätte weder Stylesheet noch Modul geladen. Die Oberfläche hat jetzt eine eigene Tabelle: in der geteilten fehlthtmlmit Absicht, sonst könnte jeder mit einem Upload-Token eine Seite unterfivecdn.d4rkst3r.deveröffentlichen. Nachgemessen: eine hochgeladene.htmlkommt weiterhin alsoctet-streammitnosniff. - Kaputtes JSON endete als nackter „Internal Server Error". Ausgerechnet das — ein 500 ohne ein Wort dazu ist der Fehler, wegen dem wir hier neu bauen. Jetzt 400 mit Text.
Was sonst nachgemessen wurde und trägt: alle drei Rumpfformen (multipart,
Base64, rohe Bytes) liefern denselben SHA-256, auch mit data:-Vorspann · der
Hash-Pfad ohne X-Path · Präfix-Fessel und can_delete je Token · ..,
versteckte Dateien und leerer Rumpf werden abgelehnt · X-Overwrite: false
gibt 409, Überschreiben replaced: true · 413 vor dem Einlesen · ETag/304,
HEAD, Bereichsanfragen und 416 · unter dem Dateihost gibt es weder API noch
/f/-Präfix · Medienliste, Suche, Statistik nach Ordnern.
Nicht getestet: das Dashboard (gibt es noch nicht) und echte Last.
✅ Fertig — ein Name statt zwei
PUBLIC_URL ist jetzt nur noch der Ursprung; das /f hängt der Dienst selbst
an. Wer es mitschriebe, bekäme Adressen mit /f/f/ — und das fiele erst auf,
wenn das erste Bild fehlt. Prefix und Route werden deshalb an genau einer
Stelle entschieden (config.filePrefix), damit die zurückgegebene Adresse und
die Route, die sie ausliefert, nicht auseinanderlaufen können.
Beide Betriebsarten sind durchgemessen: mit einem Namen kommt
…/f/vehicles/adder.png zurück und liefert die Datei; mit gesetztem
FILES_HOST kommt sie ohne Präfix, liegt unter dem Dateihost, ist am
Dashboard-Namen zusätzlich unter /f/ erreichbar, und unter dem Dateihost gibt
es weiterhin keine API.
✅ Fertig — Schritt 2, das Abbild ist gebaut und gelaufen
Gebaut, gestartet, durchgemessen — auf einer Maschine mit Docker (Desktop
29.6.2, WSL2). Derselbe Durchstich wie in Schritt 1, diesmal gegen den
Container statt gegen tsx: 24 Prüfungen, keine durchgefallen. Am
Grundgerüst war nichts zu ändern; der Bau hat keinen einzigen Befund erzeugt.
So läuft er:
export PUBLIC_URL=http://localhost:9102 ADMIN_PASSWORD=…
docker compose up -d --build
Was jetzt gemessen ist und vorher nur behauptet war:
npm ci --omit=devzieht die fertige Binärdatei. Im Abbild liegtbetter-sqlite3/build/Release/better_sqlite3.node, datiert Mai 2025 — heruntergeladen, nicht übersetzt; einMakefileoderobj.targetvonnode-gypsteht nirgends daneben.require('better-sqlite3')läuft im Laufzeit-Abbild, Node 22.23.2, ABI 127. Die Festnagelung trägt.- Der Bau läuft durch, alle drei Stufen. Das Abbild: 373 MB auf der Platte, 88,4 MB Inhalt.
- Der Healthcheck greift. Docker meldet
healthy, Exitcode 0, kein Fehlschlag — ohnecurlim Abbild,fetchaus Node reicht. - Das Volume greift. Container weggeworfen, einen neuen an dasselbe
Volume gehängt: die Datei aus dem ersten kommt weiter unter ihrer URL, und
adminwird nicht noch einmal angelegt. Nebenbei bestätigt, was im Compose steht: ein geändertesADMIN_PASSWORDsetzt nichts zurück — die alte Anmeldung gilt (200), die neue nicht (401). - Compose bricht ohne Variablen ab, wie das
:?verspricht, und zwar mit dem Text, der danebensteht — erstPUBLIC_URL, dannADMIN_PASSWORD. Mit beiden fährt der Stack hoch und bindet0.0.0.0:9102->8080. Securesteht wirklich im Cookie, wennNODE_ENV=productiongilt:sid=…; Path=/; HttpOnly; Secure; SameSite=Lax. Ohne Cookie oder mit einem erfundenen gibt jede Dashboard-Route 401 — auch/auth/me.
Neu aufgefallen, weil der Container es sichtbar macht: im Volume liegen
vier Dinge, nicht zwei — files/, media.db, media.db-shm und
media.db-wal. SQLite läuft im WAL-Modus (db.ts), und das WAL war nach
wenigen Uploads 152 KB groß, mit Daten, die in media.db noch nicht standen.
Eine Sicherung, die nur media.db mitnimmt, ist deshalb unvollständig. Siehe
Offene Entscheidungen.
Beim Ausprobieren:
NODE_ENV=productionsetzt das Sitzungs-Cookie aufSecure. Wer den Container ohne HTTPS aufruft, bekommt auf die Anmeldung 200 — und ist trotzdem nicht angemeldet, weil der Browser das Cookie still verwirft; die nächste Anfrage ist 401. Im Browser gegen eine LAN-Adresse nachgemessen. Die naheliegende Gegenprobe überlocalhostführt in die Irre: das gilt Browsern als sicherer Kontext und funktioniert. Undcurltaugt hier nicht als Zeuge — der schickt ein Secure-Cookie auch überhttpund meldet fröhlich Erfolg. Was zählt, ist der Browser.
In Portainer aus diesem Repo deploybar — Compose path nimmt EINE Datei,
Ergänzungen werden stillschweigend ignoriert. Das hat uns bei Lite einen
Nachmittag gekostet.
Und noch eine Annahme, die beim Nachsehen fiel: es gibt gar keine zweite Maschine. Gebaut und gelaufen ist auf dem Server — hier laufen Gitea, Portainer, NPM und die Nextcloud. Die alte Notiz „auf dieser Maschine gibt es weder Docker noch WSL" galt vor der Installation von Docker Desktop und ist überholt. Was zum Betrieb fehlt, ist deshalb nicht der Umzug auf einen anderen Rechner, sondern nur der öffentliche Auftritt: DNS-Eintrag für
media.d4rkst3r.de, ein Host in NPM mit TLS, ein echtes Passwort.
Gemessen am 11.08.2026 belegt: 80, 81, 443, 2224, 3000, 3080, 3478, 8000,
8080, 8443, 8090, 9000, 9443, 11000 — 8443 fehlte in der alten Liste.
Der Host-Port ist 9101, nicht die 9102 aus dem Compose-Standard: darauf
zeigt der fertige NPM-Host (siehe oben). Gesetzt wird er über HOST_PORT in
der .env, der Standard im Compose bleibt 9102.
✅ Fertig — der Dienst steht öffentlich
Seit dem 11.08.2026 läuft er unter https://fivemanage.d4rkst3r.de, hinter
NPM mit Let's-Encrypt-Zertifikat, Container auf 9101. Die Werte stehen in einer
.env neben dem Compose (gitignored); dieselben Werte sind in Portainer die
Stack-Variablen.
Durchgemessen über die öffentliche Adresse, nicht über localhost:
- Anmeldung durch echtes HTTPS — das ist der Fall, den
curlbisher nicht bezeugen konnte:Secuream Cookie stört jetzt nicht mehr, weil die Verbindung wirklich TLS ist. 200, Cookie da,/auth/me200. - Token mit Präfix-Fessel
vehicles/und ohne Löschrecht angelegt. Upload nachanderswo/→ 403 mit Text, Löschen → 403 mit Text. - Upload und Abruf über TLS und Proxy: Byte für Byte identisch.
Der 502, der keiner war. Der erste Aufruf gab 502 in 78 ms. Die naheliegende Erklärung — „ein Moment, gleich geht's" — war falsch: der NPM-Host zeigte in dem Moment auf 9102, unser Container liegt auf 9101, also sofortige Ablehnung. Kein Timeout, keine DNS-Frage, kein Zufall. Ein 502 binnen Millisekunden ist immer ein Connection refused, und dann stimmt eine Portnummer nicht.
✅ Fertig — Schritt 3, der Fotostudio lädt selbst hoch
Am 11.08.2026 im Spiel gelaufen. /studio shot adder, und die Konsole sagte
in zwei Zeilen, was passiert ist:
[photostudio] Upload nach https://fivemanage.d4rkst3r.de — Dienst antwortet.
[photostudio] adder freigestellt abgelegt (129 KB)
[photostudio] adder hochgeladen: https://fivemanage.d4rkst3r.de/f/vehicles/adder.webp
Von der Dienstseite gegengeprüft, statt der Logzeile zu glauben: 132 908 Bytes
hier wie dort, derselbe SHA-256 (f14dfc5b…5515e), ausgeliefert als
image/webp, und die ersten Bytes sind RIFF….WEBP — ein echtes WebP und
kein falsch benanntes PNG.
Damit ist auch die letzte offene Frage aus dem Prüfstand beantwortet:
PerformHttpRequest verhält sich in FiveM wie die Fälschung, Kopfzeilen und
base64-Rumpf kommen unverändert an, und der Rückruf erreicht den
Event-Handler.
Was noch niemand gemessen hat: ein ganzer Serienlauf über 900 Fahrzeuge —
also 900 Uploads hintereinander, mit dem, was dabei an Fristen und
Gleichzeitigkeit auftreten kann. Und Upload.put, die wartende Form, ist
ungenutzt: der Lauf nimmt Upload.send. Sie steht da, weil die ROADMAP sie so
benannt hat, und ist damit ungeprüfter Code.
Der Quelltext liegt in d4rk_photostudio (eigenes Repo, D:\FXServer\txData\…):
server/upload.lua Upload.send(pfad, bytes, cb) und Upload.put(pfad, bytes) → url
config.upload.example.lua committet
config.upload.lua gitignored, enthaelt den Token
Hängt an d4rk_photostudio:saveKeyed — also genau an der Freigabe: das Bild
geht erst auf die Platte, dann in den Dienst, und ein Fehlschlag bricht
nichts ab, sondern landet im Protokoll des Laufs. Ein Lauf über 900 Fahrzeuge,
der an einem Netzhuster stirbt, wäre der teuerste denkbare Fehler.
Gemessen ist alles, was sich ohne FiveM messen lässt — und das ist mehr, als es
zunächst schien: upload.lua wurde in einer echten Lua-5.3-VM (fengari,
über Node) mit gefälschten FiveM-Funktionen ausgeführt. 30 Prüfungen, keine
durchgefallen: fehlende, kaputte und unvollständige config.upload.lua;
Adresse, Methode und alle vier Kopfzeilen; der Fehlertext des Dienstes statt
einer nackten Zahl; Status 0; und dass die Frist den Rückruf nicht ein zweites
Mal auslöst. Der Rumpf, den die VM erzeugt hat, ging danach unverändert an
den echten Dienst — hochgeladen, abgerufen, Byte für Byte identisch.
Damit ist erreicht, worum es ursprünglich ging. Ein Bild entsteht im Spiel, wird freigestellt, landet auf der Platte und liegt Sekunden später unter einer vorhersagbaren Adresse — ohne Fivemanage, ohne PostgreSQL, ohne MinIO, ohne einen einzigen Handgriff von Hand dazwischen.
✅ Fertig — Schritt 4, das Dashboard
Liegt in server/ui, gebaut nach server/web (gitignored), im Abbild von
einer eigenen Docker-Stufe. React 18, TypeScript, Vite 5, Tailwind 4
(CSS-first), Lucide, Zustand — kein Konsta, wie geplant.
Anmeldung · Galerie mit Vorschau, Suche, Mehrfachauswahl, Blättern · Einzel- und Sammellöschung mit Nachfrage, die die Pfade zeigt · Speicherverbrauch nach Ordnern · Token-Verwaltung mit Klartext genau einmal · Passwortwechsel.
Vite 5 bleibt, aber
@vitejs/plugin-reactist auf^4festgenagelt. Die 6 verlangtvite ^8. In denpeerDependenciesnachgesehen, nicht geraten:@tailwindcss/vite@4nimmt^5.2 || ^6 || ^7 || ^8, undplugin-react@4nimmt^4.2 || ^5 || ^6 || ^7. Vite 5.4.21 liegt in beiden Schnittmengen.
Wie die eine Regel durchgesetzt wird, und nicht nur gemeint ist. In
api.ts steht der einzige fetch und genau eine Fehlerklasse — der
Fivemanage-Fehler war, zwei zu haben (new Error geworfen, instanceof ApiError geprüft, jeder Fehlschlag verschluckt). In store.ts steht run():
es nimmt die Aktion und den Erfolgstext, meldet bei Erfolg diesen und bei
Fehlschlag den Text des Dienstes. Wer darüber geht, kann keinen stillen
Knopf bauen. Jeder Knopf, der wartet, zeigt einen Kreisel und ist gesperrt —
der zweite Klick eine Sekunde später war der Weg zu neunzehn gleichnamigen
Organisationen.
Nachgemessen am laufenden Container: / liefert die Oberfläche als
text/html, assets/*.js als text/javascript, assets/*.css als
text/css (die MIME-Falle aus Schritt 1 trägt also), und /tokens,
/speicher, /konto geben dieselbe index.html zurück — die
SPA-Rückfallroute tut, wofür sie gebaut wurde.
⚠️ Der Proxy hält Bilder fest — 30 Minuten lang
Beim Nachmessen der Zwischenspeicher-Kopfzeilen gefunden, und es trifft
genau den Fotostudio-Ablauf. NPMs assets.conf greift über eine Regex auf
jede URL, die auf .css .js .png .webp … endet — also auch auf
/f/vehicles/adder.webp. Sie setzt proxy_cache public-cache,
proxy_cache_valid any 30m und wirft unsere Kopfzeilen weg
(proxy_ignore_headers Cache-Control, proxy_hide_header Cache-Control).
Reproduziert, nicht vermutet:
Bild hochladen → abrufen: "ERSTE-FASSUNG"
dieselbe Datei neu → abrufen über NPM: "ERSTE-FASSUNG" ← alt
abrufen am Dienst: "ZWEITE-FASSUNG" ← neu
Ein neu fotografiertes Fahrzeug sieht bis zu 30 Minuten lang aus wie das alte, obwohl Upload, Protokoll und Datenbank alle „fertig" sagen. Das ist die Sorte Fehler, die man drei Ebenen tiefer sucht.
Behoben am 11.08.2026 und nachgemessen: die Datei trägt jetzt wieder
unsere eigene Kopfzeile (cache-control: public, max-age=300, kein Expires
von NPM mehr), und ein überschriebenes Bild kommt sofort durch. Der
Schnipsel steht im Feld Custom Nginx Configuration des Proxy-Hosts — NPM
setzt ihn dort innerhalb des server-Blocks ein ({{ advanced_config }},
Zeile 28 von 52 in der Vorlage), eine location ist dort also gültig:
# Dateien nicht zwischenspeichern -- sie koennen sich unter derselben
# Adresse aendern. Das ^~ ist der ganze Trick: eine gewoehnliche
# Praefix-Location VERLIERT gegen die Regex aus assets.conf.
location ^~ /f/ {
proxy_cache off;
include conf.d/include/proxy.conf;
}
Dass ^~ nötig ist und ein blankes location /f/ nicht reicht, ist in einem
Wegwerf-nginx nachgemessen: ohne ^~ gewinnt die Regex, mit ^~ das Präfix.
✅ Fertig — Sicherung, Anmeldebremse, Vorschaubilder
Sicherung. tools/sichern.ps1, täglich um 04:30 als geplante Aufgabe
(nur bei angemeldetem Benutzer — Docker Desktop läuft ohnehin nur dann).
Die Datenbank wird nicht kopiert, sondern über SQLites eigene
Sicherungsschnittstelle herausgeholt (server/src/backup.ts, db.backup()).
Der Grund steht weiter oben: im WAL-Modus liegt das Zuletzte noch nicht in
media.db, und selbst alle drei Dateien zu kopieren ist nicht sicher, wenn
währenddessen geschrieben wird. Die Bilder kommen aus einem nur lesend
eingehängten Volume dazu, alles in ein tar.gz.
Und die Sicherung prüft sich selbst: sie wird ausgepackt, die Datenbank geöffnet, Medien, Token und Benutzer gezählt und mit dem laufenden Dienst verglichen. Stimmt es nicht, endet das Skript mit Fehler.
Einmal wirklich zurückgespielt, nicht nur behauptet: in ein leeres Wegwerf-Volume ausgepackt, ein zweiter Dienst darauf gestartet, mit dem echten Passwort angemeldet, 21 Dateien vorgefunden, ein Bild abgerufen — Byte für Byte identisch mit dem Betrieb.
Nicht mitgesichert werden die Vorschaubilder: sie sind abgeleitet und lassen sich mit einem Knopf neu rechnen.
Anmeldebremse. Das Formular steht öffentlich; scrypt macht einen Versuch
teuer, aber teuer ist nicht selten. Jetzt: fünf freie Versuche je Adresse, dann
Sperre ab 30 Sekunden mit Verdopplung bis 15 Minuten, 429 samt Retry-After
und einem Text, der sagt, wie lange noch. Ein Erfolg setzt den Zähler zurück.
Bewusst nur im Speicher — wer sich aussperrt, startet den Container neu.
Durchgemessen: Versuche 1–5 geben 401, ab dem sechsten 429 mit „noch 30 Sekunden warten", nach Ablauf geht das richtige Passwort, und danach gibt ein Fehlversuch wieder 401 statt 429.
Die Adresse kommt aus
X-Real-IP, den NPM mit$remote_addrüberschreibt. Wer den Container direkt auf seinem Port erreicht, kann sie sich ausdenken — genau deshalb gehört 9101 hinter die Firewall.
Vorschaubilder. sharp erzeugt beim Upload eine 320 Pixel breite
WebP-Fassung unter /data/thumbs, ausgeliefert unter /t/<pfad>. Gemessen an
vehicles/sultan.webp: 131 502 → 13 078 Bytes, Faktor 10. Eine
Galerieseite mit 60 Kacheln fällt damit von 7,5 MB auf 766 KB.
Keine Spalte in der Datenbank: ob es eine Vorschau gibt, sagt das Dateisystem, und die Galerie fällt bei 404 auf das Vollbild zurück. Eine zweite Wahrheit, die auseinanderlaufen kann, gibt es damit gar nicht erst. Was vor dieser Funktion hochgeladen wurde, zieht ein Knopf unter Speicher nach — er nennt Zahlen, nicht nur „fertig" (21 erzeugt, 0 Fehler beim ersten Lauf).
Das Abbild wächst dadurch von 373 auf 457 MB. sharp bringt fertige
Binärdateien für linux-x64 mit; im Abbild nachgemessen: libvips 8.18.3,
ein 800×600-PNG wird zu einem 320er WebP.
Nebenbefund beim Einbauen:
npm i sharpscheitert auf diesem Rechner — aber nicht an sharp, sondern an better-sqlite3. Der Host läuft auf Node 24 (ABI 137), und dafür gibt es keine fertige Binärdatei;node-gypübernimmt und findet keine Bauwerkzeuge. Genau die Falle, die im Dockerfile steht. Umgehung:npm i sharp --package-lock-onlyträgt es nur ein, gebaut wird im Abbild auf Node 22.
✅ Fertig — Schritt 5, der Umzug
Die Fivemanage-Dateien liegen in legacy/ mit einer Erklärung daneben; unsere
docker-compose.media.yml heißt jetzt schlicht docker-compose.yml (in
Portainer also Compose path: docker-compose.yml). Gelöscht: drei Volumes und
drei Abbilder, rund 670 MB. Nachgezählt war vorher, dass kein einziges Bild
darin lag.
Der Proxy ist erledigt. docs/npm-advanced.conf ist eingetragen und
gespeichert; tools/proxy-pruefen.ps1 misst nach, statt vermuten zu lassen:
[ok] /f/ Cache-Control public, max-age=300
[ok] /t/ Cache-Control public, max-age=60
[ok] /api/ mit Token -> 200 HTTP 200
[ok] /api/ dieselbe Adresse ohne Token -> 401 HTTP 401
[ok] /api/exists?pfad ohne Token -> 401 HTTP 401
Und die drei Folgen, die daran hingen, gegengeprüft:
| vorher | jetzt | |
|---|---|---|
/api/exists/…webp ohne Token |
200 mit voller Auskunft |
401 |
| dieselbe Adresse mit gültigem Token | 401 (zwischengespeichert) |
200 |
| Vorschau nach erneutem Upload | bis zu 4 h alt | max-age=60 |
WebP-Aushandlung auf /f/ |
Vary wurde entfernt |
vary: Accept steht |
Die WebP-Aushandlung am selben Bild nachgemessen: ohne Accept kommen
image/png mit 12 295 B, mit Accept: image/webp kommen image/webp mit
2 270 B — 82 % weniger, gleiche Adresse.
Was die Sache gelehrt hat, steht in
docs/npm-advanced.confals Kommentar:assets.confgreift nach der Endung der Adresse, nicht nach ihrer Bedeutung. Alles, was auf.webp,.png,.jsendet, fällt hinein — auch eine API-Antwort. Undproxy_cache_keykennt denAuthorization-Kopf nicht, also wird dieselbe Antwort an alle ausgeliefert, in beide Richtungen.Deshalb bleibt
/api/exists?pfad=…die empfohlene Form: sie ist dicht, ohne von dieser Proxy-Einstellung abzuhängen. Eine Zugangsprüfung, die an einer Zeile in einer fremden Oberfläche hängt, ist keine.
Außerdem: den Proxy-Host fivecdn.d4rkst3r.de löschen — er zeigt auf den toten
Port 9100 und gibt 502.
✅ Fertig — Papierkorb, Abrufzähler, sparsame Fassungen, Archive
Vier Dinge, gemessen und im Betrieb:
Papierkorb. Löschen war endgültig, und das bei einem Knopf „alle 3861
wählen" direkt neben „löschen". Jetzt wandert die Datei nach /data/papierkorb
und der Datensatz in eine eigene Tabelle; nach 30 Tagen räumt der Dienst
stündlich selbst auf. Ende zu Ende geprüft: löschen → öffentlich 404, Eintrag
im Papierkorb, zurückholen → 200 samt neu gerechneter Vorschau.
Abrufzähler. media.abrufe und zuletzt_abgerufen. Gezählt wird im
Speicher und alle 30 Sekunden weggeschrieben — ein UPDATE je Kachel wären
bei einer Galerieseite 120 Schreibvorgänge. Der 304 zählt mit (der Aufrufer
wollte die Datei), HEAD nicht. Was vor dem Einbau lag, steht als „nie
geholt" da, auch wenn es täglich benutzt wurde — das sagt die Karte im
Speicherbericht auch dazu.
Sparsame Fassungen ohne Adressänderung. Neben jedem PNG/JPEG liegt eine
WebP-Fassung, ausgeliefert unter derselben Adresse je nach Accept.
Nachgemessen: 6448 → 2758 Bytes für einen Browser, unverändert 6448 für curl.
Mit Vary: Accept und getrennten ETags (…-w). Details in docs/API.md.
Über den ganzen Bestand nachgerechnet: 3606 Fassungen, 199,3 MB → 33,1 MB, gespart 166,2 MB (83 %). Bei 29 lohnt WebP nicht — dort bleibt es beim Original, und das ist kein Fehler, sondern die Regel „nur wenn es kleiner wird".
Und ein Knopf, der gelogen hat. Der erste Nachrüstlauf über alle Dateien
brauchte mehr als 90 Sekunden — genau da gibt Nginx Proxy Manager auf
(proxy_read_timeout). Ergebnis: 504 im Browser, während die Arbeit im
Hintergrund weiterlief und fertig wurde. Ein Knopf, der Erfolg als Fehler
meldet, ist schlimmer als einer ohne Rückmeldung.
Behoben, indem beide Wartungsknöpfe jetzt in Runden zu 300 Stück laufen:
der Server meldet offen und fertig, die Oberfläche ruft erneut auf und
zeigt dabei „280 erzeugt, noch 129 offen …". Nachgemessen: 7,8 s je Runde
statt 90+ am Stück. Der Vorschau-Knopf hatte dieselbe Wand und wurde
mitgezogen, obwohl er noch nicht dagegengelaufen war.
ZIP hochladen. Ein Archiv wird ausgepackt statt abgelegt, die Ordner darin
bleiben erhalten und hängen sich hinter den gewählten Zielordner. fflate,
also reines JavaScript — dieses Projekt hat schon einen halben Abend an einer
ABI-Nummer verloren. Grenzen: 5000 Einträge, 256 MB entpackt, geprüft vor
dem Entpacken (Zip-Bombe). Zip-Slip nachgemessen: ../../../../etc/passwd und
..\..\windows\hosts abgelehnt, die harmlose Datei im selben Archiv abgelegt.
✅ Fertig — Betrachter für Lesbares, und ein richtiger Player
Bild, Video, Ton und PDF konnte der Betrachter schon. Was fehlte, war
ausgerechnet die Art, die man am ehesten nur lesen will: eine .md landete im
Zweig „lässt sich hier nicht anzeigen".
Jetzt lesbar: Markdown gesetzt, .lua/.json eingefärbt (derselbe
Tokenizer wie auf der API-Seite, nicht ein zweiter), CSV als Tabelle — mit
Anführungszeichen und doppelten darin, das ist der Teil, den ein
split(';') falsch macht —, alles Übrige mit Zeilennummern in einer eigenen
Spalte, damit ein Kopieren sie nicht mitnimmt.
Nicht alles wird geholt. Die Größe steht in der Datenbank, also wissen wir
vorher, worauf wir uns einlassen: über 512 KB kommt per Bereichsanfrage nur der
Anfang, und das steht auch da. Gemessen: Range: bytes=0-49 → 206, 50 Bytes.
Markdown ohne Bibliothek. marked + DOMPurify enden bei
dangerouslySetInnerHTML, und die Sicherheit hängt dann an der Frage, ob die
Filterliste vollständig ist. Hier entsteht nie eine HTML-Zeichenkette —
Text wird zu React-Knoten. Im ganzen ui/ steht kein einziges
dangerouslySetInnerHTML (außer in den zwei Kommentarzeilen, die erklären,
warum).
Die eine Lücke, die React nicht schließt, ist href: [klick](javascript:…)
käme durch. Zwölf Fälle in Node durchgeprüft, alle bestanden — http, https,
mailto und relative Ziele durch; javascript:, JavaScript:, mit
Leerzeichen davor, data:, vbscript:, file: abgelehnt und als Text stehen
gelassen.
Der Player. Die Bedienelemente bleiben die des Browsers — ein selbstgebauter Schieber sieht in jedem Browser anders falsch aus und kann keine Tastatur. Drumherum kam, was der Browser nicht mitbringt:
- Lautstärke über Dateien hinweg.
<video>setzt sie bei jedem neuen Element auf 1 zurück; wer zwanzig Clips durchsieht, stellt sie sonst zwanzigmal leise — und beim einundzwanzigsten fährt sie ungefragt hoch. - Weiter zum nächsten Stück, abschaltbar. Und nur, wenn das Nächste auch etwas zum Abspielen ist: nach einem Lied ungefragt bei einem Bild zu landen wäre schlechter als stehenzubleiben.
- Tastatur: Leertaste hält an, ←/→ springen fünf Sekunden, Umschalt+←/→ wechselt die Datei. Bei allem anderen blättern die Pfeile wie bisher. Die Belegung steht als Zeile unter dem Bild — eine unsichtbare Sonderregel fühlt sich an wie ein Fehler.
Server: die Typtabelle kennt jetzt md, csv, lua, xml, yml, toml,
ini, cfg, sql, log — alle als text/plain. Nicht als text/html,
und html/css/js stehen weiterhin nicht drin: wer einen Upload-Token
hat, könnte sonst eine Seite unter unserem Namen veröffentlichen.
Der Betrachter entscheidet trotzdem nach der Endung und erst danach nach der
gemeldeten Art. Grund: items/readme.md lag schon da und trug
application/octet-stream, weil sie vor der erweiterten Tabelle hochgeladen
wurde. Wer nur auf die Art schaut, zeigt die alte README nicht an und eine neue
schon — und sucht den Unterschied an der falschen Stelle.
✅ Fertig — Vorschau für Video und Ton, und zwei Ordner-Lücken
Mit den ersten echten Clips fiel auf, was bei 3600 Bildern nie auffiel: alles, was kein Bild ist, bekam ein Fragezeichen auf die Kachel — also ausgerechnet das Zeichen für „unbekannt" auf einer völlig gesunden MP4.
Jetzt: Standbild aus dem Clip (ein Zehntel hinein, weil viele mit Schwarz beginnen), bei Ton das eingebettete Titelbild, dazu ein Abspielzeichen und die Laufzeit in der Ecke. Fehlt eine Vorschau, kommt ein Zeichen, das die Art benennt, statt eines Fragezeichens.
Der Preis: ffmpeg im Abbild — und der wurde zweimal gemessen und zweimal gedrückt:
| Weg | Abbild |
|---|---|
| vorher | 460 MB |
apt-get install ffmpeg |
1,11 GB |
| statisches ffmpeg + ffprobe | 835 MB |
| statisches ffmpeg allein | 648 MB |
Debians Paket schaltet jeden Filter frei und zieht die Abhängigkeiten mit:
libllvm15 (114 MB), Mesa samt libgl1-mesa-dri, libz3, und libflite1 —
ein Sprachsynthesizer. Alles für Filter, die ein Dienst ohne Bildschirm nie
anfasst; --no-install-recommends half nicht, es sind harte Abhängigkeiten.
ffprobe flog danach auch noch raus: 135 MB für eine einzige Aufgabe, die
Länge zu nennen — die steht auch im Kopf, den ffmpeg beim Öffnen druckt.
Gegengeprüft an beiden Dateien, auf zwei Nachkommastellen identisch:
ffprobe 29.767967 | ffmpeg Duration: 00:00:29.77
ffprobe 645.642449 | ffmpeg Duration: 00:10:45.64
Ein Fehler, der zweimal derselbe war. „Keine Bildspur" ist bei Ton der Normalfall — die meisten MP3 tragen kein Titelbild. Der erste Anlauf erkannte das am Wortlaut der Fehlermeldung, und der ist zwischen den Fassungen anders:
ffmpeg 5.1 "Stream map '0:v:0' matches no streams."
ffmpeg 7.1 "Failed to set value '0:v:0' for option 'map'"
Ergebnis: eine gesunde MP3 wurde als „1 fehlgeschlagen" gezählt. Gelesen
wird jetzt die Spurenliste (Stream #0:1: Video:), die seit jeher so
dasteht — und die kommt aus demselben Aufruf wie die Länge, also ein Blick in
die Datei statt drei. Gemessen: fehler: 0, ohneBild: 1.
Beide Wege durchgeprüft — Video: 320×180, Durchschnittsfarbe RGB(97,140,155),
also echtes Bild und kein schwarzer Vorspann. Ton mit angehängtem Titelbild:
Vorschau erzeugt, dauer: 8.05 → Kachel zeigt 0:08.
Und zwei Ordner-Lücken, beide vom Benutzer gefunden:
- „Wie lege ich neue Ordner an?" — Gar nicht, außer man wusste es: der Knopf saß nur im Verschieben-Dialog. Wer einen leeren Ordner wollte, musste also erst Dateien auswählen und „Verschieben" drücken. Eine Funktion, die man nur über eine andere Funktion erreicht, ist für den, der sie sucht, nicht vorhanden. Jetzt steht sie unter dem Ordnerbaum — mit der Angabe, in welchem Ordner sie entsteht.
- Zielordner beim Hochladen. Vorher galt stillschweigend der in der Galerie angeklickte Ordner. Richtig, solange man es weiß — und ein Fallstrick, sobald man es nicht weiß: wer nach einer Suche hochlädt, steht in „Alle" und legt an der Wurzel ab. Der angeklickte Ordner bleibt die Voreinstellung, ist jetzt aber sichtbar und änderbar, samt „neuer Ordner" direkt daneben.
✅ Fertig — Umbenennen, Fivemanage-Sprache, Freigabe-Links, sichtbare Zahlen
Umbenennen gab es gar nicht — verschieben ja, umbenennen nirgends. Jetzt für Datei und Ordner. Die Warnung ist dabei der eigentliche Teil: beim Umbenennen ändert sich die Adresse, und die steht womöglich in einem Skript, das niemand mehr im Kopf hat. Der Dialog zeigt deshalb die Abrufzahl („diese Datei wurde 4000× geholt") und beim Ordner die Zahl der betroffenen Dateien, bevor gedrückt wird.
Am echten Fall geprüft — der Kollisionsfund von oben: items/coiloverss.png
(hielt die +-Fassung) → items/coiloverss-plus.png, alte Adresse 404, neue
200 mit 4375 B, Vorschau mitgewandert. Ordner: probe → beispiele, 5 Dateien,
alle Datenbankpfade in einer Transaktion umgeschrieben.
Und der Fallstrick beim Ordner:
thumbPathhängt.webpan. Für eine Datei richtig, für einen Ordner Unsinn — der Vorschauordner heißtthumbs/vehiclesund nichtthumbs/vehicles.webp. Dafür gibt es jetztmoveFolder.
Die 93 Kollisionen sind erledigt. tools/kollisionen.ps1 rechnet dieselbe
Zähmung auf der Quelle nach und zeigt, was zusammenfällt. Ergebnis: 91 von 93
sind dieselbe Datei zweimal — WEAPON_SMG.png und weapon_smg.png, byteweise
gleich groß. Da fehlte nichts. Echt verloren waren zwei, beide mit + im
Namen (das + wird zum Strich und dann abgeschnitten). Beide nachgeholt.
Fivemanage-Sprache. POST /api/image|video|audio (v1) und POST /api/v3/file
(v3), Schlüssel nackt im Authorization-Kopf. Der Umzug fremder Skripte ist
damit eine Zeile: die Adresse. Beide Formen sind aus echtem Code abgelesen —
fivemanage/sdk und Awleks/Devm-Camera —, nicht geraten. Gemessen: v1 →
{url,id,path}, v3 → {status:"ok",data:{id,url}}, ohne Schlüssel 401, Video
an /api/image → 415 mit Grund.
Dabei bin ich in eine Falle gelaufen, vor der im eigenen Repo ein Kommentar warnt. Die Token-Wache hing an
use('*')— und der Einhängepunkt ist/api, also galt sie auch für/api/dashdaneben. Das Dashboard bekam 401 auf die Anmeldung. Wortwörtlich derselbe Fehler steht seit Monaten inupload.tsals Kommentar, weil er dort schon einmal passiert ist. Gemerkt hat es der Gegentest, nicht der Kopf.
Freigabe-Links. /s/<schlüssel> zeigt einen Ordner ohne Anmeldung — ein
Katalog für Spieler. Was dabei ausdrücklich dabeisteht, in der Karte und in
der Rückfrage vor dem Zurückziehen: freigegeben wird die Liste, nicht der
Inhalt. Die Dateien sind ohnehin öffentlich; ein zurückgezogener Link macht
sie nicht wieder privat, er nimmt nur die Übersicht weg. Ohne diesen Satz zieht
jemand einen Link zurück und glaubt, etwas sei verschwunden.
Die Antwort ist abgemessen: Name, Größe, Art, Adresse, Länge. Nicht: Hash, Token, Zeitpunkte, Abrufzahlen, IDs. Ein unbekannter Schlüssel und ein zurückgezogener geben dieselbe Antwort — ein Unterschied wäre eine Auskunft an jemanden, der Schlüssel durchprobiert.
Zahlen, die schon da waren. Sortieren nach Abrufen und nach „zuletzt geholt"
(die Kachel zeigt dann auch diese Zahl statt Größe und Datum — nach etwas zu
ordnen, das man nirgends sieht, ist eine Reihenfolge ohne Begründung). Und je
Token, was damit abgelegt wurde: media.token_id wird seit dem ersten Tag
geschrieben und war nirgends zu sehen. Gemessen: d4rk_photostudio hält 810
Dateien / 99,5 MB, 3640 Dateien liegen ohne Token da (aus dem Dashboard).
✅ Fertig — Konten, Kontingent, Plattenwächter, Umbenennen im Verlauf
Konten sichtbar. Es gab zwei — admin und eines aus Discord — und keine
Stelle, an der man sie sehen konnte. Weil jeder mit der Rolle auf dem
Discord-Server beim ersten Anmelden still ein Konto bekommt, wären aus zwei
unbemerkt zwanzig geworden. Jetzt: Liste unter Einstellungen → Konten mit
Weg (Discord/Passwort), angelegt, zuletzt da, offene Sitzungen — und ein
Entfernen, das die Sitzungen mitnimmt.
Zwei Sperren, beide mit Grund: das eigene Konto lässt sich hier nicht entfernen (der eine Klick, nach dem niemand mehr hereinkommt), und das letzte auch nicht. Und die Rückfrage sagt bei einem Discord-Konto dazu, dass die nächste Anmeldung ein neues anlegt, solange die Rolle bleibt — sonst hält jemand das Entfernen für eine Aussperrung.
Kontingent je Token. max_bytes gab es längst, aber es galt je Datei:
ein Token mit „höchstens 2 MB je Bild" konnte trotzdem die Platte füllen, es
brauchte nur genug Bilder. quota_bytes gilt für alles zusammen, gezählt über
media.token_id — also über das, was wirklich liegt, statt über einen
mitlaufenden Zähler, der auseinanderlaufen kann. Auf beiden Wegen geprüft
(eigene API und Fivemanage-Weg): eine Grenze, die nur an einer von zwei Türen
hängt, ist keine.
Die erste Fassung meldete „hält 0.0 MB von 0.0 MB, und diese Datei braucht 0.0 MB" — formal richtig, praktisch die Meldung, die genau die Frage nicht beantwortet, wegen der man sie liest. Die Einheit wächst jetzt mit: „hält 12 KB von 20 KB, und diese Datei braucht 12 KB."
Plattenwächter. Meldet nach Discord bei 80 % (knapp), 90 % (eng) und 95 % (kritisch) — je Stufe genau einmal, und wenn wieder Luft ist, meldet der nächste Engpass erneut. Der Grund steht in der Meldung: eine volle Platte ist der eine Zustand, in dem gleichzeitig nichts mehr hereinkommt und die Sicherung nicht mehr schreiben kann.
Gemessen — und die Abweichung zu df ist gewollt:
gesamt 1006,9 GB
frei (bavail) 909,5 GB -> 9,7 % belegt <- was der Wächter sieht
frei (bfree) 960,7 GB -> 4,6 % belegt
df sagt 5 %
bavail zieht die ext4-Reserve ab, an die der Dienst als Nicht-root ohnehin
nicht herankommt. Er warnt damit etwas früher — die richtige Richtung.
Dabei fiel ein schlafender Fehler auf: der Merker gegen Wiederholungen lag unter einem Schlüssel für alle Wachhunde. Mit zwei davon hätten sie sich gegenseitig überschrieben — einer meldet, der andere hält daraufhin seinen Zustand für neu. Der Merker hängt jetzt am Anlass.
Umbenennen im Verlauf. Stand dort als „verschoben". Technisch stimmt das,
für den Leser nicht: verschoben heißt „liegt woanders", umbenannt heißt „heißt
anders, liegt noch da" — und genau diesen Unterschied schlägt man im Verlauf
nach. Eigene Art mit eigenem Zeichen; die Filterleiste kennt jetzt auch
move und rename, die vorher gar nicht filterbar waren.
Und der Einzeiler im Fotostudio ist gesetzt: Upload.exists fragt über
/api/exists?pfad=… statt über den Pfad, mit dem Grund als Kommentar daneben.
Syntaktisch gegengeprüft (luac -p).
✅ Fertig — die Sicherung ist zurückgespielt worden
Bis hierhin war sie geprüft, nicht erprobt. sichern.ps1 packt das
Archiv aus, öffnet die Datenbank, zählt, lädt hoch, holt zurück und vergleicht
die Prüfsumme — das beantwortet „ist die Datei heil?". Es beantwortet nicht
„kommt daraus wieder ein Dienst?".
Dazwischen liegen Handgriffe, die man sonst zum ersten Mal an dem Abend macht,
an dem man sie unter Druck macht: wie das Volume heißt, dass der Container
stehen muss, wem die Dateien gehören müssen, dass thumbs/ und webp/
abgeleitet sind.
tools/zurueckspielen.ps1 macht beides — und die Übung ist der Standard:
Wegwerf-Volume, Archiv hinein, ein zweiter Container davor, drei zufällige
Dateien durch den Dienst geholt und ihr SHA-256 gegen die Datenbank
geprüft, danach alles abgeräumt. Der laufende Dienst wird nicht angefasst.
Am 11.08.2026 gelaufen, mit dem Archiv von 23:07:
4452 Medieneinträge, 316,3 MB, 2 Token, 2 Benutzer, 0 im Papierkorb
4452 Dateien -- gleich viele wie Einträge
ok items/velum2.png 113567 B
ok items/flesh-dolphin-2star.png 3623 B
ok items/burgermeat.png 4269 B
Die Uebung ist bestanden.
Der Ernstfall (-Echt) verlangt ein getipptes ZURUECKSPIELEN, hält den
Container an, ersetzt media.db, files/ und papierkorb/, löscht
thumbs/ und webp/ (Abgeleitetes stehenzulassen wäre schlimmer: die
Galerie zeigte die Vorschau einer Datei, die dort nicht mehr liegt), startet
wieder und zählt selbst nach.
✅ Fertig — /status für die Statusseite, und ein Fehler, den es dabei fand
Es gab schon /health, und es bleibt, wie es ist:
app.get('/health', (c) => c.json({ ok: true }))
Diese Zeile beweist genau eines: der Prozess nimmt Anfragen an. Daran hängt der
HEALTHCHECK des Containers, und dort ist billig richtig — eine schwere
Prüfung, die bei einer langsamen Platte einmal ausfällt, ließe Docker den
Container neu starten, also genau dann, wenn er unter Last steht.
Für eine Statusseite ist das zu dünn: sie stünde auf Grün, während die Platte
voll ist. /status sieht deshalb wirklich nach — Datenbank (echte Abfrage),
Platte (schreiben und wieder löschen), Ausliefern (zufällige Datei nachmessen),
Bestand, Platz, Sicherung. 200 wenn der Dienst sein Geschäft tut, 503
wenn nicht; ?streng=1 lässt auch eine Beeinträchtigung rot werden.
Öffentlich, aber wortkarg: keine Dateizahlen, Größen, Pfade, Tokennamen oder Benutzer. Und mit einer 10-Sekunden-Bremse — ein öffentlicher Endpunkt, der auf die Platte schreibt, wäre sonst ein Verstärker.
Beide Wege gemessen, der weiche am laufenden Dienst (Sicherungszeitpunkt auf 40 Stunden gesetzt und wieder zurück):
stand: beeintraechtigt · /status -> 200 · /status?streng=1 -> 503
Und dabei fiel ein Fehler in meinem eigenen Zurückspiel-Skript auf
Der harte Weg sollte an einem Wegwerf-Container geprüft werden. Der traf zufällig auf ein gebrauchtes Volume — und zeigte:
Zeilen in media.db |
|
|---|---|
mit den alten media.db-wal / -shm daneben |
0 |
| dieselbe Datei ohne sie | 4452 |
| Dateien auf der Platte | 4452 |
zurueckspielen.ps1 entfernte media.db, aber nicht die beiden
WAL-Begleiter. Die liegen bei einem echten Zurückspielen immer da — der
laufende Dienst arbeitet im WAL-Modus. SQLite spielt das WAL der alten
Datenbank über die neue, und heraus kommt der schlimmste denkbare Zustand:
der Dienst kommt hoch, /health ist grün, die Mediathek ist leer — während
alle Dateien danebenliegen.
Zwei Konsequenzen:
- Die Aufräumzeile steht jetzt an einer Stelle und nimmt
media.db-wal,media.db-shmund*.tmpmit. Übung und Ernstfall fahren denselben Befehl — zwei Fassungen wären zwei, von denen die geübte die harmlosere ist. - Die Übung täuscht jetzt eine bestehende Installation vor, bevor sie zurückspielt. In ein leeres Volume zu spielen probt den Fall, der nie eintritt.
==> Eine bestehende Installation vortaeuschen
im Volume liegt jetzt: files media.db media.db-shm media.db-wal
==> Zurueckspielen -- mit DEMSELBEN Befehl wie der Ernstfall
4452 Medieneintraege · 4452 Dateien -- gleich viele
ok items/shushi.png · items/weedbud_1.png · items/cc-castella.png
Die Uebung ist bestanden.
- Und
/statuserkennt diesen Zustand jetzt selbst. Am kaputten Container gemessen:
/health sagt: HTTP 200
/status sagt: HTTP 503 kein Eintrag in der Datenbank, aber Dateien auf der Platte
Genau dafür gibt es die Übung: gefunden beim Üben, nicht im Ernstfall.
⬜ Entschieden, noch nicht umgesetzt — der Name wird media.d4rkst3r.de
Der alte Name bleibt daneben stehen und zeigt auf denselben Dienst. Kein Umleiten, kein Stichtag: beide Namen funktionieren, und die Skripte lassen sich in Ruhe umstellen statt an einem Abend alle gleichzeitig.
Möglich ist das, weil FILES_HOST leer ist — der Dienst achtet gar nicht auf
den Hostnamen und antwortet unter jedem. Nachgemessen: GET /health mit
Host: media.d4rkst3r.de an Port 9101 gibt bereits 200.
Was der Wechsel kostet — gemessen, nicht geschätzt
In diesem Dienst: eine Variable. Von 4630 Medienpfaden und 15
Einstellungen enthält keine einzige den Hostnamen. Die Adressen werden bei
jeder Antwort aus PUBLIC_URL gebaut.
Außerhalb, vollständig durchsucht:
| Wo | Stellen |
|---|---|
d4rk_phone/config.lua |
imageUrl, brandLogoUrl |
d4rk_photostudio/config.upload.lua (+ .example) |
base |
d4rk_photostudio/settings.json |
zwei Bildquellen |
| d4rkbot, überwachte Dienste | zwei Einträge |
| Freigabe-Links | zwei, bereits herausgegeben |
Was du tun musst
1. DNS. Ein A-Eintrag media → 88.218.224.10, also dieselbe Adresse wie
fivemanage. Der Platzhalter *.d4rkst3r.de hilft nicht: er zeigt auf
185.194.237.210 und damit woandershin.
Und nicht über Cloudflare leiten.
hub.d4rkst3r.deantwortet von188.114.96.3(Cloudflare),fivemanage.d4rkst3r.dedirekt von88.218.224.10. Der neue Name gehört zur zweiten Sorte — graue Wolke. Sonst steht ein zweiter Zwischenspeicher davor, der nach Dateiendung greift, und wir haben genau die Probleme wieder, die der NPM-Block gerade gelöst hat: alte Vorschauen und ein weggeworfenesVary: Accept.
2. NPM. Neuer Proxy-Host media.d4rkst3r.de → host.docker.internal:9101,
Zertifikat holen — und den Inhalt von docs/npm-advanced.conf auch dort
einfügen. Ein neuer Host bringt seine eigene assets.conf mit.
3. Sagen. Dann setze ich PUBLIC_URL um und ziehe Dokumentation, Fotostudio,
Handy und die Watchdog-Einträge nach.
✅ Umgestellt am 12.08.2026
PUBLIC_URL=https://media.d4rkst3r.de. Beide Namen liefern weiter aus —
FILES_HOST ist leer, der Dienst achtet gar nicht auf den Hostnamen, und in
NPM stehen beide auf demselben Host. Nachgemessen: unter beiden Namen health 200 und dasselbe Bild.
Nachgezogen: 7 Stellen in den Ressourcen (d4rk_phone/config.lua ×2,
config.upload.lua, config.upload.example.lua ×2, settings.json ×2),
2 Watchdog-Einträge im Bot, 16 Stellen in Dokumentation und Werkzeugen.
Die Freigabe-Links zeigen von selbst auf den neuen Namen — sie werden aus
PUBLIC_URL gebaut — und die alten funktionieren weiter.
Der Lua-Syntaxtest schlug bei
d4rk_phone/config.luafehl, und zwar an Zeile 93:customModel = `prop_player_phone_02`. Das ist kein Schaden, sondern eine CfxLua-Erweiterung — Backticks sind dort ein joaat-Hash und in Standard-Lua 5.4 ein Syntaxfehler. 85 davon stecken in der Datei.luac -ptaugt für CfxLua-Dateien also nicht; geprüft wurde stattdessen, dass genau die zwei URL-Zeilen anders sind.
⚠️ Und die Falle auf dem Weg dorthin: Cloudflare
Am 12.08.2026 eingerichtet. NPM ist dabei sogar besser gelöst als vorgeschlagen:
beide Namen auf demselben Host (server_name fivemanage.d4rkst3r.de media.d4rkst3r.de;), die drei ^~-Blöcke gelten damit für beide.
Der DNS-Eintrag geht aber über Cloudflare, und das bricht die WebP-Auslieferung. Gemessen, dieselbe Datei, dieselbe Sekunde:
fivemanage (direkt) ohne Accept -> image/png 12295 B richtig
mit Accept -> image/webp 2270 B richtig
media (Cloudflare) 1. mit Accept -> image/webp 2270 B MISS
2. mit Accept -> image/webp 2270 B HIT
3. OHNE Accept -> image/webp 2270 B HIT ← FALSCH
Und andersherum genauso: wer zuerst kommt, prägt den Zwischenspeicher für alle.
Es sind unsere eigenen Bytes (2270 B = unsere WebP-Fassung), also kein
Cloudflare-Polish, sondern ein Cache-Schlüssel ohne Accept. Vary: Accept
wird an den Client durchgereicht, aber nicht zum Unterscheiden benutzt —
Cloudflare variiert standardmäßig nur über Accept-Encoding.
Für ein Lua-Skript heißt das: items/foo.png liefert WebP-Bytes unter einem
.png-Namen, und das fällt erst auf, wenn irgendwo ein Bild nicht lädt.
Gelöst durch die graue Wolke — Cloudflare-Proxy aus, DNS-only, wie bei
fivemanage. Danach gemessen, dieselben Dateien:
media (grau) ohne Accept -> image/png 12295 B
mit Accept -> image/webp 2270 B
Server: openresty · vary: Accept · dreimal hintereinander stabil
Bytes identisch mit dem alten Namen (sha256 stimmt überein)
Was das Umstellen der URLs in den Skripten NICHT gelöst hätte: das Problem
saß am Host, nicht am Aufrufer. Hätten alle Skripte auf media gezeigt und
media wäre orange geblieben, bekämen alle die falschen Bytes — die
Umstellung hätte es verteilt statt behoben. Wer Cloudflare behalten will,
braucht dort eine Cache Rule, die /f/* und /t/* ausnimmt.
Gegenprüfen lässt sich beides mit dem, was schon da ist:
pwsh -File tools\proxy-pruefen.ps1 -Basis https://media.d4rkst3r.de -Token d4rk_...
✅ Fertig — Verlauf als CSV, Meldung bei Upload, Deckel auf dem Papierkorb
Abschnitt 3 aus docs/ideen.md, der Rest der Liste bis auf die Sicherungsziele.
Verlauf als CSV. GET /api/dash/verlauf/export, wahlweise
?was=verwaltung. Der Knopf sitzt in der Reiterleiste und holt den Verlauf,
der gerade offen ist — zwei Knöpfe nebeneinander wären die Frage „welchen
von beiden?" an einer Stelle, wo die Antwort schon auf dem Bildschirm steht.
Semikolon und CRLF wie beim Bestands-Export; das war dort nachgemessen worden,
hier wäre es ein zweiter Anlauf gewesen. Gemessen an der ausgelieferten Datei:
5364 Zeilen, 5364 CRLF, kein einziges nacktes LF, Dateiname
d4rk_media-dateien-2026-08-12.csv.
Deckel auf dem Papierkorb. 30 Tage und 20 GB — was zuerst greift,
greift. Beim Ausleeren fliegt erst das zu Alte, dann das Älteste, bis die
Grenze wieder passt; dessen Versehen wäre am ehesten schon aufgefallen. Die
Zahl steht als maxBytes in der Antwort von /api/dash/papierkorb, damit die
Oberfläche sie nicht ein zweites Mal hinschreibt — gemessen: 21474836480.
Bei 910 GB frei ist das keine Grenze, sondern eine Zusicherung: der Papierkorb kann nicht mehr unbemerkt zur zweiten Ablage werden.
Meldung bei Upload — die Drossel ist der eigentliche Inhalt. Jeder Upload schiebt eine Frist von zwei Minuten nach hinten; erst nach der Ruhe geht eine Nachricht raus, mit Anzahl, Summe, Absendern und den ersten fünf Pfaden.
Ohne das wäre die Funktion ein Schaden: 900 Fahrzeugbilder ergäben 900 Nachrichten, Discord drosselt Webhooks, und wer danach eine echte Meldung bekommt, sieht sie nicht mehr.
Und derselbe Fehler wie bei platte lauerte schon wieder: die Liste der
Anlässe steht an zwei Stellen — im Server und in der Oberfläche —, und der
Server verwirft beim Speichern alles, was in seiner Liste fehlt. Ein
Kreuzchen, das sich setzen lässt und beim nächsten Laden weg ist. Diesmal
beide Stellen angefasst und nachgemessen: vier geschickt, vier
gespeichert. In beiden steht jetzt ein Verweis auf die andere.
✅ Fertig — die Sicherung liegt an drei Orten statt an zweien
Der letzte offene Punkt aus docs/ideen.md. Ein zweites Laufwerk,
einstellbar im Panel unter Einstellungen → Sicherung; tools/sichern.ps1 legt
das Archiv dort ab, vergleicht die Prüfsumme und dünnt auch dort aus.
Warum das nicht dasselbe ist wie die Nextcloud. Die hilft, wenn der ganze Rechner weg ist — aber wer 335 MB, später 100 GB, zurückholen muss, lädt sie über die Leitung. Das zweite Laufwerk ist in Minuten zurückgespielt. Zwei Fragen, zwei Antworten.
Und es warnt, wenn es dieselbe Platte ist. Das ist der eigentliche Inhalt:
ein zweiter Ordner auf C: sieht im Panel genauso grün aus wie eine echte
zweite Platte und hilft gegen gar nichts. Verglichen wird die physische
Platte und nicht der Laufwerksbuchstabe — zwei Partitionen derselben NVMe
sterben zusammen. Nachgemessen, dieser Rechner:
Platte 0 SAMSUNG MZVL21T0HDLU-00B07 954 GB -> C: (Daten + C:\backup)
Platte 1 SAMSUNG MZVL21T0HDLU-00B07 954 GB -> D: (das zweite Ziel)
C:\backup gegen D:\backup -> Nr. 0 gegen Nr. 1 -> "andere Platte"
C:\backup gegen C:\Users -> Nr. 0 gegen Nr. 0 -> Warnung
Ein echter Lauf, kein Trockentest: 4630 Einträge, 335,9 MB, auf beiden lokalen
Zielen und in der Nextcloud dieselbe Prüfsumme FFCE4D0A7310…. Der Bericht in
sicherung.letzte trägt jetzt zweit: true; fehlt das Feld, ist kein
zweites Ziel eingerichtet — es steht absichtlich nicht als false da, denn das
läse sich wie „hat nicht geklappt".
Die Statusseite zählt die Orte mit: sicherung ok an 3 Orten statt nur
ok. Eine Sicherung, die es nur einmal gibt, ist grün und trotzdem eine, die
ein Plattenausfall mitnimmt — das gehört dorthin, wo jemand hinsieht.
Und die Übung ist aus der neuen Kopie gefahren worden, nicht nur aus der alten — sonst wäre das zweite Ziel eine Datei und kein Sicherungsziel:
pwsh -File tools\zurueckspielen.ps1 -Ordner D:ackup\d4rk_media
4630 Medieneintraege, 340.5 MB, 2 Token, 2 Benutzer, 0 im Papierkorb
4630 Dateien -- gleich viele wie Eintraege
drei zufaellige Dateien DURCH den Dienst geholt: identische Bytes
Die Uebung ist bestanden.
tools/zurueckspielen.ps1 sagt im Kopf, was zu tippen ist, wenn genau
diese Platte das Problem ist. Das ist der Fall, für den das Ganze da ist, und
niemand soll ihn um vier Uhr nachts erst herleiten müssen.
⚠️ Repariert — die geplante Aufgabe zeigte auf einen Pfad mit Versionsnummer
Gefunden beim Nachsehen, ob die Sicherung überhaupt läuft. Sie lief (12.08.2026
04:30:01, Ergebnis 0) — aber sie startete pwsh.exe so:
C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.4.0_x64__8wekyb3d8bbwe\pwsh.exe
Die Versionsnummer steht im Pfad. Beim nächsten PowerShell-Update heißt der
Ordner anders, die Aufgabe findet ihre pwsh.exe nicht mehr, und die Sicherung
hört auf zu laufen — an einem Tag, an dem niemand etwas an ihr geändert hat.
Der Wächter hätte es nach 26 Stunden gemeldet; angenehmer ist es, wenn es gar
nicht erst passiert.
Ersetzt durch den stabilen Ausführungsalias, der Updates überlebt:
C:\Users\Darkster\AppData\Local\Microsoft\WindowsApps\pwsh.exe
Der geht, weil die Aufgabe als Darkster und Interactive läuft — ein Alias
unter LOCALAPPDATA wäre für einen Dienst unter SYSTEM wertlos gewesen. Vorher
nachgemessen: derselbe Prozess, dieselbe Fassung 7.6.4, Rückgabewert 0.
Danach die Aufgabe von Hand gestartet und den ganzen Weg gemessen, nicht nur den Start:
Zustand: Ready Ergebnis: 0
sicherung.letzte: ok: true | zweit: true | hoch: true | 4630 Dateien
Die Argumente sind unverändert geblieben; die Aufgabe heißt weiterhin
d4rk_media Sicherung und läuft weiterhin täglich um 04:30.
✅ Fertig — ein Rundgang durch den laufenden Dienst
python tools/rundgang.py — 24 Prüfungen über HTTP gegen den Dienst, so wie er
gerade läuft. Nur lesend: kein Upload, kein Löschen, keine Einstellung wird
angefasst.
Wozu, wo doch jede Änderung einzeln gemessen wird: weil sie einzeln gemessen wird. An einem Tag mit sechs Neustarts fasst niemand mehr alles an, und die Sachen, die keiner mehr anfasst, sind genau die, die kaputtgehen — ein WebP, das nach einem Umbau nicht mehr ausgehandelt wird, meldet sich nicht.
Zuerst kommt, was ohne Anmeldung dicht sein muss (acht Wege, alle 401). Das ist die Sorte Fehler, die man nicht sieht: eine Route, die versehentlich vor dem Wächter registriert wird, funktioniert prächtig — für alle. Genau das ist in diesem Repo schon zweimal passiert.
Dann: Bestand, /f/ mit WebP-Aushandlung an derselben Adresse und Vary: Accept, beide CSV-Exporte auf nackte LF, Papierkorb-Deckel, Sicherungsziele
und die Statusseite.
Zwei Fehlschläge beim Bauen, beide im Test und nicht im Dienst — und deshalb stehen sie als Kommentar drin:
- Ohne Filter zog er
vehicles/asbo.webpund verglich WebP mit WebP. Gleiche Größe, Test rot, Dienst richtig. - Der Filter hieß dann
q=stattquery=. Unbekannte Parameter ignoriert der Dienst und liefert alles — was wie ein kaputter Filter aussieht und ein kaputter Aufruf ist.
Ein Test, der das Falsche misst, ist schlimmer als keiner: er wird geglaubt.
Stand heute: 24 in Ordnung, 0 nicht.
⚠️ Richtigstellung: „drei Orte" sind zwei Platten, und keiner davon ist auswärts
Am 12.08.2026 nachgemessen, nachdem die Frage aufkam, wie man Sicherungen aus dem Haus bekommt. Das Ergebnis widerspricht dem, was zwei Abschnitte weiter oben steht.
Alle Docker-Volumes liegen in einer einzigen Datei:
C:\Users\Darkster\AppData\Local\Docker\wsl\disk\docker_data.vhdx 51,5 GB
Das ist Platte 0. Darin liegen die Daten von d4rk_media, die Datenbank des Bots
— und die Nextcloud. cdn.d4rkst3r.de und media.d4rkst3r.de lösen beide
auf 88.218.224.10 auf, und die vierzehn nextcloud-aio-* Container laufen auf
genau dieser Maschine.
| Ziel | Platte | wirklich getrennt? |
|---|---|---|
C:\backup\d4rk_media |
0 | nein, dieselbe wie die Daten |
D:\backup\d4rk_media |
1 | ja |
Nextcloud cdn.d4rkst3r.de |
0 | nein — dieselbe Platte wie die Daten |
Die Nextcloud-Kopie ist die am wenigsten getrennte von allen dreien. Sie stand hier bisher als „die einzige Trennung" — das war falsch, und zwar aus dem üblichen Grund: eine Adresse mit eigenem Namen und eigenem Zertifikat sieht wie ein anderer Ort aus. Nachgesehen hatte das niemand.
Was die drei Ziele wirklich abdecken:
- Eine Platte stirbt →
D:fängt es auf. Das ist echt und war den Aufwand wert. - Der Rechner stirbt, brennt, wird gestohlen oder verschlüsselt → alle drei Kopien sind gleichzeitig weg.
Die Statusseite sagt weiterhin sicherung ok an 3 Orten. Das ist wörtlich
richtig — sie zählt Ablageorte, nicht Platten und nicht Gebäude. Wer daraus
liest „also ist es sicher", liest mehr hinein, als dasteht.
Und wo der Rechner steht, gehört dazu: ein Windows-Root-Server im Rechenzentrum, bedient von zu Hause über mRemoteNG. Das macht ein Ziel beim Betreiber daheim nicht zu einem halben Schritt, sondern zum richtigen — zwei Gebäude, zwei Netze, zwei Anbieter.
Dazu steht auf der Serverseite schon alles bereit: sshd läuft (Automatic,
0.0.0.0:22), die Firewall lässt ihn durch, das SFTP-Subsystem ist an. Es
fehlt ein Konto, das nur lesen darf — und ein Skript auf dem Heimrechner, das
holt. Kandidaten und Begründung in docs/ideen.md.
⬜ Als Nächstes
Der Vergleichsmaßstab ist weg. Am 11.08.2026 sind die Lite-Container gelöscht worden — mit ihnen die Möglichkeit, im Zweifel nachzusehen, wie es dort aussah. Was noch liegt und eine Entscheidung braucht:
Volumes fivemanager_db fivemanager_minio fivemanager_pgdata
Abbilder ghcr.io/fivemanage/lite:0.1.0-beta.23 minio/minio minio/mc
Nachgesehen, bevor jemand fragt: es ist nichts drin. Alle drei Volumes
schreibgeschützt eingehängt und durchgezählt — kein einziges Bild.
fivemanager_minio sind 244 KB und besteht ausschließlich aus .minio.sys;
der Eimer media hat Metadaten, aber kein einziges Objekt. Die anderen beiden
sind leere Datenbanken (MySQL 196 MB, PostgreSQL 47 MB — das ist ihr
Leergewicht). Löschen kostet nichts, es ist nur noch nicht getan.
⬜ Später, wenn es sich lohnt
- Vorschaubilder serverseitig (spart dem Dashboard das Laden von 900 Vollbildern)
- Namensräume je Ressource, mit eigenem Token und eigener Statistik
- Spieler-Metadaten an einem Upload (wer, wann, welcher Server)
- Aufräumregeln: was älter als X ist und zu keinem Datensatz gehört
- Mehrere Benutzer statt nur
admin
Offene Entscheidungen
Sicherung.Entschieden und gebaut —tools/sichern.ps1, täglich um 04:30, siehe oben. Weder Restic nochdocker cp, sondern SQLites eigene Sicherungsschnittstelle plus eintar.gzaus einem nur lesend eingehängten Volume. Die Vermutung von damals war richtig: ein blosses Kopieren wäre wegen des WAL still unvollständig gewesen. Offen bleibt das Ziel: die Archive liegen auf derselben Platte wie die Daten. Gegen einen Plattenausfall hilft das nicht — dafür müssten sie in die Nextcloud oder auf eine andere Maschine.- Bilder aus dem Spiel (Screenshots, Clips von Spielern) — dafür brauchte es Größenbegrenzungen je Token und vermutlich eine Warteschlange. Erst planen, wenn es ansteht.
Was aus dem Fivemanage-Anlauf hängen bleibt
Steht ausführlich im README, hier nur die Kurzfassung — es sind alles Dinge, die uns wieder begegnen:
- Portainer nimmt unter
Compose patheine Datei. Ergänzungen werden stillschweigend übergangen: alle Container gesund, alles 502. - Host-Ports an
0.0.0.0binden, nicht an127.0.0.1. NPM liegt im Standard-Bridge-Netz und kommt über die Docker-Brücke, nicht über Loopback. Zu macht der Port die Firewall. - Belegt auf diesem Server: 80, 81, 443, 2224, 3000, 3080, 3478, 8000, 8080, 8090, 9000, 9443, 11000. Unsere: 9100 und 9101.
9101:8080ist keine Kollision. Nur die linke Zahl muss einmalig sein.- Ein Dienst, der ohne Konfiguration hochfährt, kostet mehr Zeit als
einer, der sich weigert. Deshalb bricht
config.tsbeim Start ab.