468 lines
13 KiB
Go
468 lines
13 KiB
Go
// 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))
|
|
}
|