2 Commits
Author SHA1 Message Date
phil 4c5dcd779d 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
2026-11-11 00:11:43 +00:00
phil 7f1a4186ea Updated README 2026-11-06 23:27:39 +00:00
3 changed files with 108 additions and 27 deletions

No files matched your search

+18 -19
View File
@@ -1,4 +1,4 @@
# Home Assistant Backup Automation Scripts # Home Assistant Backup Script
These scripts automate the backup process for **Home Assistant** servers, enabling you to create, transfer, and manage backups. The backups are securely transferred from the Home Assistant server to your local machine, with options for cleaning up old backups based on configurable retention periods. These scripts automate the backup process for **Home Assistant** servers, enabling you to create, transfer, and manage backups. The backups are securely transferred from the Home Assistant server to your local machine, with options for cleaning up old backups based on configurable retention periods.
@@ -11,33 +11,26 @@ These scripts automate the backup process for **Home Assistant** servers, enabli
- **Old Backup Cleanup**: Automatically cleans up old backups on both the Home Assistant server and local machine after a specified retention period. - **Old Backup Cleanup**: Automatically cleans up old backups on both the Home Assistant server and local machine after a specified retention period.
- **Notification Support** (Optional): Configure **ntfy** or **Discord** notifications to alert you about the backup status (available in the first script version). - **Notification Support** (Optional): Configure **ntfy** or **Discord** notifications to alert you about the backup status (available in the first script version).
## Requirements ## Requirements:
Both scripts require the following to function correctly: Both scripts require the following to function correctly:
- **Bash shell** (on your local machine and Home Assistant server) - **Linux shell** (on your local machine)
- **SSH** access to the Home Assistant server - **`ssh`** access to the Home Assistant server
- **`scp`** and **`ssh`** tools - **`scp`** and **`ssh`** tools
- **Home Assistant** installed on the server with the `ha` CLI tool - **Home Assistant** installed on the server with the `ha` CLI tool
- **Optional**: Access to a notification service (for Version 2&3) - **Optional**: Access to a notification service
## Configuration
Go port of `home_assistant_backup.sh`. Uses the same `home_assistant_backup.conf`.
## Build ## Build
```
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ha-backup . CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ha-backup .
```
Override the version at build time if needed:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=3.1.2" -o ha-backup .
Check the version: Check the version:
```
./ha-backup -version ./ha-backup -version
```
Cross-compile examples:
GOOS=linux GOARCH=amd64 go build -o ha-backup-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o ha-backup-linux-arm64 . # Raspberry Pi / ARM NAS
## Logging ## Logging
@@ -62,7 +55,7 @@ If the log file can't be opened, ha-backup prints a warning and continues the ba
Set `LOG_FILE` in `home_assistant_backup.conf`: Set `LOG_FILE` in `home_assistant_backup.conf`:
```bash ```
LOG_FILE="/var/log/ha-backup.log" # default LOG_FILE="/var/log/ha-backup.log" # default
LOG_FILE="/home/backupuser/ha-backup.log" # custom path LOG_FILE="/home/backupuser/ha-backup.log" # custom path
LOG_FILE="none" # disable file logging LOG_FILE="none" # disable file logging
@@ -70,9 +63,15 @@ LOG_FILE="none" # disable file logging
## Run ## Run
./ha-backup # looks for home_assistant_backup.conf next to the binary, then in cwd ##### Looks for home_assistant_backup.conf next to the binary, then in cwd
./ha-backup -config /path/to.conf ```
./ha-backup
```
##### Loads the home_assistant_backup.conf from a specified file
```
./ha-backup -config /path/to.conf
```
Requires `ssh` and `scp` on the machine running it. `jq` and `yq` are no longer needed. Requires `ssh` and `scp` on the machine running it. `jq` and `yq` are no longer needed.
+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 {