Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions utils/uci-git-backup/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#
# Copyright (C) 2026 Mathias Rangger
#
# This is free software, licensed under the GNU General Public License v2.
# See /LICENSE for more information.
#

include $(TOPDIR)/rules.mk

PKG_NAME:=uci-git-backup
PKG_VERSION:=1.2.1
PKG_RELEASE:=1

PKG_MAINTAINER:=Mathias Rangger <mathias.rangger@gmail.com>
PKG_LICENSE:=GPL-2.0-only

include $(INCLUDE_DIR)/package.mk

define Package/uci-git-backup
SECTION:=utils
CATEGORY:=Utilities
TITLE:=Automatically back up UCI configuration to Git
DEPENDS:=+git +git-http +openssh-client
endef

define Package/uci-git-backup/description
Automatically commits and pushes watched UCI configuration to a remote Git
repository whenever a watched config is committed. Includes manual connection
testing and restore helpers for LuCI integration.
endef

define Package/uci-git-backup/conffiles
/etc/config/uci_git_backup
endef

define Build/Configure
endef

define Build/Compile
endef

define Package/uci-git-backup/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./files/uci_git_backup.config $(1)/etc/config/uci_git_backup

$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/uci-git-backup.init $(1)/etc/init.d/uci-git-backup

$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) ./files/99-uci-git-backup $(1)/etc/uci-defaults/99-uci-git-backup

$(INSTALL_DIR) $(1)/etc/uci-git-backup

$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) ./files/uci-git-backup $(1)/usr/bin/uci-git-backup
$(INSTALL_BIN) ./files/uci-git-test $(1)/usr/bin/uci-git-test
$(INSTALL_BIN) ./files/uci-git-list $(1)/usr/bin/uci-git-list
$(INSTALL_BIN) ./files/uci-git-restore $(1)/usr/bin/uci-git-restore
endef

$(eval $(call BuildPackage,uci-git-backup))
8 changes: 8 additions & 0 deletions utils/uci-git-backup/files/99-uci-git-backup
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh
# Runs once after package installation (on next boot or via uci-defaults trigger).
# Enables and starts the uci-git-backup service.

/etc/init.d/uci-git-backup enable
/etc/init.d/uci-git-backup start

exit 0
252 changes: 252 additions & 0 deletions utils/uci-git-backup/files/uci-git-backup
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
#!/bin/sh
#
# uci-git-backup - copy /etc/config/ into a local git repo and push to remote.
#
# Called by the procd reload trigger whenever a watched UCI config changes.
# Can also be called manually or from LuCI.
#

LOCK_FILE="/var/run/uci-git-backup.lock"
LOCK_PID_FILE="$LOCK_FILE/pid"
SSH_KEY_FILE="/etc/uci-git-backup/id_rsa"

get_local_fqdn() {
LOCAL_HOSTNAME="$(uci -q get system.@system[0].hostname 2>/dev/null || cat /proc/sys/kernel/hostname 2>/dev/null || echo openwrt)"
LOCAL_DOMAIN="$(uci -q get dhcp.@dnsmasq[0].domain 2>/dev/null || true)"

case "$LOCAL_DOMAIN" in
''|lan)
printf '%s\n' "$LOCAL_HOSTNAME"
;;
*)
printf '%s.%s\n' "$LOCAL_HOSTNAME" "$LOCAL_DOMAIN"
;;
esac
}

default_author_name() {
printf 'OpenWrt (%s)\n' "$(get_local_fqdn)"
}

log() {
logger -t uci-git-backup "$*"
}

die() {
log "ERROR: $*"
rm -rf "$LOCK_FILE"
exit 1
}

explain_git_error() {
case "$1" in
*'error in libcrypto: unsupported'*)
printf '%s\n%s\n' \
"$1" \
'Router SSH client could not load the private key. Use an unencrypted PEM RSA/ECDSA key, or convert an existing key with: ssh-keygen -p -m PEM -f <keyfile>'
;;
*)
printf '%s\n' "$1"
;;
esac
}

run_with_timeout() {
RUN_TIMEOUT_SECS="$1"
shift
RUN_TIMEOUT_MARKER="/tmp/uci-git-backup-timeout.$$.$(date +%s)"
rm -f "$RUN_TIMEOUT_MARKER"

"$@" &
RUN_TIMEOUT_CMD_PID=$!
(
sleep "$RUN_TIMEOUT_SECS"
printf '1\n' > "$RUN_TIMEOUT_MARKER"
kill -TERM "$RUN_TIMEOUT_CMD_PID" 2>/dev/null || true
sleep 1
kill -KILL "$RUN_TIMEOUT_CMD_PID" 2>/dev/null || true
) &
RUN_TIMEOUT_WATCHDOG_PID=$!

wait "$RUN_TIMEOUT_CMD_PID"
RUN_TIMEOUT_STATUS=$?
kill "$RUN_TIMEOUT_WATCHDOG_PID" 2>/dev/null || true
wait "$RUN_TIMEOUT_WATCHDOG_PID" 2>/dev/null || true

if [ -f "$RUN_TIMEOUT_MARKER" ]; then
rm -f "$RUN_TIMEOUT_MARKER"
return 124
fi

rm -f "$RUN_TIMEOUT_MARKER"
return "$RUN_TIMEOUT_STATUS"
}

# Prevent concurrent runs (procd may fire multiple triggers in rapid succession)
if ! mkdir "$LOCK_FILE" 2>/dev/null; then
if [ -f "$LOCK_PID_FILE" ]; then
LOCK_PID="$(cat "$LOCK_PID_FILE" 2>/dev/null || true)"
if [ -n "$LOCK_PID" ] && ! kill -0 "$LOCK_PID" 2>/dev/null; then
log "Removing stale lock from PID $LOCK_PID"
rm -rf "$LOCK_FILE"
fi
elif [ -d "$LOCK_FILE" ]; then
log "Removing stale lock without PID metadata"
rm -rf "$LOCK_FILE"
fi

if ! mkdir "$LOCK_FILE" 2>/dev/null; then
log "Another instance is already running, skipping"
exit 0
fi
fi
printf '%s\n' "$$" > "$LOCK_PID_FILE"
trap 'rm -rf "$LOCK_FILE"' EXIT INT TERM

# Load configuration

. /lib/functions.sh
config_load uci_git_backup

config_get ENABLED config enabled '0'
config_get REMOTE_URL config remote_url ''
config_get BRANCH config branch 'main'
config_get AUTH_TYPE config auth_type 'password'
config_get USERNAME config username ''
config_get PASSWORD config password ''
config_get REPO_PATH config repo_path '/etc/uci-git-backup/repo'
config_get AUTHOR_NAME config author_name 'OpenWrt'
config_get AUTHOR_EMAIL config author_email 'openwrt@localhost'

[ -n "$AUTHOR_NAME" ] || AUTHOR_NAME="$(default_author_name)"
[ "$AUTHOR_NAME" = 'OpenWRT' ] && AUTHOR_NAME="$(default_author_name)"
[ "$AUTHOR_NAME" = 'OpenWrt' ] && AUTHOR_NAME="$(default_author_name)"

[ "$ENABLED" = "1" ] || { log "Backup is disabled (set enabled=1 in LuCI to activate)"; exit 0; }
[ -n "$REMOTE_URL" ] || die "No remote repository URL configured"

# Git identity

export GIT_AUTHOR_NAME="$AUTHOR_NAME"
export GIT_AUTHOR_EMAIL="$AUTHOR_EMAIL"
export GIT_COMMITTER_NAME="$AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$AUTHOR_EMAIL"

# Suppress interactive prompts
export GIT_TERMINAL_PROMPT=0

# Build authenticated remote URL

case "$AUTH_TYPE" in
password)
[ -n "$USERNAME" ] || [ -z "$PASSWORD" ] || \
die "HTTPS password/token auth requires a username. For PAT login, use your Git username plus the token as the password."

if [ -n "$USERNAME" ]; then
PROTO="${REMOTE_URL%%://*}://"
REST="${REMOTE_URL#*://}"
REST="${REST#*@}"
if [ -n "$PASSWORD" ]; then
ENC_PASS=$(printf '%s' "$PASSWORD" | sed 's/@/%40/g; s/:/%3A/g')
AUTH_URL="${PROTO}${USERNAME}:${ENC_PASS}@${REST}"
else
AUTH_URL="${PROTO}${USERNAME}@${REST}"
fi
else
AUTH_URL="$REMOTE_URL"
fi
;;
ssh)
AUTH_URL="$REMOTE_URL"
if [ -f "$SSH_KEY_FILE" ]; then
export GIT_SSH_COMMAND="ssh -i $SSH_KEY_FILE \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-o ConnectTimeout=30"
else
die "SSH key file not found at $SSH_KEY_FILE. Upload your key in LuCI."
fi
;;
*)
AUTH_URL="$REMOTE_URL"
;;
esac

# Initialise local repository

if [ ! -d "$REPO_PATH/.git" ]; then
mkdir -p "$REPO_PATH" || die "Cannot create repo directory: $REPO_PATH"
git init "$REPO_PATH" 2>&1 | while IFS= read -r line; do log "$line"; done
git -C "$REPO_PATH" config user.name "$AUTHOR_NAME"
git -C "$REPO_PATH" config user.email "$AUTHOR_EMAIL"
git -C "$REPO_PATH" remote add origin "$AUTH_URL"
log "Initialised new git repository at $REPO_PATH"
else
git -C "$REPO_PATH" remote set-url origin "$AUTH_URL" 2>/dev/null
fi

# Sync /etc/config/ into the repo

CONFIG_DEST="$REPO_PATH/config"
mkdir -p "$CONFIG_DEST"

find /etc/config -maxdepth 1 -type f ! -name '*.apk-new' | while read -r f; do
base="${f##*/}"
cp -f "$f" "$CONFIG_DEST/$base"
done

find "$CONFIG_DEST" -type f | while read -r f; do
rel="${f#$CONFIG_DEST/}"
case "$rel" in
*.apk-new)
rm -f "$f"
continue
;;
esac

[ -f "/etc/config/$rel" ] || rm -f "$f"
done

uci export > "$REPO_PATH/uci-export.txt" 2>/dev/null

# Commit if there are changes

git -C "$REPO_PATH" add -A

if git -C "$REPO_PATH" diff --cached --quiet; then
log "No changes detected, nothing to commit"
exit 0
fi

HOSTNAME="$(cat /proc/sys/kernel/hostname 2>/dev/null || echo openwrt)"
TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"

git -C "$REPO_PATH" commit -m "UCI backup from $HOSTNAME at $TIMESTAMP" \
|| die "git commit failed"

# Push to remote

PUSH_LOG="/tmp/uci-git-backup-push.log"

if run_with_timeout 20 git -C "$REPO_PATH" -c http.connectTimeout=15 push origin "HEAD:$BRANCH" >"$PUSH_LOG" 2>&1; then
log "Backup pushed successfully -> $REMOTE_URL ($BRANCH)"
else
PUSH_STATUS=$?
if [ "$PUSH_STATUS" -eq 124 ]; then
die "Push failed: remote connection timed out after 20 seconds"
fi

log "Normal push rejected (remote is ahead), retrying with --force-with-lease"
if run_with_timeout 20 git -C "$REPO_PATH" -c http.connectTimeout=15 push --force-with-lease origin "HEAD:$BRANCH" >"$PUSH_LOG" 2>&1; then
log "Force-with-lease push succeeded"
else
PUSH_STATUS=$?
if [ "$PUSH_STATUS" -eq 124 ]; then
die "Push failed: remote connection timed out after 20 seconds"
fi

PUSH_ERR=$(cat "$PUSH_LOG")
PUSH_ERR=$(explain_git_error "$PUSH_ERR")
die "Push failed: $PUSH_ERR"
fi
fi
45 changes: 45 additions & 0 deletions utils/uci-git-backup/files/uci-git-backup.init
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/bin/sh /etc/rc.common
#
# UCI Git Backup - procd init script
#
# This service has no persistent daemon process. It uses procd's
# reload-trigger mechanism: whenever a listed UCI config is committed
# (e.g. via LuCI Save & Apply or `uci commit`), procd calls
# reload_service() which runs the backup.
#

USE_PROCD=1
START=99
STOP=01

start_service() {
# Register a dummy oneshot instance so procd tracks this service
# and evaluates our service_triggers() declaration.
procd_open_instance
procd_set_param command /bin/true
procd_set_param oneshot 1
procd_close_instance
}

service_triggers() {
# Built-in set of standard OpenWrt UCI configs to watch
local cfg
for cfg in \
network wireless firewall dhcp system \
dropbear uhttpd rpcd openvpn \
sqm qos nlbwmon luci
do
procd_add_reload_trigger "$cfg"
done

# User-configured extra triggers (list option in UCI)
local extras
extras=$(uci -q get uci_git_backup.config.extra_triggers 2>/dev/null)
for cfg in $extras; do
procd_add_reload_trigger "$cfg"
done
}

reload_service() {
/usr/bin/uci-git-backup
}
16 changes: 16 additions & 0 deletions utils/uci-git-backup/files/uci-git-list
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/sh

. /lib/functions.sh
config_load uci_git_backup

config_get REPO_PATH config repo_path '/etc/uci-git-backup/repo'

[ -d "$REPO_PATH/.git" ] || {
echo "No local backup repository found. Run a backup first." >&2
exit 1
}

git -C "$REPO_PATH" log --pretty=format:'%H %ai %s' -30 2>/dev/null || {
echo "Failed to read backup history from $REPO_PATH" >&2
exit 1
}
Loading
Loading