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:
1 parent
7f1a4186ea
commit
4c5dcd779d
2 files changed
+90
-8
No files matched your search
@@ -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"
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user