From 70e1b1cc6e5b4fb8e45e24134d34760d55f73099 Mon Sep 17 00:00:00 2001 From: Phil Date: Thu, 5 Nov 2026 16:28:52 +0000 Subject: [PATCH] Added version 3 Go port of the script --- V3/README.md | 26 ++ V3/go.mod | 3 + V3/home_assistant_backup.conf | 32 +++ V3/main.go | 467 ++++++++++++++++++++++++++++++++++ 4 files changed, 528 insertions(+) create mode 100644 V3/README.md create mode 100644 V3/go.mod create mode 100644 V3/home_assistant_backup.conf create mode 100644 V3/main.go diff --git a/V3/README.md b/V3/README.md new file mode 100644 index 0000000..3c0f71f --- /dev/null +++ b/V3/README.md @@ -0,0 +1,26 @@ +# ha-backup + +Go port of `home_assistant_backup.sh`. Uses the same `home_assistant_backup.conf`. + +## Build +``` +CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ha-backup . +``` + +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 +``` + +## Run +#### looks for home_assistant_backup.conf next to the binary, then in cwd +``` +./ha-backup +``` + +``` +./ha-backup -config /path/to.conf +``` + +Requires `ssh` and `scp` on the machine running it. `jq` and `yq` are no longer needed. diff --git a/V3/go.mod b/V3/go.mod new file mode 100644 index 0000000..4fcd142 --- /dev/null +++ b/V3/go.mod @@ -0,0 +1,3 @@ +module ha-backup + +go 1.21 diff --git a/V3/home_assistant_backup.conf b/V3/home_assistant_backup.conf new file mode 100644 index 0000000..3cf8050 --- /dev/null +++ b/V3/home_assistant_backup.conf @@ -0,0 +1,32 @@ +### Global Settings ### +# Set your Home Assistant SSH credentials +HA_SSH_KEY="SSH_PRIVATE_KEY" # Replace with your SSH Private key for HomeAssistant +HA_SSH_USER="SSH_USER" # Replace with your HomeAssistant server username (Default is root) +HA_SSH_HOST="SSH_HOST_IP_OR_HOSTNAME" # Replace with your HomeAssistant server IP address +HA_SSH_PORT="SSH_PORT" # Replace with your HomeAssistant server port + +## Storage +# Set the backup destination path +BACKUP_DEST="BACKUP_FILE_DESTINATION" # Replace with the location for the backup files + +# Set the location of the Home Assistant backups (Set to default location /backup) +# If you have a custom install, using the likes of Supervised +# this location should normally be /usr/share/hassio/backup +BACKUP_FILE_LOCATION="/backup" + +# Storage threshold (in KB) for triggering cleanup +STORAGE_THRESHOLD_KB=2097152 # 2GB = 2 * 1024 * 1024 KB +# Set clean up old backups (adjust the number of days to keep) +LOCAL_BACKUP_CLEANUP="90" # Set the number of days that local backup files will be kept for (Default 90 days) +REMOTE_BACKUP_CLEANUP="30" # Set the number of days that backups on the Home Assistant server will be kept for (Default 30 days) + +## Notification Settings +# ntfy settings +NTFY_ENABLED=false # Set to 'true' to enable ntfy notifications +NTFY_SERVER="NTFY_SERVER_FQDN_ADDRESS" # Replace with your ntfy server address +NTFY_TOPIC="NTFY_TOPIC" # Replace with your ntfy topic + +# Discord webhook settings +DISCORD_ENABLED=false # Set to 'true' to enable Discord notifications +DISCORD_WEBHOOK_URL="DISCORD_WEBHOOK_URL" # Replace with your Discord webhook URL + diff --git a/V3/main.go b/V3/main.go new file mode 100644 index 0000000..9483640 --- /dev/null +++ b/V3/main.go @@ -0,0 +1,467 @@ +// ha-backup: creates a Home Assistant backup over SSH, copies it locally, +// prunes old backups (remote and local) 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. +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "io/fs" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// Configuration (reads the same KEY="value" # comment format as the .sh script) +// --------------------------------------------------------------------------- + +const defaultConfigName = "home_assistant_backup.conf" + +type Config struct { + SSHKey string + SSHUser string + SSHHost string + SSHPort string + + BackupDest string + BackupFileLocation string + StorageThresholdKB int64 + LocalCleanupDays int + RemoteCleanupDays int + + NtfyEnabled bool + NtfyServer string + NtfyTopic string + + DiscordEnabled bool + DiscordWebhookURL string +} + +func parseValue(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if raw[0] == '"' || raw[0] == '\'' { + q := raw[0] + if end := strings.IndexByte(raw[1:], q); end >= 0 { + return raw[1 : 1+end] + } + return raw[1:] + } + // Unquoted: strip trailing comment (a '#' preceded by whitespace). + for i := 1; i < len(raw); i++ { + if raw[i] == '#' && (raw[i-1] == ' ' || raw[i-1] == '\t') { + raw = raw[:i] + break + } + } + return strings.TrimSpace(raw) +} + +func readConfFile(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + vals := map[string]string{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimPrefix(line, "export ") + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + vals[strings.TrimSpace(k)] = parseValue(v) + } + return vals, sc.Err() +} + +func loadConfig(path string) (*Config, error) { + m, err := readConfFile(path) + if err != nil { + return nil, err + } + + get := func(key, def string) string { + if v, ok := m[key]; ok && v != "" { + return v + } + return def + } + + cfg := &Config{ + SSHKey: get("HA_SSH_KEY", ""), + SSHUser: get("HA_SSH_USER", ""), + SSHHost: get("HA_SSH_HOST", ""), + SSHPort: get("HA_SSH_PORT", ""), + BackupDest: get("BACKUP_DEST", ""), + BackupFileLocation: strings.TrimRight(get("BACKUP_FILE_LOCATION", "/backup"), "/"), + NtfyEnabled: get("NTFY_ENABLED", "false") == "true", + NtfyServer: get("NTFY_SERVER", ""), + NtfyTopic: get("NTFY_TOPIC", ""), + DiscordEnabled: get("DISCORD_ENABLED", "false") == "true", + DiscordWebhookURL: get("DISCORD_WEBHOOK_URL", ""), + } + + if cfg.StorageThresholdKB, err = strconv.ParseInt(get("STORAGE_THRESHOLD_KB", "2097152"), 10, 64); err != nil { + return nil, fmt.Errorf("invalid STORAGE_THRESHOLD_KB: %w", err) + } + if cfg.LocalCleanupDays, err = strconv.Atoi(get("LOCAL_BACKUP_CLEANUP", "90")); err != nil { + return nil, fmt.Errorf("invalid LOCAL_BACKUP_CLEANUP: %w", err) + } + if cfg.RemoteCleanupDays, err = strconv.Atoi(get("REMOTE_BACKUP_CLEANUP", "30")); err != nil { + return nil, fmt.Errorf("invalid REMOTE_BACKUP_CLEANUP: %w", err) + } + return cfg, nil +} + +// validate mirrors sanity_check() from the shell script. +func (c *Config) validate() error { + type req struct{ val, placeholder, name string } + checks := []req{ + {c.SSHKey, "SSH_PRIVATE_KEY", "HA_SSH_KEY"}, + {c.SSHUser, "SSH_USER", "HA_SSH_USER"}, + {c.SSHHost, "SSH_HOST_IP_OR_HOSTNAME", "HA_SSH_HOST"}, + {c.SSHPort, "SSH_PORT", "HA_SSH_PORT"}, + {c.BackupDest, "BACKUP_FILE_DESTINATION", "BACKUP_DEST"}, + } + if c.NtfyEnabled { + checks = append(checks, + req{c.NtfyServer, "NTFY_SERVER_FQDN_ADDRESS", "NTFY_SERVER"}, + req{c.NtfyTopic, "NTFY_TOPIC", "NTFY_TOPIC"}) + } + if c.DiscordEnabled { + checks = append(checks, req{c.DiscordWebhookURL, "DISCORD_WEBHOOK_URL", "DISCORD_WEBHOOK_URL"}) + } + for _, r := range checks { + if r.val == "" || r.val == r.placeholder { + return fmt.Errorf("%s is not set", r.name) + } + } + for _, bin := range []string{"ssh", "scp"} { + if _, err := exec.LookPath(bin); err != nil { + return fmt.Errorf("%s is not installed or not in PATH", bin) + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Notifications +// --------------------------------------------------------------------------- + +type Notifier struct { + cfg *Config + client *http.Client +} + +func NewNotifier(cfg *Config) *Notifier { + return &Notifier{cfg: cfg, client: &http.Client{Timeout: 15 * time.Second}} +} + +func (n *Notifier) post(url, contentType string, body []byte) { + resp, err := n.client.Post(url, contentType, bytes.NewReader(body)) + if err != nil { + fmt.Fprintf(os.Stderr, "notification failed (%s): %v\n", url, err) + return + } + resp.Body.Close() + if resp.StatusCode >= 300 { + fmt.Fprintf(os.Stderr, "notification failed (%s): HTTP %d\n", url, resp.StatusCode) + } +} + +// Send delivers msg to every enabled notification channel. +func (n *Notifier) Send(msg string) { + if n.cfg.NtfyEnabled { + url := strings.TrimRight(n.cfg.NtfyServer, "/") + "/" + n.cfg.NtfyTopic + n.post(url, "text/plain", []byte(msg)) + } + if n.cfg.DiscordEnabled { + body, _ := json.Marshal(map[string]string{"content": msg}) + n.post(n.cfg.DiscordWebhookURL, "application/json", body) + } +} + +// --------------------------------------------------------------------------- +// SSH / SCP helpers (shell out to the system OpenSSH client) +// --------------------------------------------------------------------------- + +type Remote struct{ cfg *Config } + +func (r Remote) target() string { return r.cfg.SSHUser + "@" + r.cfg.SSHHost } + +// run executes a command on the Home Assistant host and returns its stdout. +// stderr is passed through to the terminal. +func (r Remote) run(remoteCmd string) (string, error) { + cmd := exec.Command("ssh", "-i", r.cfg.SSHKey, "-p", r.cfg.SSHPort, r.target(), remoteCmd) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = os.Stderr + err := cmd.Run() + return out.String(), err +} + +func (r Remote) copyFrom(remotePath, localPath string) error { + cmd := exec.Command("scp", "-i", r.cfg.SSHKey, "-P", r.cfg.SSHPort, + r.target()+":"+remotePath, localPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// availableKB returns free space (in KB) on the remote root filesystem. +func (r Remote) availableKB() (int64, error) { + out, err := r.run("df -k / | tail -n 1") + if err != nil { + return 0, err + } + fields := strings.Fields(out) + if len(fields) < 4 { + return 0, fmt.Errorf("unexpected df output: %q", out) + } + return strconv.ParseInt(fields[3], 10, 64) +} + +// --------------------------------------------------------------------------- +// Home Assistant operations +// --------------------------------------------------------------------------- + +var slugRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +type backupEntry struct { + Slug string `json:"slug"` + Date string `json:"date"` +} + +type backupListResp struct { + Backups []backupEntry `json:"backups"` + Data struct { + Backups []backupEntry `json:"backups"` + } `json:"data"` +} + +func (r Remote) listBackups() ([]backupEntry, error) { + out, err := r.run("ha backups --raw-json") + if err != nil { + return nil, err + } + var resp backupListResp + if err := json.Unmarshal([]byte(out), &resp); err != nil { + return nil, fmt.Errorf("could not parse 'ha backups' output as JSON: %w", err) + } + if len(resp.Data.Backups) > 0 { + return resp.Data.Backups, nil + } + return resp.Backups, nil +} + +func removeOldRemoteBackups(r Remote, n *Notifier, days int) { + fmt.Printf("Removing backups older than %d days on Home Assistant server...\n", days) + + backups, err := r.listBackups() + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour) + removed := 0 + for _, b := range backups { + t, err := time.Parse(time.RFC3339Nano, b.Date) + if err != nil { + fmt.Printf("Skipping backup %s: cannot parse date %q\n", b.Slug, b.Date) + continue + } + if !t.Before(cutoff) { + continue + } + if !slugRe.MatchString(b.Slug) { + fmt.Printf("Skipping backup with unexpected slug %q\n", b.Slug) + continue + } + fmt.Printf("Removing backup with slug: %s\n", b.Slug) + if _, err := r.run("ha backups remove " + b.Slug); err != nil { + fmt.Printf("Failed to remove backup with slug: %s\n", b.Slug) + continue + } + removed++ + fmt.Printf("Successfully removed backup with slug: %s\n", b.Slug) + n.Send("Old backup removed: " + b.Slug) + } + if removed == 0 { + fmt.Println("No old backups found to remove.") + } +} + +type newBackupResp struct { + Data struct { + Slug string `json:"slug"` + } `json:"data"` + Slug string `json:"slug"` +} + +// createBackup creates a backup and returns its slug. +func (r Remote) createBackup(name string) (string, error) { + out, err := r.run("ha backup new --name=" + name + " --raw-json") + if err != nil { + return "", fmt.Errorf("failed to create backup on Home Assistant server: %w", err) + } + + var resp newBackupResp + if json.Unmarshal([]byte(out), &resp) == nil { + if resp.Data.Slug != "" { + return strings.TrimSpace(resp.Data.Slug), nil + } + if resp.Slug != "" { + return strings.TrimSpace(resp.Slug), nil + } + } + + // Fallback: plain-text "slug: xxxx" line (what the original script parsed). + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(strings.TrimRight(line, "\r")) + if strings.HasPrefix(line, "slug:") { + if s := strings.TrimSpace(strings.TrimPrefix(line, "slug:")); s != "" { + return s, nil + } + } + } + return "", fmt.Errorf("failed to extract backup slug") +} + +// removeOldLocalBackups deletes *.tar files under dir that `find -mtime +days` +// would match (age of at least days+1 full 24h periods). +func removeOldLocalBackups(dir string, days int) error { + minAge := time.Duration(days+1) * 24 * time.Hour + now := time.Now() + return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.Type().IsRegular() || !strings.HasSuffix(d.Name(), ".tar") { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + if now.Sub(info.ModTime()) >= minAge { + return os.Remove(path) + } + return nil + }) +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +func findConfig(flagPath string) string { + if flagPath != "" { + return flagPath + } + if exe, err := os.Executable(); err == nil { + p := filepath.Join(filepath.Dir(exe), defaultConfigName) + if _, err := os.Stat(p); err == nil { + return p + } + } + return defaultConfigName // current working directory +} + +func main() { + confFlag := flag.String("config", "", "path to config file (default: "+defaultConfigName+" next to the binary, then current directory)") + flag.Parse() + + confPath := findConfig(*confFlag) + cfg, err := loadConfig(confPath) + if err != nil { + fmt.Printf("Error: cannot load config file %s: %v\n", confPath, err) + os.Exit(1) + } + if err := cfg.validate(); err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + + notify := NewNotifier(cfg) + remote := Remote{cfg: cfg} + + fail := func(msg string) { + full := "Error: " + msg + notify.Send(full) + fmt.Println(full) + os.Exit(1) + } + + timestamp := time.Now().Format("2006-01-02_15-04-05") + start := time.Now() + + notify.Send("Backup process started at " + timestamp) + + // Free-space check on the Home Assistant host. + avail, err := remote.availableKB() + if err != nil { + fail("failed to check disk space on Home Assistant server: " + err.Error()) + } + if avail < cfg.StorageThresholdKB { + fmt.Printf("Disk space is below the threshold (%d KB available, %d KB required). Proceeding with remote backup cleanup...\n", + avail, cfg.StorageThresholdKB) + removeOldRemoteBackups(remote, notify, cfg.RemoteCleanupDays) + } else { + fmt.Println("Sufficient disk space available on Home Assistant server. Skipping remote backup cleanup.") + } + + // Create the backup. + slug, err := remote.createBackup("auto_backup_" + timestamp) + if err != nil { + fail(err.Error() + ".") + } + if !slugRe.MatchString(slug) { + fail(fmt.Sprintf("unexpected backup slug %q.", slug)) + } + notify.Send("Backup created successfully with slug: " + slug) + + // Copy it locally. + backupFile := filepath.Join(cfg.BackupDest, fmt.Sprintf("%s_%s.tar", timestamp, slug)) + if err := remote.copyFrom(cfg.BackupFileLocation+"/"+slug+".tar", backupFile); err != nil { + fail("Failed to copy backup to local machine.") + } + + info, err := os.Stat(backupFile) + if err != nil { + fail("Failed to get backup file size.") + } + + // Local retention. + if err := removeOldLocalBackups(cfg.BackupDest, cfg.LocalCleanupDays); err != nil { + fail("Failed to clean up old backups locally: " + err.Error()) + } + notify.Send("Old backups cleaned up successfully.") + + fmt.Println("Backup completed successfully: " + backupFile) + notify.Send("Backup completed successfully: " + backupFile) + + runtime := int(time.Since(start).Seconds()) + notify.Send(fmt.Sprintf("Backup file size: %d bytes. Script runtime: %d seconds.", info.Size(), runtime)) +}