Add SHA-256 verification of copied backups (v3.3.0)

After copying a backup from Home Assistant, compare its SHA-256 (via
sha256sum on the server) with a hash of the local file. On mismatch,
delete the local copy and send an error notification; the remote backup
is left in place so the run can be retried. If either checksum cannot
be computed, the run fails rather than reporting an unverified copy as
successful.

- Add VERIFY_CHECKSUM config option (default true; "false" skips the check)
- Make boolean config values case-insensitive (VERIFY_CHECKSUM, NTFY_ENABLED,
  DISCORD_ENABLED), so "True" now enables them. Previously only lowercase
  "true" did, so NTFY_ENABLED="True" silently left ntfy off
- Bump version to 3.3.0
This commit is contained in:
phil committed 2026-11-11 00:11:43 +00:00
1 parent 7f1a4186ea
commit 4c5dcd779d
2 files changed
+90 -8

No files matched your search

+9
View File
@@ -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 # 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) # Set to "none" to disable logging to a file (console output is unaffected)
LOG_FILE="/var/log/ha-backup.log" 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"
+81 -8
View File
@@ -1,6 +1,7 @@
// ha-backup v3.2.1: creates a Home Assistant backup over SSH, copies it locally, // ha-backup v3.3.0: creates a Home Assistant backup over SSH, copies it locally,
// prunes old backups (remote and local), logs to /var/log and sends optional // verifies the copy with a SHA-256 checksum, prunes old backups (remote and
// ntfy/Discord notifications. Go port of home_assistant_backup.sh. // 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 // 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. // still required (same as the original script), but `jq` and `yq` are not.
@@ -9,6 +10,8 @@ package main
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "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: // version is the release version. It can be overridden at build time with:
// //
// go build -ldflags "-X main.version=3.2.1" // go build -ldflags "-X main.version=3.3.0"
var version = "3.2.1" var version = "3.3.0"
type Config struct { type Config struct {
SSHKey string SSHKey string
@@ -51,6 +54,11 @@ type Config struct {
LocalCleanupDays int LocalCleanupDays int
RemoteCleanupDays 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 // LogFile is the path of the log file. Set LOG_FILE="none" to disable
// file logging. // file logging.
LogFile string LogFile string
@@ -122,6 +130,11 @@ func loadConfig(path string) (*Config, error) {
return def 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{ cfg := &Config{
SSHKey: get("HA_SSH_KEY", ""), SSHKey: get("HA_SSH_KEY", ""),
SSHUser: get("HA_SSH_USER", ""), SSHUser: get("HA_SSH_USER", ""),
@@ -129,11 +142,12 @@ func loadConfig(path string) (*Config, error) {
SSHPort: get("HA_SSH_PORT", ""), SSHPort: get("HA_SSH_PORT", ""),
BackupDest: get("BACKUP_DEST", ""), BackupDest: get("BACKUP_DEST", ""),
BackupFileLocation: strings.TrimRight(get("BACKUP_FILE_LOCATION", "/backup"), "/"), BackupFileLocation: strings.TrimRight(get("BACKUP_FILE_LOCATION", "/backup"), "/"),
VerifyChecksum: getBool("VERIFY_CHECKSUM", "true"),
LogFile: get("LOG_FILE", defaultLogFile), LogFile: get("LOG_FILE", defaultLogFile),
NtfyEnabled: get("NTFY_ENABLED", "false") == "true", NtfyEnabled: getBool("NTFY_ENABLED", "false"),
NtfyServer: get("NTFY_SERVER", ""), NtfyServer: get("NTFY_SERVER", ""),
NtfyTopic: get("NTFY_TOPIC", ""), NtfyTopic: get("NTFY_TOPIC", ""),
DiscordEnabled: get("DISCORD_ENABLED", "false") == "true", DiscordEnabled: getBool("DISCORD_ENABLED", "false"),
DiscordWebhookURL: get("DISCORD_WEBHOOK_URL", ""), DiscordWebhookURL: get("DISCORD_WEBHOOK_URL", ""),
} }
@@ -523,6 +537,33 @@ func (r Remote) remoteSize(path string) int64 {
return n 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. // copyWithProgress runs scp while reporting how much has arrived locally.
// On a terminal it updates a single line (console only, never written to the // 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. // 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) notify.Send("Backup created successfully with slug: " + slug)
// Copy it locally. // Copy it locally.
remotePath := cfg.BackupFileLocation + "/" + slug + ".tar"
backupFile := filepath.Join(cfg.BackupDest, fmt.Sprintf("%s_%s.tar", timestamp, slug)) backupFile := filepath.Join(cfg.BackupDest, fmt.Sprintf("%s_%s.tar", timestamp, slug))
logf("Copying backup to %s ...", backupFile) logf("Copying backup to %s ...", backupFile)
copyStart := time.Now() 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.") 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)) 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. // Local retention.
logf("Cleaning up local backups older than %d days...", cfg.LocalCleanupDays) logf("Cleaning up local backups older than %d days...", cfg.LocalCleanupDays)
if err := removeOldLocalBackups(cfg.BackupDest, cfg.LocalCleanupDays); err != nil { if err := removeOldLocalBackups(cfg.BackupDest, cfg.LocalCleanupDays); err != nil {