Record upload IP and add dated administrative key reports

This commit is contained in:
Sucukdeluxe
2026-09-22 04:57:09 +02:00
parent 76bd13a03d
commit aeff3193e6
8 changed files with 103 additions and 8 deletions
+5
View File
@@ -8,6 +8,11 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
## Zuletzt verifizierter Stand
- 22. September 2026, nach v2.0.91: Upload-IP als administrative Metadaten für neue Downloader-Online-Sicherungen implementiert; noch nicht produktiv aktiviert. Arbeitsbranch `fix/backup-source-ip`. `sourceIp` wird ausschließlich aus der serverseitig erkannten Request-Adresse ermittelt, nicht aus Nutzdaten. Produktiv ist der Dienst nur über Loopback erreichbar; Nginx überschreibt `X-Forwarded-For` mit `$remote_addr` (read-only geprüft). Bei Proxy/VPN ist dies ggf. deren Ausgangs-IP. Keine rückwirkende Zuordnung bei bestehenden Sicherungen, keine Ausgabe der IP über die öffentliche Restore-API.
- `recovery-admin.mjs list` liefert zusätzlich `sourceIp` (Legacy: null). `recover ... --with-metadata` schreibt `TT.MM.JJJJ - HH:mm | IP: … | Schlüssel: MDD2-…` ausschließlich in eine neue geschützte Datei; Zeit der Sicherung in `Europe/Berlin`, Sommer-/Winterzeit berücksichtigt. Ohne Option bleibt die bisherige reine Schlüsseldatei unverändert. Alte Sicherungen zeigen `IP: unbekannt`; ungültige Metadaten werden nicht als IP ausgegeben. Kein neues Windows-Update nötig, keine Änderung an Download/Entpacken oder Uploader.
- Verifiziert: TypeScript, elf Client-/Service-Integrationstests und alle 22 Server-Tests erfolgreich, inklusive IPv4/IPv6, fehlender/ungültiger IP, ignoriertem Forwarding-Header ohne Proxy-Vertrauen, unveränderlichen Metadaten bei doppeltem Upload, öffentlicher API-Abgrenzung, Datum/Sommer-/Winterzeit, Admin-Rohformat und formatierter Ausgabe ohne Schlüssel in Logs.
- Live-Bestand vor Aktivierung: 14 Sicherungen samt bisherigem Dienst, Env, Systemd-Unit und Drop-in gesichert nach `/root/mdd-recovery-preflight-R9Egj0/rollback.tar.gz`, SHA-256 `f43b1a74d35a179e4557a98425c2c7d31dd1c70ba72218636aa68a3064cbb5ff`. Archiv probeweise wiederhergestellt und Datensätze/Konfiguration verifiziert. Kandidat unter `/root/mdd-recovery-preflight-R9Egj0/candidate`; Staging `/tmp/mdd-recovery-stage-MFCvVd`, alle 22 Server-Tests unter Node 20 und HTTP-/Admin-Roundtrip mit IP-Bericht erfolgreich; 14 Live-Datensätze unverändert. Konkrete Freigabe zur Umstellung ausschließlich von `mdd-backup-api.service` auf den IP-Metadatenstand ist angefragt; noch kein Neustart dafür erfolgt.
- Abgeschlossen am 22. September 2026: `v2.0.91` auf GitHub und Forgejo veröffentlicht, Tag auf beiden Remotes exakt `0aa8c7ac4024f5739ef7e6c2c350ad0a934bd296`. Titel und neuester stabiler Release identisch; GitHub-Changelog Englisch, Forgejo-Changelog Deutsch. Alle zwölf öffentlichen Assets vollständig erneut heruntergeladen und Größen/SHA-256 mit den lokalen Originalen verglichen. Beide `latest.yml` bestätigen Version 2.0.91 und Installer-SHA-512. Setup-SHA-256 `0cabc0206300a3060f7d5028a6e8020772673d31267bfb55e552998bc9245369`, Portable-SHA-256 `1b57b016ec01c62e55d76bca802b5b5b2b2610fb87e478e9d1e66f369eecf00d`. Lokale Originale unter `release/staging-v2.0.91`; Source-ZIP mit 7-Zip geprüft und Git-Kommentar entspricht dem Tag-Commit.
- Abschließende Live-Prüfung: ausschließlich Downloader umgestellt, Dienst aktiv und öffentliche Recovery-Konfiguration erreichbar; ursprüngliche zwölf Datensätze bytegenau unverändert. Während der Arbeit kam unabhängig ein weiterer Datensatz ohne Recovery-Kopie hinzu und wurde unverändert belassen (13 insgesamt bei Abschluss). Schlüsselwiederherstellung benötigt einen neuen Export mit Client ab 2.0.91; alte Exporte bleiben nicht nachträglich wiederherstellbar. Windows-Anwendung auf Saschas Rechner nicht installiert oder neu gestartet. Desktop-Anleitung mit konkreten SSH-Befehlen, Zwischenablagehinweisen und verschlüsseltem Off-Server-Backup aktualisiert.
+11 -1
View File
@@ -4,6 +4,8 @@ Die API speichert clientseitig verschlüsselte Backups. Neue Clients hinterlegen
Jeder Export wird als eigener unveränderlicher Datensatz gespeichert. Es gibt keine automatische Ablaufzeit und ein neuer Export überschreibt oder löscht keine älteren Sicherungen.
Neue Datensätze speichern zusätzlich die beim Upload erkannte IP-Adresse als administratives Metadatum (`sourceIp`, außerhalb des verschlüsselten Blobs). Die IP wird zusammen mit dem Datensatz gelöscht und nicht über den öffentlichen Restore-Endpunkt ausgegeben. Sie bezeichnet die vom Dienst gesehene Upload-Verbindung, bei Proxy/VPN also möglicherweise deren Ausgangs-IP, nicht zwingend den ursprünglichen Rechner oder eine eindeutige Person. Vorhandene Datensätze werden nicht nachträglich ergänzt.
## Konfiguration
| Variable | Standard | Bedeutung |
@@ -58,7 +60,7 @@ Die folgenden Linux-Pfade sind Beispiele und müssen an die tatsächliche Instal
node src/recovery-admin.mjs list /var/lib/mdd-backups
```
`recoveryKeyId: null` bedeutet: keine Schlüsselkopie vorhanden. Bei mehreren Sicherungen dienen Erstellungszeitpunkt und Backup-ID zur Zuordnung; Inhalte oder Accountnamen werden nicht aufgelistet.
`recoveryKeyId: null` bedeutet: keine Schlüsselkopie vorhanden. Bei mehreren Sicherungen dienen Erstellungszeitpunkt, `sourceIp` und Backup-ID zur Zuordnung; Inhalte oder Accountnamen werden nicht aufgelistet. `sourceIp: null` bedeutet, dass keine gültige IP gespeichert wurde. Bei `TRUST_PROXY=true` muss der vorgeschaltete, allein zugelassene Proxy den Forwarding-Header überschreiben. In der Downloader-Produktivkonfiguration bindet der Dienst nur an Loopback; Nginx setzt `X-Forwarded-For` ausdrücklich auf `$remote_addr` und übernimmt keine vom Client behauptete Adresse.
4. Als Administrator den Schlüssel in eine neue Datei außerhalb des Datenverzeichnisses schreiben:
@@ -68,4 +70,12 @@ Die folgenden Linux-Pfade sind Beispiele und müssen an die tatsächliche Instal
`BACKUP_ID` durch die ausgewählte ID ersetzen. Der Befehl prüft Schlüssel-Fingerabdruck, OAEP-Bindung, MDD2-Prüfsumme, Löschverifikator und AES-GCM-Authentizität des Backups. Er überschreibt keine Datei und gibt den Schlüssel nicht im Terminal aus. Die Ausgabedatei enthält den vollständigen MDD2-Schlüssel und ist wie ein Passwort zu behandeln. Sicher auf den eigenen Rechner übertragen, in MDD unter „Online-Schlüssel importieren“ verwenden und nicht in Chats oder Logs kopieren.
Für eine lesbare Zuordnung beim Auslesen `--with-metadata` ergänzen:
```sh
node src/recovery-admin.mjs recover /var/lib/mdd-backups BACKUP_ID /root/mdd-recovery-keys/private.pem /root/mdd-online-key-info.txt --with-metadata
```
Die geschützte Ausgabedatei enthält dann beispielsweise `22.09.2026 - 14:30 | IP: 203.0.113.7 | Schlüssel: MDD2-…`. Das Datum bezeichnet die Erstellung der Sicherung, dargestellt in `Europe/Berlin` mit Sommer-/Winterzeit. Ohne gespeicherte IP steht `IP: unbekannt`. Für den Import nur den `MDD2-…`-Teil kopieren. Ohne Option bleibt das bisherige reine Schlüsselformat für Import und Skripte unverändert.
Schlüsselrotation: Alte private Schlüssel sicher behalten, solange zugehörige Sicherungen existieren. Neue öffentliche Schlüssel gelten nur für neue Exporte; vorhandene Kopien werden nicht umgeschrieben. Bei Verlust des privaten Schlüssels sind die dazugehörigen Schlüsselkopien nicht wiederherstellbar. Der Dienst benötigt den privaten Schlüssel auch nach der Einrichtung nicht.
+5 -4
View File
@@ -2,6 +2,7 @@ import { generateKeyPairSync } from 'node:crypto'
import { mkdir, open, readFile, readdir, realpath } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { recoverOnlineKey, recoveryDescriptor } from './recovery.mjs'
import { formatRecoveryReport, recoveryMetadata } from './recovery-report.mjs'
async function writeExclusive(file, text) {
const handle = await open(file, 'wx', 0o600)
@@ -33,11 +34,11 @@ async function run() {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (!entry.isFile() || !/^[A-Za-z0-9_-]{22}\.json$/.test(entry.name)) continue
const record = JSON.parse(await readFile(join(directory, entry.name), 'utf8'))
process.stdout.write(JSON.stringify({ id: entry.name.slice(0, -5), createdAt: record.createdAt, recoveryKeyId: record.recovery?.keyId ?? null }) + '\n')
process.stdout.write(JSON.stringify({ id: entry.name.slice(0, -5), ...recoveryMetadata(record), recoveryKeyId: record.recovery?.keyId ?? null }) + '\n')
}
return
}
if (command === 'recover' && args.length === 4) {
if (command === 'recover' && (args.length === 4 || (args.length === 5 && args[4] === '--with-metadata'))) {
const [root, id, privateFile, output] = args
if (!/^[A-Za-z0-9_-]{22}$/.test(id) || Buffer.from(id, 'base64url').toString('base64url') !== id) throw new Error()
const directory = await realpath(root)
@@ -49,11 +50,11 @@ async function run() {
}
const record = JSON.parse(await readFile(join(directory, `${id}.json`), 'utf8'))
const key = recoverOnlineKey({ ...record, id }, await readFile(privatePath, 'utf8'))
await writeExclusive(outputPath, key + '\n')
await writeExclusive(outputPath, args[4] === '--with-metadata' ? formatRecoveryReport(record, key) : key + '\n')
process.stdout.write('Online key written to the requested file. Protect it like a password.\n')
return
}
process.stderr.write('Usage: recovery-admin.mjs init <new-key-directory> | list <backup-data-directory> | recover <backup-data-directory> <backup-id> <private.pem> <new-output-file>\n')
process.stderr.write('Usage: recovery-admin.mjs init <new-key-directory> | list <backup-data-directory> | recover <backup-data-directory> <backup-id> <private.pem> <new-output-file> [--with-metadata]\n')
process.exitCode = 1
}
@@ -0,0 +1,21 @@
import { isIP } from 'node:net'
export function recoveryMetadata(record) {
let sourceIp = typeof record.sourceIp === 'string' && isIP(record.sourceIp) ? record.sourceIp : null
if (sourceIp?.startsWith('::ffff:') && isIP(sourceIp.slice(7)) === 4) sourceIp = sourceIp.slice(7)
return { createdAt: typeof record.createdAt === 'string' ? record.createdAt : null, sourceIp }
}
export function formatRecoveryReport(record, key) {
if (!/^MDD2-[A-Za-z0-9_-]{70}$/.test(key)) throw new Error('Invalid online key')
const metadata = recoveryMetadata(record)
const date = metadata.createdAt ? new Date(metadata.createdAt) : null
let timestamp = 'Datum unbekannt'
if (date && Number.isFinite(date.getTime())) {
const parts = Object.fromEntries(new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
}).formatToParts(date).map(part => [part.type, part.value]))
timestamp = `${parts.day}.${parts.month}.${parts.year} - ${parts.hour}:${parts.minute}`
}
return `${timestamp} | IP: ${metadata.sourceIp ?? 'unbekannt'} | Schlüssel: ${key}\n`
}
+4 -3
View File
@@ -182,7 +182,7 @@ async function recordExists(rootDir, id) {
}
}
async function createRecord(rootDir, payload, maxStorageBytes) {
async function createRecord(rootDir, payload, maxStorageBytes, sourceIp) {
await mkdir(rootDir, { recursive: true })
if (await recordExists(rootDir, payload.id)) return 'duplicate'
const contents = Buffer.from(JSON.stringify({
@@ -190,7 +190,8 @@ async function createRecord(rootDir, payload, maxStorageBytes) {
blob: payload.blob,
deleteVerifier: payload.deleteVerifier,
...(payload.recovery ? { recovery: payload.recovery } : {}),
createdAt: new Date().toISOString()
createdAt: new Date().toISOString(),
sourceIp: isIP(sourceIp) ? sourceIp : null
}), 'utf8')
if (await directoryUsage(rootDir) + contents.length > maxStorageBytes) return 'full'
const temporaryPath = join(rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
@@ -450,7 +451,7 @@ export function createBackupServer(options) {
}
const result = await runStorageMutation(() => withStorageLock(
options.rootDir,
() => createRecord(options.rootDir, parsed.value, maxStorageBytes)
() => createRecord(options.rootDir, parsed.value, maxStorageBytes, clientAddress(request, options.trustedProxy === true))
))
if (result === 'duplicate') {
sendJson(response, 409, { error: 'already_exists' })
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { formatRecoveryReport, recoveryMetadata } from '../src/recovery-report.mjs'
const key = 'MDD2-' + 'A'.repeat(70)
test('formats date in Berlin time with source IP and the online key', () => {
assert.equal(formatRecoveryReport({ createdAt: '2026-09-22T12:30:00.000Z', sourceIp: '203.0.113.7' }, key), `22.09.2026 - 14:30 | IP: 203.0.113.7 | Schlüssel: ${key}\n`)
assert.equal(formatRecoveryReport({ createdAt: '2026-01-22T12:30:00.000Z', sourceIp: '2001:db8::7' }, key), `22.01.2026 - 13:30 | IP: 2001:db8::7 | Schlüssel: ${key}\n`)
assert.equal(formatRecoveryReport({ createdAt: '2026-09-21T22:00:00.000Z', sourceIp: '::ffff:192.0.2.1' }, key), `22.09.2026 - 00:00 | IP: 192.0.2.1 | Schlüssel: ${key}\n`)
})
test('keeps legacy metadata explicitly unknown and does not invent a date or IP', () => {
assert.deepEqual(recoveryMetadata({}), { createdAt: null, sourceIp: null })
assert.equal(formatRecoveryReport({}, key), `Datum unbekannt | IP: unbekannt | Schlüssel: ${key}\n`)
assert.equal(formatRecoveryReport({ createdAt: 'invalid', sourceIp: 'invalid\nInjected' }, key), `Datum unbekannt | IP: unbekannt | Schlüssel: ${key}\n`)
assert.throws(() => formatRecoveryReport({}, key + '\nInjected'))
})
+27
View File
@@ -61,6 +61,33 @@ test('health endpoint reports readiness without exposing storage details', async
assert.equal(response.headers.get('cache-control'), 'no-store')
})
for (const [label, trustedProxy, forwarded, expected] of [
['socket peer without proxy trust', false, '203.0.113.7', '127.0.0.1'],
['trusted ingress IPv4', true, '203.0.113.7', '203.0.113.7'],
['trusted ingress IPv6', true, '2001:db8::7', '2001:db8::7'],
['invalid forwarding header', true, 'not-an-ip', '127.0.0.1']
]) {
test(`records source IP from ${label} without exposing it through restore`, async t => {
const api = await startApi({ trustedProxy })
t.after(() => api.close())
const fixture = backupFixture()
const response = await request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded }, body: JSON.stringify(fixture.payload) })
assert.equal(response.status, 201)
const filename = join(api.rootDir, `${fixture.payload.id}.json`)
const original = await readFile(filename, 'utf8')
const stored = JSON.parse(original)
assert.equal(stored.sourceIp, expected)
assert(Number.isFinite(Date.parse(stored.createdAt)))
const duplicate = await request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': '192.0.2.9' }, body: JSON.stringify(fixture.payload) })
assert.equal(duplicate.status, 409)
assert.equal(await readFile(filename, 'utf8'), original)
const restore = await request(api, '/v1/backups/restore', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: fixture.payload.id }) })
assert.deepEqual(await restore.json(), { blob: fixture.payload.blob })
const forged = await request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...backupFixture().payload, sourceIp: '192.0.2.99' }) })
assert.equal(forged.status, 400)
})
}
test('health endpoint rejects an unusable storage path', async t => {
const container = await mkdtemp(join(tmpdir(), 'mdd-backup-health-'))
const rootDir = join(container, 'not-a-directory')
+12
View File
@@ -71,11 +71,23 @@ describe("online backup client and service", () => {
const listed = run("list", rootDir);
expect(listed.status).toBe(0);
expect(JSON.parse(listed.stdout).id).toBe(stored.id);
expect(JSON.parse(listed.stdout).sourceIp).toBe("127.0.0.1");
expect(listed.stdout).not.toContain(created.key);
const recovered = run("recover", rootDir, stored.id, privateFile, outputFile);
expect(recovered.status).toBe(0);
expect(recovered.stdout + recovered.stderr).not.toContain(created.key);
expect((await fs.promises.readFile(outputFile, "utf8")).trim()).toBe(created.key);
const reportFile = path.join(directory, "report.txt");
const report = run("recover", rootDir, stored.id, privateFile, reportFile, "--with-metadata");
expect(report.status).toBe(0);
expect(report.stdout + report.stderr).not.toContain(created.key);
expect(await fs.promises.readFile(reportFile, "utf8")).toMatch(new RegExp(`^\\d{2}\\.\\d{2}\\.\\d{4} - \\d{2}:\\d{2} \\| IP: 127\\.0\\.0\\.1 \\| Schlüssel: ${created.key}\\n$`));
const legacyFile = path.join(directory, "legacy-report.txt");
const legacyRecord = { ...JSON.parse(raw) };
delete legacyRecord.sourceIp;
await fs.promises.writeFile(path.join(rootDir, `${stored.id}.json`), JSON.stringify(legacyRecord));
expect(run("recover", rootDir, stored.id, privateFile, legacyFile, "--with-metadata").status).toBe(0);
expect(await fs.promises.readFile(legacyFile, "utf8")).toContain(` | IP: unbekannt | Schlüssel: ${created.key}`);
expect(run("recover", rootDir, stored.id, privateFile, outputFile).status).toBe(1);
expect(run("recover", rootDir, "../invalid", privateFile, outputFile).status).toBe(1);
expect(run("recover", rootDir, stored.id, privateFile, path.join(rootDir, "leaked.txt")).status).toBe(1);