🍉 Sync 2026-09-25 18:00:12

This commit is contained in:
github-actions[bot] committed 2026-09-25 18:00:12 +08:00
1 parent 7c17dda4d0
commit 2ee65a261e
135 files changed
+5726 -7799

No files matched your search

-51
View File
@@ -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 <vizzlef@gmail.com>
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))
+7 -80
View File
@@ -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/<pkg> 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/<pid>` 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
}
+2 -10
View File
@@ -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
+75 -437
View File
@@ -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 <from> <to>` 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 <method> -> 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 <json-line> <jsonfilter-expr> -- 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 <json-line> <jsonfilter-array-expr> -- 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 <reason> -- 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 <field> <text> -- "{ "<field>": "<text>" }", 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_<method> 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 <key|-> <gitbackup args...> -- "-" relays JSON as is, else
# wraps as {"<key>": ...}. 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 <from> <to>` 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 >/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 >/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 >/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
# `</dev/null` by construction here, so its interactive accept-or-decline
# prompt (auth.sh's gb_accept_hostkey) can never be answered from the web
# at all. This is the one new rpcd method that closes that gap, wrapping
# `gitbackup hostkey` (usr/sbin/gitbackup's cmd_hostkey) -- itself
# synchronous and fast (a single SSH host-key dial, ConnectTimeout=15;
# `accept` makes no network call at all), unlike run/test/restore above.
#
# One method serves both steps of the flow, picked by whether "fingerprint"
# was given: absent/empty -> `hostkey show` (dial, report, write nothing);
# present -> `hostkey accept <fingerprint>` (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 <fingerprint>", 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 <fingerprint>; 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
File diff suppressed because it is too large. Load diff
+11 -45
View File
@@ -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://<username>@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
+60 -343
View File
@@ -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 <string> -- <string> 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 <key-path>
#
# 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 <force> 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 <fingerprint>" 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 "<key>.old"/"<key>.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 <fp>", destroy
# nothing, 4
# key, force, matching fp -- keep <key>.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 <host> <port> <out-file>
#
# 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=<scratch> -o StrictHostKeyChecking=accept-new -o
# BatchMode=yes git@github.com exit` still populates <scratch> 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 <out-file> and returns 0 when a key was obtained, or returns 2
# with <out-file> removed when nothing could be obtained at all (host
# unreachable, connection refused, DNS failure).
# _gb_auth_dial_hostkey <host> <port> <out-file> -- 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 <host> [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`). 3 used to
# be indistinguishable from 1 here: the old code fed `read`'s result
# straight into the `case` below without ever looking at `read`'s own exit
# status, so an EOF -- which busybox ash's `read` reports as a nonzero
# return with `_gb_ans` left empty -- fell into the same "not y" branch a
# real, typed "no" does, logging "declined by the operator" for an operator
# who was never actually asked. Reported live against a real GitHub deploy
# key on a real router: `gitbackup test` run from `gbrpc_test` (stdin
# `</dev/null` by construction, ticket 20) always produced that exact
# false accusation, and offered no way to accept the key from the web at
# all -- gb_hostkey_show/gb_hostkey_accept below are that way.
gb_accept_hostkey() {
# Sets _gb_host, _gb_port, _gb_known; true when already in known_hosts.
_gb_hostkey_init() {
_gb_host="$1"
_gb_port="${2:-22}"
case "$_gb_port" in
''|0) _gb_port=22 ;;
esac
[ "$_gb_port" != 0 ] || _gb_port=22
mkdir -p "$GB_ETC_DIR" 2>/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 <host> [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 <host> [port]
#
# The non-interactive half of ticket 20's web flow: dials <host>, 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 <host> <port> -- nothing to show, already known
# pending <host> <port> <fingerprint...> -- 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 <host> [port] -- "trusted <host> <port>" or
# "pending <host> <port> <fp>". 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 <host> [port] <fingerprint>
#
# Commits the key gb_hostkey_show cached, but ONLY when <fingerprint>
# matches ssh-keygen's own fingerprint of exactly that cached material.
# <fingerprint> 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 <fingerprint> 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 <host> <port> <fp> -- 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
}
+9 -57
View File
@@ -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 <outfile>
#
# Writes one recovery document to <outfile> -- the same content LuCI's
# "Download recovery card" button (ticket 11) offers for offline storage and
# `gitbackup run` commits to devices/<id>/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 <outfile> -- 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 <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 <PATH-TO-DEPLOY-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'
+32 -167
View File
@@ -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 <outdir>
gb_manifest_path() {
printf '%s/manifest.json\n' "$1"
}
# gb_manifest_equal <manifest-a> <manifest-b>
#
# 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 <outdir>
#
# 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> -- 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 <path> -- true when <path> 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 <path>, 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 <outdir> <entries-file>
#
# 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 <entries-file>. 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 <entries-file> -- a real file survives the subshell boundary,
# a shell variable set in there would not.
# Pipe subshell: state goes to <entries-file>, 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 <outdir> <entries-file>
#
# `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 <outdir> -- 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 <outdir> <entries-file>
_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 <text> -- <text>'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. <text> 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 <value> -- <value> 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 <path> <mode> <uid> <gid> <sha256>
_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 <path> <mode> <uid> <gid> <target>
_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 <path> <mode> <uid> <gid>
_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 <type> <path> <mode> <uid> <gid> [<key> <value>]
_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")}"
}
+10 -49
View File
@@ -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 <template> -- substitutes every {device} in <template> with
# gb_device_id's answer.
#
# Takes only the template on purpose (see interfaces.md): the device name is
# always gb_device_id's own resolution, never a caller-supplied override, so
# there is exactly one place that can disagree with it. gb_device_id is only
# called when the template actually has a placeholder, so a plain template
# (e.g. a literal path_prefix) never pays for device resolution it does not
# need and cannot fail because of it.
# gb_expand <template> -- substitutes {device}; resolves the device only when
# the placeholder is present, so a plain template never fails on it.
gb_expand() {
_gb_tpl="$1"
case "$_gb_tpl" in
_gb_dev=''
case "$1" in
*'{device}'*) _gb_dev=$(gb_device_id) || exit $? ;;
esac
_gb_out=''
_gb_rest="$_gb_tpl"
while true; do
case "$_gb_rest" in
*'{device}'*)
_gb_out="$_gb_out${_gb_rest%%\{device\}*}$_gb_dev"
_gb_rest="${_gb_rest#*\{device\}}"
;;
*)
_gb_out="$_gb_out$_gb_rest"
break
;;
esac
done
printf '%s\n' "$_gb_out"
gb_subst_device "$1" "$_gb_dev"
}
# _gb_device_by_hostname -- gitbackup.main.device_id='hostname'.
#
# Refuses the stock 'OpenWrt' hostname: two routers left at the default would
# resolve to the same device name, push to the same branch, and overwrite
# each other's backups.
# Refuses the stock 'OpenWrt' hostname: two routers left at the default
# would push to the same branch and overwrite each other.
_gb_device_by_hostname() {
_gb_board=$(ubus call system board 2>/dev/null)
_gb_h=$(printf '%s' "$_gb_board" | jsonfilter -e '@.hostname')
@@ -67,7 +34,6 @@ _gb_device_by_hostname() {
printf '%s\n' "$_gb_h"
}
# _gb_device_by_custom -- gitbackup.main.device_id='custom'.
_gb_device_by_custom() {
_gb_d=$(gb_uci_get gitbackup.main.device)
[ -n "$_gb_d" ] ||
@@ -81,12 +47,8 @@ _gb_device_by_custom() {
printf '%s\n' "$_gb_d"
}
# _gb_device_by_board -- gitbackup.main.device_id='board'.
#
# <model-slug>-<last 6 hex digits of the first NIC's MAC>, deterministic
# because it depends only on hardware, never on config. GB_SYSFS_NET
# overrides the sysfs root so tests/run.sh can feed a fake set of interfaces;
# the router never sets it and gets the real /sys/class/net.
# <model-slug>-<last 6 hex of first NIC MAC>: hardware-only, deterministic.
# GB_SYSFS_NET is a test seam.
_gb_device_by_board() {
_gb_board=$(ubus call system board 2>/dev/null)
_gb_model=$(printf '%s' "$_gb_board" | jsonfilter -e '@.model')
@@ -113,7 +75,6 @@ _gb_device_by_board() {
printf '%s-%s\n' "$(_gb_slugify "$_gb_model")" "$_gb_tail"
}
# _gb_slugify <string> -- lowercase, every byte outside [a-z0-9] becomes '-'.
_gb_slugify() {
# shellcheck disable=SC2018,SC2019 # ASCII on purpose: device names must match [A-Za-z0-9._-]
printf '%s' "$1" | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9]/-/g'
@@ -1,50 +1,18 @@
# gitbackup hard-exclude list.
#
# One glob pattern per line, matched against an absolute path -- either one
# `sysupgrade -l` reported or a directory found while looking for empty
# directories (collect.sh, _gb_collect_is_excluded). "#" starts a comment,
# blank lines are ignored. A pattern ending in "/**" matches the path
# itself and everything under it; anything else is matched with plain
# shell `case` glob rules.
#
# This block is unconditional: it is enforced at every visibility setting.
# The extra secret-file excludes visibility=public adds (/etc/shadow,
# dropbear host keys, uhttpd.key, /etc/ssl/private/**, WireGuard private
# keys -- spec "Scrub") are not here: they depend on a UCI setting this
# file has no way to read, and belong to scrub.sh instead (ticket 05).
# gitbackup hard-exclude list: one glob per line, matched against absolute
# paths (collect.sh). "/**" matches the path and everything under it;
# otherwise plain shell `case` glob rules. Enforced at every visibility.
# The package's own credentials must never end up inside its own backup,
# no matter how the router is configured (R76).
# The package's own credentials never go into its own backup.
/etc/gitbackup/**
# --- public-only hard-exclude (ticket 05, spec "Scrub") ---
#
# Every line below starting "#public:" is, to collect.sh, just another
# comment -- '#'* is always skipped by _gb_collect_is_excluded, so
# collect.sh's own unconditional read of this file (used at every
# visibility, private included) never sees these as patterns and its
# behavior needs no visibility awareness added to it. scrub.sh is the only
# reader that treats "#public:" specially: it strips the prefix and applies
# what remains as a hard-exclude pattern against the already-collected tree,
# and scrub.sh itself only ever runs when visibility=public -- so the
# distinction lives in who reads the line, not in a flag either reader has
# to check. These are files no redaction can make safe to publish, because
# the secret IS the file, not a value inside it: removed from the tree
# entirely and dropped from manifest.json's entries[] (never scrubbed[] --
# that array is for redacted option values, not removed files). Same glob
# rules as above, applied to the pattern text after "#public:".
#
# Named verbatim in the brief and spec "Scrub":
# --- excluded only while scrubbing ---
# "#public: <pattern>" lines are DATA: collect.sh excludes them when the run
# scrubs (forced on by visibility=public). Whole-file secrets; no redaction
# makes them safe to publish.
#public: /etc/shadow
#public: /etc/dropbear/*_host_key*
#public: /etc/uhttpd.key
#public: /etc/ssl/private/**
# VERIFY: no OpenWrt-specific source confirms this path is ever populated --
# the wireguard package itself keeps keys inline in UCI (network.*.private_key,
# already in scrub.list) and has no file-based key storage of its own. This
# is the upstream wg-quick convention (a *.conf/key pair under
# /etc/wireguard/), kept as defense-in-depth for a router where someone
# generated keys by hand (`wg genkey`) and left the file there instead of
# only pasting it into UCI -- not verified against an OpenWrt package that
# actually writes there.
# VERIFY: no OpenWrt package is known to write here (UCI wireguard keeps keys
# inline, see scrub.list); wg-quick convention, kept for hand-made keys.
#public: /etc/wireguard/**
+23 -196
View File
@@ -1,46 +1,14 @@
# shellcheck shell=sh
#
# gitbackup -- push without a clone (spec "Как коммит попадает в
# репозиторий без клона (G05, G06)"). This is the trick the whole package
# exists to prove: `git commit-tree` only ever needs the PARENT COMMIT
# object to exist in the local object database -- never its tree, never
# its blobs.
# gitbackup -- push without a clone: gb_remote_head, gb_fetch_meta,
# gb_build_tree, gb_commit_push.
#
# Ticket 07's own V10 check (done before any of this was written, both
# against this repo's git 2.51 on the dev host and consistent with git
# 2.50.1 on the owlab 25.12.4 stand): a parent SHA that is not in the local
# object database AT ALL makes `commit-tree -p <sha>` hard-fail with
# "fatal: <sha> is not a valid object" -- so fetching that one commit
# object (gb_fetch_meta below) is mandatory, not an optional optimization.
# Once just the commit object exists locally, `commit-tree` happily builds
# a new tree/commit on top of it, and `git push` negotiates and sends only
# the objects the far end is actually missing -- proven with a single
# hand-copied commit object and zero blobs, `git fsck --full` clean on the
# receiving bare repository afterward.
#
# Every function here reads GB_URL from the environment rather than taking
# it as an argument -- the same convention visibility.sh/auth.sh use for
# config context the CLI already resolved once (gb_validate_config) -- so a
# caller only ever names what actually varies per call: a branch, a tree, a
# directory. gb_build_tree additionally reads GB_PREFIX and GB_PARENT this
# same way (see its own comment) for the same reason.
#
# Sourced, never executed: nothing here runs at load time. Depends on
# lib.sh (gb_log) already being sourced by the caller, same convention as
# every other module in this directory.
# `git commit-tree -p <parent>` needs the parent commit object locally or
# fails "not a valid object" -- hence gb_fetch_meta's blobless shallow fetch.
# Reads GB_URL, GB_PREFIX, GB_PARENT as resolved by the CLI. Needs lib.sh.
# gb_remote_head <branch>
#
# Prints <branch>'s current tip SHA on GB_URL and returns 0; prints nothing
# and returns 0 when the branch does not exist yet (a device's first ever
# backup) -- verified live: `git ls-remote <a-reachable-repo>
# refs/heads/<no-such-branch>` exits 0 with empty output, not an error, as
# long as the repository itself was reachable. Prints nothing and returns 1
# when `git ls-remote` itself failed (network unreachable, host key
# refused, authentication rejected -- git folds all of these into one
# nonzero exit and a "fatal: ..." message); turning that into the shared
# exit-3 (network/auth) contract is the caller's job, not this module's,
# same division of responsibility gb_visibility_ok's 0/3/4 already uses.
# gb_remote_head <branch> -- tip SHA, or nothing (rc 0) for a new branch.
# Returns 1 when ls-remote fails; mapping to exit 3 is the caller's.
gb_remote_head() {
_gb_branch="$1"
_gb_out=$(git ls-remote "${GB_URL:?gb_remote_head: GB_URL is not set}" \
@@ -54,63 +22,10 @@ gb_remote_head() {
return 0
}
# gb_fetch_meta <branch> <repodir> [depth]
#
# Prepares <repodir> as a scratch, non-bare git repository (created empty
# the first time; reused as-is on a retry, spec step 14) and fetches only
# <branch>'s tip -- commit and trees, never blobs, when the remote
# understands partial clone (`--filter=blob:none`).
#
# <depth> defaults to 1 -- this function's original, and still by far its
# most common, shape: `run`'s own "just the tip" need, every call site in
# usr/sbin/gitbackup. An explicit empty string ("") skips `--depth`
# entirely and fetches the branch's whole history instead, which is what
# restore.sh needs to reach an arbitrary past commit (ticket 18: this
# parameter is what let restore.sh's own near-identical
# init/remote-add/fetch sequence for exactly that one case go away, instead
# of staying a second, hand-maintained copy of this same function that
# differed from it only in this one flag).
#
# Not `--filter=tree:0`, even though that measures smaller in the spec's
# own table (12 KiB flat vs. 644 KiB for openwrt/luci -- "Проверенные
# факты 25.12.4"): the very next thing usr/sbin/gitbackup's run does with
# this fetch is read one specific blob out of it (the old manifest.json,
# for the unchanged-since-last-time comparison), and `blob:none` leaves
# every tree in place so that blob can be fetched lazily by path -- a
# `git cat-file -p <parent>:<prefix>/manifest.json` -- instead of a second,
# bespoke transfer. Confirmed live (this repo's own git, promisor remote
# behavior): after a `--filter=blob:none` fetch against a repository with
# `uploadpack.allowFilter` on, the fetched pack carries zero blobs, and the
# very next `cat-file -p <path>` transparently pulls in exactly the one
# blob asked for and no more (object count went from 8 with blobs already
# present to a lazy on-demand fetch of 1 when they were not).
#
# A remote that does not support the filter extension at all -- confirmed
# live against a plain local-path remote, which never speaks the partial-
# clone protocol extension regardless of git version -- degrades on its
# own to an ordinary `--depth=1` (git's own message: "warning: filtering
# not recognized by server, ignoring"). Either way this is still only
# <branch>'s single tip commit, on this device's own branch -- never
# history, never another device's branch -- so "no clone, ever" holds
# regardless of what the remote can do.
#
# Fetched through a NAMED remote ("origin"), configured once and reused on
# the one retry spec step 14 allows, rather than the anonymous-URL form
# (`git fetch <url> <branch>`) gb_remote_head/gb_commit_push use -- an
# anonymous partial-clone fetch turned out to leave the resulting promisor
# pack in a state where a LATER operation needing one of its still-missing
# objects could not resolve where to lazily fetch it from again (confirmed
# live: an anonymous fetch's own "remote" bookkeeping is the URL string
# itself, which git's shorthand-remote-name rule rejects outright for a
# plain filesystem path -- "warning: promisor remote name cannot begin
# with '/'" -- and the lazy re-fetch a later `write-tree` needs then fails
# outright, not just warns). A named remote sidesteps this by construction
# and is not a behavior change for a real https/ssh URL, which was never
# affected by the rejected-shorthand rule to begin with.
#
# Returns 0 on success; 1 if the repository could not be initialized or the
# fetch itself failed (network/auth -- again the caller's exit-3 to assign,
# same convention as gb_remote_head).
# gb_fetch_meta <branch> <repodir> [depth] -- depth "" = full history.
# blob:none, not tree:0: trees stay so one blob can be lazily fetched by path.
# Named remote: an anonymous-URL partial fetch breaks later lazy fetches
# ("promisor remote name cannot begin with '/'").
gb_fetch_meta() {
_gb_branch="$1"
_gb_repodir="$2"
@@ -138,55 +53,11 @@ gb_fetch_meta() {
return 0
}
# gb_build_tree <repodir> <treedir> [extra_file] [extra_gitpath]
#
# Hashes and stages every regular file and symlink under <treedir> into a
# scratch index, rooted at GB_PREFIX (environment, not an argument -- see
# this file's header), writes a tree object from that index into <repodir>
# and prints its SHA on stdout. <treedir> holds this run's own collected
# backup set (collect.sh's outdir) with no prefix baked into it on disk --
# the prefix only ever exists inside the git tree this function builds, not
# on the filesystem gb_collect wrote to.
#
# [extra_file]/[extra_gitpath] (ticket 22) stage exactly one additional
# regular file at a git path given VERBATIM, bypassing GB_PREFIX entirely --
# the one thing usr/sbin/gitbackup needs this for is a device branch's own
# README.md, which has to sit at the BRANCH ROOT (a sibling of
# "devices/<id>") for GitHub to render it when a human opens that branch
# from a phone; a copy staged under GB_PREFIX would never be seen there.
# Both empty (the two-argument form every other call site, including every
# existing test, still uses) skips this step entirely -- behavior is
# unchanged. Mode is always 100644: nothing that calls this today needs an
# executable or a symlink at the extra path.
#
# When GB_PARENT (also environment: the SHA gb_remote_head returned, empty
# for a brand-new branch) is set, the index is first seeded from that
# commit's FULL tree (`git read-tree`), and GB_PREFIX's own old subtree is
# then cleared (`git rm --cached`) before <treedir>'s fresh content is
# staged over it. This is what spec step 11 calls out for a branch shared
# by several devices (gitbackup.origin.branch with no `{device}`):
# everything outside GB_PREFIX survives untouched, and its blobs are never
# fetched -- gb_fetch_meta only ever asked for trees, and read-tree alone
# needs no blobs either, confirmed live. Clearing GB_PREFIX before restaging
# it (rather than only adding paths <treedir> happens to still have) is
# what makes a path this device stopped backing up actually disappear from
# the pushed tree instead of lingering forever; on the shipped default (a
# branch of "device/{device}" per device) GB_PARENT's tree only ever
# contained this same GB_PREFIX to begin with, so the clear+restage is a
# no-op in effect there, not a special case this function has to detect.
#
# Only three modes ever reach the index -- git stores no others: 100644,
# 100755 (an executable regular file, `[ -x ]`), 120000 (symlink). A
# symlink's blob is hashed from the raw target STRING via --stdin, never
# from the path: `git hash-object <symlink-path>` opens the path and so
# follows the link, and fails outright ("could not open ... No such file or
# directory") for the dangling symlinks this backup set is full of
# (/etc/resolv.conf and friends) -- confirmed live, and confirmed live that
# `printf '%s' <target> | git hash-object --stdin` reproduces exactly the
# blob SHA `git add` itself would have stored for that same symlink.
#
# Returns 0 and prints the tree SHA on success; 1 (nothing printed) if any
# git plumbing step failed.
# gb_build_tree <repodir> <treedir> [extra_file] [extra_gitpath] -- prints the
# tree SHA. With GB_PARENT, the parent tree is seeded and GB_PREFIX cleared:
# other devices on a shared branch survive, dropped paths disappear.
# Symlinks are hashed from the target string; hashing the path would follow
# the link and fail on dangling ones.
gb_build_tree() {
_gb_repodir="$1"
_gb_treedir="$2"
@@ -207,11 +78,6 @@ gb_build_tree() {
git rm -r --cached --ignore-unmatch -q -- "$_gb_prefix" >/dev/null 2>&1
fi
# The loop body runs on the read side of a pipe (a subshell, POSIX sh) --
# same technique collect.sh's own _gb_collect_files relies on -- but
# every git call inside it addresses $_gb_idx as a real file via
# GIT_INDEX_FILE, which survives the subshell boundary; only a shell
# variable set in there would not.
( cd "$_gb_treedir" && find . \( -type f -o -type l \) ) | while IFS= read -r _gb_rel; do
_gb_rel="${_gb_rel#./}"
_gb_full="$_gb_treedir/$_gb_rel"
@@ -249,33 +115,9 @@ gb_build_tree() {
return 0
}
# gb_commit_push <repodir> <tree> <parent> <msgfile> <branch>
#
# Creates one commit from <tree> and <msgfile> -- parented on <parent>, or
# a root commit when <parent> is empty (a brand-new branch) -- and pushes
# it straight to <branch> on GB_URL: `git push <sha>:refs/heads/<branch>`,
# which is what lets git figure out and send only the objects the remote
# does not already have, without this side ever holding a checkout of
# anything (spec step 12/13).
#
# Prints the new commit's SHA on stdout and returns 0 on success. On
# failure prints nothing and returns:
# 1 `commit-tree` itself failed (a bad tree/parent SHA -- a bug on this
# side, not a remote problem)
# 2 the push was rejected because the branch moved (spec step 14:
# "Отказ non-fast-forward -> повтор с шага 8 один раз, дальше exit 1").
# Matched against git's own literal wording for BOTH of the two ways
# it phrases this same situation -- confirmed live, forcing the race
# two different ways: "(non-fast-forward)" when the branch diverged
# from a history this side never fetched, "(fetch first)" when this
# side's own remote-tracking view is simply stale (the far more
# common shape in practice: this side fetched the parent once, then
# someone else pushed before this side's own push landed). Both are
# "the branch moved under us, re-fetch and retry" to this package.
# The caller owns the one retry; this function only reports which
# kind of failure happened.
# 3 any other push failure (auth rejected, network gone mid-run) -- the
# caller's exit 3, same convention as gb_remote_head/gb_fetch_meta.
# gb_commit_push <repodir> <tree> <parent> <msgfile> <branch> -- prints the
# new SHA. Returns 1 commit-tree failed, 2 branch moved (caller retries once),
# 3 other push failure.
gb_commit_push() {
_gb_repodir="$1"
_gb_tree="$2"
@@ -284,25 +126,10 @@ gb_commit_push() {
_gb_branch="$5"
_gb_gitdir="$_gb_repodir/.git"
# GIT_AUTHOR_*/GIT_COMMITTER_* set explicitly, not left to git's own
# fallback chain (user.name/user.email, then a guessed
# username@hostname): found live on the owlab stand -- a freshly
# booted router has no ~/.gitconfig and no [user] section anywhere,
# and commit-tree refuses outright ("Author identity unknown ...
# Please tell me who you are") rather than guess. A dev machine's own
# global gitconfig had been masking this in every host-side test up to
# that point. One fixed bot identity for every device's every commit --
# the commit body's own Device: field is what actually identifies
# which router pushed it, this is not trying to be a person.
if [ -n "$_gb_parent" ]; then
_gb_commit=$(GIT_AUTHOR_NAME=gitbackup GIT_AUTHOR_EMAIL=gitbackup@localhost \
GIT_COMMITTER_NAME=gitbackup GIT_COMMITTER_EMAIL=gitbackup@localhost \
git --git-dir="$_gb_gitdir" commit-tree "$_gb_tree" -p "$_gb_parent" -F "$_gb_msgfile" 2>&1)
else
_gb_commit=$(GIT_AUTHOR_NAME=gitbackup GIT_AUTHOR_EMAIL=gitbackup@localhost \
GIT_COMMITTER_NAME=gitbackup GIT_COMMITTER_EMAIL=gitbackup@localhost \
git --git-dir="$_gb_gitdir" commit-tree "$_gb_tree" -F "$_gb_msgfile" 2>&1)
fi
# Routers have no user.name/email and commit-tree refuses to guess.
_gb_commit=$(GIT_AUTHOR_NAME=gitbackup GIT_AUTHOR_EMAIL=gitbackup@localhost \
GIT_COMMITTER_NAME=gitbackup GIT_COMMITTER_EMAIL=gitbackup@localhost \
git --git-dir="$_gb_gitdir" commit-tree "$_gb_tree" ${_gb_parent:+-p "$_gb_parent"} -F "$_gb_msgfile" 2>&1)
_gb_rc=$?
if [ "$_gb_rc" -ne 0 ]; then
gb_log err "gb_commit_push: commit-tree failed: $_gb_commit"
+54 -168
View File
@@ -1,37 +1,24 @@
# shellcheck shell=sh
#
# gitbackup -- primitives shared by every module.
# gitbackup -- shared primitives: gb_log, gb_die, gb_uci_get, gb_path_canon,
# gb_json_esc/str/bool, gb_manifest_field/tail/each/join, gb_free_kb,
# gb_have_net, gb_subst_device, gb_join_lines.
#
# Sourced, never executed: defining functions is all this file does, so a test
# can pull it in and call one function without a router underneath it.
#
# Variables are prefixed _gb_ instead of being declared `local`, because
# `local` is not in POSIX sh; busybox ash has it, the shells this suite also
# runs under do not all agree, and the prefix costs nothing.
#
# Nothing here runs at load time: sourcing this file defines functions and
# touches nothing else, which is what makes it testable off the router.
# No side effects at load time. _gb_ prefix instead of `local` (not POSIX).
# gb_log <level> <message>...
#
# level is a syslog level -- debug, info, notice, warning, err -- so it can be
# handed to logger unchanged. Syslog is the log, because `gitbackup log` reads
# it back with logread and nothing else survives a reboot on a read-only root.
# gb_log <syslog-level> <message>... -- syslog is the log (`gitbackup log`
# reads it back with logread).
gb_log() {
_gb_level="$1"
shift
logger -t gitbackup -p "daemon.$_gb_level" -- "$*" 2>/dev/null
# Anything the operator has to act on also goes to stderr: a CLI run by
# hand that answers only into syslog looks like it did nothing.
case "$_gb_level" in
notice|warning|err) printf '%s: %s\n' "$_gb_level" "$*" >&2 ;;
esac
}
# gb_die <exit-code> <message>...
#
# Exit codes are the contract of the whole package: 0 ok, 1 general error,
# 2 invalid configuration, 3 network or authentication, 4 refused for safety.
# Exit codes: 0 ok, 1 general error, 2 invalid config, 3 network/auth, 4 refused for safety.
gb_die() {
_gb_code="$1"
shift
@@ -39,40 +26,18 @@ gb_die() {
exit "$_gb_code"
}
# gb_uci_get <config.section.option> [default]
#
# An unset option and an empty one are the same answer here on purpose: UCI
# writes `option x ''` for a field the user cleared in LuCI, and every caller
# wants the default in both cases.
# gb_uci_get <config.section.option> [default] -- empty yields the default
# too: LuCI writes `option x ''` for a cleared field.
gb_uci_get() {
_gb_value=$(uci -q get "$1") || _gb_value=''
[ -n "$_gb_value" ] || _gb_value="${2-}"
printf '%s\n' "$_gb_value"
}
# gb_path_canon <path>
#
# Prints <path> in its one canonical spelling: repeated slashes collapsed,
# "." components dropped, ".." components resolved lexically, no trailing
# slash. A relative path is printed back unchanged -- there is nothing to
# resolve it against here, and every caller checks for absoluteness itself.
#
# Lexical, not filesystem-resolving: no realpath(1) on the base image, and
# `readlink -f` would additionally follow symlinks, which is exactly what
# this package must NOT do to a backup path (collect.sh's R24.1 -- a
# symlink is backed up as a symlink, never dereferenced).
#
# This exists because two separate decisions in this package are made by
# matching a path against a fixed pattern -- gb_paths_validate's blacklist
# and collect.sh's _gb_collect_is_excluded -- and `case` glob matching is
# purely textual. "/etc/gitbackup", "//etc/gitbackup", "/etc/./gitbackup"
# and "/etc/../etc/gitbackup" name the same directory to the kernel and to
# find(1), but only the first one matches a "/etc/gitbackup*" pattern. The
# package's own exclude.list calls its exclusion of that directory
# unconditional ("must never end up inside its own backup, no matter how
# the router is configured", R76); without canonicalization it was not,
# and an entry spelled "//etc/gitbackup" pushed the deploy private key and
# the git token into the very repository they authenticate to.
# gb_path_canon <path> -- lexical canonical spelling (no realpath on the
# image; readlink -f would follow symlinks, which are backed up as symlinks).
# Exclusion checks are textual `case` matches: without this "//etc/gitbackup"
# slips past them and the deploy key and token get pushed.
gb_path_canon() {
_gb_pc_rest="${1-}"
case "$_gb_pc_rest" in
@@ -97,16 +62,7 @@ gb_path_canon() {
printf '%s\n' "${_gb_pc_out:-/}"
}
# gb_json_esc <string>
#
# Escapes a string for use inside JSON double quotes. Written out rather than
# delegated because the base image has no JSON writer, and "just interpolate
# it" breaks on the first quote in a path -- which is exactly the input this
# package handles.
#
# Only the five characters that can actually reach us are escaped: the values
# are UCI options, filesystem paths and error messages. A NUL cannot survive a
# shell variable in the first place.
# The base image has no JSON writer; only ", \, newline, tab and CR can reach us.
gb_json_esc() {
_gb_rest="${1-}"
_gb_out=''
@@ -115,7 +71,6 @@ gb_json_esc() {
_gb_tab=$(printf '\t')
_gb_cr=$(printf '\r')
while [ -n "$_gb_rest" ]; do
# The first character: strip everything after it, then strip it.
_gb_ch="${_gb_rest%"${_gb_rest#?}"}"
_gb_rest="${_gb_rest#?}"
# shellcheck disable=SC1003 # the '\' branch matches a literal backslash, not an escaped quote
@@ -131,7 +86,7 @@ gb_json_esc() {
printf '%s' "$_gb_out"
}
# gb_json_str <string> -- an escaped, quoted JSON string, or null when empty.
# Quoted JSON string, or null when empty.
gb_json_str() {
if [ -z "${1-}" ]; then
printf 'null'
@@ -140,7 +95,6 @@ gb_json_str() {
fi
}
# gb_json_bool <value> -- true when the value is a UCI-style truth, else false.
gb_json_bool() {
case "${1-}" in
1|on|true|yes|enabled) printf 'true' ;;
@@ -148,40 +102,9 @@ gb_json_bool() {
esac
}
# gb_manifest_field <object> <field>
#
# <object> is one manifest.json array item -- exactly one line, no nested
# brackets, the shape gb_collect (collect.sh) writes and gb_manifest_each
# (below) hands to its own callback. Prints <field>'s value: unescaped and
# unquoted for a JSON string (gb_json_esc above is the only writer, and it
# only ever emits "\"" and "\\" for a filesystem path or symlink target --
# newlines/tabs/CR do not occur in either), or the raw text up to the next
# "," or "}" for a bare (numeric) field. Empty when the field is absent, or
# JSON null -- gb_json_str's own bare-word encoding of an empty string
# (above), which no caller here ever wants to see as the literal text
# "null".
#
# One reader for what used to be three (ticket 18): collect.sh compared
# whole manifests with a hand-rolled sed tail (gb_manifest_tail, below, is
# what that became), restore.sh had two near-identical field extractors of
# its own (a quoted-string one and a bare-numeric one), and scrub.sh
# reached for "path" with one more copy of the same parameter-expansion
# pair, unescaped. Same review finding, same fix, in one place instead of
# three that could each drift the moment the manifest format ever does.
#
# The quoted-string branch walks the value one character at a time instead
# of cutting at the first literal '"' with a parameter-expansion pattern:
# `${v%%\"*}` (the consolidation's first shape, inherited from ticket 08's
# _gb_restore_json_str) stops at the FIRST quote character in the string
# with no regard for whether a backslash precedes it, so a path containing
# a literal quote -- which gb_json_esc above escapes as `\"`, and which
# sysupgrade places no restriction against (only whitespace in a path is
# unsupported) -- truncates there instead of at its own closing quote. The
# writer can produce `\"`; the reader must be able to consume it. Walking
# character by character and tracking "the previous character was an
# unescaped backslash" finds the real closing quote and un-escapes `\"`/`\\`
# in the same pass, so the trailing `sed` cleanup this branch used to need
# is gone -- there is nothing left for it to fix up.
# gb_manifest_field <object> <field> -- one single-line manifest item; empty
# when absent or null. Walks char by char so an escaped \" inside a path does
# not end the value early.
gb_manifest_field() {
_gb_mf_obj="$1"
_gb_mf_field="$2"
@@ -194,10 +117,6 @@ gb_manifest_field() {
_gb_mf_ch="${_gb_mf_rest%"${_gb_mf_rest#?}"}"
_gb_mf_rest="${_gb_mf_rest#?}"
if [ "$_gb_mf_bs" -eq 1 ]; then
# Whatever follows a backslash is emitted as itself:
# gb_json_esc only ever escapes '"' and '\' into a
# manifest string field, so this covers both cases
# without needing to special-case which one it was.
_gb_mf_v="$_gb_mf_v$_gb_mf_ch"
_gb_mf_bs=0
continue
@@ -221,35 +140,15 @@ gb_manifest_field() {
esac
}
# gb_manifest_tail <manifest.json> [section]
#
# Everything from ' "<section>": [' (default "entries") onward, i.e. that
# key's own array plus every key written after it, to end of file. Relies
# on gb_collect's fixed layout (collect.sh): "entries" and "scrubbed" are
# always the last two top-level keys, one object per line, so this one sed
# range captures both without a JSON parser this image does not have.
# gb_manifest_equal (collect.sh) is the one caller: comparing this tail
# between two manifests is exactly comparing entries[] and scrubbed[]
# together (spec: "generated не участвует в сравнении манифестов").
# gb_manifest_tail <manifest.json> [section] -- section key to EOF. Relies on
# gb_collect keeping "entries" and "scrubbed" last, so comparing tails
# ignores "generated".
gb_manifest_tail() {
sed -n '/^ "'"${2:-entries}"'": \[/,$p' "$1"
}
# gb_manifest_each <manifest.json> <section> <callback>
#
# Calls "<callback> <object>" once per <section>'s array item (e.g.
# "entries" or "scrubbed"), <object> already unindented and with its
# trailing comma (present on every item but the last) stripped -- the
# shape gb_manifest_field expects. Reads the file via a plain redirect, not
# a pipe: POSIX sh only forks a subshell for the pipe form, so a callback's
# own global-variable accumulation (no `local` anywhere in this codebase)
# survives the loop here.
#
# A section's own closing line is either ' ],' (something else follows it
# in the object, e.g. "entries" with "scrubbed" after) or ' ]' (it is the
# last key, e.g. "scrubbed" itself); both are accepted regardless of which
# section was asked for, because the two sections never overlap and each
# one's own opening line is what starts the count in the first place.
# gb_manifest_each <manifest.json> <section> <callback> -- one call per item.
# Redirect, not a pipe, so the callback's globals survive the loop.
gb_manifest_each() {
_gb_me_manifest="$1"
_gb_me_section="$2"
@@ -271,54 +170,14 @@ gb_manifest_each() {
done <"$_gb_me_manifest"
}
# gb_free_kb <directory> -- free kilobytes on the filesystem holding it.
#
# -P because the default df output wraps long device names onto their own line
# and the awk below would then read the wrong row.
# -P keeps long device names from wrapping onto their own line.
gb_free_kb() {
df -Pk "$1" 2>/dev/null | awk 'NR==2 {print $4}'
}
# gb_have_net <host> [port]
#
# True when a TCP connection opens. There is no curl in the base image, and
# -- D01, measured live on the owlab 25.12.4 stand -- this image's busybox
# nc (v1.37.0) is not the usual BSD/GNU nc at all: `nc --help` prints
# "Usage: nc [IPADDR PORT]" and nothing else. No `-w`, no `-z`, no flags of
# any kind; `nc -w 5 host port` (this function's previous body) prints that
# same usage banner and exits 1 for every host, reachable or not, which is
# exactly the bug ticket 01 flagged as D01 and interfaces.md warned every
# later module off relying on. The fix is simply never passing nc a flag it
# does not have: `nc host port` alone is accepted, connects, and exits 0/1
# correctly -- confirmed live for a real open port, a closed one (ECONNREFUSED)
# and a bad hostname.
#
# The remaining problem a flag-free nc leaves is a *hang*: an address that
# is filtered rather than refused (no RST, no reply at all) leaves a bare
# `nc host port` blocked on the kernel's own TCP connect timeout, which is
# well over a minute -- unacceptable for a check `run` is supposed to fail
# out of quickly. There is no `timeout` applet either (confirmed: absent
# from `busybox --list` and no separate binary on the stand), so the bound
# is hand-rolled by POLLING, not by a second background "watchdog" job that
# kills nc after 5s: an earlier version of this function did exactly that
# (`( sleep 5; kill $ncpid ) & watchpid=$!; ...; kill $watchpid`), and it
# leaked a process every time nc finished BEFORE the 5s elapsed (the common
# case) -- found live on the owlab stand, not in any unit test: killing the
# watchdog SUBSHELL does not kill the plain `sleep 5` still running inside
# it, which is a separate child process with its own pid; that orphaned
# sleep keeps running for whatever time was left, still holding every file
# descriptor it inherited at the moment it was forked -- including, when
# `run`'s own step 1 flock (fd 9 on /var/run/gitbackup.lock) was already
# held at the time gb_have_net was called, the lock file itself. Confirmed
# live: calling `run` twice back to back after a fast, successful
# gb_have_net check left the SECOND call seeing the lock as still busy for
# up to ~5s, purely from the first call's orphaned watchdog sleep -- despite
# the first `run` process having already exited outright. Polling for nc's
# own pid to disappear (`kill -0`, no signal sent, just an existence check)
# needs no second process at all, so there is nothing left over to leak.
# Measured live: an address that never answers (192.0.2.1, a TEST-NET-1
# address routed nowhere) returns in ~5s instead of hanging; a real open
# port and a real closed port both still return in well under a second.
# gb_have_net <host> [port] -- TCP connect within ~5s. busybox nc takes no
# -w/-z and there is no `timeout`, so nc's pid is polled. No `sleep; kill`
# watchdog: its orphaned sleep kept run's flock fd open after exit.
gb_have_net() {
_gb_hn_host="$1"
_gb_hn_port="${2:-443}"
@@ -337,3 +196,30 @@ gb_have_net() {
wait "$_gb_hn_pid" 2>/dev/null
return $?
}
# Device comes from the caller: restore has no device_id config yet.
gb_subst_device() {
_gb_sd_out=''
_gb_sd_rest="$1"
while true; do
case "$_gb_sd_rest" in
*'{device}'*)
_gb_sd_out="$_gb_sd_out${_gb_sd_rest%%\{device\}*}$2"
_gb_sd_rest="${_gb_sd_rest#*\{device\}}"
;;
*)
printf '%s\n' "$_gb_sd_out$_gb_sd_rest"
return 0
;;
esac
done
}
gb_join_lines() {
awk -v sep="$1" '$0 != "" { printf "%s%s", s, $0; s = sep }'
}
# stdin lines as a manifest JSON array body.
gb_manifest_join() {
grep . | sed 's/^/ /; $!s/$/,/'
}
+21 -163
View File
@@ -1,90 +1,27 @@
# shellcheck shell=sh
#
# gitbackup -- Paths: the /etc/sysupgrade.conf editor (ticket 17, spec
# "Paths", "Сбор и manifest.json", "Проверенные факты 25.12.4 -> sysupgrade").
#
# The whole point (spec, verbatim): "Расширение списка -- редактирование
# /etc/sysupgrade.conf, а не отдельный UCI-список. То, что пользователь
# добавил ради бэкапа, автоматически переживёт реальный sysupgrade." This
# module owns that file, its blacklist, and the effective-set size
# estimate; usr/sbin/gitbackup's `paths` subcommand is its only caller
# today, but nothing here assumes that -- a future rpcd method could call
# these same functions instead of re-deriving the blacklist a second time.
#
# GB_SYSUPGRADE_CONF overrides the file this module reads/writes (default
# /etc/sysupgrade.conf) -- the same override name
# usr/libexec/rpcd/luci.gitbackup's own gbrpc_set_paths already uses for
# the identical file. GB_ROOT overrides the filesystem root existence
# checks resolve against, same convention collect.sh/restore.sh already
# use, so tests can point this at a fixture tree instead of the host
# filesystem.
#
# Depends on lib.sh (gb_log) already being sourced by the caller, same
# convention every sibling module here uses. Sourced, never executed:
# nothing here runs at load time -- both variable defaults below are cheap
# parameter expansions, not commands.
# gitbackup -- editor for /etc/sysupgrade.conf, so paths added for backup also
# survive a real sysupgrade. Exposes gb_paths_list, gb_paths_entries,
# gb_paths_size_kb, gb_paths_validate, gb_paths_add, gb_paths_del,
# gb_paths_replace_entries. Path validation lives only here, not in JS.
# Needs lib.sh.
GB_SYSUPGRADE_CONF="${GB_SYSUPGRADE_CONF:-/etc/sysupgrade.conf}"
GB_ROOT="${GB_ROOT:-}"
# gb_paths_list -- the raw content of GB_SYSUPGRADE_CONF, one line per
# call. This is deliberately NOT sysupgrade -l's own wider effective set
# (spec: that is GB_SYSUPGRADE_CONF union /lib/upgrade/keep.d/* union
# changed conffiles) -- a package's own paths editor has nothing to add or
# remove from the other two sources, so listing them here would be lines
# `paths del` could never actually delete. gb_paths_size_kb below is the
# one place the wider effective set matters.
# The file verbatim, comments included -- writes rely on it. Not the effective
# set (keep.d, conffiles): this editor cannot change those.
gb_paths_list() {
[ -r "$GB_SYSUPGRADE_CONF" ] && cat "$GB_SYSUPGRADE_CONF"
return 0
}
# _gb_paths_is_comment_or_blank <line> -- true (0) when <line> is not a real
# path entry: empty, or a sysupgrade.conf comment. sysupgrade.conf's own
# comment syntax is "first character is '#'" -- nothing past that character
# is ever inspected, so "# /etc/openvpn/" is a commented-out EXAMPLE line,
# not a disabled entry for /etc/openvpn/, and backs up nothing at all
# (ticket 26). This is the one and only place that decides what counts as
# "not an entry" -- gb_paths_entries (what gets listed) and
# gb_paths_replace_entries (what gets preserved on a full-list write) both
# call it, so the two can never quietly disagree about the same line.
_gb_paths_is_comment_or_blank() {
case "$1" in
'' | '#'*) return 0 ;;
*) return 1 ;;
esac
}
# gb_paths_entries -- gb_paths_list, filtered down to genuine path entries:
# blank lines and comments (see _gb_paths_is_comment_or_blank) are dropped.
# gb_paths_list itself is left alone and stays raw -- ticket 26 chose two
# functions with two distinct meanings ("what the file literally contains"
# vs. "what a human's own path list looks like") over one function whose
# answer would depend on which caller was asking. This is what the LuCI
# view and `paths list --json`'s new "entries" field are built on: a stock
# /etc/sysupgrade.conf's four header/example lines are comments, contain
# zero real entries, and must render as an empty, unremovable list.
gb_paths_entries() {
gb_paths_list | while IFS= read -r _gb_pe_l || [ -n "$_gb_pe_l" ]; do
_gb_paths_is_comment_or_blank "$_gb_pe_l" && continue
printf '%s\n' "$_gb_pe_l"
done
gb_paths_list | grep -v -e '^$' -e '^#'
return 0
}
# gb_paths_size_kb -- kilobytes of everything `sysupgrade -l` would
# actually collect (the full effective set: GB_SYSUPGRADE_CONF union
# keep.d union changed conffiles -- the union sysupgrade itself computes,
# not just this file's own lines), read straight off the filesystem with
# no copy made. Moved here from usr/sbin/gitbackup's own former
# _gb_run_estimate_kb (ticket 07) so `run`'s pre-flight space check and
# `paths`'s own 2 MB warning threshold (spec "LuCI -> Paths": "Счётчик
# суммарного размера набора с предупреждением на 2 MB") can never quietly
# disagree about what "the size of the backup set" means. Symlinks are
# skipped (their own size is a few bytes and does not matter to either
# caller's headroom math); a path sysupgrade -l names that is no longer
# there contributes 0, same tolerance gb_collect already has for that
# case.
# KB of the full effective set (`sysupgrade -l`); symlinks count as 0.
gb_paths_size_kb() {
sysupgrade -l 2>/dev/null | while IFS= read -r _gb_pkb_p || [ -n "$_gb_pkb_p" ]; do
[ -n "$_gb_pkb_p" ] || continue
@@ -93,27 +30,9 @@ gb_paths_size_kb() {
done | awk '{s += $1} END {printf "%d", (s + 1023) / 1024}'
}
# gb_paths_validate <path>
#
# 0 when <path> may be added to GB_SYSUPGRADE_CONF; on refusal, prints one
# human-readable reason to stderr and returns a code from this package's
# own shared contract (interfaces.md "Коды выхода") -- 2 for a plain bad
# argument (not absolute, contains a space, not canonically spelled, does
# not exist), 4 for a refusal on safety grounds (the fixed blacklist) --
# so a caller can pass the return value straight to gb_die without having
# to reclassify it.
#
# Checked in the order a human would want to hear about it: absolute
# first (everything below assumes that), then the one syntactic limit
# `sysupgrade -l`'s own `find` invocation imposes (spec "Проверенные
# факты 25.12.4 -> sysupgrade": "пути с пробелами не поддерживаются
# конструктивно"), then the one spelling every textual check below (and
# collect.sh's own hard-exclude) is able to reason about, then the fixed
# blacklist (never configurable -- ticket 17's own brief: "запрет на
# /etc/gitbackup/**, /proc, /sys, /tmp"), and
# only last whether the path actually exists -- there is no point telling
# an operator their entry is missing from disk before telling them it was
# never going to be accepted at all.
# gb_paths_validate <path> -- 0, or a reason on stderr and 2 (bad argument) /
# 4 (blacklisted). Spaces are refused: sysupgrade passes lines to find(1)
# unquoted.
gb_paths_validate() {
_gb_pv_path="$1"
@@ -132,25 +51,8 @@ gb_paths_validate() {
;;
esac
# The blacklist below is `case` glob matching, i.e. purely textual, so
# it only holds for one spelling of each path. "//etc/gitbackup",
# "/etc/./gitbackup" and "/etc/../etc/gitbackup" all name the reserved
# directory to find(1) -- which is what a sysupgrade.conf line becomes
# -- while matching none of the patterns that reserve it, and
# collect.sh's own hard-exclude of that directory is textual in the
# same way. An entry spelled like that therefore used to sail past
# both and put /etc/gitbackup/token and the deploy private key into
# the backup repository itself.
#
# Refused rather than silently rewritten to the canonical spelling:
# what lands in sysupgrade.conf is what the operator (or the LuCI
# Paths view) asked for, verbatim, everywhere else in this file --
# gb_paths_add's idempotence and gb_paths_del's exact-line removal
# both depend on that -- and quietly editing an entry on the way in
# would break the "add what I typed, remove what I see" contract for
# the sake of one accepted keystroke. The canonical spelling is named
# in the message instead, and reaches the Paths view as the rejection
# reason gbrpc_set_paths already reports per entry.
# The blacklist is textual, so only one spelling may pass; refused rather
# than rewritten, since entries are stored exactly as given.
_gb_pv_canon=$(gb_path_canon "$_gb_pv_path")
if [ "$_gb_pv_canon" != "$_gb_pv_path" ]; then
printf '%s: not a canonical path -- write it as %s\n' "$_gb_pv_path" "$_gb_pv_canon" >&2
@@ -176,10 +78,7 @@ gb_paths_validate() {
;;
esac
# -e alone misses a dangling symlink (lstat vs stat) -- and this
# backup set is full of legitimate ones, /etc/resolv.conf being the
# standard example (collect.sh's own R24.1 comment) -- so -L is
# checked too before refusing.
# -L too: -e misses dangling symlinks such as /etc/resolv.conf.
if [ ! -e "$GB_ROOT$_gb_pv_path" ] && [ ! -L "$GB_ROOT$_gb_pv_path" ]; then
printf '%s: no such file or directory\n' "$_gb_pv_path" >&2
return 2
@@ -188,11 +87,6 @@ gb_paths_validate() {
return 0
}
# gb_paths_add <path> -- appends <path> to GB_SYSUPGRADE_CONF unless
# gb_paths_validate refuses it (its own reason already on stderr by then,
# its return code propagated unchanged) or it is already there verbatim
# (idempotent: spec/ticket 17 "add/del правят именно его, идемпотентно" --
# running add twice must not duplicate the line).
gb_paths_add() {
_gb_pa_path="$1"
gb_paths_validate "$_gb_pa_path" || return $?
@@ -204,23 +98,7 @@ gb_paths_add() {
printf '%s\n' "$_gb_pa_path" >>"$GB_SYSUPGRADE_CONF"
}
# gb_paths_del <path> -- removes every line equal to <path>, verbatim.
# Silently succeeds when it was not there to begin with -- same
# idempotence gb_paths_add already provides, in the other direction.
#
# Deliberately NOT routed through gb_paths_validate (ticket 26 checked
# this): an exact-line removal of something that is not a valid entry is
# harmless -- there is nothing to protect against by refusing to delete a
# line that was never going to be backed up in the first place -- and a
# human running this subcommand by hand is trusted the way `rm` is. This
# does mean `gitbackup paths del '## some comment'` will happily strip
# that exact comment line the same way it would any other line; that is
# an accepted property of a deliberately unvalidated CLI subcommand, not a
# defect. The actual bug ticket 26 fixed lived one layer up: the LuCI view
# never called this function at all for its own "Remove" button (it always
# went through `set_paths`'s full-list replace, see gbrpc_set_paths in
# usr/libexec/rpcd/luci.gitbackup), and gb_paths_entries below is what
# stops that button from ever being offered for a comment line again.
# Deliberately unvalidated: must be able to remove any stale entry.
gb_paths_del() {
_gb_pd_path="$1"
[ -r "$GB_SYSUPGRADE_CONF" ] || return 0
@@ -230,36 +108,16 @@ gb_paths_del() {
mv "$_gb_pd_tmp" "$GB_SYSUPGRADE_CONF"
}
# gb_paths_replace_entries <entries> -- rewrites GB_SYSUPGRADE_CONF as:
# every comment/blank line gb_paths_list already contains, in its original
# order and wording, completely unchanged -- followed by <entries>
# (newline-separated, blank lines ignored), one per line, exactly as
# given. Ticket 13's decision that a full-list write (`set_paths`, the
# rpcd plugin's only way to persist the LuCI Paths view's edits) writes
# back VERBATIM what it was handed stays -- a directory entry is still
# never expanded here. What changes is what "the file" means during that
# rewrite: it used to mean "only what the caller sent", so the very first
# save from the Paths view against a stock /etc/sysupgrade.conf silently
# erased its own header comments and the two commented-out example lines,
# because the caller (the LuCI view, then `gbrpc_set_paths`) never saw
# them in the first place -- gb_paths_entries (this same module) is what
# the view lists and edits, and comments are excluded from it by design.
# Losing someone else's lines on a save they never touched is not
# acceptable (ticket 26's own brief), so this function -- the one place a
# full-list write happens -- re-reads the comments straight from disk
# and keeps them, unconditionally, regardless of what the caller sent.
# Keeps the file's comment lines (a save must not erase the stock header),
# then <entries> verbatim. Never expand directories into the effective set:
# files created later would silently stop being backed up.
gb_paths_replace_entries() {
_gb_pre_tmp="${GB_SYSUPGRADE_CONF}.tmp.$$"
mkdir -p "$(dirname "$GB_SYSUPGRADE_CONF")" 2>/dev/null
{
gb_paths_list | while IFS= read -r _gb_pre_l || [ -n "$_gb_pre_l" ]; do
_gb_paths_is_comment_or_blank "$_gb_pre_l" && printf '%s\n' "$_gb_pre_l"
done
printf '%s\n' "$1" | while IFS= read -r _gb_pre_e || [ -n "$_gb_pre_e" ]; do
[ -n "$_gb_pre_e" ] || continue
printf '%s\n' "$_gb_pre_e"
done
gb_paths_list | grep -e '^$' -e '^#'
printf '%s\n' "$1" | grep .
} >"$_gb_pre_tmp"
mv "$_gb_pre_tmp" "$GB_SYSUPGRADE_CONF"
+10 -102
View File
@@ -1,30 +1,11 @@
# shellcheck shell=sh
#
# gitbackup -- remote URL parsing, provider detection, deploy-key deep links
# (spec "Парсер remote URL", brief §5.3).
#
# Sourced, never executed: nothing here runs at load time. gb_provider reads
# gitbackup.origin.provider through gb_uci_get (lib.sh), so a caller must
# source lib.sh first, same convention as device.sh.
# gitbackup -- remote URL parsing: gb_parse_url, gb_provider. Needs lib.sh.
# gb_parse_url <url>
#
# Parses one of the four remote URL forms the spec requires, with or without
# a trailing ".git":
# git@host:owner/repo.git scp-like (git's own alias syntax)
# ssh://[user@]host[:port]/owner/repo.git
# https://host[:port]/owner/repo.git
#
# Prints "scheme host port owner repo" on one line, so a caller does
# `set -- $(gb_parse_url "$url")`. port is "0" when the URL did not name
# one -- the parser does not guess a scheme's default port, because what
# that default even means differs by caller (auth.sh's ssh -p vs.
# visibility.sh's HTTPS API host, see visibility.sh's _gb_hostport_for_api).
#
# Returns 1 and prints nothing on anything that is not one of the three
# forms above, or where owner/repo fail the charset check -- a mangled URL
# must fail outright, never be parsed halfway into a wrong-but-plausible
# result (ticket 03 acceptance criterion).
# gb_parse_url <url> -- accepts [user@]host:owner/repo, ssh://[user@]host[:port]/
# owner/repo, https://host[:port]/owner/repo (".git" optional). Prints
# "scheme host port owner repo", port 0 when absent. Anything else: return 1,
# no output -- never half-parses.
gb_parse_url() {
_gb_u="$1"
_gb_scheme=''
@@ -48,15 +29,10 @@ gb_parse_url() {
_gb_parse_hostport_path "$_gb_rest" || return 1
;;
*://*)
# Some other scheme (git://, ftp://, ...) -- not one of the three
# forms the spec names, refused rather than guessed at.
return 1
;;
*:*)
# scp-like alias syntax: "[user@]host:owner/repo(.git)?". Git itself
# only accepts this when the part before the first ':' has no '/' --
# that is what tells it apart from a local path or an already-handled
# scheme above.
# git treats it as scp-like only with no '/' before the first ':'.
_gb_scheme=ssh
_gb_port=''
_gb_userhost="${_gb_u%%:*}"
@@ -79,11 +55,7 @@ gb_parse_url() {
printf '%s %s %s %s %s\n' "$_gb_scheme" "$_gb_host" "${_gb_port:-0}" "$_gb_owner" "$_gb_repo"
}
# _gb_parse_hostport_path <host[:port]/owner/repo[.git]>
#
# Shared by the https:// and ssh:// branches once their scheme prefix (and,
# for ssh://, an optional user@) is already stripped. Sets _gb_host, _gb_port
# and, through _gb_parse_ownerrepo, _gb_owner/_gb_repo.
# Sets _gb_host, _gb_port, _gb_owner, _gb_repo.
_gb_parse_hostport_path() {
_gb_r="$1"
case "$_gb_r" in
@@ -109,10 +81,7 @@ _gb_parse_hostport_path() {
_gb_parse_ownerrepo "$_gb_path"
}
# _gb_parse_ownerrepo <owner/repo[.git]> -- sets _gb_owner and _gb_repo.
#
# Exactly one '/' is allowed: nested groups ("group/subgroup/repo") are not
# one of the four supported forms and are refused, not guessed at.
# Exactly one '/': nested groups are refused, not guessed at.
_gb_parse_ownerrepo() {
_gb_p="$1"
case "$_gb_p" in
@@ -128,7 +97,6 @@ _gb_parse_ownerrepo() {
return 0
}
# _gb_charset_ok <string> -- non-empty and only [A-Za-z0-9._-].
_gb_charset_ok() {
[ -n "$1" ] || return 1
case "$1" in
@@ -137,18 +105,8 @@ _gb_charset_ok() {
return 0
}
# gb_provider <host>
#
# github.com/gitlab.com/bitbucket.org/codeberg.org are recognized by host;
# anything else is "generic" unless gitbackup.origin.provider names one
# explicitly, which always wins over host detection (ticket 03 acceptance
# criterion) -- the one escape hatch for a self-hosted GitLab/Gitea/Forgejo
# instance, which cannot be told apart from an unknown host by name alone.
#
# codeberg.org maps to "gitea": Codeberg runs Forgejo, whose anonymous repo
# API is the same shape as Gitea's (spec, "Проверенные факты 25.12.4 →
# Провайдеры"), and gitbackup.origin.provider's enum has no separate
# "codeberg"/"forgejo" value to return instead.
# gb_provider <host> -- a non-auto gitbackup.origin.provider wins (self-hosted
# escape hatch). codeberg.org is Forgejo, whose API is Gitea's.
gb_provider() {
_gb_host="$1"
_gb_opt=$(gb_uci_get gitbackup.origin.provider auto)
@@ -164,53 +122,3 @@ gb_provider() {
*) printf 'generic\n' ;;
esac
}
# gb_deeplink <provider> <scheme> <host> <owner> <repo>
#
# A URL to the provider's "add deploy key" page, or -- for generic, which has
# no such page anywhere -- an instruction to add the key to authorized_keys
# by hand (brief §5.3).
#
# GitHub and Bitbucket are hardcoded to their one cloud host: gb_provider
# only ever returns "github"/"bitbucket" for github.com/bitbucket.org, so
# there is no self-hosted variant to build a different host for. GitLab and
# Gitea/Forgejo are commonly self-hosted, so their link is built from the
# remote's own host instead -- the brief's table shows a gitlab.com example,
# but GitLab's repository-settings page lives at the same path on every
# instance, not just gitlab.com. <scheme> is coerced to https: a deploy-key
# page is only ever reachable over the web, but the parsed scheme is often
# "ssh" (the git remote's own transport, from an scp-like or ssh:// URL),
# which is never a valid scheme for a browser link.
gb_deeplink() {
_gb_p="$1"
_gb_sch="$2"
_gb_h="$3"
_gb_o="$4"
_gb_r="$5"
case "$_gb_sch" in
http|https) ;;
*) _gb_sch=https ;;
esac
case "$_gb_p" in
github)
# VERIFY: an anonymous request to /settings/keys/new returns 404 for
# both a real and a garbage owner/repo (spec, "Проверенные факты"),
# so unlike the other three this exact path could not be confirmed
# by the same probe -- it is GitHub's documented deploy-key URL, not
# a measured one.
printf 'https://github.com/%s/%s/settings/keys/new\n' "$_gb_o" "$_gb_r"
;;
gitlab)
printf '%s://%s/%s/%s/-/settings/repository\n' "$_gb_sch" "$_gb_h" "$_gb_o" "$_gb_r"
;;
gitea)
printf '%s://%s/%s/%s/settings/keys\n' "$_gb_sch" "$_gb_h" "$_gb_o" "$_gb_r"
;;
bitbucket)
printf 'https://bitbucket.org/%s/%s/admin/access-keys/\n' "$_gb_o" "$_gb_r"
;;
*)
printf 'No provider API to link to; add the deploy key to ~/.ssh/authorized_keys for the git user on %s, ideally with restrict,command="git-shell".\n' "$_gb_h"
;;
esac
}
+41 -365
View File
@@ -1,79 +1,19 @@
# shellcheck shell=sh
#
# gitbackup -- Path 2 restore, applying a backup set straight back onto the
# filesystem (spec "Восстановление"). This is the module that makes
# manifest.json's mode/uid/gid/target fields matter: git stores none of
# them, so a plain checkout of files/ would hand back /etc/shadow at
# whatever mode git happens to give a new file (world-readable) and every
# dropbear host key owned by whoever ran the restore -- a router that
# LOOKS restored but that dropbear and login refuse to trust.
# gitbackup -- restore: gb_restore. Needs lib.sh and gitio.sh; GB_URL from
# the environment. Applies manifest mode/uid/gid/symlinks, which git does not
# store: a plain checkout would leave /etc/shadow world-readable. Not
# `sysupgrade -r`, which is not atomic. The device comes from the argument (a
# bare router has no device_id yet): gb_subst_device, never gb_expand.
#
# Exposes gb_restore; every _gb_restore_-prefixed helper below is private
# and namespaced by module, same convention every other module here uses
# (all of *.sh end up sourced into one 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.
#
# Depends on lib.sh (gb_log, gb_uci_get, gb_manifest_field, gb_manifest_each
# -- gb_json_str is NOT used here, this module only ever reads manifest.json,
# never writes one) and gitio.sh (gb_remote_head, gb_fetch_meta) already
# being sourced by the caller -- restore reuses gitio.sh's own proven fetch
# machinery (a NAMED "origin" remote, a --filter=blob:none fetch, an
# optional depth) for both the common case and the specific-past-commit one
# instead of re-deriving either.
# Does NOT depend on collect.sh or device.sh: the device being restored is
# whatever the caller passes as an argument, never gb_device_id's own
# resolution -- a fresh/bare router calling `gitbackup restore --device D`
# has, by construction, no working device_id config yet (bootstrap's own
# Path 1 step 4 runs this before the restored gitbackup.main.device_id even
# exists on disk), so gb_expand's automatic gb_device_id() call cannot be
# reused here; _gb_restore_expand below is a deliberate, small duplicate of
# gb_expand's substitution loop that takes the device explicitly instead.
#
# GB_ROOT overrides the filesystem root every write and every real-path
# read goes through (default '', meaning the actual '/') -- the same seam
# collect.sh's GB_ROOT already is, so a test can point this at a fixture
# tree instead of the host filesystem and collect_fixture-shaped setups
# apply unchanged. GB_URL is read from the environment, not taken as an
# argument -- same convention gitio.sh/visibility.sh use for config context
# the CLI already resolved once.
#
# VERIFY: Path 0 ("uploaded devices/<d>/backup.tar.gz through LuCI's own
# stock Backup/Flash Firmware -> Restore configuration") is NOT this
# module's own code at all -- Path 0 is the router's stock `sysupgrade -r`
# reading a plain sysupgrade archive, gitbackup never runs in that path.
# What was actually confirmed live on the owlab owrt2512 25.12.4 stand:
# `sysupgrade -b` followed straight by `sysupgrade -r` on that same
# archive genuinely restores config (a changed system.hostname reverted
# correctly) with no error, AS LONG AS the archive excludes /etc/hosts --
# on THIS container-based stand /etc/hosts is a Docker/OrbStack bind
# mount, not a real file, so any tar restore that includes it hits `tar:
# can't remove old file etc/hosts: Resource busy`. That failure is
# confirmed to be the stand's own container artifact, not a router
# behavior: a real router has no such bind mount over /etc/hosts. What
# remains genuinely unverified: an actual click through LuCI's own
# Restore configuration upload widget (no browser available here), and
# the container limitation above itself was never worked around inside
# gitbackup's own code -- only demonstrated by hand-filtering the tar
# before feeding it to sysupgrade -r.
# VERIFY: Path 0 (LuCI upload -> stock `sysupgrade -r`) is not this code and
# was never clicked through.
GB_ROOT="${GB_ROOT:-}"
# gb_restore <device> <commit-or-empty> [--dry-run] [--force] [--yes] [--with-packages]
#
# <commit-or-empty> is "" or "HEAD" for the branch's current tip (the
# common case, and the only one bootstrap's Path 1 ever uses); anything
# else is fetched as a specific historical commit, which needs the whole
# branch's history (not gb_fetch_meta's own --depth=1) to be reachable at
# all -- still only THIS device's one branch, never every branch in the
# repository, so "полного клона не появляется" holds either way.
#
# Returns 0 on success, 1 on a general failure (sha256 mismatch, missing
# manifest, declined confirmation), 2 on a bad argument, 3 on network/auth,
# 4 refused for safety (board mismatch without --force) -- the same
# four-code contract every other module here already answers to
# (interfaces.md, "Коды выхода"); usr/sbin/gitbackup's cmd_restore is what
# turns this into the process's actual exit code.
# Returns 0 ok, 1 failure, 2 bad argument, 3 network/auth, 4 refused (board
# mismatch without --force, or an unusable manifest).
gb_restore() {
_gb_device="$1"
_gb_commit="${2:-}"
@@ -95,15 +35,10 @@ gb_restore() {
[ -n "$_gb_device" ] || { gb_log err 'gb_restore: device is required'; return 2; }
: "${GB_URL:?gb_restore: GB_URL is not set}"
_gb_branch=$(_gb_restore_expand "$(gb_uci_get gitbackup.origin.branch 'device/{device}')" "$_gb_device")
_gb_prefix=$(_gb_restore_expand "$(gb_uci_get gitbackup.main.path_prefix 'devices/{device}')" "$_gb_device")
_gb_branch=$(gb_subst_device "$(gb_uci_get gitbackup.origin.branch 'device/{device}')" "$_gb_device")
_gb_prefix=$(gb_subst_device "$(gb_uci_get gitbackup.main.path_prefix 'devices/{device}')" "$_gb_device")
# Everything from here lives under /tmp, same rule cmd_run's own WORK
# follows (never flash, never USB) -- trap fires when THIS PROCESS (or
# the caller's subshell, in a test) exits, not when this function
# returns, so every early `return` below needs no manual cleanup of
# its own, same convention cmd_run's scattered gb_die calls already
# rely on.
# Under /tmp, never flash.
_gb_work=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-restore.XXXXXX") ||
{ gb_log err 'gb_restore: cannot create a work directory under /tmp'; return 1; }
trap 'rm -rf "$_gb_work"' EXIT INT TERM
@@ -120,17 +55,7 @@ gb_restore() {
gb_fetch_meta "$_gb_branch" "$_gb_repodir" || return 3
_gb_target="$_gb_tip"
else
# A specific past commit needs the branch's full history to be
# reachable at all -- gb_fetch_meta's own default depth of 1
# (gitio.sh, tuned for `run`'s own "just the tip" need) cannot
# reach it. Passing it an explicit empty depth skips --depth
# entirely instead of this module repeating gb_fetch_meta's
# init/remote-add/fetch sequence a second time (ticket 18: this
# WAS that second copy, byte-for-byte, before gb_fetch_meta grew
# a depth argument). Still one branch, not the repository:
# "минимально: своя ветка, нужный коммит" (spec). Same mapping
# to exit 3 on any failure as the tip-fetch branch above, whether
# gb_fetch_meta failed at init or at the fetch itself.
# A past commit needs full branch history (still one branch).
gb_fetch_meta "$_gb_branch" "$_gb_repodir" '' || return 3
_gb_target="$_gb_commit"
fi
@@ -140,13 +65,7 @@ gb_restore() {
return 1
fi
# The one checkout call this whole module needs: a pathspec-scoped
# `checkout <commit> -- <path>` reads only $_gb_prefix's own tree from
# $_gb_target and lazily fetches only the blobs under it (the
# --filter=blob:none fetch above already marked this remote a
# promisor, same mechanism gitio.sh's own gb_fetch_meta comment
# documents for `cat-file -p`) -- this is "sparse на devices/<D>/"
# without needing git's separate sparse-checkout feature at all.
# Pathspec checkout lazily fetches just these blobs (promisor remote).
_gb_co_err=$(git -C "$_gb_repodir" checkout -q "$_gb_target" -- "$_gb_prefix" 2>&1)
_gb_co_rc=$?
if [ "$_gb_co_rc" -ne 0 ] || [ ! -d "$_gb_repodir/$_gb_prefix" ]; then
@@ -161,14 +80,8 @@ gb_restore() {
return 1
fi
# Before anything reads a field out of it: the manifest is remote
# input. Everything else in this function already treats the CONTENT
# of files/ as untrusted-until-hashed; this pass extends the same
# suspicion to the entry metadata that decides WHERE that content
# goes and what it ends up owned by. Refusing the whole manifest
# rather than skipping the offending entry is deliberate -- a
# manifest this package did not write is not a backup that was
# partially applied, it is a backup that should not be applied.
# The manifest comes from the remote and is untrusted: validate every
# field before it picks a destination or becomes chmod/chown argv.
_gb_restore_check_entries "$_gb_manifest" || {
gb_log err "gb_restore: $_gb_prefix/manifest.json at $_gb_target has unusable entries, nothing was written:$_gb_entries_bad"
return 4
@@ -180,20 +93,11 @@ gb_restore() {
_gb_restore_check_release "$_gb_srcroot/meta/os-release.txt"
# The whole point of this module: not one byte reaches GB_ROOT until
# every file's content is proven correct against the manifest.
# Not one byte is written until every file's sha256 is proven.
_gb_restore_verify_sha "$_gb_srcroot/files" "$_gb_manifest" || return 1
# Ticket 19: a second read-only pass, same "сверили всё -> потом
# пишем" principle as the sha256 one just above, run before the
# dry-run branch for the same reason -- it finds out WHICH paths
# cannot possibly be replaced before a single byte is written,
# instead of finding out only when cp fails mid-restore.
# _gb_precheck_bad is consumed below by _gb_restore_write_one, which
# skips a doomed cp entirely for anything in it rather than let
# busybox's own misleading "File exists" (produced by an unlink that
# silently failed, then a create that hits EEXIST) stand in as the
# reported reason.
# Unreplaceable paths are skipped and reported instead of failing in cp
# with busybox's misleading "File exists".
_gb_restore_check_writable "$_gb_manifest"
if [ "$_gb_dry" -eq 1 ]; then
@@ -211,37 +115,11 @@ gb_restore() {
esac
fi
# Files first (content only), THEN mode/uid/gid/empty-dirs/symlinks in
# a second pass (spec: "разложить файлы, затем применить mode/uid/gid,
# создать пустые каталоги, пересоздать симлинки") -- content and
# ownership are deliberately two passes, not interleaved per entry.
_gb_restore_write_files "$_gb_srcroot/files" "$_gb_manifest"
_gb_write_rc=$?
# Ticket 19: this call used to be gated behind
# `_gb_restore_write_files ... || { ...; return 1; }`, so ONE failed
# write (one busy bind mount, one read-only path) skipped applying
# rights to EVERY OTHER file. Found live on the owlab owrt2512 stand:
# a restore that failed only on /etc/hosts (a Docker/OrbStack bind
# mount there, but on a real router this is any path that is
# mounted, busy, read-only, or immutable) left /etc/shadow 644
# root:root -- world-readable -- because this line was never
# reached, and dropbear separately refused its own restored host
# keys for the same reason.
#
# Decision (spec explicitly leaves this to the implementer): a
# partially-restored router with correct permissions on every path
# that DID land is strictly safer than either extreme -- refusing
# the WHOLE restore over one stubborn path throws away everything
# else that would have worked (network, wireless, dropbear keys,
# cron, ...) over a file that is present in literally every backup
# (/etc/hosts is always in sysupgrade -l); silently reporting success
# is worse still. So this module always writes what it can, always
# applies rights to whatever got written, and always tells the
# operator the truth (below) with a non-zero exit -- unconditionally,
# not only under --force. --force here keeps meaning exactly what it
# already means one check up (override a board mismatch); it is not
# involved in this decision at all.
# Always apply perms, even after a partial write: otherwise /etc/shadow
# could stay world-readable.
_gb_restore_apply_perms "$_gb_manifest"
_gb_restore_print_scrubbed "$_gb_manifest"
@@ -260,48 +138,8 @@ gb_restore() {
return 0
}
# _gb_restore_expand <template> <device> -- gb_expand's own substitution
# loop (device.sh), duplicated rather than reused: gb_expand always
# resolves {device} through gb_device_id() itself, which is exactly what
# this module must NOT do (see this file's header). Kept in lockstep with
# gb_expand by inspection -- the loop body is identical on purpose.
_gb_restore_expand() {
_gb_tpl="$1"
_gb_dev="$2"
_gb_out=''
_gb_rest="$_gb_tpl"
while true; do
case "$_gb_rest" in
*'{device}'*)
_gb_out="$_gb_out${_gb_rest%%\{device\}*}$_gb_dev"
_gb_rest="${_gb_rest#*\{device\}}"
;;
*)
_gb_out="$_gb_out$_gb_rest"
break
;;
esac
done
printf '%s\n' "$_gb_out"
}
# _gb_restore_check_board <old-board.json> <force> -- 0 (match, or --force
# overrode a mismatch) or 4 (refused). Compares "model" (the field
# device.sh's own board strategy already reads, and the one confirmed
# `null` on generic/armsr targets -- spec: "Поле board бывает null...
# Это не дефект") and "release.target" (the actual build target, e.g.
# "mediatek/filogic" -- present even when model is not, so it is what
# actually catches a generic-target-to-generic-target restore across
# genuinely different hardware that model alone would wave through as
# "both null, must match").
#
# Confirmed live on the owlab 25.12.4 armsr/armv8 stand: `ubus call system
# board` there has no "model" key at all (this target's own real-world
# instance of "Поле board бывает null" -- not a fixture assumption) and
# does have "release": {"target": "armsr/armv8", ...}; the real jsonfilter
# parses that pretty-printed, multi-line, tab-indented object correctly
# with `-e '@.release.target'` -- confirmed against the real binary, not
# just this suite's flat-JSON test stub.
# "model" is absent on generic/armsr; "release.target" catches
# generic-to-generic hardware changes.
_gb_restore_check_board() {
_gb_old_board="$1"
_gb_force="$2"
@@ -327,12 +165,8 @@ _gb_restore_check_board() {
return 4
}
# _gb_restore_check_release <old-os-release.txt> -- a major-version
# mismatch is a warning, never a refusal (spec: "Расхождение os-release.txt
# в мажорной версии -> предупреждение, не отказ"). Missing/unparsable data
# on either side is silently skipped, not a warning of its own -- an old
# backup predating this field, or a target with no /etc/os-release at all,
# is not itself evidence of anything wrong.
# Major-version mismatch warns only. The backup is untrusted: VERSION_ID is
# read with sed, the file is never sourced.
_gb_restore_check_release() {
_gb_old_rel="$1"
[ -r "$_gb_old_rel" ] || return 0
@@ -349,18 +183,7 @@ _gb_restore_check_release() {
return 0
}
# Field reads and entries[] iteration used to be this module's own private
# copies (a quoted-string extractor, a bare-numeric one, and a hand-rolled
# entries[] walker) -- ticket 18 moved all three to lib.sh as
# gb_manifest_field and gb_manifest_each, shared with collect.sh's
# gb_manifest_equal and scrub.sh's manifest rewriter instead of each
# module maintaining its own copy of the same one-object-per-line,
# no-nested-brackets parsing.
# gb_restore's own hard gate: computes _gb_sha_bad (space-separated bad
# paths) via _gb_restore_verify_sha_one, then refuses as a whole if
# anything mismatched. _gb_srcfiles is set right before the each_entry
# call and consumed only inside it -- a temporary, not a public seam.
# Hard gate: any mismatch refuses the whole restore.
_gb_restore_verify_sha() {
_gb_srcfiles="$1"
_gb_manifest="$2"
@@ -385,73 +208,8 @@ _gb_restore_verify_sha_one() {
[ -n "$_gb_got" ] && [ "$_gb_got" = "$_gb_want" ] || _gb_sha_bad="$_gb_sha_bad $_gb_path"
}
# _gb_restore_check_writable <manifest.json> -- ticket 19's preflight: a
# second read-only pass, run alongside _gb_restore_verify_sha above (same
# "сверили всё -> потом пишем" principle applied to writability, not just
# content). Sets _gb_precheck_bad, a space-separated path list in the same
# shape as _gb_sha_bad above, to every "file" entry whose destination
# already exists and is, as far as metadata alone can predict, NOT going
# to survive cp's own unlink-then-recreate:
#
# - its containing directory has no write permission. Also correctly
# answers "no" when the directory's own FILESYSTEM is mounted
# read-only, even for root -- access(2), which `[ -w ... ]` is built
# on, checks the mount's own read-only flag regardless of permission
# bits. The one half of this check reproducible from an
# unprivileged host test (chmod 555 a directory).
# - the destination's own device differs from its containing
# directory's (`stat -c %d` on each). True only when the exact
# destination PATH is itself a distinct mount point -- e.g. a
# Docker/OrbStack bind mount of a single file over /etc/hosts.
# Confirmed live on the owlab owrt2512 stand: `stat -c%d /etc/hosts`
# there is 41, `stat -c%d /etc` is 1048628 -- while /etc/shadow and
# /etc/config both report 1048628, same as /etc itself, so this does
# NOT misfire on an ordinary file. This is exactly what made cp -f
# fail there with a misleading "File exists" (busybox/coreutils cp's
# own unlink-before-recreate silently failed the unlink, then hit
# EEXIST on the create) -- also confirmed live: `cp -f /tmp/x
# /etc/hosts` -> "cp: can't create '/etc/hosts': File exists". This
# half cannot be fabricated from an unprivileged host test (that
# would need a real mount of its own), so it is stand-verified only,
# same as several other OS-level facts already in this file.
#
# Deliberately narrow: a permission-bit problem on the FILE itself, a
# read-only attribute, or an immutable flag are not caught here -- there
# is no metadata-only signal for those that would not also misfire on an
# ordinary read-only file elsewhere in the tree (mode 444 is common and
# perfectly overwritable by root). Those still only surface at actual
# write time, exactly as before this ticket; this preflight only removes
# the ONE failure mode the spec explicitly asks to catch ahead of time.
# _gb_restore_check_entries <manifest.json> -- read-only validation of
# every entry's METADATA, before any of it is used. Sets _gb_entries_bad
# (a multi-line " <path>: <reason>" report, empty when everything is
# usable) and returns 0 iff it stayed empty.
#
# What this does NOT do is restrict WHICH paths may be restored. Writing
# back whatever the backup set contained, wherever it lived, is the whole
# point of this module -- there is no allowlist here and there should not
# be one. What it checks is that each entry says what it means, exactly
# once, in the one spelling every later pass will agree on:
#
# - path is absolute and canonical (gb_path_canon, lib.sh). Two passes
# read this field independently -- _gb_restore_check_writable_one
# builds _gb_precheck_bad from it and _gb_restore_write_one looks
# itself up in that list by string match -- so "/etc/./hosts" and
# "/etc/hosts" naming the same file in two entries would make the
# preflight and the write disagree about which one it decided on.
# - type is one this module actually handles, so an unknown one is a
# loud refusal instead of an entry silently skipped by all three
# `case` statements below.
# - mode is 3 or 4 octal digits, uid/gid are digits. These go straight
# into `chmod`/`chown` argv (_gb_restore_perm_one), where a value
# like "--reference=/etc/shadow" or a leading "-" would be read as an
# option rather than as the mode it claims to be.
#
# A manifest that fails any of this was not written by this package's own
# collect.sh -- every field above is machine-generated there from stat(1)
# output -- so the honest response is to refuse the restore outright
# (gb_restore does, with exit 4), not to sanitize a hostile manifest into
# a plausible-looking one and apply it.
# Offenders go to _gb_entries_bad. No path allowlist: restoring the set
# wherever it lived is the point.
_gb_restore_check_entries() {
_gb_manifest="$1"
_gb_entries_bad=''
@@ -459,7 +217,6 @@ _gb_restore_check_entries() {
[ -z "$_gb_entries_bad" ]
}
# _gb_restore_entry_reject <shown-path> <reason> -- one line onto the report.
_gb_restore_entry_reject() {
_gb_entries_bad="$_gb_entries_bad
$1: $2"
@@ -518,6 +275,8 @@ _gb_restore_check_entries_one() {
done
}
# Sets _gb_precheck_bad: unwritable directory (read-only mount too), or the
# path is its own mount point (e.g. a bind mount over /etc/hosts).
_gb_restore_check_writable() {
_gb_manifest="$1"
_gb_precheck_bad=''
@@ -532,8 +291,6 @@ _gb_restore_check_writable_one() {
esac
_gb_path=$(gb_manifest_field "$_gb_obj" path)
_gb_dest="${GB_ROOT:-}$_gb_path"
# Nothing at $_gb_dest yet -- cp will just create it, no replace
# involved, so there is nothing here that could go wrong this way.
[ -e "$_gb_dest" ] || return 0
_gb_wdir=$(dirname "$_gb_dest")
@@ -549,11 +306,6 @@ _gb_restore_check_writable_one() {
fi
}
# _gb_restore_print_plan <manifest.json> -- --dry-run's own output, and
# also what the interactive confirmation prompt shows before asking. Marks
# each path "overwrite" (something is already there at GB_ROOT) or
# "create" (nothing is), which is what "печатает, что будет перезаписано"
# actually needs to be useful, not just a bare path list.
_gb_restore_print_plan() {
_gb_manifest="$1"
printf 'The following paths will be written:\n'
@@ -572,23 +324,7 @@ _gb_restore_plan_one() {
fi
}
# _gb_restore_write_files <srcfiles> <manifest.json> -- pass 1: file
# CONTENT only, no mode/uid/gid yet (_gb_restore_apply_perms is pass 2).
# Only reached after _gb_restore_verify_sha has already confirmed every
# file's hash, so a failure here is an I/O problem (disk full, a path
# collision with an existing directory, a mount that refuses to give up
# its own path), not a data-integrity one.
#
# Sets _gb_write_bad, a multi-line " <path>: <reason>" report (empty
# when nothing failed), and returns 0 iff it stayed empty. The caller
# (gb_restore) deliberately does NOT gate _gb_restore_apply_perms on this
# return value (ticket 19) -- it only reads _gb_write_rc/_gb_write_bad
# afterward, to tell the operator the truth and end the call non-zero,
# once rights have already been applied to whatever DID get written.
# gb_manifest_each itself never stops iterating on a callback's own
# return code (lib.sh), so one failing entry here already does not
# prevent the REST of this same pass from running either -- only the
# call site one level up used to throw that away.
# Pass 1, content only; failures collect in _gb_write_bad.
_gb_restore_write_files() {
_gb_srcfiles="$1"
_gb_manifest="$2"
@@ -605,9 +341,6 @@ _gb_restore_write_one() {
esac
_gb_path=$(gb_manifest_field "$_gb_obj" path)
# Already known doomed by _gb_restore_check_writable above -- skip
# the attempt outright rather than let cp run into it and report its
# own less useful message (see that function's own comment for why).
case " $_gb_precheck_bad " in
*" $_gb_path "*)
_gb_write_bad="$_gb_write_bad
@@ -622,23 +355,10 @@ _gb_restore_write_one() {
$_gb_path: $_gb_mkdir_err"
return 0
}
# A symlink sitting where a "file" entry wants to land is removed
# first, never written through. Two reasons, and the second is the
# one that matters: a "file" entry must end up an actual file, which
# is what the symlink branch of _gb_restore_perm_one already assumes
# in the other direction (it rm -f's whatever is there before its own
# ln -s); and `cp -f` disagrees with itself about this across the two
# implementations this package runs on -- GNU cp opens the
# destination O_TRUNC, which FOLLOWS the link and rewrites whatever
# it points at, somewhere the manifest never named, while busybox cp
# unlinks first (the -f comment below). Deciding it here makes both
# behave the same, and makes the set of files a restore can touch
# exactly the set of paths its manifest lists.
# Never write through a symlink: cp -f follows it to a path the manifest
# never named.
[ -L "$_gb_dest" ] && rm -f "$_gb_dest" 2>/dev/null
# -f: busybox cp (unlike GNU/BSD cp) refuses an existing destination
# outright ("File exists") without it -- found live on the owlab
# stand restoring over /etc/hosts, not caught by any host-side test
# since macOS/GNU cp both overwrite by default with no flag at all.
# -f: busybox cp refuses an existing destination without it.
_gb_cp_err=$(cp -f "$_gb_srcfiles$_gb_path" "$_gb_dest" 2>&1) || {
_gb_write_bad="$_gb_write_bad
$_gb_path: $_gb_cp_err"
@@ -646,11 +366,7 @@ _gb_restore_write_one() {
}
}
# _gb_restore_apply_perms <manifest.json> -- pass 2: mode/uid/gid for
# files and directories, empty directories created outright (they have no
# content of their own to have been written in pass 1), symlinks created
# from scratch (their "content" IS the target string, never copied as a
# file -- collect.sh's own R24.1 comment on the same asymmetry).
# Pass 2: mode/uid/gid, empty dirs, symlinks.
_gb_restore_apply_perms() {
_gb_manifest="$1"
gb_manifest_each "$_gb_manifest" entries _gb_restore_perm_one
@@ -672,36 +388,13 @@ _gb_restore_perm_one() {
[ -n "$_gb_uid" ] && [ -n "$_gb_gid" ] && chown "$_gb_uid:$_gb_gid" "$_gb_dest" 2>/dev/null
;;
symlink)
# _gb_symtarget, NOT _gb_target: found live on the owlab stand --
# gb_restore's own outer scope holds the commit sha being
# restored in a variable of that exact name (no `local` anywhere
# in this codebase, interfaces.md's own convention), and this
# helper runs from inside gb_restore's own call chain
# (_gb_restore_apply_perms -> gb_manifest_each -> here) --
# reusing _gb_target here clobbered it, so the FINAL "restored
# <device> from <commit>" message printed the last symlink's
# target string instead of the commit sha whenever the backup
# set contained even one symlink. The restore itself was
# unaffected (this ran after every actual write), only the
# closing report was wrong.
# Not _gb_target: gb_restore keeps the commit sha there (no locals).
_gb_symtarget=$(gb_manifest_field "$_gb_obj" target)
mkdir -p "$(dirname "$_gb_dest")" 2>/dev/null
rm -f "$_gb_dest" 2>/dev/null
# `--`: the target string is the one manifest field with no
# shape of its own to validate (any string is a legal symlink
# target), so it is the one that can still arrive looking like
# an option -- "-s", "--force". End-of-options settles it
# without constraining what a symlink is allowed to point at.
ln -s -- "$_gb_symtarget" "$_gb_dest" 2>/dev/null
# -h: change the symlink's own ownership, not the (possibly
# dangling, e.g. /etc/resolv.conf) target's -- busybox chown
# documents -h for exactly this. Mode is never set on a
# symlink: Linux always reports lstat mode 0777 for one
# regardless of chmod, so a freshly `ln -s`'d link already
# matches whatever collect.sh recorded (it read the same
# lstat mode the same way). Confirmed live on the owlab
# 25.12.4 stand: chown -h on this busybox actually changes
# the symlink's own uid/gid, not a dangling target's.
# -h: chown the link itself, not a possibly dangling target. No
# chmod: Linux symlinks are always 0777.
[ -n "$_gb_uid" ] && [ -n "$_gb_gid" ] && chown -h "$_gb_uid:$_gb_gid" "$_gb_dest" 2>/dev/null
;;
file)
@@ -711,17 +404,7 @@ _gb_restore_perm_one() {
esac
}
# _gb_restore_print_scrubbed <manifest.json> -- prints scrubbed[]'s option
# paths to stderr (informational for the operator, same stream
# gb_accept_hostkey's own prompts use), one per line, with a header only
# once and only if there is at least one. Silent when scrubbed[] is empty
# -- a private backup restoring cleanly should say nothing extra.
#
# gb_manifest_each (lib.sh) walks "scrubbed" the same way
# _gb_restore_apply_perms's own each-entry call above walks "entries" --
# this used to be its own hand-rolled entries/scrubbed-array walker,
# parallel to (and slightly different from) the one gb_restore's other
# helpers shared, before ticket 18 moved both to lib.sh.
# Redacted options on stderr, so the operator re-enters them.
_gb_restore_print_scrubbed() {
_gb_manifest="$1"
_gb_scrub_any=0
@@ -740,15 +423,8 @@ _gb_restore_scrubbed_one() {
printf ' %s\n' "$_gb_opt" >&2
}
# _gb_restore_packages <meta-dir> -- --with-packages, best-effort (spec:
# "apk add по installed_packages.txt best-effort, в конце напечатать
# список неустановившегося"). Package names come from the `{origin}`
# group `apk list --installed` itself prints (meta/installed_packages.txt
# is that command's own full-line output, per collect.sh's own comment) --
# not from splitting the leading "name-version" token, which is exactly
# the fragile parse the spec warns against (a version string can itself
# contain a dash where the name also could). Restores repositories.txt
# first so a custom feed's own packages have somewhere to come from.
# Best effort. Names come from the {origin} group of `apk list --installed`:
# splitting "name-version" is ambiguous.
_gb_restore_packages() {
_gb_meta="$1"
_gb_repos="$_gb_meta/repositories.txt"
+26 -310
View File
@@ -1,36 +1,11 @@
# shellcheck shell=sh
#
# gitbackup -- schedule (spec "Расписание", R100/R101/R103-R107).
#
# Exposes gb_cron_valid, gb_preset_expr, gb_cron_apply and gb_cron_next.
# Every _gb_-prefixed helper is private, same convention as lib.sh. Sourced,
# never executed: nothing here runs at load time, so tests/run.sh can source
# it and call one function without a router underneath it. Assumes lib.sh
# (gb_uci_get, gb_log) and device.sh (gb_device_id) are already sourced,
# same as every other module in this directory.
# gitbackup -- schedule: gb_cron_valid, gb_preset_expr, gb_cron_apply. Needs
# lib.sh and device.sh.
# gb_preset_expr <preset> <device> -- hourly/daily/weekly, spec's table.
#
# The minute (and, for daily/weekly, the hour) is not fixed: it is folded
# out of a hash of <device> so that a fleet of routers all set to the same
# preset do not wake up in the same second and hit GitHub's API together
# (spec: "двадцать роутеров с daily... получат rate limit все разом").
# Deterministic on purpose, not `$RANDOM` -- re-saving the settings form
# must not reshuffle a router's own schedule.
#
# H is folded into 0-5 (spec's "ночное окно 0:00-5:59"), not the full
# 0-23, so a daily/weekly backup never lands in the middle of the day.
#
# The hash is sha256sum, not a literal "crc" tool: the base image ships no
# cksum(1) at all (`busybox cksum` -> "applet not found", measured on the
# 25.12.4 stand), while sha256sum is already there and already a DEPENDS
# collect.sh relies on. Any digest with a roughly uniform byte distribution
# is equally deterministic for this purpose. 8 hex characters -> 32 bits,
# read as decimal through the explicit "0x" arithmetic prefix -- ash's
# arithmetic does not support the ksh/bash "16#..." base-literal syntax
# (verified on the stand: `$((16#$h))` is an "arithmetic syntax error"),
# but the POSIX "0x..." form works on both ash and the bash/zsh /bin/sh this
# suite also runs under during development.
# gb_preset_expr <preset> <device> -- time derived from a hash of <device>:
# a fleet does not hit the remote at once, re-saving does not reshuffle.
# sha256sum: busybox has no cksum; "0x": ash has no 16#N arithmetic.
gb_preset_expr() {
_gb_preset="$1"
_gb_dev="$2"
@@ -50,92 +25,20 @@ gb_preset_expr() {
esac
}
# _gb_strip_lead0 <digits> -- private. Prints <digits> with every leading
# zero removed, down to a single "0" for an all-zero input ("007" -> "7",
# "00" -> "0", "46" -> "46").
#
# Exists only so the rest of this file can safely hand a cron field's
# leading-zero-tolerant value (e.g. the hour "08") to `$(( ))`: busybox
# ash's arithmetic reads a leading zero as an octal prefix ("08" is then an
# invalid octal digit and the whole expression errors out), and -- verified
# on the stand, where it produced the exact same "arithmetic syntax
# error" -- does not implement POSIX's "10#N" decimal-radix prefix as a
# workaround either, unlike the bash/zsh /bin/sh this suite also runs
# under during development. A plain glob-based strip has no radix opinion
# at all.
_gb_strip_lead0() {
_gb_s0="$1"
while true; do
case "$_gb_s0" in
0?*) _gb_s0="${_gb_s0#0}" ;;
*) break ;;
esac
done
printf '%s' "$_gb_s0"
}
# _gb_field_values <field-expr> <min> <max> -- private.
#
# Expands one cron field into its sorted, deduplicated, space-separated list
# of matching integers, or prints nothing and returns 1 if the field is not
# a comma-separated list of "*", "*/N", "A", "A-B" or "A-B/N" terms, or any
# number in it falls outside [<min>,<max>]. This is the one engine behind
# both gb_cron_valid (a field is valid exactly when this expands it without
# error) and gb_cron_next (which needs the actual expansion to search for
# the next match).
#
# Bounds checking always goes through `[ -ge ]`/`[ -le ]` (POSIX `test`),
# never `$(( ))`: a leading-zero field value like the hour "08" is a normal
# decimal 8 to `test`, but `$(( 08 ))` is an invalid octal literal and
# busybox ash's arithmetic errors out on it outright (verified on the
# stand: `v=08; echo $((v))` exits nonzero). Arithmetic is used only once a
# value is already known to be a plain decimal string, and only after
# _gb_strip_lead0 below has removed any leading zero -- ash's arithmetic
# does not support the POSIX "10#N" decimal-radix prefix either (also
# verified on the stand: `$((10#08))` is the same "arithmetic syntax
# error", not the workaround it looks like on a bash/zsh /bin/sh).
#
# "*/N" starts from <min>, matching busybox for every field this package's
# presets or a hand-written cron_expr actually vary (minute, hour, month,
# weekday all have min 0 or 1 in a way that lines up with busybox's own
# internal 0-based bitmap). The one field where this is a deliberate,
# disclosed simplification is day-of-month: busybox's own bitmap for that
# field reserves index 0 for a "day zero" that can never occur and starts
# stepping from THAT, so its real "*/10" fires on 10/20/30 (index 0 is
# inert), not the 1/11/21/31 this function returns starting from min=1 --
# measured on the stand by running `busybox crond -f -d0` against fixture
# crontabs and reading its own field-parse debug dump. Nothing in this
# package's presets or acceptance criteria exercises a stepped day-of-month
# field; gb_cron_next's answer for a hand-written one could come out one
# step earlier than the real crond fires. Documented rather than silently
# assumed away.
_gb_field_values() {
# _gb_field_valid <field-expr> <min> <max> -- bounds use `test`, never $(( )):
# a leading zero would read as octal there.
_gb_field_valid() {
_gb_fv_expr="$1"
_gb_fv_min="$2"
_gb_fv_max="$3"
_gb_fv_out=''
# A leading/trailing/doubled comma has to be rejected before the split
# below, not after it: shell field-splitting on IFS="," drops a
# *trailing* delimiter instead of yielding a trailing empty field (the
# same expansion that turns "a," into the one field "a", not "a" and
# ""), so "5," would otherwise sail through as the single term "5" --
# measured by a fixture in tests/fixtures/cron.tsv that stayed green
# for the wrong reason until this guard was added. A *leading* comma
# does still produce a real empty first field and would be caught
# below regardless, but checking both shapes here up front is one rule
# instead of two.
# IFS splitting silently drops a trailing empty field: "5," would pass.
case "$_gb_fv_expr" in
,*|*,|*,,*) return 1 ;;
esac
_gb_fv_saved_ifs="$IFS"
IFS=','
# -f (noglob) around the split is not optional: an unquoted "*" or
# "*/5" term is exactly the input this field parses, and without it the
# shell pathname-expands "*" against the current directory instead of
# leaving it as a literal word -- caught by a test run from the repo
# root, where "*" silently became a couple dozen filenames.
set -f
# shellcheck disable=SC2086 # splitting on IFS=',' is the point
set -- $_gb_fv_expr
@@ -144,35 +47,22 @@ _gb_field_values() {
[ "$#" -gt 0 ] || return 1
for _gb_fv_term in "$@"; do
# An empty term is a bare/leading/trailing/doubled comma ("," ",,"),
# never a valid field on its own.
[ -n "$_gb_fv_term" ] || return 1
_gb_fv_step=1
_gb_fv_range="$_gb_fv_term"
case "$_gb_fv_term" in
*/*)
_gb_fv_range="${_gb_fv_term%%/*}"
_gb_fv_step="${_gb_fv_term#*/}"
case "$_gb_fv_step" in ''|*[!0-9]*) return 1 ;; esac
# A step of 0, or one bigger than the field's own span,
# parses without complaint in real busybox crond but
# silently degenerates to firing on the field's single
# lowest value forever -- measured on the stand
# (`*/0`/`*/61` in a minute field both produce a bitmap
# with only minute 0 set, no parse error at all). That is
# exactly the kind of "looks configured, never really
# runs" trap R101's live validation exists to catch
# before it reaches a crontab.
# Step 0 or beyond the span parses in busybox crond but fires
# only on the lowest value.
[ "$_gb_fv_step" -ge 1 ] && [ "$_gb_fv_step" -le "$_gb_fv_max" ] || return 1
;;
esac
case "$_gb_fv_range" in
'*')
_gb_fv_lo="$_gb_fv_min"
_gb_fv_hi="$_gb_fv_max"
;;
'*') ;;
*-*)
_gb_fv_lo="${_gb_fv_range%%-*}"
_gb_fv_hi="${_gb_fv_range#*-}"
@@ -180,52 +70,19 @@ _gb_field_values() {
case "$_gb_fv_hi" in ''|*[!0-9]*) return 1 ;; esac
[ "$_gb_fv_lo" -ge "$_gb_fv_min" ] && [ "$_gb_fv_lo" -le "$_gb_fv_max" ] || return 1
[ "$_gb_fv_hi" -ge "$_gb_fv_min" ] && [ "$_gb_fv_hi" -le "$_gb_fv_max" ] || return 1
# A reversed range ("5-2") parses in real busybox crond
# too, but produces a bitmap that is wrong in a way no
# user intended (measured on the stand) rather than an
# error -- rejected here instead of reproduced.
# Reversed range parses in busybox but yields a wrong bitmap.
[ "$_gb_fv_lo" -le "$_gb_fv_hi" ] || return 1
;;
*)
case "$_gb_fv_range" in ''|*[!0-9]*) return 1 ;; esac
[ "$_gb_fv_range" -ge "$_gb_fv_min" ] && [ "$_gb_fv_range" -le "$_gb_fv_max" ] || return 1
_gb_fv_lo="$_gb_fv_range"
_gb_fv_hi="$_gb_fv_range"
;;
esac
# A plain "A/N" (no "-" range) is real, accepted busybox syntax --
# measured on the stand -- but the step is a no-op: it fires on A
# alone, never repeating. The loop below already reproduces that
# with no special case, because it starts and ends at the same
# value: one iteration, done.
_gb_fv_v=$(_gb_strip_lead0 "$_gb_fv_lo")
_gb_fv_v=$(( _gb_fv_v ))
_gb_fv_hi_n=$(_gb_strip_lead0 "$_gb_fv_hi")
_gb_fv_hi_n=$(( _gb_fv_hi_n ))
_gb_fv_step_n=$(_gb_strip_lead0 "$_gb_fv_step")
_gb_fv_step_n=$(( _gb_fv_step_n ))
while [ "$_gb_fv_v" -le "$_gb_fv_hi_n" ]; do
_gb_fv_out="$_gb_fv_out $_gb_fv_v"
_gb_fv_v=$((_gb_fv_v + _gb_fv_step_n))
done
done
printf '%s' "$_gb_fv_out" | tr -s ' ' '\n' | sed '/^$/d' | sort -n -u | tr '\n' ' '
printf '\n'
}
# gb_cron_valid <expr> -- 0 if busybox crond on 25.12 can actually run
# <expr>, 1 otherwise, with a reason on stderr.
#
# Rejects the "@daily"/"@hourly"/"@reboot" family outright and by name:
# `CONFIG_FEATURE_CROND_SPECIAL_TIMES=n` on the 25.12.4 build means the
# binary has no macro table at all, so "@daily aa bb cc dd ee ff" gives a
# loud "parse error at @daily" -- but a *short* macro line like the classic
# "@daily /bin/echo A" (fewer than six whitespace-separated tokens) is
# dropped by crond with no error and no run, ever (both measured on the
# stand). A validator that let @-macros through on the strength of the
# first, louder failure mode would still ship the second, silent one.
# gb_cron_valid <expr> -- 1 with a reason on stderr. busybox crond has no
# @-macros: "@daily ..." errors or, under six tokens, is dropped silently.
gb_cron_valid() {
_gb_cv_expr="${1-}"
case "$_gb_cv_expr" in
@@ -236,8 +93,6 @@ gb_cron_valid() {
;;
esac
# -f (noglob): see _gb_field_values -- a field is often a literal "*"
# and must not be pathname-expanded by this split.
set -f
# shellcheck disable=SC2086 # splitting on whitespace is the point
set -- $_gb_cv_expr
@@ -248,19 +103,13 @@ gb_cron_valid() {
return 1
fi
# Field bounds: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12,
# day-of-week 0-6 -- the last confirmed on the stand by the fact that
# "0 3 * * 7" is itself a parse error (no Sunday-as-7 alias on this
# busybox), while 0 and 6 both parse. Five explicit checks rather than a
# table-driven loop: POSIX sh has no arrays, and indexing positional
# parameters by an arithmetic offset would need `eval` for no real gain
# over just naming each field once.
# busybox has no Sunday-as-7 alias.
_gb_cv_bad=''
_gb_field_values "$1" 0 59 >/dev/null || _gb_cv_bad='minute'
[ -n "$_gb_cv_bad" ] || { _gb_field_values "$2" 0 23 >/dev/null || _gb_cv_bad='hour'; }
[ -n "$_gb_cv_bad" ] || { _gb_field_values "$3" 1 31 >/dev/null || _gb_cv_bad='day-of-month'; }
[ -n "$_gb_cv_bad" ] || { _gb_field_values "$4" 1 12 >/dev/null || _gb_cv_bad='month'; }
[ -n "$_gb_cv_bad" ] || { _gb_field_values "$5" 0 6 >/dev/null || _gb_cv_bad='day-of-week'; }
_gb_field_valid "$1" 0 59 || _gb_cv_bad='minute'
[ -n "$_gb_cv_bad" ] || { _gb_field_valid "$2" 0 23 || _gb_cv_bad='hour'; }
[ -n "$_gb_cv_bad" ] || { _gb_field_valid "$3" 1 31 || _gb_cv_bad='day-of-month'; }
[ -n "$_gb_cv_bad" ] || { _gb_field_valid "$4" 1 12 || _gb_cv_bad='month'; }
[ -n "$_gb_cv_bad" ] || { _gb_field_valid "$5" 0 6 || _gb_cv_bad='day-of-week'; }
if [ -n "$_gb_cv_bad" ]; then
printf '%s field is not a valid value, range, step or list of them in "%s"\n' \
"$_gb_cv_bad" "$_gb_cv_expr" >&2
@@ -269,149 +118,16 @@ gb_cron_valid() {
return 0
}
# gb_cron_next <expr> -- the next UTC "YYYY-MM-DD HH:MM" <expr> fires at or
# after now, or nothing (exit 1) if <expr> is invalid.
#
# Searches whole days forward from today (up to ~4 years, comfortably past
# any Feb-29-only expression), because every field this function has to
# reason about -- month, day-of-month, day-of-week -- only ever changes at a
# day boundary; only the final hour/minute search happens inside the
# matching day. All arithmetic is on Unix time in UTC, which never has a day
# shorter or longer than 86400 seconds (no DST to account for), so a whole
# day is added by adding exactly 86400 -- no calendar library, no date
# string round-trip, just `date -u -d @<epoch>` to read back the
# month/day-of-month/weekday for a given instant (the "@seconds_since_1970"
# form busybox `date --help` documents as recognized on the stand).
gb_cron_next() {
_gb_cn_expr="$1"
gb_cron_valid "$_gb_cn_expr" >/dev/null 2>&1 || return 1
# -f (noglob): see _gb_field_values -- a field is often a literal "*"
# and must not be pathname-expanded by this split.
set -f
# shellcheck disable=SC2086
set -- $_gb_cn_expr
set +f
_gb_cn_minutes=$(_gb_field_values "$1" 0 59) || return 1
_gb_cn_hours=$(_gb_field_values "$2" 0 23) || return 1
_gb_cn_doms=$(_gb_field_values "$3" 1 31) || return 1
_gb_cn_months=$(_gb_field_values "$4" 1 12) || return 1
_gb_cn_dows=$(_gb_field_values "$5" 0 6) || return 1
# Standard cron day-selection rule: when BOTH day-of-month and
# day-of-week are restricted (neither is "*"), a day qualifies if it
# matches EITHER one (OR); when only one is restricted, that one alone
# decides.
_gb_cn_dom_star=0
case "$3" in '*') _gb_cn_dom_star=1 ;; esac
_gb_cn_dow_star=0
case "$5" in '*') _gb_cn_dow_star=1 ;; esac
_gb_cn_now=$(date -u +%s) || return 1
_gb_cn_midnight=$(( _gb_cn_now - (_gb_cn_now % 86400) ))
_gb_cn_day=0
while [ "$_gb_cn_day" -le 1466 ]; do
_gb_cn_day_epoch=$(( _gb_cn_midnight + _gb_cn_day * 86400 ))
_gb_cn_ymw=$(date -u -d "@$_gb_cn_day_epoch" '+%m %d %w') || return 1
_gb_cn_mo=${_gb_cn_ymw%% *}
_gb_cn_rest=${_gb_cn_ymw#* }
_gb_cn_dm=${_gb_cn_rest%% *}
_gb_cn_wd=${_gb_cn_rest#* }
# Same _gb_strip_lead0 as _gb_field_values above, needed here
# because `date`'s own %m/%d output is zero-padded.
_gb_cn_mo=$(_gb_strip_lead0 "$_gb_cn_mo")
_gb_cn_mo=$(( _gb_cn_mo ))
_gb_cn_dm=$(_gb_strip_lead0 "$_gb_cn_dm")
_gb_cn_dm=$(( _gb_cn_dm ))
_gb_cn_wd=$(_gb_strip_lead0 "$_gb_cn_wd")
_gb_cn_wd=$(( _gb_cn_wd ))
case " $_gb_cn_months " in
*" $_gb_cn_mo "*) _gb_cn_month_ok=1 ;;
*) _gb_cn_month_ok=0 ;;
esac
if [ "$_gb_cn_month_ok" -eq 1 ]; then
_gb_cn_dom_ok=0
case " $_gb_cn_doms " in *" $_gb_cn_dm "*) _gb_cn_dom_ok=1 ;; esac
_gb_cn_dow_ok=0
case " $_gb_cn_dows " in *" $_gb_cn_wd "*) _gb_cn_dow_ok=1 ;; esac
if [ "$_gb_cn_dom_star" -eq 1 ] && [ "$_gb_cn_dow_star" -eq 1 ]; then
_gb_cn_day_ok=1
elif [ "$_gb_cn_dom_star" -eq 1 ]; then
_gb_cn_day_ok="$_gb_cn_dow_ok"
elif [ "$_gb_cn_dow_star" -eq 1 ]; then
_gb_cn_day_ok="$_gb_cn_dom_ok"
elif [ "$_gb_cn_dom_ok" -eq 1 ] || [ "$_gb_cn_dow_ok" -eq 1 ]; then
_gb_cn_day_ok=1
else
_gb_cn_day_ok=0
fi
if [ "$_gb_cn_day_ok" -eq 1 ]; then
for _gb_cn_h in $_gb_cn_hours; do
for _gb_cn_m in $_gb_cn_minutes; do
_gb_cn_cand=$(( _gb_cn_day_epoch + _gb_cn_h * 3600 + _gb_cn_m * 60 ))
if [ "$_gb_cn_cand" -ge "$_gb_cn_now" ]; then
date -u -d "@$_gb_cn_cand" '+%Y-%m-%d %H:%M'
return 0
fi
done
done
fi
fi
_gb_cn_day=$((_gb_cn_day + 1))
done
return 1
}
# gb_cron_apply -- reads gitbackup.main.schedule (and cron_expr/device_id as
# needed), (re)writes the single "# gitbackup"-marked line in the crontab,
# and makes it live.
#
# Idempotent by construction: every existing line ending in "# gitbackup" is
# dropped before the (at most one) new one is appended, so applying twice in
# a row -- two saves of the settings form, a reboot re-running this from
# start_service, whatever -- leaves exactly one line, never a growing pile,
# and never touches a line that is not ours (spec: OpenWrt's own crontab
# ships with unrelated vnstat/adblock entries out of the box; confirmed
# present, untouched by this, on the owlab stand).
#
# schedule=off removes that line but leaves the file itself in place, even
# at zero length: `/etc/init.d/cron`'s start_service begins with
# `[ -z "$(ls /etc/crontabs/)" ] && return 1` -- crond refuses to start only
# when the *directory* is empty, and a zero-byte file already satisfies
# that (spec "Проверенные факты", confirmed unchanged on this stand).
# Deleting the file on schedule=off would silently take crond down with it
# for every *other* cron job on the router, which is not this package's
# call to make.
#
# GB_CRONTAB overrides the crontab path so tests/run.sh can point this at a
# plain temp file; the router never sets it. The final
# `/etc/init.d/cron enable && /etc/init.d/cron restart` is guarded by
# `[ -x /etc/init.d/cron ]` for the same reason -- there is no such script
# on a development host -- and is the one piece of this function verified
# only on the owlab stand (via `ps`), never in tests/run.sh, per this
# ticket's own acceptance criteria.
# gb_cron_apply -- rewrites only the "# gitbackup"-marked line. schedule=off
# keeps the crontab file even if empty: cron's init script refuses to start
# on an empty crontabs directory, which would kill every other job.
gb_cron_apply() {
_gb_ca_crontab="${GB_CRONTAB:-/etc/crontabs/root}"
_gb_ca_sched=$(gb_uci_get gitbackup.main.schedule daily)
_gb_ca_expr=''
# Deliberately not named with a "gitbackup" + "." substring next to
# each other: t_config_sections_match_code's own grep reads any
# "gitbackup.<word>." it finds anywhere in this directory as a UCI
# section reference, and a suffix like that would fail it outright
# for naming a temp file, not a config path (hit during development).
#
# A stray one of these from some earlier crashed run (this file's own
# development left several on the owlab stand before a bug in
# _gb_field_values was fixed) is swept up here rather than left to
# accumulate one file per bad run forever -- the crontab directory has
# no other owner watching it for that.
# No "gitbackup." substring in the temp name: a test grep would read it as
# a UCI path. Sweeps leftovers from crashed runs.
rm -f "${_gb_ca_crontab}".newtmp.* 2>/dev/null
_gb_ca_tmp="${_gb_ca_crontab}.newtmp.$$"
if [ -f "$_gb_ca_crontab" ]; then
+27 -70
View File
@@ -1,100 +1,57 @@
# gitbackup scrub list -- UCI options whose value is a secret, redacted by
# scrub.sh when visibility=public. One pattern per line, "#" starts a
# trailing comment that MUST name where the option was confirmed to exist --
# a line without one is not something this ticket allows to exist (spec
# "Scrub": "собирается обходом реальных источников, не из головы").
# gitbackup scrub list: UCI options whose value is a secret, redacted by
# scrub.sh when scrubbing. Every entry must cite where the option was
# confirmed to exist -- no entries from memory.
#
# Pattern shape: "<config>.@<type>[*].<option>"
# <config> the /etc/config/<config> file the option lives in.
# <type> the UCI section TYPE the option is nested under (NOT a section
# name -- names are user-chosen and unpredictable, e.g. "wan" or
# "myvpn", but every section still declares a type). May end in
# a literal "*" to prefix-match a family of dynamically-named
# types (WireGuard peers, see below) -- scrub.sh treats a
# trailing "*" here as a shell case-glob, everything else as a
# literal string.
# [*] every section of that type, addressed the same way `uci show`
# itself would print an anonymous one (@type[N]); scrub.sh
# resolves this itself by listing every section declaration of
# the matching type in the file and checking whether <option> is
# actually set on it, so a named section (radio0, wan, myvpn --
# uci addresses those by name, not [N]) is still found. Nothing
# is fabricated: an option that is not present on a matching
# section is left alone, never created.
# <option> the option name to redact.
# Pattern: "<config>.@<type>[*].<option>"
# <type> section TYPE, not name. A trailing "*" is a case-glob prefix
# (dynamic WireGuard peer types); anything else is literal.
# [*] every section of that type, named or anonymous; an option not
# set on a section is left alone, never created.
#
# Checked and found to hold NO inline UCI secret (file-based secrets, if
# any, are exclude.list's job, not this file's):
# - dropbear: openwrt/openwrt package/network/services/dropbear/files/
# dropbear.config -- enable/PasswordAuth/RootPasswordAuth/Port only.
# - mwan3: openwrt/luci applications/luci-app-mwan3, every
# view/mwan3/network/*.js -- no password/secret/key option, "keyup" in
# the source is an event name, not an option.
#
# Deliberately NOT listed even though the subsystem has secrets, because the
# secret lives in a FILE the option merely points at, not in the option
# value itself (a file path is not "the value replaced by a placeholder" --
# scrub.sh only ever rewrites option values, and a leaked path is not the
# same class of leak as a leaked passphrase):
# - openvpn: tls_auth/tls_crypt/secret/askpass/auth_user_pass point at
# /etc/openvpn/*, per openwrt/packages net/openvpn/files/openvpn.config.
# - wireguard: the private/preshared keys ARE inline UCI values (listed
# below); an interface's ca/usercert-equivalent is not applicable here.
# No inline secret: dropbear (dropbear.config), mwan3 (luci-app-mwan3 views).
# Not listed: openvpn tls_auth/tls_crypt/secret/askpass/auth_user_pass are
# file paths, not secret values; scrub.sh only rewrites values.
# wireless -- WPA/WPA2/WPA3 PSK. Given verbatim in the brief and already the
# stock gitbackup.security.scrub_option default; confirmed as a wifi-iface
# option (not wifi-device) in
# openwrt/luci modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js:2531
# wireless -- WPA PSK on wifi-iface (not wifi-device), openwrt/luci
# modules/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js:2531
wireless.@wifi-iface[*].key
# wireless -- WEP keys 1-4 (legacy, still settable). Same file, the
# uci.unset/taboption block generating 'key1'..'key4', wireless.js:1857-1882
# wireless -- WEP keys 1-4, wireless.js:1857-1882
wireless.@wifi-iface[*].key1
wireless.@wifi-iface[*].key2
wireless.@wifi-iface[*].key3
wireless.@wifi-iface[*].key4
# wireless -- SAE/WPA3 "password" field (distinct option from 'key' in the
# same form), wireless.js:2120,2531
# wireless -- SAE/WPA3 password, wireless.js:2120,2531
wireless.@wifi-iface[*].password
# wireless -- 802.1X/WPA-Enterprise RADIUS secrets, wireless.js:1729,1746,1830
# wireless -- 802.1X RADIUS secrets, wireless.js:1729,1746,1830
wireless.@wifi-iface[*].auth_secret
wireless.@wifi-iface[*].acct_secret
wireless.@wifi-iface[*].dae_secret
# network -- PAP/CHAP login used by every ppp-family proto (pppoe, pptp,
# l2tp, pppoa) on a generic 'interface' section; confirmed identical across
# openwrt/luci protocols/luci-proto-ppp/htdocs/luci-static/resources/protocol/{pppoe,pptp,l2tp,pppoa,ppp}.js
# network -- ppp-family login (pppoe, pptp, l2tp, pppoa), openwrt/luci
# protocols/luci-proto-ppp/htdocs/luci-static/resources/protocol/{pppoe,pptp,l2tp,pppoa,ppp}.js
network.@interface[*].username
network.@interface[*].password
# network -- OpenVPN client cert passphrase and login password once migrated
# onto 'network' (config file says "package network", type stays
# 'interface', proto=openvpn): openwrt/packages
# net/openvpn/files/openvpn.config:16,19 (password, cert_password) plus the
# migration confirming the target file/type,
# network -- OpenVPN cert passphrase after migration to network/interface,
# openwrt/packages net/openvpn/files/openvpn.config:16,19 and
# net/openvpn/files/etc/uci-defaults/60_openvpn_migrate.sh
network.@interface[*].cert_password
# network -- OpenConnect: second factor/OTP password and an inline PEM
# private key (form.TextValue, not a file path), openwrt/luci
# network -- OpenConnect OTP password and inline PEM key, openwrt/luci
# protocols/luci-proto-openconnect/htdocs/luci-static/resources/protocol/openconnect.js:148,166
network.@interface[*].password2
network.@interface[*].userkey
# network -- WireGuard interface private key, openwrt/luci
# network -- WireGuard private key, openwrt/luci
# protocols/luci-proto-wireguard/htdocs/luci-static/resources/protocol/wireguard.js:128,380
network.@interface[*].private_key
# network -- WireGuard peer preshared key. Peer sections use a DYNAMIC
# section type "wireguard_<ifname>" (one per WireGuard interface), not a
# fixed type name -- confirmed by wireguard.js:195,344-384,572
# ("uci.add('network', 'wireguard_' + s.section)"), hence the type prefix
# wildcard instead of a literal type.
# network -- WireGuard peer PSK; peer type is dynamic "wireguard_<ifname>",
# wireguard.js:195,344-384,572
network.@wireguard_*[*].preshared_key
# ddns -- login credentials substituted into the update URL, on a 'service'
# section (uci.add('ddns', 'service', section_id)): openwrt/luci
# ddns -- update-URL credentials, openwrt/luci
# applications/luci-app-ddns/htdocs/luci-static/resources/view/ddns/overview.js:751,757-763
ddns.@service[*].username
ddns.@service[*].password
# uhttpd -- HTTP Basic-Auth credentials for a URL prefix, on a 'httpauth'
# section (config_list_foreach "$cfg" httpauth create_httpauth):
# openwrt/openwrt package/network/services/uhttpd/files/uhttpd.init:68-79
# uhttpd -- Basic-Auth credentials, openwrt/openwrt
# package/network/services/uhttpd/files/uhttpd.init:68-79
uhttpd.@httpauth[*].username
uhttpd.@httpauth[*].password
+39 -262
View File
@@ -1,55 +1,17 @@
# shellcheck shell=sh
#
# gitbackup -- secret redaction for visibility=public (spec "Scrub").
#
# Exposes gb_scrub; every _gb_scrub_-prefixed helper below is private,
# namespaced the same way collect.sh explains its own helpers are (every
# *.sh here ends up sourced into one process by usr/sbin/gitbackup, so a
# same-named helper in a sibling module would silently shadow this one
# otherwise). Sourced, never executed: nothing here runs at load time --
# t_scrub_no_side_effect_on_source in tests/run.sh asserts this directly by
# sourcing the module over a tree that still has its secrets and checking
# nothing changed.
#
# Depends on lib.sh (gb_log, gb_json_str, gb_uci_get, gb_manifest_field) already being sourced
# by the caller, same convention every other module here uses. Does NOT
# depend on collect.sh: gb_scrub computes manifest.json's path itself
# (outdir/manifest.json, the one fixed shape gb_collect ever writes) rather
# than calling collect.sh's gb_manifest_path, so this module has no load-order
# requirement on it and the two stay testable apart.
#
# Who calls this and when is not this module's concern (spec: "Только при
# visibility=public, где он принудителен" is a decision usr/sbin/gitbackup's
# gb_validate_config/_gb_effective_scrub already makes and exposes as
# GB_SCRUB -- see tests/run.sh's t_cli_public_forces_scrub). gb_scrub itself
# has no opinion on visibility and no config lookup for whether to run: it
# scrubs whatever tree it is given, unconditionally, the same way gb_collect
# has no opinion on what to do with the tree it builds.
#
# GB_SCRUB_LIST/GB_EXCLUDE_LIST override where the static pattern files live
# (defaulting under GB_SHARE, same convention collect.sh's GB_EXCLUDE_LIST
# uses) so tests/run.sh can point this at a fixture instead of the real
# package data. Neither is set by the router.
# gitbackup -- secret redaction: gb_scrub. Needs lib.sh. Works only on the
# collected copy, never the live config. Whether to run is the CLI's call
# (GB_SCRUB); whole-file secrets are excluded by collect.sh instead.
GB_SCRUB_LIST="${GB_SCRUB_LIST:-${GB_SHARE:-/usr/share/gitbackup}/scrub.list}"
GB_EXCLUDE_LIST="${GB_EXCLUDE_LIST:-${GB_SHARE:-/usr/share/gitbackup}/exclude.list}"
# The placeholder every redacted value becomes. No hash: spec "Scrub" --
# "Хеша не остаётся: короткий PSK по хешу подбирается, и хеш сам по себе
# был бы утечкой." A fixed constant, not overridable -- unlike the two
# above, there is no legitimate reason a caller would want a different one.
# Fixed placeholder, no hash: a short PSK is brute-forceable from its hash.
GB_SCRUB_PLACEHOLDER='<gitbackup:redacted>'
# gb_scrub <outdir>
#
# Mutates outdir/files/etc/config/* in place via `uci -c`, never sed (spec:
# "многострочные list ломают любой построчный разбор, и молча"), removes the
# files exclude.list's public-only block names entirely, rewrites
# outdir/manifest.json's scrubbed[] array and re-hashes/drops the affected
# entries[] lines, and prints one "<path>\t<uci-option-path>" line per
# redacted option to stdout for the caller's own logging. A tree with no
# etc/config directory at all (a stripped-down fixture, or a router with
# nothing there) is a no-op, not an error.
# gb_scrub <outdir> -- redacts via `uci -c ... -t <savedir>`, never sed
# (multi-line lists break line-based edits silently), re-hashes, fills
# scrubbed[], prints "<path>\t<option>" per redacted option.
gb_scrub() {
_gb_outdir="$1"
[ -n "$_gb_outdir" ] || { gb_log err 'gb_scrub: outdir is required'; return 1; }
@@ -61,34 +23,24 @@ gb_scrub() {
mkdir -p "$_gb_savedir"
: >"$_gb_scratch/records"
: >"$_gb_scratch/rehash"
: >"$_gb_scratch/removed"
[ -d "$_gb_confdir" ] && _gb_scrub_options "$_gb_confdir" "$_gb_scratch"
_gb_scrub_hard_exclude "$_gb_outdir" "$_gb_scratch"
cat "$_gb_scratch/records"
[ -r "$_gb_manifest" ] && _gb_scrub_write_manifest "$_gb_manifest" \
"$_gb_scratch/records" "$_gb_scratch/rehash" "$_gb_scratch/removed"
"$_gb_scratch/records" "$_gb_scratch/rehash"
rm -rf "$_gb_scratch"
}
# _gb_scrub_options <confdir> <scratch> -- the UCI-editing half of gb_scrub.
# Walks scrub.list (+ the UCI-configurable list scrub_option) grouped by
# config file, so each touched file is `uci show`n and committed exactly
# once regardless of how many patterns target it.
# Grouped by config, so each file is shown and committed once.
_gb_scrub_options() {
_gb_confdir="$1"
_gb_scratch="$2"
_gb_savedir="$_gb_scratch/save"
# sort -u: gitbackup.security's own shipped default
# (list scrub_option 'wireless.@wifi-iface[*].key', ticket 01) is the
# same line scrub.list already carries -- found live on the owlab
# stand, where an unfiltered run redacted the one real option correctly
# but printed and recorded it twice. One pattern, one record, however
# many sources agreed on it.
# sort -u: the shipped scrub_option default duplicates a scrub.list line.
_gb_scrub_load_patterns | sort -u >"$_gb_scratch/patterns"
[ -s "$_gb_scratch/patterns" ] || return 0
@@ -98,10 +50,8 @@ _gb_scrub_options() {
_gb_sections="$_gb_scratch/sections.$_gb_cfg"
: >"$_gb_sections"
# One line per section DECLARATION ("cfg.ref=type"), ref TAB type.
# A declaration line has exactly one dot after the config name; an
# option line ("cfg.ref.option=value") has two, so the ".*" guard
# tells them apart without needing to know option names in advance.
# Section declarations only ("cfg.ref=type" -> ref TAB type); option
# lines have a second dot.
uci -c "$_gb_confdir" show "$_gb_cfg" 2>/dev/null | \
while IFS= read -r _gb_l; do
case "$_gb_l" in
@@ -122,24 +72,14 @@ _gb_scrub_options() {
[ "$_gb_pc" = "$_gb_cfg" ] || continue
while IFS=' ' read -r _gb_ref _gb_type; do
[ -n "$_gb_ref" ] || continue
# Unquoted on the right on purpose: a scrub.list type may end
# in "*" to prefix-match a dynamically-named type family
# (WireGuard peers, "wireguard_<ifname>") -- see scrub.list's
# own header. A plain type name has no glob metacharacters,
# so this is an exact match for every pattern that is not
# using the prefix form.
# Unquoted: a trailing "*" prefix-matches (wireguard_<ifname>).
# shellcheck disable=SC2254
case "$_gb_type" in
$_gb_ptype) ;;
*) continue ;;
esac
_gb_path="$_gb_cfg.$_gb_ref.$_gb_popt"
# Existence check first: `uci set` on a nonexistent option
# CREATES it (verified against openwrt/uci's list.c uci_set,
# the "!ptr->o && ptr->option" branch), and fabricating an
# option the user never set is not this module's job to do,
# ever -- scrub redacts what is there, it does not invent
# config the restored router would then apply.
# `uci set` on a missing option would create it.
uci -c "$_gb_confdir" get "$_gb_path" >/dev/null 2>&1 || continue
uci -c "$_gb_confdir" -t "$_gb_savedir" set \
"$_gb_path=$GB_SCRUB_PLACEHOLDER" >/dev/null 2>&1 || continue
@@ -149,15 +89,8 @@ _gb_scrub_options() {
done <"$_gb_scratch/patterns"
if [ -f "$_gb_touched" ]; then
# -t, not -P: -P also sets CLI_FLAG_NOCOMMIT (verified live on
# the owlab stand -- a `commit` issued with -P silently no-ops,
# ret=0, file untouched), which would make this commit do
# nothing. -t sets the same custom save directory without that
# flag, so `set` and `commit` agree on where the pending delta
# lives without ever touching the router's own default savedir
# (/tmp/.uci) -- needed since two gb_scrub calls (or two test
# cases) touching a config of the same name must not collide
# through a savedir keyed only by that name.
# -t, not -P: -P also sets no-commit, so commit would silently
# no-op. -t keeps set/commit on a private savedir, off /tmp/.uci.
uci -c "$_gb_confdir" -t "$_gb_savedir" commit "$_gb_cfg" >/dev/null 2>&1
_gb_sha=$(sha256sum "$_gb_confdir/$_gb_cfg" 2>/dev/null | awk '{print $1}')
[ -n "$_gb_sha" ] && printf '/etc/config/%s\t%s\n' "$_gb_cfg" "$_gb_sha" >>"$_gb_scratch/rehash"
@@ -165,21 +98,14 @@ _gb_scrub_options() {
done
}
# _gb_scrub_load_patterns -- prints "config type option" (space separated)
# for every recognized scrub.list line plus every gitbackup.security's list
# scrub_option entry, same format, spec "Расширяется через list scrub_option
# в UCI". gb_uci_get (lib.sh) already returns a UCI list option's values
# space-joined on one line -- the same shape `uci get` itself prints -- so
# splitting it with a plain for loop needs no extra parsing.
# scrub.list plus gitbackup.security.scrub_option (space-joined UCI list).
_gb_scrub_load_patterns() {
if [ -r "$GB_SCRUB_LIST" ]; then
while IFS= read -r _gb_l || [ -n "$_gb_l" ]; do
_gb_scrub_parse_pattern "$_gb_l"
done <"$GB_SCRUB_LIST"
fi
# set -f: same reasoning as _gb_scrub_parse_pattern's own -- a pattern's
# "[*]" is a valid glob bracket expression, so splitting this on words
# must not also pathname-expand it.
# noglob: "[*]" is a valid glob bracket expression.
# shellcheck disable=SC2046 # word-splitting is the point: one pattern per word
set -f
for _gb_p in $(gb_uci_get gitbackup.security.scrub_option); do
@@ -188,18 +114,11 @@ _gb_scrub_load_patterns() {
set +f
}
# _gb_scrub_parse_pattern <line> -- parses one "<config>.@<type>[*].<option>"
# line (comment and surrounding blanks already allowed in <line>) and prints
# "<config> <type> <option>", or nothing for a blank/comment-only line, or a
# warning (never a partial/guessed result) for anything else.
# "<config>.@<type>[*].<option>" -> "<config> <type> <option>"; anything
# unrecognized warns, never a guessed result.
_gb_scrub_parse_pattern() {
_gb_raw="${1%%#*}"
# set -f: a pattern's own "[*]" wildcard marker is a syntactically valid
# glob bracket expression (matches one literal "*"), so plain word
# splitting here could also pathname-expand against the working
# directory -- the exact live bug _gb_scrub_hard_exclude's own comment
# describes, guarded against here for the same reason even though no
# fixture has hit it yet.
# noglob: "[*]" would otherwise pathname-expand.
set -f
# shellcheck disable=SC2086 # word-splitting trims surrounding blanks; a pattern itself has none
set -- $_gb_raw
@@ -222,91 +141,7 @@ _gb_scrub_parse_pattern() {
esac
}
# _gb_scrub_hard_exclude <outdir> <scratch> -- removes exclude.list's
# public-only lines (every "#public: <pattern>" line -- a plain comment to
# collect.sh, see exclude.list's own header on why) from outdir/files,
# recording each removed absolute path (one per line, matching
# manifest.json's own path format) to scratch/removed so
# _gb_scrub_write_manifest drops it from entries[] too. Same glob rules as
# collect.sh's own hard-exclude ("/**" suffix = itself and everything under
# it, else a plain case/shell glob) -- reimplemented here rather than
# calling collect.sh's private _gb_collect_is_excluded, which is not this
# module's to reach into (see this file's header on cross-module coupling).
_gb_scrub_hard_exclude() {
_gb_outdir="$1"
_gb_scratch="$2"
_gb_files="$_gb_outdir/files"
[ -r "$GB_EXCLUDE_LIST" ] || return 0
while IFS= read -r _gb_pat || [ -n "$_gb_pat" ]; do
case "$_gb_pat" in
'#public:'*) _gb_pat="${_gb_pat#'#public:'}" ;;
*) continue ;;
esac
# set -f around this: found live on the owlab stand -- with globbing
# on, this bare `set --` doesn't just trim the mandatory space after
# "#public:", it pathname-expands the pattern against the CURRENT
# working directory right then, and an absolute pattern like
# "/etc/dropbear/*_host_key*" glob-matches the ROUTER'S OWN real
# /etc/dropbear -- not outdir/files -- silently replacing the
# pattern with whatever real host keys exist on the box running the
# test. The intended glob (against "$_gb_files$_gb_pat" below) never
# ran on the right string, and the real host key was never removed.
set -f
# shellcheck disable=SC2086
set -- $_gb_pat
set +f
_gb_pat="${1:-}"
[ -n "$_gb_pat" ] || continue
case "$_gb_pat" in
*/'**')
_gb_prefix=${_gb_pat%/\*\*}
find "$_gb_files$_gb_prefix" -mindepth 0 2>/dev/null | \
while IFS= read -r _gb_found; do
_gb_relpath="${_gb_found#"$_gb_files"}"
printf '%s\n' "$_gb_relpath" >>"$_gb_scratch/removed"
done
rm -rf "${_gb_files:?}$_gb_prefix"
;;
*)
# Unquoted glob on purpose: real filesystem expansion against
# outdir/files is exactly what a hard-exclude pattern like
# "/etc/dropbear/*_host_key*" needs. No match leaves the
# literal pattern string, which -e then rejects -- ash and
# every POSIX sh here run with nullglob off, same as a bare
# `for f in *.nosuchext` on an empty match.
# shellcheck disable=SC2086,SC2231
for _gb_found in "$_gb_files"$_gb_pat; do
[ -e "$_gb_found" ] || continue
_gb_relpath="${_gb_found#"$_gb_files"}"
printf '%s\n' "$_gb_relpath" >>"$_gb_scratch/removed"
rm -f "$_gb_found"
done
;;
esac
done <"$GB_EXCLUDE_LIST"
}
# _gb_scrub_join -- reads raw (unindented, comma-free) object-body lines on
# stdin and writes them 4-space-indented and comma-joined (no trailing comma
# on the last one), the exact array-body shape collect.sh's own
# _gb_collect_join produces -- reimplemented locally rather than called
# (collect.sh's version is private to it, see this file's header). Empty
# input produces empty output, matching what collect.sh itself writes for an
# empty array.
_gb_scrub_join() {
_gb_first=1
while IFS= read -r _gb_b || [ -n "$_gb_b" ]; do
[ -n "$_gb_b" ] || continue
if [ "$_gb_first" -eq 1 ]; then _gb_first=0; else printf ',\n'; fi
printf ' %s' "$_gb_b"
done
[ "$_gb_first" -eq 1 ] || printf '\n'
}
# _gb_scrub_records_json <records> -- <records> is "<path>\t<option>" lines;
# prints one {"path":...,"option":...} object per line. No value and no hash
# ever appears here (spec: "значений в нём нет") -- only the two fields the
# records file itself carries.
# Never a value or hash in the manifest.
_gb_scrub_records_json() {
[ -s "$1" ] || return 0
while IFS=' ' read -r _gb_rp _gb_ro || [ -n "$_gb_rp" ]; do
@@ -315,80 +150,22 @@ _gb_scrub_records_json() {
done <"$1"
}
# _gb_scrub_write_manifest <manifest.json> <records> <rehash> <removed>
#
# Line-oriented, same technique collect.sh's own gb_manifest_equal already
# relies on (gb_collect emits exactly one JSON object per array item, no
# nested newlines) -- this is editing gitbackup's OWN manifest format, not
# UCI config text, so the "no sed on multi-line list" rule this module
# exists for does not apply to it. Rewrites entries[] (dropping the paths in
# <removed>, re-hashing the ones in <rehash>) and scrubbed[] (built fresh
# from <records>) in one pass; everything else in the file is copied
# unchanged.
# _gb_scrub_write_manifest <manifest.json> <records> <rehash> -- relies on our
# own manifest having one entry per line.
_gb_scrub_write_manifest() {
_gb_manifest="$1"
_gb_records="$2"
_gb_rehash="$3"
_gb_removed="$4"
_gb_tmp="$_gb_manifest.tmp.$$"
_gb_ent="$_gb_manifest.entries.$$"
: >"$_gb_tmp"
: >"$_gb_ent"
_gb_in_entries=0
_gb_in_scrubbed=0
while IFS= read -r _gb_line || [ -n "$_gb_line" ]; do
if [ "$_gb_in_entries" -eq 1 ]; then
case "$_gb_line" in
' ],')
_gb_scrub_join <"$_gb_ent" >>"$_gb_tmp"
printf '%s\n' "$_gb_line" >>"$_gb_tmp"
_gb_in_entries=0
;;
*)
# Strip the trailing "," (absent only on the last item)
# and the fixed 4-space indent collect.sh always writes,
# so _gb_scrub_join can re-add both consistently once the
# surviving set is known.
_gb_obj="${_gb_line%,}"
_gb_obj="${_gb_obj# }"
_gb_p=$(gb_manifest_field "$_gb_obj" path)
if [ -s "$_gb_removed" ] && grep -qxF "$_gb_p" "$_gb_removed"; then
: # dropped -- hard-excluded, no longer in the tree
else
_gb_newsha=''
if [ -s "$_gb_rehash" ]; then
_gb_newsha=$(awk -F'\t' -v p="$_gb_p" '$1==p{print $2; exit}' "$_gb_rehash")
fi
if [ -n "$_gb_newsha" ]; then
_gb_obj=$(printf '%s' "$_gb_obj" | sed 's/"sha256":"[^"]*"/"sha256":"'"$_gb_newsha"'"/')
fi
printf '%s\n' "$_gb_obj" >>"$_gb_ent"
fi
;;
esac
continue
fi
case "$_gb_line" in
' "entries": ['*)
printf '%s\n' "$_gb_line" >>"$_gb_tmp"
_gb_in_entries=1
continue
;;
' "scrubbed": ['*)
printf '%s\n' "$_gb_line" >>"$_gb_tmp"
_gb_scrub_records_json "$_gb_records" | _gb_scrub_join >>"$_gb_tmp"
_gb_in_scrubbed=1
continue
;;
' ]')
[ "$_gb_in_scrubbed" -eq 1 ] && _gb_in_scrubbed=0
;;
esac
printf '%s\n' "$_gb_line" >>"$_gb_tmp"
done <"$_gb_manifest"
rm -f "$_gb_ent"
mv "$_gb_tmp" "$_gb_manifest"
_gb_tmp="$1.tmp.$$"
_gb_scrub_records_json "$2" | gb_manifest_join >"$_gb_tmp.scrubbed"
cp "$1" "$_gb_tmp"
while IFS=' ' read -r _gb_rp _gb_rs || [ -n "$_gb_rp" ]; do
[ -n "$_gb_rp" ] || continue
_gb_needle="\"path\":$(gb_json_str "$_gb_rp"),"
awk -v n="$_gb_needle" -v sha="$_gb_rs" '
index($0, n) { sub(/"sha256":"[^"]*"/, "\"sha256\":\"" sha "\"") } { print }
' "$_gb_tmp" >"$_gb_tmp.new" && mv "$_gb_tmp.new" "$_gb_tmp"
done <"$3"
awk -v f="$_gb_tmp.scrubbed" '
{ print }
/^ "scrubbed": \[/ { while ((getline l < f) > 0) print l }
' "$_gb_tmp" >"$1"
rm -f "$_gb_tmp" "$_gb_tmp.scrubbed"
}
@@ -1,39 +1,17 @@
# shellcheck shell=sh
#
# gitbackup -- the visibility gate (spec "Гейт видимости").
#
# There is no encryption anywhere in this project, so this is the only thing
# standing between a public repository and the root password hash, private
# dropbear host keys, WPA PSK and WireGuard keys it would carry. A public
# repository is exit 4 with no way to override it -- not a warning.
#
# Sourced, never executed: nothing here runs at load time. gb_visibility_ok
# calls gb_uci_get, gb_log, gb_die and gb_json_bool (lib.sh) and gb_parse_url/
# gb_provider (remoteurl.sh), so a caller must source both first, same
# convention as device.sh.
#
# GB_STATE_DIR overrides the cache directory (default /var/run/gitbackup),
# the same variable usr/sbin/gitbackup already uses -- tests point it at a
# tmp directory, the router never sets it.
# gitbackup -- the visibility gate: gb_visibility_ok. With no encryption, this
# is all that keeps /etc/shadow, host keys and PSKs out of a public repo:
# public is exit 4 with no override. Needs lib.sh and remoteurl.sh.
# gb_visibility_ok <url>
#
# Anonymously asks the provider's API whether <url>'s repository is visible
# to a passer-by, and returns:
# 0 not visible anonymously (404) or the operator explicitly accepted the
# risk for a provider that cannot be checked at all -- proceed.
# 3 inconclusive: the network is down, the API answered 5xx, or answered
# with an HTTP status this gate does not recognize. Deliberately NOT 0:
# a caller that cannot tell "verified private" from "could not check"
# apart would push on a repository nobody actually confirmed is closed
# to the world. Reuses the shared network/auth exit code (interfaces.md,
# "Коды выхода") rather than inventing a fifth one.
# 4 visible anonymously (HTTP 200) -- refused, unconditionally.
# Dies with exit 2 -- invalid configuration, same as every other config
# check in this package -- when <url> does not parse, or when the remote is
# provider=generic without gitbackup.origin.acknowledged=1 (nothing there
# can ever be checked, so silence would mean "assume private" on zero
# evidence, exactly the failure mode this whole gate exists to prevent).
# gb_visibility_ok <url> -- anonymous provider API probe:
# 0 not visible anonymously (404) -- proceed.
# 3 inconclusive (offline, 5xx, unknown status). Never 0: "could not
# check" must not be treated as "private".
# 4 visible anonymously (200) -- refused.
# Dies 2 on an unparsable URL, or on provider=generic without
# gitbackup.origin.acknowledged=1 (nothing to check; silence is not proof).
# Definite answers are cached for 24h.
gb_visibility_ok() {
_gb_url="$1"
_gb_parsed=$(gb_parse_url "$_gb_url") ||
@@ -86,9 +64,7 @@ gb_visibility_ok() {
_gb_result=0
;;
*)
# Inconclusive: never cache a guess, so the very next call (this run
# or the next) tries the network again instead of trusting a stale
# "could not tell" for a whole day.
# Inconclusive: never cached, so the next call retries.
return 3
;;
esac
@@ -98,15 +74,8 @@ gb_visibility_ok() {
return "$_gb_result"
}
# _gb_visibility_api_url <provider> <scheme> <host> <port> <owner> <repo>
#
# The anonymous, unauthenticated read-only endpoint each provider answers
# with 200 (visible) or 404 (not visible/does not exist) for -- spec
# "Гейт видимости" and "Проверенные факты 25.12.4 → Провайдеры", each row
# measured against the real API, not guessed. Always https: git access may
# be over ssh, but every one of these platforms serves its API over https on
# the same host. Returns 1 for a provider with no known API (generic, or
# anything gitbackup.origin.provider names that this gate does not recognize).
# Anonymous endpoint answering 200 (visible) / 404 (not); https even for ssh
# remotes. Returns 1 for a provider with no known API.
_gb_visibility_api_url() {
case "$1" in
github)
@@ -127,14 +96,7 @@ _gb_visibility_api_url() {
esac
}
# _gb_hostport_for_api <scheme> <host> <port> -- host[:port] for the same
# server's HTTPS API.
#
# Only a port that was itself already on an https:// remote is trustworthy
# here. A scp-like or ssh:// remote's port is dropbear/openssh's, which a
# self-hosted Gitea/Forgejo/GitLab install commonly runs on a different port
# than its web UI (e.g. ssh on 2222, https on the default 443) -- carrying
# it over would probe the wrong port.
# Keeps the port only from an https remote; an ssh port is not the API port.
_gb_hostport_for_api() {
if [ "$1" = https ] && [ "${3:-0}" != 0 ]; then
printf '%s:%s\n' "$2" "$3"
@@ -143,18 +105,9 @@ _gb_hostport_for_api() {
fi
}
# _gb_visibility_probe <api-url>
#
# Returns 0 when the repository is visible anonymously (HTTP 200), 1 when it
# is not (HTTP 404 -- private and nonexistent are the same answer here on
# purpose, spec: "приватный анонимно неотличим от несуществующего"), 2 for
# anything else: another HTTP status, a timeout, a refused connection, a TLS
# failure, or offline. -q is deliberately not passed to uclient-fetch: the
# only place it prints "HTTP error <code>" is to stderr, and only when not
# quiet -- verified against uclient/uclient-fetch.c (header_done_cb's default
# case; net/error branches for connection/timeout/TLS return non-8 codes and
# are folded into the same "inconclusive" outcome here as any other network
# failure).
# 0 on 200, 1 on 404 (private and nonexistent look the same), 2 otherwise.
# No -q: uclient-fetch prints "HTTP error <code>" only when not quiet, and
# exit 8 alone does not tell 404 from other statuses.
_gb_visibility_probe() {
_gb_probe_msg=$(uclient-fetch --timeout=10 -O /dev/null "$1" 2>&1 >/dev/null)
_gb_probe_rc=$?
-29
View File
@@ -1,29 +0,0 @@
# This file exists only so `owlab build` can find package/gitbackup/Makefile --
# `owlab build` runs the OpenWrt SDK, which is NOT how gitbackup is actually released
# (see /owfeed.yml and /tools/stage.sh: `owfeed build` is the primary, fast path for a
# noarch package with nothing to compile). This one stays because the Makefile is still
# a valid, if slower, way for anyone to build the package themselves.
#
# owlab's build command looks for an OpenWrt package Makefile in its config
# directory and that directory's IMMEDIATE children only (read from source:
# owfeed/owlab/cmd/owlab/build.go, findPackage) -- it does not walk two
# levels down from a repo root the way the real OpenWrt SDK's own feed
# scanner does. The repository's own top-level owlab.yaml is what
# `owlab up`/`sync`/`test`/`exec` use for the live router; owlab.yaml
# discovery walks upward from the working directory and stops at the FIRST
# file it finds, so this one only has to exist here to be picked up before
# that search ever reaches the repository root. It repeats the release the
# top-level file tests against rather than deferring to it, because there is
# no way to defer: discovery does not chain two config files together.
#
# cd package/gitbackup && owlab build --release 25.12.4
version: 1
project:
name: gitbackup
routers:
- id: owrt2512
release: "25.12.4"
ports: { http: 8029, ssh: 2230 }