Record upload IP and add dated administrative key reports
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user