// ha-backup v3.2.0: 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. // // 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" "io/fs" "net/http" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "sync" "time" ) // --------------------------------------------------------------------------- // Configuration (reads the same KEY="value" # comment format as the .sh script) // --------------------------------------------------------------------------- const defaultConfigName = "home_assistant_backup.conf" // defaultLogFile is where the log is written unless LOG_FILE says otherwise. 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.0" var version = "3.2.0" type Config struct { SSHKey string SSHUser string SSHHost string SSHPort string BackupDest string BackupFileLocation string StorageThresholdKB int64 LocalCleanupDays int RemoteCleanupDays int // LogFile is the path of the log file. Set LOG_FILE="none" to disable // file logging. LogFile string 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"), "/"), LogFile: get("LOG_FILE", defaultLogFile), 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 } // --------------------------------------------------------------------------- // Logging // --------------------------------------------------------------------------- var ( logMu sync.Mutex logFile *os.File // nil when file logging is disabled or could not be opened ) // openLog opens (or creates) the log file in append mode. An empty path or // "none" disables file logging. Console output is never affected. func openLog(path string) error { if path == "" || strings.EqualFold(path, "none") { return nil } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640) if err != nil { return err } logMu.Lock() logFile = f logMu.Unlock() return nil } // logf prints a timestamped line to the console and, when enabled, appends a // line with a full date and time to the log file. func logf(format string, a ...any) { msg := fmt.Sprintf(format, a...) now := time.Now() logMu.Lock() defer logMu.Unlock() fmt.Printf("[%s] %s\n", now.Format("15:04:05"), msg) if logFile != nil { fmt.Fprintf(logFile, "%s %s\n", now.Format("2006-01-02 15:04:05"), msg) } } // stderrWriter returns a writer for subprocess stderr that goes to the // terminal and, when enabled, to the log file as well. func stderrWriter() io.Writer { logMu.Lock() defer logMu.Unlock() if logFile != nil { return io.MultiWriter(os.Stderr, logFile) } return os.Stderr } // --------------------------------------------------------------------------- // 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 { logf("notification failed (%s): %v", url, err) return } resp.Body.Close() if resp.StatusCode >= 300 { logf("notification failed (%s): HTTP %d", 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 (and the log file). 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 = stderrWriter() 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 = stderrWriter() 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) { logf("Removing backups older than %d days on Home Assistant server...", days) backups, err := r.listBackups() if err != nil { logf("Error: %v", 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 { logf("Skipping backup %s: cannot parse date %q", b.Slug, b.Date) continue } if !t.Before(cutoff) { continue } if !slugRe.MatchString(b.Slug) { logf("Skipping backup with unexpected slug %q", b.Slug) continue } logf("Removing backup with slug: %s", b.Slug) if _, err := r.run("ha backups remove " + b.Slug); err != nil { logf("Failed to remove backup with slug: %s", b.Slug) continue } removed++ logf("Successfully removed backup with slug: %s", b.Slug) n.Send("Old backup removed: " + b.Slug) } if removed == 0 { logf("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 { logf("Removing old local backup: %s", path) return os.Remove(path) } return nil }) } // --------------------------------------------------------------------------- // Progress reporting // --------------------------------------------------------------------------- func humanBytes(n int64) string { const unit = 1024 if n < unit { return fmt.Sprintf("%d B", n) } div, exp := int64(unit), 0 for m := n / unit; m >= unit; m /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) } func stdoutIsTerminal() bool { fi, err := os.Stdout.Stat() return err == nil && fi.Mode()&os.ModeCharDevice != 0 } // withHeartbeat runs fn and prints a "still running" line every interval so // long operations don't look hung. func withHeartbeat(label string, interval time.Duration, fn func() error) error { start := time.Now() done := make(chan struct{}) go func() { t := time.NewTicker(interval) defer t.Stop() for { select { case <-done: return case <-t.C: logf("%s... still running (%s elapsed)", label, time.Since(start).Round(time.Second)) } } }() err := fn() close(done) return err } // remoteSize returns the size in bytes of a file on the HA host (0 if unknown). func (r Remote) remoteSize(path string) int64 { out, err := r.run("wc -c < '" + path + "'") if err != nil { return 0 } f := strings.Fields(out) if len(f) == 0 { return 0 } n, _ := strconv.ParseInt(f[0], 10, 64) return n } // 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. func (r Remote) copyWithProgress(remotePath, localPath string) error { total := r.remoteSize(remotePath) if total > 0 { logf("Backup size on Home Assistant: %s", humanBytes(total)) } tty := stdoutIsTerminal() interval := 10 * time.Second if tty { interval = time.Second } stop := make(chan struct{}) stopped := make(chan struct{}) start := time.Now() go func() { defer close(stopped) t := time.NewTicker(interval) defer t.Stop() for { select { case <-stop: if tty { fmt.Print("\r\033[K") } return case <-t.C: info, err := os.Stat(localPath) if err != nil { continue } cur := info.Size() secs := time.Since(start).Seconds() speed := int64(0) if secs > 0 { speed = int64(float64(cur) / secs) } var msg string if total > 0 { msg = fmt.Sprintf("Copying: %s / %s (%d%%) at %s/s", humanBytes(cur), humanBytes(total), cur*100/total, humanBytes(speed)) } else { msg = fmt.Sprintf("Copying: %s at %s/s", humanBytes(cur), humanBytes(speed)) } if tty { fmt.Printf("\r\033[K[%s] %s", time.Now().Format("15:04:05"), msg) } else { logf("%s", msg) } } } }() err := r.copyFrom(remotePath, localPath) close(stop) <-stopped return err } // --------------------------------------------------------------------------- // 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)") versionFlag := flag.Bool("version", false, "print version and exit") flag.Parse() if *versionFlag { fmt.Printf("ha-backup v%s\n", version) return } 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) } // Open the log file as early as possible so validation errors are logged // too. A failure here (e.g. not running as root) is not fatal. if err := openLog(cfg.LogFile); err != nil { fmt.Fprintf(os.Stderr, "Warning: cannot open log file %s: %v (continuing without file logging)\n", cfg.LogFile, err) } if err := cfg.validate(); err != nil { logf("Error: %v", err) os.Exit(1) } notify := NewNotifier(cfg) remote := Remote{cfg: cfg} fail := func(msg string) { full := "Error: " + msg notify.Send(full) logf("%s", full) os.Exit(1) } timestamp := time.Now().Format("2006-01-02_15-04-05") start := time.Now() logf("ha-backup v%s - backup process started (%s)", version, timestamp) if logFile != nil { logf("Logging to %s", cfg.LogFile) } notify.Send("Backup process started at " + timestamp) // Free-space check on the Home Assistant host. logf("Checking free disk space on %s...", cfg.SSHHost) avail, err := remote.availableKB() if err != nil { fail("failed to check disk space on Home Assistant server: " + err.Error()) } logf("Free space: %s (threshold: %s)", humanBytes(avail*1024), humanBytes(cfg.StorageThresholdKB*1024)) if avail < cfg.StorageThresholdKB { logf("Disk space is below the threshold. Running remote backup cleanup...") removeOldRemoteBackups(remote, notify, cfg.RemoteCleanupDays) } else { logf("Sufficient disk space available. Skipping remote backup cleanup.") } // Create the backup. logf("Creating backup on Home Assistant (this can take a few minutes)...") var slug string err = withHeartbeat("Creating backup", 10*time.Second, func() (e error) { slug, e = remote.createBackup("auto_backup_" + timestamp) return e }) if err != nil { fail(err.Error() + ".") } if !slugRe.MatchString(slug) { fail(fmt.Sprintf("unexpected backup slug %q.", slug)) } logf("Backup created with slug: %s", slug) notify.Send("Backup created successfully with slug: " + slug) // Copy it locally. 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 { fail("Failed to copy backup to local machine.") } info, err := os.Stat(backupFile) if err != nil { fail("Failed to get backup file size.") } logf("Copy complete: %s in %s", humanBytes(info.Size()), time.Since(copyStart).Round(time.Second)) // Local retention. logf("Cleaning up local backups older than %d days...", cfg.LocalCleanupDays) 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.") logf("Backup completed successfully: %s", backupFile) notify.Send("Backup completed successfully: " + backupFile) runtime := int(time.Since(start).Seconds()) logf("Total runtime: %d seconds", runtime) notify.Send(fmt.Sprintf("Backup file size: %d bytes. Script runtime: %d seconds.", info.Size(), runtime)) }