From 008ae19fb92f17430cd9495798f3bb2f92ff1af8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 17 Sep 2026 23:44:37 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9B=84=20Sync=202026-09-17=2023:44:37?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gitbackup/Makefile | 51 + gitbackup/files/etc/config/gitbackup | 26 + gitbackup/files/etc/init.d/gitbackup | 138 ++ gitbackup/files/etc/uci-defaults/99-gitbackup | 18 + .../files/usr/libexec/rpcd/luci.gitbackup | 678 +++++++ gitbackup/files/usr/sbin/gitbackup | 1604 +++++++++++++++++ .../files/usr/share/gitbackup/askpass.sh | 69 + gitbackup/files/usr/share/gitbackup/auth.sh | 512 ++++++ gitbackup/files/usr/share/gitbackup/card.sh | 145 ++ .../files/usr/share/gitbackup/collect.sh | 345 ++++ gitbackup/files/usr/share/gitbackup/device.sh | 120 ++ .../files/usr/share/gitbackup/exclude.list | 50 + gitbackup/files/usr/share/gitbackup/gitio.sh | 330 ++++ gitbackup/files/usr/share/gitbackup/lib.sh | 339 ++++ gitbackup/files/usr/share/gitbackup/paths.sh | 266 +++ .../files/usr/share/gitbackup/remoteurl.sh | 216 +++ .../files/usr/share/gitbackup/restore.sh | 774 ++++++++ .../files/usr/share/gitbackup/schedule.sh | 452 +++++ .../files/usr/share/gitbackup/scrub.list | 100 + gitbackup/files/usr/share/gitbackup/scrub.sh | 394 ++++ .../files/usr/share/gitbackup/visibility.sh | 177 ++ gitbackup/owlab.yaml | 29 + luci-app-gitbackup/Makefile | 39 + .../resources/view/gitbackup/diffview.js | 142 ++ .../resources/view/gitbackup/history.js | 814 +++++++++ .../resources/view/gitbackup/oplog.js | 145 ++ .../resources/view/gitbackup/overview.js | 1064 +++++++++++ .../resources/view/gitbackup/paths.js | 570 ++++++ .../resources/view/gitbackup/settings.js | 1493 +++++++++++++++ luci-app-gitbackup/po/ru/gitbackup.po | 1037 +++++++++++ luci-app-gitbackup/po/templates/gitbackup.pot | 1175 ++++++++++++ .../share/luci/menu.d/luci-app-gitbackup.json | 48 + .../share/rpcd/acl.d/luci-app-gitbackup.json | 36 + 33 files changed, 13396 insertions(+) create mode 100644 gitbackup/Makefile create mode 100644 gitbackup/files/etc/config/gitbackup create mode 100755 gitbackup/files/etc/init.d/gitbackup create mode 100644 gitbackup/files/etc/uci-defaults/99-gitbackup create mode 100755 gitbackup/files/usr/libexec/rpcd/luci.gitbackup create mode 100755 gitbackup/files/usr/sbin/gitbackup create mode 100755 gitbackup/files/usr/share/gitbackup/askpass.sh create mode 100644 gitbackup/files/usr/share/gitbackup/auth.sh create mode 100644 gitbackup/files/usr/share/gitbackup/card.sh create mode 100644 gitbackup/files/usr/share/gitbackup/collect.sh create mode 100644 gitbackup/files/usr/share/gitbackup/device.sh create mode 100644 gitbackup/files/usr/share/gitbackup/exclude.list create mode 100644 gitbackup/files/usr/share/gitbackup/gitio.sh create mode 100644 gitbackup/files/usr/share/gitbackup/lib.sh create mode 100644 gitbackup/files/usr/share/gitbackup/paths.sh create mode 100644 gitbackup/files/usr/share/gitbackup/remoteurl.sh create mode 100644 gitbackup/files/usr/share/gitbackup/restore.sh create mode 100644 gitbackup/files/usr/share/gitbackup/schedule.sh create mode 100644 gitbackup/files/usr/share/gitbackup/scrub.list create mode 100644 gitbackup/files/usr/share/gitbackup/scrub.sh create mode 100644 gitbackup/files/usr/share/gitbackup/visibility.sh create mode 100644 gitbackup/owlab.yaml create mode 100644 luci-app-gitbackup/Makefile create mode 100644 luci-app-gitbackup/htdocs/luci-static/resources/view/gitbackup/diffview.js create mode 100644 luci-app-gitbackup/htdocs/luci-static/resources/view/gitbackup/history.js create mode 100644 luci-app-gitbackup/htdocs/luci-static/resources/view/gitbackup/oplog.js create mode 100644 luci-app-gitbackup/htdocs/luci-static/resources/view/gitbackup/overview.js create mode 100644 luci-app-gitbackup/htdocs/luci-static/resources/view/gitbackup/paths.js create mode 100644 luci-app-gitbackup/htdocs/luci-static/resources/view/gitbackup/settings.js create mode 100644 luci-app-gitbackup/po/ru/gitbackup.po create mode 100644 luci-app-gitbackup/po/templates/gitbackup.pot create mode 100644 luci-app-gitbackup/root/usr/share/luci/menu.d/luci-app-gitbackup.json create mode 100644 luci-app-gitbackup/root/usr/share/rpcd/acl.d/luci-app-gitbackup.json diff --git a/gitbackup/Makefile b/gitbackup/Makefile new file mode 100644 index 000000000..018db9347 --- /dev/null +++ b/gitbackup/Makefile @@ -0,0 +1,51 @@ +# +# Copyright (C) 2026 VizzleTF +# +# This is free software, licensed under the GNU General Public License v2. +# + +include $(TOPDIR)/rules.mk + +PKG_NAME:=gitbackup +PKG_VERSION:=0.1.1 +PKG_RELEASE:=1 + +PKG_LICENSE:=GPL-2.0-only +PKG_MAINTAINER:=VizzleTF + +include $(INCLUDE_DIR)/package.mk + +define Package/gitbackup + SECTION:=net + CATEGORY:=Network + TITLE:=Automatic configuration backup to a Git repository + # Every entry earns its place with a measured 25.12.4 failure, not habit + # (spec, "Проверенные факты"): stat is entirely absent from the base image + # and the manifest depends on it (coreutils-stat); dbclient cannot read + # OpenSSH-format keys or honor UserKnownHostsFile, so ssh needs the real + # openssh client and keygen; git-http is required for `git fetch https://` + # (fails with "remote-https is not a git command" otherwise). flock is a + # busybox applet and needs nothing here. + DEPENDS:=+git +git-http +ca-bundle +jsonfilter +coreutils-stat +openssh-client +openssh-keygen + PKGARCH:=all +endef + +define Package/gitbackup/description + gitbackup commits the router's configuration to a private git repository on + a schedule, without keeping a working copy of that repository on flash, and + restores it back onto a freshly flashed router. +endef + +define Package/gitbackup/conffiles +/etc/config/gitbackup +endef + +define Build/Compile +endef + +define Package/gitbackup/install + $(INSTALL_DIR) $(1) + $(CP) ./files/* $(1)/ +endef + +$(eval $(call BuildPackage,gitbackup)) diff --git a/gitbackup/files/etc/config/gitbackup b/gitbackup/files/etc/config/gitbackup new file mode 100644 index 000000000..2c492258f --- /dev/null +++ b/gitbackup/files/etc/config/gitbackup @@ -0,0 +1,26 @@ + +config gitbackup 'main' + option enabled '0' + option archive '1' + option schedule 'daily' + option cron_expr '' + option on_config_change '1' + option debounce '60' + option device_id 'hostname' + option device '' + option path_prefix 'devices/{device}' + +config remote 'origin' + option url '' + option branch 'device/{device}' + option auth 'sshkey' + option key_file '/etc/gitbackup/id_ed25519' + option token_file '/etc/gitbackup/token' + option ca_file '' + option provider 'auto' + option visibility 'private' + option acknowledged '0' + +config security 'security' + option scrub '0' + list scrub_option 'wireless.@wifi-iface[*].key' diff --git a/gitbackup/files/etc/init.d/gitbackup b/gitbackup/files/etc/init.d/gitbackup new file mode 100755 index 000000000..9380374fb --- /dev/null +++ b/gitbackup/files/etc/init.d/gitbackup @@ -0,0 +1,138 @@ +#!/bin/sh /etc/rc.common +# shellcheck shell=sh +# shellcheck disable=SC2034 # START/USE_PROCD/EXTRA_COMMANDS are read by rc.common, not this file +# +# gitbackup runs from cron, not as a daemon, so there is no process for +# start_service to supervise. This file's job is: (1) apply +# gitbackup.main.schedule to /etc/crontabs/root and make crond pick it up +# (reload_service, ticket 06), and (2) watch the packages actually in the +# backup set for config.change and turn a burst of edits into one debounced +# `gitbackup run` (service_triggers/config_change, spec R106/R107). +# +# GB_SHARE/GB_STATE_DIR/GB_BIN are overridable only so tests/run.sh can +# source this file directly (rc.common's own dispatch is not exercised by +# the unit tests -- see this file's own functions being called straight +# from a test body) against a tree, state directory and gitbackup binary +# that are not the router's real ones; the router never sets any of them. + +GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}" +GB_STATE_DIR="${GB_STATE_DIR:-/var/run/gitbackup}" +GB_BIN="${GB_BIN:-/usr/sbin/gitbackup}" + +# shellcheck disable=SC1091 # GB_SHARE is a runtime path, not resolvable statically +. "$GB_SHARE/lib.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/device.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/schedule.sh" + +START=60 +USE_PROCD=1 + +# config_change is not one of rc.common's built-in actions (start/stop/ +# reload/...); EXTRA_COMMANDS is rc.common's own mechanism for adding one +# (it appends this to ALL_COMMANDS right after sourcing this file -- see +# /etc/rc.common on the stand), which is what lets procd's run_script +# target `/etc/init.d/gitbackup config_change` actually dispatch anywhere. +EXTRA_COMMANDS="config_change" +EXTRA_HELP=" config_change Internal: debounced trigger used by service_triggers, not for interactive use" + +start_service() { + reload_service +} + +stop_service() { + : +} + +# reload_service -- (re)applies gitbackup.main.schedule/cron_expr to the +# crontab and makes crond pick it up. Runs on `/etc/init.d/gitbackup +# reload|restart|start` and, through procd_add_reload_trigger below, +# whenever gitbackup's own UCI config changes (e.g. a Settings save in +# LuCI) -- so flipping schedule from daily to weekly takes effect without a +# manual reload. +reload_service() { + gb_cron_apply +} + +# _gb_backed_up_packages -- private. The set of UCI packages the config- +# change trigger below watches: every /etc/config/ that is actually +# part of the backup set, per `sysupgrade -l` (spec: "список берётся из +# sysupgrade -l, фильтруется до /etc/config/*"), minus gitbackup's own +# config -- that one already gets procd_add_reload_trigger's ordinary +# `reload` below, which re-applies the schedule, not a debounced backup +# run; watching it a second time here would just fire both for the same +# edit. +_gb_backed_up_packages() { + sysupgrade -l 2>/dev/null | sed -n 's#^/etc/config/##p' | sort -u | grep -v '^gitbackup$' +} + +# config_change -- procd's run_script target for a backed-up package's +# config.change event, registered once per package in service_triggers +# below. Does not run a backup on the spot: a save in LuCI can touch +# several packages (e.g. network + firewall together) or the same one +# repeatedly, and each touch fires this again -- so this only (re)arms a +# single delayed `gitbackup run` after $debounce seconds, cancelling +# whatever the previous call armed first. A burst of edits therefore +# collapses into exactly one run (spec R106/R107), not one per edit. +# +# busybox ash's arithmetic (and `sleep`) only does whole seconds -- +# `sleep 0.3` is "invalid number", measured on the stand -- hence the +# integer-only guard on $debounce below rather than trusting it unchecked. +# +# Killing the previous timer's PID only stops that subshell; if it had +# already progressed past `sleep` (a race that needs the previous debounce +# window to have fully elapsed a moment before this call, and is why a +# closer-together burst is exactly what this exists to collapse), the +# short-lived orphaned `sleep` left behind exits on its own and does +# nothing further -- rm and run only happen after it, in the same +# subshell, and that subshell is what got killed. +config_change() { + _gb_debounce=$(gb_uci_get gitbackup.main.debounce 60) + case "$_gb_debounce" in ''|*[!0-9]*) _gb_debounce=60 ;; esac + + mkdir -p "$GB_STATE_DIR" + _gb_pidfile="$GB_STATE_DIR/debounce.pid" + + # Review finding, ticket 06: killing the PID from the pidfile blind is + # fine right after this same router wrote it, but a long-uptime router + # can wrap PIDs, and by the time the NEXT config.change fires that same + # number may belong to an unrelated process this has no business + # signaling. `kill -0` (send no signal, just ask whether the PID + # exists) is the cheap half of that check -- it does not prove the PID + # is still OUR sleep, only that it is not free for the kernel to have + # reused by something else; reused PIDs are strictly the OS's own + # concern here. Deliberately `kill -0`, not a `/proc/` test: this + # file's own tests/run.sh unit test runs it on macOS, which has no + # /proc at all, while `kill -0` is the one portable primitive both that + # host and busybox ash on the router actually implement. + if [ -f "$_gb_pidfile" ]; then + _gb_prev_pid=$(cat "$_gb_pidfile") + kill -0 "$_gb_prev_pid" 2>/dev/null && kill "$_gb_prev_pid" 2>/dev/null + fi + + ( sleep "$_gb_debounce"; rm -f "$_gb_pidfile"; "$GB_BIN" run --trigger procd >/dev/null 2>&1 ) & + echo $! >"$_gb_pidfile" +} + +service_triggers() { + procd_add_reload_trigger "gitbackup" + + # R106/R107 both live under one on/off switch: a router that wants + # gitbackup to run strictly on its own schedule can turn the + # config-change trigger off without touching the watched packages. + [ "$(gb_uci_get gitbackup.main.on_config_change 1)" = 1 ] || return 0 + + # Same path resolution procd.sh's own _procd_add_reload_trigger uses + # (readlink, falling back to $initscript itself when it is not a + # symlink) -- $initscript is rc.common's own variable (set to "$1" + # before this file is sourced, see /etc/rc.common on the stand), not + # invented here; shellcheck cannot see that external assignment. + # shellcheck disable=SC2154 + _gb_script=$(readlink "$initscript") + _gb_name=$(basename "${_gb_script:-$initscript}") + + for _gb_pkg in $(_gb_backed_up_packages); do + procd_add_config_trigger "config.change" "$_gb_pkg" /etc/init.d/"$_gb_name" config_change + done +} diff --git a/gitbackup/files/etc/uci-defaults/99-gitbackup b/gitbackup/files/etc/uci-defaults/99-gitbackup new file mode 100644 index 000000000..e54a85a0d --- /dev/null +++ b/gitbackup/files/etc/uci-defaults/99-gitbackup @@ -0,0 +1,18 @@ +# shellcheck shell=sh +# +# gitbackup: create the secrets directory once, at first boot after install. +# +# Not done in postinst -- postinst runs while an image is being built, where +# there may be neither entropy nor a real, writable /etc yet, and the spec is +# explicit that installing the package must not run a backup or generate keys. +# +# /etc/init.d/boot runs every /etc/uci-defaults/* script as +# `( . "./$(basename $file)" )` and deletes it afterwards only if that exited +# 0, so this file has no shebang and is not executable -- same convention as +# base-files' own uci-defaults scripts (verified in the local checkout). + +[ -d /etc/gitbackup ] || mkdir -m 0700 /etc/gitbackup +chown root:root /etc/gitbackup +chmod 0700 /etc/gitbackup + +exit 0 diff --git a/gitbackup/files/usr/libexec/rpcd/luci.gitbackup b/gitbackup/files/usr/libexec/rpcd/luci.gitbackup new file mode 100755 index 000000000..813111bfa --- /dev/null +++ b/gitbackup/files/usr/libexec/rpcd/luci.gitbackup @@ -0,0 +1,678 @@ +#!/bin/sh +# shellcheck shell=sh +# +# luci.gitbackup -- rpcd plugin, ubus object "luci.gitbackup" (spec "rpcd и +# ACL"). Everything the CLI (usr/sbin/gitbackup) can do becomes reachable +# from the web here, and NOTHING ELSE ever runs: this file's own job is +# translating between the rpcd "list"/"call" protocol (TechRef: +# https://openwrt.org/docs/techref/rpcd) and a `gitbackup` CLI invocation -- +# never `git`, `sysupgrade`, or any other router command directly. +# +# Ticket 21 (R110/R111/R109, D07 -- the operator's own words: "вызывать под +# капотом может любые команды роутера. желательно только gitbackup"). Before +# this ticket, gbrpc_history/gbrpc_diff/gbrpc_list_paths/gbrpc_audit_paths +# below ran `git ls-remote`/`git fetch`/`git log`/`git diff`/`git diff-tree`/ +# `sysupgrade -l` themselves, straight against gitbackup.origin.url out of +# UCI -- WITHOUT ever running it through remoteurl.sh's own gb_parse_url +# first, which is the one validator this package already had for exactly +# this string (ticket 03). `gitbackup` itself always has validated its own +# GB_URL before spawning git (cmd_test/cmd_run already did; cmd_diff/ +# cmd_restore now do too, ticket 21) -- so routing every one of these +# through the CLI closes that gap for free, rather than teaching this file +# a second copy of the same check. Where the CLI had no equivalent yet +# (the branch's commit list, a diff between two arbitrary commits), ticket +# 21 added `gitbackup history`/`gitbackup diff ` instead of +# leaving a second git call here. What is left below with logic of its own +# is exactly the methods that touch no external command at all: set_paths/ +# set_secret write plain files, validate_cron calls schedule.sh's own pure +# shell functions. +# +# Protocol, confirmed against this checkout's own stock examples +# (luci-app-https-dns-proxy, luci-app-squid) rather than invented: +# luci.gitbackup list -> one JSON object, method name -> example +# params object (types only, ubus does not +# enforce this against real calls). +# luci.gitbackup call -> reads exactly one line of compact JSON +# (the call's params) from stdin, prints +# exactly one JSON object on stdout, and +# always exits 0 -- ubus turns a nonzero +# exit into an RPC-level error with no +# body, which would break this ticket's +# own "ошибка тоже JSON, а не пустой +# ответ" requirement. +# +# `get_secret` does not exist anywhere below. `status` prints only +# token_set (bool), never the token itself. +# +# GB_SHARE/GB_STATE_DIR/GB_BIN are the same override seam +# usr/sbin/gitbackup and etc/init.d/gitbackup already use, so tests/run.sh +# can point this at a tree, state directory and CLI binary that are not the +# router's real ones; the router never sets any of them. + +set -u + +GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}" +GB_STATE_DIR="${GB_STATE_DIR:-/var/run/gitbackup}" +GB_BIN="${GB_BIN:-/usr/sbin/gitbackup}" +GB_SYSUPGRADE_CONF="${GB_SYSUPGRADE_CONF:-/etc/sysupgrade.conf}" + +# shellcheck disable=SC1091 # GB_SHARE is a runtime path, not resolvable statically +. "$GB_SHARE/lib.sh" +# device.sh (gb_device_id/gb_expand) and auth.sh (gb_git_env, the one `eval +# "$(...)"` this package's whole codebase has ever needed) are deliberately +# NOT sourced here any more (ticket 21): both were only ever used by the +# git-calling helpers this file no longer has. `gitbackup` itself still +# sources both to do that work -- this file just never touches either +# again, which is the strongest available answer to "can this eval be +# removed": it is gone from this process entirely, not merely justified. +# shellcheck disable=SC1091 +. "$GB_SHARE/schedule.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/paths.sh" +# card.sh (ticket 09) may not exist on a system built before it landed -- +# same "command -v gb_card" tolerance usr/sbin/gitbackup's own cmd_run +# already uses, not a new convention invented here. +if [ -r "$GB_SHARE/card.sh" ]; then + # shellcheck disable=SC1091 + . "$GB_SHARE/card.sh" +fi + +# --------------------------------------------------------------------------- +# JSON param helpers -- jsonfilter, not jshn.sh: interfaces.md only promises +# "jsonfilter есть, ubus есть", jshn.sh is a libubox implementation detail +# this package's own DEPENDS never asked for, and the rest of gitbackup +# already hand-rolls JSON output (lib.sh's gb_json_str/gb_json_esc) instead +# of pulling in a second writer -- reusing that here keeps one JSON style +# in the whole package instead of two. +# --------------------------------------------------------------------------- + +# _gb_rpc_param -- empty string (not an error) +# for a missing/wrong-type field: jsonfilter itself exits 1 and prints +# nothing when the path does not resolve (confirmed live against this +# project's own owlab image), which every caller below treats the same as +# "field omitted". +_gb_rpc_param() { + printf '%s' "$1" | jsonfilter -e "$2" 2>/dev/null +} + +# _gb_rpc_param_list -- one element per +# line (jsonfilter's own "@.x[*]" behavior, confirmed live), empty output +# for a missing array. +_gb_rpc_param_list() { + printf '%s' "$1" | jsonfilter -e "$2" 2>/dev/null +} + +# _gb_rpc_error -- the one shape every failed method below prints: +# a JSON object with a "reason" field, never an empty body and never a bare +# exit code (ticket 10 acceptance criterion). +_gb_rpc_error() { + printf '{ "reason": %s }\n' "$(gb_json_str "$1")" +} + +# _gb_rpc_ok_msg -- "{ "": "" }", the shared +# shape for the several methods that just wrap one piece of CLI text. +_gb_rpc_text() { + printf '{ "%s": %s }\n' "$1" "$(gb_json_str "$2")" +} + +# --------------------------------------------------------------------------- +# Method implementations, one gbrpc_ per ACL entry -- the case in +# main() at the bottom names every one of these and no others, and +# t_rpcd_acl_matches_plugin (tests/run.sh) checks the reverse direction, so +# the two can never quietly drift apart. +# --------------------------------------------------------------------------- + +# --- read tier --------------------------------------------------------- + +gbrpc_status() { + _gb_rpc_out=$("$GB_BIN" status 2>/dev/null) + if [ -n "$_gb_rpc_out" ]; then + printf '%s\n' "$_gb_rpc_out" + else + _gb_rpc_error 'gitbackup status produced no output' + fi +} + +gbrpc_log() { + _gb_rpc_lines=$(_gb_rpc_param "$1" '@.lines') + case "$_gb_rpc_lines" in '' | *[!0-9]*) _gb_rpc_lines=200 ;; esac + _gb_rpc_text text "$("$GB_BIN" log "$_gb_rpc_lines" 2>/dev/null)" +} + +gbrpc_pubkey() { + _gb_rpc_out=$("$GB_BIN" pubkey 2>&1) + _gb_rpc_rc=$? + if [ "$_gb_rpc_rc" -eq 0 ]; then + _gb_rpc_text pubkey "$_gb_rpc_out" + else + _gb_rpc_error "$_gb_rpc_out" + fi +} + +# gbrpc_list_paths -- three distinct sets, on purpose (the read/write +# asymmetry this ticket exists to close): "paths" is the RAW, unexpanded +# content of GB_SYSUPGRADE_CONF (paths.sh's own gb_paths_list) -- exactly +# what the file contains, comments and blank lines included. "entries" +# (ticket 26) is gb_paths_entries -- the same content with every comment +# and blank line dropped, i.e. only genuine path entries, a directory line +# still included verbatim -- what the LuCI view lists and edits, and the +# only shape gbrpc_set_paths below may ever write back; a stock +# /etc/sysupgrade.conf's own header comments and commented-out example +# lines are not entries and must never render as a removable row. "effective" +# is plain `sysupgrade -l`, the full union spec §sysupgrade names +# (GB_SYSUPGRADE_CONF ∪ /lib/upgrade/keep.d/* ∪ changed conffiles) already +# recursively expanded to individual files/symlinks. Read-only: it exists +# for sizing (gb_paths_size_kb's own walk) and so a caller can show what is +# already covered without the operator having added it. Collapsing "paths" +# and "effective" into one field (the previous shape of this method) is +# exactly what let a directory a human added survive only until the next +# `set_paths` call, which would write the already-expanded file list +# straight back to disk and permanently lose the "anything added to this +# directory later is covered too" property a real sysupgrade gives a +# directory line. +# +# Ticket 21: used to build both halves itself, calling `sysupgrade -l` +# directly -- the one external command this method ever needed, and the +# one thing "только gitbackup" says this file should never do again. The +# CLI already builds exactly this shape (usr/sbin/gitbackup's own +# `paths list --json`, ticket 21) for the same reason `config_diff` below +# has always wrapped `gitbackup diff` instead of re-deriving it here. +gbrpc_list_paths() { + _gb_rpc_out=$("$GB_BIN" paths list --json 2>&1) + _gb_rpc_rc=$? + if [ "$_gb_rpc_rc" -eq 0 ]; then + printf '%s\n' "$_gb_rpc_out" + else + _gb_rpc_error "$_gb_rpc_out" + fi +} + +# gbrpc_validate_cron -- live validation for the Settings raw-cron field +# (spec "живая валидацию в Settings"): reuses schedule.sh's own +# gb_cron_valid/gb_cron_next rather than reimplementing either, so the +# rpcd-facing answer and the crontab that gb_cron_apply actually writes can +# never disagree about what is valid. +gbrpc_validate_cron() { + _gb_rpc_expr=$(_gb_rpc_param "$1" '@.expr') + # gb_cron_valid prints its reason on stderr only on failure -- captured + # unconditionally here (empty when the expression is fine) so both + # branches below read from the one variable instead of calling it a + # third time. + _gb_rpc_why=$(gb_cron_valid "$_gb_rpc_expr" 2>&1 >/dev/null) + if gb_cron_valid "$_gb_rpc_expr" >/dev/null 2>&1; then + _gb_rpc_next=$(gb_cron_next "$_gb_rpc_expr" 2>/dev/null) + printf '{ "valid": true, "next": %s }\n' "$(gb_json_str "$_gb_rpc_next")" + else + printf '{ "valid": false, "reason": %s }\n' "$(gb_json_str "$_gb_rpc_why")" + fi +} + +# gbrpc_history -- last N commits of this device's own branch: sha, +# subject (which already starts with the backup's own date, spec G03.1, so +# there is no separate "date" field to keep in sync with it) and the list +# of /etc/config/*-and-everything-else paths that commit actually touched. +# +# Ticket 21: this used to resolve GB_URL out of UCI and fetch/log/diff-tree +# it itself, WITHOUT ever validating its shape through gb_parse_url first +# -- exactly the bypass the ticket exists to close (the operator's own +# words: "вызывать под капотом может любые команды роутера. желательно +# только gitbackup"). `gitbackup history` (usr/sbin/gitbackup, ticket 21) +# now does the exact same fetch/log/diff-tree work, but only after running +# GB_URL through gb_parse_url the same way cmd_test/cmd_run always have -- +# this wrapper only ever forwards --limit and relays whatever the CLI +# printed (success: its own valid JSON body; failure: its stderr, wrapped +# into the shared { "reason": ... } shape every other method here uses). +gbrpc_history() { + _gb_rpc_limit=$(_gb_rpc_param "$1" '@.limit') + case "$_gb_rpc_limit" in '' | *[!0-9]*) _gb_rpc_limit=20 ;; esac + + _gb_rpc_out=$("$GB_BIN" history --limit "$_gb_rpc_limit" 2>&1) + _gb_rpc_rc=$? + if [ "$_gb_rpc_rc" -eq 0 ]; then + printf '%s\n' "$_gb_rpc_out" + else + _gb_rpc_error "$_gb_rpc_out" + fi +} + +# gbrpc_diff -- plain `git diff ` text, restricted implicitly to +# this device's own branch (its tree only ever holds GB_PREFIX's own +# content to begin with, per gb_build_tree) -- no separate path filter +# needed on top of that. +# +# Ticket 21: `from`/`to` used to be checked only for non-emptiness, with no +# separator in front of them in the `git diff` call that followed -- a +# value shaped like an option (leading "-") would have reached git's own +# argument parser as one. This still checks for the missing-field case +# itself (a fast, obvious refusal that need not spend a process on the +# CLI), but the actual FORM check -- "a full 40-character commit sha, not +# just any non-empty string" -- now lives once, in `gitbackup diff` +# (usr/sbin/gitbackup, ticket 21): the same "одна точка входа -- одна точка +# валидации" this whole ticket asks for. A malformed value is refused by +# the CLI (exit 2) and relayed here as the same { "reason": ... } shape, +# never reaching a `git` argv at all. +# +# In the ACL's WRITE tier, not read, unlike every other method here that +# only reads. Not because it writes anything -- it does not -- but because +# of what it returns: `git diff` between two commits of the backup branch +# is the CONTENT of the backed-up files, and at the shipped default +# (visibility=private, security.scrub='0') that content is unscrubbed -- +# /etc/shadow's root hash, the dropbear host keys, the WPA PSKs. A read +# grant is meant to answer "is the backup healthy", which status/log/ +# history/config_diff all do without handing over a single file's bytes +# (config_diff compares manifests: paths, modes, hashes, never content). +# Splitting the tiers on "may see the router's secrets" rather than on +# "changes something" is what makes the read tier actually safe to hand +# out. The LuCI History view's own "View diff" button therefore needs the +# write grant, which is the same grant its neighbouring "Restore" button +# has always needed. +gbrpc_diff() { + _gb_rpc_from=$(_gb_rpc_param "$1" '@.from') + _gb_rpc_to=$(_gb_rpc_param "$1" '@.to') + if [ -z "$_gb_rpc_from" ] || [ -z "$_gb_rpc_to" ]; then + _gb_rpc_error 'both "from" and "to" commit shas are required' + return 0 + fi + + _gb_rpc_out=$("$GB_BIN" diff "$_gb_rpc_from" "$_gb_rpc_to" 2>&1) + _gb_rpc_rc=$? + if [ "$_gb_rpc_rc" -ne 0 ]; then + _gb_rpc_error "$_gb_rpc_out" + return 0 + fi + _gb_rpc_text diff "$_gb_rpc_out" +} + +# gbrpc_config_diff -- "has the LIVE config drifted from the last commit", +# which the two-sha `diff` above cannot answer (that one compares two +# already-committed shas, ticket 14/R124 History). This wraps `gitbackup +# diff` (usr/sbin/gitbackup's cmd_diff, ticket 17) instead: it re-collects +# the current backup set and compares its manifest against the manifest of +# the last commit pushed to this device's own branch, field by field -- +# catching a chmod/chown that `git diff`/`git status` never sees at all +# (same blob, same tree entry mode class). No params; `gitbackup diff` +# itself resolves the branch/prefix from UCI exactly like `run` does. +# +# `gitbackup diff` prints nothing at all when there is no difference (its +# own documented contract) and returns 0 either way once the comparison +# ran; a nonzero exit only ever means it could not even get as far as +# comparing (gb_die: bad config, exit 2; unreachable remote, exit 3) -- +# turned into the same { "reason": ... } shape every other method here +# uses, never a bare exit code, so the overview indicator can tell "no +# drift" apart from "could not check" instead of defaulting either one to +# "all saved". +gbrpc_config_diff() { + _gb_rpc_out=$("$GB_BIN" diff 2>&1) + _gb_rpc_rc=$? + if [ "$_gb_rpc_rc" -ne 0 ]; then + _gb_rpc_error "$_gb_rpc_out" + return 0 + fi + if [ -n "$_gb_rpc_out" ]; then + printf '{ "differs": true, "text": %s }\n' "$(gb_json_str "$_gb_rpc_out")" + else + printf '{ "differs": false, "text": "" }\n' + fi +} + +# gbrpc_audit_paths -- G02: what changed on this router since the base +# image but is NOT in the backup set. /overlay/upper is the standard +# OpenWrt overlayfs upper layer (everything the running system has written +# relative to the read-only squashfs lower layer); anything under it that +# is neither hard-excluded (/etc/gitbackup/**, this package's own secrets) +# nor already named by `sysupgrade -l` is exactly "changed and not backed +# up". +# +# Ticket 21: this used to walk /overlay/upper and call `sysupgrade -l` +# itself -- no user-controlled input reaches it (no params at all), so it +# was never the injection risk history/diff/list_paths were, but it is +# still an external command run directly by this file instead of by +# `gitbackup`, which "только gitbackup" does not carve out an exception +# for. `gitbackup paths audit` (usr/sbin/gitbackup, ticket 21) is the exact +# same walk, moved rather than rewritten -- including the VERIFY below, +# which is about the walk itself, not about which process performs it. +# +# VERIFY: measured only that /overlay/upper does not exist inside the +# owlab package-test container used to develop this file (a plain +# extracted rootfs, never booted through OpenWrt's own fstab/mount_root) -- +# not confirmed against a real overlay mount on the owrt2512 stand. Falls +# back to an empty list rather than erroring when the directory is absent, +# so a router (or dev container) without a live overlay mount still gets a +# valid, honestly-empty answer instead of a broken one. +gbrpc_audit_paths() { + _gb_rpc_out=$("$GB_BIN" paths audit 2>&1) + _gb_rpc_rc=$? + if [ "$_gb_rpc_rc" -eq 0 ]; then + printf '%s\n' "$_gb_rpc_out" + else + _gb_rpc_error "$_gb_rpc_out" + fi +} + +# --- write tier ---------------------------------------------------------- + +# gbrpc_run/gbrpc_test/gbrpc_restore all share one shape: the operation can +# run past any sane ubus call timeout (network waits, `test`'s own +# ConnectTimeout=15 twice over, `run`'s full collect/push cycle), so none +# of them wait for the CLI to finish. Each backgrounds it, detached from +# this script's own stdin/stdout/stderr (`/dev/null 2>&1 &`, +# the same detach shape etc/init.d/gitbackup's own config_change debounce +# timer already uses and that this project's tests have already proven +# survives its parent exiting), and answers immediately with +# `{ "started": true }`. The operator watches progress through `log` +# (ticket 10 acceptance criterion: "результат забирается через log") -- +# every one of these already logs via gb_log under the shared "gitbackup" +# syslog tag, so no new log format is needed here. +gbrpc_run() { + "$GB_BIN" run --trigger luci /dev/null 2>&1 & + printf '{ "started": true }\n' +} + +gbrpc_test() { + "$GB_BIN" test /dev/null 2>&1 & + printf '{ "started": true }\n' +} + +# gbrpc_hostkey -- ticket 20. gbrpc_test above backgrounds `gitbackup test` +# and answers immediately, so it can never hand the operator an SSH host +# key's fingerprint to confirm -- and `gitbackup test`'s own stdin is +# ` `hostkey show` (dial, report, write nothing); +# present -> `hostkey accept ` (commit ONLY if it matches what +# is actually cached from the last `show` -- gb_hostkey_accept's own job, +# not this file's). Never accepts a host key with no fingerprint given: an +# empty "fingerprint" here can only mean "show", not "accept anything". +gbrpc_hostkey() { + _gb_rpc_fp=$(_gb_rpc_param "$1" '@.fingerprint') + if [ -n "$_gb_rpc_fp" ]; then + _gb_rpc_out=$("$GB_BIN" hostkey accept "$_gb_rpc_fp" 2>&1) + else + _gb_rpc_out=$("$GB_BIN" hostkey show 2>&1) + fi + _gb_rpc_rc=$? + if [ "$_gb_rpc_rc" -eq 0 ]; then + printf '%s\n' "$_gb_rpc_out" + else + _gb_rpc_error "$_gb_rpc_out" + fi +} + +# gbrpc_keygen -- ticket 25. "force" with no "confirm" (or the wrong one) +# no longer regenerates anything over an existing key: `gitbackup keygen +# --force` now only prints the current key's fingerprint and refuses (exit +# 4, cmd_keygen's own gb_die message) -- exactly the same show-then-name- +# it-back shape gbrpc_hostkey above already uses for the host-key prompt. +# The fingerprint is pulled back out of cmd_keygen's own first stdout line +# ("confirm-required ", relayed verbatim ahead of its +# stderr explanation) rather than out of the human-readable message text, +# so a future wording change to that message cannot silently break this +# parse. +gbrpc_keygen() { + _gb_rpc_force=$(_gb_rpc_param "$1" '@.force') + _gb_rpc_confirm=$(_gb_rpc_param "$1" '@.confirm') + _gb_rpc_args='' + case "$_gb_rpc_force" in true | 1) _gb_rpc_args='--force' ;; esac + if [ -n "$_gb_rpc_confirm" ]; then + # shellcheck disable=SC2086 # $_gb_rpc_args is empty or exactly one flag token + _gb_rpc_out=$("$GB_BIN" keygen $_gb_rpc_args --confirm "$_gb_rpc_confirm" 2>&1) + else + # shellcheck disable=SC2086 # $_gb_rpc_args is empty or exactly one flag token + _gb_rpc_out=$("$GB_BIN" keygen $_gb_rpc_args 2>&1) + fi + _gb_rpc_rc=$? + if [ "$_gb_rpc_rc" -eq 0 ]; then + printf '{ "ok": true, "message": %s }\n' "$(gb_json_str "$_gb_rpc_out")" + return + fi + if [ "$_gb_rpc_rc" -eq 4 ]; then + _gb_rpc_fp=$(printf '%s\n' "$_gb_rpc_out" | sed -n '1s/^confirm-required //p') + printf '{ "ok": false, "confirm_required": true, "fingerprint": %s, "reason": %s }\n' \ + "$(gb_json_str "$_gb_rpc_fp")" "$(gb_json_str "$_gb_rpc_out")" + return + fi + printf '{ "ok": false, "reason": %s }\n' "$(gb_json_str "$_gb_rpc_out")" +} + +# gbrpc_set_secret -- writes gitbackup.origin.token_file directly (there is +# no CLI subcommand for this: the CLI never accepts a secret as an +# argument, spec "Токен... никогда в argv", and a web form field is not +# argv, but it is still not going anywhere near UCI -- R109, "/etc/config/* +# входит в sysupgrade -l"). Never logs the value: gb_log calls below name +# only the file path, exactly like gb_keygen's own logging does for the +# key it writes. +# +# An empty value clears the secret rather than writing a lone newline: a +# 1-byte file would still pass cmd_status's own `[ -s "$token_file" ]` +# check and misreport token_set=true for a field the operator just cleared. +gbrpc_set_secret() { + _gb_rpc_value=$(_gb_rpc_param "$1" '@.value') + _gb_rpc_file=$(gb_uci_get gitbackup.origin.token_file /etc/gitbackup/token) + if [ -z "$_gb_rpc_file" ]; then + _gb_rpc_error 'gitbackup.origin.token_file is empty' + return 0 + fi + + _gb_rpc_dir=$(dirname "$_gb_rpc_file") + mkdir -p "$_gb_rpc_dir" 2>/dev/null + chmod 0700 "$_gb_rpc_dir" 2>/dev/null + + if [ -n "$_gb_rpc_value" ]; then + printf '%s\n' "$_gb_rpc_value" >"$_gb_rpc_file" + else + : >"$_gb_rpc_file" + fi + if ! chmod 0600 "$_gb_rpc_file" 2>/dev/null; then + _gb_rpc_error "could not set permissions on $_gb_rpc_file" + return 0 + fi + gb_log notice "luci.gitbackup set_secret: wrote $_gb_rpc_file" + + _gb_rpc_token_set=false + [ -s "$_gb_rpc_file" ] && _gb_rpc_token_set=true + printf '{ "ok": true, "token_set": %s }\n' "$_gb_rpc_token_set" +} + +# gbrpc_set_paths -- writes /etc/sysupgrade.conf, one RAW path per line, +# exactly as given (a directory line included, never expanded -- that +# expansion is sysupgrade's own job at upgrade time, not this method's). +# +# Every entry is validated through paths.sh's own gb_paths_validate -- +# the same function the CLI's `gitbackup paths add` already calls -- not a +# second, hand-rolled copy of its rules. Previously only two of its four +# checks (absolute, the fixed blacklist) were duplicated here, leaving a +# space in the path and a nonexistent path to the LuCI view's own JS to +# catch; that was never a safe backstop, because a write straight to flash +# is not something to trust a client's own validation for, and this +# method is not the LuCI view's exclusive caller. Routing through the one +# shared validator also fixes the JS view's own long-flagged gap: it could +# never reliably tell a dangling symlink (a legitimate entry, e.g. +# /etc/resolv.conf) from a genuinely missing path, because no rpcd ACL +# anywhere grants `fs.lstat`. This function runs ON the router, so `-e || +# -L` (gb_paths_validate's own check) sees the real filesystem and gets it +# right without needing that ACL grant at all. +# +# Rejected entries are reported, not silently dropped (spec: "отклоняются +# с объяснением, а не молча"), but do not abort the whole write -- the +# remaining, valid paths are still saved. +# +# Ticket 26: this used to write the validated entries as the ENTIRE new +# file (`mv "$_gb_rpc_tmp" "$GB_SYSUPGRADE_CONF"`, nothing else). The LuCI +# Paths view only ever sends back gb_paths_entries' own list (comments +# excluded by design, see gbrpc_list_paths's own header above), so that +# used to mean the first `set_paths` call against a stock +# /etc/sysupgrade.conf -- one Add or Remove click anywhere -- silently +# erased its two header-comment lines and its two commented-out example +# lines, because this method never looked at them at all before +# overwriting the file. paths.sh's own gb_paths_replace_entries is the +# fix: it re-reads GB_SYSUPGRADE_CONF's comment/blank lines off disk right +# before the write and keeps them, unconditionally, regardless of what +# this call's "paths" param contained -- so a save from the view can never +# lose a line it was never shown. What still writes back verbatim, +# unexpanded, is exactly the caller's own entries -- ticket 13's decision. +gbrpc_set_paths() { + _gb_rpc_dir=$(dirname "$GB_SYSUPGRADE_CONF") + mkdir -p "$_gb_rpc_dir" 2>/dev/null + + _gb_rpc_tmp="${GB_SYSUPGRADE_CONF}.newtmp.$$" + _gb_rpc_kept_f="${_gb_rpc_tmp}.kept" + _gb_rpc_rej_f="${_gb_rpc_tmp}.rej" + : >"$_gb_rpc_tmp" + : >"$_gb_rpc_kept_f" + : >"$_gb_rpc_rej_f" + + # One ready-made JSON fragment per line in each scratch file -- + # gb_json_str already escapes any real newline in its input (lib.sh), + # so a line-at-a-time read below can never split a value in half. This + # validation loop runs in its own subshell (the pipe from + # _gb_rpc_param_list), which is fine: it only ever writes to these two + # files, never to a variable this function reads back afterward. + _gb_rpc_param_list "$1" '@.paths[*]' | while IFS= read -r _gb_rpc_p || [ -n "$_gb_rpc_p" ]; do + [ -n "$_gb_rpc_p" ] || continue + _gb_rpc_reason=$(gb_paths_validate "$_gb_rpc_p" 2>&1 >/dev/null) + if [ -n "$_gb_rpc_reason" ]; then + printf '{ "path": %s, "reason": %s }\n' \ + "$(gb_json_str "$_gb_rpc_p")" "$(gb_json_str "$_gb_rpc_reason")" >>"$_gb_rpc_rej_f" + else + printf '%s\n' "$_gb_rpc_p" >>"$_gb_rpc_tmp" + printf '%s\n' "$(gb_json_str "$_gb_rpc_p")" >>"$_gb_rpc_kept_f" + fi + done + + gb_paths_replace_entries "$(cat "$_gb_rpc_tmp" 2>/dev/null)" + rm -f "$_gb_rpc_tmp" + + printf '{ "ok": true, "written": [' + _gb_rpc_first=1 + while IFS= read -r _gb_rpc_j || [ -n "$_gb_rpc_j" ]; do + [ -n "$_gb_rpc_j" ] || continue + [ "$_gb_rpc_first" -eq 1 ] || printf ',' + _gb_rpc_first=0 + printf '%s' "$_gb_rpc_j" + done <"$_gb_rpc_kept_f" + printf '], "rejected": [' + _gb_rpc_first=1 + while IFS= read -r _gb_rpc_j || [ -n "$_gb_rpc_j" ]; do + [ -n "$_gb_rpc_j" ] || continue + [ "$_gb_rpc_first" -eq 1 ] || printf ',' + _gb_rpc_first=0 + printf '%s' "$_gb_rpc_j" + done <"$_gb_rpc_rej_f" + printf '] }\n' + rm -f "$_gb_rpc_kept_f" "$_gb_rpc_rej_f" +} + +gbrpc_restore() { + _gb_rpc_device=$(_gb_rpc_param "$1" '@.device') + _gb_rpc_commit=$(_gb_rpc_param "$1" '@.commit') + _gb_rpc_dry=$(_gb_rpc_param "$1" '@.dry_run') + _gb_rpc_force=$(_gb_rpc_param "$1" '@.force') + _gb_rpc_pkgs=$(_gb_rpc_param "$1" '@.with_packages') + + if [ -z "$_gb_rpc_device" ]; then + _gb_rpc_error 'device is required' + return 0 + fi + + set -- --device "$_gb_rpc_device" --yes + [ -n "$_gb_rpc_commit" ] && set -- "$@" --commit "$_gb_rpc_commit" + case "$_gb_rpc_dry" in true | 1) set -- "$@" --dry-run ;; esac + case "$_gb_rpc_force" in true | 1) set -- "$@" --force ;; esac + case "$_gb_rpc_pkgs" in true | 1) set -- "$@" --with-packages ;; esac + + "$GB_BIN" restore "$@" /dev/null 2>&1 & + printf '{ "started": true }\n' +} + +# gbrpc_card -- card.sh (ticket 09) may not exist yet on a system built +# ahead of it landing; the same tolerance usr/sbin/gitbackup's own cmd_run +# already applies, not a stub invented for this file only. +gbrpc_card() { + if ! command -v gb_card >/dev/null 2>&1; then + _gb_rpc_error 'the recovery card generator is not installed on this system yet' + return 0 + fi + _gb_rpc_file=$(mktemp "${TMPDIR:-/tmp}/gbrpc-card.XXXXXX") || { + _gb_rpc_error 'could not create a scratch file under /tmp' + return 0 + } + if gb_card "$_gb_rpc_file" 2>/dev/null; then + _gb_rpc_text card "$(cat "$_gb_rpc_file")" + else + _gb_rpc_error 'the recovery card could not be generated, see the log' + fi + rm -f "$_gb_rpc_file" +} + +# --------------------------------------------------------------------------- +# rpcd entry point +# --------------------------------------------------------------------------- + +case "${1:-}" in + list) + cat <<'EOF' +{ + "status": {}, + "log": { "lines": 0 }, + "diff": { "from": "", "to": "" }, + "config_diff": {}, + "pubkey": {}, + "list_paths": {}, + "history": { "limit": 0 }, + "audit_paths": {}, + "validate_cron": { "expr": "" }, + "run": {}, + "keygen": { "force": true, "confirm": "" }, + "test": {}, + "hostkey": { "fingerprint": "" }, + "set_secret": { "value": "" }, + "set_paths": { "paths": [] }, + "restore": { "device": "", "commit": "", "dry_run": true, "force": true, "with_packages": true }, + "card": {} +} +EOF + ;; + call) + # `|| _gb_rpc_input='{}'` here (rather than `|| true`) would silently + # DISCARD a real, fully-read line: confirmed live against the actual + # rpcd daemon (not just a manual `echo ... | plugin call` test, + # which never reproduced this) -- rpcd's own call params arrive on + # stdin with NO trailing newline, so `read -r` reads the whole line + # correctly but still returns nonzero for hitting EOF instead of a + # newline. Every parameterized method (log/diff/history/ + # validate_cron/set_secret/set_paths/restore) would otherwise always + # see "{}" and never its real arguments, while a hand-typed + # `printf '...\n' | ... call ...` test -- which DOES add a trailing + # newline -- would keep looking correct throughout development. + IFS= read -r _gb_rpc_input || true + [ -n "$_gb_rpc_input" ] || _gb_rpc_input='{}' + case "${2:-}" in + status) gbrpc_status ;; + log) gbrpc_log "$_gb_rpc_input" ;; + diff) gbrpc_diff "$_gb_rpc_input" ;; + config_diff) gbrpc_config_diff ;; + pubkey) gbrpc_pubkey ;; + list_paths) gbrpc_list_paths ;; + history) gbrpc_history "$_gb_rpc_input" ;; + audit_paths) gbrpc_audit_paths ;; + validate_cron) gbrpc_validate_cron "$_gb_rpc_input" ;; + run) gbrpc_run ;; + keygen) gbrpc_keygen "$_gb_rpc_input" ;; + test) gbrpc_test ;; + hostkey) gbrpc_hostkey "$_gb_rpc_input" ;; + set_secret) gbrpc_set_secret "$_gb_rpc_input" ;; + set_paths) gbrpc_set_paths "$_gb_rpc_input" ;; + restore) gbrpc_restore "$_gb_rpc_input" ;; + card) gbrpc_card ;; + *) _gb_rpc_error "unknown method '${2:-}'" ;; + esac + ;; +esac + +exit 0 diff --git a/gitbackup/files/usr/sbin/gitbackup b/gitbackup/files/usr/sbin/gitbackup new file mode 100755 index 000000000..a34652c28 --- /dev/null +++ b/gitbackup/files/usr/sbin/gitbackup @@ -0,0 +1,1604 @@ +#!/bin/sh +# shellcheck shell=sh +# +# gitbackup -- CLI entry point and exit-code contract. +# +# Exit codes, shared with every module: 0 ok, 1 general error, 2 invalid +# configuration, 3 network or authentication, 4 refused for safety. +# +# GB_SHARE and GB_STATE_DIR are overridable only so tests/run.sh can point +# this script at a tree that is not /usr/share/gitbackup and a state +# directory that is not /var/run/gitbackup; the router never sets either. + +set -u + +GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}" +GB_STATE_DIR="${GB_STATE_DIR:-/var/run/gitbackup}" + +# shellcheck disable=SC1091 # GB_SHARE is a runtime path, not resolvable statically +. "$GB_SHARE/lib.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/device.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/remoteurl.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/visibility.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/auth.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/collect.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/scrub.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/gitio.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/restore.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/paths.sh" +# card.sh does not exist yet (ticket 09, bootstrap): cmd_run's own RECOVERY.md +# step below checks for gb_card with `command -v` before calling it rather +# than sourcing a file that is not there yet -- "ничего не устанавливать +# молча" cuts both ways, this does not invent card.sh's API in the meantime +# either. +if [ -r "$GB_SHARE/card.sh" ]; then + # shellcheck disable=SC1091 + . "$GB_SHARE/card.sh" +fi + +usage() { + cat <<'EOF' +Usage: gitbackup [options] + +Commands: + run run a backup now + status print current status as JSON + collect build a backup set and manifest without pushing + diff show what changed since the last successful run, or [from to] + (two full commit shas) for the diff between two backups + history list this device's own backup commits as JSON + keygen generate the SSH deploy key ([--force [--confirm ]] + to replace an existing one -- --force alone only shows its + fingerprint and refuses; naming it back with --confirm proceeds) + pubkey print the deploy key's public key + test verify connectivity and remote configuration + hostkey show|accept -- confirm the remote's SSH host key + without an interactive terminal (ticket 20, the web-flow path) + paths list [--json]|add|del|audit -- edit /etc/sysupgrade.conf + restore restore configuration from a commit + card print a recovery card + log show recent gitbackup log entries +EOF +} + +# gb_cron_looks_valid +# +# A minimal structural check -- not the validator schedule.sh will own once +# it exists (ticket 06), just enough to keep config validation honest today. +# busybox crond has no @-macros at all (CONFIG_FEATURE_CROND_SPECIAL_TIMES=n, +# measured on the 25.12.4 stand) and silently drops any crontab line shorter +# than six fields, so a config this obviously broken is worth exit 2 now +# rather than a cron job that never fires and never says why. +gb_cron_looks_valid() { + # shellcheck disable=SC2086 # word-splitting is the point: counting the expression's fields + set -- $1 + [ "$#" -eq 5 ] || return 1 + case "$1" in '@'*) return 1 ;; esac + return 0 +} + +# _gb_valid_full_sha -- exactly 40 lowercase hex characters: the one +# shape a commit identifier coming from this package's own git plumbing +# ever takes (gitio.sh's `git log --format=%H`/gb_remote_head/ +# gb_commit_push, all full sha1 hexdigests, never abbreviated). Ticket 21 +# (R110/R111/R109, D07): `cmd_diff`'s two-commit form and `cmd_restore`'s +# --commit both hand a caller-supplied string straight to a bare `git` +# argument (diff/checkout/cat-file) -- checking only "non-empty" let a value +# shaped like an option ("-o value", "--upload-pack=...") reach that +# argument unexamined. This is deliberately a single shared check: "одна +# точка входа -- одна точка валидации" applies just as much to two callers +# inside the same binary as it does to the rpcd plugin bypassing this +# binary entirely (this ticket's own headline finding). +_gb_valid_full_sha() { + case "$1" in + ''|*[!0-9a-f]*) return 1 ;; + esac + # The charset check above only rules out the WRONG characters; a 7-char + # abbreviated sha or a 41-char typo both pass it just as easily as a + # real 40-char sha does, so length is checked separately here. + [ "${#1}" -eq 40 ] +} + +# gb_validate_config -- resolves the device name and enforces the config rules +# from the spec, dying with exit 2 on the first one that fails. For subcommands +# that actually act on the remote -- run, test, restore, and every other +# not-implemented-yet stub in this ticket -- an unusable config is worth +# refusing before anything starts. status does NOT call this (see +# gb_status_config): a freshly installed router's default config is not an +# error a read-only status check gets to refuse, only something it has to +# report honestly. Sets GB_DEVICE, GB_URL and GB_SCRUB for callers. +gb_validate_config() { + GB_DEVICE=$(gb_device_id) || exit $? + + _gb_sched=$(gb_uci_get gitbackup.main.schedule daily) + if [ "$_gb_sched" = cron ]; then + _gb_cron=$(gb_uci_get gitbackup.main.cron_expr) + gb_cron_looks_valid "$_gb_cron" || + gb_die 2 "gitbackup.main.cron_expr: '$_gb_cron' is not a 5-field cron expression busybox crond can run (no @macros)" + fi + + GB_URL=$(gb_uci_get gitbackup.origin.url) + [ -n "$GB_URL" ] || gb_die 2 'gitbackup.origin.url: empty, a repository url is required' + + GB_SCRUB=$(_gb_effective_scrub) +} + +# _gb_effective_scrub -- R99: a public remote is scrubbed whether the user +# remembered to say so or not. This overrides only the in-memory decision for +# this run/status call, not the user's saved gitbackup.security.scrub -- +# silently rewriting their config is its own kind of surprise. Shared by the +# strict and the lenient validator so the two never disagree about it. +_gb_effective_scrub() { + _gb_vis=$(gb_uci_get gitbackup.origin.visibility private) + _gb_scrub=$(gb_uci_get gitbackup.security.scrub 0) + if [ "$_gb_vis" = public ] && [ "$(gb_json_bool "$_gb_scrub")" != true ]; then + gb_log notice 'gitbackup.origin.visibility=public forces security.scrub on for this run' + _gb_scrub=1 + fi + printf '%s\n' "$_gb_scrub" +} + +# gb_status_config -- the same fields gb_validate_config resolves, but never +# exits. A field that would make the strict validator die instead lands in +# GB_MISSING (a space-separated list of option names) and GB_CONFIGURED is set +# to false; `gitbackup status` on the acceptance criterion's fresh-install +# config (device_id=hostname, hostname 'OpenWrt', empty url) must print valid +# JSON, not exit 2. Sets GB_DEVICE (possibly empty), GB_URL, GB_SCRUB, +# GB_CONFIGURED and GB_MISSING. +gb_status_config() { + GB_MISSING='' + + # gb_device_id dies (exit 2) inside this command substitution's own + # subshell on any of its three failure modes; the subshell's exit status + # lands in $?, and the die's own log line is discarded here rather than + # reported twice. + if ! GB_DEVICE=$(gb_device_id 2>/dev/null); then + GB_DEVICE='' + GB_MISSING="$GB_MISSING device_id" + fi + + _gb_sched=$(gb_uci_get gitbackup.main.schedule daily) + if [ "$_gb_sched" = cron ]; then + _gb_cron=$(gb_uci_get gitbackup.main.cron_expr) + gb_cron_looks_valid "$_gb_cron" || GB_MISSING="$GB_MISSING cron_expr" + fi + + GB_URL=$(gb_uci_get gitbackup.origin.url) + [ -n "$GB_URL" ] || GB_MISSING="$GB_MISSING url" + + GB_SCRUB=$(_gb_effective_scrub) + + if [ -n "$GB_MISSING" ]; then + GB_CONFIGURED=false + else + GB_CONFIGURED=true + fi +} + +cmd_status() { + _gb_enabled=$(gb_uci_get gitbackup.main.enabled 0) + _gb_token_file=$(gb_uci_get gitbackup.origin.token_file /etc/gitbackup/token) + _gb_token_set=false + [ -s "$_gb_token_file" ] && _gb_token_set=true + + _gb_last_run='null' + [ -f "$GB_STATE_DIR/last_run" ] && _gb_last_run=$(cat "$GB_STATE_DIR/last_run") + + printf '{\n' + printf ' "enabled": %s,\n' "$(gb_json_bool "$_gb_enabled")" + printf ' "configured": %s,\n' "$GB_CONFIGURED" + printf ' "device": %s,\n' "$(gb_json_str "$GB_DEVICE")" + printf ' "scrub": %s,\n' "$(gb_json_bool "$GB_SCRUB")" + printf ' "token_set": %s,\n' "$_gb_token_set" + printf ' "last_run": %s,\n' "$_gb_last_run" + printf ' "missing": [' + _gb_first=1 + for _gb_m in $GB_MISSING; do + [ "$_gb_first" -eq 1 ] || printf ',' + printf '%s' "$(gb_json_str "$_gb_m")" + _gb_first=0 + done + printf ']\n' + printf '}\n' +} + +# cmd_test -- `gitbackup test` (ticket 04, spec "Аутентификация" + +# "Гейт видимости"). Reaches the remote end to end and tells the operator +# exactly which of the three ways that can fail actually happened, rather +# than one generic error: +# +# exit 3 network unreachable, or the remote rejected the credentials +# exit 4 the repository is publicly visible -- push refused outright +# +# Both network-unreachable and auth-rejected land on exit 3: the shared +# exit-code contract (interfaces.md, "Коды выхода") has exactly one slot for +# "network or auth", by design -- what tells the two apart for the operator +# is the message this prints and logs, not the exit code. +# +# Deliberately does NOT call lib.sh's gb_have_net as a pre-check (an earlier +# version of this function did): measured live on the owlab 25.12.4 stand, +# this image's busybox nc applet does not implement `-w` at all -- +# `nc -w 5 host port` prints nc's usage banner and exits 1 for every host, +# reachable or not, which made gb_have_net report every remote as +# unreachable including a real, working connection to github.com. git's own +# error text already distinguishes "could not connect" from "connected but +# rejected" without needing a second, less reliable tool to ask first (see +# _gb_test_classify below) -- confirmed against real output from this same +# stand for both the https and ssh transports. +cmd_test() { + _gb_parsed=$(gb_parse_url "$GB_URL") || + gb_die 2 "gitbackup.origin.url: '$GB_URL' does not match any supported remote URL form" + # shellcheck disable=SC2086 # word-splitting is the point: five fields from one line + set -- $_gb_parsed + _gb_scheme="$1" + _gb_host="$2" + _gb_port="$3" + + # The visibility gate first: a repository confirmed public is refused + # before this ever dials the remote for real, and "could not tell" (exit + # 3, spec "Гейт видимости": "3 означает проверить не удалось, а не + # можно") is branched on separately from "confirmed not public" (0) -- + # never folded into "proceed". + gb_visibility_ok "$GB_URL" + case $? in + 4) gb_die 4 "$GB_URL is publicly visible to an anonymous request; push refused" ;; + 3) gb_die 3 "could not verify whether $GB_URL is public (network or provider API unreachable); try again once connectivity is confirmed" ;; + esac + + # Host key acceptance only happens here, never in `run` (spec: + # "Host key принимается только внутри gitbackup test"). Safe to call on + # every `test` run: gb_accept_hostkey itself is a no-op (return 0) once + # the key is already trusted. Its own ssh call is what actually dials + # the network for an ssh:// remote -- a host that cannot be reached at + # all is caught right here (rc 2) rather than falling through to + # git ls-remote below. + if [ "$_gb_scheme" = ssh ]; then + _gb_hk_port="$_gb_port" + [ "$_gb_hk_port" != 0 ] || _gb_hk_port=22 + gb_accept_hostkey "$_gb_host" "$_gb_hk_port" + case $? in + 0) ;; + 1) gb_die 3 "$_gb_host:$_gb_hk_port host key was not accepted" ;; + 3) + # Ticket 20: no interactive input to ask for confirmation + # with (e.g. rpcd's own `gitbackup test ' to trust it" + ;; + *) gb_die 3 "$_gb_host:$_gb_hk_port host key could not be obtained -- is the network reachable?" ;; + esac + fi + + # eval, not process substitution (a bashism this CLI cannot use, + # interfaces.md: POSIX sh for busybox ash) -- gb_git_env's own quoting + # is what makes this eval safe. + eval "$(gb_git_env)" + if _gb_err=$(git ls-remote "$GB_URL" 2>&1 >/dev/null); then + gb_log notice "gitbackup test: $GB_URL is reachable and authenticated" + # Ticket 25: a successful ls-remote against the configured remote is + # proof the key currently at key_file (if any) is accepted by the + # provider right now -- exactly the event gb_keygen's own + # "id_ed25519.old" is being kept around to wait for (auth.sh, see + # gb_keygen's header comment). A no-op whenever nothing was ever + # kept, which is every `test` run that was not preceded by a + # `keygen --force`. + gb_keygen_forget_old + printf 'ok: %s is reachable and authenticated\n' "$GB_URL" + return 0 + fi + + _gb_test_classify "$_gb_host" "$_gb_err" +} + +# _gb_test_classify -- the message-only half of the +# network/auth split: both exit 3 (see cmd_test's own header comment), but +# the operator still needs to know which one happened. Matched against +# git's own wording for a connection that never got made at all -- +# confirmed live on the owlab stand: `git ls-remote https://127.0.0.1:1/...` +# gives "Failed to connect to 127.0.0.1 port 1 ...: Error", and +# `git ls-remote ssh://git@127.0.0.1:2/...` gives +# "ssh: connect to host 127.0.0.1 port 2: Connection refused" -- both +# wrapped in git's own "fatal: unable to access"/"Could not read from +# remote repository". Anything else (measured: "Permission denied +# (publickey)" for a real host with the wrong key) is authentication, not +# network. +_gb_test_classify() { + _gb_h="$1" + _gb_e="$2" + case "$_gb_e" in + *'Could not resolve host'*|*'Failed to connect'*|*'Connection timed out'*| \ + *'Connection refused'*|*'Network is unreachable'*|*'ssh: connect to host'*|*'No route to host'*) + gb_die 3 "cannot reach $_gb_h: $_gb_e" + ;; + *) + gb_die 3 "authentication to $GB_URL failed: $_gb_e" + ;; + esac +} + +# cmd_hostkey show|accept -- ticket 20, the web-flow half of +# host key acceptance: `gitbackup test`'s own interactive prompt +# (gb_accept_hostkey) is unreachable from a caller with no stdin at all +# (rpcd's own `"$GB_BIN" test -- commits the fingerprint gb_hostkey_show +# cached, but only if matches it exactly +# +# JSON is this command's own boundary (gb_hostkey_show/gb_hostkey_accept +# below print plain text, same split gb_pubkey/`gitbackup pubkey` already +# has) so the rpcd plugin can relay this command's stdout unchanged, the +# same convention `paths list --json`/`history` already use. +# +# Only meaningful for an ssh:// remote -- an https:// remote authenticates +# with a token, not a host key, and has none to show or accept. +cmd_hostkey() { + _gb_hk_sub="${1:-}" + [ -n "$_gb_hk_sub" ] && shift + _gb_hk_fp="${1:-}" + + _gb_parsed=$(gb_parse_url "$GB_URL") || + gb_die 2 "gitbackup.origin.url: '$GB_URL' does not match any supported remote URL form" + # shellcheck disable=SC2086 # word-splitting is the point: five fields from one line + set -- $_gb_parsed + _gb_hk_scheme="$1" + _gb_hk_host="$2" + _gb_hk_port="$3" + [ "$_gb_hk_port" != 0 ] || _gb_hk_port=22 + + if [ "$_gb_hk_scheme" != ssh ]; then + gb_die 2 "gitbackup.origin.url uses $_gb_hk_scheme://, not ssh:// -- there is no host key to show or accept" + fi + + case "$_gb_hk_sub" in + show) + _gb_hk_out=$(gb_hostkey_show "$_gb_hk_host" "$_gb_hk_port") + _gb_hk_rc=$? + [ "$_gb_hk_rc" -eq 0 ] || + gb_die 3 "could not reach $_gb_hk_host:$_gb_hk_port to obtain its host key -- is the network reachable?" + + # shellcheck disable=SC2086 # word-splitting is the point + set -- $_gb_hk_out + _gb_hk_state="$1" + if [ "$_gb_hk_state" = trusted ]; then + printf '{ "host": %s, "port": %s, "trusted": true }\n' \ + "$(gb_json_str "$_gb_hk_host")" "$_gb_hk_port" + else + shift 3 + _gb_hk_fp_text="$*" + printf '{ "host": %s, "port": %s, "trusted": false, "fingerprint": %s }\n' \ + "$(gb_json_str "$_gb_hk_host")" "$_gb_hk_port" "$(gb_json_str "$_gb_hk_fp_text")" + fi + ;; + accept) + [ -n "$_gb_hk_fp" ] || + gb_die 2 'gitbackup hostkey accept: a fingerprint argument is required (the exact text "hostkey show" printed)' + gb_hostkey_accept "$_gb_hk_host" "$_gb_hk_port" "$_gb_hk_fp" + case $? in + 0) printf '{ "ok": true }\n' ;; + 4) gb_die 4 "fingerprint for $_gb_hk_host:$_gb_hk_port does not match the pending host key; refusing to record it" ;; + *) gb_die 1 "no pending host key for $_gb_hk_host:$_gb_hk_port -- run 'gitbackup hostkey show' first" ;; + esac + ;; + *) + gb_die 2 "gitbackup hostkey: unknown subcommand '$_gb_hk_sub' (expected show or accept)" + ;; + esac +} + +# --------------------------------------------------------------------------- +# run (ticket 07, spec "Как коммит попадает в репозиторий без клона") +# --------------------------------------------------------------------------- +# +# The 15-step algorithm split across two functions: cmd_run does steps 1-6 +# (flock, the network precheck, the /tmp working directory and its trap, +# the space check, the visibility gate) -- everything that needs GB_URL in +# its real, schema-valid form (gb_parse_url, gb_visibility_ok both die on +# anything else) and that ends in "skipped, exit 0" far more often than it +# reaches git at all. _gb_run_backup does steps 7-15 (collect, compare, +# build, commit, push, state) and reads GB_URL only as an opaque string +# handed straight to `git` -- it never re-parses it -- which is what makes +# it callable on its own, against a real local bare repository, for +# gitio.sh's own tests (interfaces.md: gitio.sh is the one module tested +# against a real repo, not stubs). + +# _gb_run_join_comma -- reads lines on stdin, prints them comma-and-space +# joined on one line, empty input producing empty output. Used for both the +# subject's short device-config list and the body's full path list. +_gb_run_join_comma() { + _gb_rjc_first=1 + while IFS= read -r _gb_rjc_l || [ -n "$_gb_rjc_l" ]; do + [ -n "$_gb_rjc_l" ] || continue + if [ "$_gb_rjc_first" -eq 1 ]; then _gb_rjc_first=0; else printf ', '; fi + printf '%s' "$_gb_rjc_l" + done +} + +# _gb_run_changed_configs +# +# Prints the basenames (one per line, sorted, deduped) of every +# /etc/config/* path whose manifest entry line differs between the two +# files -- added, removed, or changed in any field (content OR mode/uid/ +# gid, so a chmod-only edit still shows up here, same as it already does +# in gb_manifest_equal's own broader "did anything change" verdict). No +# comm(1) (confirmed absent from the 25.12.4 image) and no diff(1) +# dependency: concatenating both files' matching lines and keeping only the +# ones sort+uniq -u find exactly once is the same "differs between the two +# sets" test using only applets already in DEPENDS. +_gb_run_changed_configs() { + _gb_rcc_old="$1" + _gb_rcc_new="$2" + { + [ -r "$_gb_rcc_old" ] && grep '"path":"/etc/config/' "$_gb_rcc_old" + grep '"path":"/etc/config/' "$_gb_rcc_new" + } 2>/dev/null | sort | uniq -u | sed -n 's#.*"path":"/etc/config/\([^"]*\)".*#\1#p' | sort -u +} + +# _gb_run_write_message +# +# Spec "Формат коммита": subject starts with the backup's own UTC date and +# time (G03: readable straight out of a commit list on the web, no need to +# open the commit), then the short device name, then up to three changed +# config names -- "and N more" past that. Body: Changed (full +# /etc/config/* paths), Trigger (this run's --trigger), OpenWrt (release + +# revision straight out of /etc/openwrt_release, parsed with parameter +# expansion rather than a quoting-fragile sed regex -- the file's own +# `KEY='value'` shape needs nothing more), Device. +_gb_run_write_message() { + _gb_rwm_file="$1" + _gb_rwm_trigger="$2" + _gb_rwm_list="$3" + _gb_rwm_count="$4" + + _gb_rwm_when=$(date -u +'%Y-%m-%d %H:%M') + + _gb_rwm_short=$(printf '%s\n' "$_gb_rwm_list" | sed -n '1,3p' | _gb_run_join_comma) + [ -n "$_gb_rwm_short" ] || _gb_rwm_short='config' + if [ "$_gb_rwm_count" -gt 3 ]; then + _gb_rwm_short="$_gb_rwm_short and $((_gb_rwm_count - 3)) more" + fi + + _gb_rwm_paths=$(printf '%s\n' "$_gb_rwm_list" | sed -n '/./ s#^#/etc/config/#p' | _gb_run_join_comma) + [ -n "$_gb_rwm_paths" ] || _gb_rwm_paths='(no /etc/config/* entry changed)' + + _gb_rwm_release='' + _gb_rwm_revision='' + _gb_rwm_relfile="${GB_ROOT:-}/etc/openwrt_release" + if [ -r "$_gb_rwm_relfile" ]; then + while IFS='=' read -r _gb_rwm_k _gb_rwm_v || [ -n "$_gb_rwm_k" ]; do + _gb_rwm_v="${_gb_rwm_v#\'}" + _gb_rwm_v="${_gb_rwm_v%\'}" + case "$_gb_rwm_k" in + DISTRIB_RELEASE) _gb_rwm_release="$_gb_rwm_v" ;; + DISTRIB_REVISION) _gb_rwm_revision="$_gb_rwm_v" ;; + esac + done <"$_gb_rwm_relfile" + fi + + { + printf '%s %s: %s\n' "$_gb_rwm_when" "$GB_DEVICE" "$_gb_rwm_short" + printf '\n' + printf 'Changed: %s\n' "$_gb_rwm_paths" + printf 'Trigger: %s\n' "$_gb_rwm_trigger" + printf 'OpenWrt: %s %s\n' "$_gb_rwm_release" "$_gb_rwm_revision" + printf 'Device: %s\n' "$GB_DEVICE" + } >"$_gb_rwm_file" +} + +# _gb_run_write_state [] +# +# Two files, spec step 15: GB_STATE_DIR/last_run is the one cmd_status +# already reads back verbatim as its own "last_run" JSON field (ticket 01) +# -- just a quoted timestamp string, nothing richer. GB_STATE_DIR/state.json +# is the fuller record the spec itself asks for (result, reason, branch, +# commit, per-stage timings) for a future status/overview screen; both are +# tmpfs (git is the source of truth, spec: "Состояние в +# /var/run/gitbackup/state.json (tmpfs); git -- источник истины") and +# neither is ever read back by this CLI itself. +_gb_run_write_state() { + _gb_rws_result="$1" + _gb_rws_reason="$2" + _gb_rws_commit="${3:-}" + _gb_rws_timings="${4:-{\"collect_s\":0,\"compare_s\":0,\"push_s\":0}}" + + mkdir -p "$GB_STATE_DIR" 2>/dev/null + _gb_rws_now=$(date -u +%Y-%m-%dT%H:%M:%SZ) + printf '%s\n' "$(gb_json_str "$_gb_rws_now")" >"$GB_STATE_DIR/last_run" 2>/dev/null + { + printf '{\n' + printf ' "generated": %s,\n' "$(gb_json_str "$_gb_rws_now")" + printf ' "result": %s,\n' "$(gb_json_str "$_gb_rws_result")" + printf ' "reason": %s,\n' "$(gb_json_str "$_gb_rws_reason")" + printf ' "branch": %s,\n' "$(gb_json_str "${_gb_branch:-}")" + printf ' "commit": %s,\n' "$(gb_json_str "$_gb_rws_commit")" + printf ' "timings": %s\n' "$_gb_rws_timings" + printf '}\n' + } >"$GB_STATE_DIR/state.json" 2>/dev/null +} + +# _gb_run_ensure_main_index -- ticket 22, criterion 4: +# with the shipped "device/{device}" branch template, a fresh repository +# has a "main" branch that GitHub renders on the repository's own front +# page, but nothing ever commits to it -- a human opening the bare repo URL +# after a router died sees an empty page, even though every device branch +# carries a working README.md (this file's own README.md-at-branch-root +# step, right above). This writes a short, one-time index to "main" +# instead, ONLY when main does not exist at all yet: `gb_remote_head main` +# printing nothing with rc 0 (interfaces.md's own contract: empty + 0 means +# "reachable, branch absent", same read every call site in this file +# already gives GB_PARENT). +# +# Deliberately called only once per device -- from _gb_run_backup, only +# when THIS device's own branch was itself brand new (see +# _gb_branch_was_new above) -- not on every run: an extra `git ls-remote` +# to check "does main exist" on every single scheduled run, forever, is a +# cost every run would pay for a question that only has one interesting +# answer ever (the first time any device pushes to an empty repository). +# Once main exists, later devices' own first runs see it already there and +# skip this entirely -- so this never becomes a second push on any run +# past the very first one, for any device. +# +# A generic, no-device index rather than one gb_card call per device: main +# is shared across every device on this remote, and gb_card's whole +# document (auth flags, a specific device's own bootstrap one-liner) only +# makes sense once a browser is already on the ONE branch it is about. +# This index's only job is to point that browser at the right branch to +# begin with; it is not a second recovery card and cannot drift from the +# real one printed on each device branch, because it never repeats any of +# that content. +# +# Best-effort only: failure here must never fail the backup run that is +# still in progress when this is called, same convention gb_card's own +# header uses for exactly the same reason. Prints nothing; return value is +# not checked by the caller. +_gb_run_ensure_main_index() { + _gb_rmi_repodir="$1" + _gb_rmi_branch="$2" + + # GB_PARENT (environment, gitio.sh's own seam) still holds the DEVICE + # branch's own parent from the caller -- irrelevant here and actively + # wrong to reuse: gb_build_tree would try to seed main's tree from a + # commit that belongs to a different branch entirely. Cleared so this + # always produces main's own root commit, same shape as any device's + # own first-ever backup (gb_commit_push's empty-parent form). + GB_PARENT='' + + _gb_rmi_head=$(gb_remote_head main 2>/dev/null) + _gb_rmi_rc=$? + [ "$_gb_rmi_rc" -eq 0 ] || return 0 + [ -z "$_gb_rmi_head" ] || return 0 + + _gb_rmi_readme="$WORK/main-readme.md" + { + printf '# gitbackup\n\n' + printf 'This repository holds automatic router configuration backups. Each\n' + printf 'router pushes to its own branch here -- by default named\n' + # shellcheck disable=SC2016 # backticks are markdown code spans in + # the generated document, not attempted command substitution + printf '`device/`, e.g. `%s` for the router that just created this page.\n\n' "$_gb_rmi_branch" + # shellcheck disable=SC2016 + printf 'Open one of the `device/*` branches above: its own README.md carries\n' + printf 'that router'\''s ready-made recovery instructions, including a one-line\n' + printf 'command to restore a freshly flashed replacement.\n' + } >"$_gb_rmi_readme" 2>/dev/null + [ -s "$_gb_rmi_readme" ] || return 0 + + _gb_rmi_msg="$WORK/main-readme-message" + printf 'gitbackup: initialize repository index\n\nSee device/* branches for each router'\''s own backup and recovery\ninstructions.\n' \ + >"$_gb_rmi_msg" 2>/dev/null + + # An empty scratch treedir -- gb_build_tree's own walk stages every + # regular file/symlink it finds under , so this has to hold + # NOTHING: the README is added purely through the extra_file/ + # extra_gitpath pair, at the exact root path "README.md", GB_PREFIX + # entirely bypassed (this repo has no per-device prefix to speak of on + # "main"). GB_PREFIX is still required to be non-empty by gb_build_tree + # itself (`${GB_PREFIX:?...}`) even though nothing here ever uses it. + _gb_rmi_emptydir="$WORK/main-readme-empty" + rm -rf "$_gb_rmi_emptydir" + mkdir -p "$_gb_rmi_emptydir" 2>/dev/null + + _gb_rmi_tree=$(GB_PREFIX='.' gb_build_tree "$_gb_rmi_repodir" "$_gb_rmi_emptydir" "$_gb_rmi_readme" 'README.md' 2>/dev/null) + [ -n "$_gb_rmi_tree" ] || return 0 + + gb_commit_push "$_gb_rmi_repodir" "$_gb_rmi_tree" '' "$_gb_rmi_msg" main >/dev/null 2>&1 + return 0 +} + +# _gb_run_backup -- spec steps 7-15, see this section's own +# header comment for why this is split out of cmd_run. +_gb_run_backup() { + _gb_trigger="$1" + + _gb_branch=$(gb_expand "$(gb_uci_get gitbackup.origin.branch 'device/{device}')") + _gb_prefix=$(gb_expand "$(gb_uci_get gitbackup.main.path_prefix 'devices/{device}')") + # shellcheck disable=SC2034 # read by gb_build_tree (gitio.sh) as an environment seam, not used in this file + GB_PREFIX="$_gb_prefix" + + _gb_outdir="$WORK/tree" + _gb_repodir="$WORK/repo" + # gb_fetch_meta (gitio.sh) also does this, but only runs at all when a + # parent already exists -- a brand-new branch's first-ever backup has + # no parent to fetch, and gb_build_tree/gb_commit_push both still need + # a real .git here to run their own plumbing against. + [ -d "$_gb_repodir/.git" ] || git init -q "$_gb_repodir" || + gb_die 1 "gitbackup run: git init $_gb_repodir failed" + + # Step 7: collect, then scrub in place if this run's effective + # visibility=public forced it on (usr/sbin/gitbackup's own + # _gb_effective_scrub already resolved GB_SCRUB once, before cmd_run). + _gb_t_collect_start=$(date +%s) + gb_collect "$_gb_outdir" || gb_die 1 'gitbackup run: gb_collect failed' + if [ "$(gb_json_bool "$GB_SCRUB")" = true ]; then + gb_scrub "$_gb_outdir" 2>/dev/null | while IFS= read -r _gb_sl || [ -n "$_gb_sl" ]; do + [ -n "$_gb_sl" ] && gb_log info "gitbackup run: scrubbed $_gb_sl" + done + fi + _gb_t_collect_end=$(date +%s) + + # Steps 8-10: who is the parent, what did it look like, did anything + # actually change. GB_PARENT (environment) is gitio.sh's own seam for + # gb_build_tree, not a positional argument -- see gitio.sh's header. + _gb_t_compare_start=$(date +%s) + GB_PARENT=$(gb_remote_head "$_gb_branch") + _gb_head_rc=$? + [ "$_gb_head_rc" -eq 0 ] || gb_die 3 "gitbackup run: could not reach $GB_URL to resolve $_gb_branch" + # Ticket 22: remembered from BEFORE the retry loop below can reassign + # GB_PARENT -- "was this device's branch brand new" has to reflect what + # was true when this run started, not the retry's re-resolved parent. + _gb_branch_was_new="" + [ -z "$GB_PARENT" ] && _gb_branch_was_new=1 + + _gb_old_manifest="$WORK/old_manifest.json" + _gb_changed=1 + if [ -n "$GB_PARENT" ]; then + gb_fetch_meta "$_gb_branch" "$_gb_repodir" || + gb_die 3 "gitbackup run: could not fetch $_gb_branch metadata from $GB_URL" + if git --git-dir="$_gb_repodir/.git" cat-file -p "$GB_PARENT:$_gb_prefix/manifest.json" \ + >"$_gb_old_manifest" 2>/dev/null; then + if gb_manifest_equal "$_gb_old_manifest" "$(gb_manifest_path "$_gb_outdir")"; then + _gb_changed=0 + fi + fi + fi + _gb_t_compare_end=$(date +%s) + _gb_t_collect_s=$((_gb_t_collect_end - _gb_t_collect_start)) + _gb_t_compare_s=$((_gb_t_compare_end - _gb_t_compare_start)) + + if [ "$_gb_changed" -eq 0 ]; then + gb_log notice "gitbackup run: no changes since the last backup on $_gb_branch" + printf 'no changes\n' + _gb_run_write_state ok no-changes "$GB_PARENT" \ + "{\"collect_s\":$_gb_t_collect_s,\"compare_s\":$_gb_t_compare_s,\"push_s\":0}" + gb_log notice "gitbackup run: timings -- collect ${_gb_t_collect_s}s, compare ${_gb_t_compare_s}s, push 0s" + exit 0 + fi + + # 11a: the archive, only now that there is definitely something to + # push (spec: sysupgrade -b's own gzip is not deterministic byte for + # byte, so building it before the compare above would commit a diff + # every single run even when nothing on the router changed). + # + # Never when this run scrubs. sysupgrade -b reads the LIVE + # filesystem, not the tree step 7 just scrubbed, and it lands inside + # $_gb_outdir, which gb_build_tree stages whole (gitio.sh: a bare + # `find . \( -type f -o -type l \)`, no exclude logic of its own) -- + # so the archive carried every byte the scrub had just removed, + # straight into the same commit. That made scrubbing decorative in + # exactly the configuration it exists for: visibility=public forces + # scrub on (_gb_effective_scrub, R99) precisely because anyone can + # read the repository, and this shipped /etc/shadow, the dropbear + # host keys and the WPA PSKs to them anyway, in a tarball, with the + # run reporting success and manifest.scrubbed[] claiming the values + # had been handled. + # + # Skipped rather than scrubbed-then-rebuilt: sysupgrade -b's output + # has to stay something `sysupgrade -r` accepts byte for byte (the + # recovery card's Path 0 is exactly that), and a tarball this package + # reassembles from the scrubbed tree would be a second, hand-rolled + # implementation of a format it does not own. Turning scrub on is the + # operator saying these values must not reach the repository; the + # archive is the one thing here that cannot honour that, so it is the + # thing that gives way. Said out loud, at notice level, because a + # file quietly missing from a backup is exactly the kind of surprise + # a recovery card must not contain. + if [ "$(gb_json_bool "$(gb_uci_get gitbackup.main.archive 1)")" = true ]; then + if [ "$(gb_json_bool "$GB_SCRUB")" = true ]; then + gb_log notice 'gitbackup run: scrubbing is on, so no backup.tar.gz is pushed -- sysupgrade -b reads the live filesystem and would carry exactly the values just scrubbed out of the tree' + else + sysupgrade -b "$_gb_outdir/backup.tar.gz" >/dev/null 2>&1 || + gb_log warning 'gitbackup run: sysupgrade -b failed, continuing without backup.tar.gz' + fi + fi + + # 11b: the recovery card -- only once card.sh (ticket 09) exists; see + # this file's own sourcing block for why it is optional today. + # + # Ticket 22: the exact same content also has to be reachable as + # README.md at the BRANCH ROOT (a sibling of $_gb_prefix), because + # GitHub only ever renders the root README when a human opens the + # branch from a phone after a reset -- devices//RECOVERY.md alone + # is invisible on that first screen. One gb_card call, one file, then + # copied byte for byte -- never a second generator call, which could + # only ever earn a different "Generated:" timestamp and nothing else, + # i.e. exactly the kind of two-texts-that-drifted-apart this ticket + # exists to rule out. gb_build_tree (gitio.sh) stages it at the exact + # git path "README.md", bypassing GB_PREFIX entirely. + _gb_readme_root='' + if command -v gb_card >/dev/null 2>&1; then + gb_card "$_gb_outdir/RECOVERY.md" 2>/dev/null + if [ -s "$_gb_outdir/RECOVERY.md" ]; then + _gb_readme_root="$WORK/branch-readme.md" + cp -f "$_gb_outdir/RECOVERY.md" "$_gb_readme_root" 2>/dev/null || _gb_readme_root='' + fi + fi + + _gb_changed_list=$(_gb_run_changed_configs "$_gb_old_manifest" "$(gb_manifest_path "$_gb_outdir")") + _gb_changed_count=$(printf '%s\n' "$_gb_changed_list" | grep -c .) + _gb_msgfile="$WORK/commit-message" + _gb_run_write_message "$_gb_msgfile" "$_gb_trigger" "$_gb_changed_list" "$_gb_changed_count" + + # Steps 11/12/13/14: build the tree, commit, push, retry once on a + # non-fast-forward (spec step 14) by re-resolving the parent and + # rebuilding the tree on top of it -- gb_build_tree's own GB_PARENT + # seam means this collected backup set ($_gb_outdir, untouched since + # step 7) does not need to be re-collected for the retry, only the git + # plumbing around it does. + _gb_t_push_start=$(date +%s) + _gb_attempt=1 + while :; do + _gb_tree=$(gb_build_tree "$_gb_repodir" "$_gb_outdir" "$_gb_readme_root" 'README.md') + [ -n "$_gb_tree" ] || gb_die 1 'gitbackup run: gb_build_tree failed' + + _gb_commit=$(gb_commit_push "$_gb_repodir" "$_gb_tree" "$GB_PARENT" "$_gb_msgfile" "$_gb_branch") + _gb_push_rc=$? + case "$_gb_push_rc" in + 0) break ;; + 2) + if [ "$_gb_attempt" -ge 2 ]; then + gb_die 1 "gitbackup run: $_gb_branch changed again during the retry, giving up (spec step 14: one retry only)" + fi + gb_log notice "gitbackup run: $_gb_branch moved under us, retrying once" + _gb_attempt=2 + GB_PARENT=$(gb_remote_head "$_gb_branch") + _gb_head_rc=$? + [ "$_gb_head_rc" -eq 0 ] || gb_die 3 "gitbackup run: could not re-resolve $_gb_branch for the retry" + gb_fetch_meta "$_gb_branch" "$_gb_repodir" || + gb_die 3 "gitbackup run: could not re-fetch $_gb_branch metadata for the retry" + ;; + 1) gb_die 1 'gitbackup run: gb_commit_push could not build the commit, see log' ;; + *) gb_die 3 'gitbackup run: push failed, see log' ;; + esac + done + _gb_t_push_end=$(date +%s) + _gb_t_push_s=$((_gb_t_push_end - _gb_t_push_start)) + + gb_log notice "gitbackup run: pushed $_gb_commit to $_gb_branch" + printf 'pushed %s to %s\n' "$_gb_commit" "$_gb_branch" + _gb_run_write_state ok pushed "$_gb_commit" \ + "{\"collect_s\":$_gb_t_collect_s,\"compare_s\":$_gb_t_compare_s,\"push_s\":$_gb_t_push_s}" + gb_log notice "gitbackup run: timings -- collect ${_gb_t_collect_s}s, compare ${_gb_t_compare_s}s, push ${_gb_t_push_s}s" + + # Ticket 22, criterion 4: only ever attempted right after THIS device's + # own branch was created for the very first time (_gb_branch_was_new, + # captured before the retry loop above could reassign GB_PARENT) -- a + # regular run against an already-existing branch never even calls + # gb_remote_head on "main", let alone pushes to it. Best-effort, after + # the real backup already succeeded and its own state is already + # written; never changes this run's own reported result. + if [ -n "$_gb_branch_was_new" ]; then + _gb_run_ensure_main_index "$_gb_repodir" "$_gb_branch" + fi +} + +# cmd_run [--trigger cron|manual|procd|luci] -- spec steps 1-6, then hands +# off to _gb_run_backup for the rest. The default trigger, "cron", is what +# schedule.sh's own crontab line actually invokes (`gitbackup run` with no +# flag at all, ticket 06) -- an explicit --trigger is how the other three +# callers (a human at the CLI, procd's config_change, the LuCI rpcd bridge) +# say which one they are. +cmd_run() { + _gb_trigger=cron + while [ "$#" -gt 0 ]; do + case "$1" in + --trigger) + shift + [ "$#" -gt 0 ] && { _gb_trigger="$1"; shift; } + ;; + --trigger=*) _gb_trigger="${1#--trigger=}"; shift ;; + *) shift ;; + esac + done + + # Step 1: flock, non-blocking. Busybox flock's FD form (`flock -n 9` + # against an fd opened by this same shell's own `exec`) -- confirmed on + # the owlab stand's busybox 1.37.0 (`flock [-sxun] FD | { FILE ... }`). + # Locked -> skipped, exit 0: cron must not mail about an overlap that + # resolved itself (brief, verbatim: "flock /var/run/gitbackup.lock, + # занято -> выход 0, skipped"). + _gb_lockfile="${GB_LOCK_FILE:-/var/run/gitbackup.lock}" + mkdir -p "$(dirname "$_gb_lockfile")" 2>/dev/null + exec 9>"$_gb_lockfile" || gb_die 1 "gitbackup run: cannot open lock file $_gb_lockfile" + if ! flock -n 9; then + gb_log notice 'gitbackup run: another run already holds the lock, skipped' + printf 'skipped: another run is already in progress\n' + exit 0 + fi + + # Step 3: a bounded reachability probe before anything else touches + # disk. gb_have_net (lib.sh, D01) is the fixed implementation. + _gb_parsed=$(gb_parse_url "$GB_URL") || + gb_die 2 "gitbackup.origin.url: '$GB_URL' does not match any supported remote URL form" + # shellcheck disable=SC2086 # word-splitting is the point: five fields from one line + set -- $_gb_parsed + _gb_scheme="$1" + _gb_host="$2" + _gb_port="$3" + _gb_netport="$_gb_port" + if [ "$_gb_netport" = 0 ]; then + if [ "$_gb_scheme" = ssh ]; then _gb_netport=22; else _gb_netport=443; fi + fi + if ! gb_have_net "$_gb_host" "$_gb_netport"; then + gb_log notice "gitbackup run: $_gb_host:$_gb_netport unreachable, skipped" + printf 'skipped: %s is unreachable\n' "$_gb_host" + exit 0 + fi + + # Step 4: everything from here on lives under /tmp -- never flash, + # never USB ("качать репозиторий на роутер вообще нельзя") -- and is + # gone the moment this process ends: $WORK holds a full plaintext + # backup set and, briefly, credentials. + WORK=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup.XXXXXX") || + gb_die 1 'gitbackup run: cannot create a working directory under /tmp' + trap 'rm -rf "$WORK"' EXIT INT TERM + + # Step 5: space, checked before anything is actually collected. + # gb_paths_size_kb (paths.sh, ticket 17) -- shared with `gitbackup + # paths`'s own 2 MB warning so the two can never disagree about what + # "the size of the backup set" means. + _gb_set_kb=$(gb_paths_size_kb) + _gb_need_kb=$((_gb_set_kb * 2 + 1024)) + # The same directory $WORK was just created under, not a hardcoded + # "/tmp": they must agree, or a TMPDIR override (tests/run.sh's own + # seam, since /tmp itself is shared and not size-controllable in a + # unit test) would check one filesystem while actually writing to + # another. + _gb_free_kb=$(gb_free_kb "${TMPDIR:-/tmp}") + case "$_gb_free_kb" in '' | *[!0-9]*) _gb_free_kb=0 ;; esac + if [ "$_gb_free_kb" -lt "$_gb_need_kb" ]; then + gb_die 1 "gitbackup run: not enough space in /tmp -- need ~${_gb_need_kb} KB (backup set ~${_gb_set_kb} KB x2 + 1024 KB headroom), have ${_gb_free_kb} KB free; reduce the backup set (e.g. gitbackup.main.archive='0', or trim the sysupgrade path list) or free up /tmp" + fi + + # Step 6: the visibility gate. Code 3 ("could not verify") is treated + # exactly like "no network" above -- skipped, not a hard failure, spec + # "Гейт видимости": "Сеть недоступна или API ответило 5xx -- прогон + # завершается skipped, а не «считаем приватным»". A confirmed-public + # repository (4) is the one outcome that is never silent. + gb_visibility_ok "$GB_URL" + case $? in + 4) gb_die 4 "$GB_URL is publicly visible to an anonymous request; push refused" ;; + 3) + gb_log notice "gitbackup run: could not verify whether $GB_URL is public, skipped" + printf 'skipped: could not verify repository visibility\n' + exit 0 + ;; + esac + + eval "$(gb_git_env)" + + _gb_run_backup "$_gb_trigger" +} + +# cmd_restore -- ticket 08, spec "Восстановление" (Path 2). Deliberately +# does NOT go through gb_validate_config: that resolves GB_DEVICE via +# gb_device_id, which a fresh/bare router (bootstrap's own Path 1 step 4, +# or any router whose device_id config is not usable yet) cannot answer, +# and the device being restored is an explicit --device argument here, not +# this router's own resolved identity (restore.sh's own header explains +# why gb_expand cannot be reused for it either). Only GB_URL actually has +# to exist for a restore to mean anything. +cmd_restore() { + _gb_r_device='' + _gb_r_commit='' + _gb_r_flags='' + while [ "$#" -gt 0 ]; do + case "$1" in + --device) shift; _gb_r_device="${1:-}"; shift ;; + --device=*) _gb_r_device="${1#--device=}"; shift ;; + --commit) shift; _gb_r_commit="${1:-}"; shift ;; + --commit=*) _gb_r_commit="${1#--commit=}"; shift ;; + --dry-run|--force|--yes|--with-packages) _gb_r_flags="$_gb_r_flags $1"; shift ;; + *) shift ;; + esac + done + + # No explicit --device: fall back to this router's own resolved + # identity, the "restore my own last backup" shape -- bootstrap's own + # Path 1 always passes --device explicitly instead. + [ -n "$_gb_r_device" ] || _gb_r_device=$(gb_device_id) || exit $? + + GB_URL=$(gb_uci_get gitbackup.origin.url) + [ -n "$GB_URL" ] || gb_die 2 'gitbackup.origin.url: empty, a repository url is required' + + # Ticket 21 (R109/D07): every path that ends up calling git on GB_URL + # validates its shape first, no exceptions -- restore used to skip + # straight from UCI to gb_restore's own git ls-remote/fetch/checkout, + # the same "URL used without gb_parse_url" gap the rpcd plugin's own + # resolve-remote bypass had (see the plugin's own header comment). + gb_parse_url "$GB_URL" >/dev/null || + gb_die 2 "gitbackup.origin.url: '$GB_URL' does not match any supported remote URL form" + + # _gb_r_commit reaches restore.sh as a bare `git checkout -- + # `/`git cat-file -e ^{commit}` argument whenever it is + # not empty -- same "the form the product defines, not just non-empty" + # rule as cmd_diff's two-commit form below (_gb_valid_full_sha): + # empty/"HEAD" (the branch tip, the only form bootstrap's own Path 1 + # ever passes) or a full 40-hex commit sha, nothing else, so a value + # shaped like an option can never reach that git argument. + if [ -n "$_gb_r_commit" ] && [ "$_gb_r_commit" != HEAD ]; then + _gb_valid_full_sha "$_gb_r_commit" || + gb_die 2 "gitbackup restore: --commit '$_gb_r_commit' must be a full 40-character commit sha, or omitted/HEAD for the branch tip" + fi + + eval "$(gb_git_env)" + + # shellcheck disable=SC2086 # word-splitting is the point: $_gb_r_flags is zero or more bare flag tokens + gb_restore "$_gb_r_device" "$_gb_r_commit" $_gb_r_flags + exit $? +} + +# --------------------------------------------------------------------------- +# history (ticket 21, R110/R111/D07) -- the branch's own commit list (sha, +# subject, changed paths). Moved here from the rpcd plugin, which used to +# run this exact fetch/log/diff-tree sequence itself, straight against +# gitbackup.origin.url out of UCI, without ever validating its shape first +# (the operator's own words: "вызывать под капотом может любые команды +# роутера. желательно только gitbackup") -- `gitbackup` is now the only +# thing that ever spawns git for this, same as run/test/diff/restore above, +# and GB_URL is run through gb_parse_url before any of them do. +# --------------------------------------------------------------------------- +cmd_history() { + _gb_h_limit=20 + while [ "$#" -gt 0 ]; do + case "$1" in + --limit) shift; _gb_h_limit="${1:-20}"; shift ;; + --limit=*) _gb_h_limit="${1#--limit=}"; shift ;; + *) shift ;; + esac + done + case "$_gb_h_limit" in ''|*[!0-9]*) _gb_h_limit=20 ;; esac + + gb_parse_url "$GB_URL" >/dev/null || + gb_die 2 "gitbackup.origin.url: '$GB_URL' does not match any supported remote URL form" + + _gb_h_branch=$(gb_expand "$(gb_uci_get gitbackup.origin.branch 'device/{device}')") || + gb_die 2 'gitbackup.origin.branch could not be expanded' + + eval "$(gb_git_env)" + + _gb_h_head=$(gb_remote_head "$_gb_h_branch") + _gb_h_head_rc=$? + [ "$_gb_h_head_rc" -eq 0 ] || gb_die 3 "gitbackup history: could not reach $GB_URL to resolve $_gb_h_branch" + if [ -z "$_gb_h_head" ]; then + printf '{ "branch": %s, "commits": [] }\n' "$(gb_json_str "$_gb_h_branch")" + return 0 + fi + + _gb_h_dir=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-history.XXXXXX") || + gb_die 1 'gitbackup history: cannot create a scratch directory under /tmp' + trap 'rm -rf "$_gb_h_dir"' EXIT INT TERM + + # Full history, not gitio.sh's own --depth=1 default: history needs + # every backup's own commit, not just the tip. An explicit empty depth + # skips --depth entirely (gb_fetch_meta's own header, ticket 18) -- + # reused rather than a second fetch implementation, same as restore.sh's + # own past-commit case. + gb_fetch_meta "$_gb_h_branch" "$_gb_h_dir" '' || + gb_die 3 "gitbackup history: could not fetch $_gb_h_branch from $GB_URL" + + _gb_h_tab=$(printf '\t') + printf '{ "branch": %s, "commits": [' "$(gb_json_str "$_gb_h_branch")" + _gb_h_first=1 + git -C "$_gb_h_dir" log -n "$_gb_h_limit" --format='%H%x09%s' FETCH_HEAD 2>/dev/null | + while IFS= read -r _gb_h_line || [ -n "$_gb_h_line" ]; do + _gb_h_sha="${_gb_h_line%%"$_gb_h_tab"*}" + _gb_h_subject="${_gb_h_line#*"$_gb_h_tab"}" + [ "$_gb_h_first" -eq 1 ] || printf ',' + _gb_h_first=0 + printf '{ "sha": %s, "subject": %s, "changed": [' \ + "$(gb_json_str "$_gb_h_sha")" "$(gb_json_str "$_gb_h_subject")" + _gb_h_cfirst=1 + # --root: a ROOT commit has no parent to diff against, and + # diff-tree prints nothing at all without it -- confirmed live + # against a device's own first-ever backup commit. + git -C "$_gb_h_dir" diff-tree --no-commit-id --name-only -r --root "$_gb_h_sha" 2>/dev/null | + while IFS= read -r _gb_h_path || [ -n "$_gb_h_path" ]; do + [ -n "$_gb_h_path" ] || continue + [ "$_gb_h_cfirst" -eq 1 ] || printf ',' + _gb_h_cfirst=0 + printf '%s' "$(gb_json_str "$_gb_h_path")" + done + printf '] }' + done + printf '] }\n' + + rm -rf "$_gb_h_dir" + trap - EXIT INT TERM +} + +# --------------------------------------------------------------------------- +# collect (ticket 17) -- a thin wrapper over gb_collect (collect.sh), the +# same function _gb_run_backup's own step 7 already calls. Exists so the +# backup set and manifest.json can be inspected on demand -- a dry run of +# `run`'s own first half, with nothing pushed and nothing scrubbed (scrub +# is `run`'s own decision to make on a real push, not this command's). +# --------------------------------------------------------------------------- +cmd_collect() { + _gb_c_out='' + while [ "$#" -gt 0 ]; do + case "$1" in + --out) shift; _gb_c_out="${1:-}"; shift ;; + --out=*) _gb_c_out="${1#--out=}"; shift ;; + *) shift ;; + esac + done + [ -n "$_gb_c_out" ] || gb_die 2 'gitbackup collect: --out DIR is required' + + mkdir -p "$_gb_c_out" || gb_die 1 "gitbackup collect: cannot create $_gb_c_out" + gb_collect "$_gb_c_out" || gb_die 1 'gitbackup collect: gb_collect failed' + printf 'collected backup set into %s\n' "$_gb_c_out" +} + +# --------------------------------------------------------------------------- +# diff (ticket 17, D03) -- current configuration vs. the last commit on +# this device's own branch. The one rule this command exists to enforce: +# the source of truth is manifest.json, field by field, NEVER `git status` +# or a checkout -- git stores exactly three file modes (100644/100755/ +# 120000) and has no idea a 644->640 chmod on otherwise-identical content +# ever happened, which is exactly the gap D03/ticket 17 was opened to +# close (spec: "Источником решения обязан быть манифест... манифест ловит +# смену прав, которую git не видит вовсе"). +# +# Reuses exactly the fetch-the-parent's-manifest trick _gb_run_backup's +# own steps 8-10 already use (gb_remote_head, then gb_fetch_meta's +# blob:none partial fetch, then one lazy `git cat-file -p +# :/manifest.json`) -- never a clone, same rule the rest +# of this file follows. No branch yet (first-ever backup) reads as +# "everything currently collected is new", not an error. +# --------------------------------------------------------------------------- + +# _gb_diff_each_entry -- calls " " +# once per entries[] item, unindented, trailing comma stripped -- same +# one-object-per-line shape collect.sh's own gb_manifest_equal and +# restore.sh's own _gb_restore_each_entry already rely on. A missing or +# unreadable (a brand-new branch has no old one) simply calls +# the callback zero times rather than erroring -- both dump helpers below +# handle "no entries" as "empty file", which is exactly what a fresh +# device's first `diff` needs. +_gb_diff_each_entry() { + _gb_de_manifest="$1" + _gb_de_cb="$2" + _gb_de_in=0 + [ -r "$_gb_de_manifest" ] || return 0 + while IFS= read -r _gb_de_line || [ -n "$_gb_de_line" ]; do + case "$_gb_de_line" in + ' "entries": ['*) _gb_de_in=1; continue ;; + ' ],') _gb_de_in=0; continue ;; + esac + [ "$_gb_de_in" -eq 1 ] || continue + _gb_de_obj="${_gb_de_line%,}" + _gb_de_obj="${_gb_de_obj# }" + [ -n "$_gb_de_obj" ] || continue + "$_gb_de_cb" "$_gb_de_obj" + done <"$_gb_de_manifest" +} + +# _gb_diff_json_str/_gb_diff_json_num -- same two small +# readers restore.sh's own _gb_restore_json_str/_gb_restore_json_num +# already are, reimplemented here rather than shared: both modules keep +# their own private helpers by convention (scrub.sh's header comment), +# every *.sh here ending up sourced into one process. +_gb_diff_json_str() { + _gb_djs_obj="$1" + _gb_djs_field="$2" + case "$_gb_djs_obj" in + *'"'"$_gb_djs_field"'":"'*) + _gb_djs_v="${_gb_djs_obj#*\""$_gb_djs_field"\":\"}" + _gb_djs_v="${_gb_djs_v%%\"*}" + printf '%s' "$_gb_djs_v" | sed 's/\\"/"/g; s/\\\\/\\/g' + ;; + *) printf '' ;; + esac +} + +_gb_diff_json_num() { + _gb_djn_obj="$1" + _gb_djn_field="$2" + case "$_gb_djn_obj" in + *'"'"$_gb_djn_field"'":'*) + _gb_djn_v="${_gb_djn_obj#*\""$_gb_djn_field"\":}" + _gb_djn_v="${_gb_djn_v%%[,\}]*}" + printf '%s' "$_gb_djn_v" + ;; + *) printf '' ;; + esac +} + +# _gb_diff_dump_line/_gb_diff_dump -- writes one +# "\t" line per entry to , sorted by path. JSON +# entries never carry a raw tab (paths are filesystem paths; gb_json_esc, +# collect.sh's own writer, only ever escapes '"' and '\'), so splitting on +# \t below is safe for every value this package's own manifests ever +# produce. +_gb_diff_dump_line() { + _gb_dd_obj="$1" + _gb_dd_path=$(_gb_diff_json_str "$_gb_dd_obj" path) + [ -n "$_gb_dd_path" ] && printf '%s\t%s\n' "$_gb_dd_path" "$_gb_dd_obj" >>"$_gb_dd_out" +} +# +# Sorted via plain redirection into a SEPARATE temp file, never +# `sort -o ` -- confirmed live on the owlab 25.12.4 +# stand: this busybox `sort` treats `-o FILE` as a no-op and prints the +# sorted result to STDOUT regardless (the file itself is left completely +# untouched). `_gb_run_backup`'s own caller never redirects _gb_diff_dump's +# stdout anywhere, so that sorted content leaked straight into `gitbackup +# diff`'s own output, ahead of the real report -- measured directly: a +# 37-entry manifest produced 74 lines, the first 37 being this leak. GNU/ +# BSD sort's own -o writes to the file as documented, which is why this +# never showed up on the host. +_gb_diff_dump() { + _gb_dd_out="$2" + : >"$_gb_dd_out" + _gb_diff_each_entry "$1" _gb_diff_dump_line + _gb_dd_sorted="${_gb_dd_out}.sorted.$$" + sort "$_gb_dd_out" >"$_gb_dd_sorted" 2>/dev/null + mv "$_gb_dd_sorted" "$_gb_dd_out" +} + +# _gb_diff_describe_change -- prints one "~" +# line naming every field that actually differs between two entries with +# the same path: type, mode, uid, gid, content (sha256, named rather than +# shown -- a raw hash is not useful to a human reading this), and a +# symlink's target. This is the line that makes a chmod-only edit visible +# at all (spec's own headline example, and the one D03/ticket 17 exists to +# fix) -- mode is compared exactly like every other field, not skipped as +# "cosmetic". +_gb_diff_describe_change() { + _gb_ddc_old="$1" + _gb_ddc_new="$2" + _gb_ddc_path="$3" + _gb_ddc_parts='' + + _gb_ddc_ot=$(_gb_diff_json_str "$_gb_ddc_old" type) + _gb_ddc_nt=$(_gb_diff_json_str "$_gb_ddc_new" type) + [ "$_gb_ddc_ot" = "$_gb_ddc_nt" ] || _gb_ddc_parts="$_gb_ddc_parts type $_gb_ddc_ot->$_gb_ddc_nt;" + + _gb_ddc_om=$(_gb_diff_json_num "$_gb_ddc_old" mode) + _gb_ddc_nm=$(_gb_diff_json_num "$_gb_ddc_new" mode) + [ "$_gb_ddc_om" = "$_gb_ddc_nm" ] || _gb_ddc_parts="$_gb_ddc_parts mode $_gb_ddc_om->$_gb_ddc_nm;" + + _gb_ddc_ou=$(_gb_diff_json_num "$_gb_ddc_old" uid) + _gb_ddc_nu=$(_gb_diff_json_num "$_gb_ddc_new" uid) + [ "$_gb_ddc_ou" = "$_gb_ddc_nu" ] || _gb_ddc_parts="$_gb_ddc_parts uid $_gb_ddc_ou->$_gb_ddc_nu;" + + _gb_ddc_og=$(_gb_diff_json_num "$_gb_ddc_old" gid) + _gb_ddc_ng=$(_gb_diff_json_num "$_gb_ddc_new" gid) + [ "$_gb_ddc_og" = "$_gb_ddc_ng" ] || _gb_ddc_parts="$_gb_ddc_parts gid $_gb_ddc_og->$_gb_ddc_ng;" + + _gb_ddc_osha=$(_gb_diff_json_str "$_gb_ddc_old" sha256) + _gb_ddc_nsha=$(_gb_diff_json_str "$_gb_ddc_new" sha256) + [ "$_gb_ddc_osha" = "$_gb_ddc_nsha" ] || _gb_ddc_parts="$_gb_ddc_parts content changed;" + + _gb_ddc_otgt=$(_gb_diff_json_str "$_gb_ddc_old" target) + _gb_ddc_ntgt=$(_gb_diff_json_str "$_gb_ddc_new" target) + [ "$_gb_ddc_otgt" = "$_gb_ddc_ntgt" ] || _gb_ddc_parts="$_gb_ddc_parts symlink target $_gb_ddc_otgt->$_gb_ddc_ntgt;" + + [ -n "$_gb_ddc_parts" ] && printf '~ %s:%s\n' "$_gb_ddc_path" "$_gb_ddc_parts" +} + +# _gb_diff_compare -- the union of both files' own +# paths (sorted, deduped), one exact-match awk lookup per side per path +# (no `join`(1): interfaces.md's own DEPENDS never promises one, and this +# suite's own host confirms it is not always there either) -- print +# "added"/"removed" for a path on only one side, hand a path on both sides +# to _gb_diff_describe_change, which prints nothing at all when every +# field still matches. No output whatsoever means no differences, exactly +# what "diff на неизменившейся системе печатает пустой результат" asks +# for -- there is no separate "unchanged" branch to keep in sync with this +# one. +_gb_diff_compare() { + _gb_dc_old="$1" + _gb_dc_new="$2" + { cut -f1 "$_gb_dc_old" 2>/dev/null; cut -f1 "$_gb_dc_new" 2>/dev/null; } | sort -u | + while IFS= read -r _gb_dc_p || [ -n "$_gb_dc_p" ]; do + [ -n "$_gb_dc_p" ] || continue + _gb_dc_o=$(awk -F'\t' -v p="$_gb_dc_p" '$1==p{print $2; exit}' "$_gb_dc_old" 2>/dev/null) + _gb_dc_n=$(awk -F'\t' -v p="$_gb_dc_p" '$1==p{print $2; exit}' "$_gb_dc_new" 2>/dev/null) + if [ -z "$_gb_dc_o" ] && [ -n "$_gb_dc_n" ]; then + printf '+ %s (%s)\n' "$_gb_dc_p" "$(_gb_diff_json_str "$_gb_dc_n" type)" + elif [ -n "$_gb_dc_o" ] && [ -z "$_gb_dc_n" ]; then + printf -- '- %s (%s)\n' "$_gb_dc_p" "$(_gb_diff_json_str "$_gb_dc_o" type)" + elif [ "$_gb_dc_o" != "$_gb_dc_n" ]; then + _gb_diff_describe_change "$_gb_dc_o" "$_gb_dc_n" "$_gb_dc_p" + fi + done +} + +# cmd_diff [ ] -- two shapes sharing one name, on purpose (spec/ +# ticket 21 both treat "diff" as one question with two possible baselines, +# never two commands): with no arguments, the original ticket-17 question +# ("live config vs the last commit", read field-by-field out of +# manifest.json -- see this function's own header history above). With +# exactly two arguments, ticket 21's own addition -- a plain `git diff` +# between two already-committed shas (History's per-backup diff, moved here +# from the rpcd plugin, which used to run this same `git diff` itself with +# no shape check on either argument at all beyond non-empty). +# +# Both shapes read GB_URL, so it is validated here exactly once for either +# one (R109/D07: "URL... на каждом пути, где потом вызывается git, на всех +# путях без исключения") -- this command used to skip that check entirely, +# unlike cmd_test/cmd_run, and accepted whatever gitbackup.origin.url held. +cmd_diff() { + gb_parse_url "$GB_URL" >/dev/null || + gb_die 2 "gitbackup.origin.url: '$GB_URL' does not match any supported remote URL form" + + if [ "$#" -eq 2 ]; then + _gb_d_from="$1" + _gb_d_to="$2" + _gb_valid_full_sha "$_gb_d_from" || + gb_die 2 "gitbackup diff: 'from' must be a full 40-character commit sha, got '$_gb_d_from'" + _gb_valid_full_sha "$_gb_d_to" || + gb_die 2 "gitbackup diff: 'to' must be a full 40-character commit sha, got '$_gb_d_to'" + + _gb_dc_branch=$(gb_expand "$(gb_uci_get gitbackup.origin.branch 'device/{device}')") || + gb_die 2 'gitbackup.origin.branch could not be expanded' + + _gb_dc_work=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-diffc.XXXXXX") || + gb_die 1 'gitbackup diff: cannot create a working directory under /tmp' + trap 'rm -rf "$_gb_dc_work"' EXIT INT TERM + + eval "$(gb_git_env)" + + # Full history, not gitio.sh's own --depth=1 default: either commit + # named here can be arbitrarily far back on the branch (History's own + # window is independent of gb_fetch_meta's "just the tip" default). + gb_fetch_meta "$_gb_dc_branch" "$_gb_dc_work" '' || + gb_die 3 "gitbackup diff: could not fetch $_gb_dc_branch from $GB_URL" + + # --end-of-options: belt-and-suspenders alongside _gb_valid_full_sha + # above -- a 40-lowercase-hex string can never start with '-' to + # begin with, but this is the same "где значение всё же идёт в + # команду -- ставь --" (ticket 21) this project already applies to + # every user-influenced argument that reaches a real command. Not a + # bare "--": for `git diff`, that specifically means "end of + # revisions, everything after is a pathspec", which would make git + # treat $_gb_d_from/$_gb_d_to as PATHS instead of commits -- the + # option-parsing-only guard (git >= 2.24, both the dev host's 2.51 + # and the owlab stand's 2.50.1 qualify) is what --end-of-options is + # for instead. + _gb_dc_out=$(git -C "$_gb_dc_work" diff --end-of-options "$_gb_d_from" "$_gb_d_to" 2>&1) + _gb_dc_rc=$? + if [ "$_gb_dc_rc" -ne 0 ]; then + gb_die 3 "gitbackup diff: $_gb_dc_out" + fi + printf '%s\n' "$_gb_dc_out" + return 0 + fi + + if [ "$#" -ne 0 ]; then + gb_die 2 'gitbackup diff: expected either no arguments (live config vs the last commit) or exactly two (from-sha to-sha)' + fi + + _gb_d_branch=$(gb_expand "$(gb_uci_get gitbackup.origin.branch 'device/{device}')") + _gb_d_prefix=$(gb_expand "$(gb_uci_get gitbackup.main.path_prefix 'devices/{device}')") + + _gb_d_work=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-diff.XXXXXX") || + gb_die 1 'gitbackup diff: cannot create a working directory under /tmp' + trap 'rm -rf "$_gb_d_work"' EXIT INT TERM + + eval "$(gb_git_env)" + + _gb_d_parent=$(gb_remote_head "$_gb_d_branch") + _gb_d_head_rc=$? + [ "$_gb_d_head_rc" -eq 0 ] || gb_die 3 "gitbackup diff: could not reach $GB_URL to resolve $_gb_d_branch" + + _gb_d_outdir="$_gb_d_work/tree" + gb_collect "$_gb_d_outdir" || gb_die 1 'gitbackup diff: gb_collect failed' + if [ "$(gb_json_bool "$GB_SCRUB")" = true ]; then + # Same reasoning as _gb_run_backup's own scrub step: comparing an + # unscrubbed local tree against a manifest that was scrubbed before + # it was ever pushed would report every scrubbed field as "changed" + # on every single call. + gb_scrub "$_gb_d_outdir" >/dev/null 2>&1 + fi + + _gb_d_old_manifest="$_gb_d_work/old_manifest.json" + : >"$_gb_d_old_manifest" + if [ -n "$_gb_d_parent" ]; then + _gb_d_repodir="$_gb_d_work/repo" + gb_fetch_meta "$_gb_d_branch" "$_gb_d_repodir" || + gb_die 3 "gitbackup diff: could not fetch $_gb_d_branch metadata from $GB_URL" + git --git-dir="$_gb_d_repodir/.git" cat-file -p "$_gb_d_parent:$_gb_d_prefix/manifest.json" \ + >"$_gb_d_old_manifest" 2>/dev/null + fi + + _gb_d_old_tsv="$_gb_d_work/old.tsv" + _gb_d_new_tsv="$_gb_d_work/new.tsv" + _gb_diff_dump "$_gb_d_old_manifest" "$_gb_d_old_tsv" + _gb_diff_dump "$(gb_manifest_path "$_gb_d_outdir")" "$_gb_d_new_tsv" + + _gb_diff_compare "$_gb_d_old_tsv" "$_gb_d_new_tsv" + return 0 +} + +# _gb_paths_json_array -- prints (one path per line, blanks +# skipped) as a JSON array body (no surrounding brackets, so a caller can +# wrap it in whichever field name applies). Shared by `paths list --json` +# and `paths audit` below -- both used to be built ad hoc, once each, in +# the rpcd plugin itself. +_gb_paths_json_array() { + _gb_pja_first=1 + printf '%s\n' "$1" | while IFS= read -r _gb_pja_p || [ -n "$_gb_pja_p" ]; do + [ -n "$_gb_pja_p" ] || continue + [ "$_gb_pja_first" -eq 1 ] || printf ',' + printf '%s' "$(gb_json_str "$_gb_pja_p")" + _gb_pja_first=0 + done +} + +# --------------------------------------------------------------------------- +# paths (ticket 17, spec "LuCI -> Paths") -- list|add|del over +# /etc/sysupgrade.conf, backed entirely by paths.sh (gb_paths_list/ +# gb_paths_add/gb_paths_del/gb_paths_size_kb/gb_paths_validate). Not +# gated by gb_validate_config, unlike collect/diff/card above: managing +# the extra-paths list has nothing to do with whether a remote is +# configured yet, and a freshly installed router (no gitbackup.origin.url +# at all) must still be able to use it -- gb_validate_config would refuse +# that with exit 2 for a reason this command does not share. +# +# `list --json` and `audit` (ticket 21, R110/D07) are the JSON shapes the +# rpcd plugin's own gbrpc_list_paths/gbrpc_audit_paths used to build +# themselves, calling `sysupgrade -l` (and, for audit, walking +# /overlay/upper) directly -- moved here so gitbackup remains the only +# thing that ever runs those commands ("желательно только gitbackup"). +# --------------------------------------------------------------------------- +cmd_paths() { + _gb_p_sub="${1:-list}" + [ "$#" -gt 0 ] && shift + + case "$_gb_p_sub" in + list) + if [ "${1:-}" = '--json' ]; then + # Three distinct sets, on purpose: "paths" is gb_paths_list's + # own raw, unexpanded content of GB_SYSUPGRADE_CONF, comments + # and blank lines included -- kept for whatever wants to see + # the file exactly as it sits on disk. "entries" (ticket 26) + # is gb_paths_entries: the same content with every comment + # and blank line dropped, i.e. only genuine path entries -- a + # directory line included verbatim, still unexpanded, and the + # only thing a caller should ever list as "one entry, one + # Remove button" or hand back to `set_paths`. Before this + # field existed, callers built that list themselves by + # filtering "paths" (or, before ticket 21, did not filter it + # at all) -- two places able to disagree about what counts as + # an entry; now there is exactly one, gb_paths_entries itself. + # "effective" is plain `sysupgrade -l`, already expanded -- + # read-only, for sizing/audit, never round-tripped into a + # save (see gbrpc_list_paths's own header comment for why + # collapsing the two into one field used to silently narrow + # a directory entry down to a moment-of-save file list). + _gb_p_raw=$(gb_paths_list) + _gb_p_entries=$(gb_paths_entries) + _gb_p_eff=$(sysupgrade -l 2>/dev/null) + printf '{ "paths": [%s], "entries": [%s], "effective": [%s] }\n' \ + "$(_gb_paths_json_array "$_gb_p_raw")" "$(_gb_paths_json_array "$_gb_p_entries")" "$(_gb_paths_json_array "$_gb_p_eff")" + else + gb_paths_list + printf '# total: %s KB (effective sysupgrade set, sysupgrade -l)\n' "$(gb_paths_size_kb)" + fi + ;; + add) + _gb_p_path="${1:-}" + [ -n "$_gb_p_path" ] || gb_die 2 'gitbackup paths add: a path is required' + _gb_p_err=$(gb_paths_add "$_gb_p_path" 2>&1) + _gb_p_rc=$? + if [ "$_gb_p_rc" -eq 0 ]; then + printf 'added %s\n' "$_gb_p_path" + else + gb_die "$_gb_p_rc" "gitbackup paths add: $_gb_p_err" + fi + ;; + del) + _gb_p_path="${1:-}" + [ -n "$_gb_p_path" ] || gb_die 2 'gitbackup paths del: a path is required' + gb_paths_del "$_gb_p_path" + printf 'removed %s\n' "$_gb_p_path" + ;; + audit) + # G02: what changed on this router since the base image but is + # NOT in the backup set. /overlay/upper is the standard OpenWrt + # overlayfs upper layer; anything under it that is neither + # hard-excluded (/etc/gitbackup/**, this package's own secrets) + # nor already named by `sysupgrade -l` is exactly "changed and + # not backed up". + # + # VERIFY: measured only that /overlay/upper does not exist + # inside the owlab package-test container this file's own tests + # run in (a plain extracted rootfs, never booted through + # OpenWrt's own fstab/mount_root) -- not confirmed against a + # real overlay mount on the owrt2512 stand. Falls back to an + # empty list rather than erroring when the directory is absent. + _gb_p_upper="${GB_ROOT:-}/overlay/upper" + if [ ! -d "$_gb_p_upper" ]; then + printf '{ "paths": [] }\n' + return 0 + fi + _gb_p_backed=$(sysupgrade -l 2>/dev/null) + printf '{ "paths": [' + _gb_p_first=1 + ( cd "$_gb_p_upper" 2>/dev/null && find . \( -type f -o -type l \) ) | + while IFS= read -r _gb_p_rel || [ -n "$_gb_p_rel" ]; do + _gb_p_abs="/${_gb_p_rel#./}" + case "$_gb_p_abs" in + /etc/gitbackup | /etc/gitbackup/*) continue ;; + esac + printf '%s\n' "$_gb_p_backed" | grep -qxF "$_gb_p_abs" && continue + [ "$_gb_p_first" -eq 1 ] || printf ',' + _gb_p_first=0 + printf '%s' "$(gb_json_str "$_gb_p_abs")" + done + printf '] }\n' + ;; + *) + gb_die 2 "gitbackup paths: unknown subcommand '$_gb_p_sub' (expected list, add, del or audit)" + ;; + esac +} + +# --------------------------------------------------------------------------- +# card (ticket 17) -- a thin wrapper over gb_card (card.sh, ticket 09). +# gb_card itself never dies and always returns 0 (its own header comment); +# the only failure this wrapper can actually report is card.sh not being +# installed at all. +# --------------------------------------------------------------------------- +cmd_card() { + _gb_cd_out='' + while [ "$#" -gt 0 ]; do + case "$1" in + --out) shift; _gb_cd_out="${1:-}"; shift ;; + --out=*) _gb_cd_out="${1#--out=}"; shift ;; + *) shift ;; + esac + done + [ -n "$_gb_cd_out" ] || gb_die 2 'gitbackup card: --out FILE is required' + command -v gb_card >/dev/null 2>&1 || + gb_die 1 'gitbackup card: the recovery card generator (card.sh) is not installed on this system' + + gb_card "$_gb_cd_out" + printf 'wrote recovery card to %s\n' "$_gb_cd_out" +} + +# cmd_log [lines] -- A01: syslog is the log (lib.sh's own gb_log header +# comment -- logread survives a reboot, a plain variable does not), so +# `gitbackup log` is a thin, tag-filtered read of it. run's own gb_log +# calls above (collect/compare/push timings, skip/refuse reasons, retry +# notices) are what actually show up here; this command adds no format of +# its own to keep in sync with them. +cmd_log() { + _gb_lines="${1:-50}" + case "$_gb_lines" in '' | *[!0-9]*) _gb_lines=50 ;; esac + logread -e gitbackup 2>/dev/null | tail -n "$_gb_lines" +} + +[ "$#" -ge 1 ] || { usage; exit 0; } + +cmd="$1" +shift + +case "$cmd" in + status) + # Lenient on purpose: see gb_status_config. A fresh install's default + # config must not turn a read-only status check into exit 2. + gb_status_config + cmd_status + ;; + keygen) + # No gb_validate_config: generating a deploy key depends only on + # gitbackup.origin.key_file (which always has a default), never on + # GB_URL or the device name -- in the real bootstrap order the key + # is generated and its public half handed to the provider BEFORE a + # remote url even exists to validate. + # + # Ticket 25: --force alone is no longer enough to overwrite an + # existing key (see gb_keygen's own header comment, auth.sh) -- + # --confirm must additionally name back the exact + # fingerprint gb_keygen prints when it refuses. This loop only + # collects the two flags; gb_keygen itself is what decides whether + # what was named actually matches. + _gb_force='' + _gb_confirm='' + while [ "$#" -gt 0 ]; do + case "$1" in + --force) _gb_force=1; shift ;; + --confirm) shift; _gb_confirm="${1:-}"; shift ;; + --confirm=*) _gb_confirm="${1#--confirm=}"; shift ;; + *) shift ;; + esac + done + _gb_kg_out=$(gb_keygen "$_gb_force" "$_gb_confirm") + _gb_kg_rc=$? + if [ "$_gb_kg_rc" -eq 4 ]; then + # The exact "confirm-required " line gb_keygen + # printed is relayed to stdout unchanged first (rpcd's own + # gbrpc_keygen parses this first line back out to hand the + # fingerprint to the web UI, the same way cmd_hostkey's "show" + # output already does for the host-key flow) -- then a + # human-readable explanation follows on stderr via gb_die. + printf '%s\n' "$_gb_kg_out" + # shellcheck disable=SC2086 # word-splitting is the point: the fixed leading token, then the fingerprint text + set -- $_gb_kg_out + shift + gb_die 4 "$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519) already exists (fingerprint: $*) -- keygen --force destroys the private half beyond recovery, and the deploy key already added at your git provider stops working the instant it does; re-run 'gitbackup keygen --force --confirm \"$*\"' to proceed, then add the NEW public key at the provider" + fi + [ "$_gb_kg_rc" -eq 0 ] || exit 1 + printf 'generated %s\n' "$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519)" + ;; + pubkey) + # Same reasoning as keygen: reading back a key that may already + # exist needs no remote configuration at all. + gb_pubkey || exit 1 + ;; + test) + gb_validate_config + cmd_test + ;; + hostkey) + gb_validate_config + cmd_hostkey "$@" + ;; + run) + gb_validate_config + cmd_run "$@" + ;; + log) + # No gb_validate_config: reading the log back is a diagnostic that + # has to work even on a router whose config is not fully set up yet + # (same reasoning as status/keygen/pubkey above). + cmd_log "$@" + ;; + restore) + cmd_restore "$@" + ;; + collect|diff|card) + gb_validate_config + case "$cmd" in + collect) cmd_collect "$@" ;; + diff) cmd_diff "$@" ;; + card) cmd_card "$@" ;; + esac + ;; + history) + gb_validate_config + cmd_history "$@" + ;; + paths) + # No gb_validate_config -- see cmd_paths's own header comment. + cmd_paths "$@" + ;; + *) + usage >&2 + gb_die 1 "unknown command '$cmd'" + ;; +esac diff --git a/gitbackup/files/usr/share/gitbackup/askpass.sh b/gitbackup/files/usr/share/gitbackup/askpass.sh new file mode 100755 index 000000000..7674c8875 --- /dev/null +++ b/gitbackup/files/usr/share/gitbackup/askpass.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# shellcheck shell=sh +# +# gitbackup -- GIT_ASKPASS helper (spec "Аутентификация"). +# +# Not sourced like the rest of usr/share/gitbackup/*.sh -- git execs this +# directly (GIT_ASKPASS=/usr/share/gitbackup/askpass.sh, set by +# auth.sh's gb_git_env) once per missing credential component, with the +# prompt text as $1 ("Username for '...': ", "Password for '...': ", the +# exact wording is git's own and varies by version), and reads exactly one +# line back from this script's stdout. +# +# This is the only place gitbackup.origin.token_file is ever read for a +# push: the token reaches git through this pipe, never through argv, a URL, +# or a log line. +# +# The username prompt is answered with a fixed, non-secret placeholder -- +# NEVER the token -- and only the password prompt gets the token. This is +# not the obvious design: an earlier version answered both prompts with the +# token (a fine-grained PAT works as either field on GitHub, per its own +# docs), on the reasoning that a value read from a file and only ever +# piped to git's stdin cannot show up in `ps` because it was never a +# command-line argument. That reasoning was wrong, caught only by actually +# tracing a live request on the owlab stand (GIT_TRACE=1): git treats +# whatever the username prompt returns as the credential's username, and +# builds the URL for its *next* prompt -- "Password for +# 'http://@host:port': " -- with that value inline, then execs +# this script again with that whole string as $1. A token answered as the +# username therefore reappears as a literal substring of argv on the very +# next invocation, which is precisely what this package must never do +# (spec: "Токен... никогда в argv"). Answering the username with a fixed +# placeholder instead means the value that could ever leak into an argv or +# a URL is never the secret. Re-verified after the fix, same live trace: +# the second prompt now reads "Password for +# 'http://gitbackup@host:port': " -- the token appears nowhere in argv, at +# any point, confirmed by sampling `ps` throughout the whole exchange. +# +# VERIFY: the placeholder username plus token-as-password shape is +# confirmed against GitHub's own docs (a fine-grained PAT as the password +# with any non-empty username); not confirmed live against GitLab/ +# Bitbucket/self-hosted Gitea, which needs a real hosted remote and real +# credentials neither of which exist on this dev stand (spec.md, "Открытые +# места": "push на живой GitHub/GitLab -- нет учётных данных на стенде"). +set -u + +GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}" +# shellcheck disable=SC1091 # GB_SHARE is a runtime path, not resolvable statically +. "$GB_SHARE/lib.sh" + +case "${1:-}" in + *sername*) + # Never the token: see this file's header. The exact string is + # arbitrary -- git only requires it to be non-empty -- "gitbackup" + # just makes a stray log line or trace recognizable as this + # package's own request rather than a real account name. + printf 'gitbackup\n' + ;; + *) + _gb_token_file=$(gb_uci_get gitbackup.origin.token_file /etc/gitbackup/token) + if [ ! -r "$_gb_token_file" ]; then + gb_log err "askpass: no readable token at $_gb_token_file (gitbackup.origin.token_file)" + exit 1 + fi + # head -n 1, not cat: a trailing blank line or a second line in the + # token file (a stray newline from how it was pasted in) must not + # become part of what git sends as the credential. + head -n 1 "$_gb_token_file" + ;; +esac diff --git a/gitbackup/files/usr/share/gitbackup/auth.sh b/gitbackup/files/usr/share/gitbackup/auth.sh new file mode 100644 index 000000000..2f3585ca6 --- /dev/null +++ b/gitbackup/files/usr/share/gitbackup/auth.sh @@ -0,0 +1,512 @@ +# shellcheck shell=sh +# +# gitbackup -- access to the remote (spec "Аутентификация"). +# +# Exposes gb_git_env, gb_keygen, gb_pubkey and gb_accept_hostkey; every +# _gb_auth_-prefixed helper below is private and namespaced by module, same +# convention collect.sh uses (multiple *.sh here end up sourced into the +# same process by usr/sbin/gitbackup, so a bare _gb_ helper name could +# collide with a sibling module's). Sourced, never executed: nothing here +# runs at load time -- GB_ETC_DIR below is a variable assignment from an +# environment default, not a filesystem access. +# +# Depends on lib.sh (gb_log, gb_uci_get) already being sourced by the +# caller, same convention every other module here uses. +# +# GB_ETC_DIR overrides gitbackup's own secrets directory (default +# /etc/gitbackup). It is deliberately NOT a UCI option: gitbackup.origin. +# key_file and .token_file already give the operator a path for those two, +# but known_hosts has no such option in the shipped schema (spec: +# "known_hosts лежит в /etc/gitbackup/", a fixed location, not configurable) +# -- this exists only so tests/run.sh can point it at a tmp directory +# instead of the real /etc/gitbackup, the same seam GB_ROOT/GB_STATE_DIR/ +# GB_SYSFS_NET give the other modules. +GB_ETC_DIR="${GB_ETC_DIR:-/etc/gitbackup}" + +# gb_git_env +# +# Prints "export KEY='VALUE'" lines, one per line, quoted so a caller can +# safely `eval "$(gb_git_env)"` even though GIT_SSH_COMMAND's value itself +# contains spaces -- and so the assignments are exported into the calling +# shell's environment, where the `git` child process this whole thing +# exists for can actually see them. +# +# GIT_SSH_COMMAND and GIT_ASKPASS are both always printed, regardless of +# gitbackup.origin.auth: it is the git remote URL's own scheme (ssh:// vs +# https://), not this option, that decides which one a given `git` +# invocation actually uses -- ssh transport never calls GIT_ASKPASS for +# anything, and GIT_SSH_COMMAND is simply inert for an https transport. Not +# branching on .auth here means there is exactly one thing to keep in sync +# with the remote URL parser (remoteurl.sh) instead of two. +# +# GIT_SSH_COMMAND carries the four options the spec names by name: -i (the +# deploy key), UserKnownHostsFile (this package's own known_hosts, never +# the invoking user's ~/.ssh/known_hosts -- a stale entry there must not be +# able to either accept or block this package's own host-key decision), +# StrictHostKeyChecking=yes (an unknown or changed host key is refused, +# full stop -- gitbackup test is the only place that ever adds one) and +# BatchMode=yes (never fall back to an interactive passphrase/password +# prompt that would hang a cron job forever) -- plus ConnectTimeout=15, not +# named in the spec but added after a live failure on the owlab stand: a +# host that never answers at all (as opposed to actively refusing the +# connection) leaves `ssh` hanging with neither of those two safeguards to +# stop it, well past two minutes with no output. BatchMode only rules out +# an interactive *prompt*; it does nothing about the TCP handshake itself +# never completing, which is exactly what a firewalled or simply +# nonexistent host looks like. +# +# GIT_SSL_CAINFO is printed only when gitbackup.origin.ca_file is set, and +# is the only sanctioned way to talk to a self-signed/private-CA remote: +# GIT_SSL_NO_VERIFY and http.sslVerify=false are refused even as an option +# by this package, spec "Аутентификация" and "Границы и швы" -- there is no +# code path anywhere in this package that can turn certificate verification +# off, only one that can hand it an extra trusted certificate. +# +# _gb_ssh_cmd below is built as a plain `ssh ...` command, not busybox's +# `dbclient` -- and this StrictHostKeyChecking=yes option is exactly why. +# Measured on the 25.12.4 stand (dropbear 2025.89, git 2.50.1): with +# GIT_SSH_COMMAND set to a dbclient invocation, `GIT_TRACE=1 git fetch` +# shows git's ssh-variant probe (`dbclient -G ...`, used to detect whether +# the ssh command understands OpenSSH's own option syntax) failing, after +# which git silently falls back to its "simple" transport form and stops +# passing any `-o NAME=VALUE` pair at all -- not just this one, every +# option this function sets, including UserKnownHostsFile and BatchMode. +# The failure is invisible: git does not warn that it dropped them, fetch +# still succeeds against a reachable host, and the option only turns out +# to have been a no-op the day host-key checking was supposed to catch +# something. That silent downgrade, not merely dbclient's separate inability +# to read OpenSSH-format keys (see package/gitbackup/Makefile), is why this +# package depends on the real openssh-client instead. +# +# Never prints the token itself: GIT_ASKPASS names askpass.sh's path, and +# the token is read off disk by askpass.sh's own process, later, only once +# git has actually decided it needs credentials. +gb_git_env() { + _gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519) + _gb_known="$GB_ETC_DIR/known_hosts" + _gb_ssh_cmd="ssh -i $(_gb_auth_shquote "$_gb_key") -o UserKnownHostsFile=$(_gb_auth_shquote "$_gb_known") -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=15" + + printf 'export GIT_SSH_COMMAND=%s\n' "$(_gb_auth_shquote "$_gb_ssh_cmd")" + printf 'export GIT_ASKPASS=%s\n' "$(_gb_auth_shquote "${GB_SHARE:-/usr/share/gitbackup}/askpass.sh")" + # Never let git fall back to its own interactive prompt on a real tty -- + # askpass.sh is the only credential source this package allows. + printf 'export GIT_TERMINAL_PROMPT=%s\n' "$(_gb_auth_shquote 0)" + + _gb_ca=$(gb_uci_get gitbackup.origin.ca_file) + [ -n "$_gb_ca" ] && printf 'export GIT_SSL_CAINFO=%s\n' "$(_gb_auth_shquote "$_gb_ca")" + return 0 +} + +# _gb_auth_shquote -- wrapped in single quotes, with any +# single quote inside it escaped, so the result can be eval'd back to the +# original value regardless of what it contains (a path with a space is the +# realistic case here, not an adversarial one, but the quoting is correct +# either way). +_gb_auth_shquote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +# _gb_auth_key_fingerprint +# +# ssh-keygen -lf's own fingerprint line for whichever half of the pair is +# actually readable, preferring the public half (same preference order +# gb_pubkey below already uses) -- unlike gb_pubkey, this never needs the +# `-y` derivation fallback: `ssh-keygen -lf` accepts either a private or a +# public key file directly and prints the identical fingerprint for +# either, since the fingerprint is a hash of the public half alone. +_gb_auth_key_fingerprint() { + _gb_akf_key="$1" + if [ -r "$_gb_akf_key.pub" ]; then + ssh-keygen -lf "$_gb_akf_key.pub" 2>/dev/null + elif [ -r "$_gb_akf_key" ]; then + ssh-keygen -lf "$_gb_akf_key" 2>/dev/null + fi +} + +# gb_keygen [force] [confirm-fingerprint] +# +# Generates the deploy key at gitbackup.origin.key_file (default +# /etc/gitbackup/id_ed25519): ed25519, no passphrase. No passphrase is +# deliberate, not an oversight -- GIT_SSH_COMMAND always carries +# BatchMode=yes (gb_git_env), which refuses to prompt for one, so an +# encrypted key would simply make every push hang forever instead of +# failing loudly. +# +# Refuses to overwrite a key that already exists unless is a +# non-empty argument: losing the only copy of a deploy key locks every +# remote it was already added to until the operator notices and re-adds +# the new public half everywhere (ticket 04 acceptance criterion). This +# part of the contract is unchanged by ticket 25 below: `gitbackup keygen` +# with no `force` at all on an existing key behaves exactly as before. +# +# Ticket 25: `force` alone used to be enough to destroy the working key on +# the spot, no warning, no way back -- reported live from a real router +# where `keygen --force`, run only to see where the operation writes, +# silently ate the key a working deploy relied on. The private half is +# gone the instant the `rm -f` below runs, and every remote it had been +# added to as a deploy key stops authenticating that same instant, with +# nothing on this router able to undo either side of that. +# +# gb_hostkey_show/gb_hostkey_accept above already solved the equivalent +# problem for a different irreversible decision -- "confirm by naming +# exactly what was shown", not a bare y/N -- and this reuses that same +# shape instead of adding a `read` prompt here: a `read`-based prompt +# would need a second, separate branch for "no controlling terminal at +# all" (ticket 20's own EOF-is-neither-yes-nor-no lesson, gb_accept_hostkey +# above), and that branch would need testing on its own. Requiring the +# caller to name the fingerprint back sidesteps the distinction entirely -- +# an interactive operator and a non-interactive script take the identical +# path, and there is no `read` anywhere in this function to hang or +# misread an EOF as a decision nobody made: +# +# force set, no existing key -- generates immediately, exactly +# as before (nothing to confirm) +# force unset, existing key -- refused, exit 1, unchanged +# force set, existing key, confirm -- prints "confirm-required +# empty or not matching the current " to stdout (nothing +# key's own fingerprint destroyed) and returns 4 +# force set, existing key, confirm -- proceeds: the old key is kept +# matches the current fingerprint aside (see below) and a fresh one +# exactly is generated; returns 0 +# +# A caller that never asks (only ever passes `force`) gets told what is +# missing on every single call -- it can never destroy anything by +# accident, and it is never met with a bare, unexplained refusal either. +# +# Decision (ticket 25 leaves this to the implementer, either answer +# acceptable if justified): the key about to be destroyed is copied aside +# as ".old"/".old.pub" before anything is removed, left there +# until gb_keygen_forget_old below is called. Nothing on this router ever +# reads ".old" back automatically -- there is deliberately no "fall back +# to the old key if the new one fails" logic, which would make a failed +# push ambiguous about which key actually ran and silently reintroduce the +# very key this whole flow just told the operator to stop using. The +# reason to keep it anyway is narrower: the one step this flow cannot +# verify from the router is whether the operator actually went to the +# provider and added the NEW public key before the next push runs. If they +# forgot, or pasted the wrong key, the router itself becomes unreachable +# over the only channel this package uses, and the file this comment is +# justifying is then the difference between "cp it back and try again" and +# "nothing, the old private half is just gone" -- for the cost of one +# extra ~400-byte file that a successful `gitbackup test` (cmd_test, +# usr/sbin/gitbackup) removes on its own the first time the NEW key is +# actually proven to work. Deleting outright instead (the spec's other +# sanctioned answer) would not shorten the provider trip either way; it +# would only remove this one way an already-bad day gets worse. +# +# Creates the key's directory at 0700 if it does not exist yet (uci- +# defaults/99-gitbackup already does this for the default /etc/gitbackup, +# but gitbackup.origin.key_file can point anywhere), and leaves the +# private key at 0600 -- ssh-keygen's own default, asserted here rather +# than trusted, since a future ssh-keygen with a different umask-derived +# default would otherwise change this package's security posture with no +# code change on this side to catch it. +gb_keygen() { + _gb_force="${1:-}" + _gb_confirm="${2:-}" + _gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519) + if [ -z "$_gb_key" ]; then + gb_log err 'gb_keygen: gitbackup.origin.key_file is empty' + return 1 + fi + + if [ -e "$_gb_key" ]; then + if [ -z "$_gb_force" ]; then + gb_log err "gb_keygen: $_gb_key already exists; pass a non-empty force argument to replace it" + return 1 + fi + + _gb_fp=$(_gb_auth_key_fingerprint "$_gb_key") + if [ -z "$_gb_confirm" ] || [ "$_gb_confirm" != "$_gb_fp" ]; then + if [ -n "$_gb_confirm" ]; then + gb_log err "gb_keygen: confirmation fingerprint does not match the key currently at $_gb_key; refusing" + fi + printf 'confirm-required %s\n' "$_gb_fp" + return 4 + fi + + # Confirmed: keep the outgoing key's own copy before touching + # anything -- see this function's header comment for why. Best + # effort on purpose (2>/dev/null): a failure to write the backup + # copy must never block the regeneration the operator explicitly + # confirmed, only mean there is nothing to roll back to. + cp -f "$_gb_key" "$_gb_key.old" 2>/dev/null && chmod 0600 "$_gb_key.old" 2>/dev/null + [ -e "$_gb_key.pub" ] && cp -f "$_gb_key.pub" "$_gb_key.old.pub" 2>/dev/null + fi + + _gb_dir=$(dirname "$_gb_key") + mkdir -p "$_gb_dir" || { gb_log err "gb_keygen: cannot create $_gb_dir"; return 1; } + chmod 0700 "$_gb_dir" || return 1 + + rm -f "$_gb_key" "$_gb_key.pub" + if ! ssh-keygen -t ed25519 -N '' -C gitbackup -f "$_gb_key" >/dev/null; then + gb_log err "gb_keygen: ssh-keygen failed for $_gb_key" + return 1 + fi + chmod 0600 "$_gb_key" || return 1 + [ -e "$_gb_key.pub" ] && chmod 0644 "$_gb_key.pub" + gb_log notice "gb_keygen: generated $_gb_key" + return 0 +} + +# gb_keygen_forget_old +# +# Ticket 25's other half of the id_ed25519.old decision above (gb_keygen's +# own header comment): removes the pre-regeneration copy once something has +# actually proven the CURRENT key works end to end. Called from cmd_test's +# own success path (usr/sbin/gitbackup) -- a successful `git ls-remote` +# against the configured remote is the one signal this package already +# computes that means "the key at key_file is accepted by the provider +# right now", which is exactly the event this flow is waiting for. A no-op +# whenever there is nothing to forget (the overwhelming majority of +# successful `test` runs never followed a keygen at all), so this is safe +# to call unconditionally on every success rather than tracking anywhere +# whether a keygen happened first. +gb_keygen_forget_old() { + _gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519) + rm -f "$_gb_key.old" "$_gb_key.old.pub" +} + +# gb_pubkey +# +# Prints the deploy key's public half. Reads the already-generated .pub +# file when it is there (the common case, ssh-keygen always writes one), +# and falls back to deriving it from the private key with `ssh-keygen -y` +# only if that file is missing -- an operator could plausibly delete just +# the .pub half by hand and expect this to still work. +gb_pubkey() { + _gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519) + if [ -r "$_gb_key.pub" ]; then + cat "$_gb_key.pub" + return 0 + fi + if [ -r "$_gb_key" ]; then + ssh-keygen -y -f "$_gb_key" + return $? + fi + gb_log err "gb_pubkey: no key at $_gb_key; run 'gitbackup keygen' first" + return 1 +} + +# _gb_auth_dial_hostkey +# +# The dial step gb_accept_hostkey and gb_hostkey_show below both need, +# factored out so there is exactly one place that knows how to fetch a raw +# host key off the wire. There is no ssh-keyscan on this image without +# pulling in the separate openssh-client-utils package (measured on the +# 25.12.4 stand: `apk info -L openssh-client openssh-keygen` together +# provide neither ssh-keyscan nor any equivalent) -- rather than growing +# DEPENDS for one command, this reuses the `ssh` binary DEPENDS already +# carries. The SSH protocol completes its host-key exchange before user +# authentication even starts, so `ssh -o StrictHostKeyChecking=accept-new +# -o BatchMode=yes` against a scratch, empty known_hosts file writes the +# host's key into it regardless of whether the authentication that follows +# succeeds -- verified live on the owlab stand: `ssh -o +# UserKnownHostsFile= -o StrictHostKeyChecking=accept-new -o +# BatchMode=yes git@github.com exit` still populates with +# github.com's key even though the command itself exits 255 on "Permission +# denied (publickey)" right after, because no key for github.com had +# actually been added yet. +# +# Writes and returns 0 when a key was obtained, or returns 2 +# with removed when nothing could be obtained at all (host +# unreachable, connection refused, DNS failure). +_gb_auth_dial_hostkey() { + _gb_adh_host="$1" + _gb_adh_port="$2" + _gb_adh_out="$3" + rm -f "$_gb_adh_out" + + # ConnectTimeout=15: same reasoning as gb_git_env's own copy of it -- a + # host that never answers at all, rather than actively refusing, + # otherwise leaves this hanging well past two minutes (measured live). + ssh -o UserKnownHostsFile="$_gb_adh_out" -o StrictHostKeyChecking=accept-new \ + -o BatchMode=yes -o ConnectTimeout=15 -p "$_gb_adh_port" "git@$_gb_adh_host" exit >/dev/null 2>&1 + + if [ ! -s "$_gb_adh_out" ]; then + rm -f "$_gb_adh_out" + return 2 + fi + return 0 +} + +# _gb_hostkey_pending_file -- fixed path under GB_ETC_DIR, never part of +# known_hosts itself. Holds the raw known_hosts-format line(s) for the +# LAST host key gb_hostkey_show fetched, and nothing else: gb_hostkey_accept +# below commits exactly this file's bytes, never a fresh dial of its own +# (see gb_hostkey_accept's own header comment for why that distinction is +# the whole point of the two-step design). +_gb_hostkey_pending_file() { + printf '%s/hostkey_pending' "$GB_ETC_DIR" +} + +# gb_accept_hostkey [port] +# +# Interactive, and only ever meant to be called from `gitbackup test` +# (spec: "Host key принимается только внутри gitbackup test"). Fetches the +# host's SSH key, shows its fingerprint on stderr, asks for a yes/no on +# stdin, and only on "yes" appends it to this package's own known_hosts +# (GB_ETC_DIR/known_hosts) -- never the invoking user's ~/.ssh/known_hosts. +# +# Returns 0 once the key is confirmed and recorded (or was already known -- +# see the ssh-keygen -F check below, which makes this safe to call +# unconditionally from `gitbackup test` every time), 1 when the operator +# explicitly declines, 2 when no host key could be obtained at all (host +# unreachable, connection refused, DNS failure), 3 when there was no way to +# even ASK the operator (ticket 20: `read` hit EOF -- no controlling +# terminal at all, e.g. rpcd's own `gitbackup test /dev/null + _gb_known="$GB_ETC_DIR/known_hosts" + + # Already trusted: nothing to accept. Asked of ssh-keygen itself, not a + # raw grep, so a known_hosts entry hashed with -H (never written by this + # package, but nothing stops an operator from hand-editing the file) is + # still recognized correctly. + if [ -r "$_gb_known" ] && ssh-keygen -F "$_gb_host" -f "$_gb_known" >/dev/null 2>&1; then + return 0 + fi + + _gb_scratch=$(mktemp "${TMPDIR:-/tmp}/gitbackup-hostkey.XXXXXX") || return 2 + if ! _gb_auth_dial_hostkey "$_gb_host" "$_gb_port" "$_gb_scratch"; then + gb_log err "gb_accept_hostkey: could not reach $_gb_host:$_gb_port to obtain its host key" + return 2 + fi + + _gb_fp=$(ssh-keygen -lf "$_gb_scratch" 2>/dev/null) + printf 'Host key for %s:%s --\n %s\nAccept and remember it? [y/N] ' "$_gb_host" "$_gb_port" "$_gb_fp" >&2 + + # See this function's own header comment: `read`'s exit status, not + # just the string it captured, is what tells a real "no" (read + # succeeded, the operator typed something other than y/yes) apart from + # "could not ask at all" (read failed -- EOF, no stdin to read from). + if IFS= read -r _gb_ans; then + case "$_gb_ans" in + y|Y|yes|YES) ;; + *) + rm -f "$_gb_scratch" + gb_log notice "gb_accept_hostkey: $_gb_host:$_gb_port declined by the operator" + return 1 + ;; + esac + else + rm -f "$_gb_scratch" + gb_log err "gb_accept_hostkey: $_gb_host:$_gb_port could not ask for confirmation -- no interactive input is available here. Run 'gitbackup test' from a terminal with a real stdin, or accept the host key from the LuCI web UI (Settings -> Connection test), which uses 'gitbackup hostkey show'/'hostkey accept' for exactly this case." + return 3 + fi + + cat "$_gb_scratch" >>"$_gb_known" + chmod 0600 "$_gb_known" + rm -f "$_gb_scratch" + gb_log notice "gb_accept_hostkey: $_gb_host:$_gb_port accepted and recorded in $_gb_known" + return 0 +} + +# gb_hostkey_show [port] +# +# The non-interactive half of ticket 20's web flow: dials , same as +# gb_accept_hostkey above, but never asks anything and never writes +# known_hosts. Instead it caches the exact bytes it fetched in +# _gb_hostkey_pending_file, so a later gb_hostkey_accept call can commit +# precisely THAT material -- not whatever a second, independent dial might +# return -- which is what keeps the show/confirm round trip from opening a +# window for a key swap in between (see gb_hostkey_accept's own comment). +# +# Prints exactly one line to stdout: +# trusted -- nothing to show, already known +# pending -- ssh-keygen -lf's own text +# Deliberately plain, not JSON: usr/sbin/gitbackup's cmd_hostkey is the one +# JSON boundary here, same split gb_pubkey/`gitbackup pubkey` already uses. +# +# Returns 0 in both printed cases above, 2 when the host could not be +# reached at all (nothing printed, nothing cached). +gb_hostkey_show() { + _gb_host="$1" + _gb_port="${2:-22}" + case "$_gb_port" in + ''|0) _gb_port=22 ;; + esac + + mkdir -p "$GB_ETC_DIR" 2>/dev/null + _gb_known="$GB_ETC_DIR/known_hosts" + if [ -r "$_gb_known" ] && ssh-keygen -F "$_gb_host" -f "$_gb_known" >/dev/null 2>&1; then + printf 'trusted %s %s\n' "$_gb_host" "$_gb_port" + return 0 + fi + + _gb_pending=$(_gb_hostkey_pending_file) + if ! _gb_auth_dial_hostkey "$_gb_host" "$_gb_port" "$_gb_pending"; then + gb_log err "gb_hostkey_show: could not reach $_gb_host:$_gb_port to obtain its host key" + return 2 + fi + chmod 0600 "$_gb_pending" + + _gb_fp=$(ssh-keygen -lf "$_gb_pending" 2>/dev/null) + printf 'pending %s %s %s\n' "$_gb_host" "$_gb_port" "$_gb_fp" + return 0 +} + +# gb_hostkey_accept [port] +# +# Commits the key gb_hostkey_show cached, but ONLY when +# matches ssh-keygen's own fingerprint of exactly that cached material. +# is the text the operator was shown and clicked "accept" +# on, so this check is what stops a second, possibly different dial from +# silently substituting a different key between the moment the fingerprint +# was displayed and the moment it was confirmed (an active MITM that +# changed which key answers the connection the second time around, or a +# second concurrent gb_hostkey_show call from another tab overwriting the +# pending file first). This is exactly why this function never dials the +# network itself: re-fetching "to double check" would defeat the point -- +# it would be confirming a NEW dial, not the one the operator actually +# saw. +# +# Returns 0 once accepted and recorded, 1 when there is nothing pending +# (gb_hostkey_show was never called, or its result already got +# consumed/replaced -- call it again), 4 when does not match +# what is actually cached (exit-code contract: "4 отказ по безопасности" -- +# this is refused, never silently accepted with a mismatch logged instead). +gb_hostkey_accept() { + _gb_host="$1" + _gb_port="${2:-22}" + _gb_want_fp="$3" + case "$_gb_port" in + ''|0) _gb_port=22 ;; + esac + + _gb_pending=$(_gb_hostkey_pending_file) + if [ ! -s "$_gb_pending" ]; then + gb_log err "gb_hostkey_accept: no pending host key for $_gb_host:$_gb_port -- run 'gitbackup hostkey show' first" + return 1 + fi + + _gb_have_fp=$(ssh-keygen -lf "$_gb_pending" 2>/dev/null) + if [ -z "$_gb_want_fp" ] || [ "$_gb_have_fp" != "$_gb_want_fp" ]; then + gb_log err "gb_hostkey_accept: fingerprint for $_gb_host:$_gb_port does not match the pending host key -- refusing" + return 4 + fi + + mkdir -p "$GB_ETC_DIR" 2>/dev/null + _gb_known="$GB_ETC_DIR/known_hosts" + cat "$_gb_pending" >>"$_gb_known" + chmod 0600 "$_gb_known" + rm -f "$_gb_pending" + gb_log notice "gb_hostkey_accept: $_gb_host:$_gb_port accepted and recorded in $_gb_known" + return 0 +} diff --git a/gitbackup/files/usr/share/gitbackup/card.sh b/gitbackup/files/usr/share/gitbackup/card.sh new file mode 100644 index 000000000..41327374c --- /dev/null +++ b/gitbackup/files/usr/share/gitbackup/card.sh @@ -0,0 +1,145 @@ +# shellcheck shell=sh +# +# gitbackup -- recovery card (spec "Recovery card и RECOVERY.md", ticket 09). +# +# Exposes gb_card; every _gb_card_-prefixed helper below is private, same +# per-module namespacing convention every sibling file here uses. Sourced, +# never executed: nothing here runs at load time. +# +# Depends on lib.sh (gb_log, gb_uci_get) and remoteurl.sh (gb_parse_url, +# gb_provider) already being sourced by the caller -- same convention +# visibility.sh/restore.sh use. Reads GB_URL and GB_DEVICE from the +# environment rather than taking them as arguments: usr/sbin/gitbackup's +# _gb_run_backup already has both resolved (by gb_validate_config, before +# _gb_run_backup even starts) at the one call site this module is actually +# reached from today (step 11b, "the recovery card"), same convention +# gitio.sh/restore.sh already use for config context the CLI resolved once. +# A future LuCI "Download recovery card" button (ticket 11) goes through +# `gitbackup card`, which resolves the same two through gb_validate_config +# first, so this module never has to. +# +# gb_card is deliberately built to never die and never fail loudly: it is +# called from the middle of a real backup run (usr/sbin/gitbackup's +# `gb_card "$_gb_outdir/RECOVERY.md" 2>/dev/null`), and a bug in a recovery +# document must not be able to abort the backup it rides along with. Every +# helper below degrades to a shorter card instead of calling gb_die, and +# gb_card itself always returns 0. + +# gb_card +# +# Writes one recovery document to -- the same content LuCI's +# "Download recovery card" button (ticket 11) offers for offline storage and +# `gitbackup run` commits to devices//RECOVERY.md (spec: "Оба генерирует +# gb_card; различаются только назначением и местом"). Contains a ready-made +# bootstrap one-liner with --repo/--device already filled in, a link to the +# repository's own web UI, a reminder that a token or deploy key still has +# to be supplied by hand, and a short Path 0 fallback for when there is no +# network to run bootstrap.sh over at all. Never the secret itself: neither +# the token nor the deploy key's private half ever passes through this +# module (interfaces.md: "Секрета в карточке нет"). +gb_card() { + _gb_c_out="$1" + _gb_c_dev="${GB_DEVICE:-}" + [ -n "$_gb_c_dev" ] || _gb_c_dev='' + _gb_c_url="${GB_URL:-}" + [ -n "$_gb_c_url" ] || _gb_c_url=$(gb_uci_get gitbackup.origin.url) + + _gb_c_authflag='--token ' + _gb_c_weblink='' + + # GB_SCRUB is this run's EFFECTIVE scrub decision, already resolved + # once by gb_validate_config (usr/sbin/gitbackup) and exported for + # every subcommand -- the same seam collect.sh reads GB_DEVICE + # through, and the reason this file does not re-derive it from + # gitbackup.security.scrub itself: visibility=public forces scrub on + # regardless of what that option says, and a card that missed the + # override would promise a Path 0 that does not exist. + _gb_c_scrub=0 + [ "$(gb_json_bool "${GB_SCRUB:-0}")" = true ] && _gb_c_scrub=1 + _gb_c_archive=0 + if [ "$(gb_json_bool "$(gb_uci_get gitbackup.main.archive 1)")" = true ] && [ "$_gb_c_scrub" = 0 ]; then + _gb_c_archive=1 + fi + + if [ -n "$_gb_c_url" ]; then + _gb_c_parsed=$(gb_parse_url "$_gb_c_url" 2>/dev/null) + if [ -n "$_gb_c_parsed" ]; then + # shellcheck disable=SC2086 # word-splitting is the point: five fields from one line + set -- $_gb_c_parsed + _gb_c_scheme="$1" + _gb_c_host="$2" + _gb_c_owner="$4" + _gb_c_repo="$5" + [ "$_gb_c_scheme" = ssh ] && _gb_c_authflag='--ssh-key ' + # No port here, same known limitation as gb_deeplink + # (remoteurl.sh): every provider this package recognizes serves + # its web UI on the default https port, so this has never + # needed one -- a self-hosted instance on a nonstandard port + # would need it added here too, at the same time. + _gb_c_weblink="https://$_gb_c_host/$_gb_c_owner/$_gb_c_repo" + fi + fi + [ -n "$_gb_c_url" ] || _gb_c_url='' + + { + printf '# gitbackup recovery card -- %s\n\n' "$_gb_c_dev" + printf 'Generated: %s\n\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'This router backs itself up to a private git repository. If it dies, a\n' + printf 'freshly flashed replacement can be brought back to the same configuration\n' + printf 'with one command, run over ssh once the replacement has booted and has\n' + printf 'network access.\n\n' + printf 'This card does NOT contain a secret. You still have to bring one: either a\n' + printf 'personal access token with read access to the repository below (HTTPS\n' + printf 'remotes), or the path to the SSH deploy key already registered on it (SSH\n' + printf 'remotes).\n\n' + printf '## Path 1 -- one command\n\n' + printf '```sh\n' + printf 'uclient-fetch -qO- https://raw.githubusercontent.com/VizzleTF/luci-app-gitbackup/main/bootstrap.sh \\\n' + printf ' | sh -s -- --repo %s --device %s %s\n' "$_gb_c_url" "$_gb_c_dev" "$_gb_c_authflag" + printf '```\n\n' + if [ -n "$_gb_c_weblink" ]; then + printf 'Repository: %s\n\n' "$_gb_c_weblink" + fi + # Path 0 is printed only when there is actually an archive in the + # repository to open it with. `gitbackup run` writes + # backup.tar.gz when main.archive is on AND this run does not + # scrub (usr/sbin/gitbackup's own step 11a -- sysupgrade -b reads + # the live filesystem, so pushing its output would undo the scrub + # the same run was just asked to perform). A recovery card is + # read exactly once, by someone whose router is already broken; + # sending them after a file that was never pushed is worse than + # telling them this path is unavailable, and why. + if [ "$_gb_c_archive" = 1 ]; then + printf '## Path 0 -- no network, or bootstrap.sh will not run\n\n' + printf '1. Open the repository above from any other device and download\n' + # shellcheck disable=SC2016 # backticks are markdown code spans in the + # generated document, not an attempted command substitution here + printf ' `devices/%s/backup.tar.gz` from the latest commit on branch\n' "$_gb_c_dev" + # shellcheck disable=SC2016 + printf ' `device/%s` (or the branch your `gitbackup.origin.branch` template\n' "$_gb_c_dev" + printf ' resolves to).\n' + printf '2. On the router: LuCI -> System -> Backup / Flash Firmware -> Restore\n' + printf ' configuration, and upload that file. Over ssh instead:\n' + # shellcheck disable=SC2016 + printf ' `sysupgrade -r backup.tar.gz` after copying it onto the router.\n' + printf '3. Reboot. This restores the raw sysupgrade archive, not the package own\n' + printf ' sha256-verified restore -- see docs/RESTORE.md for the difference.\n' + elif [ "$_gb_c_scrub" = 1 ]; then + printf '## Path 0 -- not available on this router\n\n' + # shellcheck disable=SC2016 + printf 'No `backup.tar.gz` is pushed while config scrubbing is on:\n' + # shellcheck disable=SC2016 + printf '`sysupgrade -b` reads the live filesystem, so its archive would carry\n' + printf 'exactly the secrets scrubbing keeps out of the commit. Use Path 1\n' + # shellcheck disable=SC2016 + printf 'above, or `gitbackup restore` -- see docs/RESTORE.md.\n' + else + printf '## Path 0 -- not available on this router\n\n' + # shellcheck disable=SC2016 + printf 'No `backup.tar.gz` is pushed (`gitbackup.main.archive` is off). Use\n' + # shellcheck disable=SC2016 + printf 'Path 1 above, or `gitbackup restore` -- see docs/RESTORE.md.\n' + fi + } >"$_gb_c_out" 2>/dev/null + return 0 +} diff --git a/gitbackup/files/usr/share/gitbackup/collect.sh b/gitbackup/files/usr/share/gitbackup/collect.sh new file mode 100644 index 000000000..99a0235cf --- /dev/null +++ b/gitbackup/files/usr/share/gitbackup/collect.sh @@ -0,0 +1,345 @@ +# shellcheck shell=sh +# +# gitbackup -- backup-set collection and manifest.json (spec "Сбор и +# manifest.json"). +# +# Exposes gb_collect, gb_manifest_path and gb_manifest_equal; every +# _gb_collect_-prefixed helper below is private and namespaced by module +# (not just "_gb_") because every *.sh here ends up sourced into the same +# process by usr/sbin/gitbackup -- a same-named helper in a sibling module +# would silently shadow this one otherwise. Sourced, never executed: +# nothing here runs at load time. +# +# Depends on lib.sh (gb_log, gb_json_esc/str, ...) already being sourced by +# the caller, same convention device.sh uses. It does NOT depend on +# device.sh: the device name is read from $GB_DEVICE, which +# usr/sbin/gitbackup's gb_validate_config already resolves once via +# gb_device_id and exports for every subcommand (same contract as GB_URL +# and GB_SCRUB) -- collecting would otherwise resolve the device a second +# time and could disagree with the CLI's own answer. +# +# GB_ROOT overrides the filesystem root every real-path read goes through +# (default '', meaning the actual '/'). GB_EXCLUDE_LIST overrides where the +# hard-exclude patterns live (default GB_SHARE/exclude.list, GB_SHARE +# itself defaulting like the CLI's own). Neither is set by the router -- +# both exist so tests/run.sh can point collect.sh at a fixture tree instead +# of the host filesystem, the same seam GB_SYSFS_NET gives device.sh. + +GB_ROOT="${GB_ROOT:-}" +GB_EXCLUDE_LIST="${GB_EXCLUDE_LIST:-${GB_SHARE:-/usr/share/gitbackup}/exclude.list}" + +# gb_manifest_path +gb_manifest_path() { + printf '%s/manifest.json\n' "$1" +} + +# gb_manifest_equal +# +# True when two manifest.json files describe the same backup set. Compares +# only entries[] and scrubbed[] (spec: "generated не участвует в сравнении +# манифестов, иначе изменения были бы каждый прогон") -- and, by the same +# reasoning, neither do version/hostname/device/openwrt/board: none of them +# describe what restore actually writes back, and comparing them would turn +# an unrelated hostname change into a spurious commit on the next run. +# +# gb_manifest_tail (lib.sh) is the shared reader now: this used to be its +# own private sed range here, one of three near-identical readers of the +# same flat manifest.json format that scrub.sh and restore.sh had each +# grown independently (ticket 18) -- one reader, not three. +gb_manifest_equal() { + [ -r "$1" ] && [ -r "$2" ] || return 1 + [ "$(gb_manifest_tail "$1")" = "$(gb_manifest_tail "$2")" ] +} + +# gb_collect +# +# Builds outdir/files (backup-set tree, paths preserved), outdir/meta (five +# files) and outdir/manifest.json. The path list is `sysupgrade -l` and +# nothing else (R29) -- collect.sh never walks /etc on its own to decide +# what belongs in the set, only to find the empty directories `sysupgrade +# -l` can never report (see _gb_collect_empty_dirs) and to concatenate +# /etc/apk/repositories.d/* for meta/repositories.txt. +gb_collect() { + _gb_outdir="$1" + [ -n "$_gb_outdir" ] || { gb_log err 'gb_collect: outdir is required'; return 1; } + + mkdir -p "$_gb_outdir/files" "$_gb_outdir/meta" || + { gb_log err "gb_collect: cannot create $_gb_outdir"; return 1; } + + _gb_scratch=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-collect.XXXXXX") || return 1 + : >"$_gb_scratch/entries" + + _gb_collect_files "$_gb_outdir" "$_gb_scratch/entries" + _gb_collect_empty_dirs "$_gb_outdir" "$_gb_scratch/entries" + _gb_collect_meta "$_gb_outdir" + _gb_collect_write_manifest "$_gb_outdir" "$_gb_scratch/entries" + + rm -rf "$_gb_scratch" +} + +# _gb_collect_is_excluded -- true when matches a pattern in +# GB_EXCLUDE_LIST. A pattern ending in "/**" matches the path itself and +# everything under it; anything else is matched with plain `case` glob +# rules. Re-reads the file on every call rather than caching it in a +# variable: the list is a handful of lines, and this keeps the match logic +# free of the quoting/IFS games a cached multi-line variable would need. +# +# Matched on the canonical spelling of , never the raw one: `case` +# globbing is textual, so "//etc/gitbackup/token" and +# "/etc/./gitbackup/token" match no pattern that names /etc/gitbackup even +# though find(1), cp(1) and the kernel all agree they are that file. This +# is the second half of the same fix gb_paths_validate carries -- that one +# stops such an entry being accepted into sysupgrade.conf at all, this one +# holds even for a path that never went through it, since `sysupgrade -l` +# unions in /lib/upgrade/keep.d/* and changed conffiles that this package +# does not own. exclude.list's own R76 ("must never end up inside its own +# backup, no matter how the router is configured") is only true with both. +_gb_collect_is_excluded() { + # Own variable name, deliberately not the _gb_path every caller loop + # here already uses: POSIX sh has no locals, and canonicalizing into + # that name would silently rewrite the caller's loop variable too. + _gb_ie_path=$(gb_path_canon "$1") + [ -r "$GB_EXCLUDE_LIST" ] || return 1 + while IFS= read -r _gb_pat || [ -n "$_gb_pat" ]; do + case "$_gb_pat" in + ''|'#'*) continue ;; + esac + case "$_gb_pat" in + */'**') + _gb_prefix=${_gb_pat%/\*\*} + case "$_gb_ie_path" in + "$_gb_prefix"|"$_gb_prefix"/*) return 0 ;; + esac + ;; + *) + # shellcheck disable=SC2254 # unquoted on purpose: this is a case glob, not a literal + case "$_gb_ie_path" in + $_gb_pat) return 0 ;; + esac + ;; + esac + done <"$GB_EXCLUDE_LIST" + return 1 +} + +# _gb_collect_files +# +# Copies every file and symlink `sysupgrade -l` reports, minus hard-exclude, +# into outdir/files with its path preserved, and appends one manifest entry +# line per item to . Runs the loop body on the read side of a +# pipe (a subshell in POSIX sh), so it only ever accumulates state by +# appending to -- a real file survives the subshell boundary, +# a shell variable set in there would not. +_gb_collect_files() { + _gb_outdir="$1" + _gb_entries="$2" + sysupgrade -l 2>/dev/null | while IFS= read -r _gb_path || [ -n "$_gb_path" ]; do + [ -n "$_gb_path" ] || continue + # One spelling per file from here down, so the staged tree, the + # manifest entry and the exclude decision cannot disagree about + # what "this path" is: "/etc/./config/network" and + # "/etc/config/network" are one file to the kernel but two + # distinct manifest entries (and two distinct restore targets) + # if the raw string is carried through. + _gb_path=$(gb_path_canon "$_gb_path") + _gb_collect_is_excluded "$_gb_path" && continue + + _gb_src="$GB_ROOT$_gb_path" + _gb_dest="$_gb_outdir/files$_gb_path" + mkdir -p "$(dirname "$_gb_dest")" || continue + + if [ -L "$_gb_src" ]; then + # Never dereferenced (R24.1): the target string is copied as-is, + # and mode/uid/gid come from `stat` without -L, i.e. the link + # itself, not what it points to. + _gb_target=$(readlink "$_gb_src") || continue + ln -s "$_gb_target" "$_gb_dest" || continue + _gb_mug=$(stat -c '%a %u %g' "$_gb_src" 2>/dev/null) || _gb_mug='0 0 0' + # shellcheck disable=SC2086 # word-splitting is the point: unpacking "mode uid gid" + set -- $_gb_mug + _gb_collect_entry_symlink "$_gb_path" "$1" "$2" "$3" "$_gb_target" >>"$_gb_entries" + elif [ -f "$_gb_src" ]; then + cp "$_gb_src" "$_gb_dest" || continue + _gb_mug=$(stat -c '%a %u %g' "$_gb_src" 2>/dev/null) || _gb_mug='0 0 0' + # shellcheck disable=SC2086 + set -- $_gb_mug + _gb_sha=$(sha256sum "$_gb_dest" 2>/dev/null | awk '{print $1}') + _gb_collect_entry_file "$_gb_path" "$1" "$2" "$3" "$_gb_sha" >>"$_gb_entries" + else + gb_log warning "gb_collect: sysupgrade -l listed '$_gb_path' but it is not there anymore" + fi + done +} + +# _gb_collect_empty_dirs +# +# `sysupgrade -l` never reports a directory (spec: it flattens every source +# through `find ... -type f -o -type l`), so an empty directory anywhere in +# the backup set would silently vanish from the tree and from git, which +# does not store empty directories either. This walks the same sources +# sysupgrade.conf feeds that `find` -- /etc/sysupgrade.conf and every file +# under /lib/upgrade/keep.d/ -- and records, as a `dir` entry, every +# directory under a directory-type line that turns out to have zero +# children. A directory that has children (even only other empty +# directories) is not itself empty and gets no entry of its own: restore +# recreates it implicitly by recreating what it contains. +_gb_collect_empty_dirs() { + _gb_outdir="$1" + _gb_entries="$2" + { + [ -r "$GB_ROOT/etc/sysupgrade.conf" ] && cat "$GB_ROOT/etc/sysupgrade.conf" + for _gb_f in "$GB_ROOT/lib/upgrade/keep.d/"*; do + [ -r "$_gb_f" ] && cat "$_gb_f" + done + } 2>/dev/null | while IFS= read -r _gb_line || [ -n "$_gb_line" ]; do + case "$_gb_line" in + /*) ;; + *) continue ;; # blank, comment, or a relative line -- not a source we can resolve + esac + # keep.d lines end directories in "/" (measured on the 25.12.4 stand, + # e.g. "/etc/config/"); stripped here so `find`'s own top-level match + # does not echo it back into the manifest path. + _gb_line="${_gb_line%/}" + [ -n "$_gb_line" ] && [ -d "$GB_ROOT$_gb_line" ] || continue + + find "$GB_ROOT$_gb_line" -type d 2>/dev/null | while IFS= read -r _gb_realdir; do + _gb_relpath="${_gb_realdir#"$GB_ROOT"}" + _gb_collect_is_excluded "$_gb_relpath" && continue + [ -z "$(find "$_gb_realdir" -mindepth 1 -maxdepth 1 2>/dev/null)" ] || continue + _gb_mug=$(stat -c '%a %u %g' "$_gb_realdir" 2>/dev/null) || _gb_mug='0 0 0' + # shellcheck disable=SC2086 + set -- $_gb_mug + _gb_collect_entry_dir "$_gb_relpath" "$1" "$2" "$3" >>"$_gb_entries" + done + done +} + +# _gb_collect_meta -- the five meta/ files R17-R21 ask for. +_gb_collect_meta() { + _gb_outdir="$1" + + # board.json: `ubus call system board` (R17), read back by + # _gb_collect_write_manifest instead of calling ubus a second time. + ubus call system board 2>/dev/null >"$_gb_outdir/meta/board.json" + + # installed_packages.txt: full "name-version" lines, not bare names -- + # --with-packages (a later ticket) reads names from `apk info` instead + # (spec, "apk"). R18. + apk list --installed 2>/dev/null >"$_gb_outdir/meta/installed_packages.txt" + + # repositories.txt: /etc/apk/repositories.d/*, concatenated -- 25.12 has + # no /etc/apk/repositories at all (measured on stand), so that path is + # never read. R19. + : >"$_gb_outdir/meta/repositories.txt" + for _gb_f in "$GB_ROOT/etc/apk/repositories.d/"*; do + [ -r "$_gb_f" ] && cat "$_gb_f" >>"$_gb_outdir/meta/repositories.txt" + done + + # sysupgrade.conf: a copy of the extra-paths file as it stood at + # collection time (R20). + if [ -r "$GB_ROOT/etc/sysupgrade.conf" ]; then + cp "$GB_ROOT/etc/sysupgrade.conf" "$_gb_outdir/meta/sysupgrade.conf" + else + : >"$_gb_outdir/meta/sysupgrade.conf" + fi + + # os-release.txt: a copy of /etc/os-release (R21). Confirmed on the + # owlab 25.12.4 stand: the file exists and VERSION_ID="25.12.4" is + # exactly the field manifest.openwrt below reads out of it. + if [ -r "$GB_ROOT/etc/os-release" ]; then + cp "$GB_ROOT/etc/os-release" "$_gb_outdir/meta/os-release.txt" + else + : >"$_gb_outdir/meta/os-release.txt" + fi +} + +# _gb_collect_write_manifest +_gb_collect_write_manifest() { + _gb_outdir="$1" + _gb_entries="$2" + _gb_manifest=$(gb_manifest_path "$_gb_outdir") + + _gb_board=$(cat "$_gb_outdir/meta/board.json" 2>/dev/null) + _gb_hostname=$(printf '%s' "$_gb_board" | jsonfilter -e '@.hostname' 2>/dev/null) + _gb_model=$(printf '%s' "$_gb_board" | jsonfilter -e '@.model' 2>/dev/null) + _gb_openwrt=$(sed -n 's/^VERSION_ID="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' \ + "$_gb_outdir/meta/os-release.txt" 2>/dev/null | head -n 1) + _gb_now=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + # sort -u both dedupes (an empty directory reachable from two sources in + # sysupgrade.conf would otherwise get two identical entries) and makes + # two collections of an unchanged router byte-identical apart from + # "generated" -- gb_manifest_equal does not depend on this, but it means + # `diff` on two manifests is actually readable. + _gb_sorted=$(sort -u "$_gb_entries") + + { + printf '{\n' + printf ' "version": "1",\n' + printf ' "generated": %s,\n' "$(gb_json_str "$_gb_now")" + printf ' "hostname": %s,\n' "$(gb_json_str "$_gb_hostname")" + printf ' "device": %s,\n' "$(gb_json_str "${GB_DEVICE:-}")" + printf ' "openwrt": %s,\n' "$(gb_json_str "$_gb_openwrt")" + printf ' "board": %s,\n' "$(gb_json_str "$_gb_model")" + printf ' "entries": [\n' + _gb_collect_join "$_gb_sorted" + printf ' ],\n' + printf ' "scrubbed": [\n' + # Always empty here: scrub only runs at visibility=public and edits + # outdir/files in place after gb_collect has already run (ticket 05 + # owns that step and, with it, the entries/scrubbed this manifest + # needs once scrubbing has happened -- not this function's concern, + # see collect.sh's own header comment on ownership). + printf ' ]\n' + printf '}\n' + } >"$_gb_manifest" +} + +# _gb_collect_join -- 's non-empty lines, each indented 4 +# spaces and comma-joined, with no trailing comma on the last one -- the +# shape a JSON array's body needs. is a whole variable, not a file: +# the values it holds already went through sort -u in the one caller that +# exists today. +_gb_collect_join() { + _gb_first=1 + printf '%s\n' "$1" | while IFS= read -r _gb_line || [ -n "$_gb_line" ]; do + [ -n "$_gb_line" ] || continue + if [ "$_gb_first" -eq 1 ]; then _gb_first=0; else printf ',\n'; fi + printf ' %s' "$_gb_line" + done + [ -z "$1" ] || printf '\n' +} + +# _gb_collect_json_num -- if it is all digits, else "0". +# +# Guards mode/uid/gid against a failed `stat` leaving them empty: an +# unquoted empty field would make the manifest invalid JSON, and "0" is a +# safe, obviously-wrong value a human reviewing the manifest will notice +# rather than a parser choking on it. +_gb_collect_json_num() { + case "${1:-}" in + ''|*[!0-9]*) printf '0' ;; + *) printf '%s' "$1" ;; + esac +} + +# _gb_collect_entry_file +_gb_collect_entry_file() { + printf '{"path":%s,"type":"file","mode":%s,"uid":%s,"gid":%s,"sha256":%s}\n' \ + "$(gb_json_str "$1")" "$(_gb_collect_json_num "$2")" \ + "$(_gb_collect_json_num "$3")" "$(_gb_collect_json_num "$4")" "$(gb_json_str "$5")" +} + +# _gb_collect_entry_symlink +_gb_collect_entry_symlink() { + printf '{"path":%s,"type":"symlink","mode":%s,"uid":%s,"gid":%s,"target":%s}\n' \ + "$(gb_json_str "$1")" "$(_gb_collect_json_num "$2")" \ + "$(_gb_collect_json_num "$3")" "$(_gb_collect_json_num "$4")" "$(gb_json_str "$5")" +} + +# _gb_collect_entry_dir +_gb_collect_entry_dir() { + printf '{"path":%s,"type":"dir","mode":%s,"uid":%s,"gid":%s}\n' \ + "$(gb_json_str "$1")" "$(_gb_collect_json_num "$2")" \ + "$(_gb_collect_json_num "$3")" "$(_gb_collect_json_num "$4")" +} diff --git a/gitbackup/files/usr/share/gitbackup/device.sh b/gitbackup/files/usr/share/gitbackup/device.sh new file mode 100644 index 000000000..81e8c99b3 --- /dev/null +++ b/gitbackup/files/usr/share/gitbackup/device.sh @@ -0,0 +1,120 @@ +# shellcheck shell=sh +# +# gitbackup -- device identity (spec G01). +# +# Exposes gb_device_id and gb_expand; every _gb_-prefixed helper below is +# private and may change without notice to callers, same convention as +# lib.sh. Sourced, never executed: nothing here runs at load time. + +# gb_device_id -- resolves gitbackup.main.device_id to a device name. +# +# One of three strategies. Called on every subcommand (through the CLI's +# config validation), so the strategies and their failure modes live here +# once instead of being reimplemented at every call site. +gb_device_id() { + _gb_strategy=$(gb_uci_get gitbackup.main.device_id hostname) + case "$_gb_strategy" in + hostname) _gb_device_by_hostname ;; + custom) _gb_device_by_custom ;; + board) _gb_device_by_board ;; + *) gb_die 2 "gitbackup.main.device_id: unknown strategy '$_gb_strategy' (want hostname, custom or board)" ;; + esac +} + +# gb_expand