syncova-backup/packages/repository/immutability.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

311 lines
11 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
)
// EnforcementLevel benennt, **wer** eine Loeschung verhindern kann.
//
// Der Unterschied ist der ganze Sinn dieser Datei. „Unveraenderlich" ist in
// Backup-Produkten ein Wort, das oft eine Datenbankspalte meint: Die Software
// weigert sich zu loeschen, und jeder mit Dateizugriff loescht trotzdem. Wer
// darauf eine Ransomware-Strategie baut, verliert seine Backups.
//
// Syncova meldet deshalb nie eine Stufe, die es nicht **gemessen** hat
// (PROMPT.md §15: „Die Immutability soll nicht nur eine Software-Markierung
// sein"; SYNCOVA_IMPLEMENTATION_PLAN.md §13: „Do not claim true immutability if
// the underlying storage cannot enforce it").
type EnforcementLevel string
const (
// EnforcementNone bedeutet: kein Schutz.
EnforcementNone EnforcementLevel = "none"
// EnforcementAdvisory bedeutet: nur diese Software haelt sich daran.
//
// Der Regelfall bei einem gewoehnlichen Verzeichnis. Der Schutz wirkt gegen
// Bedienfehler und gegen jeden, der ueber Syncova geht — gegen einen
// Angreifer mit Dateizugriff wirkt er nicht.
EnforcementAdvisory EnforcementLevel = "advisory"
// EnforcementFilesystem bedeutet: das Dateisystem verweigert die Loeschung.
//
// Gemessen, nicht behauptet: Eine Probedatei mit gesetztem
// Unveraenderlich-Kennzeichen liess sich nachweislich nicht loeschen. Wer
// Systemverwalter ist, kann das Kennzeichen weiterhin entfernen — auch das
// wird gesagt statt verschwiegen.
EnforcementFilesystem EnforcementLevel = "filesystem"
// EnforcementStorage bedeutet: die Speicherebene selbst verhindert es.
//
// S3 Object Lock, WORM-Datentraeger. **Nicht umgesetzt** — diese Stufe wird
// von der Messung niemals vergeben.
EnforcementStorage EnforcementLevel = "storage"
)
// Describe erklaert eine Durchsetzungsstufe in einem Satz.
func (level EnforcementLevel) Describe() string {
switch level {
case EnforcementFilesystem:
return "Das Dateisystem verweigert die Loeschung geschuetzter Backups. " +
"Ein Systemverwalter kann den Schutz weiterhin aufheben."
case EnforcementAdvisory:
return "Der Schutz wird ausschliesslich von dieser Software durchgesetzt. " +
"Wer Zugriff auf die Dateien hat, kann die Backups loeschen."
case EnforcementStorage:
return "Die Speicherebene verhindert die Loeschung."
default:
return "Es besteht kein Aufbewahrungsschutz."
}
}
// IsTechnicallyEnforced meldet einen Schutz ausserhalb dieser Software.
func (level EnforcementLevel) IsTechnicallyEnforced() bool {
return level == EnforcementFilesystem || level == EnforcementStorage
}
// ProtectionObservation ist ein einzelner Versuch der Messung.
//
// Der Bericht nennt die Versuche einzeln, nicht nur ihr Ergebnis: Wer wissen
// will, worauf sich eine Aussage stuetzt, soll es nachlesen koennen, statt
// einem Wort glauben zu muessen.
type ProtectionObservation struct {
// Attempt beschreibt den unternommenen Versuch.
Attempt string `json:"attempt"`
// Prevented meldet, ob das System den Versuch verhindert hat.
Prevented bool `json:"prevented"`
// Detail nennt die Meldung des Betriebssystems.
Detail string `json:"detail,omitempty"`
}
// EnforcementReport ist das Ergebnis einer Messung.
type EnforcementReport struct {
// Level ist die nachgewiesene Durchsetzungsstufe.
Level EnforcementLevel `json:"level"`
// Explanation erklaert die Stufe verstaendlich.
Explanation string `json:"explanation"`
// Observations sind die einzelnen Versuche.
Observations []ProtectionObservation `json:"observations"`
// FlagSupported meldet, ob das System ein Unveraenderlich-Kennzeichen kennt.
FlagSupported bool `json:"immutable_flag_supported"`
// MeasuredAt ist der Zeitpunkt der Messung in UTC.
MeasuredAt time.Time `json:"measured_at"`
}
// probeFilePrefix ist der Namensanfang der Probedateien einer Messung.
const probeFilePrefix = ".enforcement-probe-"
// MeasureEnforcement stellt fest, was dieses Dateisystem tatsaechlich verhindert.
//
// Die Messung legt Probedateien im Metadatenverzeichnis des Repositorys an und
// versucht, sie zu loeschen. Echte Daten werden dabei nicht angefasst.
//
// Zwei Versuche, und der erste ist der wichtigere:
//
// 1. Eine Datei mit den Rechten 0400 loeschen. Das **gelingt** — unter POSIX
// haengt das Loeschen am Schreibrecht des Verzeichnisses, nicht an dem der
// Datei. Wer „nur lesbar" fuer Loeschschutz haelt, irrt sich; genau dieser
// Irrtum steckte im urspruenglichen geharteten Modus.
// 2. Eine Datei mit gesetztem Unveraenderlich-Kennzeichen loeschen. Verhindert
// das System es, ist die Stufe „filesystem" belegt.
func MeasureEnforcement(measureContext context.Context, rootPath string, baseLogger *slog.Logger) (*EnforcementReport, error) {
if contextError := measureContext.Err(); contextError != nil {
return nil, contextError
}
probeDirectory := filepath.Join(rootPath, directoryMetadata)
if _, statError := os.Stat(probeDirectory); statError != nil {
return nil, fmt.Errorf("das metadatenverzeichnis ist nicht erreichbar: %w", statError)
}
report := &EnforcementReport{
Level: EnforcementAdvisory,
FlagSupported: immutableFlagSupported(),
MeasuredAt: time.Now().UTC(),
Observations: make([]ProtectionObservation, 0, 2),
}
permissionObservation, permissionError := measureReadOnlyDeletion(probeDirectory)
if permissionError != nil {
return nil, permissionError
}
report.Observations = append(report.Observations, permissionObservation)
flagObservation, flagError := measureImmutableFlagDeletion(probeDirectory)
if flagError != nil {
return nil, flagError
}
report.Observations = append(report.Observations, flagObservation)
if flagObservation.Prevented {
report.Level = EnforcementFilesystem
}
report.Explanation = report.Level.Describe()
if baseLogger != nil {
baseLogger.Info("die durchsetzungsstufe wurde gemessen",
slog.String("stufe", string(report.Level)),
slog.Bool("kennzeichen_unterstuetzt", report.FlagSupported))
}
return report, nil
}
// measureReadOnlyDeletion prueft, ob Dateirechte vor Loeschung schuetzen.
func measureReadOnlyDeletion(probeDirectory string) (ProtectionObservation, error) {
probePath, createError := createProbeFile(probeDirectory)
if createError != nil {
return ProtectionObservation{}, createError
}
defer func() { _ = os.Remove(probePath) }()
if chmodError := os.Chmod(probePath, 0o400); chmodError != nil {
return ProtectionObservation{}, fmt.Errorf("die probedatei liess sich nicht schuetzen: %w", chmodError)
}
observation := ProtectionObservation{
Attempt: "Eine Datei mit den Rechten 0400 loeschen",
}
if removeError := os.Remove(probePath); removeError != nil {
observation.Prevented = true
observation.Detail = removeError.Error()
return observation, nil
}
observation.Detail = "Die Loeschung gelang. Dateirechte schuetzen unter POSIX nicht vor " +
"dem Loeschen — dafuer zaehlt das Schreibrecht des Verzeichnisses."
return observation, nil
}
// measureImmutableFlagDeletion prueft das Unveraenderlich-Kennzeichen.
func measureImmutableFlagDeletion(probeDirectory string) (ProtectionObservation, error) {
observation := ProtectionObservation{
Attempt: "Eine Datei mit Unveraenderlich-Kennzeichen loeschen",
}
if !immutableFlagSupported() {
observation.Detail = "Dieses Betriebssystem kennt kein Unveraenderlich-Kennzeichen."
return observation, nil
}
probePath, createError := createProbeFile(probeDirectory)
if createError != nil {
return ProtectionObservation{}, createError
}
// Das Kennzeichen wird in jedem Fall wieder entfernt — auch bei einem
// Fehler. Eine liegengebliebene geschuetzte Probedatei liesse sich sonst
// nicht einmal von Hand aufraeumen.
defer func() {
_ = setImmutableFlag(probePath, false)
_ = os.Remove(probePath)
}()
if flagError := setImmutableFlag(probePath, true); flagError != nil {
observation.Detail = fmt.Sprintf(
"Das Kennzeichen liess sich nicht setzen: %s. Der Schutz bleibt eine Software-Regel.",
flagError)
return observation, nil
}
if removeError := os.Remove(probePath); removeError != nil {
observation.Prevented = true
observation.Detail = "Das Betriebssystem verweigerte die Loeschung: " + removeError.Error()
return observation, nil
}
observation.Detail = "Die Loeschung gelang trotz gesetztem Kennzeichen."
return observation, nil
}
// createProbeFile legt eine Probedatei an.
func createProbeFile(probeDirectory string) (string, error) {
probeFile, createError := os.CreateTemp(probeDirectory, probeFilePrefix+"*")
if createError != nil {
return "", fmt.Errorf("die probedatei konnte nicht angelegt werden: %w", createError)
}
probePath := probeFile.Name()
if _, writeError := probeFile.WriteString("syncova enforcement probe"); writeError != nil {
_ = probeFile.Close()
_ = os.Remove(probePath)
return "", fmt.Errorf("die probedatei konnte nicht beschrieben werden: %w", writeError)
}
if closeError := probeFile.Close(); closeError != nil {
_ = os.Remove(probePath)
return "", fmt.Errorf("die probedatei konnte nicht geschlossen werden: %w", closeError)
}
return probePath, nil
}
// protectManifestFile setzt den Loeschschutz einer Manifestdatei.
//
// Der Aufruf ist absichtlich nachsichtig: Kann das Dateisystem das Kennzeichen
// nicht setzen, bleibt es beim Software-Schutz. Ein Backup deshalb scheitern zu
// lassen waere falsch — ein geschriebenes Backup ohne technischen Loeschschutz
// ist besser als gar keines. Gesagt wird es trotzdem, ueber die gemessene Stufe.
func protectManifestFile(manifestPath string) error {
if !immutableFlagSupported() {
return nil
}
return setImmutableFlag(manifestPath, true)
}
// releaseManifestFile hebt den Loeschschutz einer Manifestdatei auf.
//
// Aufgerufen wird das nur, wenn die Aufbewahrungsfrist abgelaufen ist und die
// Loeschung nach den Regeln der Anwendung zulaessig war. Der Weg daran vorbei
// fuehrt ueber das Betriebssystem, nicht ueber diese Funktion.
func releaseManifestFile(manifestPath string) error {
if !immutableFlagSupported() {
return nil
}
if flagError := setImmutableFlag(manifestPath, false); flagError != nil {
// Fehlt die Berechtigung, ist das eine Aussage und kein Betriebsfehler:
// Der Schutz greift staerker als diese Software.
if errors.Is(flagError, os.ErrPermission) {
return fmt.Errorf("das unveraenderlich-kennzeichen liess sich nicht entfernen: %w", flagError)
}
return flagError
}
return nil
}
// ReleaseImmutableFlagForMaintenance hebt den Loeschschutz **einer** Datei auf.
//
// Der Weg ist absichtlich unbequem: eine Datei je Aufruf, ein sprechender Name,
// keine rekursive Fassung. Eine Funktion, die den Schutz eines ganzen
// Repositorys in einem Aufruf aufhebt, waere genau das Werkzeug, das ein
// Angreifer sucht — und ein Bedienfehler haette dieselbe Wirkung.
//
// Gebraucht wird sie fuer den legitimen Fall, ein ausgemustertes Repository
// tatsaechlich zu entfernen. Wer das tut, geht Datei fuer Datei vor und weiss
// damit, was er tut.
func ReleaseImmutableFlagForMaintenance(filePath string) error {
return releaseManifestFile(filePath)
}