diff --git a/ROADMAP.md b/ROADMAP.md index cbd8a34..1e54c40 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1235,6 +1235,51 @@ Dazu steht auf der Serverseite schon alles bereit: `sshd` läuft (Automatic, fehlt ein Konto, das nur lesen darf — und ein Skript auf dem Heimrechner, das **holt**. Kandidaten und Begründung in `docs/ideen.md`. +### ✅ Fertig — die Archive lassen sich aus dem Panel holen + +Einstellungen → **Sicherungen holen**: die fertigen Archive mit Datum, Größe +und einem Knopf. Der erste Schritt, der eine Kopie **von diesem Rechner +herunter** bringt — bisher hätte man sich dafür per Fernwartung auf den Server +klicken müssen, und genau daran hat es gelegen. Nicht Unwille, sondern +Umständlichkeit. + +**Der Ordner hängt nur lesend.** Ein Dienst, der seine eigenen Sicherungen +löschen kann, ist genau das Werkzeug, das man einem übernommenen Dienst nicht +in die Hand geben will. Herausgeben reicht. Nachgemessen im Container: + +``` +touch /sicherungen/probe -> Read-only file system +``` + +**Und es gibt nur eine Form von Namen** — nicht „alles außer `..` und `/`", +sondern ein Muster, das genau auf die eigenen Archive passt. Wer einen Pfad +hineinschmuggeln will, müsste ihn erst so benennen. Das ist der Unterschied +zwischen dicht gemacht und dicht gebaut: + +``` +../../etc/passwd -> 404 +..%2f..%2fdata%2fmedia.db -> 400 +media.db -> 400 +d4rk_media-…-0854.tar.gz.evil -> 400 +ohne Anmeldung -> 401 +``` + +Der ganze Weg einmal wirklich gegangen, nicht nur die Kopfzeilen: + +``` +336 MB in 15,9 s durch den Proxy (~21 MB/s) +sha256 auf der Platte == sha256 heruntergeladen +tar tzf -> 4639 Eintraege +``` + +Jeder Griff steht im **Verwaltungs-Verlauf** (`sicherung | geholt | | + | `). Geloggt wird der Griff und nicht der Erfolg: ein +abgebrochener Download taucht dort ebenfalls auf, und das ist so gewollt — wer +später fragt, wer sich den Bestand geholt hat, will die Anfrage sehen. + +Die fünf neuen Prüfungen sind in `tools/rundgang.py` mit drin; der steht jetzt +bei **29 in Ordnung, 0 nicht**. + ### ⬜ Als Nächstes **Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container diff --git a/docker-compose.yml b/docker-compose.yml index c3526a5..83da8f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,17 @@ services: # genau ein Kopiervorgang. - media-data:/data + # Die fertigen Archive, damit man sie sich aus dem Panel holen kann, + # ohne sich per Fernwartung auf den Server zu klicken. Genau das war + # der Grund, aus dem es lange keine Kopie ausserhalb dieser Maschine + # gab: nicht Unwillen, sondern Umstaendlichkeit. + # + # DAS :ro AM ENDE IST DER WICHTIGE TEIL. Ein Dienst, der seine + # eigenen Sicherungen loeschen kann, ist genau das Werkzeug, das man + # einem uebernommenen Dienst nicht in die Hand geben will. + # Herausgeben reicht. + - ${BACKUP_DIR:-C:/backup/d4rk_media}:/sicherungen:ro + ports: # Links der Host-Port, rechts der im Container. Nur die LINKE Zahl # muss auf diesem Server einmalig sein. diff --git a/docs/ideen.md b/docs/ideen.md index 6e1bf9c..6e57642 100644 --- a/docs/ideen.md +++ b/docs/ideen.md @@ -158,6 +158,14 @@ Sicherungsziel kaum. | **Discord-Kanal** (nur der Bot) | dasselbe | 9 MB je Datei — für d4rk_media mit 336 MB unbrauchbar | | 5-TB-Platte **am Server** | nur Plattenausfall | dritte Platte im selben Gehäuse — `D:` kann das schon | +### Der erste Schritt ist gemacht: von Hand holen + +Einstellungen → **Sicherungen holen** listet die Archive und gibt sie heraus +(nur lesend eingehängt, Namensmuster statt Filter, jeder Griff im +Verwaltungs-Verlauf). Das ersetzt keine geplante Abholung — aber es macht die +Auswärtskopie zu etwas, das man im Vorbeigehen erledigt, statt zu einem +Vorhaben. Was noch fehlt, ist, dass es **von allein** passiert. + ### Warum HOLEN und nicht SCHICKEN Ein Ziel, in das der Server selbst schreiben darf, ist auch eines, das ein diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index fb38f4d..89724eb 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -41,6 +41,7 @@ import { } from '../auth.js' import { mkdir, readdir, readFile, rm, rmdir, stat } from 'node:fs/promises' import { join } from 'node:path' +import { createReadStream } from 'node:fs' import { DISCORD_KEYS, MARKE_KEY, @@ -1938,6 +1939,79 @@ const GEHEIM = new Set([DISCORD_KEYS.clientSecret, SICHERUNG_KEYS.passwo * behaupten, die es dort nie gab. */ const NICHT_MITNEHMEN = [/^sicherung\.letzte$/, /^discord\.zuletzt/] +// ------------------------------------------------------- Sicherungen holen +// +// WOZU. Die Archive liegen auf dem WIRT, nicht im Container -- und wer sie +// woanders haben will, musste sich bisher per Fernwartung auf den Server +// klicken und sie von Hand ziehen. Das ist der Grund, aus dem es keine Kopie +// ausserhalb dieser Maschine gibt: nicht Unwillen, sondern Umstaendlichkeit. +// +// DER ORDNER HAENGT NUR LESEND. Ein Dienst, der seine eigenen Sicherungen +// loeschen kann, ist genau das Werkzeug, das man einem uebernommenen Dienst +// nicht in die Hand geben will. Herausgeben reicht. +// +// UND ES GIBT NUR EINE FORM VON NAMEN. Nicht "alles ausser .. und /", sondern +// ein Muster, das genau auf die eigenen Archive passt -- wer einen Pfad +// hineinschmuggeln will, muesste ihn erst so benennen. Das ist der Unterschied +// zwischen "dicht gemacht" und "dicht gebaut". +const SICHERUNGEN_DIR = process.env.SICHERUNGEN_DIR || '/sicherungen' +const ARCHIV_MUSTER = /^d4rk_media-\d{4}-\d{2}-\d{2}-\d{4}\.tar\.gz$/ + +dashRoutes.get('/sicherungen', async (c) => { + let namen: string[] + try { + namen = await readdir(SICHERUNGEN_DIR) + } catch { + // Nicht eingehaengt ist kein Fehler, sondern ein Zustand -- die + // Oberflaeche sagt dann, was zu tun ist, statt eine rote Meldung zu + // zeigen, die nach Defekt aussieht. + return c.json({ eingehaengt: false, items: [], ordner: SICHERUNGEN_DIR }) + } + + const items = [] + for (const name of namen.filter((n) => ARCHIV_MUSTER.test(n))) { + try { + const s = await stat(join(SICHERUNGEN_DIR, name)) + items.push({ name, size: s.size, at: s.mtimeMs }) + } catch { + /* zwischendurch weggeraeumt -- dann gehoert es nicht in die Liste */ + } + } + items.sort((a, b) => b.at - a.at) + + return c.json({ + eingehaengt: true, + ordner: SICHERUNGEN_DIR, + items, + bytes: items.reduce((n, i) => n + i.size, 0), + }) +}) + +dashRoutes.get('/sicherungen/:name', async (c) => { + const name = c.req.param('name') + if (!ARCHIV_MUSTER.test(name)) { + return c.json({ error: 'Kein Sicherungsarchiv.' }, 400) + } + + const pfad = join(SICHERUNGEN_DIR, name) + let groesse: number + try { + groesse = (await stat(pfad)).size + } catch { + return c.json({ error: 'Gibt es nicht (mehr).' }, 404) + } + + // Gestroemt und nicht in den Speicher gelesen: das Archiv ist 336 MB, und + // die haetten sonst der Dienst UND der Proxy gleichzeitig im Arbeitsspeicher. + logVerwaltung(c.get('user').username, 'sicherung', 'geholt', name, `${groesse} Bytes`) + return c.body(createReadStream(pfad) as any, 200, { + 'Content-Type': 'application/gzip', + 'Content-Length': String(groesse), + 'Content-Disposition': `attachment; filename="${name}"`, + 'Cache-Control': 'no-store', + }) +}) + dashRoutes.get('/einstellungen/export', (c) => { const mitGeheim = c.req.query('geheim') === '1' const alle = db.prepare('SELECT key, value FROM settings ORDER BY key').all() as { diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 33d83df..5336f6c 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -230,6 +230,13 @@ export type Uebersicht = { vorlagen: Vorlage[] } +export type Sicherung = { + eingehaengt: boolean + ordner: string + bytes: number + items: { name: string; size: number; at: number }[] +} + export type Vorlage = { name: string; muster: string } /** Ein Konto. `weg` sagt, worueber jemand hereinkommt — die wichtigere Auskunft @@ -480,6 +487,7 @@ export const api = { ), einstellungen: () => get('/api/dash/settings'), + sicherungen: () => get('/api/dash/sicherungen'), sicherungSpeichern: (sicherung: Record) => post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }), meldungenSpeichern: (meldungen: { webhook?: string; anlaesse?: string[] }) => diff --git a/server/ui/src/components/SicherungenKarte.tsx b/server/ui/src/components/SicherungenKarte.tsx new file mode 100644 index 0000000..b89437b --- /dev/null +++ b/server/ui/src/components/SicherungenKarte.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from 'react' +import { Download, HardDriveDownload, Loader2 } from 'lucide-react' +import { api, type Sicherung } from '../api' +import { useStore } from '../store' +import { bytes, zeit } from '../format' +import { Karte } from './Bausteine' + +/** Die fertigen Archive herunterladen. + * + * WOZU: die Sicherung lief längst, aber sie lag ausschließlich auf **dieser** + * Maschine — auf zwei Platten, und die dritte Kopie in der Nextcloud läuft + * auch hier. Wer sie woanders haben wollte, musste sich per Fernwartung auf + * den Server klicken und sie von Hand ziehen. Das ist der Grund, aus dem es + * keine Kopie außerhalb gab: nicht Unwillen, sondern Umständlichkeit. + * + * Ein Knopf hier ersetzt keine geplante Abholung — aber er macht die + * Auswärtskopie zu etwas, das man im Vorbeigehen erledigt, statt zu einem + * Vorhaben. */ +export function SicherungenKarte() { + const note = useStore((s) => s.note) + const [daten, setDaten] = useState(null) + const [laedt, setLaedt] = useState(true) + + useEffect(() => { + api.sicherungen() + .then(setDaten) + .catch((err) => note('fehler', err instanceof Error ? err.message : 'Unbekannt')) + .finally(() => setLaedt(false)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (laedt) { + return ( + +
+ +
+
+ ) + } + + return ( + + {daten.items.length} Stück · {bytes(daten.bytes)} + + ) : undefined + } + > + {!daten?.eingehaengt ? ( +

+ Der Sicherungsordner ist nicht eingehängt. In der{' '} + docker-compose.yml braucht der + Dienst{' '} + + {daten?.ordner ?? '/sicherungen'} + {' '} + — nur lesend. +

+ ) : daten.items.length === 0 ? ( +

+ Noch keine Archive in {daten.ordner}. + Die Sicherung läuft täglich um 04:30. +

+ ) : ( + <> +

+ + + Alle Kopien liegen auf dieser{' '} + Maschine — auch die in der Nextcloud, die hier mitläuft. Ein Archiv, + das du herunterlädst, ist das erste, das den Server verlässt. + +

+ + + + )} +
+ ) +} diff --git a/server/ui/src/pages/Einstellungen.tsx b/server/ui/src/pages/Einstellungen.tsx index be7e835..9a8852d 100644 --- a/server/ui/src/pages/Einstellungen.tsx +++ b/server/ui/src/pages/Einstellungen.tsx @@ -14,6 +14,7 @@ import { bytes, zeit } from '../format' import { Feld, Karte, Knopf } from '../components/Bausteine' import { DiscordKarte } from '../components/DiscordKarte' import { VorlagenKarte } from '../components/VorlagenKarte' +import { SicherungenKarte } from '../components/SicherungenKarte' import { FreigabenKarte } from '../components/FreigabenKarte' import { BenutzerKarte } from '../components/BenutzerKarte' import { MitnehmenKarte } from '../components/MitnehmenKarte' @@ -46,6 +47,7 @@ export function Einstellungen() { + diff --git a/tools/rundgang.py b/tools/rundgang.py index 40267b2..f7fdb26 100644 --- a/tools/rundgang.py +++ b/tools/rundgang.py @@ -89,7 +89,8 @@ if r.stdout != '200': print('\n-- Was ohne Anmeldung dicht sein muss ' + '-' * 32) for weg in ('/api/dash/media', '/api/dash/tokens', '/api/dash/verlauf/export', '/api/dash/papierkorb', '/api/dash/benutzer', '/api/dash/settings', - '/api/dash/einstellungen/export', '/api/dash/verwaltung'): + '/api/dash/einstellungen/export', '/api/dash/verwaltung', + '/api/dash/sicherungen'): code, *_ = curl(f'{BASIS}{weg}', keks=False) pruefe(weg, code == '401', f'HTTP {code}') @@ -155,6 +156,23 @@ for anhang, name in (('', 'dateien'), ('?was=verwaltung', 'verwaltung')): pruefe(f'CSV {name}', name in r.stdout and nackte == 0, f'{roh.count(CRLF)} Zeilen, {nackte} nackte LF') +# -- Die Archive zum Mitnehmen ------------------------------------------------- +print(chr(10) + '-- Die Archive zum Mitnehmen ' + '-' * 40) +code, _, _, rumpf = curl(f'{BASIS}/api/dash/sicherungen') +sich = json.loads(rumpf) +pruefe('Sicherungsordner eingehaengt', sich.get('eingehaengt') is True, + f"{len(sich.get('items', []))} Archive in {sich.get('ordner')}") + +# Der Weg nach draussen ist der, den man pruefen MUSS: dort liegt der ganze +# Bestand als eine Datei, und ein Muster, das zu viel durchlaesst, gaebe jede +# Datei des Servers heraus. Geprueft wird deshalb, was NICHT gehen darf. +for boese, was in (('../../etc/passwd', 'Pfad hoch'), + ('..%2f..%2fdata%2fmedia.db', 'kodiert hoch'), + ('media.db', 'andere Datei'), + ('d4rk_media-2026-01-01-0000.tar.gz.evil', 'Anhang drangehaengt')): + code, *_ = curl(f'{BASIS}/api/dash/sicherungen/{boese}') + pruefe(f'abgewiesen: {was}', code in ('400', '404'), f'HTTP {code}') + # -- Papierkorb und Sicherung -------------------------------------------------- print('\n-- Papierkorb und Sicherung ' + '-' * 42) code, _, _, rumpf = curl(f'{BASIS}/api/dash/papierkorb')