Moved V1 and V2 of the scripts into their own Repos and archived them

V1 - https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V1/
V2 - https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V2

Moved V3 code into main dir
Updated and merged README file
This commit is contained in:
phil committed 2026-11-06 23:16:49 +00:00
1 parent caae2fb707
commit d45c309110
10 files changed
+60 -502

No files matched your search

+730
View File
@@ -0,0 +1,730 @@
// 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.
//
// 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.1"
var version = "3.2.1"
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
}
logf("Found %d backup(s) on Home Assistant server.", len(backups))
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 (none are older than %d days).", days)
}
}
type newBackupResp struct {
Result string `json:"result"`
Message string `json:"message"`
Data struct {
Slug string `json:"slug"`
} `json:"data"`
Slug string `json:"slug"`
}
// describeOutput turns the output of an `ha` command into a short message for
// error reports: the "message" field of a JSON response if there is one,
// otherwise the raw text (collapsed to one line and truncated).
func describeOutput(out string) string {
var resp newBackupResp
if json.Unmarshal([]byte(out), &resp) == nil && resp.Message != "" {
return resp.Message
}
text := strings.Join(strings.Fields(out), " ")
if r := []rune(text); len(r) > 300 {
text = string(r[:300]) + "..."
}
return text
}
// 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 {
if d := describeOutput(out); d != "" {
return "", fmt.Errorf("failed to create backup on Home Assistant server: %w (Home Assistant said: %s)", err, d)
}
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
}
if resp.Result == "error" && resp.Message != "" {
return "", fmt.Errorf("backup creation failed: Home Assistant said: %s", resp.Message)
}
}
// 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
}
}
}
if d := describeOutput(out); d != "" {
return "", fmt.Errorf("failed to extract backup slug (Home Assistant said: %s)", d)
}
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))
lowSpace := avail < cfg.StorageThresholdKB
if lowSpace {
logf("Disk space is below the threshold. Running remote backup cleanup...")
removeOldRemoteBackups(remote, notify, cfg.RemoteCleanupDays)
// Re-check: cleanup may not have freed anything.
if a, err := remote.availableKB(); err != nil {
logf("Could not re-check free space after cleanup: %v", err)
} else {
avail = a
lowSpace = avail < cfg.StorageThresholdKB
logf("Free space after cleanup: %s", humanBytes(avail*1024))
}
if lowSpace {
warn := fmt.Sprintf("Warning: low disk space on Home Assistant server (%s free, threshold %s) and cleanup did not free enough space.",
humanBytes(avail*1024), humanBytes(cfg.StorageThresholdKB*1024))
logf("%s", warn)
notify.Send(warn)
}
} 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 {
msg := err.Error()
if lowSpace {
msg = fmt.Sprintf("not enough disk space on Home Assistant server (%s free, threshold %s); %s",
humanBytes(avail*1024), humanBytes(cfg.StorageThresholdKB*1024), msg)
}
fail(msg + ".")
}
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))
}