7 Commits
Author SHA1 Message Date
phil 1af0a20429 ## 3.2.1
### Fixed
- Backup failures caused by low disk space on the Home Assistant host are now
  reported as such, e.g. "not enough disk space on Home Assistant server
  (1.2 GB free, threshold 2.0 GB)", instead of only "failed to extract backup slug".
- A failed `ha backup new` now includes Home Assistant's own error message in
  the notification and log.

### Changed
- Free space is re-checked after the remote cleanup runs. If it is still below
  `STORAGE_THRESHOLD_KB`, a warning is sent to ntfy/Discord before the backup
  is attempted.
- The remote cleanup now logs how many backups it found, and says when none are
  older than `REMOTE_BACKUP_CLEANUP` days.
2026-11-06 22:53:27 +00:00
phil 0444e0d45c Fixed README version 2026-11-06 22:41:17 +00:00
phil 6b9bd80fa8 ## 3.2.0
### Added
- File logging to `/var/log/ha-backup.log`. Each line has a full date and time,
  and the file is appended to on every run.
- New `LOG_FILE` setting in `home_assistant_backup.conf`. Set it to a custom
  path, or to `"none"` to disable file logging.
- `ssh`/`scp` error output is also written to the log file.
- Deletions of old local backups are now logged.

### Changed
- All console messages now go through the same logger, so remote cleanup,
  notification failures and config validation errors appear in the log too.
- If the log file can't be opened (for example, no write permission to
  `/var/log`), a warning is printed and the backup continues without file logging.
- The terminal progress bar stays on the console only and is not written to the log.
2026-11-06 22:40:15 +00:00
phil 5c05a27ae2 Port Home Assistant backup script to Go (v3.1.1)
Rewrite home_assistant_backup.sh as a single Go binary that reads the
existing home_assistant_backup.conf unchanged.

- Create a backup over SSH, scp it locally, and prune old backups both
  on the HA host (when free space is low) and locally
- Send ntfy and Discord notifications at the same points as the script
- Drop the jq and yq dependencies: parse `ha backups --raw-json` and
  `ha backup new --raw-json` in Go, falling back to `slug:` line parsing
- Add timestamped progress output, a heartbeat while HA builds the
  backup, and live size/percent/speed reporting during the copy
- Build the Discord payload with json.Marshal and validate backup slugs
  before using them in remote commands
- Abort with a notification if the remote disk-space check fails
- Add -config and -version flags
- Build with CGO_ENABLED=0 for a static binary that runs on older glibc
2026-11-05 16:40:40 +00:00
phil 70e1b1cc6e Added version 3
Go port of the script
2026-11-05 16:28:52 +00:00
phil cab5d0cfda Updated the script so it now has a separate config file and also fixed issue calculating the storage on the server 2026-10-17 18:33:35 +01:00
phil ce835ec488 Forgot to upload the script 2025-02-01 15:34:17 +00:00
6 changed files with 977 additions and 31 deletions

No files matched your search

+32
View File
@@ -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
+120 -31
View File
@@ -1,34 +1,113 @@
#!/bin/bash #!/bin/bash
set -x #set -x
### Global Settings ### SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Set your Home Assistant SSH credentials CONFIG_FILE="$SCRIPT_DIR/home_assistant_backup.conf"
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
# Set the backup destination path if [ ! -f "$CONFIG_FILE" ]; then
BACKUP_DEST="BACKUP_FILE_DESTINATION" # Replace with the location for the backup files echo "Error: Config file not found: $CONFIG_FILE"
exit 1
fi
# ntfy settings # Load variables
NTFY_ENABLED=false # Set to 'true' to enable ntfy notifications # shellcheck disable=SC1090
NTFY_SERVER="NTFY_SERVER_FQDN_ADDRESS" # Replace with your ntfy server address source "$CONFIG_FILE"
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
# Set clean up old backups (adjust the number of days to keep)
BACKUP_FILE_CLEANUP="90" # Set the number of days that backup files will be kept for (Default 90 days)
# 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"
### Script ### ### Script ###
## Sanity Check##
# Function to check if the necessary variables are set and if jq and yq are installed
sanity_check() {
# Check if jq and yq are installed
if ! command -v jq &> /dev/null; then
echo "Error: jq is not installed. Please install jq before running this script."
exit 1
fi
if ! command -v yq &> /dev/null; then
echo "Error: yq is not installed. Please install yq before running this script."
exit 1
fi
# Check for each variable
if [ -z "$HA_SSH_KEY" ] || [ "$HA_SSH_KEY" == "SSH_PRIVATE_KEY" ]; then
echo "Error: SSH_PRIVATE_KEY is not set."
exit 1
fi
if [ -z "$HA_SSH_USER" ] || [ "$HA_SSH_USER" == "SSH_USER" ]; then
echo "Error: SSH_USER is not set."
exit 1
fi
if [ -z "$HA_SSH_HOST" ] || [ "$HA_SSH_HOST" == "SSH_HOST_IP_OR_HOSTNAME" ]; then
echo "Error: SSH_HOST_IP_OR_HOSTNAME is not set."
exit 1
fi
if [ -z "$HA_SSH_PORT" ] || [ "$HA_SSH_PORT" == "SSH_PORT" ]; then
echo "Error: SSH_PORT is not set."
exit 1
fi
if [ -z "$BACKUP_DEST" ] || [ "$BACKUP_DEST" == "BACKUP_FILE_DESTINATION" ]; then
echo "Error: BACKUP_FILE_DESTINATION is not set."
exit 1
fi
if [ "$NTFY_ENABLED" == "true" ]; then
if [ -z "$NTFY_SERVER" ] || [ "$NTFY_SERVER" == "NTFY_SERVER_FQDN_ADDRESS" ]; then
echo "Error: NTFY_SERVER_FQDN_ADDRESS is not set."
exit 1
fi
if [ -z "$NTFY_TOPIC" ] || [ "$NTFY_TOPIC" == "NTFY_TOPIC" ]; then
echo "Error: NTFY_TOPIC is not set."
exit 1
fi
fi
if [ "$DISCORD_ENABLED" == "true" ]; then
if [ -z "$DISCORD_WEBHOOK_URL" ] || [ "$DISCORD_WEBHOOK_URL" == "DISCORD_WEBHOOK_URL" ]; then
echo "Error: DISCORD_WEBHOOK_URL is not set."
exit 1
fi
fi
}
# Call the function to check requirements and variables
sanity_check
# Function to remove old backups from Home Assistant server (older than $REMOTE_BACKUP_CLEANUP days)
remove_old_backups_ssh() {
echo "Removing backups older than $REMOTE_BACKUP_CLEANUP days on Home Assistant server..."
# Fetch the list of backups and parse the YAML output using yq
BACKUPS_OUTPUT=$(ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "ha backups")
if ! echo "$BACKUPS_OUTPUT" | yq eval >/dev/null 2>&1; then
echo "Error: The output of 'ha backups' is not valid YAML."
return 1
fi
# Convert the YAML to JSON and extract old backups using jq
OLD_BACKUPS=$(echo "$BACKUPS_OUTPUT" | yq eval -o=json | jq --argjson days "$REMOTE_BACKUP_CLEANUP" -r '.backups[] | select((.date | sub("\\.[0-9]+\\+00:00$"; "Z") | fromdateiso8601) < (now - ($days * 86400))) | .slug')
# Remove each old backup by its slug
if [ -n "$OLD_BACKUPS" ]; then
for slug in $OLD_BACKUPS; do
echo "Removing backup with slug: $slug"
ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "ha backups remove $slug"
if [ $? -eq 0 ]; then
echo "Successfully removed backup with slug: $slug"
send_ntfy_notification "Old backup removed: $slug"
send_discord_notification "Old backup removed: $slug"
else
echo "Failed to remove backup with slug: $slug"
fi
done
else
echo "No old backups found to remove."
fi
}
### Function for sending notifications ### ### Function for sending notifications ###
send_ntfy_notification() { send_ntfy_notification() {
@@ -58,6 +137,16 @@ START_TIME=$(date +%s)
send_ntfy_notification "Backup process started at $TIMESTAMP" send_ntfy_notification "Backup process started at $TIMESTAMP"
send_discord_notification "Backup process started at $TIMESTAMP" send_discord_notification "Backup process started at $TIMESTAMP"
# Check available disk space on Home Assistant server
AVAILABLE_SPACE=$(ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "df -k / | tail -n 1 | tr -s ' ' | cut -d ' ' -f 4")
if [ "$AVAILABLE_SPACE" -lt "$STORAGE_THRESHOLD_KB" ]; then
echo "Disk space is less than 2GB on Home Assistant server. Proceeding with remote backup cleanup..."
# Remove old backups on Home Assistant server if less than 2GB available
remove_old_backups_ssh
else
echo "Sufficient disk space available on Home Assistant server. Skipping remote backup cleanup."
fi
# Create a backup on the Home Assistant server and extract the slug # Create a backup on the Home Assistant server and extract the slug
BACKUP_OUTPUT=$(ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "ha backup new --name=auto_backup_$TIMESTAMP") BACKUP_OUTPUT=$(ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "ha backup new --name=auto_backup_$TIMESTAMP")
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
@@ -104,14 +193,13 @@ if [ $? -ne 0 ]; then
exit 1 exit 1
fi fi
# Clean up old backups locally (older than $LOCAL_BACKUP_CLEANUP days)
# Clean up old backups if needed (adjust the number of days to keep) find "$BACKUP_DEST" -name '*.tar' -mtime +$LOCAL_BACKUP_CLEANUP -exec rm {} \;
find "$BACKUP_DEST" -name '*.tar' -mtime +90 -exec rm {} \;
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
# Send notification if cleanup fails # Send notification if cleanup fails
send_ntfy_notification "Error: Failed to clean up old backups." send_ntfy_notification "Error: Failed to clean up old backups locally."
send_discord_notification "Error: Failed to clean up old backups." send_discord_notification "Error: Failed to clean up old backups locally."
echo "Error: Failed to clean up old backups." echo "Error: Failed to clean up old backups locally."
exit 1 exit 1
fi fi
@@ -131,3 +219,4 @@ RUNTIME=$((END_TIME - START_TIME))
# Send notification with backup file size and script runtime # Send notification with backup file size and script runtime
send_ntfy_notification "Backup file size: $BACKUP_FILE_SIZE bytes. Script runtime: $RUNTIME seconds." send_ntfy_notification "Backup file size: $BACKUP_FILE_SIZE bytes. Script runtime: $RUNTIME seconds."
send_discord_notification "Backup file size: $BACKUP_FILE_SIZE bytes. Script runtime: $RUNTIME seconds." send_discord_notification "Backup file size: $BACKUP_FILE_SIZE bytes. Script runtime: $RUNTIME seconds."
+54
View File
@@ -0,0 +1,54 @@
# ha-backup v3.2.0
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 .
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:
./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
ha-backup writes a log to `/var/log/ha-backup.log` by default. Each line is timestamped, and the file is appended to on every run. The same output is still printed to the console.
### Create the log file
Writing to `/var/log` requires elevated permissions, so create the file once and give ownership to the user that runs the backup. Replace `backupuser` with that user (use `root` if you run it as root):
If you'd rather do it in steps:
```
sudo touch /var/log/ha-backup.log
sudo chown backupuser:backupuser /var/log/ha-backup.log
sudo chmod 640 /var/log/ha-backup.log
```
If the log file can't be opened, ha-backup prints a warning and continues the backup without file logging.
### Change or disable the log location
Set `LOG_FILE` in `home_assistant_backup.conf`:
```bash
LOG_FILE="/var/log/ha-backup.log" # default
LOG_FILE="/home/backupuser/ha-backup.log" # custom path
LOG_FILE="none" # disable file logging
```
## Run
./ha-backup # looks for home_assistant_backup.conf next to the binary, then in cwd
./ha-backup -config /path/to.conf
Requires `ssh` and `scp` on the machine running it. `jq` and `yq` are no longer needed.
+3
View File
@@ -0,0 +1,3 @@
module ha-backup
go 1.21
+38
View File
@@ -0,0 +1,38 @@
### 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
## Logging
# Set the log file location (Default /var/log/ha-backup.log)
# 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)
LOG_FILE="/var/log/ha-backup.log"
+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))
}