diff --git a/home_assistant_backup.conf b/home_assistant_backup.conf index e851d8d..d5e3934 100644 --- a/home_assistant_backup.conf +++ b/home_assistant_backup.conf @@ -36,3 +36,12 @@ DISCORD_WEBHOOK_URL="DISCORD_WEBHOOK_URL" # Replace with your Discord webhook U # The file must be writable by the user running ha-backup, see the README # Set to "none" to disable logging to a file (console output is unaffected) LOG_FILE="/var/log/ha-backup.log" + +## File Validation +# Verify the local copy against the backup on the Home Assistant host using +# SHA-256 (sha256sum on the server, compared with the hash of the local file). +# If the hashes differ, the local copy is deleted and an error notification is +# sent; the backup on the Home Assistant server is left untouched so you can retry. +# Verification reads the whole file twice (once on each side), so it adds time +# on large backups. Set to "false" to skip it. Accepts true/false in any case. +VERIFY_CHECKSUM="true" # "true" (default) or "false" diff --git a/main.go b/main.go index 94f9dd7..17d27fd 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ -// ha-backup v3.2.1: creates a Home Assistant backup over SSH, copies it locally, -// prunes old backups (remote and local), logs to /var/log and sends optional -// ntfy/Discord notifications. Go port of home_assistant_backup.sh. +// ha-backup v3.3.0: creates a Home Assistant backup over SSH, copies it locally, +// verifies the copy with a SHA-256 checksum, prunes old backups (remote and +// local), logs to /var/log and sends optional ntfy/Discord notifications. +// Go port of home_assistant_backup.sh. // // Only the standard library is used. The system `ssh` and `scp` binaries are // still required (same as the original script), but `jq` and `yq` are not. @@ -9,6 +10,8 @@ package main import ( "bufio" "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "flag" "fmt" @@ -36,8 +39,8 @@ const defaultLogFile = "/var/log/ha-backup.log" // version is the release version. It can be overridden at build time with: // -// go build -ldflags "-X main.version=3.2.1" -var version = "3.2.1" +// go build -ldflags "-X main.version=3.3.0" +var version = "3.3.0" type Config struct { SSHKey string @@ -51,6 +54,11 @@ type Config struct { LocalCleanupDays int RemoteCleanupDays int + // VerifyChecksum enables the SHA-256 comparison between the backup on the + // Home Assistant host and the local copy. Set VERIFY_CHECKSUM="false" to + // skip it. + VerifyChecksum bool + // LogFile is the path of the log file. Set LOG_FILE="none" to disable // file logging. LogFile string @@ -122,6 +130,11 @@ func loadConfig(path string) (*Config, error) { return def } + // getBool treats "true" in any case (true, True, TRUE) as true. + getBool := func(key, def string) bool { + return strings.EqualFold(get(key, def), "true") + } + cfg := &Config{ SSHKey: get("HA_SSH_KEY", ""), SSHUser: get("HA_SSH_USER", ""), @@ -129,11 +142,12 @@ func loadConfig(path string) (*Config, error) { SSHPort: get("HA_SSH_PORT", ""), BackupDest: get("BACKUP_DEST", ""), BackupFileLocation: strings.TrimRight(get("BACKUP_FILE_LOCATION", "/backup"), "/"), + VerifyChecksum: getBool("VERIFY_CHECKSUM", "true"), LogFile: get("LOG_FILE", defaultLogFile), - NtfyEnabled: get("NTFY_ENABLED", "false") == "true", + NtfyEnabled: getBool("NTFY_ENABLED", "false"), NtfyServer: get("NTFY_SERVER", ""), NtfyTopic: get("NTFY_TOPIC", ""), - DiscordEnabled: get("DISCORD_ENABLED", "false") == "true", + DiscordEnabled: getBool("DISCORD_ENABLED", "false"), DiscordWebhookURL: get("DISCORD_WEBHOOK_URL", ""), } @@ -523,6 +537,33 @@ func (r Remote) remoteSize(path string) int64 { return n } +// remoteSHA256 returns the SHA-256 of a file on the HA host. +func (r Remote) remoteSHA256(path string) (string, error) { + out, err := r.run("sha256sum '" + path + "'") + if err != nil { + return "", err + } + f := strings.Fields(out) + if len(f) == 0 || len(f[0]) != 64 { + return "", fmt.Errorf("unexpected sha256sum output: %q", out) + } + return strings.ToLower(f[0]), nil +} + +// localSHA256 streams a local file through SHA-256. +func localSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + // copyWithProgress runs scp while reporting how much has arrived locally. // On a terminal it updates a single line (console only, never written to the // log file); otherwise (cron, logs) it logs a line every 10 seconds. @@ -701,10 +742,11 @@ func main() { notify.Send("Backup created successfully with slug: " + slug) // Copy it locally. + remotePath := cfg.BackupFileLocation + "/" + slug + ".tar" backupFile := filepath.Join(cfg.BackupDest, fmt.Sprintf("%s_%s.tar", timestamp, slug)) logf("Copying backup to %s ...", backupFile) copyStart := time.Now() - if err := remote.copyWithProgress(cfg.BackupFileLocation+"/"+slug+".tar", backupFile); err != nil { + if err := remote.copyWithProgress(remotePath, backupFile); err != nil { fail("Failed to copy backup to local machine.") } @@ -714,6 +756,37 @@ func main() { } logf("Copy complete: %s in %s", humanBytes(info.Size()), time.Since(copyStart).Round(time.Second)) + // Verify the copy against the checksum on the Home Assistant host. + if cfg.VerifyChecksum { + logf("Verifying checksum (SHA-256)...") + + var remoteSum, localSum string + err = withHeartbeat("Hashing remote file", 10*time.Second, func() (e error) { + remoteSum, e = remote.remoteSHA256(remotePath) + return e + }) + if err != nil { + fail("failed to compute checksum on Home Assistant server: " + err.Error()) + } + + err = withHeartbeat("Hashing local file", 10*time.Second, func() (e error) { + localSum, e = localSHA256(backupFile) + return e + }) + if err != nil { + fail("failed to compute local checksum: " + err.Error()) + } + + if localSum != remoteSum { + os.Remove(backupFile) // don't leave a corrupt file looking like a good backup + fail(fmt.Sprintf("checksum mismatch, local copy removed (remote %s, local %s).", remoteSum, localSum)) + } + logf("Checksum OK: %s", localSum) + notify.Send("Backup checksum verified (SHA-256).") + } else { + logf("Checksum verification disabled (VERIFY_CHECKSUM=false).") + } + // Local retention. logf("Cleaning up local backups older than %d days...", cfg.LocalCleanupDays) if err := removeOldLocalBackups(cfg.BackupDest, cfg.LocalCleanupDays); err != nil {