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>
311 lines
11 KiB
Go
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)
|
|
}
|