diff --git a/airconnect/Makefile b/airconnect/Makefile index 1603d7f9c..fd48cbbe8 100644 --- a/airconnect/Makefile +++ b/airconnect/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=airconnect -PKG_VERSION:=1.12.2 +PKG_VERSION:=1.12.3 PKG_RELEASE=1 PKG_SOURCE:=AirConnect-$(PKG_VERSION).zip diff --git a/cloudflared-next/Makefile b/cloudflared-next/Makefile index aaa2eec45..489642df1 100644 --- a/cloudflared-next/Makefile +++ b/cloudflared-next/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=cloudflared-next -PKG_VERSION:=2026.9.1 -PKG_RELEASE:=1 +PKG_VERSION:=2026.9.3 +PKG_RELEASE:=2 # Architecture mapping for pre-built binaries ifeq ($(ARCH),x86_64) diff --git a/dae/Makefile b/dae/Makefile index 92afa9fb2..0b3d21648 100644 --- a/dae/Makefile +++ b/dae/Makefile @@ -5,10 +5,10 @@ include $(TOPDIR)/rules.mk PKG_NAME:=dae -PKG_VERSION:=2026.09.23 -PKG_RELEASE:=47 +PKG_VERSION:=2026.09.24 +PKG_RELEASE:=48 -PKG_SOURCE:=dae-src-2026.09.23-c9f580bcaa8a.tar.gz +PKG_SOURCE:=dae-src-2026.09.24-17135da7c881.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_HASH:=skip diff --git a/dae/files/dae.init b/dae/files/dae.init index a47ea99a7..671c9263e 100644 --- a/dae/files/dae.init +++ b/dae/files/dae.init @@ -20,7 +20,7 @@ start_service() { local config_file config_get config_file "config" "config_file" "/etc/dae/config.dae" - "$PROG" validate -c "$config_file" || return 1 + DAE_LOCATION_ASSET="/usr/share/v2ray" "$PROG" validate -c "$config_file" || return 1 local log_maxbackups log_maxsize config_get log_maxbackups "config" "log_maxbackups" "1" diff --git a/daed/Makefile b/daed/Makefile index 6bf264cdc..ce8845d3c 100644 --- a/daed/Makefile +++ b/daed/Makefile @@ -5,10 +5,10 @@ include $(TOPDIR)/rules.mk PKG_NAME:=daed -PKG_VERSION:=2026.09.23 -PKG_RELEASE:=58 +PKG_VERSION:=2026.09.24 +PKG_RELEASE:=59 -PKG_SOURCE:=daed-src-2026.09.23-fcc80bac4542.tar.gz +PKG_SOURCE:=daed-src-2026.09.24-527bb166b1ae.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_HASH:=skip diff --git a/daed/patches/0012-export-config-marshaller-bytes.patch b/daed/patches/0012-export-config-marshaller-bytes.patch deleted file mode 100644 index 115d5745d..000000000 --- a/daed/patches/0012-export-config-marshaller-bytes.patch +++ /dev/null @@ -1,21 +0,0 @@ -From: kenzok8 -Date: 2026-09-23 -Subject: [PATCH] daed: expose config marshaller bytes - -The current wing generator calls Marshaller.Bytes when updating global config. ---- -diff --git a/dae-core/config/marshal.go b/dae-core/config/marshal.go -index f68006a..dfd6777 100644 ---- a/dae-core/config/marshal.go -+++ b/dae-core/config/marshal.go -@@ -42,6 +42,10 @@ type Marshaller struct { - buf bytes.Buffer - } - -+func (m *Marshaller) Bytes() []byte { -+ return m.buf.Bytes() -+} -+ - func (m *Marshaller) writeLine(depth int, line string) { - if depth < 0 { - depth = 0 diff --git a/dnsproxy/Makefile b/dnsproxy/Makefile index f01eb3570..de692d70a 100644 --- a/dnsproxy/Makefile +++ b/dnsproxy/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=dnsproxy -PKG_VERSION:=0.84.2 -PKG_RELEASE:=18 +PKG_VERSION:=0.85.0 +PKG_RELEASE:=19 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/AdguardTeam/dnsproxy/tar.gz/v$(PKG_VERSION)? diff --git a/gitbackup/Makefile b/gitbackup/Makefile deleted file mode 100644 index 018db9347..000000000 --- a/gitbackup/Makefile +++ /dev/null @@ -1,51 +0,0 @@ -# -# 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/init.d/gitbackup b/gitbackup/files/etc/init.d/gitbackup index 9380374fb..41f37c8bd 100755 --- a/gitbackup/files/etc/init.d/gitbackup +++ b/gitbackup/files/etc/init.d/gitbackup @@ -2,18 +2,8 @@ # 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. +# Not a daemon: applies the cron schedule and debounces config.change events +# of backed-up packages into one `gitbackup run`. GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}" GB_STATE_DIR="${GB_STATE_DIR:-/var/run/gitbackup}" @@ -29,11 +19,7 @@ GB_BIN="${GB_BIN:-/usr/sbin/gitbackup}" 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. +# Lets rc.common dispatch the procd trigger target `config_change`. EXTRA_COMMANDS="config_change" EXTRA_HELP=" config_change Internal: debounced trigger used by service_triggers, not for interactive use" @@ -41,52 +27,17 @@ 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. +# UCI packages in the backup set, minus gitbackup (has the reload trigger). _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. +# (Re)arm one delayed run, cancelling the previous timer. +# busybox `sleep` takes whole seconds only, hence the integer guard. config_change() { _gb_debounce=$(gb_uci_get gitbackup.main.debounce 60) case "$_gb_debounce" in ''|*[!0-9]*) _gb_debounce=60 ;; esac @@ -94,18 +45,6 @@ config_change() { 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 @@ -118,21 +57,9 @@ config_change() { 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 + procd_add_config_trigger "config.change" "$_gb_pkg" /etc/init.d/gitbackup config_change done } diff --git a/gitbackup/files/etc/uci-defaults/99-gitbackup b/gitbackup/files/etc/uci-defaults/99-gitbackup index e54a85a0d..19a6add67 100644 --- a/gitbackup/files/etc/uci-defaults/99-gitbackup +++ b/gitbackup/files/etc/uci-defaults/99-gitbackup @@ -1,15 +1,7 @@ # 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). +# First boot, not postinst (postinst also runs at image build time). +# Sourced by /etc/init.d/boot and deleted on exit 0: no shebang needed. [ -d /etc/gitbackup ] || mkdir -m 0700 /etc/gitbackup chown root:root /etc/gitbackup diff --git a/gitbackup/files/usr/libexec/rpcd/luci.gitbackup b/gitbackup/files/usr/libexec/rpcd/luci.gitbackup index 813111bfa..ba460451f 100755 --- a/gitbackup/files/usr/libexec/rpcd/luci.gitbackup +++ b/gitbackup/files/usr/libexec/rpcd/luci.gitbackup @@ -1,53 +1,10 @@ #!/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. +# rpcd plugin for ubus object "luci.gitbackup": translates calls into +# `gitbackup` CLI runs. Always prints JSON and exits 0: a non-zero exit would +# become a bodyless ubus error. Failures answer {"reason": ...}. +# The method list is mirrored in acl.d/luci-app-gitbackup.json (tested). set -u @@ -56,73 +13,49 @@ 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 +# shellcheck disable=SC1091 # GB_SHARE is a runtime path . "$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 +# shellcheck disable=SC1091 +. "$GB_SHARE/card.sh" -# --------------------------------------------------------------------------- -# 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 --------------------------------------------------------- +# _gb_rpc_relay -- "-" relays JSON as is, else +# wraps as {"": ...}. stderr stays out of a success answer: one gb_log +# notice in front of the JSON breaks the whole reply. +_gb_rpc_relay() { + _gb_rpc_key="$1" + shift + _gb_rpc_errf=$(mktemp "${TMPDIR:-/tmp}/gbrpc-err.XXXXXX") || { + _gb_rpc_error 'could not create a scratch file under /tmp' + return 0 + } + _gb_rpc_out=$("$GB_BIN" "$@" 2>"$_gb_rpc_errf") + _gb_rpc_rc=$? + _gb_rpc_err=$(cat "$_gb_rpc_errf") + rm -f "$_gb_rpc_errf" + if [ "$_gb_rpc_rc" -ne 0 ]; then + _gb_rpc_error "$(printf '%s\n%s' "$_gb_rpc_err" "$_gb_rpc_out" | grep .)" + elif [ "$_gb_rpc_key" = - ]; then + printf '%s\n' "$_gb_rpc_out" + else + _gb_rpc_text "$_gb_rpc_key" "$_gb_rpc_out" + fi +} gbrpc_status() { _gb_rpc_out=$("$GB_BIN" status 2>/dev/null) @@ -140,132 +73,34 @@ gbrpc_log() { } 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 + _gb_rpc_relay pubkey pubkey } -# 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 + _gb_rpc_relay - paths list --json +} + +gbrpc_audit_paths() { + _gb_rpc_relay - paths audit } -# 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")" + if _gb_rpc_why=$(gb_cron_valid "$_gb_rpc_expr" 2>&1 >/dev/null); then + printf '{ "valid": true }\n' 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 + _gb_rpc_relay - history --limit "$_gb_rpc_limit" } -# 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. +# In the ACL's WRITE tier: it returns backed-up content (shadow hash, host +# keys, PSKs); tiers split on "may see secrets", not "changes something". gbrpc_diff() { _gb_rpc_from=$(_gb_rpc_param "$1" '@.from') _gb_rpc_to=$(_gb_rpc_param "$1" '@.to') @@ -273,184 +108,65 @@ gbrpc_diff() { _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" + _gb_rpc_relay diff diff "$_gb_rpc_from" "$_gb_rpc_to" } -# 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". +# Live config vs last commit, by manifest -- sees a chmod `git diff` cannot. gbrpc_config_diff() { - _gb_rpc_out=$("$GB_BIN" diff 2>&1) - _gb_rpc_rc=$? - if [ "$_gb_rpc_rc" -ne 0 ]; then + if ! _gb_rpc_out=$("$GB_BIN" diff 2>&1); then _gb_rpc_error "$_gb_rpc_out" - return 0 - fi - if [ -n "$_gb_rpc_out" ]; then + elif [ -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' } +# stdin is /dev/null, so an unknown host key ends as "needs confirmation"; +# the web flow then goes through `hostkey`. 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". +# No fingerprint: show the remote's host key. With one: accept exactly it. 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) + _gb_rpc_relay - hostkey accept "$_gb_rpc_fp" 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" + _gb_rpc_relay - hostkey show 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. +# keygen over an existing key needs --force --confirm ; exit 4 +# means "confirm first" and carries the fingerprint on its first line. 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 + set -- keygen + case "$_gb_rpc_force" in true | 1) set -- "$@" --force ;; esac + [ -z "$_gb_rpc_confirm" ] || set -- "$@" --confirm "$_gb_rpc_confirm" + _gb_rpc_out=$("$GB_BIN" "$@" 2>&1) _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 + elif [ "$_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 + else + printf '{ "ok": false, "reason": %s }\n' "$(gb_json_str "$_gb_rpc_out")" 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. +# Writes the token to token_file (0600 in a 0700 dir). The value never goes +# to argv, UCI or the log; there is no method to read it back. gbrpc_set_secret() { _gb_rpc_value=$(_gb_rpc_param "$1" '@.value') _gb_rpc_file=$(gb_uci_get gitbackup.origin.token_file /etc/gitbackup/token) @@ -479,95 +195,37 @@ gbrpc_set_secret() { 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. +# Writes accepted entries verbatim, never the expanded effective set: files +# created later under a directory would silently stop being backed up. gbrpc_set_paths() { - _gb_rpc_dir=$(dirname "$GB_SYSUPGRADE_CONF") - mkdir -p "$_gb_rpc_dir" 2>/dev/null + mkdir -p "$(dirname "$GB_SYSUPGRADE_CONF")" 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" + : >"$_gb_rpc_tmp.kept" + : >"$_gb_rpc_tmp.rej" - # 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 + _gb_rpc_param "$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" + "$(gb_json_str "$_gb_rpc_p")" "$(gb_json_str "$_gb_rpc_reason")" >>"$_gb_rpc_tmp.rej" else printf '%s\n' "$_gb_rpc_p" >>"$_gb_rpc_tmp" - printf '%s\n' "$(gb_json_str "$_gb_rpc_p")" >>"$_gb_rpc_kept_f" + gb_json_str "$_gb_rpc_p" >>"$_gb_rpc_tmp.kept" + echo >>"$_gb_rpc_tmp.kept" 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" + printf '{ "ok": true, "written": [%s], "rejected": [%s] }\n' \ + "$(gb_join_lines , <"$_gb_rpc_tmp.kept")" "$(gb_join_lines , <"$_gb_rpc_tmp.rej")" + rm -f "$_gb_rpc_tmp" "$_gb_rpc_tmp.kept" "$_gb_rpc_tmp.rej" } +# Backgrounded like run/test; --yes because the view already asked twice. gbrpc_restore() { _gb_rpc_device=$(_gb_rpc_param "$1" '@.device') _gb_rpc_commit=$(_gb_rpc_param "$1" '@.commit') @@ -590,14 +248,7 @@ gbrpc_restore() { 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 @@ -610,10 +261,6 @@ gbrpc_card() { rm -f "$_gb_rpc_file" } -# --------------------------------------------------------------------------- -# rpcd entry point -# --------------------------------------------------------------------------- - case "${1:-}" in list) cat <<'EOF' @@ -639,17 +286,8 @@ case "${1:-}" in 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. + # rpcd sends params with NO trailing newline: `read` fills the + # variable but returns 1, so `|| X='{}'` would drop every parameter. IFS= read -r _gb_rpc_input || true [ -n "$_gb_rpc_input" ] || _gb_rpc_input='{}' case "${2:-}" in diff --git a/gitbackup/files/usr/sbin/gitbackup b/gitbackup/files/usr/sbin/gitbackup index a34652c28..0d57bda3a 100755 --- a/gitbackup/files/usr/sbin/gitbackup +++ b/gitbackup/files/usr/sbin/gitbackup @@ -1,14 +1,8 @@ #!/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. +# gitbackup CLI. Exit codes, shared with every module: 0 ok, 1 general error, +# 2 invalid configuration, 3 network or authentication, 4 refused for safety. set -u @@ -35,15 +29,10 @@ GB_STATE_DIR="${GB_STATE_DIR:-/var/run/gitbackup}" . "$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 +# shellcheck disable=SC1091 +. "$GB_SHARE/schedule.sh" +# shellcheck disable=SC1091 +. "$GB_SHARE/card.sh" usage() { cat <<'EOF' @@ -62,7 +51,7 @@ Commands: 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) + without an interactive terminal (what the web UI uses) paths list [--json]|add|del|audit -- edit /etc/sysupgrade.conf restore restore configuration from a commit card print a recovery card @@ -70,73 +59,44 @@ Commands: 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). +# Guards every caller-supplied commit before it reaches a bare git argument: +# an option-shaped value ("--upload-pack=...") must never get there. _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. + # The charset check alone passes abbreviated or overlong shas. [ "${#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_remote -- sets GB_SCHEME, GB_HOST, GB_PORT; exit 2 on an unsupported URL. +gb_remote() { + _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_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_branch() { + gb_expand "$(gb_uci_get gitbackup.origin.branch 'device/{device}')" +} + +# gb_validate_config -- strict config check for commands that touch the +# remote: exit 2 on the first unusable field. Sets GB_DEVICE, GB_URL, GB_SCRUB. +gb_validate_config() { + gb_status_config + case " $GB_MISSING " in + *' device_id '*) GB_DEVICE=$(gb_device_id) || exit $? ;; + esac + case " $GB_MISSING " in + *' cron_expr '*) gb_die 2 "gitbackup.main.cron_expr: $(gb_cron_valid "$(gb_uci_get gitbackup.main.cron_expr)" 2>&1)" ;; + esac + [ -n "$GB_URL" ] || gb_die 2 'gitbackup.origin.url: empty, a repository url is required' +} + +# _gb_effective_scrub -- a public remote is always scrubbed. Overrides only +# this run's decision, never the saved gitbackup.security.scrub. _gb_effective_scrub() { _gb_vis=$(gb_uci_get gitbackup.origin.visibility private) _gb_scrub=$(gb_uci_get gitbackup.security.scrub 0) @@ -147,20 +107,11 @@ _gb_effective_scrub() { 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 -- never exits: unusable fields go to GB_MISSING and +# GB_CONFIGURED is false. 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" @@ -169,7 +120,7 @@ gb_status_config() { _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" + gb_cron_valid "$_gb_cron" >/dev/null 2>&1 || GB_MISSING="$GB_MISSING cron_expr" fi GB_URL=$(gb_uci_get gitbackup.origin.url) @@ -177,11 +128,8 @@ gb_status_config() { GB_SCRUB=$(_gb_effective_scrub) - if [ -n "$GB_MISSING" ]; then - GB_CONFIGURED=false - else - GB_CONFIGURED=true - fi + GB_CONFIGURED=true + [ -z "$GB_MISSING" ] || GB_CONFIGURED=false } cmd_status() { @@ -211,87 +159,39 @@ cmd_status() { 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 -- exit 3 covers network and auth (one slot in the contract); the +# message tells them apart. No gb_have_net precheck: git's error classifies. 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" + gb_remote + _gb_scheme="$GB_SCHEME" _gb_host="$GB_HOST" _gb_port="$GB_PORT" - # 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". + # rc 3 means "could not verify", never "safe". 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. + # Host keys are accepted only here, never in `run`. 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" + # gb_accept_hostkey sets _gb_port to the effective port (0 -> 22). + gb_accept_hostkey "$_gb_host" "$_gb_port" case $? in 0) ;; - 1) gb_die 3 "$_gb_host:$_gb_hk_port host key was not accepted" ;; + 1) gb_die 3 "$_gb_host:$_gb_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" + # No stdin to ask on (e.g. rpcd): not the same as a typed decline. + gb_die 3 "$_gb_host:$_gb_port host key needs confirmation before this can proceed -- run 'gitbackup hostkey show' (or use the web UI) to see its fingerprint, then 'gitbackup hostkey accept ' to trust it" ;; - *) gb_die 3 "$_gb_host:$_gb_hk_port host key could not be obtained -- is the network reachable?" ;; + *) gb_die 3 "$_gb_host:$_gb_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. + # gb_git_env quotes its output for eval. 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`. + # ls-remote proved the current key works: drop the `keygen --force` backup. gb_keygen_forget_old printf 'ok: %s is reachable and authenticated\n' "$GB_URL" return 0 @@ -300,18 +200,6 @@ cmd_test() { _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" @@ -326,36 +214,15 @@ _gb_test_classify() { 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 show|accept -- host key confirmation for the web +# UI (rpcd runs `test` with no stdin). ssh:// only. 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_remote + _gb_hk_scheme="$GB_SCHEME" _gb_hk_host="$GB_HOST" _gb_hk_port="$GB_PORT" [ "$_gb_hk_port" != 0 ] || _gb_hk_port=22 if [ "$_gb_hk_scheme" != ssh ]; then @@ -399,44 +266,12 @@ cmd_hostkey() { } # --------------------------------------------------------------------------- -# run (ticket 07, spec "Как коммит попадает в репозиторий без клона") +# run -- cmd_run does the gates; _gb_run_backup the git half, treating GB_URL +# as opaque so it can be tested against a local bare repository. # --------------------------------------------------------------------------- -# -# 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. +# Basenames of /etc/config/* entries differing in any field. sort | uniq -u: +# the image ships no comm/diff. _gb_run_changed_configs() { _gb_rcc_old="$1" _gb_rcc_new="$2" @@ -446,16 +281,6 @@ _gb_run_changed_configs() { } 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" @@ -464,49 +289,34 @@ _gb_run_write_message() { _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) + _gb_rwm_short=$(printf '%s\n' "$_gb_rwm_list" | sed -n '1,3p' | gb_join_lines ', ') [ -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) + _gb_rwm_paths=$(printf '%s\n' "$_gb_rwm_list" | sed -n '/./ s#^#/etc/config/#p' | gb_join_lines ', ') [ -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 + # The router's own file (not a backup's), so sourcing it is safe. + _gb_rwm_release=$( + DISTRIB_RELEASE='' DISTRIB_REVISION='' + # shellcheck disable=SC1090,SC1091 + [ -r "${GB_ROOT:-}/etc/openwrt_release" ] && . "${GB_ROOT:-}/etc/openwrt_release" + printf '%s %s' "$DISTRIB_RELEASE" "$DISTRIB_REVISION" + ) { 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 'OpenWrt: %s\n' "$_gb_rwm_release" 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. +# last_run and state.json live on tmpfs; git remains the source of truth. _gb_run_write_state() { _gb_rws_result="$1" _gb_rws_reason="$2" @@ -528,51 +338,13 @@ _gb_run_write_state() { } >"$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. +# Pushes an index README to "main" if it does not exist yet. Only on a +# device's first push (saves an ls-remote per run); never fails the backup. _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). + # Clear the device branch's parent: main gets its own root commit. GB_PARENT='' _gb_rmi_head=$(gb_remote_head main 2>/dev/null) @@ -599,13 +371,8 @@ _gb_run_ensure_main_index() { 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_build_tree still requires a non-empty GB_PREFIX; the README goes in via + # the extra-file pair. _gb_rmi_emptydir="$WORK/main-readme-empty" rm -rf "$_gb_rmi_emptydir" mkdir -p "$_gb_rmi_emptydir" 2>/dev/null @@ -617,28 +384,21 @@ _gb_run_ensure_main_index() { 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_branch=$(gb_branch) _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. + # A brand-new branch has no parent to fetch, but the plumbing still + # needs a .git here. [ -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 @@ -648,16 +408,12 @@ _gb_run_backup() { 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_PARENT is an environment input to gb_build_tree. _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. + # Captured before the retry loop can reassign GB_PARENT. _gb_branch_was_new="" [ -z "$GB_PARENT" ] && _gb_branch_was_new=1 @@ -686,34 +442,9 @@ _gb_run_backup() { 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. + # Archive only after the compare: sysupgrade -b's gzip is not + # byte-deterministic. Never when scrubbing: it reads the LIVE filesystem and + # would carry every value the scrub just removed. 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' @@ -723,26 +454,13 @@ _gb_run_backup() { 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. + # One gb_card call for both RECOVERY.md and the branch-root README.md, so + # the copies cannot drift. _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 + 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 _gb_changed_list=$(_gb_run_changed_configs "$_gb_old_manifest" "$(gb_manifest_path "$_gb_outdir")") @@ -750,12 +468,7 @@ _gb_run_backup() { _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. + # On non-fast-forward retry once on the new parent, reusing the collected set. _gb_t_push_start=$(date +%s) _gb_attempt=1 while :; do @@ -768,7 +481,7 @@ _gb_run_backup() { 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)" + gb_die 1 "gitbackup run: $_gb_branch changed again during the retry, giving up after one retry" fi gb_log notice "gitbackup run: $_gb_branch moved under us, retrying once" _gb_attempt=2 @@ -791,24 +504,13 @@ _gb_run_backup() { "{\"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 [--trigger cron|manual|procd|luci] -- the crontab line passes no +# flag, hence the cron default. cmd_run() { _gb_trigger=cron while [ "$#" -gt 0 ]; do @@ -822,12 +524,8 @@ cmd_run() { 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"). + # Non-blocking flock on an fd. Busy -> skipped, exit 0: cron must not + # report an overlap that resolved itself. _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" @@ -837,15 +535,8 @@ cmd_run() { 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_remote + _gb_scheme="$GB_SCHEME" _gb_host="$GB_HOST" _gb_port="$GB_PORT" _gb_netport="$_gb_port" if [ "$_gb_netport" = 0 ]; then if [ "$_gb_scheme" = ssh ]; then _gb_netport=22; else _gb_netport=443; fi @@ -856,36 +547,23 @@ cmd_run() { 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 holds a plaintext backup set and briefly credentials: /tmp only + # (never flash/USB), removed on any exit. 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. + # TMPDIR may override /tmp: check the filesystem $WORK lives on. _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. + # Visibility gate: 3 (could not verify) is skipped like no network, never + # treated as private; 4 (public) always fails loudly. gb_visibility_ok "$GB_URL" case $? in 4) gb_die 4 "$GB_URL is publicly visible to an anonymous request; push refused" ;; @@ -901,14 +579,8 @@ cmd_run() { _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 -- no gb_validate_config: a bare router may not resolve its +# device id. Only the URL is required. cmd_restore() { _gb_r_device='' _gb_r_commit='' @@ -924,29 +596,15 @@ cmd_restore() { 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. + # No --device: restore this router's own backup. [ -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_remote - # _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. + # --commit reaches bare git arguments: allow only empty/HEAD or a full sha. 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" @@ -960,14 +618,7 @@ cmd_restore() { } # --------------------------------------------------------------------------- -# 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. +# history -- the branch's commits as JSON. # --------------------------------------------------------------------------- cmd_history() { _gb_h_limit=20 @@ -980,10 +631,9 @@ cmd_history() { 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_remote - _gb_h_branch=$(gb_expand "$(gb_uci_get gitbackup.origin.branch 'device/{device}')") || + _gb_h_branch=$(gb_branch) || gb_die 2 'gitbackup.origin.branch could not be expanded' eval "$(gb_git_env)" @@ -1000,11 +650,7 @@ cmd_history() { 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. + # Full history (empty depth), not gb_fetch_meta's --depth=1 default. gb_fetch_meta "$_gb_h_branch" "$_gb_h_dir" '' || gb_die 3 "gitbackup history: could not fetch $_gb_h_branch from $GB_URL" @@ -1020,9 +666,7 @@ cmd_history() { 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. + # --root: without it diff-tree prints nothing for a root 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 @@ -1039,11 +683,7 @@ cmd_history() { } # --------------------------------------------------------------------------- -# 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). +# collect -- gb_collect into --out for inspection; nothing pushed or scrubbed. # --------------------------------------------------------------------------- cmd_collect() { _gb_c_out='' @@ -1062,162 +702,54 @@ cmd_collect() { } # --------------------------------------------------------------------------- -# 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. +# diff -- live config vs the last commit by manifest.json, never git: git +# tracks only three file modes and misses a chmod. # --------------------------------------------------------------------------- -# _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. +# A manifest path never holds a raw tab. _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" + _gb_dd_path=$(gb_manifest_field "$1" path) + [ -z "$_gb_dd_path" ] || printf '%s\t%s\n' "$_gb_dd_path" "$1" } -# -# 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" + if [ -r "$1" ]; then gb_manifest_each "$1" entries _gb_diff_dump_line; fi | sort >"$2" } -# _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_manifest_field "$_gb_ddc_old" type) + _gb_ddc_nt=$(gb_manifest_field "$_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_manifest_field "$_gb_ddc_old" mode) + _gb_ddc_nm=$(gb_manifest_field "$_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_manifest_field "$_gb_ddc_old" uid) + _gb_ddc_nu=$(gb_manifest_field "$_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_manifest_field "$_gb_ddc_old" gid) + _gb_ddc_ng=$(gb_manifest_field "$_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_manifest_field "$_gb_ddc_old" sha256) + _gb_ddc_nsha=$(gb_manifest_field "$_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_manifest_field "$_gb_ddc_old" target) + _gb_ddc_ntgt=$(gb_manifest_field "$_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. +# No join(1) on the image. No output = no changes. _gb_diff_compare() { _gb_dc_old="$1" _gb_dc_new="$2" @@ -1227,32 +759,19 @@ _gb_diff_compare() { _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)" + printf '+ %s (%s)\n' "$_gb_dc_p" "$(gb_manifest_field "$_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)" + printf -- '- %s (%s)\n' "$_gb_dc_p" "$(gb_manifest_field "$_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 [ ] -- no args: live vs last commit. Two full shas: +# `git diff` between two backups (History). 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" + gb_remote if [ "$#" -eq 2 ]; then _gb_d_from="$1" @@ -1262,7 +781,7 @@ cmd_diff() { _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_dc_branch=$(gb_branch) || gb_die 2 'gitbackup.origin.branch could not be expanded' _gb_dc_work=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-diffc.XXXXXX") || @@ -1271,23 +790,12 @@ cmd_diff() { 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). + # Full history: either commit may be far back on the branch. 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. + # --end-of-options, not "--": for git diff "--" would turn the shas + # into pathspecs. Needs git >= 2.24. _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 @@ -1301,7 +809,7 @@ cmd_diff() { 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_branch=$(gb_branch) _gb_d_prefix=$(gb_expand "$(gb_uci_get gitbackup.main.path_prefix 'devices/{device}')") _gb_d_work=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-diff.XXXXXX") || @@ -1317,10 +825,7 @@ cmd_diff() { _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. + # Scrub like `run` does, or every scrubbed field would show as changed. gb_scrub "$_gb_d_outdir" >/dev/null 2>&1 fi @@ -1343,11 +848,6 @@ cmd_diff() { 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 @@ -1359,20 +859,8 @@ _gb_paths_json_array() { } # --------------------------------------------------------------------------- -# 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"). +# paths -- list|add|del|audit over /etc/sysupgrade.conf via paths.sh. Not +# gated by gb_validate_config: works before a remote is configured. # --------------------------------------------------------------------------- cmd_paths() { _gb_p_sub="${1:-list}" @@ -1381,29 +869,14 @@ cmd_paths() { 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). + # "paths": the file verbatim. "entries": what to list and send back. + # "effective": read-only; saving it back would shrink a directory entry + # to the files present at save time. _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")" + printf '{ "paths": [%s], "entries": [%s], "effective": [%s], "size_kb": %s }\n' \ + "$(_gb_paths_json_array "$_gb_p_raw")" "$(_gb_paths_json_array "$_gb_p_entries")" "$(_gb_paths_json_array "$_gb_p_eff")" "$(gb_paths_size_kb)" else gb_paths_list printf '# total: %s KB (effective sysupgrade set, sysupgrade -l)\n' "$(gb_paths_size_kb)" @@ -1427,19 +900,10 @@ cmd_paths() { 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. + # Files changed since the base image (/overlay/upper) that are + # neither in `sysupgrade -l` nor gitbackup's own secrets. + # VERIFY: not checked against a real overlay mount; an absent + # directory yields an empty list. _gb_p_upper="${GB_ROOT:-}/overlay/upper" if [ ! -d "$_gb_p_upper" ]; then printf '{ "paths": [] }\n' @@ -1468,10 +932,7 @@ cmd_paths() { } # --------------------------------------------------------------------------- -# 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. +# card -- write the recovery card to --out; gb_card never fails. # --------------------------------------------------------------------------- cmd_card() { _gb_cd_out='' @@ -1483,19 +944,11 @@ cmd_card() { 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 [lines] -- tag-filtered syslog (logread survives a reboot). cmd_log() { _gb_lines="${1:-50}" case "$_gb_lines" in '' | *[!0-9]*) _gb_lines=50 ;; esac @@ -1509,24 +962,12 @@ 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. + # Lenient: a fresh install's default config must not make status 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. + # No gb_validate_config: the key is created before a remote URL exists. _gb_force='' _gb_confirm='' while [ "$#" -gt 0 ]; do @@ -1540,12 +981,8 @@ case "$cmd" in _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. + # Relay the "confirm-required " line on stdout (rpcd + # parses it), then explain on stderr. 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 @@ -1556,8 +993,7 @@ case "$cmd" in 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. + # Needs no remote configuration either. gb_pubkey || exit 1 ;; test) @@ -1573,9 +1009,7 @@ case "$cmd" in 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). + # No gb_validate_config: must work on a half-configured router. cmd_log "$@" ;; restore) @@ -1594,7 +1028,7 @@ case "$cmd" in cmd_history "$@" ;; paths) - # No gb_validate_config -- see cmd_paths's own header comment. + # No gb_validate_config: works before a remote is configured. cmd_paths "$@" ;; *) diff --git a/gitbackup/files/usr/share/gitbackup/askpass.sh b/gitbackup/files/usr/share/gitbackup/askpass.sh index 7674c8875..a917bbca2 100755 --- a/gitbackup/files/usr/share/gitbackup/askpass.sh +++ b/gitbackup/files/usr/share/gitbackup/askpass.sh @@ -1,46 +1,17 @@ #!/bin/sh # shellcheck shell=sh # -# gitbackup -- GIT_ASKPASS helper (spec "Аутентификация"). +# GIT_ASKPASS helper, executed by git with the prompt in $1. The only reader +# of the token file: the token reaches git through this pipe, never argv, a +# URL or a log. # -# 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. +# Only the password prompt gets the token. git embeds the username in the +# URL of its next prompt and passes that as argv here, so a token answered +# as username would leak into argv (seen with GIT_TRACE=1). # -# 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 -- нет учётных данных на стенде"). +# VERIFY: placeholder-user + token-as-password is documented for GitHub PATs +# only; not tested against GitLab/Bitbucket/Gitea. + set -u GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}" @@ -49,10 +20,7 @@ GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}" 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. + # Any non-empty value works; never the token. printf 'gitbackup\n' ;; *) @@ -61,9 +29,7 @@ case "${1:-}" in 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. + # First line only: a stray trailing newline must not reach git. 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 index 2f3585ca6..d9b1c0c0f 100644 --- a/gitbackup/files/usr/share/gitbackup/auth.sh +++ b/gitbackup/files/usr/share/gitbackup/auth.sh @@ -1,86 +1,18 @@ # 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. +# gitbackup -- remote access: gb_git_env, gb_keygen, gb_keygen_forget_old, +# gb_pubkey, gb_accept_hostkey, gb_hostkey_show, gb_hostkey_accept. Needs lib.sh. GB_ETC_DIR="${GB_ETC_DIR:-/etc/gitbackup}" -# gb_git_env +# gb_git_env -- "export KEY='VALUE'" lines for `eval "$(gb_git_env)"`. +# ConnectTimeout: BatchMode does not cover the TCP handshake, and a host that +# drops packets would hang ssh for minutes. # -# 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. +# Must be OpenSSH `ssh`, not dbclient: git's ssh-variant probe fails on it and +# falls back to "simple", dropping every -o option, host-key checking included. # -# 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. +# TLS verification can never be turned off; ca_file is the only way to trust +# a private CA. The token is never printed: askpass.sh reads it on demand. gb_git_env() { _gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519) _gb_known="$GB_ETC_DIR/known_hosts" @@ -88,8 +20,6 @@ gb_git_env() { 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) @@ -97,23 +27,10 @@ gb_git_env() { 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 @@ -123,84 +40,16 @@ _gb_auth_key_fingerprint() { fi } -# gb_keygen [force] [confirm-fingerprint] +# gb_keygen [force] [confirm-fingerprint] -- no passphrase: BatchMode=yes +# would make an encrypted key hang every push. # -# 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. +# Replacing a key cuts off every remote it is registered with, so it takes +# force AND the current fingerprint named back: +# key, no force -- refuse, 1 +# key, force, no/wrong fp -- print "confirm-required ", destroy +# nothing, 4 +# key, force, matching fp -- keep .old(.pub) for manual rollback, +# regenerate, 0 gb_keygen() { _gb_force="${1:-}" _gb_confirm="${2:-}" @@ -225,11 +74,7 @@ gb_keygen() { 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. + # Best effort: must not block a confirmed regeneration. 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 @@ -249,31 +94,12 @@ gb_keygen() { 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. +# Called once `gitbackup test` has proven the current key works. 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 @@ -288,38 +114,15 @@ gb_pubkey() { 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 -- no ssh-keyscan in +# openssh-client: key exchange precedes auth, so accept-new against an empty +# scratch known_hosts records the key even though auth fails. 2 unreachable. _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 @@ -330,58 +133,33 @@ _gb_auth_dial_hostkey() { 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_accept commits exactly the bytes gb_hostkey_show fetched, never +# a fresh dial, so what the operator confirmed is what gets trusted. _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" + [ -r "$_gb_known" ] && ssh-keygen -F "$_gb_host" -f "$_gb_known" >/dev/null 2>&1 +} - # 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_hostkey_record() { + cat "$1" >>"$_gb_known" + chmod 0600 "$_gb_known" + rm -f "$1" + gb_log notice "$2: $_gb_host:$_gb_port accepted and recorded in $_gb_known" +} + +# gb_accept_hostkey [port] -- terminal prompt; never trusts silently. +# 0 trusted, 1 declined, 2 unreachable, 3 no stdin (web uses show/accept). +gb_accept_hostkey() { + _gb_hostkey_init "$1" "${2:-}" && return 0 _gb_scratch=$(mktemp "${TMPDIR:-/tmp}/gitbackup-hostkey.XXXXXX") || return 2 if ! _gb_auth_dial_hostkey "$_gb_host" "$_gb_port" "$_gb_scratch"; then @@ -392,60 +170,27 @@ gb_accept_hostkey() { _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 + if ! IFS= read -r _gb_ans; then 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 ;; + 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 - 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 + _gb_hostkey_record "$_gb_scratch" gb_accept_hostkey +} + +# gb_hostkey_show [port] -- "trusted " or +# "pending ". Records nothing. 2 when unreachable. +gb_hostkey_show() { + if _gb_hostkey_init "$1" "${2:-}"; then printf 'trusted %s %s\n' "$_gb_host" "$_gb_port" return 0 fi @@ -462,33 +207,11 @@ gb_hostkey_show() { 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 -- only on an exact fingerprint match. +# 1 nothing pending, 4 mismatch. gb_hostkey_accept() { - _gb_host="$1" - _gb_port="${2:-22}" + _gb_hostkey_init "$1" "${2:-}" _gb_want_fp="$3" - case "$_gb_port" in - ''|0) _gb_port=22 ;; - esac _gb_pending=$(_gb_hostkey_pending_file) if [ ! -s "$_gb_pending" ]; then @@ -502,11 +225,5 @@ gb_hostkey_accept() { 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 + _gb_hostkey_record "$_gb_pending" gb_hostkey_accept } diff --git a/gitbackup/files/usr/share/gitbackup/card.sh b/gitbackup/files/usr/share/gitbackup/card.sh index 41327374c..8d1369639 100644 --- a/gitbackup/files/usr/share/gitbackup/card.sh +++ b/gitbackup/files/usr/share/gitbackup/card.sh @@ -1,42 +1,10 @@ # shellcheck shell=sh # -# gitbackup -- recovery card (spec "Recovery card и RECOVERY.md", ticket 09). +# gitbackup -- recovery card: gb_card. Needs lib.sh and remoteurl.sh; reads +# GB_URL/GB_DEVICE/GB_SCRUB as resolved by the CLI. # -# 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 -- writes RECOVERY.md. Never contains a secret. Runs +# mid-backup, so it never dies and always returns 0. gb_card() { _gb_c_out="$1" _gb_c_dev="${GB_DEVICE:-}" @@ -47,13 +15,8 @@ gb_card() { _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_SCRUB, not gitbackup.security.scrub: visibility=public forces scrub + # on, and missing that 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 @@ -71,11 +34,7 @@ gb_card() { _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. + # No port: known provider web UIs are on the default https port. _gb_c_weblink="https://$_gb_c_host/$_gb_c_owner/$_gb_c_repo" fi fi @@ -100,15 +59,8 @@ gb_card() { 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. + # Path 0 only when an archive is actually pushed (archive on, no + # scrub); otherwise say why it is unavailable. 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' diff --git a/gitbackup/files/usr/share/gitbackup/collect.sh b/gitbackup/files/usr/share/gitbackup/collect.sh index 99a0235cf..a66bf14f6 100644 --- a/gitbackup/files/usr/share/gitbackup/collect.sh +++ b/gitbackup/files/usr/share/gitbackup/collect.sh @@ -1,64 +1,24 @@ # 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. +# gitbackup -- backup-set collection: gb_collect, gb_manifest_path, +# gb_manifest_equal. Needs lib.sh; reads GB_DEVICE and GB_SCRUB set by the CLI. 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. +# Only entries[] and scrubbed[]: "generated" and host metadata would +# otherwise produce a commit every run. 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 -- outdir/files, meta/, manifest.json. The path list is +# `sysupgrade -l` only; sources are walked just for empty dirs. gb_collect() { _gb_outdir="$1" [ -n "$_gb_outdir" ] || { gb_log err 'gb_collect: outdir is required'; return 1; } @@ -77,31 +37,18 @@ gb_collect() { 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. + # Canonical: case globs are textual. Own variable name: callers loop over + # _gb_path and sh has no locals. _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 + # Whole-file secrets: excluded only when this run scrubs. + '#public: '*) + [ "$(gb_json_bool "${GB_SCRUB:-0}")" = true ] || continue + _gb_pat="${_gb_pat#'#public: '}" + ;; ''|'#'*) continue ;; esac case "$_gb_pat" in @@ -122,25 +69,12 @@ _gb_collect_is_excluded() { 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. +# Pipe subshell: state goes to , not variables. _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 @@ -149,40 +83,28 @@ _gb_collect_files() { 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. + # Never dereferenced: stat without -L reports the link itself. _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" + _gb_collect_entry symlink "$_gb_path" "$1" "$2" "$3" target "$_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" + _gb_collect_entry file "$_gb_path" "$1" "$2" "$3" sha256 "$_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. +# `sysupgrade -l` never lists directories and git stores none, so empty dirs +# get explicit `dir` entries. _gb_collect_empty_dirs() { _gb_outdir="$1" _gb_entries="$2" @@ -196,9 +118,7 @@ _gb_collect_empty_dirs() { /*) ;; *) 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. + # keep.d lines end directories in "/"; strip it. _gb_line="${_gb_line%/}" [ -n "$_gb_line" ] && [ -d "$GB_ROOT$_gb_line" ] || continue @@ -209,43 +129,30 @@ _gb_collect_empty_dirs() { _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" + _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. + # 25.12 has no /etc/apk/repositories, only repositories.d/*. : >"$_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 @@ -253,7 +160,6 @@ _gb_collect_meta() { fi } -# _gb_collect_write_manifest _gb_collect_write_manifest() { _gb_outdir="$1" _gb_entries="$2" @@ -266,11 +172,7 @@ _gb_collect_write_manifest() { "$_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. + # Stable order across runs. _gb_sorted=$(sort -u "$_gb_entries") { @@ -282,40 +184,16 @@ _gb_collect_write_manifest() { 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 '%s\n' "$_gb_sorted" | gb_manifest_join 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). + # Filled later by gb_scrub. 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. +# A failed stat must not produce invalid JSON. _gb_collect_json_num() { case "${1:-}" in ''|*[!0-9]*) printf '0' ;; @@ -323,23 +201,10 @@ _gb_collect_json_num() { 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")" +# _gb_collect_entry [ ] +_gb_collect_entry() { + printf '{"path":%s,"type":"%s","mode":%s,"uid":%s,"gid":%s%s}\n' \ + "$(gb_json_str "$2")" "$1" "$(_gb_collect_json_num "$3")" \ + "$(_gb_collect_json_num "$4")" "$(_gb_collect_json_num "$5")" \ + "${6:+,\"$6\":$(gb_json_str "$7")}" } diff --git a/gitbackup/files/usr/share/gitbackup/device.sh b/gitbackup/files/usr/share/gitbackup/device.sh index 81e8c99b3..6b7e535a9 100644 --- a/gitbackup/files/usr/share/gitbackup/device.sh +++ b/gitbackup/files/usr/share/gitbackup/device.sh @@ -1,16 +1,7 @@ # 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. +# gitbackup -- device identity: gb_device_id, gb_expand. -# 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 @@ -21,42 +12,18 @@ gb_device_id() { esac } -# gb_expand