⛄ Sync 2026-09-17 23:44:37

This commit is contained in:
github-actions[bot] committed 2026-09-17 23:44:37 +08:00
1 parent 2141bfe460
commit 008ae19fb9
33 files changed
+13396

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
#
# Copyright (C) 2026 VizzleTF
#
# This is free software, licensed under the GNU General Public License v2.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=gitbackup
PKG_VERSION:=0.1.1
PKG_RELEASE:=1
PKG_LICENSE:=GPL-2.0-only
PKG_MAINTAINER:=VizzleTF <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))
+26
View File
@@ -0,0 +1,26 @@
config gitbackup 'main'
option enabled '0'
option archive '1'
option schedule 'daily'
option cron_expr ''
option on_config_change '1'
option debounce '60'
option device_id 'hostname'
option device ''
option path_prefix 'devices/{device}'
config remote 'origin'
option url ''
option branch 'device/{device}'
option auth 'sshkey'
option key_file '/etc/gitbackup/id_ed25519'
option token_file '/etc/gitbackup/token'
option ca_file ''
option provider 'auto'
option visibility 'private'
option acknowledged '0'
config security 'security'
option scrub '0'
list scrub_option 'wireless.@wifi-iface[*].key'
+138
View File
@@ -0,0 +1,138 @@
#!/bin/sh /etc/rc.common
# shellcheck shell=sh
# shellcheck disable=SC2034 # START/USE_PROCD/EXTRA_COMMANDS are read by rc.common, not this file
#
# gitbackup runs from cron, not as a daemon, so there is no process for
# start_service to supervise. This file's job is: (1) apply
# gitbackup.main.schedule to /etc/crontabs/root and make crond pick it up
# (reload_service, ticket 06), and (2) watch the packages actually in the
# backup set for config.change and turn a burst of edits into one debounced
# `gitbackup run` (service_triggers/config_change, spec R106/R107).
#
# GB_SHARE/GB_STATE_DIR/GB_BIN are overridable only so tests/run.sh can
# source this file directly (rc.common's own dispatch is not exercised by
# the unit tests -- see this file's own functions being called straight
# from a test body) against a tree, state directory and gitbackup binary
# that are not the router's real ones; the router never sets any of them.
GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}"
GB_STATE_DIR="${GB_STATE_DIR:-/var/run/gitbackup}"
GB_BIN="${GB_BIN:-/usr/sbin/gitbackup}"
# shellcheck disable=SC1091 # GB_SHARE is a runtime path, not resolvable statically
. "$GB_SHARE/lib.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/device.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/schedule.sh"
START=60
USE_PROCD=1
# config_change is not one of rc.common's built-in actions (start/stop/
# reload/...); EXTRA_COMMANDS is rc.common's own mechanism for adding one
# (it appends this to ALL_COMMANDS right after sourcing this file -- see
# /etc/rc.common on the stand), which is what lets procd's run_script
# target `/etc/init.d/gitbackup config_change` actually dispatch anywhere.
EXTRA_COMMANDS="config_change"
EXTRA_HELP=" config_change Internal: debounced trigger used by service_triggers, not for interactive use"
start_service() {
reload_service
}
stop_service() {
:
}
# reload_service -- (re)applies gitbackup.main.schedule/cron_expr to the
# crontab and makes crond pick it up. Runs on `/etc/init.d/gitbackup
# reload|restart|start` and, through procd_add_reload_trigger below,
# whenever gitbackup's own UCI config changes (e.g. a Settings save in
# LuCI) -- so flipping schedule from daily to weekly takes effect without a
# manual reload.
reload_service() {
gb_cron_apply
}
# _gb_backed_up_packages -- private. The set of UCI packages the config-
# change trigger below watches: every /etc/config/<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.
_gb_backed_up_packages() {
sysupgrade -l 2>/dev/null | sed -n 's#^/etc/config/##p' | sort -u | grep -v '^gitbackup$'
}
# config_change -- procd's run_script target for a backed-up package's
# config.change event, registered once per package in service_triggers
# below. Does not run a backup on the spot: a save in LuCI can touch
# several packages (e.g. network + firewall together) or the same one
# repeatedly, and each touch fires this again -- so this only (re)arms a
# single delayed `gitbackup run` after $debounce seconds, cancelling
# whatever the previous call armed first. A burst of edits therefore
# collapses into exactly one run (spec R106/R107), not one per edit.
#
# busybox ash's arithmetic (and `sleep`) only does whole seconds --
# `sleep 0.3` is "invalid number", measured on the stand -- hence the
# integer-only guard on $debounce below rather than trusting it unchecked.
#
# Killing the previous timer's PID only stops that subshell; if it had
# already progressed past `sleep` (a race that needs the previous debounce
# window to have fully elapsed a moment before this call, and is why a
# closer-together burst is exactly what this exists to collapse), the
# short-lived orphaned `sleep` left behind exits on its own and does
# nothing further -- rm and run only happen after it, in the same
# subshell, and that subshell is what got killed.
config_change() {
_gb_debounce=$(gb_uci_get gitbackup.main.debounce 60)
case "$_gb_debounce" in ''|*[!0-9]*) _gb_debounce=60 ;; esac
mkdir -p "$GB_STATE_DIR"
_gb_pidfile="$GB_STATE_DIR/debounce.pid"
# Review finding, ticket 06: killing the PID from the pidfile blind is
# fine right after this same router wrote it, but a long-uptime router
# can wrap PIDs, and by the time the NEXT config.change fires that same
# number may belong to an unrelated process this has no business
# signaling. `kill -0` (send no signal, just ask whether the PID
# exists) is the cheap half of that check -- it does not prove the PID
# is still OUR sleep, only that it is not free for the kernel to have
# reused by something else; reused PIDs are strictly the OS's own
# concern here. Deliberately `kill -0`, not a `/proc/<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
fi
( sleep "$_gb_debounce"; rm -f "$_gb_pidfile"; "$GB_BIN" run --trigger procd >/dev/null 2>&1 ) &
echo $! >"$_gb_pidfile"
}
service_triggers() {
procd_add_reload_trigger "gitbackup"
# R106/R107 both live under one on/off switch: a router that wants
# gitbackup to run strictly on its own schedule can turn the
# config-change trigger off without touching the watched packages.
[ "$(gb_uci_get gitbackup.main.on_config_change 1)" = 1 ] || return 0
# Same path resolution procd.sh's own _procd_add_reload_trigger uses
# (readlink, falling back to $initscript itself when it is not a
# symlink) -- $initscript is rc.common's own variable (set to "$1"
# before this file is sourced, see /etc/rc.common on the stand), not
# invented here; shellcheck cannot see that external assignment.
# shellcheck disable=SC2154
_gb_script=$(readlink "$initscript")
_gb_name=$(basename "${_gb_script:-$initscript}")
for _gb_pkg in $(_gb_backed_up_packages); do
procd_add_config_trigger "config.change" "$_gb_pkg" /etc/init.d/"$_gb_name" config_change
done
}
@@ -0,0 +1,18 @@
# shellcheck shell=sh
#
# gitbackup: create the secrets directory once, at first boot after install.
#
# Not done in postinst -- postinst runs while an image is being built, where
# there may be neither entropy nor a real, writable /etc yet, and the spec is
# explicit that installing the package must not run a backup or generate keys.
#
# /etc/init.d/boot runs every /etc/uci-defaults/* script as
# `( . "./$(basename $file)" )` and deletes it afterwards only if that exited
# 0, so this file has no shebang and is not executable -- same convention as
# base-files' own uci-defaults scripts (verified in the local checkout).
[ -d /etc/gitbackup ] || mkdir -m 0700 /etc/gitbackup
chown root:root /etc/gitbackup
chmod 0700 /etc/gitbackup
exit 0
+678
View File
@@ -0,0 +1,678 @@
#!/bin/sh
# shellcheck shell=sh
#
# luci.gitbackup -- rpcd plugin, ubus object "luci.gitbackup" (spec "rpcd и
# ACL"). Everything the CLI (usr/sbin/gitbackup) can do becomes reachable
# from the web here, and NOTHING ELSE ever runs: this file's own job is
# translating between the rpcd "list"/"call" protocol (TechRef:
# https://openwrt.org/docs/techref/rpcd) and a `gitbackup` CLI invocation --
# never `git`, `sysupgrade`, or any other router command directly.
#
# Ticket 21 (R110/R111/R109, D07 -- the operator's own words: "вызывать под
# капотом может любые команды роутера. желательно только gitbackup"). Before
# this ticket, gbrpc_history/gbrpc_diff/gbrpc_list_paths/gbrpc_audit_paths
# below ran `git ls-remote`/`git fetch`/`git log`/`git diff`/`git diff-tree`/
# `sysupgrade -l` themselves, straight against gitbackup.origin.url out of
# UCI -- WITHOUT ever running it through remoteurl.sh's own gb_parse_url
# first, which is the one validator this package already had for exactly
# this string (ticket 03). `gitbackup` itself always has validated its own
# GB_URL before spawning git (cmd_test/cmd_run already did; cmd_diff/
# cmd_restore now do too, ticket 21) -- so routing every one of these
# through the CLI closes that gap for free, rather than teaching this file
# a second copy of the same check. Where the CLI had no equivalent yet
# (the branch's commit list, a diff between two arbitrary commits), ticket
# 21 added `gitbackup history`/`gitbackup diff <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.
set -u
GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}"
GB_STATE_DIR="${GB_STATE_DIR:-/var/run/gitbackup}"
GB_BIN="${GB_BIN:-/usr/sbin/gitbackup}"
GB_SYSUPGRADE_CONF="${GB_SYSUPGRADE_CONF:-/etc/sysupgrade.conf}"
# shellcheck disable=SC1091 # GB_SHARE is a runtime path, not resolvable statically
. "$GB_SHARE/lib.sh"
# device.sh (gb_device_id/gb_expand) and auth.sh (gb_git_env, the one `eval
# "$(...)"` this package's whole codebase has ever needed) are deliberately
# NOT sourced here any more (ticket 21): both were only ever used by the
# git-calling helpers this file no longer has. `gitbackup` itself still
# sources both to do that work -- this file just never touches either
# again, which is the strongest available answer to "can this eval be
# removed": it is gone from this process entirely, not merely justified.
# shellcheck disable=SC1091
. "$GB_SHARE/schedule.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/paths.sh"
# card.sh (ticket 09) may not exist on a system built before it landed --
# same "command -v gb_card" tolerance usr/sbin/gitbackup's own cmd_run
# already uses, not a new convention invented here.
if [ -r "$GB_SHARE/card.sh" ]; then
# shellcheck disable=SC1091
. "$GB_SHARE/card.sh"
fi
# ---------------------------------------------------------------------------
# JSON param helpers -- jsonfilter, not jshn.sh: interfaces.md only promises
# "jsonfilter есть, ubus есть", jshn.sh is a libubox implementation detail
# this package's own DEPENDS never asked for, and the rest of gitbackup
# already hand-rolls JSON output (lib.sh's gb_json_str/gb_json_esc) instead
# of pulling in a second writer -- reusing that here keeps one JSON style
# in the whole package instead of two.
# ---------------------------------------------------------------------------
# _gb_rpc_param <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 ---------------------------------------------------------
gbrpc_status() {
_gb_rpc_out=$("$GB_BIN" status 2>/dev/null)
if [ -n "$_gb_rpc_out" ]; then
printf '%s\n' "$_gb_rpc_out"
else
_gb_rpc_error 'gitbackup status produced no output'
fi
}
gbrpc_log() {
_gb_rpc_lines=$(_gb_rpc_param "$1" '@.lines')
case "$_gb_rpc_lines" in '' | *[!0-9]*) _gb_rpc_lines=200 ;; esac
_gb_rpc_text text "$("$GB_BIN" log "$_gb_rpc_lines" 2>/dev/null)"
}
gbrpc_pubkey() {
_gb_rpc_out=$("$GB_BIN" pubkey 2>&1)
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -eq 0 ]; then
_gb_rpc_text pubkey "$_gb_rpc_out"
else
_gb_rpc_error "$_gb_rpc_out"
fi
}
# gbrpc_list_paths -- three distinct sets, on purpose (the read/write
# asymmetry this ticket exists to close): "paths" is the RAW, unexpanded
# content of GB_SYSUPGRADE_CONF (paths.sh's own gb_paths_list) -- exactly
# what the file contains, comments and blank lines included. "entries"
# (ticket 26) is gb_paths_entries -- the same content with every comment
# and blank line dropped, i.e. only genuine path entries, a directory line
# still included verbatim -- what the LuCI view lists and edits, and the
# only shape gbrpc_set_paths below may ever write back; a stock
# /etc/sysupgrade.conf's own header comments and commented-out example
# lines are not entries and must never render as a removable row. "effective"
# is plain `sysupgrade -l`, the full union spec §sysupgrade names
# (GB_SYSUPGRADE_CONF ∪ /lib/upgrade/keep.d/* ∪ changed conffiles) already
# recursively expanded to individual files/symlinks. Read-only: it exists
# for sizing (gb_paths_size_kb's own walk) and so a caller can show what is
# already covered without the operator having added it. Collapsing "paths"
# and "effective" into one field (the previous shape of this method) is
# exactly what let a directory a human added survive only until the next
# `set_paths` call, which would write the already-expanded file list
# straight back to disk and permanently lose the "anything added to this
# directory later is covered too" property a real sysupgrade gives a
# directory line.
#
# Ticket 21: used to build both halves itself, calling `sysupgrade -l`
# directly -- the one external command this method ever needed, and the
# one thing "только gitbackup" says this file should never do again. The
# CLI already builds exactly this shape (usr/sbin/gitbackup's own
# `paths list --json`, ticket 21) for the same reason `config_diff` below
# has always wrapped `gitbackup diff` instead of re-deriving it here.
gbrpc_list_paths() {
_gb_rpc_out=$("$GB_BIN" paths list --json 2>&1)
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -eq 0 ]; then
printf '%s\n' "$_gb_rpc_out"
else
_gb_rpc_error "$_gb_rpc_out"
fi
}
# gbrpc_validate_cron -- live validation for the Settings raw-cron field
# (spec "живая валидацию в Settings"): reuses schedule.sh's own
# gb_cron_valid/gb_cron_next rather than reimplementing either, so the
# rpcd-facing answer and the crontab that gb_cron_apply actually writes can
# never disagree about what is valid.
gbrpc_validate_cron() {
_gb_rpc_expr=$(_gb_rpc_param "$1" '@.expr')
# gb_cron_valid prints its reason on stderr only on failure -- captured
# unconditionally here (empty when the expression is fine) so both
# branches below read from the one variable instead of calling it a
# third time.
_gb_rpc_why=$(gb_cron_valid "$_gb_rpc_expr" 2>&1 >/dev/null)
if gb_cron_valid "$_gb_rpc_expr" >/dev/null 2>&1; then
_gb_rpc_next=$(gb_cron_next "$_gb_rpc_expr" 2>/dev/null)
printf '{ "valid": true, "next": %s }\n' "$(gb_json_str "$_gb_rpc_next")"
else
printf '{ "valid": false, "reason": %s }\n' "$(gb_json_str "$_gb_rpc_why")"
fi
}
# gbrpc_history -- last N commits of this device's own branch: sha,
# subject (which already starts with the backup's own date, spec G03.1, so
# there is no separate "date" field to keep in sync with it) and the list
# of /etc/config/*-and-everything-else paths that commit actually touched.
#
# Ticket 21: this used to resolve GB_URL out of UCI and fetch/log/diff-tree
# it itself, WITHOUT ever validating its shape through gb_parse_url first
# -- exactly the bypass the ticket exists to close (the operator's own
# words: "вызывать под капотом может любые команды роутера. желательно
# только gitbackup"). `gitbackup history` (usr/sbin/gitbackup, ticket 21)
# now does the exact same fetch/log/diff-tree work, but only after running
# GB_URL through gb_parse_url the same way cmd_test/cmd_run always have --
# this wrapper only ever forwards --limit and relays whatever the CLI
# printed (success: its own valid JSON body; failure: its stderr, wrapped
# into the shared { "reason": ... } shape every other method here uses).
gbrpc_history() {
_gb_rpc_limit=$(_gb_rpc_param "$1" '@.limit')
case "$_gb_rpc_limit" in '' | *[!0-9]*) _gb_rpc_limit=20 ;; esac
_gb_rpc_out=$("$GB_BIN" history --limit "$_gb_rpc_limit" 2>&1)
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -eq 0 ]; then
printf '%s\n' "$_gb_rpc_out"
else
_gb_rpc_error "$_gb_rpc_out"
fi
}
# gbrpc_diff -- plain `git diff <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.
gbrpc_diff() {
_gb_rpc_from=$(_gb_rpc_param "$1" '@.from')
_gb_rpc_to=$(_gb_rpc_param "$1" '@.to')
if [ -z "$_gb_rpc_from" ] || [ -z "$_gb_rpc_to" ]; then
_gb_rpc_error 'both "from" and "to" commit shas are required'
return 0
fi
_gb_rpc_out=$("$GB_BIN" diff "$_gb_rpc_from" "$_gb_rpc_to" 2>&1)
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -ne 0 ]; then
_gb_rpc_error "$_gb_rpc_out"
return 0
fi
_gb_rpc_text diff "$_gb_rpc_out"
}
# gbrpc_config_diff -- "has the LIVE config drifted from the last commit",
# which the two-sha `diff` above cannot answer (that one compares two
# already-committed shas, ticket 14/R124 History). This wraps `gitbackup
# diff` (usr/sbin/gitbackup's cmd_diff, ticket 17) instead: it re-collects
# the current backup set and compares its manifest against the manifest of
# the last commit pushed to this device's own branch, field by field --
# catching a chmod/chown that `git diff`/`git status` never sees at all
# (same blob, same tree entry mode class). No params; `gitbackup diff`
# itself resolves the branch/prefix from UCI exactly like `run` does.
#
# `gitbackup diff` prints nothing at all when there is no difference (its
# own documented contract) and returns 0 either way once the comparison
# ran; a nonzero exit only ever means it could not even get as far as
# comparing (gb_die: bad config, exit 2; unreachable remote, exit 3) --
# turned into the same { "reason": ... } shape every other method here
# uses, never a bare exit code, so the overview indicator can tell "no
# drift" apart from "could not check" instead of defaulting either one to
# "all saved".
gbrpc_config_diff() {
_gb_rpc_out=$("$GB_BIN" diff 2>&1)
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -ne 0 ]; then
_gb_rpc_error "$_gb_rpc_out"
return 0
fi
if [ -n "$_gb_rpc_out" ]; then
printf '{ "differs": true, "text": %s }\n' "$(gb_json_str "$_gb_rpc_out")"
else
printf '{ "differs": false, "text": "" }\n'
fi
}
# gbrpc_audit_paths -- G02: what changed on this router since the base
# image but is NOT in the backup set. /overlay/upper is the standard
# OpenWrt overlayfs upper layer (everything the running system has written
# relative to the read-only squashfs lower layer); anything under it that
# is neither hard-excluded (/etc/gitbackup/**, this package's own secrets)
# nor already named by `sysupgrade -l` is exactly "changed and not backed
# up".
#
# Ticket 21: this used to walk /overlay/upper and call `sysupgrade -l`
# itself -- no user-controlled input reaches it (no params at all), so it
# was never the injection risk history/diff/list_paths were, but it is
# still an external command run directly by this file instead of by
# `gitbackup`, which "только gitbackup" does not carve out an exception
# for. `gitbackup paths audit` (usr/sbin/gitbackup, ticket 21) is the exact
# same walk, moved rather than rewritten -- including the VERIFY below,
# which is about the walk itself, not about which process performs it.
#
# VERIFY: measured only that /overlay/upper does not exist inside the
# owlab package-test container used to develop this file (a plain
# extracted rootfs, never booted through OpenWrt's own fstab/mount_root) --
# not confirmed against a real overlay mount on the owrt2512 stand. Falls
# back to an empty list rather than erroring when the directory is absent,
# so a router (or dev container) without a live overlay mount still gets a
# valid, honestly-empty answer instead of a broken one.
gbrpc_audit_paths() {
_gb_rpc_out=$("$GB_BIN" paths audit 2>&1)
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -eq 0 ]; then
printf '%s\n' "$_gb_rpc_out"
else
_gb_rpc_error "$_gb_rpc_out"
fi
}
# --- write tier ----------------------------------------------------------
# gbrpc_run/gbrpc_test/gbrpc_restore all share one shape: the operation can
# run past any sane ubus call timeout (network waits, `test`'s own
# ConnectTimeout=15 twice over, `run`'s full collect/push cycle), so none
# of them wait for the CLI to finish. Each backgrounds it, detached from
# this script's own stdin/stdout/stderr (`</dev/null >/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'
}
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".
gbrpc_hostkey() {
_gb_rpc_fp=$(_gb_rpc_param "$1" '@.fingerprint')
if [ -n "$_gb_rpc_fp" ]; then
_gb_rpc_out=$("$GB_BIN" hostkey accept "$_gb_rpc_fp" 2>&1)
else
_gb_rpc_out=$("$GB_BIN" hostkey show 2>&1)
fi
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -eq 0 ]; then
printf '%s\n' "$_gb_rpc_out"
else
_gb_rpc_error "$_gb_rpc_out"
fi
}
# gbrpc_keygen -- ticket 25. "force" with no "confirm" (or the wrong one)
# no longer regenerates anything over an existing key: `gitbackup keygen
# --force` now only prints the current key's fingerprint and refuses (exit
# 4, cmd_keygen's own gb_die message) -- exactly the same show-then-name-
# it-back shape gbrpc_hostkey above already uses for the host-key prompt.
# The fingerprint is pulled back out of cmd_keygen's own first stdout line
# ("confirm-required <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.
gbrpc_keygen() {
_gb_rpc_force=$(_gb_rpc_param "$1" '@.force')
_gb_rpc_confirm=$(_gb_rpc_param "$1" '@.confirm')
_gb_rpc_args=''
case "$_gb_rpc_force" in true | 1) _gb_rpc_args='--force' ;; esac
if [ -n "$_gb_rpc_confirm" ]; then
# shellcheck disable=SC2086 # $_gb_rpc_args is empty or exactly one flag token
_gb_rpc_out=$("$GB_BIN" keygen $_gb_rpc_args --confirm "$_gb_rpc_confirm" 2>&1)
else
# shellcheck disable=SC2086 # $_gb_rpc_args is empty or exactly one flag token
_gb_rpc_out=$("$GB_BIN" keygen $_gb_rpc_args 2>&1)
fi
_gb_rpc_rc=$?
if [ "$_gb_rpc_rc" -eq 0 ]; then
printf '{ "ok": true, "message": %s }\n' "$(gb_json_str "$_gb_rpc_out")"
return
fi
if [ "$_gb_rpc_rc" -eq 4 ]; then
_gb_rpc_fp=$(printf '%s\n' "$_gb_rpc_out" | sed -n '1s/^confirm-required //p')
printf '{ "ok": false, "confirm_required": true, "fingerprint": %s, "reason": %s }\n' \
"$(gb_json_str "$_gb_rpc_fp")" "$(gb_json_str "$_gb_rpc_out")"
return
fi
printf '{ "ok": false, "reason": %s }\n' "$(gb_json_str "$_gb_rpc_out")"
}
# gbrpc_set_secret -- writes gitbackup.origin.token_file directly (there is
# no CLI subcommand for this: the CLI never accepts a secret as an
# argument, spec "Токен... никогда в argv", and a web form field is not
# argv, but it is still not going anywhere near UCI -- R109, "/etc/config/*
# входит в sysupgrade -l"). Never logs the value: gb_log calls below name
# only the file path, exactly like gb_keygen's own logging does for the
# key it writes.
#
# An empty value clears the secret rather than writing a lone newline: a
# 1-byte file would still pass cmd_status's own `[ -s "$token_file" ]`
# check and misreport token_set=true for a field the operator just cleared.
gbrpc_set_secret() {
_gb_rpc_value=$(_gb_rpc_param "$1" '@.value')
_gb_rpc_file=$(gb_uci_get gitbackup.origin.token_file /etc/gitbackup/token)
if [ -z "$_gb_rpc_file" ]; then
_gb_rpc_error 'gitbackup.origin.token_file is empty'
return 0
fi
_gb_rpc_dir=$(dirname "$_gb_rpc_file")
mkdir -p "$_gb_rpc_dir" 2>/dev/null
chmod 0700 "$_gb_rpc_dir" 2>/dev/null
if [ -n "$_gb_rpc_value" ]; then
printf '%s\n' "$_gb_rpc_value" >"$_gb_rpc_file"
else
: >"$_gb_rpc_file"
fi
if ! chmod 0600 "$_gb_rpc_file" 2>/dev/null; then
_gb_rpc_error "could not set permissions on $_gb_rpc_file"
return 0
fi
gb_log notice "luci.gitbackup set_secret: wrote $_gb_rpc_file"
_gb_rpc_token_set=false
[ -s "$_gb_rpc_file" ] && _gb_rpc_token_set=true
printf '{ "ok": true, "token_set": %s }\n' "$_gb_rpc_token_set"
}
# gbrpc_set_paths -- writes /etc/sysupgrade.conf, one RAW path per line,
# exactly as given (a directory line included, never expanded -- that
# expansion is sysupgrade's own job at upgrade time, not this method's).
#
# Every entry is validated through paths.sh's own gb_paths_validate --
# the same function the CLI's `gitbackup paths add` already calls -- not a
# second, hand-rolled copy of its rules. Previously only two of its four
# checks (absolute, the fixed blacklist) were duplicated here, leaving a
# space in the path and a nonexistent path to the LuCI view's own JS to
# catch; that was never a safe backstop, because a write straight to flash
# is not something to trust a client's own validation for, and this
# method is not the LuCI view's exclusive caller. Routing through the one
# shared validator also fixes the JS view's own long-flagged gap: it could
# never reliably tell a dangling symlink (a legitimate entry, e.g.
# /etc/resolv.conf) from a genuinely missing path, because no rpcd ACL
# anywhere grants `fs.lstat`. This function runs ON the router, so `-e ||
# -L` (gb_paths_validate's own check) sees the real filesystem and gets it
# right without needing that ACL grant at all.
#
# Rejected entries are reported, not silently dropped (spec: "отклоняются
# с объяснением, а не молча"), but do not abort the whole write -- the
# remaining, valid paths are still saved.
#
# Ticket 26: this used to write the validated entries as the ENTIRE new
# file (`mv "$_gb_rpc_tmp" "$GB_SYSUPGRADE_CONF"`, nothing else). The LuCI
# Paths view only ever sends back gb_paths_entries' own list (comments
# excluded by design, see gbrpc_list_paths's own header above), so that
# used to mean the first `set_paths` call against a stock
# /etc/sysupgrade.conf -- one Add or Remove click anywhere -- silently
# erased its two header-comment lines and its two commented-out example
# lines, because this method never looked at them at all before
# overwriting the file. paths.sh's own gb_paths_replace_entries is the
# fix: it re-reads GB_SYSUPGRADE_CONF's comment/blank lines off disk right
# before the write and keeps them, unconditionally, regardless of what
# this call's "paths" param contained -- so a save from the view can never
# lose a line it was never shown. What still writes back verbatim,
# unexpanded, is exactly the caller's own entries -- ticket 13's decision.
gbrpc_set_paths() {
_gb_rpc_dir=$(dirname "$GB_SYSUPGRADE_CONF")
mkdir -p "$_gb_rpc_dir" 2>/dev/null
_gb_rpc_tmp="${GB_SYSUPGRADE_CONF}.newtmp.$$"
_gb_rpc_kept_f="${_gb_rpc_tmp}.kept"
_gb_rpc_rej_f="${_gb_rpc_tmp}.rej"
: >"$_gb_rpc_tmp"
: >"$_gb_rpc_kept_f"
: >"$_gb_rpc_rej_f"
# One ready-made JSON fragment per line in each scratch file --
# gb_json_str already escapes any real newline in its input (lib.sh),
# so a line-at-a-time read below can never split a value in half. This
# validation loop runs in its own subshell (the pipe from
# _gb_rpc_param_list), which is fine: it only ever writes to these two
# files, never to a variable this function reads back afterward.
_gb_rpc_param_list "$1" '@.paths[*]' | while IFS= read -r _gb_rpc_p || [ -n "$_gb_rpc_p" ]; do
[ -n "$_gb_rpc_p" ] || continue
_gb_rpc_reason=$(gb_paths_validate "$_gb_rpc_p" 2>&1 >/dev/null)
if [ -n "$_gb_rpc_reason" ]; then
printf '{ "path": %s, "reason": %s }\n' \
"$(gb_json_str "$_gb_rpc_p")" "$(gb_json_str "$_gb_rpc_reason")" >>"$_gb_rpc_rej_f"
else
printf '%s\n' "$_gb_rpc_p" >>"$_gb_rpc_tmp"
printf '%s\n' "$(gb_json_str "$_gb_rpc_p")" >>"$_gb_rpc_kept_f"
fi
done
gb_paths_replace_entries "$(cat "$_gb_rpc_tmp" 2>/dev/null)"
rm -f "$_gb_rpc_tmp"
printf '{ "ok": true, "written": ['
_gb_rpc_first=1
while IFS= read -r _gb_rpc_j || [ -n "$_gb_rpc_j" ]; do
[ -n "$_gb_rpc_j" ] || continue
[ "$_gb_rpc_first" -eq 1 ] || printf ','
_gb_rpc_first=0
printf '%s' "$_gb_rpc_j"
done <"$_gb_rpc_kept_f"
printf '], "rejected": ['
_gb_rpc_first=1
while IFS= read -r _gb_rpc_j || [ -n "$_gb_rpc_j" ]; do
[ -n "$_gb_rpc_j" ] || continue
[ "$_gb_rpc_first" -eq 1 ] || printf ','
_gb_rpc_first=0
printf '%s' "$_gb_rpc_j"
done <"$_gb_rpc_rej_f"
printf '] }\n'
rm -f "$_gb_rpc_kept_f" "$_gb_rpc_rej_f"
}
gbrpc_restore() {
_gb_rpc_device=$(_gb_rpc_param "$1" '@.device')
_gb_rpc_commit=$(_gb_rpc_param "$1" '@.commit')
_gb_rpc_dry=$(_gb_rpc_param "$1" '@.dry_run')
_gb_rpc_force=$(_gb_rpc_param "$1" '@.force')
_gb_rpc_pkgs=$(_gb_rpc_param "$1" '@.with_packages')
if [ -z "$_gb_rpc_device" ]; then
_gb_rpc_error 'device is required'
return 0
fi
set -- --device "$_gb_rpc_device" --yes
[ -n "$_gb_rpc_commit" ] && set -- "$@" --commit "$_gb_rpc_commit"
case "$_gb_rpc_dry" in true | 1) set -- "$@" --dry-run ;; esac
case "$_gb_rpc_force" in true | 1) set -- "$@" --force ;; esac
case "$_gb_rpc_pkgs" in true | 1) set -- "$@" --with-packages ;; esac
"$GB_BIN" restore "$@" </dev/null >/dev/null 2>&1 &
printf '{ "started": true }\n'
}
# gbrpc_card -- card.sh (ticket 09) may not exist yet on a system built
# ahead of it landing; the same tolerance usr/sbin/gitbackup's own cmd_run
# already applies, not a stub invented for this file only.
gbrpc_card() {
if ! command -v gb_card >/dev/null 2>&1; then
_gb_rpc_error 'the recovery card generator is not installed on this system yet'
return 0
fi
_gb_rpc_file=$(mktemp "${TMPDIR:-/tmp}/gbrpc-card.XXXXXX") || {
_gb_rpc_error 'could not create a scratch file under /tmp'
return 0
}
if gb_card "$_gb_rpc_file" 2>/dev/null; then
_gb_rpc_text card "$(cat "$_gb_rpc_file")"
else
_gb_rpc_error 'the recovery card could not be generated, see the log'
fi
rm -f "$_gb_rpc_file"
}
# ---------------------------------------------------------------------------
# rpcd entry point
# ---------------------------------------------------------------------------
case "${1:-}" in
list)
cat <<'EOF'
{
"status": {},
"log": { "lines": 0 },
"diff": { "from": "", "to": "" },
"config_diff": {},
"pubkey": {},
"list_paths": {},
"history": { "limit": 0 },
"audit_paths": {},
"validate_cron": { "expr": "" },
"run": {},
"keygen": { "force": true, "confirm": "" },
"test": {},
"hostkey": { "fingerprint": "" },
"set_secret": { "value": "" },
"set_paths": { "paths": [] },
"restore": { "device": "", "commit": "", "dry_run": true, "force": true, "with_packages": true },
"card": {}
}
EOF
;;
call)
# `|| _gb_rpc_input='{}'` here (rather than `|| true`) would silently
# DISCARD a real, fully-read line: confirmed live against the actual
# rpcd daemon (not just a manual `echo ... | plugin call` test,
# which never reproduced this) -- rpcd's own call params arrive on
# stdin with NO trailing newline, so `read -r` reads the whole line
# correctly but still returns nonzero for hitting EOF instead of a
# newline. Every parameterized method (log/diff/history/
# validate_cron/set_secret/set_paths/restore) would otherwise always
# see "{}" and never its real arguments, while a hand-typed
# `printf '...\n' | ... call ...` test -- which DOES add a trailing
# newline -- would keep looking correct throughout development.
IFS= read -r _gb_rpc_input || true
[ -n "$_gb_rpc_input" ] || _gb_rpc_input='{}'
case "${2:-}" in
status) gbrpc_status ;;
log) gbrpc_log "$_gb_rpc_input" ;;
diff) gbrpc_diff "$_gb_rpc_input" ;;
config_diff) gbrpc_config_diff ;;
pubkey) gbrpc_pubkey ;;
list_paths) gbrpc_list_paths ;;
history) gbrpc_history "$_gb_rpc_input" ;;
audit_paths) gbrpc_audit_paths ;;
validate_cron) gbrpc_validate_cron "$_gb_rpc_input" ;;
run) gbrpc_run ;;
keygen) gbrpc_keygen "$_gb_rpc_input" ;;
test) gbrpc_test ;;
hostkey) gbrpc_hostkey "$_gb_rpc_input" ;;
set_secret) gbrpc_set_secret "$_gb_rpc_input" ;;
set_paths) gbrpc_set_paths "$_gb_rpc_input" ;;
restore) gbrpc_restore "$_gb_rpc_input" ;;
card) gbrpc_card ;;
*) _gb_rpc_error "unknown method '${2:-}'" ;;
esac
;;
esac
exit 0
+1604
View File
File diff suppressed because it is too large. Load diff
+69
View File
@@ -0,0 +1,69 @@
#!/bin/sh
# shellcheck shell=sh
#
# gitbackup -- GIT_ASKPASS helper (spec "Аутентификация").
#
# Not sourced like the rest of usr/share/gitbackup/*.sh -- git execs this
# directly (GIT_ASKPASS=/usr/share/gitbackup/askpass.sh, set by
# auth.sh's gb_git_env) once per missing credential component, with the
# prompt text as $1 ("Username for '...': ", "Password for '...': ", the
# exact wording is git's own and varies by version), and reads exactly one
# line back from this script's stdout.
#
# This is the only place gitbackup.origin.token_file is ever read for a
# push: the token reaches git through this pipe, never through argv, a URL,
# or a log line.
#
# The username prompt is answered with a fixed, non-secret placeholder --
# NEVER the token -- and only the password prompt gets the token. This is
# not the obvious design: an earlier version answered both prompts with the
# token (a fine-grained PAT works as either field on GitHub, per its own
# docs), on the reasoning that a value read from a file and only ever
# piped to git's stdin cannot show up in `ps` because it was never a
# command-line argument. That reasoning was wrong, caught only by actually
# tracing a live request on the owlab stand (GIT_TRACE=1): git treats
# whatever the username prompt returns as the credential's username, and
# builds the URL for its *next* prompt -- "Password for
# 'http://<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 -- нет учётных данных на стенде").
set -u
GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}"
# shellcheck disable=SC1091 # GB_SHARE is a runtime path, not resolvable statically
. "$GB_SHARE/lib.sh"
case "${1:-}" in
*sername*)
# Never the token: see this file's header. The exact string is
# arbitrary -- git only requires it to be non-empty -- "gitbackup"
# just makes a stray log line or trace recognizable as this
# package's own request rather than a real account name.
printf 'gitbackup\n'
;;
*)
_gb_token_file=$(gb_uci_get gitbackup.origin.token_file /etc/gitbackup/token)
if [ ! -r "$_gb_token_file" ]; then
gb_log err "askpass: no readable token at $_gb_token_file (gitbackup.origin.token_file)"
exit 1
fi
# head -n 1, not cat: a trailing blank line or a second line in the
# token file (a stray newline from how it was pasted in) must not
# become part of what git sends as the credential.
head -n 1 "$_gb_token_file"
;;
esac
+512
View File
@@ -0,0 +1,512 @@
# shellcheck shell=sh
#
# gitbackup -- access to the remote (spec "Аутентификация").
#
# Exposes gb_git_env, gb_keygen, gb_pubkey and gb_accept_hostkey; every
# _gb_auth_-prefixed helper below is private and namespaced by module, same
# convention collect.sh uses (multiple *.sh here end up sourced into the
# same process by usr/sbin/gitbackup, so a bare _gb_ helper name could
# collide with a sibling module's). Sourced, never executed: nothing here
# runs at load time -- GB_ETC_DIR below is a variable assignment from an
# environment default, not a filesystem access.
#
# Depends on lib.sh (gb_log, gb_uci_get) already being sourced by the
# caller, same convention every other module here uses.
#
# GB_ETC_DIR overrides gitbackup's own secrets directory (default
# /etc/gitbackup). It is deliberately NOT a UCI option: gitbackup.origin.
# key_file and .token_file already give the operator a path for those two,
# but known_hosts has no such option in the shipped schema (spec:
# "known_hosts лежит в /etc/gitbackup/", a fixed location, not configurable)
# -- this exists only so tests/run.sh can point it at a tmp directory
# instead of the real /etc/gitbackup, the same seam GB_ROOT/GB_STATE_DIR/
# GB_SYSFS_NET give the other modules.
GB_ETC_DIR="${GB_ETC_DIR:-/etc/gitbackup}"
# gb_git_env
#
# Prints "export KEY='VALUE'" lines, one per line, quoted so a caller can
# safely `eval "$(gb_git_env)"` even though GIT_SSH_COMMAND's value itself
# contains spaces -- and so the assignments are exported into the calling
# shell's environment, where the `git` child process this whole thing
# exists for can actually see them.
#
# GIT_SSH_COMMAND and GIT_ASKPASS are both always printed, regardless of
# gitbackup.origin.auth: it is the git remote URL's own scheme (ssh:// vs
# https://), not this option, that decides which one a given `git`
# invocation actually uses -- ssh transport never calls GIT_ASKPASS for
# anything, and GIT_SSH_COMMAND is simply inert for an https transport. Not
# branching on .auth here means there is exactly one thing to keep in sync
# with the remote URL parser (remoteurl.sh) instead of two.
#
# GIT_SSH_COMMAND carries the four options the spec names by name: -i (the
# deploy key), UserKnownHostsFile (this package's own known_hosts, never
# the invoking user's ~/.ssh/known_hosts -- a stale entry there must not be
# able to either accept or block this package's own host-key decision),
# StrictHostKeyChecking=yes (an unknown or changed host key is refused,
# full stop -- gitbackup test is the only place that ever adds one) and
# BatchMode=yes (never fall back to an interactive passphrase/password
# prompt that would hang a cron job forever) -- plus ConnectTimeout=15, not
# named in the spec but added after a live failure on the owlab stand: a
# host that never answers at all (as opposed to actively refusing the
# connection) leaves `ssh` hanging with neither of those two safeguards to
# stop it, well past two minutes with no output. BatchMode only rules out
# an interactive *prompt*; it does nothing about the TCP handshake itself
# never completing, which is exactly what a firewalled or simply
# nonexistent host looks like.
#
# GIT_SSL_CAINFO is printed only when gitbackup.origin.ca_file is set, and
# is the only sanctioned way to talk to a self-signed/private-CA remote:
# GIT_SSL_NO_VERIFY and http.sslVerify=false are refused even as an option
# by this package, spec "Аутентификация" and "Границы и швы" -- there is no
# code path anywhere in this package that can turn certificate verification
# off, only one that can hand it an extra trusted certificate.
#
# _gb_ssh_cmd below is built as a plain `ssh ...` command, not busybox's
# `dbclient` -- and this StrictHostKeyChecking=yes option is exactly why.
# Measured on the 25.12.4 stand (dropbear 2025.89, git 2.50.1): with
# GIT_SSH_COMMAND set to a dbclient invocation, `GIT_TRACE=1 git fetch`
# shows git's ssh-variant probe (`dbclient -G ...`, used to detect whether
# the ssh command understands OpenSSH's own option syntax) failing, after
# which git silently falls back to its "simple" transport form and stops
# passing any `-o NAME=VALUE` pair at all -- not just this one, every
# option this function sets, including UserKnownHostsFile and BatchMode.
# The failure is invisible: git does not warn that it dropped them, fetch
# still succeeds against a reachable host, and the option only turns out
# to have been a no-op the day host-key checking was supposed to catch
# something. That silent downgrade, not merely dbclient's separate inability
# to read OpenSSH-format keys (see package/gitbackup/Makefile), is why this
# package depends on the real openssh-client instead.
#
# Never prints the token itself: GIT_ASKPASS names askpass.sh's path, and
# the token is read off disk by askpass.sh's own process, later, only once
# git has actually decided it needs credentials.
gb_git_env() {
_gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519)
_gb_known="$GB_ETC_DIR/known_hosts"
_gb_ssh_cmd="ssh -i $(_gb_auth_shquote "$_gb_key") -o UserKnownHostsFile=$(_gb_auth_shquote "$_gb_known") -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=15"
printf 'export GIT_SSH_COMMAND=%s\n' "$(_gb_auth_shquote "$_gb_ssh_cmd")"
printf 'export GIT_ASKPASS=%s\n' "$(_gb_auth_shquote "${GB_SHARE:-/usr/share/gitbackup}/askpass.sh")"
# Never let git fall back to its own interactive prompt on a real tty --
# askpass.sh is the only credential source this package allows.
printf 'export GIT_TERMINAL_PROMPT=%s\n' "$(_gb_auth_shquote 0)"
_gb_ca=$(gb_uci_get gitbackup.origin.ca_file)
[ -n "$_gb_ca" ] && printf 'export GIT_SSL_CAINFO=%s\n' "$(_gb_auth_shquote "$_gb_ca")"
return 0
}
# _gb_auth_shquote <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
ssh-keygen -lf "$_gb_akf_key.pub" 2>/dev/null
elif [ -r "$_gb_akf_key" ]; then
ssh-keygen -lf "$_gb_akf_key" 2>/dev/null
fi
}
# gb_keygen [force] [confirm-fingerprint]
#
# Generates the deploy key at gitbackup.origin.key_file (default
# /etc/gitbackup/id_ed25519): ed25519, no passphrase. No passphrase is
# deliberate, not an oversight -- GIT_SSH_COMMAND always carries
# BatchMode=yes (gb_git_env), which refuses to prompt for one, so an
# encrypted key would simply make every push hang forever instead of
# failing loudly.
#
# Refuses to overwrite a key that already exists unless <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.
gb_keygen() {
_gb_force="${1:-}"
_gb_confirm="${2:-}"
_gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519)
if [ -z "$_gb_key" ]; then
gb_log err 'gb_keygen: gitbackup.origin.key_file is empty'
return 1
fi
if [ -e "$_gb_key" ]; then
if [ -z "$_gb_force" ]; then
gb_log err "gb_keygen: $_gb_key already exists; pass a non-empty force argument to replace it"
return 1
fi
_gb_fp=$(_gb_auth_key_fingerprint "$_gb_key")
if [ -z "$_gb_confirm" ] || [ "$_gb_confirm" != "$_gb_fp" ]; then
if [ -n "$_gb_confirm" ]; then
gb_log err "gb_keygen: confirmation fingerprint does not match the key currently at $_gb_key; refusing"
fi
printf 'confirm-required %s\n' "$_gb_fp"
return 4
fi
# Confirmed: keep the outgoing key's own copy before touching
# anything -- see this function's header comment for why. Best
# effort on purpose (2>/dev/null): a failure to write the backup
# copy must never block the regeneration the operator explicitly
# confirmed, only mean there is nothing to roll back to.
cp -f "$_gb_key" "$_gb_key.old" 2>/dev/null && chmod 0600 "$_gb_key.old" 2>/dev/null
[ -e "$_gb_key.pub" ] && cp -f "$_gb_key.pub" "$_gb_key.old.pub" 2>/dev/null
fi
_gb_dir=$(dirname "$_gb_key")
mkdir -p "$_gb_dir" || { gb_log err "gb_keygen: cannot create $_gb_dir"; return 1; }
chmod 0700 "$_gb_dir" || return 1
rm -f "$_gb_key" "$_gb_key.pub"
if ! ssh-keygen -t ed25519 -N '' -C gitbackup -f "$_gb_key" >/dev/null; then
gb_log err "gb_keygen: ssh-keygen failed for $_gb_key"
return 1
fi
chmod 0600 "$_gb_key" || return 1
[ -e "$_gb_key.pub" ] && chmod 0644 "$_gb_key.pub"
gb_log notice "gb_keygen: generated $_gb_key"
return 0
}
# gb_keygen_forget_old
#
# Ticket 25's other half of the id_ed25519.old decision above (gb_keygen's
# own header comment): removes the pre-regeneration copy once something has
# actually proven the CURRENT key works end to end. Called from cmd_test's
# own success path (usr/sbin/gitbackup) -- a successful `git ls-remote`
# against the configured remote is the one signal this package already
# computes that means "the key at key_file is accepted by the provider
# right now", which is exactly the event this flow is waiting for. A no-op
# whenever there is nothing to forget (the overwhelming majority of
# successful `test` runs never followed a keygen at all), so this is safe
# to call unconditionally on every success rather than tracking anywhere
# whether a keygen happened first.
gb_keygen_forget_old() {
_gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519)
rm -f "$_gb_key.old" "$_gb_key.old.pub"
}
# gb_pubkey
#
# Prints the deploy key's public half. Reads the already-generated .pub
# file when it is there (the common case, ssh-keygen always writes one),
# and falls back to deriving it from the private key with `ssh-keygen -y`
# only if that file is missing -- an operator could plausibly delete just
# the .pub half by hand and expect this to still work.
gb_pubkey() {
_gb_key=$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519)
if [ -r "$_gb_key.pub" ]; then
cat "$_gb_key.pub"
return 0
fi
if [ -r "$_gb_key" ]; then
ssh-keygen -y -f "$_gb_key"
return $?
fi
gb_log err "gb_pubkey: no key at $_gb_key; run 'gitbackup keygen' first"
return 1
}
# _gb_auth_dial_hostkey <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() {
_gb_adh_host="$1"
_gb_adh_port="$2"
_gb_adh_out="$3"
rm -f "$_gb_adh_out"
# ConnectTimeout=15: same reasoning as gb_git_env's own copy of it -- a
# host that never answers at all, rather than actively refusing,
# otherwise leaves this hanging well past two minutes (measured live).
ssh -o UserKnownHostsFile="$_gb_adh_out" -o StrictHostKeyChecking=accept-new \
-o BatchMode=yes -o ConnectTimeout=15 -p "$_gb_adh_port" "git@$_gb_adh_host" exit >/dev/null 2>&1
if [ ! -s "$_gb_adh_out" ]; then
rm -f "$_gb_adh_out"
return 2
fi
return 0
}
# _gb_hostkey_pending_file -- fixed path under GB_ETC_DIR, never part of
# known_hosts itself. Holds the raw known_hosts-format line(s) for the
# LAST host key gb_hostkey_show fetched, and nothing else: gb_hostkey_accept
# below commits exactly this file's bytes, never a fresh dial of its own
# (see gb_hostkey_accept's own header comment for why that distinction is
# the whole point of the two-step design).
_gb_hostkey_pending_file() {
printf '%s/hostkey_pending' "$GB_ETC_DIR"
}
# gb_accept_hostkey <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() {
_gb_host="$1"
_gb_port="${2:-22}"
case "$_gb_port" in
''|0) _gb_port=22 ;;
esac
mkdir -p "$GB_ETC_DIR" 2>/dev/null
_gb_known="$GB_ETC_DIR/known_hosts"
# Already trusted: nothing to accept. Asked of ssh-keygen itself, not a
# raw grep, so a known_hosts entry hashed with -H (never written by this
# package, but nothing stops an operator from hand-editing the file) is
# still recognized correctly.
if [ -r "$_gb_known" ] && ssh-keygen -F "$_gb_host" -f "$_gb_known" >/dev/null 2>&1; then
return 0
fi
_gb_scratch=$(mktemp "${TMPDIR:-/tmp}/gitbackup-hostkey.XXXXXX") || return 2
if ! _gb_auth_dial_hostkey "$_gb_host" "$_gb_port" "$_gb_scratch"; then
gb_log err "gb_accept_hostkey: could not reach $_gb_host:$_gb_port to obtain its host key"
return 2
fi
_gb_fp=$(ssh-keygen -lf "$_gb_scratch" 2>/dev/null)
printf 'Host key for %s:%s --\n %s\nAccept and remember it? [y/N] ' "$_gb_host" "$_gb_port" "$_gb_fp" >&2
# See this function's own header comment: `read`'s exit status, not
# just the string it captured, is what tells a real "no" (read
# succeeded, the operator typed something other than y/yes) apart from
# "could not ask at all" (read failed -- EOF, no stdin to read from).
if IFS= read -r _gb_ans; then
case "$_gb_ans" in
y|Y|yes|YES) ;;
*)
rm -f "$_gb_scratch"
gb_log notice "gb_accept_hostkey: $_gb_host:$_gb_port declined by the operator"
return 1
;;
esac
else
rm -f "$_gb_scratch"
gb_log err "gb_accept_hostkey: $_gb_host:$_gb_port could not ask for confirmation -- no interactive input is available here. Run 'gitbackup test' from a terminal with a real stdin, or accept the host key from the LuCI web UI (Settings -> Connection test), which uses 'gitbackup hostkey show'/'hostkey accept' for exactly this case."
return 3
fi
cat "$_gb_scratch" >>"$_gb_known"
chmod 0600 "$_gb_known"
rm -f "$_gb_scratch"
gb_log notice "gb_accept_hostkey: $_gb_host:$_gb_port accepted and recorded in $_gb_known"
return 0
}
# gb_hostkey_show <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 ;;
esac
mkdir -p "$GB_ETC_DIR" 2>/dev/null
_gb_known="$GB_ETC_DIR/known_hosts"
if [ -r "$_gb_known" ] && ssh-keygen -F "$_gb_host" -f "$_gb_known" >/dev/null 2>&1; then
printf 'trusted %s %s\n' "$_gb_host" "$_gb_port"
return 0
fi
_gb_pending=$(_gb_hostkey_pending_file)
if ! _gb_auth_dial_hostkey "$_gb_host" "$_gb_port" "$_gb_pending"; then
gb_log err "gb_hostkey_show: could not reach $_gb_host:$_gb_port to obtain its host key"
return 2
fi
chmod 0600 "$_gb_pending"
_gb_fp=$(ssh-keygen -lf "$_gb_pending" 2>/dev/null)
printf 'pending %s %s %s\n' "$_gb_host" "$_gb_port" "$_gb_fp"
return 0
}
# gb_hostkey_accept <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() {
_gb_host="$1"
_gb_port="${2:-22}"
_gb_want_fp="$3"
case "$_gb_port" in
''|0) _gb_port=22 ;;
esac
_gb_pending=$(_gb_hostkey_pending_file)
if [ ! -s "$_gb_pending" ]; then
gb_log err "gb_hostkey_accept: no pending host key for $_gb_host:$_gb_port -- run 'gitbackup hostkey show' first"
return 1
fi
_gb_have_fp=$(ssh-keygen -lf "$_gb_pending" 2>/dev/null)
if [ -z "$_gb_want_fp" ] || [ "$_gb_have_fp" != "$_gb_want_fp" ]; then
gb_log err "gb_hostkey_accept: fingerprint for $_gb_host:$_gb_port does not match the pending host key -- refusing"
return 4
fi
mkdir -p "$GB_ETC_DIR" 2>/dev/null
_gb_known="$GB_ETC_DIR/known_hosts"
cat "$_gb_pending" >>"$_gb_known"
chmod 0600 "$_gb_known"
rm -f "$_gb_pending"
gb_log notice "gb_hostkey_accept: $_gb_host:$_gb_port accepted and recorded in $_gb_known"
return 0
}
+145
View File
@@ -0,0 +1,145 @@
# shellcheck shell=sh
#
# gitbackup -- recovery card (spec "Recovery card и RECOVERY.md", ticket 09).
#
# Exposes gb_card; every _gb_card_-prefixed helper below is private, same
# per-module namespacing convention every sibling file here uses. Sourced,
# never executed: nothing here runs at load time.
#
# Depends on lib.sh (gb_log, gb_uci_get) and remoteurl.sh (gb_parse_url,
# gb_provider) already being sourced by the caller -- same convention
# visibility.sh/restore.sh use. Reads GB_URL and GB_DEVICE from the
# environment rather than taking them as arguments: usr/sbin/gitbackup's
# _gb_run_backup already has both resolved (by gb_validate_config, before
# _gb_run_backup even starts) at the one call site this module is actually
# reached from today (step 11b, "the recovery card"), same convention
# gitio.sh/restore.sh already use for config context the CLI resolved once.
# A future LuCI "Download recovery card" button (ticket 11) goes through
# `gitbackup card`, which resolves the same two through gb_validate_config
# first, so this module never has to.
#
# gb_card is deliberately built to never die and never fail loudly: it is
# called from the middle of a real backup run (usr/sbin/gitbackup's
# `gb_card "$_gb_outdir/RECOVERY.md" 2>/dev/null`), and a bug in a recovery
# document must not be able to abort the backup it rides along with. Every
# helper below degrades to a shorter card instead of calling gb_die, and
# gb_card itself always returns 0.
# gb_card <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() {
_gb_c_out="$1"
_gb_c_dev="${GB_DEVICE:-}"
[ -n "$_gb_c_dev" ] || _gb_c_dev='<device>'
_gb_c_url="${GB_URL:-}"
[ -n "$_gb_c_url" ] || _gb_c_url=$(gb_uci_get gitbackup.origin.url)
_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_c_scrub=0
[ "$(gb_json_bool "${GB_SCRUB:-0}")" = true ] && _gb_c_scrub=1
_gb_c_archive=0
if [ "$(gb_json_bool "$(gb_uci_get gitbackup.main.archive 1)")" = true ] && [ "$_gb_c_scrub" = 0 ]; then
_gb_c_archive=1
fi
if [ -n "$_gb_c_url" ]; then
_gb_c_parsed=$(gb_parse_url "$_gb_c_url" 2>/dev/null)
if [ -n "$_gb_c_parsed" ]; then
# shellcheck disable=SC2086 # word-splitting is the point: five fields from one line
set -- $_gb_c_parsed
_gb_c_scheme="$1"
_gb_c_host="$2"
_gb_c_owner="$4"
_gb_c_repo="$5"
[ "$_gb_c_scheme" = ssh ] && _gb_c_authflag='--ssh-key <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.
_gb_c_weblink="https://$_gb_c_host/$_gb_c_owner/$_gb_c_repo"
fi
fi
[ -n "$_gb_c_url" ] || _gb_c_url='<REPO-URL>'
{
printf '# gitbackup recovery card -- %s\n\n' "$_gb_c_dev"
printf 'Generated: %s\n\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'This router backs itself up to a private git repository. If it dies, a\n'
printf 'freshly flashed replacement can be brought back to the same configuration\n'
printf 'with one command, run over ssh once the replacement has booted and has\n'
printf 'network access.\n\n'
printf 'This card does NOT contain a secret. You still have to bring one: either a\n'
printf 'personal access token with read access to the repository below (HTTPS\n'
printf 'remotes), or the path to the SSH deploy key already registered on it (SSH\n'
printf 'remotes).\n\n'
printf '## Path 1 -- one command\n\n'
printf '```sh\n'
printf 'uclient-fetch -qO- https://raw.githubusercontent.com/VizzleTF/luci-app-gitbackup/main/bootstrap.sh \\\n'
printf ' | sh -s -- --repo %s --device %s %s\n' "$_gb_c_url" "$_gb_c_dev" "$_gb_c_authflag"
printf '```\n\n'
if [ -n "$_gb_c_weblink" ]; then
printf 'Repository: %s\n\n' "$_gb_c_weblink"
fi
# Path 0 is printed only when there is actually an archive in the
# repository to open it with. `gitbackup run` writes
# backup.tar.gz when main.archive is on AND this run does not
# scrub (usr/sbin/gitbackup's own step 11a -- sysupgrade -b reads
# the live filesystem, so pushing its output would undo the scrub
# the same run was just asked to perform). A recovery card is
# read exactly once, by someone whose router is already broken;
# sending them after a file that was never pushed is worse than
# telling them this path is unavailable, and why.
if [ "$_gb_c_archive" = 1 ]; then
printf '## Path 0 -- no network, or bootstrap.sh will not run\n\n'
printf '1. Open the repository above from any other device and download\n'
# shellcheck disable=SC2016 # backticks are markdown code spans in the
# generated document, not an attempted command substitution here
printf ' `devices/%s/backup.tar.gz` from the latest commit on branch\n' "$_gb_c_dev"
# shellcheck disable=SC2016
printf ' `device/%s` (or the branch your `gitbackup.origin.branch` template\n' "$_gb_c_dev"
printf ' resolves to).\n'
printf '2. On the router: LuCI -> System -> Backup / Flash Firmware -> Restore\n'
printf ' configuration, and upload that file. Over ssh instead:\n'
# shellcheck disable=SC2016
printf ' `sysupgrade -r backup.tar.gz` after copying it onto the router.\n'
printf '3. Reboot. This restores the raw sysupgrade archive, not the package own\n'
printf ' sha256-verified restore -- see docs/RESTORE.md for the difference.\n'
elif [ "$_gb_c_scrub" = 1 ]; then
printf '## Path 0 -- not available on this router\n\n'
# shellcheck disable=SC2016
printf 'No `backup.tar.gz` is pushed while config scrubbing is on:\n'
# shellcheck disable=SC2016
printf '`sysupgrade -b` reads the live filesystem, so its archive would carry\n'
printf 'exactly the secrets scrubbing keeps out of the commit. Use Path 1\n'
# shellcheck disable=SC2016
printf 'above, or `gitbackup restore` -- see docs/RESTORE.md.\n'
else
printf '## Path 0 -- not available on this router\n\n'
# shellcheck disable=SC2016
printf 'No `backup.tar.gz` is pushed (`gitbackup.main.archive` is off). Use\n'
# shellcheck disable=SC2016
printf 'Path 1 above, or `gitbackup restore` -- see docs/RESTORE.md.\n'
fi
} >"$_gb_c_out" 2>/dev/null
return 0
}
@@ -0,0 +1,345 @@
# shellcheck shell=sh
#
# gitbackup -- backup-set collection and manifest.json (spec "Сбор и
# manifest.json").
#
# Exposes gb_collect, gb_manifest_path and gb_manifest_equal; every
# _gb_collect_-prefixed helper below is private and namespaced by module
# (not just "_gb_") because every *.sh here ends up sourced into the same
# process by usr/sbin/gitbackup -- a same-named helper in a sibling module
# would silently shadow this one otherwise. Sourced, never executed:
# nothing here runs at load time.
#
# Depends on lib.sh (gb_log, gb_json_esc/str, ...) already being sourced by
# the caller, same convention device.sh uses. It does NOT depend on
# device.sh: the device name is read from $GB_DEVICE, which
# usr/sbin/gitbackup's gb_validate_config already resolves once via
# gb_device_id and exports for every subcommand (same contract as GB_URL
# and GB_SCRUB) -- collecting would otherwise resolve the device a second
# time and could disagree with the CLI's own answer.
#
# GB_ROOT overrides the filesystem root every real-path read goes through
# (default '', meaning the actual '/'). GB_EXCLUDE_LIST overrides where the
# hard-exclude patterns live (default GB_SHARE/exclude.list, GB_SHARE
# itself defaulting like the CLI's own). Neither is set by the router --
# both exist so tests/run.sh can point collect.sh at a fixture tree instead
# of the host filesystem, the same seam GB_SYSFS_NET gives device.sh.
GB_ROOT="${GB_ROOT:-}"
GB_EXCLUDE_LIST="${GB_EXCLUDE_LIST:-${GB_SHARE:-/usr/share/gitbackup}/exclude.list}"
# gb_manifest_path <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.
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() {
_gb_outdir="$1"
[ -n "$_gb_outdir" ] || { gb_log err 'gb_collect: outdir is required'; return 1; }
mkdir -p "$_gb_outdir/files" "$_gb_outdir/meta" ||
{ gb_log err "gb_collect: cannot create $_gb_outdir"; return 1; }
_gb_scratch=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-collect.XXXXXX") || return 1
: >"$_gb_scratch/entries"
_gb_collect_files "$_gb_outdir" "$_gb_scratch/entries"
_gb_collect_empty_dirs "$_gb_outdir" "$_gb_scratch/entries"
_gb_collect_meta "$_gb_outdir"
_gb_collect_write_manifest "$_gb_outdir" "$_gb_scratch/entries"
rm -rf "$_gb_scratch"
}
# _gb_collect_is_excluded <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.
_gb_ie_path=$(gb_path_canon "$1")
[ -r "$GB_EXCLUDE_LIST" ] || return 1
while IFS= read -r _gb_pat || [ -n "$_gb_pat" ]; do
case "$_gb_pat" in
''|'#'*) continue ;;
esac
case "$_gb_pat" in
*/'**')
_gb_prefix=${_gb_pat%/\*\*}
case "$_gb_ie_path" in
"$_gb_prefix"|"$_gb_prefix"/*) return 0 ;;
esac
;;
*)
# shellcheck disable=SC2254 # unquoted on purpose: this is a case glob, not a literal
case "$_gb_ie_path" in
$_gb_pat) return 0 ;;
esac
;;
esac
done <"$GB_EXCLUDE_LIST"
return 1
}
# _gb_collect_files <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.
_gb_collect_files() {
_gb_outdir="$1"
_gb_entries="$2"
sysupgrade -l 2>/dev/null | while IFS= read -r _gb_path || [ -n "$_gb_path" ]; do
[ -n "$_gb_path" ] || continue
# One spelling per file from here down, so the staged tree, the
# manifest entry and the exclude decision cannot disagree about
# what "this path" is: "/etc/./config/network" and
# "/etc/config/network" are one file to the kernel but two
# distinct manifest entries (and two distinct restore targets)
# if the raw string is carried through.
_gb_path=$(gb_path_canon "$_gb_path")
_gb_collect_is_excluded "$_gb_path" && continue
_gb_src="$GB_ROOT$_gb_path"
_gb_dest="$_gb_outdir/files$_gb_path"
mkdir -p "$(dirname "$_gb_dest")" || continue
if [ -L "$_gb_src" ]; then
# Never dereferenced (R24.1): the target string is copied as-is,
# and mode/uid/gid come from `stat` without -L, i.e. the link
# itself, not what it points to.
_gb_target=$(readlink "$_gb_src") || continue
ln -s "$_gb_target" "$_gb_dest" || continue
_gb_mug=$(stat -c '%a %u %g' "$_gb_src" 2>/dev/null) || _gb_mug='0 0 0'
# shellcheck disable=SC2086 # word-splitting is the point: unpacking "mode uid gid"
set -- $_gb_mug
_gb_collect_entry_symlink "$_gb_path" "$1" "$2" "$3" "$_gb_target" >>"$_gb_entries"
elif [ -f "$_gb_src" ]; then
cp "$_gb_src" "$_gb_dest" || continue
_gb_mug=$(stat -c '%a %u %g' "$_gb_src" 2>/dev/null) || _gb_mug='0 0 0'
# shellcheck disable=SC2086
set -- $_gb_mug
_gb_sha=$(sha256sum "$_gb_dest" 2>/dev/null | awk '{print $1}')
_gb_collect_entry_file "$_gb_path" "$1" "$2" "$3" "$_gb_sha" >>"$_gb_entries"
else
gb_log warning "gb_collect: sysupgrade -l listed '$_gb_path' but it is not there anymore"
fi
done
}
# _gb_collect_empty_dirs <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.
_gb_collect_empty_dirs() {
_gb_outdir="$1"
_gb_entries="$2"
{
[ -r "$GB_ROOT/etc/sysupgrade.conf" ] && cat "$GB_ROOT/etc/sysupgrade.conf"
for _gb_f in "$GB_ROOT/lib/upgrade/keep.d/"*; do
[ -r "$_gb_f" ] && cat "$_gb_f"
done
} 2>/dev/null | while IFS= read -r _gb_line || [ -n "$_gb_line" ]; do
case "$_gb_line" in
/*) ;;
*) continue ;; # blank, comment, or a relative line -- not a source we can resolve
esac
# keep.d lines end directories in "/" (measured on the 25.12.4 stand,
# e.g. "/etc/config/"); stripped here so `find`'s own top-level match
# does not echo it back into the manifest path.
_gb_line="${_gb_line%/}"
[ -n "$_gb_line" ] && [ -d "$GB_ROOT$_gb_line" ] || continue
find "$GB_ROOT$_gb_line" -type d 2>/dev/null | while IFS= read -r _gb_realdir; do
_gb_relpath="${_gb_realdir#"$GB_ROOT"}"
_gb_collect_is_excluded "$_gb_relpath" && continue
[ -z "$(find "$_gb_realdir" -mindepth 1 -maxdepth 1 2>/dev/null)" ] || continue
_gb_mug=$(stat -c '%a %u %g' "$_gb_realdir" 2>/dev/null) || _gb_mug='0 0 0'
# shellcheck disable=SC2086
set -- $_gb_mug
_gb_collect_entry_dir "$_gb_relpath" "$1" "$2" "$3" >>"$_gb_entries"
done
done
}
# _gb_collect_meta <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.
: >"$_gb_outdir/meta/repositories.txt"
for _gb_f in "$GB_ROOT/etc/apk/repositories.d/"*; do
[ -r "$_gb_f" ] && cat "$_gb_f" >>"$_gb_outdir/meta/repositories.txt"
done
# sysupgrade.conf: a copy of the extra-paths file as it stood at
# collection time (R20).
if [ -r "$GB_ROOT/etc/sysupgrade.conf" ]; then
cp "$GB_ROOT/etc/sysupgrade.conf" "$_gb_outdir/meta/sysupgrade.conf"
else
: >"$_gb_outdir/meta/sysupgrade.conf"
fi
# os-release.txt: a copy of /etc/os-release (R21). Confirmed on the
# owlab 25.12.4 stand: the file exists and VERSION_ID="25.12.4" is
# exactly the field manifest.openwrt below reads out of it.
if [ -r "$GB_ROOT/etc/os-release" ]; then
cp "$GB_ROOT/etc/os-release" "$_gb_outdir/meta/os-release.txt"
else
: >"$_gb_outdir/meta/os-release.txt"
fi
}
# _gb_collect_write_manifest <outdir> <entries-file>
_gb_collect_write_manifest() {
_gb_outdir="$1"
_gb_entries="$2"
_gb_manifest=$(gb_manifest_path "$_gb_outdir")
_gb_board=$(cat "$_gb_outdir/meta/board.json" 2>/dev/null)
_gb_hostname=$(printf '%s' "$_gb_board" | jsonfilter -e '@.hostname' 2>/dev/null)
_gb_model=$(printf '%s' "$_gb_board" | jsonfilter -e '@.model' 2>/dev/null)
_gb_openwrt=$(sed -n 's/^VERSION_ID="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' \
"$_gb_outdir/meta/os-release.txt" 2>/dev/null | head -n 1)
_gb_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# sort -u both dedupes (an empty directory reachable from two sources in
# sysupgrade.conf would otherwise get two identical entries) and makes
# two collections of an unchanged router byte-identical apart from
# "generated" -- gb_manifest_equal does not depend on this, but it means
# `diff` on two manifests is actually readable.
_gb_sorted=$(sort -u "$_gb_entries")
{
printf '{\n'
printf ' "version": "1",\n'
printf ' "generated": %s,\n' "$(gb_json_str "$_gb_now")"
printf ' "hostname": %s,\n' "$(gb_json_str "$_gb_hostname")"
printf ' "device": %s,\n' "$(gb_json_str "${GB_DEVICE:-}")"
printf ' "openwrt": %s,\n' "$(gb_json_str "$_gb_openwrt")"
printf ' "board": %s,\n' "$(gb_json_str "$_gb_model")"
printf ' "entries": [\n'
_gb_collect_join "$_gb_sorted"
printf ' ],\n'
printf ' "scrubbed": [\n'
# Always empty here: scrub only runs at visibility=public and edits
# outdir/files in place after gb_collect has already run (ticket 05
# owns that step and, with it, the entries/scrubbed this manifest
# needs once scrubbing has happened -- not this function's concern,
# see collect.sh's own header comment on ownership).
printf ' ]\n'
printf '}\n'
} >"$_gb_manifest"
}
# _gb_collect_join <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.
_gb_collect_json_num() {
case "${1:-}" in
''|*[!0-9]*) printf '0' ;;
*) printf '%s' "$1" ;;
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")"
}
@@ -0,0 +1,120 @@
# shellcheck shell=sh
#
# gitbackup -- device identity (spec G01).
#
# Exposes gb_device_id and gb_expand; every _gb_-prefixed helper below is
# private and may change without notice to callers, same convention as
# lib.sh. Sourced, never executed: nothing here runs at load time.
# gb_device_id -- resolves gitbackup.main.device_id to a device name.
#
# One of three strategies. Called on every subcommand (through the CLI's
# config validation), so the strategies and their failure modes live here
# once instead of being reimplemented at every call site.
gb_device_id() {
_gb_strategy=$(gb_uci_get gitbackup.main.device_id hostname)
case "$_gb_strategy" in
hostname) _gb_device_by_hostname ;;
custom) _gb_device_by_custom ;;
board) _gb_device_by_board ;;
*) gb_die 2 "gitbackup.main.device_id: unknown strategy '$_gb_strategy' (want hostname, custom or board)" ;;
esac
}
# gb_expand <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() {
_gb_tpl="$1"
case "$_gb_tpl" 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_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.
_gb_device_by_hostname() {
_gb_board=$(ubus call system board 2>/dev/null)
_gb_h=$(printf '%s' "$_gb_board" | jsonfilter -e '@.hostname')
[ -n "$_gb_h" ] ||
gb_die 2 "gitbackup.main.device_id=hostname: 'ubus call system board' returned no hostname"
[ "$_gb_h" != 'OpenWrt' ] ||
gb_die 2 'gitbackup.main.device_id=hostname: two routers with the default hostname would overwrite each other, name this device'
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" ] ||
gb_die 2 'gitbackup.main.device: empty, required when device_id=custom'
case "$_gb_d" in
*[!A-Za-z0-9._-]*)
gb_die 2 "gitbackup.main.device: '$_gb_d' must match [A-Za-z0-9._-]{1,64}" ;;
esac
[ "${#_gb_d}" -le 64 ] ||
gb_die 2 "gitbackup.main.device: '$_gb_d' is longer than the 64 characters allowed"
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.
_gb_device_by_board() {
_gb_board=$(ubus call system board 2>/dev/null)
_gb_model=$(printf '%s' "$_gb_board" | jsonfilter -e '@.model')
[ -n "$_gb_model" ] ||
gb_die 2 "gitbackup.main.device_id=board: 'ubus call system board' returned no model"
_gb_net="${GB_SYSFS_NET:-/sys/class/net}"
_gb_mac=''
# shellcheck disable=SC2012 # find's -printf is not in busybox; ls output here is plain interface names
for _gb_if in $(ls "$_gb_net" 2>/dev/null | sort); do
[ "$_gb_if" = lo ] && continue
[ -r "$_gb_net/$_gb_if/address" ] || continue
_gb_candidate=$(cat "$_gb_net/$_gb_if/address")
if [ -n "$_gb_candidate" ] && [ "$_gb_candidate" != '00:00:00:00:00:00' ]; then
_gb_mac="$_gb_candidate"
break
fi
done
[ -n "$_gb_mac" ] ||
gb_die 2 "gitbackup.main.device_id=board: no network interface with a MAC address was found under $_gb_net"
_gb_tail=$(printf '%s' "$_gb_mac" | tr -d ':' | tr 'A-F' 'a-f')
_gb_tail=$(printf '%s' "$_gb_tail" | sed 's/^.*\(......\)$/\1/')
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'
}
@@ -0,0 +1,50 @@
# 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).
# The package's own credentials must never end up inside its own backup,
# no matter how the router is configured (R76).
/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":
#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.
#public: /etc/wireguard/**
@@ -0,0 +1,330 @@
# 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.
#
# 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.
# 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() {
_gb_branch="$1"
_gb_out=$(git ls-remote "${GB_URL:?gb_remote_head: GB_URL is not set}" \
"refs/heads/$_gb_branch" 2>&1)
_gb_rc=$?
if [ "$_gb_rc" -ne 0 ]; then
gb_log err "gb_remote_head: git ls-remote $GB_URL refs/heads/$_gb_branch failed: $_gb_out"
return 1
fi
printf '%s\n' "$_gb_out" | awk 'NR==1 {print $1}'
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() {
_gb_branch="$1"
_gb_repodir="$2"
_gb_depth="${3-1}"
if [ ! -d "$_gb_repodir/.git" ]; then
git init -q "$_gb_repodir" 2>/dev/null ||
{ gb_log err "gb_fetch_meta: git init $_gb_repodir failed"; return 1; }
fi
git --git-dir="$_gb_repodir/.git" remote get-url origin >/dev/null 2>&1 ||
git --git-dir="$_gb_repodir/.git" remote add origin \
"${GB_URL:?gb_fetch_meta: GB_URL is not set}" 2>/dev/null
_gb_depth_opt=''
[ -n "$_gb_depth" ] && _gb_depth_opt="--depth=$_gb_depth"
# shellcheck disable=SC2086 # intentional: expands to zero or exactly one token, never unquoted user input
_gb_out=$(git --git-dir="$_gb_repodir/.git" fetch $_gb_depth_opt --filter=blob:none \
origin "$_gb_branch" 2>&1)
_gb_rc=$?
if [ "$_gb_rc" -ne 0 ]; then
gb_log err "gb_fetch_meta: git fetch $GB_URL $_gb_branch failed: $_gb_out"
return 1
fi
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() {
_gb_repodir="$1"
_gb_treedir="$2"
_gb_extra_file="${3:-}"
_gb_extra_gitpath="${4:-}"
_gb_prefix="${GB_PREFIX:?gb_build_tree: GB_PREFIX is not set}"
_gb_gitdir="$_gb_repodir/.git"
_gb_idx="$_gb_repodir/gitio-index.$$"
rm -f "$_gb_idx"
if [ -n "${GB_PARENT:-}" ]; then
GIT_DIR="$_gb_gitdir" GIT_INDEX_FILE="$_gb_idx" git read-tree "$GB_PARENT" 2>/dev/null || {
gb_log err "gb_build_tree: git read-tree $GB_PARENT failed"
rm -f "$_gb_idx"
return 1
}
GIT_DIR="$_gb_gitdir" GIT_INDEX_FILE="$_gb_idx" \
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"
_gb_gitpath="$_gb_prefix/$_gb_rel"
if [ -L "$_gb_full" ]; then
_gb_target=$(readlink "$_gb_full") || continue
_gb_oid=$(printf '%s' "$_gb_target" | GIT_DIR="$_gb_gitdir" git hash-object -w --stdin) || continue
_gb_mode=120000
else
_gb_oid=$(GIT_DIR="$_gb_gitdir" git hash-object -w "$_gb_full") || continue
if [ -x "$_gb_full" ]; then _gb_mode=100755; else _gb_mode=100644; fi
fi
GIT_DIR="$_gb_gitdir" GIT_INDEX_FILE="$_gb_idx" \
git update-index --add --cacheinfo "$_gb_mode,$_gb_oid,$_gb_gitpath" 2>/dev/null
done
if [ -n "$_gb_extra_file" ] && [ -n "$_gb_extra_gitpath" ] && [ -f "$_gb_extra_file" ]; then
_gb_extra_oid=$(GIT_DIR="$_gb_gitdir" git hash-object -w "$_gb_extra_file" 2>/dev/null)
if [ -n "$_gb_extra_oid" ]; then
GIT_DIR="$_gb_gitdir" GIT_INDEX_FILE="$_gb_idx" \
git update-index --add --cacheinfo "100644,$_gb_extra_oid,$_gb_extra_gitpath" 2>/dev/null
fi
fi
_gb_tree=$(GIT_DIR="$_gb_gitdir" GIT_INDEX_FILE="$_gb_idx" git write-tree 2>/dev/null)
_gb_rc=$?
rm -f "$_gb_idx"
if [ "$_gb_rc" -ne 0 ] || [ -z "$_gb_tree" ]; then
gb_log err 'gb_build_tree: git write-tree failed'
return 1
fi
printf '%s\n' "$_gb_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() {
_gb_repodir="$1"
_gb_tree="$2"
_gb_parent="$3"
_gb_msgfile="$4"
_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
_gb_rc=$?
if [ "$_gb_rc" -ne 0 ]; then
gb_log err "gb_commit_push: commit-tree failed: $_gb_commit"
return 1
fi
_gb_push_out=$(git --git-dir="$_gb_gitdir" push "${GB_URL:?gb_commit_push: GB_URL is not set}" \
"$_gb_commit:refs/heads/$_gb_branch" 2>&1)
_gb_rc=$?
if [ "$_gb_rc" -eq 0 ]; then
printf '%s\n' "$_gb_commit"
return 0
fi
case "$_gb_push_out" in
*'non-fast-forward'* | *'fetch first'*)
gb_log notice "gb_commit_push: $_gb_branch was rejected, the branch moved: $_gb_push_out"
return 2
;;
*)
gb_log err "gb_commit_push: push to $_gb_branch on $GB_URL failed: $_gb_push_out"
return 3
;;
esac
}
+339
View File
@@ -0,0 +1,339 @@
# shellcheck shell=sh
#
# gitbackup -- primitives shared by every module.
#
# 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.
# 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() {
_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.
gb_die() {
_gb_code="$1"
shift
gb_log err "$*"
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() {
_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() {
_gb_pc_rest="${1-}"
case "$_gb_pc_rest" in
/*) ;;
*) printf '%s\n' "$_gb_pc_rest"; return 0 ;;
esac
_gb_pc_out=''
while [ -n "$_gb_pc_rest" ]; do
_gb_pc_comp="${_gb_pc_rest%%/*}"
case "$_gb_pc_rest" in
*/*) _gb_pc_rest="${_gb_pc_rest#*/}" ;;
*) _gb_pc_rest='' ;;
esac
case "$_gb_pc_comp" in
''|.) ;;
..) _gb_pc_out="${_gb_pc_out%/*}" ;;
*) _gb_pc_out="$_gb_pc_out/$_gb_pc_comp" ;;
esac
done
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.
gb_json_esc() {
_gb_rest="${1-}"
_gb_out=''
_gb_nl='
'
_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
case "$_gb_ch" in
'"') _gb_out="$_gb_out\\\"" ;;
'\') _gb_out="$_gb_out\\\\" ;;
"$_gb_nl") _gb_out="$_gb_out\\n" ;;
"$_gb_tab") _gb_out="$_gb_out\\t" ;;
"$_gb_cr") _gb_out="$_gb_out\\r" ;;
*) _gb_out="$_gb_out$_gb_ch" ;;
esac
done
printf '%s' "$_gb_out"
}
# gb_json_str <string> -- an escaped, quoted JSON string, or null when empty.
gb_json_str() {
if [ -z "${1-}" ]; then
printf 'null'
else
printf '"%s"' "$(gb_json_esc "$1")"
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' ;;
*) printf 'false' ;;
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() {
_gb_mf_obj="$1"
_gb_mf_field="$2"
case "$_gb_mf_obj" in
*'"'"$_gb_mf_field"'":"'*)
_gb_mf_rest="${_gb_mf_obj#*\""$_gb_mf_field"\":\"}"
_gb_mf_v=''
_gb_mf_bs=0
while [ -n "$_gb_mf_rest" ]; do
_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
fi
# shellcheck disable=SC1003 # the '\' branch matches a literal backslash, not an escaped quote
case "$_gb_mf_ch" in
'\') _gb_mf_bs=1 ;;
'"') break ;;
*) _gb_mf_v="$_gb_mf_v$_gb_mf_ch" ;;
esac
done
printf '%s' "$_gb_mf_v"
;;
*'"'"$_gb_mf_field"'":'*)
_gb_mf_v="${_gb_mf_obj#*\""$_gb_mf_field"\":}"
_gb_mf_v="${_gb_mf_v%%[,\}]*}"
[ "$_gb_mf_v" = null ] && _gb_mf_v=''
printf '%s' "$_gb_mf_v"
;;
*) printf '' ;;
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() {
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() {
_gb_me_manifest="$1"
_gb_me_section="$2"
_gb_me_cb="$3"
_gb_me_in=0
while IFS= read -r _gb_me_line || [ -n "$_gb_me_line" ]; do
case "$_gb_me_line" in
' "'"$_gb_me_section"'": ['*) _gb_me_in=1; continue ;;
' ],' | ' ]')
[ "$_gb_me_in" -eq 1 ] && _gb_me_in=0
continue
;;
esac
[ "$_gb_me_in" -eq 1 ] || continue
_gb_me_obj="${_gb_me_line%,}"
_gb_me_obj="${_gb_me_obj# }"
[ -n "$_gb_me_obj" ] || continue
"$_gb_me_cb" "$_gb_me_obj"
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.
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() {
_gb_hn_host="$1"
_gb_hn_port="${2:-443}"
nc "$_gb_hn_host" "$_gb_hn_port" </dev/null >/dev/null 2>&1 &
_gb_hn_pid=$!
_gb_hn_waited=0
while kill -0 "$_gb_hn_pid" 2>/dev/null; do
if [ "$_gb_hn_waited" -ge 5 ]; then
kill "$_gb_hn_pid" 2>/dev/null
wait "$_gb_hn_pid" 2>/dev/null
return 1
fi
sleep 1
_gb_hn_waited=$((_gb_hn_waited + 1))
done
wait "$_gb_hn_pid" 2>/dev/null
return $?
}
@@ -0,0 +1,266 @@
# 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.
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.
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
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.
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
[ -f "$GB_ROOT$_gb_pkb_p" ] || continue
wc -c <"$GB_ROOT$_gb_pkb_p" 2>/dev/null
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() {
_gb_pv_path="$1"
case "$_gb_pv_path" in
/*) ;;
*)
printf '%s: not an absolute path\n' "$_gb_pv_path" >&2
return 2
;;
esac
case "$_gb_pv_path" in
*' '*)
printf '%s: contains a space -- sysupgrade.conf lines become find(1) arguments and cannot quote one\n' "$_gb_pv_path" >&2
return 2
;;
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.
_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
return 2
fi
case "$_gb_pv_path" in
/etc/gitbackup | /etc/gitbackup/*)
printf '%s: reserved for gitbackup itself\n' "$_gb_pv_path" >&2
return 4
;;
/proc | /proc/*)
printf '%s: not a real filesystem path\n' "$_gb_pv_path" >&2
return 4
;;
/sys | /sys/*)
printf '%s: not a real filesystem path\n' "$_gb_pv_path" >&2
return 4
;;
/tmp | /tmp/*)
printf '%s: cleared on every reboot\n' "$_gb_pv_path" >&2
return 4
;;
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.
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
fi
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 $?
if [ -r "$GB_SYSUPGRADE_CONF" ] && grep -qxF "$_gb_pa_path" "$GB_SYSUPGRADE_CONF" 2>/dev/null; then
return 0
fi
mkdir -p "$(dirname "$GB_SYSUPGRADE_CONF")" 2>/dev/null
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.
gb_paths_del() {
_gb_pd_path="$1"
[ -r "$GB_SYSUPGRADE_CONF" ] || return 0
_gb_pd_tmp="${GB_SYSUPGRADE_CONF}.tmp.$$"
grep -vxF "$_gb_pd_path" "$GB_SYSUPGRADE_CONF" >"$_gb_pd_tmp" 2>/dev/null
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.
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_pre_tmp"
mv "$_gb_pre_tmp" "$GB_SYSUPGRADE_CONF"
}
@@ -0,0 +1,216 @@
# 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.
# 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() {
_gb_u="$1"
_gb_scheme=''
_gb_host=''
_gb_port=''
_gb_owner=''
_gb_repo=''
case "$_gb_u" in
https://*)
_gb_scheme=https
_gb_rest="${_gb_u#https://}"
_gb_parse_hostport_path "$_gb_rest" || return 1
;;
ssh://*)
_gb_scheme=ssh
_gb_rest="${_gb_u#ssh://}"
case "$_gb_rest" in
*@*) _gb_rest="${_gb_rest#*@}" ;;
esac
_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.
_gb_scheme=ssh
_gb_port=''
_gb_userhost="${_gb_u%%:*}"
case "$_gb_userhost" in
*/*) return 1 ;;
esac
[ -n "$_gb_userhost" ] || return 1
case "$_gb_userhost" in
*@*) _gb_host="${_gb_userhost#*@}" ;;
*) _gb_host="$_gb_userhost" ;;
esac
_gb_parse_ownerrepo "${_gb_u#*:}" || return 1
;;
*)
return 1
;;
esac
[ -n "$_gb_host" ] || return 1
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.
_gb_parse_hostport_path() {
_gb_r="$1"
case "$_gb_r" in
*/*) ;;
*) return 1 ;;
esac
_gb_hostport="${_gb_r%%/*}"
_gb_path="${_gb_r#*/}"
case "$_gb_hostport" in
*:*)
_gb_host="${_gb_hostport%%:*}"
_gb_port="${_gb_hostport#*:}"
case "$_gb_port" in
''|*[!0-9]*) return 1 ;;
esac
;;
*)
_gb_host="$_gb_hostport"
_gb_port=''
;;
esac
[ -n "$_gb_host" ] || return 1
_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.
_gb_parse_ownerrepo() {
_gb_p="$1"
case "$_gb_p" in
*/*/*) return 1 ;;
*/*) ;;
*) return 1 ;;
esac
_gb_owner="${_gb_p%%/*}"
_gb_repo="${_gb_p#*/}"
_gb_repo="${_gb_repo%.git}"
_gb_charset_ok "$_gb_owner" || return 1
_gb_charset_ok "$_gb_repo" || return 1
return 0
}
# _gb_charset_ok <string> -- non-empty and only [A-Za-z0-9._-].
_gb_charset_ok() {
[ -n "$1" ] || return 1
case "$1" in
*[!A-Za-z0-9._-]*) return 1 ;;
esac
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() {
_gb_host="$1"
_gb_opt=$(gb_uci_get gitbackup.origin.provider auto)
case "$_gb_opt" in
auto) ;;
*) printf '%s\n' "$_gb_opt"; return 0 ;;
esac
case "$_gb_host" in
github.com) printf 'github\n' ;;
gitlab.com) printf 'gitlab\n' ;;
bitbucket.org) printf 'bitbucket\n' ;;
codeberg.org) printf 'gitea\n' ;;
*) 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
}
@@ -0,0 +1,774 @@
# 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.
#
# 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.
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.
gb_restore() {
_gb_device="$1"
_gb_commit="${2:-}"
shift 2
_gb_dry=0
_gb_force=0
_gb_yes=0
_gb_wp=0
for _gb_flag in "$@"; do
case "$_gb_flag" in
--dry-run) _gb_dry=1 ;;
--force) _gb_force=1 ;;
--yes) _gb_yes=1 ;;
--with-packages) _gb_wp=1 ;;
esac
done
[ -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")
# 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.
_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
_gb_repodir="$_gb_work/repo"
if [ -z "$_gb_commit" ] || [ "$_gb_commit" = HEAD ]; then
_gb_tip=$(gb_remote_head "$_gb_branch")
_gb_tip_rc=$?
[ "$_gb_tip_rc" -eq 0 ] || return 3
if [ -z "$_gb_tip" ]; then
gb_log err "gb_restore: $_gb_branch does not exist on $GB_URL yet -- nothing to restore"
return 1
fi
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.
gb_fetch_meta "$_gb_branch" "$_gb_repodir" '' || return 3
_gb_target="$_gb_commit"
fi
if ! git -C "$_gb_repodir" cat-file -e "$_gb_target^{commit}" 2>/dev/null; then
gb_log err "gb_restore: commit $_gb_target was not found on $_gb_branch at $GB_URL"
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.
_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
gb_log err "gb_restore: could not read $_gb_prefix from $_gb_target on $_gb_branch: $_gb_co_err"
return 1
fi
_gb_srcroot="$_gb_repodir/$_gb_prefix"
_gb_manifest="$_gb_srcroot/manifest.json"
if [ ! -r "$_gb_manifest" ]; then
gb_log err "gb_restore: $_gb_prefix/manifest.json missing at $_gb_target -- nothing to 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.
_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
}
_gb_restore_check_board "$_gb_srcroot/meta/board.json" "$_gb_force"
_gb_board_rc=$?
[ "$_gb_board_rc" -eq 0 ] || return "$_gb_board_rc"
_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.
_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.
_gb_restore_check_writable "$_gb_manifest"
if [ "$_gb_dry" -eq 1 ]; then
_gb_restore_print_plan "$_gb_manifest"
return 0
fi
if [ "$_gb_yes" -ne 1 ]; then
_gb_restore_print_plan "$_gb_manifest" >&2
printf 'Restore %s from %s onto this router? [y/N] ' "$_gb_device" "$_gb_target" >&2
IFS= read -r _gb_ans
case "$_gb_ans" in
y|Y|yes|YES) ;;
*) gb_log notice 'gb_restore: declined by the operator'; return 1 ;;
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.
_gb_restore_apply_perms "$_gb_manifest"
_gb_restore_print_scrubbed "$_gb_manifest"
[ "$_gb_wp" -eq 1 ] && _gb_restore_packages "$_gb_srcroot/meta"
if [ "$_gb_write_rc" -ne 0 ]; then
gb_log err "gb_restore: the following paths were NOT written:$_gb_write_bad"
printf 'restore of %s from %s finished, but some paths were NOT written -- see above for which and why\n' \
"$_gb_device" "$_gb_target" >&2
return 1
fi
gb_log notice "gb_restore: restored $_gb_device from $_gb_target on $_gb_branch"
printf 'restored %s from %s\n' "$_gb_device" "$_gb_target"
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.
_gb_restore_check_board() {
_gb_old_board="$1"
_gb_force="$2"
if [ ! -r "$_gb_old_board" ]; then
gb_log warning 'gb_restore: no meta/board.json in this backup, skipping the board check'
return 0
fi
_gb_old_json=$(cat "$_gb_old_board")
_gb_new_json=$(ubus call system board 2>/dev/null)
_gb_old_model=$(printf '%s' "$_gb_old_json" | jsonfilter -e '@.model' 2>/dev/null)
_gb_new_model=$(printf '%s' "$_gb_new_json" | jsonfilter -e '@.model' 2>/dev/null)
_gb_old_target=$(printf '%s' "$_gb_old_json" | jsonfilter -e '@.release.target' 2>/dev/null)
_gb_new_target=$(printf '%s' "$_gb_new_json" | jsonfilter -e '@.release.target' 2>/dev/null)
if [ "$_gb_old_model" = "$_gb_new_model" ] && [ "$_gb_old_target" = "$_gb_new_target" ]; then
return 0
fi
if [ "$_gb_force" -eq 1 ]; then
gb_log notice "gb_restore: board mismatch (model '$_gb_old_model' -> '$_gb_new_model', target '$_gb_old_target' -> '$_gb_new_target'), proceeding: --force"
return 0
fi
gb_log err "gb_restore: this backup was taken on a different board (model '$_gb_old_model' vs '$_gb_new_model', target '$_gb_old_target' vs '$_gb_new_target') -- interface names and wireless radios from that hardware will not match this one; pass --force to restore anyway"
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.
_gb_restore_check_release() {
_gb_old_rel="$1"
[ -r "$_gb_old_rel" ] || return 0
_gb_old_ver=$(sed -n 's/^VERSION_ID="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' "$_gb_old_rel" | head -n 1)
_gb_new_ver=''
[ -r "${GB_ROOT:-}/etc/os-release" ] &&
_gb_new_ver=$(sed -n 's/^VERSION_ID="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' "${GB_ROOT:-}/etc/os-release" | head -n 1)
[ -n "$_gb_old_ver" ] && [ -n "$_gb_new_ver" ] || return 0
_gb_old_major="${_gb_old_ver%%.*}"
_gb_new_major="${_gb_new_ver%%.*}"
if [ "$_gb_old_major" != "$_gb_new_major" ]; then
gb_log warning "gb_restore: this backup was taken on OpenWrt $_gb_old_ver, this router runs $_gb_new_ver -- config options from a different major release may not apply cleanly"
fi
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.
_gb_restore_verify_sha() {
_gb_srcfiles="$1"
_gb_manifest="$2"
_gb_sha_bad=''
gb_manifest_each "$_gb_manifest" entries _gb_restore_verify_sha_one
if [ -n "$_gb_sha_bad" ]; then
gb_log err "gb_restore: sha256 mismatch, refusing to write anything to disk:$_gb_sha_bad"
return 1
fi
return 0
}
_gb_restore_verify_sha_one() {
_gb_obj="$1"
case "$_gb_obj" in
*'"type":"file"'*) ;;
*) return 0 ;;
esac
_gb_path=$(gb_manifest_field "$_gb_obj" path)
_gb_want=$(gb_manifest_field "$_gb_obj" sha256)
_gb_got=$(sha256sum "$_gb_srcfiles$_gb_path" 2>/dev/null | awk '{print $1}')
[ -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.
_gb_restore_check_entries() {
_gb_manifest="$1"
_gb_entries_bad=''
gb_manifest_each "$_gb_manifest" entries _gb_restore_check_entries_one
[ -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"
}
_gb_restore_check_entries_one() {
_gb_obj="$1"
_gb_ce_path=$(gb_manifest_field "$_gb_obj" path)
_gb_ce_type=$(gb_manifest_field "$_gb_obj" type)
_gb_ce_mode=$(gb_manifest_field "$_gb_obj" mode)
_gb_ce_uid=$(gb_manifest_field "$_gb_obj" uid)
_gb_ce_gid=$(gb_manifest_field "$_gb_obj" gid)
_gb_ce_shown="$_gb_ce_path"
[ -n "$_gb_ce_shown" ] || _gb_ce_shown='(entry with no path)'
case "$_gb_ce_path" in
/*) ;;
*)
_gb_restore_entry_reject "$_gb_ce_shown" 'not an absolute path'
return 0
;;
esac
if [ "$(gb_path_canon "$_gb_ce_path")" != "$_gb_ce_path" ]; then
_gb_restore_entry_reject "$_gb_ce_shown" 'not a canonical path'
return 0
fi
case "$_gb_ce_type" in
file | dir | symlink) ;;
*)
_gb_restore_entry_reject "$_gb_ce_shown" "unknown entry type '$_gb_ce_type'"
return 0
;;
esac
if [ -n "$_gb_ce_mode" ]; then
case "$_gb_ce_mode" in
[0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;;
*)
_gb_restore_entry_reject "$_gb_ce_shown" "mode '$_gb_ce_mode' is not 3 or 4 octal digits"
return 0
;;
esac
fi
for _gb_ce_id in "$_gb_ce_uid" "$_gb_ce_gid"; do
[ -n "$_gb_ce_id" ] || continue
case "$_gb_ce_id" in
*[!0-9]*)
_gb_restore_entry_reject "$_gb_ce_shown" "owner id '$_gb_ce_id' is not numeric"
return 0
;;
esac
done
}
_gb_restore_check_writable() {
_gb_manifest="$1"
_gb_precheck_bad=''
gb_manifest_each "$_gb_manifest" entries _gb_restore_check_writable_one
}
_gb_restore_check_writable_one() {
_gb_obj="$1"
case "$_gb_obj" in
*'"type":"file"'*) ;;
*) return 0 ;;
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")
if [ ! -w "$_gb_wdir" ]; then
_gb_precheck_bad="$_gb_precheck_bad $_gb_path"
return 0
fi
_gb_dest_dev=$(stat -c %d "$_gb_dest" 2>/dev/null)
_gb_wdir_dev=$(stat -c %d "$_gb_wdir" 2>/dev/null)
if [ -n "$_gb_dest_dev" ] && [ -n "$_gb_wdir_dev" ] && [ "$_gb_dest_dev" != "$_gb_wdir_dev" ]; then
_gb_precheck_bad="$_gb_precheck_bad $_gb_path"
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'
gb_manifest_each "$_gb_manifest" entries _gb_restore_plan_one
}
_gb_restore_plan_one() {
_gb_obj="$1"
_gb_type=$(gb_manifest_field "$_gb_obj" type)
_gb_path=$(gb_manifest_field "$_gb_obj" path)
_gb_dest="${GB_ROOT:-}$_gb_path"
if [ -e "$_gb_dest" ] || [ -L "$_gb_dest" ]; then
printf ' overwrite %s (%s)\n' "$_gb_path" "$_gb_type"
else
printf ' create %s (%s)\n' "$_gb_path" "$_gb_type"
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.
_gb_restore_write_files() {
_gb_srcfiles="$1"
_gb_manifest="$2"
_gb_write_bad=''
gb_manifest_each "$_gb_manifest" entries _gb_restore_write_one
[ -z "$_gb_write_bad" ]
}
_gb_restore_write_one() {
_gb_obj="$1"
case "$_gb_obj" in
*'"type":"file"'*) ;;
*) return 0 ;;
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
$_gb_path: destination cannot be replaced (not writable, or a distinct mount point sits exactly on this path) -- skipped, not attempted"
return 0
;;
esac
_gb_dest="${GB_ROOT:-}$_gb_path"
_gb_mkdir_err=$(mkdir -p "$(dirname "$_gb_dest")" 2>&1) || {
_gb_write_bad="$_gb_write_bad
$_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.
[ -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.
_gb_cp_err=$(cp -f "$_gb_srcfiles$_gb_path" "$_gb_dest" 2>&1) || {
_gb_write_bad="$_gb_write_bad
$_gb_path: $_gb_cp_err"
return 0
}
}
# _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).
_gb_restore_apply_perms() {
_gb_manifest="$1"
gb_manifest_each "$_gb_manifest" entries _gb_restore_perm_one
}
_gb_restore_perm_one() {
_gb_obj="$1"
_gb_type=$(gb_manifest_field "$_gb_obj" type)
_gb_path=$(gb_manifest_field "$_gb_obj" path)
_gb_mode=$(gb_manifest_field "$_gb_obj" mode)
_gb_uid=$(gb_manifest_field "$_gb_obj" uid)
_gb_gid=$(gb_manifest_field "$_gb_obj" gid)
_gb_dest="${GB_ROOT:-}$_gb_path"
case "$_gb_type" in
dir)
mkdir -p "$_gb_dest" 2>/dev/null
[ -n "$_gb_mode" ] && chmod "$_gb_mode" "$_gb_dest" 2>/dev/null
[ -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.
_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.
[ -n "$_gb_uid" ] && [ -n "$_gb_gid" ] && chown -h "$_gb_uid:$_gb_gid" "$_gb_dest" 2>/dev/null
;;
file)
[ -n "$_gb_mode" ] && chmod "$_gb_mode" "$_gb_dest" 2>/dev/null
[ -n "$_gb_uid" ] && [ -n "$_gb_gid" ] && chown "$_gb_uid:$_gb_gid" "$_gb_dest" 2>/dev/null
;;
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.
_gb_restore_print_scrubbed() {
_gb_manifest="$1"
_gb_scrub_any=0
gb_manifest_each "$_gb_manifest" scrubbed _gb_restore_scrubbed_one
return 0
}
_gb_restore_scrubbed_one() {
_gb_obj="$1"
_gb_opt=$(gb_manifest_field "$_gb_obj" option)
[ -n "$_gb_opt" ] || return 0
if [ "$_gb_scrub_any" -eq 0 ]; then
printf 'The following values were redacted before this backup was pushed -- enter them by hand:\n' >&2
_gb_scrub_any=1
fi
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.
_gb_restore_packages() {
_gb_meta="$1"
_gb_repos="$_gb_meta/repositories.txt"
if [ -s "$_gb_repos" ]; then
mkdir -p "${GB_ROOT:-}/etc/apk/repositories.d" 2>/dev/null
cp "$_gb_repos" "${GB_ROOT:-}/etc/apk/repositories.d/gitbackup-restored.list" 2>/dev/null
fi
_gb_installed="$_gb_meta/installed_packages.txt"
[ -r "$_gb_installed" ] || return 0
_gb_pkg_failed=''
while IFS= read -r _gb_line || [ -n "$_gb_line" ]; do
_gb_pkg=$(printf '%s\n' "$_gb_line" | sed -n 's/.*{\([^}]*\)}.*/\1/p')
[ -n "$_gb_pkg" ] || continue
apk add "$_gb_pkg" >/dev/null 2>&1 || _gb_pkg_failed="$_gb_pkg_failed $_gb_pkg"
done <"$_gb_installed"
if [ -n "$_gb_pkg_failed" ]; then
printf 'the following packages could not be reinstalled automatically, add them by hand:%s\n' "$_gb_pkg_failed"
fi
return 0
}
@@ -0,0 +1,452 @@
# 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.
# 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() {
_gb_preset="$1"
_gb_dev="$2"
_gb_hash=$(printf '%s' "$_gb_dev" | sha256sum | cut -c1-8) || return 1
case "$_gb_hash" in ''|*[!0-9a-fA-F]*) return 1 ;; esac
_gb_n=$(( 0x$_gb_hash ))
_gb_m=$(( _gb_n % 60 ))
_gb_h=$(( _gb_n % 6 ))
_gb_d=$(( _gb_n % 7 ))
case "$_gb_preset" in
hourly) printf '%s * * * *\n' "$_gb_m" ;;
daily) printf '%s %s * * *\n' "$_gb_m" "$_gb_h" ;;
weekly) printf '%s %s * * %s\n' "$_gb_m" "$_gb_h" "$_gb_d" ;;
*) return 1 ;;
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_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.
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
set +f
IFS="$_gb_fv_saved_ifs"
[ "$#" -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.
[ "$_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#*-}"
case "$_gb_fv_lo" in ''|*[!0-9]*) return 1 ;; esac
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.
[ "$_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() {
_gb_cv_expr="${1-}"
case "$_gb_cv_expr" in
'@'*)
printf 'busybox crond does not understand "%s" (no @-macro support on 25.12); use an explicit expression, e.g. "0 3 * * *" for once a day at 03:00\n' \
"$_gb_cv_expr" >&2
return 1
;;
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
set +f
if [ "$#" -ne 5 ]; then
printf 'a cron expression needs exactly 5 fields (minute hour day-of-month month day-of-week), got %s in "%s"\n' \
"$#" "$_gb_cv_expr" >&2
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.
_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'; }
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
return 1
fi
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() {
_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.
rm -f "${_gb_ca_crontab}".newtmp.* 2>/dev/null
_gb_ca_tmp="${_gb_ca_crontab}.newtmp.$$"
if [ -f "$_gb_ca_crontab" ]; then
grep -v '# gitbackup$' "$_gb_ca_crontab" >"$_gb_ca_tmp" 2>/dev/null
else
: >"$_gb_ca_tmp"
fi
case "$_gb_ca_sched" in
off) ;;
hourly|weekly|daily)
if _gb_ca_dev=$(gb_device_id 2>/dev/null); then
_gb_ca_expr=$(gb_preset_expr "$_gb_ca_sched" "$_gb_ca_dev") || _gb_ca_expr=''
fi
;;
cron)
_gb_ca_expr=$(gb_uci_get gitbackup.main.cron_expr)
;;
*)
gb_log err "gitbackup.main.schedule: unknown value '$_gb_ca_sched', crontab left without a gitbackup entry"
;;
esac
if [ "$_gb_ca_sched" != off ]; then
if [ -n "$_gb_ca_expr" ] && gb_cron_valid "$_gb_ca_expr" 2>/dev/null; then
printf '%s /usr/sbin/gitbackup run >/dev/null 2>&1 # gitbackup\n' "$_gb_ca_expr" >>"$_gb_ca_tmp"
else
gb_log err "gitbackup: schedule '$_gb_ca_sched' did not produce a cron expression busybox crond can run, crontab left without a gitbackup entry"
fi
fi
mv "$_gb_ca_tmp" "$_gb_ca_crontab"
if [ -x /etc/init.d/cron ]; then
/etc/init.d/cron enable
/etc/init.d/cron restart
fi
}
@@ -0,0 +1,100 @@
# 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": "собирается обходом реальных источников, не из головы").
#
# 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.
#
# 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.
# 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.@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.@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.@wifi-iface[*].password
# wireless -- 802.1X/WPA-Enterprise 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.@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,
# 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
# 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
# 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_*[*].preshared_key
# ddns -- login credentials substituted into the update URL, on a 'service'
# section (uci.add('ddns', 'service', section_id)): 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.@httpauth[*].username
uhttpd.@httpauth[*].password
@@ -0,0 +1,394 @@
# 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.
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.
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() {
_gb_outdir="$1"
[ -n "$_gb_outdir" ] || { gb_log err 'gb_scrub: outdir is required'; return 1; }
_gb_confdir="$_gb_outdir/files/etc/config"
_gb_manifest="$_gb_outdir/manifest.json"
_gb_scratch=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-scrub.XXXXXX") || return 1
_gb_savedir="$_gb_scratch/save"
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"
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.
_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.
_gb_scrub_load_patterns | sort -u >"$_gb_scratch/patterns"
[ -s "$_gb_scratch/patterns" ] || return 0
_gb_configs=$(awk '{print $1}' "$_gb_scratch/patterns" | sort -u)
for _gb_cfg in $_gb_configs; do
[ -f "$_gb_confdir/$_gb_cfg" ] || continue
_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.
uci -c "$_gb_confdir" show "$_gb_cfg" 2>/dev/null | \
while IFS= read -r _gb_l; do
case "$_gb_l" in
"$_gb_cfg".*=*)
_gb_rest=${_gb_l#"$_gb_cfg".}
case "$_gb_rest" in
*.*) continue ;;
esac
printf '%s\t%s\n' "${_gb_rest%%=*}" "${_gb_rest#*=}" >>"$_gb_sections"
;;
esac
done
_gb_touched="$_gb_scratch/touched.$_gb_cfg"
rm -f "$_gb_touched"
while IFS=' ' read -r _gb_pc _gb_ptype _gb_popt; do
[ "$_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.
# 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 -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
printf '/etc/config/%s\t%s\n' "$_gb_cfg" "$_gb_path" >>"$_gb_scratch/records"
: >"$_gb_touched"
done <"$_gb_sections"
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.
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"
fi
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.
_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.
# 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
_gb_scrub_parse_pattern "$_gb_p"
done
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.
_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.
set -f
# shellcheck disable=SC2086 # word-splitting trims surrounding blanks; a pattern itself has none
set -- $_gb_raw
set +f
_gb_raw="${1:-}"
[ -n "$_gb_raw" ] || return 0
_gb_cfg="${_gb_raw%%.*}"
_gb_rest="${_gb_raw#*.}"
case "$_gb_rest" in
'@'*'[*].'*)
_gb_type="${_gb_rest#@}"
_gb_type="${_gb_type%%\[\*\]*}"
_gb_opt="${_gb_rest##*\].}"
printf '%s %s %s\n' "$_gb_cfg" "$_gb_type" "$_gb_opt"
;;
*)
gb_log warning "gitbackup scrub: unrecognized scrub.list pattern '$_gb_raw', ignored"
;;
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.
_gb_scrub_records_json() {
[ -s "$1" ] || return 0
while IFS=' ' read -r _gb_rp _gb_ro || [ -n "$_gb_rp" ]; do
[ -n "$_gb_rp" ] || continue
printf '{"path":%s,"option":%s}\n' "$(gb_json_str "$_gb_rp")" "$(gb_json_str "$_gb_ro")"
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() {
_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"
}
@@ -0,0 +1,177 @@
# 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.
# 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() {
_gb_url="$1"
_gb_parsed=$(gb_parse_url "$_gb_url") ||
gb_die 2 "gitbackup.origin.url: '$_gb_url' does not match any supported remote URL form"
# shellcheck disable=SC2086 # word-splitting is the point: five fields from one line
set -- $_gb_parsed
_gb_scheme="$1"
_gb_host="$2"
_gb_port="$3"
_gb_owner="$4"
_gb_repo="$5"
_gb_provider=$(gb_provider "$_gb_host")
if [ "$_gb_provider" = generic ]; then
_gb_ack=$(gb_uci_get gitbackup.origin.acknowledged 0)
if [ "$(gb_json_bool "$_gb_ack")" != true ]; then
gb_die 2 "gitbackup.origin.provider=generic: visibility cannot be checked automatically; a public repository here would expose /etc/shadow, dropbear private host keys, authorized_keys, WPA PSK, WireGuard private keys, PPPoE credentials and uhttpd.key -- set gitbackup.origin.acknowledged=1 only after confirming the repository is actually private"
fi
gb_log notice "gitbackup.origin.provider=generic: visibility cannot be verified automatically, proceeding on the operator's acknowledgement (gitbackup.origin.acknowledged=1)"
return 0
fi
_gb_state="${GB_STATE_DIR:-/var/run/gitbackup}"
_gb_cache="$_gb_state/visibility"
_gb_now=$(date +%s)
if [ -r "$_gb_cache" ]; then
_gb_cached_url=$(sed -n '1p' "$_gb_cache" 2>/dev/null)
_gb_cached_ts=$(sed -n '2p' "$_gb_cache" 2>/dev/null)
_gb_cached_code=$(sed -n '3p' "$_gb_cache" 2>/dev/null)
case "$_gb_cached_ts" in
''|*[!0-9]*) _gb_cached_ts='' ;;
esac
if [ "$_gb_cached_url" = "$_gb_url" ] && [ -n "$_gb_cached_ts" ] &&
[ $((_gb_now - _gb_cached_ts)) -lt 86400 ] && [ $((_gb_now - _gb_cached_ts)) -ge 0 ]; then
return "$_gb_cached_code"
fi
fi
_gb_api_url=$(_gb_visibility_api_url "$_gb_provider" "$_gb_scheme" "$_gb_host" "$_gb_port" "$_gb_owner" "$_gb_repo") ||
gb_die 1 "gitbackup: no anonymous visibility API is known for provider '$_gb_provider'"
_gb_visibility_probe "$_gb_api_url"
case $? in
0)
_gb_result=4
gb_log warning "$_gb_url is visible to an anonymous request ($_gb_api_url -> HTTP 200); push refused"
;;
1)
_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.
return 3
;;
esac
mkdir -p "$_gb_state" 2>/dev/null
printf '%s\n%s\n%s\n' "$_gb_url" "$_gb_now" "$_gb_result" >"$_gb_cache" 2>/dev/null
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).
_gb_visibility_api_url() {
case "$1" in
github)
printf 'https://api.github.com/repos/%s/%s\n' "$5" "$6"
;;
bitbucket)
printf 'https://api.bitbucket.org/2.0/repositories/%s/%s\n' "$5" "$6"
;;
gitlab)
printf 'https://%s/api/v4/projects/%s%%2F%s\n' "$(_gb_hostport_for_api "$2" "$3" "$4")" "$5" "$6"
;;
gitea)
printf 'https://%s/api/v1/repos/%s/%s\n' "$(_gb_hostport_for_api "$2" "$3" "$4")" "$5" "$6"
;;
*)
return 1
;;
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.
_gb_hostport_for_api() {
if [ "$1" = https ] && [ "${3:-0}" != 0 ]; then
printf '%s:%s\n' "$2" "$3"
else
printf '%s\n' "$2"
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).
_gb_visibility_probe() {
_gb_probe_msg=$(uclient-fetch --timeout=10 -O /dev/null "$1" 2>&1 >/dev/null)
_gb_probe_rc=$?
case "$_gb_probe_rc" in
0) return 0 ;;
8)
case "$_gb_probe_msg" in
*'HTTP error 404'*) return 1 ;;
*)
gb_log info "gitbackup visibility check: $1 -> $_gb_probe_msg"
return 2
;;
esac
;;
*)
gb_log info "gitbackup visibility check: $1 unreachable ($_gb_probe_msg)"
return 2
;;
esac
}
+29
View File
@@ -0,0 +1,29 @@
# 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 }
+39
View File
@@ -0,0 +1,39 @@
#
# Copyright (C) 2026 VizzleTF
#
# This is free software, licensed under the GNU General Public License v2.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-gitbackup
PKG_VERSION:=0.1.1
PKG_RELEASE:=1
PKG_LICENSE:=GPL-2.0-only
PKG_MAINTAINER:=VizzleTF <vizzlef@gmail.com>
LUCI_TITLE:=Automatic configuration backup to a Git repository
# gitbackup itself (package/gitbackup) is where usr/sbin/gitbackup, the
# rpcd plugin and every shell module live -- this app is client-side JS
# only (ticket 11 acceptance criterion: "ни строки Lua, ни luci-compat, ни
# luci-lib-ipkg -- ни в коде, ни в зависимостях") and has nothing to render
# without it.
LUCI_DEPENDS:=+gitbackup
# Every byte here is POSIX shell (menu.d/acl.d JSON is not shell, but is not
# compiled either), plain JSON and client-side JS -- not one compiled byte,
# same reasoning as package/gitbackup's own PKGARCH:=all (spec, "Упаковка").
# LUCI_PKGARCH already defaults to "all" via luci.mk's own
# `LUCI_PKGARCH?=$(if $(realpath src/Makefile),,all)` (no src/Makefile here),
# but ticket 11's acceptance criterion asks for it spelled out explicitly.
LUCI_PKGARCH:=all
# This repository is a standalone third-party app, not a checkout living
# inside the openwrt/luci monorepo (unlike e.g. applications/luci-app-ttyd,
# which can say `include $(TOPDIR)/feeds/luci/luci.mk is two
# directories up from it there) -- so the helper has to be referenced by
# its real location in a buildroot that has the "luci" feed checked out,
# exactly as spec "Упаковка" names it.
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,142 @@
'use strict';
// gitbackup -- shared diff viewer (ticket 23, spec "Не пиши второй
// просмотрщик diff"). Before this file existed, history.js was the only
// place in the project that rendered a diff into a colored <pre>; ticket 23
// adds a second place that needs one (Overview's "Configuration" card, R124/
// R125's own config_diff indicator, which used to show only the bare
// differs/does-not-differ flag and threw the actual text away). A second,
// separately-typed copy of history.js's own gbDiffLineClass/gbBuildDiffPre
// would be exactly the "третья реализация" ticket 23 explicitly forbids, so
// both call sites use this file instead.
//
// This is deliberately TWO renderers, not one that auto-detects its input,
// because the two rpcd methods that feed them answer genuinely different
// questions in genuinely different formats -- collapsing them into one
// "smart" parser would have to guess which format it was looking at from
// the text alone, and a manifest line that happened to start with "+" or
// "-" (this format's own add/remove marker) would be indistinguishable from
// a coincidence:
//
// unified(text) -- for `diff` (gbrpc_diff, two already-committed shas,
// History's per-commit view): plain `git diff --end-of-options <a> <b>`
// output (usr/sbin/gitbackup's cmd_diff, its two-argument form) -- real
// unified-diff syntax, "diff --git a/X b/X" headers, "@@ ... @@" hunks,
// body lines prefixed ' '/'+'/'-'.
//
// manifest(text) -- for `config_diff` (gbrpc_config_diff, no params,
// Overview's live-drift check): cmd_diff's OTHER, zero-argument form,
// which never calls `git diff` at all -- it walks both manifests'
// entries field by field (_gb_diff_compare/_gb_diff_describe_change,
// usr/sbin/gitbackup) and prints one line per changed path: "+ path
// (type)" (added), "- path (type)" (removed), or "~ path: field
// a->b; ..." (changed) -- the only way this project can show a chmod/
// chown a real `git diff` never sees at all (same blob, same tree entry
// mode class either way; interfaces.md, ticket 17/D03).
//
// No content goes through innerHTML anywhere below -- every line is its own
// text node built through E(), the same "чужие данные никогда не
// становятся разметкой" rule history.js's own diff viewer already followed
// (that file's own header comment, out of this ticket's zone, read before
// writing this).
//
// That holds only because every child below is handed to E() INSIDE AN
// ARRAY, and this file originally got that wrong. LuCI's dom.append()
// branches on the shape of its `children` argument: the Array branch is the
// one that runs each string through document.createTextNode(), while a bare
// string falls all the way through to `node.innerHTML = '' + children` and
// is parsed as markup. `E('span', attrs, line)` therefore rendered a diff
// line AS HTML -- and the text here is `git diff` output, i.e. the contents
// of files fetched from the backup remote, which anyone with push access to
// that repository controls byte for byte. `E('span', attrs, [ line ])` is
// the fix, and the bracket is load-bearing, not punctuation: nothing else
// escapes on the way here (gb_json_esc escapes only what JSON needs, LuCI's
// String.format('%s') does not escape at all, and a LuCI page carries no
// CSP). tests/dom_text_children.test.mjs holds the whole view directory to
// this, so a future call site cannot quietly drop the brackets again.
// css -- the class names both renderers below emit, meant to be spliced
// into each CALLING view's own <style> block (rule 1 of the style guide:
// every view returns its styles inside its own tree, never through
// document.head.appendChild -- a shared module has no tree of its own to
// return one in). A plain exported array of rule strings, not a <style>
// this file injects itself, keeps that rule intact while still giving both
// call sites the exact same rules instead of two hand-copied approximations
// of them.
var GB_DIFFVIEW_CSS = [
'.gitbackup-diff { max-height: 420px; overflow: auto; background: var(--background-color-low, #f5f5f5); border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .8em; font-family: monospace; font-size: .85em; white-space: pre-wrap; }',
'.gitbackup-diffline-add { display: block; color: var(--success-color-high, #2e7d32); }',
'.gitbackup-diffline-del { display: block; color: var(--error-color-high, #c62828); }',
'.gitbackup-diffline-change { display: block; color: var(--warn-color-high, #b45f06); }',
'.gitbackup-diffline-hunk { display: block; color: var(--text-color-medium, #666); }',
'.gitbackup-diffline-meta { display: block; font-weight: bold; color: var(--text-color-medium, #666); }',
'.gitbackup-diffline-ctx { display: block; color: var(--text-color-high, #333); }'
];
// gbUnifiedLineClass <line> -- one prefixed class per line of a real `git
// diff` (spec: "Diff-viewer -- свой <pre> с префиксованными классами и
// токенами --success-*/--error-*. Никаких внешних библиотек подсветки").
// Moved here byte-for-byte from history.js's own gbDiffLineClass.
function gbUnifiedLineClass(line) {
if (line.indexOf('+++') === 0 || line.indexOf('---') === 0)
return 'gitbackup-diffline-meta';
if (line.charAt(0) === '+')
return 'gitbackup-diffline-add';
if (line.charAt(0) === '-')
return 'gitbackup-diffline-del';
if (line.indexOf('@@') === 0)
return 'gitbackup-diffline-hunk';
if (line.indexOf('diff --git') === 0 || line.indexOf('index ') === 0)
return 'gitbackup-diffline-meta';
return 'gitbackup-diffline-ctx';
}
// gbManifestLineClass <line> -- one prefixed class per line of cmd_diff's
// zero-argument, manifest-field form: "+ "/"- " mark a whole path added or
// removed (green/red, same coloring a real diff's own +/- lines get),
// "~ " marks a path whose fields changed in place (warn, since it is
// neither a pure addition nor a pure removal -- e.g. a chmod), anything
// else (there should be nothing else) falls back to plain context styling.
function gbManifestLineClass(line) {
if (line.indexOf('+ ') === 0)
return 'gitbackup-diffline-add';
if (line.indexOf('- ') === 0)
return 'gitbackup-diffline-del';
if (line.indexOf('~ ') === 0)
return 'gitbackup-diffline-change';
return 'gitbackup-diffline-ctx';
}
// gbBuildPre <text> <classify> <emptyText> -- shared DOM builder: an empty
// or blank <text> renders <emptyText> as a plain hint paragraph (spec: "diff
// на неизменившейся системе печатает пустой результат" -- both cmd_diff
// shapes agree on this), anything else becomes one <span> text node per
// line inside a scrollable <pre class="gitbackup-diff">, classified by
// <classify>.
function gbBuildPre(text, classify, emptyText) {
var lines = (text || '').split('\n');
if (!lines.length || (lines.length === 1 && !lines[0]))
return E('p', { 'class': 'gitbackup-card-hint' }, [ emptyText ]);
return E('pre', { 'class': 'gitbackup-diff' }, lines.map(function(line) {
return E('span', { 'class': classify(line) }, [ line + '\n' ]);
}));
}
return L.Class.extend({
css: GB_DIFFVIEW_CSS,
// unified(text) -- History's per-commit diff (two already-committed
// shas): real `git diff` output.
unified: function(text) {
return gbBuildPre(text, gbUnifiedLineClass, _('No differences.'));
},
// manifest(text) -- Overview's live-drift diff (config_diff): cmd_diff's
// own "+ path (type)" / "- path (type)" / "~ path: field a->b; ..."
// lines.
manifest: function(text) {
return gbBuildPre(text, gbManifestLineClass, _('No differences.'));
}
});
@@ -0,0 +1,814 @@
'use strict';
'require view';
'require rpc';
'require ui';
'require view.gitbackup.diffview as diffview';
'require view.gitbackup.oplog as oplog';
/* global diffview, oplog */
// See overview.js's own identical comment: the project's eslint config
// (openwrt/luci's own) only knows a fixed list of stock LuCI module names
// as globals, not this app's own diffview.js/oplog.js aliases.
// gitbackup -- History (ticket 14, spec "Решения по реализации -> LuCI ->
// History", "Формат коммита", "Восстановление"). Client-side JS only,
// luci-base only, same jsmin-conservative style (plain ES5, `var`/
// `function`, no let/const/arrow/template literals, every statement
// semicolon-terminated) as overview.js (ticket 11) and paths.js (ticket 13),
// both read before writing this file -- their style/rpcd/poller-cleanup
// patterns are reused here rather than reinvented.
//
// Brief, verbatim: "History -- последние N коммитов своей ветки -- дата из
// subject, SHA, список изменившихся путей. Клик -> diff в ui.showModal.
// Кнопка Restore с двойным подтверждением: первый экран показывает список
// перезаписываемых файлов, второй требует ввести имя устройства; если
// board.json выбранного коммита не совпадает с текущим, на первом же
// экране -- красное предупреждение о том, что имена интерфейсов и wireless
// не подойдут."
//
// ---------------------------------------------------------------------
// rpcd bindings -- copied from root/usr/share/rpcd/acl.d/luci-app-
// gitbackup.json and usr/libexec/rpcd/luci.gitbackup's own gbrpc_*
// handlers (package/gitbackup, out of this ticket's zone). This view adds
// no rpcd method of its own: `status` (device name for the restore
// confirmation gate), `history` (the commit list), `diff` (two already-
// committed shas -- interfaces.md, ticket 13's own "Чем пользоваться":
// "diff -- твоё") and `restore` (write tier) are the whole surface it
// needs.
// ---------------------------------------------------------------------
var callStatus = rpc.declare({
object: 'luci.gitbackup',
method: 'status'
});
var callHistory = rpc.declare({
object: 'luci.gitbackup',
method: 'history',
params: [ 'limit' ]
});
var callDiff = rpc.declare({
object: 'luci.gitbackup',
method: 'diff',
params: [ 'from', 'to' ]
});
var callLog = rpc.declare({
object: 'luci.gitbackup',
method: 'log',
params: [ 'lines' ]
});
var callRestore = rpc.declare({
object: 'luci.gitbackup',
method: 'restore',
params: [ 'device', 'commit', 'dry_run', 'force', 'with_packages' ]
});
// "Последние N коммитов" (brief, verbatim) -- one fixed page size, not a
// user-adjustable control: the brief never asks for pagination, and
// `history` already runs a real `git fetch` per call (interfaces.md,
// ticket 11-13: "Долгие вызовы (config_diff, history) не вешаются на
// первичную загрузку страницы"), so a bigger N only means a slower single
// load, not a cheaper one.
var GB_HISTORY_LIMIT = 30;
// ---------------------------------------------------------------------
// Pure helpers -- no DOM access, easy to reason about and to keep in sync
// with the shell side by inspection.
// ---------------------------------------------------------------------
// gbCommitTime <subject> -- the leading "YYYY-MM-DD HH:MM" a backup
// commit's subject always starts with (spec "Формат коммита", G03: "по
// списку коммитов в вебе видно, когда снят каждый, без открывания") --
// same extraction overview.js's own gbCommitTime already does for the
// single most recent commit; duplicated here rather than shared because
// each view here is its own self-contained file, same convention
// paths.js's own private GB_CSS/gbSequentialMap already follow.
function gbCommitTime(subject) {
var m = (subject || '').match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2})/);
return m ? m[1] : null;
}
// gbShortenRepoPath <path> -- a path as it appears in this device's own
// branch tree (e.g. "devices/r1/files/etc/config/network",
// "devices/r1/meta/board.json", "devices/r1/manifest.json") rewritten to
// what an operator actually recognizes: the real absolute path under
// "files/", or a short "meta/..." / "manifest.json" label for anything
// else. The device-specific "devices/<id>/" prefix itself is never
// hard-coded here -- collect.sh's own `path_prefix` (gitbackup.main.
// path_prefix) can be a custom template, so this looks for the "/files/"
// or "/meta/" marker instead of assuming a fixed prefix shape.
function gbShortenRepoPath(path) {
var idx = path.indexOf('/files/');
if (idx !== -1)
return path.substring(idx + '/files'.length);
idx = path.indexOf('/meta/');
if (idx !== -1)
return path.substring(idx + 1);
if (/\/manifest\.json$/.test(path))
return 'manifest.json';
return path;
}
// gbSummarizeList <items[]> <max> -- "a, b, c" or, past <max> entries,
// "a, b, c and N more" -- the same "до трёх... больше трёх -- и ещё N"
// convention the commit subject itself already uses (spec "Формат
// коммита"), applied here to `changed[]` (history's own full per-commit
// path list, which for a device's very first backup commit is every file
// it has -- unabridged, unlike the subject's own three-item cap).
function gbSummarizeList(items, max) {
if (!items.length)
return '';
if (items.length <= max)
return items.join(', ');
return items.slice(0, max).join(', ') + ' ' + _('and %d more').format(items.length - max);
}
// ---------------------------------------------------------------------
// Diff parsing -- `diff` (gbrpc_diff) hands back plain `git diff <a> <b>`
// text, unrestricted (interfaces.md: "diff принимает два уже закоммиченных
// sha... и даёт git diff между ними"). Everything below reads that text
// only; no assumption is made beyond git's own documented unified-diff
// output shape (`diff --git a/X b/X`, optional "new file mode"/"deleted
// file mode", "--- "/"+++ " headers, "@@ ... @@" hunks, body lines
// prefixed ' '/'+'/'-').
// ---------------------------------------------------------------------
// gbDiffSections <text> -- one entry per "diff --git " block: { header,
// body[] } (body = every following line up to, not including, the next
// "diff --git " line). The one grouping both the file list and the
// board.json comparison below are built from, so the two can never
// disagree about where one file's diff ends and the next begins.
function gbDiffSections(text) {
var lines = (text || '').split('\n');
var sections = [];
var cur = null;
var i;
for (i = 0; i < lines.length; i++) {
if (lines[i].indexOf('diff --git ') === 0) {
cur = { header: lines[i], body: [] };
sections.push(cur);
} else if (cur) {
cur.body.push(lines[i]);
}
}
return sections;
}
// gbSectionPath <header> -- the "b/..." side of a "diff --git a/X b/X"
// line. Git always fills in the same path on both sides for a plain add
// or delete (no rename detection requested), so this never needs the
// "a/" side at all.
function gbSectionPath(header) {
var m = header.match(/^diff --git a\/(.+) b\/(.+)$/);
return m ? m[2] : null;
}
// gbSectionStatus <section> -- 'added' | 'deleted' | 'modified', from
// git's own "new file mode" / "deleted file mode" body lines (present
// only for those two cases).
function gbSectionStatus(section) {
var i;
for (i = 0; i < section.body.length; i++) {
if (section.body[i].indexOf('new file mode') === 0)
return 'added';
if (section.body[i].indexOf('deleted file mode') === 0)
return 'deleted';
}
return 'modified';
}
// gbDiffFileList <text> -- [{ path, status }], one per changed file.
function gbDiffFileList(text) {
return gbDiffSections(text).map(function(s) {
return { path: gbSectionPath(s.header), status: gbSectionStatus(s) };
}).filter(function(f) { return f.path; });
}
// gbBoardMismatch <text> -- true when the diff between the current branch
// tip and the commit about to be restored changed meta/board.json's own
// "model" or "target" field. Mirrors restore.sh's own authoritative check
// (_gb_restore_check_board: "model" plus "release.target", the same two
// fields device.sh's board strategy and the armsr/generic-target case
// both rely on -- interfaces.md's own note that `model` is `null` on
// those targets) rather than "board.json changed at all": board.json also
// carries a plain `hostname` field, and flagging every rename as a board
// mismatch would train an operator to ignore the warning. This can only
// approximate restore.sh's live `ubus call system board` comparison with
// the closest thing a read-only rpcd surface exposes -- the most recent
// commit's own meta/board.json, i.e. what the last successful backup run
// saw. restore.sh itself remains the actual, authoritative gate (it always
// re-checks against the live board and refuses without --force); this is
// only the up-front warning the brief asks for.
function gbBoardMismatch(text) {
var sections = gbDiffSections(text);
var i, j, path, line, ch;
for (i = 0; i < sections.length; i++) {
path = gbSectionPath(sections[i].header);
if (!path || !/\/meta\/board\.json$/.test(path))
continue;
for (j = 0; j < sections[i].body.length; j++) {
line = sections[i].body[j];
ch = line.charAt(0);
if (ch !== '+' && ch !== '-')
continue;
if (line.indexOf('+++') === 0 || line.indexOf('---') === 0)
continue;
if (line.indexOf('"model"') !== -1 || line.indexOf('"target"') !== -1)
return true;
}
}
return false;
}
// gbFilesPath <path> -- the real absolute filesystem path for an entry
// under ".../files/...", or null for anything else (meta/*, manifest.json).
// `restore` (restore.sh's own gb_restore) only ever writes manifest
// entries -- i.e. the "files/" tree -- back onto the router; it never
// touches meta/board.json, manifest.json or any other bookkeeping file on
// disk. A changed-file list built straight from `gbDiffFileList` would
// therefore mislabel bookkeeping-only commits (a manifest.json-only diff,
// say) as "these files will be overwritten" when nothing on disk would
// actually change.
function gbFilesPath(path) {
var idx = path.indexOf('/files/');
return idx === -1 ? null : path.substring(idx + '/files'.length);
}
// gbRestorePlan <diffText> -- [{ path, action }], action is 'create' (the
// path exists in the target commit but not in the current branch tip, so
// restore will write it fresh) or 'overwrite' (exists in both, content
// differs). A file that exists in the current tip but NOT in the target
// ('deleted' in git-diff terms, moving tip -> target) is deliberately
// left out: restore.sh has no delete pass at all (spec: "разложить файлы,
// затем применить mode/uid/gid... пересоздать симлинки" -- nothing about
// removing a file the target commit does not mention), so that path would
// simply be left untouched by a real restore, not "removed", and listing
// it as something that will be "overwritten" would be flatly wrong.
function gbRestorePlan(diffText) {
var files = gbDiffFileList(diffText);
var plan = [];
var i, real;
for (i = 0; i < files.length; i++) {
if (files[i].status === 'deleted')
continue;
real = gbFilesPath(files[i].path);
if (!real)
continue;
plan.push({ path: real, action: files[i].status === 'added' ? 'create' : 'overwrite' });
}
return plan;
}
// The colored-<pre> diff viewer itself moved to diffview.js (ticket 23:
// "не пиши второй просмотрщик diff") once Overview needed one too, for a
// different diff format (config_diff's manifest-field lines, not a real
// `git diff`) -- see that file's own header comment for why this is two
// explicit renderers in one shared place rather than one that guesses.
// handleViewDiff below calls diffview.unified(res.diff): `diff` (gbrpc_diff,
// two already-committed shas) is real `git diff --end-of-options <a> <b>`
// output (usr/sbin/gitbackup's cmd_diff, its two-argument form), exactly
// what diffview.unified expects.
// ---------------------------------------------------------------------
// Restore log classification -- restore.sh's own gb_log calls (all of
// them: success, every refusal, every fetch/checkout failure) go through
// `logger -t gitbackup` (lib.sh's gb_log), which `log`/logread already
// tag-filters -- the exact same mechanism overview.js's own live-log
// poller already reads for `run`/`test`. Restore's plain `printf` on
// success ("restored %s from %s") is NOT captured here: gbrpc_restore
// backgrounds the CLI with its stdout sent to /dev/null (same shape as
// run/test), so only the gb_log line right before it ("gb_restore:
// restored $device from $target on $branch") is ever visible through this
// poller -- reason this regex keys on that line, not the printf one.
// ---------------------------------------------------------------------
// Ticket 23: 'writing one or more files failed' used to sit here instead of
// the line below, and never matched anything restore.sh actually logs --
// found while tracing ticket 19's own change (restore.sh, _gb_restore:
// "a partially-restored router with correct permissions... always tells
// the operator the truth (below) with a non-zero exit" prints
// "gb_restore: the following paths were NOT written:<list>" via gb_log,
// never the old string) against this poller's own terminal-line set. With
// the wrong string here, a partial restore (ticket 19's own headline
// outcome -- content written, permissions applied, exit 1) never matched
// GB_RESTORE_TERMINAL_RE at all and this poller ran all the way to its own
// 60s-idle/5-minute ceiling before saying anything, exactly the
// "непонятно, чем кончилось" ticket 23 exists to fix.
var GB_RESTORE_TERMINAL_RE = new RegExp(
[
'gb_restore: restored .+ from .+ on ',
'the following paths were NOT written:',
'this backup was taken on a different board',
'sha256 mismatch, refusing to write anything to disk',
'does not exist on .+ yet -- nothing to restore',
'was not found on .+ at ',
'could not read .+ from .+ on .+:',
'git fetch .+ failed',
'cannot create a work directory',
'repository url is required'
].join('|')
);
function gbRestoreLogSuccess(line) {
return line.indexOf('gb_restore: restored ') !== -1;
}
// gbClassifyRestoreLog <line> -- the human-readable, three-severity outcome
// behind GB_RESTORE_TERMINAL_RE's own match (ticket 23: "каждая долгая
// операция заканчивается явным сообщением... причина неудачи написана
// человеческим языком"). 'partial' is its own kind, deliberately distinct
// from both 'success' and 'error' -- ticket 19's restore.sh always applies
// permissions to whatever DID get written even when something else did not
// (spec: "частичный успех... обязан быть виден и отличаться от полного"),
// and folding it into either extreme would misreport which one actually
// happened. 'blocked' covers the one refusal that is a safety check working
// as intended (a board mismatch neither this view's own heuristic nor the
// operator's confirmation caught), kept apart from a genuine 'error' (a
// network/fetch/config problem) since the next step for each is different.
function gbClassifyRestoreLog(line) {
if (gbRestoreLogSuccess(line))
return { kind: 'success', message: _('Restore finished. This router now matches the backup you selected.') };
if (line.indexOf('the following paths were NOT written:') !== -1)
return {
kind: 'partial',
message: _('Restore finished, but some files could not be written -- see the log below for which ones and why. Permissions were still applied to every file that DID get written. Consider restoring again once the problem (a busy mount, a read-only path) is resolved.')
};
if (line.indexOf('this backup was taken on a different board') !== -1)
return { kind: 'blocked', message: _('Refused: this backup was taken on a different router model -- nothing was restored.') };
if (line.indexOf('sha256 mismatch, refusing to write anything to disk') !== -1)
return { kind: 'error', message: _('Refused: the files in this backup could not be verified (a checksum did not match) -- nothing was written to disk.') };
if (/does not exist on .+ yet -- nothing to restore/.test(line))
return { kind: 'error', message: _('This device has no backup on that branch yet -- nothing was restored.') };
if (/was not found on .+ at /.test(line))
return { kind: 'error', message: _('The selected backup could not be found on the remote -- nothing was restored.') };
if (/could not read .+ from .+ on .+:/.test(line))
return { kind: 'error', message: _('Could not read the backup\'s manifest from the remote -- nothing was restored.') };
if (/git fetch .+ failed/.test(line))
return { kind: 'error', message: _('Could not reach the remote repository -- nothing was restored.') };
if (line.indexOf('cannot create a work directory') !== -1)
return { kind: 'error', message: _('Could not create a temporary work directory on the router -- nothing was restored.') };
if (line.indexOf('repository url is required') !== -1)
return { kind: 'error', message: _('The repository URL is not configured -- nothing was restored.') };
return { kind: 'error', message: _('Restore failed -- see the log below for details.') };
}
// gbRestoreSeverity <kind> -- gbClassifyRestoreLog's own four kinds
// collapsed to the three severities showRestoreResult below actually
// styles: 'success' -> ok, 'partial'/'blocked' -> warn (something is worth
// a second look, but this is not the network/config failures 'error'
// means), 'error' (and anything unrecognized) -> error.
function gbRestoreSeverity(kind) {
if (kind === 'success')
return 'ok';
if (kind === 'partial' || kind === 'blocked')
return 'warn';
return 'error';
}
var GB_CSS = [
'.gitbackup-view { container-type: inline-size; }',
'.gitbackup-card-hint { font-size: .9em; color: var(--text-color-medium, #666); }',
'.gitbackup-banner { border-radius: 4px; padding: .6em 1em; margin: 0 0 .75em; background: var(--error-color-medium, #f44336); color: var(--on-error-color, #fff); }',
'.gitbackup-banner p { margin: .2em 0; }',
'.gitbackup-history-list { list-style: none; margin: .75em 0; padding: 0; }',
'.gitbackup-history-row { border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .9em; margin: 0 0 .6em; background: var(--background-color-low, #f5f5f5); }',
'.gitbackup-history-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .5em 1em; }',
'@container (max-width: 560px) { .gitbackup-history-head { flex-direction: column; align-items: flex-start; } }',
'.gitbackup-history-date { font-weight: bold; color: var(--text-color-high, #333); }',
'.gitbackup-history-sha { font-family: monospace; font-size: .85em; color: var(--text-color-medium, #666); }',
'.gitbackup-history-subject { font-size: .9em; color: var(--text-color-high, #333); margin: .35em 0 0; }',
'.gitbackup-history-changed { font-size: .85em; color: var(--text-color-medium, #666); margin: .3em 0 0; word-break: break-word; }',
'.gitbackup-history-actions { display: flex; flex-wrap: wrap; gap: .5em; margin-top: .6em; }',
'.gitbackup-restore-files { list-style: none; margin: .5em 0; padding: 0; max-height: 260px; overflow: auto; border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; }',
'.gitbackup-restore-file-row { display: flex; gap: .6em; padding: .3em .6em; border-bottom: 1px solid var(--background-color-medium, #ddd); font-family: monospace; font-size: .85em; color: var(--text-color-high, #333); }',
'.gitbackup-restore-file-row:last-child { border-bottom: none; }',
'.gitbackup-restore-action-create { color: var(--success-color-high, #2e7d32); }',
'.gitbackup-restore-action-overwrite { color: var(--warn-color-high, #b45f06); }',
'.gitbackup-confirm-input { width: 100%; box-sizing: border-box; margin: .5em 0; }',
'.gitbackup-modal-actions { display: flex; justify-content: flex-end; gap: .5em; margin-top: 1em; }',
'.gitbackup-log { max-height: 320px; overflow: auto; background: var(--background-color-low, #f5f5f5); color: var(--text-color-high, #333); border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .8em; font-family: monospace; font-size: .85em; white-space: pre-wrap; }',
// gitbackup-hint-ok/-warn/-error -- same three severities settings.js's
// own copy already uses (ticket 12); reused here (ticket 23) for
// showRestoreResult's own success/partial-or-blocked/error message.
'.gitbackup-hint-ok { color: var(--success-color-high, #2e7d32); }',
'.gitbackup-hint-warn { color: var(--warn-color-high, #b45f06); }',
'.gitbackup-hint-error { color: var(--error-color-high, #c62828); }',
'.gitbackup-restore-outcome { font-weight: bold; }'
];
return view.extend({
// Not a config form -- see overview.js's own identical comment. Rule 11
// of the spec's style guide: the stock Save/Apply/Reset row is switched
// off through this documented hook, never hidden with CSS.
handleSaveApply: null,
handleSave: null,
handleReset: null,
load: function() {
// `status` only (device name for the second restore-confirmation
// screen) -- local file reads, same "cheap" bucket overview.js's
// own load() puts status/log/service in. `history` itself is
// deliberately NOT here: it is a real `git fetch` against the
// configured remote with no timeout of its own (interfaces.md,
// tickets 11/13: "Долгие вызовы... не вешаются на первичную
// загрузку страницы" -- measured live at several seconds against a
// real GitHub remote). render() below starts it once the rest of
// the page is already on screen, same pattern overview.js's own
// refreshHistory() already uses.
return L.resolveDefault(callStatus(), null);
},
render: function(status) {
var self = this;
var view;
self._status = status || {};
self._commits = null;
view = E('div', { 'class': 'gitbackup-view' }, [
E('style', { 'type': 'text/css' }, [ GB_CSS.concat(diffview.css).join('\n') ]),
E('h2', {}, [ _('Git Backup - History / Restore') ]),
// Ticket 22: found-in-the-field complaint was "restore exists
// but nobody looks for it on a tab called History" -- this
// hint is the fix's cheap half (the tab rename above is the
// other): it states the round-trip capability up front, in
// plain text, with no hover required, before a single commit
// row has even loaded.
E('p', { 'class': 'gitbackup-card-hint' },
[ _('You can restore this router to any backup listed below, using that backup\'s own "Restore" button. Click "View diff" first to see exactly what it would change.') ]),
E('div', { 'id': 'gitbackup-history-body' }, [
E('p', { 'class': 'gitbackup-card-hint' }, [ _('Loading…') ])
])
// Ticket 23: the old page-level "#gitbackup-restore-log" <pre>
// that used to live here is gone -- the confirmation modal
// itself now stays open for the whole restore and shows the
// live log and the final outcome in place (see
// handleConfirmRestore/showRestoreProgress/showRestoreResult
// below), instead of hiding itself immediately and leaving an
// operator to notice a page element they were never told to
// watch (spec: "модальное окно... не должно просто
// закрываться... а в конце говорит, что восстановлено").
]);
// Fired once render() has already returned the tree above, exactly
// like overview.js's own refreshHistory() -- see load()'s own
// comment for why `history` cannot run inside load() itself.
self.refreshHistory();
return view;
},
refreshHistory: function() {
var self = this;
return L.resolveDefault(callHistory(GB_HISTORY_LIMIT), null).then(function(res) {
self._commits = (res && res.commits) || null;
self._historyErr = (!res) ?
_('Could not reach the remote repository.') :
(res.reason || null);
self.renderBody();
});
},
renderBody: function() {
var self = this;
var body = document.getElementById('gitbackup-history-body');
var commits = self._commits;
if (!body)
return;
body.textContent = '';
if (self._historyErr && (!commits || !commits.length)) {
body.appendChild(E('p', { 'class': 'gitbackup-card-hint' },
[ _('Could not load the backup history: %s').format(self._historyErr) ]));
return;
}
if (!commits || !commits.length) {
body.appendChild(E('p', { 'class': 'gitbackup-card-hint' },
[ _('No backups on this device\'s branch yet.') ]));
return;
}
body.appendChild(E('ul', { 'class': 'gitbackup-history-list' },
commits.map(function(commit, i) {
return self.buildHistoryRow(commit, (i + 1 < commits.length) ? commits[i + 1].sha : null);
})));
},
buildHistoryRow: function(commit, parentSha) {
var self = this;
var time = gbCommitTime(commit.subject);
var changed = (commit.changed || []).map(gbShortenRepoPath);
return E('li', { 'class': 'gitbackup-history-row' }, [
E('div', { 'class': 'gitbackup-history-head' }, [
E('span', { 'class': 'gitbackup-history-date' }, [ time || _('(no date found in subject)') ]),
E('span', { 'class': 'gitbackup-history-sha' }, [ commit.sha.substring(0, 12) ])
]),
E('div', { 'class': 'gitbackup-history-subject' }, [ commit.subject || '-' ]),
E('div', { 'class': 'gitbackup-history-changed' },
[ changed.length ? _('Changed: %s').format(gbSummarizeList(changed, 6)) : _('Nothing recorded as changed.') ]),
E('div', { 'class': 'gitbackup-history-actions' }, [
E('button', {
'class': 'cbi-button cbi-button-neutral',
'click': ui.createHandlerFn(self, 'handleViewDiff', commit, parentSha)
}, [ _('View diff') ]),
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': ui.createHandlerFn(self, 'handleRestoreClick', commit)
}, [ _('Restore') ])
])
]);
},
// handleViewDiff <commit> <parentSha> -- the per-commit diff (spec:
// "Клик -> diff в ui.showModal"), i.e. what THAT backup changed versus
// the one right before it. `history`'s commits are newest-first and
// this branch's own push machinery (gitio.sh's gb_commit_push) always
// commits with exactly one parent, so the previous ROW's sha (already
// known client-side) is that parent -- no extra round trip needed to
// find it. The oldest row on screen has no such neighbour: its actual
// parent (if any) sits outside this page's own GB_HISTORY_LIMIT
// window, and fabricating a diff against the wrong commit would be
// worse than admitting this view simply does not know it.
handleViewDiff: function(commit, parentSha, ev) {
var self = this;
if (!parentSha) {
ui.addNotification(null, E('p', {},
[ _('No earlier backup loaded to compare against -- this is the oldest commit in this list.') ]), 'info');
return;
}
ui.showModal(_('Loading diff…'), [ E('p', { 'class': 'spinning' }, [ _('Fetching diff from the remote…') ]) ]);
return L.resolveDefault(callDiff(parentSha, commit.sha), null).then(function(res) {
if (!res || typeof res.diff !== 'string') {
ui.showModal(_('Diff'), [
E('p', {}, [ _('Could not load the diff: %s').format((res && res.reason) || _('unknown error')) ]),
E('div', { 'class': 'gitbackup-modal-actions' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
])
]);
return;
}
ui.showModal(_('Diff: %s').format(gbCommitTime(commit.subject) || commit.sha.substring(0, 12)), [
diffview.unified(res.diff),
E('div', { 'class': 'gitbackup-modal-actions' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
])
]);
});
},
// handleRestoreClick <commit> -- step 0: fetch the one diff this whole
// confirmation flow is built on (current branch tip -> the commit
// about to be restored) before showing anything, so the first
// confirmation screen (spec: "первый экран показывает список
// перезаписываемых файлов") is never a guess.
handleRestoreClick: function(commit, ev) {
var self = this;
var headSha = (self._commits && self._commits[0] && self._commits[0].sha) || commit.sha;
ui.showModal(_('Restore backup?'), [ E('p', { 'class': 'spinning' }, [ _('Comparing against the current commit…') ]) ]);
return L.resolveDefault(callDiff(headSha, commit.sha), null).then(function(res) {
var diffText = (res && typeof res.diff === 'string') ? res.diff : '';
var plan = gbRestorePlan(diffText);
var mismatch = gbBoardMismatch(diffText);
self.showRestoreConfirm1(commit, plan, mismatch);
});
},
// showRestoreConfirm1 -- first confirmation screen (spec, verbatim
// above): the list of files that will actually be written, and, when
// applicable, the board-mismatch warning ahead of any second screen --
// never after.
showRestoreConfirm1: function(commit, plan, mismatch) {
var self = this;
var body = [];
if (mismatch) {
body.push(E('div', { 'class': 'gitbackup-banner' }, [
E('p', {}, [ _('This backup was taken on a different router model than the one it would be restored onto. Interface names and wireless radios from that hardware will not match this one -- restoring anyway may leave the network unreachable until the configuration is fixed by hand.') ])
]));
}
body.push(E('p', {},
[ _('Restoring the backup from %s (%s) will write the following files:')
.format(gbCommitTime(commit.subject) || commit.sha, commit.sha.substring(0, 12)) ]));
if (plan.length) {
body.push(E('ul', { 'class': 'gitbackup-restore-files' }, plan.map(function(p) {
return E('li', { 'class': 'gitbackup-restore-file-row' }, [
E('span', { 'class': 'gitbackup-restore-action-' + p.action }, [ p.action ]),
E('span', {}, [ p.path ])
]);
})));
} else {
body.push(E('p', { 'class': 'gitbackup-card-hint' },
[ _('No file differs from the current commit -- restoring will not change anything on disk.') ]));
}
body.push(E('div', { 'class': 'gitbackup-modal-actions' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Cancel') ]),
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': ui.createHandlerFn(self, 'showRestoreConfirm2', commit, mismatch)
}, [ _('Continue') ])
]));
ui.showModal(_('Restore backup?'), body);
},
// showRestoreConfirm2 -- second confirmation screen (spec, verbatim:
// "второй требует ввести имя устройства"): the operator has to type
// this device's own name back, the same friction a destructive cloud
// console action usually asks for. `status.device` (gitbackup status's
// own `device` field, interfaces.md tickets 01/08-10) is the device
// this router itself resolves to -- and the one `restore` is about to
// be called for, since History only ever shows this router's own
// branch.
showRestoreConfirm2: function(commit, mismatch, ev) {
var self = this;
var device = self._status.device || '';
var input, confirmBtn;
input = E('input', {
'type': 'text',
'class': 'cbi-input-text gitbackup-confirm-input',
'placeholder': device
});
confirmBtn = E('button', {
'class': 'cbi-button cbi-button-negative',
'disabled': true,
'click': ui.createHandlerFn(self, 'handleConfirmRestore', commit, mismatch)
}, [ _('Restore') ]);
input.addEventListener('input', function() {
confirmBtn.disabled = !device || (input.value !== device);
});
ui.showModal(_('Confirm restore'), [
E('p', {}, [ _('Type this device\'s name (%s) to confirm restoring it.').format(device || _('(device name not configured)')) ]),
input,
E('div', { 'class': 'gitbackup-modal-actions' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Cancel') ]),
confirmBtn
])
]);
},
// handleConfirmRestore -- fires `restore` (write tier) and switches to
// oplog.js's shared bounded live-log poller (ticket 23: "не изобретать
// четвёртый механизм" -- the exact pattern overview.js's own
// handleRun/handleTest use for run/test). The call itself only starts
// the CLI in the background (gbrpc_restore always answers
// `{started:true}` immediately) and can take a while for real (network
// fetch of the target commit, sha256 verification of every file), so
// this must not block the page.
//
// Ticket 23's own headline complaint about restore specifically:
// the confirmation modal used to call ui.hideModal() right here and
// let the page's own <pre> carry the live log silently underneath --
// closing the one dialog that had the operator's attention just as an
// irreversible, file-system-rewriting operation started, then saying
// nothing further unless it failed to even START. It no longer hides
// at all: showRestoreProgress below replaces this same dialog's
// content in place, and stays open, showing the live log, until
// showRestoreResult replaces it again with the actual outcome --
// success, partial, or failure, spelled out in words (spec: "пусть
// остаётся, показывает ход и в конце говорит, что восстановлено, что
// не удалось и что делать дальше").
//
// `force` is passed whenever the board-mismatch warning fired on the
// previous screen -- without it restore.sh's own
// _gb_restore_check_board refuses outright (exit 4), which would turn
// an operator's informed "restore anyway" into a silent no-op visible
// only by reading the log by hand.
handleConfirmRestore: function(commit, mismatch, ev) {
var self = this;
var device = self._status.device || '';
self.showRestoreProgress();
return oplog.start({
fetch: function() { return L.resolveDefault(callLog(500), null).then(function(res) { return (res && res.text) || ''; }); },
terminalRe: GB_RESTORE_TERMINAL_RE,
onProgress: function(add) { self.appendRestoreProgress(add); },
onFinish: function(line) {
self.showRestoreResult(gbClassifyRestoreLog(line));
self.refreshHistory();
},
onTimeout: function() {
self.showRestoreResult({
kind: 'unknown',
message: _('No result from the restore after a while -- check the log below or the syslog by hand.')
});
self.refreshHistory();
}
}).then(function() {
return callRestore(device, commit.sha, undefined, mismatch ? true : undefined);
}).then(function(res) {
if (!res || res.started !== true) {
oplog.stop();
self.showRestoreResult({ kind: 'error', message: _('Could not start the restore.') });
}
}).catch(function(e) {
oplog.stop();
self.showRestoreResult({ kind: 'error', message: _('Could not start the restore: %s').format(e.message) });
});
},
// showRestoreProgress -- replaces the confirmation modal's own content
// with a spinner and an empty live-log pane, IN THE SAME DIALOG (no
// ui.hideModal() anywhere in this whole flow) -- see
// handleConfirmRestore's own comment for why staying open matters here
// specifically. No Close/Cancel control on purpose: restore.sh has no
// interrupt handling once it starts writing, so a "Cancel" here could
// only ever stop watching, never stop the restore itself, and offering
// it would suggest otherwise.
showRestoreProgress: function() {
ui.showModal(_('Restoring…'), [
E('p', { 'class': 'spinning' },
[ _('Restoring the backup -- this can take a while. Do not power off the router.') ]),
E('pre', { 'class': 'gitbackup-log', 'id': 'gitbackup-restore-modal-log' }, [ '' ])
]);
},
appendRestoreProgress: function(add) {
var pre = document.getElementById('gitbackup-restore-modal-log');
if (!pre)
return;
pre.textContent = pre.textContent ? pre.textContent + '\n' + add : add;
pre.scrollTop = pre.scrollHeight;
},
// showRestoreResult <cls> -- the final screen of the restore flow:
// <cls> is gbClassifyRestoreLog's own {kind, message} (or the
// onTimeout/could-not-start shapes handleConfirmRestore builds inline
// above, both the same shape). Reads the progress pane's own
// accumulated text (still in the DOM at this point, since this is the
// same ui.showModal call that is about to replace it) rather than
// keeping a second copy of it in JS state.
//
// Ticket 23's own explicit ask beyond "say what happened": "что делать
// дальше" for the one outcome that actually has a next step worth
// naming -- 'partial' (ticket 19's own headline case: content written,
// permissions applied, one or more paths failed) points at the log
// right below it, already showing exactly which paths and why.
showRestoreResult: function(cls) {
var pre = document.getElementById('gitbackup-restore-modal-log');
var fullLog = pre ? pre.textContent : '';
var severity = gbRestoreSeverity(cls.kind);
var title = cls.kind === 'success' ? _('Restore finished') :
cls.kind === 'partial' ? _('Restore finished with problems') :
cls.kind === 'blocked' ? _('Restore refused') :
_('Restore failed');
var body = [
E('p', { 'class': 'gitbackup-restore-outcome gitbackup-hint-' + severity }, [ cls.message ])
];
if (fullLog)
body.push(E('pre', { 'class': 'gitbackup-log' }, [ fullLog ]));
body.push(E('div', { 'class': 'gitbackup-modal-actions' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
]));
ui.showModal(title, body);
}
});
@@ -0,0 +1,145 @@
'use strict';
'require poll';
// gitbackup -- shared bounded live-log poller (ticket 23, spec "Не
// изобретать четвёртый механизм"). Before this file existed, overview.js
// (run/test), history.js (restore) and settings.js (test) each carried
// their own, separately-typed copy of the exact same state machine: fetch
// a log tail, diff it against the line count from the previous tick, feed
// the new lines to a caller-supplied terminal-line regex, and give up
// after 60s of no new output or a hard 5-minute ceiling regardless --
// interfaces.md's own record of tickets 11-14 already names this bound
// three times over ("терминальная строка лога, простой в 60 секунд,
// жёсткий потолок в 5 минут"), copied rather than shared. Ticket 23's own
// acceptance criterion ("механизм поллинга один на все три view, а не
// четыре разных") asks for exactly one implementation of that shape; this
// is it.
//
// LuCI's own require() resolves a module ONCE per page and hands back the
// same already-instantiated singleton to every caller (luci.js:
// `compileClass`'s `new _class()`, cached in `classes[name]`) -- so every
// one of the three views above sharing this same instance is safe only
// because each of them is its own full page load (classic multi-page LuCI
// tears down the whole JS context on tab switch) AND never runs two such
// pollers from itself at once (the button(s) a poller was started from
// stay disabled for its entire run). start() below resets every piece of
// this instance's state, so a second, later operation on the very same
// page (e.g. overview.js's "Backup now" followed later by its own "Test
// connection") reuses the same instance cleanly once the first one has
// actually stopped.
return L.Class.extend({
// start(opts) -- begins polling opts.fetch() every opts.interval
// seconds (default 2, matching every prior copy of this poller).
// opts.fetch() must resolve to the log's current text tail (a
// rejected fetch is treated as "no output this tick", same as an
// empty string, and repeated failures alone can still trip the idle
// bound below). Returns a promise that resolves once the FIRST fetch
// (establishing the starting line count) has completed and polling
// has actually started -- callers await this before firing the
// backgrounded CLI call itself, exactly like every prior copy of this
// poller did, so a very fast operation's own first output line is
// never missed.
//
// opts.terminalRe (RegExp, required) -- tested against each newly
// arrived line; a match stops the poller and fires opts.onFinish.
// opts.onProgress(addedText), if given, fires on every tick that saw
// new output at all, with only the lines new since the previous tick
// (already newline-joined, blank lines dropped) -- never the whole
// accumulated text, so a caller appending it to a growing <pre> never
// duplicates a line.
// opts.onFinish(matchedLine), if given, fires exactly once, after the
// poller has already stopped itself, with the one line that matched
// opts.terminalRe.
// opts.onTimeout(), if given, fires exactly once, after the poller has
// already stopped itself, when opts.idleLimit ticks (default 30 == 60s
// at the default 2s interval) passed with no new output at all, when
// opts.tickLimit ticks (default 150 == 5 minutes) is reached
// regardless, or when opts.fetch() itself keeps rejecting for that
// same idle span -- three different reasons this project's own
// interfaces.md has always treated as one bound ("простой... либо
// потолок"), not three outcomes a caller has to tell apart.
start: function(opts) {
var self = this;
self._opts = opts;
self._idle = 0;
self._ticks = 0;
self._lines = 0;
return opts.fetch().then(function(text) {
text = text || '';
self._lines = text ? text.split('\n').length : 0;
if (!self._bound)
self._bound = L.bind(self._tick, self);
poll.remove(self._bound);
poll.add(self._bound, opts.interval || 2);
});
},
// stop() -- idempotent; safe to call whether or not a poll is
// currently running (every call site here does, on the "could not
// even start the operation" path, whether or not start() got as far
// as poll.add).
stop: function() {
if (this._bound)
poll.remove(this._bound);
},
_tick: function() {
var self = this;
var opts = self._opts;
var idleLimit = opts.idleLimit || 30;
var tickLimit = opts.tickLimit || 150;
self._ticks++;
return opts.fetch().then(function(text) {
var lines, newLines, add, finished, matchedLine, i;
text = text || '';
lines = text.split('\n');
newLines = (lines.length >= self._lines) ? lines.slice(self._lines) : lines;
add = newLines.filter(function(l) { return l; }).join('\n');
finished = false;
matchedLine = null;
self._lines = lines.length;
if (add) {
self._idle = 0;
if (opts.onProgress)
opts.onProgress(add);
for (i = 0; i < newLines.length; i++) {
if (opts.terminalRe.test(newLines[i])) {
finished = true;
matchedLine = newLines[i];
}
}
} else {
self._idle++;
}
if (finished) {
self.stop();
if (opts.onFinish)
opts.onFinish(matchedLine);
} else if (self._idle >= idleLimit || self._ticks >= tickLimit) {
self.stop();
if (opts.onTimeout)
opts.onTimeout();
}
}, function() {
// A single failed poll must not spin forever either -- same
// convention every prior copy of this poller used (count it
// as a big idle jump rather than a distinct error path).
self._idle += idleLimit;
if (self._idle >= idleLimit) {
self.stop();
if (opts.onTimeout)
opts.onTimeout();
}
});
}
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,570 @@
'use strict';
'require view';
'require rpc';
'require ui';
'require fs';
// gitbackup -- Paths (ticket 13, spec "Решения по реализации -> LuCI -> Paths",
// "Сбор и manifest.json", "Проверенные факты 25.12.4 -> sysupgrade"). Client-side
// JS only, luci-base only -- same constraint and the same jsmin-conservative
// style (plain ES5, `var`/`function`, no let/const/arrow/template literals,
// every statement semicolon-terminated) as overview.js (ticket 11), read
// before writing this file.
//
// The whole point of this screen (spec/brief, verbatim): "Расширение списка --
// редактирование /etc/sysupgrade.conf, а не отдельный UCI-список. То, что
// пользователь добавил ради бэкапа, автоматически переживёт реальный
// sysupgrade." That file is owned entirely by package/gitbackup's paths.sh and
// this file's own rpcd bridge (usr/libexec/rpcd/luci.gitbackup) -- both out of
// this ticket's own zone in the original cut, but the read/write asymmetry
// documented below was later closed in that same package/gitbackup zone, and
// this view was updated alongside it.
//
// ---------------------------------------------------------------------
// Raw vs. entries vs. effective -- the defect this file used to work
// around instead of fixing, closed by making `list_paths` carry three
// sets explicitly:
//
// - `paths` -- the RAW, unexpanded content of /etc/sysupgrade.conf
// (gb_paths_list), comments and blank lines included.
// Not used by this view at all (see `entries` below).
// - `entries` -- (ticket 26) `paths`, filtered down to genuine path
// entries: comments (sysupgrade.conf's own syntax --
// a line starting with '#', including a commented-out
// EXAMPLE such as "# /etc/openvpn/", which backs up
// nothing) and blank lines are dropped by
// gb_paths_entries server-side. This is what this view
// lists and edits, and the only thing `set_paths` ever
// writes back -- a directory line stays a directory
// line across any number of edits, so anything added
// to it later keeps being covered by a real sysupgrade
// the same way it always was, instead of being frozen
// into the exact file list that happened to exist on
// the day of the last save here. Before this field
// existed, this view built its editable list straight
// from `paths`, so a stock router's own header
// comments and example lines rendered as four bogus,
// removable entries -- and the first save from this
// view (any Add or Remove click) silently erased them,
// because `set_paths` used to write back only what it
// was sent and never looked at the rest of the file.
// That erasure is now prevented server-side
// (gb_paths_replace_entries, paths.sh) rather than by
// this view trying to resend lines it was never shown
// in the first place -- see that function's own header
// comment for why the fix belongs there.
// - `effective` -- plain `sysupgrade -l`'s own wider union (spec:
// "(/etc/sysupgrade.conf ∪ /lib/upgrade/keep.d/*) ∪
// изменённые conffiles"), already recursively expanded
// to individual files/symlinks. Read-only: used only
// to size the real backup set (the 2 MB warning has to
// mean what `sysupgrade -b` will actually build, not
// just the lines a human typed) and to show what is
// already covered without anyone having added it here.
//
// Earlier, `list_paths` returned only the effective set under the name
// `paths`, so this view's own edits were built on top of the ALREADY
// EXPANDED list -- the first save after a human added a directory line
// via the CLI would write that directory's individual files straight
// back to sysupgrade.conf, permanently losing the one-line-per-directory
// convenience. Editing the raw set instead removes the problem instead of
// working around it.
//
// A second, related gap this same change closes: `set_paths`'s own
// server-side check used to duplicate only two of paths.sh's
// gb_paths_validate rules (absolute, the fixed blacklist), leaving a
// space in the path and a nonexistent path to this view's own JS to
// catch -- which 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` (confirmed live: every
// attempt at fs.lstat here failed with rpcd's own -32002 "Access denied",
// browser console, root login included -- only `fs.stat` is reachable).
// `set_paths` now routes every entry through gb_paths_validate itself,
// which runs ON the router and sees the real filesystem, so it gets `-e
// || -L` right without needing that ACL grant at all. This view keeps its
// own quick client-side checks (gbPathReject below) as a fast first pass
// that avoids an obviously-doomed round trip, but the round trip -- and
// its `rejected[]` response -- is the actual, authoritative gate; this
// view no longer attempts its own existence check at all.
// ---------------------------------------------------------------------
var callListPaths = rpc.declare({
object: 'luci.gitbackup',
method: 'list_paths'
});
var callAuditPaths = rpc.declare({
object: 'luci.gitbackup',
method: 'audit_paths'
});
var callSetPaths = rpc.declare({
object: 'luci.gitbackup',
method: 'set_paths',
params: [ 'paths' ]
});
// gb_paths_size_kb (paths.sh) rounds bytes up to a whole KB: awk's
// `(s + 1023) / 1024` truncated to an integer is exactly `ceil(s / 1024)`.
// No rpcd method exposes that number directly, so this view derives the
// same total the same way paths.sh does: sum the byte size of every entry
// in `list_paths`'s "effective" set -- the same full set `sysupgrade -l`
// (and `run`'s own pre-flight space check) actually measures, not just
// the raw lines a human typed -- using `fs.stat`, deliberately not
// `fs.lstat`, and not only because of the ACL note above. gb_paths_size_kb's
// own test is plain `[ -f "$path" ]`, which -- confirmed live against this
// stand's busybox ash (a symlink to a regular file passes `-f`) -- FOLLOWS
// a symlink to its target, same as `fs.stat`; only a target that is not a
// regular file (a directory, or a dangling symlink) makes `-f` false and
// the entry contributes 0. Using `fs.lstat` here would size the symlink's
// own few bytes instead of its target's real content and disagree with
// the shell-side total for the exact same set.
var GB_PATHS_WARN_KB = 2048;
// gbPathReject <path> -- null when the path is worth submitting to
// `set_paths`, otherwise one human-readable reason, worded identically to
// package/gitbackup's paths.sh (gb_paths_validate) for the checks that
// module owns and that do not need a filesystem round trip: absolute, no
// space, not in the fixed blacklist. Order matches gb_paths_validate's own
// (absolute first, everything else assumes it). This is a fast first pass
// only -- `set_paths` re-validates every entry server-side (gb_paths_validate
// itself, including existence) and is the actual gate; a path that slips
// past this function is still caught there and reported through
// commitPaths's own `rejected[]` handling below.
function gbPathReject(path) {
if (path.charAt(0) !== '/')
return _('%s: not an absolute path').format(path);
if (path.indexOf(' ') !== -1)
return _('%s: contains a space -- sysupgrade.conf lines become find(1) arguments and cannot quote one').format(path);
if (path === '/etc/gitbackup' || path.indexOf('/etc/gitbackup/') === 0)
return _('%s: reserved for gitbackup itself').format(path);
if (path === '/proc' || path.indexOf('/proc/') === 0)
return _('%s: not a real filesystem path').format(path);
if (path === '/sys' || path.indexOf('/sys/') === 0)
return _('%s: not a real filesystem path').format(path);
if (path === '/tmp' || path.indexOf('/tmp/') === 0)
return _('%s: cleared on every reboot').format(path);
return null;
}
// gbSequentialMap <items[]> <fn> -- like items.map(fn), but runs one call at
// a time and only starts the next once the previous settles, instead of
// firing every call at once. Confirmed necessary live on the owlab stand:
// stock `uhttpd` there runs with `-n 3` (three concurrent ubus-bridge
// requests, the same limit a real low-memory router ships with, not
// something this dev stand loosened), and firing one `fs.stat` per entry of
// even the 13-line clean-image default set at once left every later ubus
// call queued behind it indefinitely -- confirmed by watching the network
// panel: no request for a later call was even sent until an earlier one's
// slot freed, and with enough of them in flight the rest of the page (this
// view's own next list_paths/set_paths call, even an unrelated view) stayed
// stuck on "Loading view..." until the batch drained. `Promise.all` over
// every path was the original, incorrect implementation.
function gbSequentialMap(items, fn) {
var results = [];
var i = 0;
function next() {
if (i >= items.length)
return Promise.resolve(results);
return fn(items[i]).then(function(res) {
results.push(res);
i++;
return next();
});
}
return next();
}
// gbSumSizes <paths[]> -- { kb, unknown }. `unknown` counts entries whose
// size could not be read at all (permission, or the entry vanished between
// `list_paths` answering and this running) so the total is never silently
// wrong without saying so. Called with the "effective" set, never the raw
// one -- see the file-header comment.
function gbSumSizes(paths) {
return gbSequentialMap(paths, function(p) {
return fs.stat(p).then(function(st) { return st; }, function() { return null; });
}).then(function(stats) {
var bytes = 0;
var unknown = 0;
var i;
for (i = 0; i < stats.length; i++) {
if (stats[i] && stats[i].type === 'file')
bytes += stats[i].size || 0;
else if (!stats[i])
unknown++;
}
return { kb: Math.ceil(bytes / 1024), unknown: unknown };
});
}
// gbCoveredBy <rawEntry> <effectivePath> -- true when <effectivePath> is
// exactly <rawEntry>, or lies underneath it as a directory. <rawEntry>'s
// own trailing slash (if any -- sysupgrade.conf directory lines commonly
// carry one) is normalized away first so both spellings of the same
// directory match the same children.
function gbCoveredBy(rawEntry, effectivePath) {
var prefix = rawEntry.replace(/\/+$/, '') + '/';
return effectivePath === rawEntry || effectivePath.indexOf(prefix) === 0;
}
// gbAutomaticEntries <rawPaths[]> <effectivePaths[]> -- the "effective" set
// entries not explained by anything a human put in the raw list (not equal
// to a raw entry, not a descendant of a raw directory entry) -- i.e. only
// there because /lib/upgrade/keep.d/* or a changed package conffile
// already covers it. Shown read-only, separately from the editable raw
// list, so the operator can see the difference between "I added this" and
// "this is already covered without me doing anything".
function gbAutomaticEntries(rawPaths, effectivePaths) {
return effectivePaths.filter(function(p) {
var i;
for (i = 0; i < rawPaths.length; i++) {
if (gbCoveredBy(rawPaths[i], p))
return false;
}
return true;
});
}
function gbBuildPathRow(self, path, removable) {
var children = [ E('span', { 'class': 'gitbackup-path-text' }, [ path ]) ];
if (removable) {
children.push(E('button', {
'class': 'cbi-button cbi-button-remove',
'click': ui.createHandlerFn(self, 'handleRemovePath', path)
}, [ _('Remove') ]));
} else {
children.push(E('button', {
'class': 'cbi-button cbi-button-action',
'click': ui.createHandlerFn(self, 'handleAddFromAudit', path)
}, [ _('Add') ]));
}
return E('li', { 'class': 'gitbackup-path-row' }, children);
}
function gbBuildPathsList(self, paths) {
if (!paths.length)
return E('p', { 'class': 'gitbackup-card-hint' },
[ _('Nothing here yet -- the base image\'s own defaults (%s) usually already list a handful of files.').format('/lib/upgrade/keep.d') ]);
return E('ul', { 'class': 'gitbackup-path-list' }, paths.map(function(p) {
return gbBuildPathRow(self, p, true);
}));
}
function gbBuildSizeBox(kb, unknown) {
var children = [];
var text;
if (kb == null)
text = _('Calculating the backup set size…');
else
text = _('Total size of the backup set: %d KB').format(kb);
if (unknown)
text += ' ' + _('(some entries could not be measured and are left out of this total)');
children.push(E('p', { 'class': 'gitbackup-size' }, [ text ]));
if (kb != null && kb > GB_PATHS_WARN_KB) {
children.push(E('div', { 'class': 'gitbackup-warn' }, [
E('p', {}, [ _('This is over 2 MB. A real "sysupgrade -b" builds this archive entirely in RAM, and past this size it can fail on a low-memory device. This is only a warning -- nothing here is blocked, trim the list above if that matters on this device.') ])
]));
}
return E('div', {}, children);
}
function gbBuildAutomaticBox(automatic) {
var box = E('div', {}, [
E('h3', { 'class': 'gitbackup-section-title' }, [ _('Also covered automatically') ]),
E('p', { 'class': 'gitbackup-caption' },
[ _('These are not part of the list above -- sysupgrade already backs them up on its own (package config changes, or /lib/upgrade/keep.d), whether or not anything is added here. Read-only.') ])
]);
if (!automatic.length) {
box.appendChild(E('p', { 'class': 'gitbackup-card-hint' },
[ _('Nothing outside the list above right now.') ]));
return box;
}
box.appendChild(E('ul', { 'class': 'gitbackup-path-list' }, automatic.map(function(p) {
return E('li', { 'class': 'gitbackup-path-row' }, [
E('span', { 'class': 'gitbackup-path-text' }, [ p ])
]);
})));
return box;
}
function gbBuildAuditBox(self, audit) {
var box = E('div', {}, [
E('h3', { 'class': 'gitbackup-section-title' }, [ _('Audit: changed on this router, but not backed up') ]),
E('p', { 'class': 'gitbackup-caption' },
[ _('Everything below is different from what the installed packages shipped and is not covered by the path list above -- add it, or leave it if it does not belong in the backup.') ])
]);
if (!audit.length) {
box.appendChild(E('p', { 'class': 'gitbackup-card-hint' },
[ _('Nothing to show here. Either every local change on this router is already covered by the backup set above, or this environment could not inspect the overlay filesystem to check at all -- an empty list is not, by itself, proof that nothing has changed.') ]));
return box;
}
box.appendChild(E('ul', { 'class': 'gitbackup-path-list' }, audit.map(function(p) {
return gbBuildPathRow(self, p, false);
})));
return box;
}
var GB_CSS = [
'.gitbackup-view { container-type: inline-size; }',
'.gitbackup-caption { color: var(--text-color-medium, #666); }',
'.gitbackup-actions { display: flex; flex-wrap: wrap; gap: .5em; margin: .75em 0; align-items: center; }',
'.gitbackup-actions input[type="text"] { flex: 1 1 260px; min-width: 200px; }',
'.gitbackup-card-hint { font-size: .9em; color: var(--text-color-medium, #666); }',
'.gitbackup-path-list { list-style: none; margin: .5em 0; padding: 0; }',
'.gitbackup-path-row { display: flex; align-items: center; justify-content: space-between; gap: .75em; padding: .4em .6em; border-bottom: 1px solid var(--background-color-medium, #ddd); }',
'.gitbackup-path-row:last-child { border-bottom: none; }',
'.gitbackup-path-text { font-family: monospace; font-size: .9em; color: var(--text-color-high, #333); word-break: break-all; }',
'@container (max-width: 480px) { .gitbackup-path-row { flex-direction: column; align-items: flex-start; } }',
'.gitbackup-size { margin: .75em 0; color: var(--text-color-high, #333); }',
'.gitbackup-warn { border: 1px solid var(--warn-color-medium, #f0c629); border-radius: 4px; padding: .6em 1em; margin: .5em 0; background: var(--background-color-low, #f5f5f5); color: var(--text-color-high, #333); }',
'.gitbackup-section-title { margin: 1.25em 0 .25em; }'
];
return view.extend({
// Not a config form -- see overview.js's own identical comment. Rule 11
// of the spec's style guide: the stock Save/Apply/Reset row is switched
// off through this documented hook, never hidden with CSS.
handleSaveApply: null,
handleSave: null,
handleReset: null,
load: function() {
return Promise.all([
L.resolveDefault(callListPaths(), null),
L.resolveDefault(callAuditPaths(), null)
]);
},
render: function(data) {
var self = this;
var view;
self._paths = (data[0] && data[0].entries) || [];
self._effective = (data[0] && data[0].effective) || [];
self._audit = (data[1] && data[1].paths) || [];
self._sizeKb = null;
self._sizeUnknown = 0;
view = E('div', { 'class': 'gitbackup-view' }, [
E('style', { 'type': 'text/css' }, [ GB_CSS.join('\n') ]),
E('h2', {}, [ _('Git Backup - Paths') ]),
// Spec, verbatim: the caption is itself a feature, not a footnote
// -- this edits the same file the router's own firmware-upgrade
// tool reads, so anything listed here survives a real sysupgrade
// on its own, whether or not gitbackup itself ever runs again.
E('p', { 'class': 'gitbackup-caption' },
[ _('These paths also survive a firmware upgrade: they are written straight into /etc/sysupgrade.conf, the same file the router\'s own sysupgrade reads, not a separate list of this tool\'s own. A directory you add here stays a directory -- anything added to it later is covered too, exactly like a real sysupgrade would.') ]),
E('div', { 'class': 'gitbackup-actions' }, [
E('input', {
'type': 'text',
'id': 'gitbackup-paths-add-input',
'class': 'cbi-input-text',
'placeholder': _('/absolute/path/to/a/file/or/directory'),
'keydown': function(ev) {
if (ev.keyCode === 13) {
ev.preventDefault();
self.handleAddPath(ev);
}
}
}),
E('button', {
'class': 'cbi-button cbi-button-action',
'id': 'gitbackup-paths-add-btn',
'click': ui.createHandlerFn(self, 'handleAddPath')
}, [ _('Add path') ])
]),
E('div', { 'id': 'gitbackup-paths-body' })
]);
self.renderBody();
self.refreshSizes();
return view;
},
// Rebuilds the size summary, editable path list, automatic-coverage box
// and audit box from self._paths/self._effective/self._audit -- called
// after every load and every successful edit (commitPaths/refresh
// below). Small N (13 keep.d entries on a clean stand plus whatever has
// been added), so a full rebuild rather than per-row DOM patching is
// simple and cheap enough.
renderBody: function() {
var self = this;
var body = document.getElementById('gitbackup-paths-body');
var automatic;
if (!body)
return;
automatic = gbAutomaticEntries(self._paths, self._effective);
body.textContent = '';
body.appendChild(gbBuildSizeBox(self._sizeKb, self._sizeUnknown));
body.appendChild(gbBuildPathsList(self, self._paths));
body.appendChild(gbBuildAutomaticBox(automatic));
body.appendChild(gbBuildAuditBox(self, self._audit));
},
refreshSizes: function() {
var self = this;
var effective = self._effective;
return gbSumSizes(effective).then(function(res) {
// A slower stat batch landing after the effective set moved on
// (another edit already completed) would show a total for a
// set that is no longer displayed -- discard it instead.
if (self._effective !== effective)
return;
self._sizeKb = res.kb;
self._sizeUnknown = res.unknown;
self.renderBody();
});
},
refresh: function() {
var self = this;
return Promise.all([
L.resolveDefault(callListPaths(), null),
L.resolveDefault(callAuditPaths(), null)
]).then(function(data) {
self._paths = (data[0] && data[0].entries) || [];
self._effective = (data[0] && data[0].effective) || [];
self._audit = (data[1] && data[1].paths) || [];
self.renderBody();
return self.refreshSizes();
});
},
setAddBusy: function(busy) {
var input = document.getElementById('gitbackup-paths-add-input');
var btn = document.getElementById('gitbackup-paths-add-btn');
if (input)
input.disabled = busy;
if (btn)
btn.disabled = busy;
},
// commitPaths <newList> [addedPath] -- the one path to `set_paths`.
// Reports every backend-side rejection by name and reason (spec: "не
// молча"), then always refreshes from `list_paths` regardless of outcome
// -- the effective set on disk is the only thing worth trusting after a
// write, never this view's own optimistic guess at what changed.
// Resolves to true only when `addedPath` (if given) was not among the
// rejected entries, so callers adding a single new path know whether to
// also clear the input field.
commitPaths: function(newList, addedPath) {
var self = this;
return callSetPaths(newList).then(function(res) {
var rejected = (res && res.rejected) || [];
var ok = true;
var i;
for (i = 0; i < rejected.length; i++) {
ui.addNotification(null, E('p', {},
[ _('%s: %s').format(rejected[i].path, rejected[i].reason) ]), 'error');
if (addedPath && rejected[i].path === addedPath)
ok = false;
}
return self.refresh().then(function() { return ok; });
}, function(e) {
ui.addNotification(null, E('p', {},
[ _('Could not save the path list: %s').format(e.message) ]), 'error');
return false;
});
},
// handleAddPath -- only the fast, local checks (non-empty, not already
// present, gbPathReject's syntax-only rules) happen before this ever
// calls `set_paths`. Existence is deliberately NOT checked here: it
// needs a real filesystem, `set_paths` already checks it authoritatively
// server-side (gb_paths_validate), and a client-side attempt at the same
// question would need `fs.lstat`, which no rpcd ACL here grants -- see
// the file-header comment.
handleAddPath: function(ev) {
var self = this;
var input = document.getElementById('gitbackup-paths-add-input');
var path = input ? input.value.replace(/^\s+|\s+$/g, '') : '';
var reason;
if (!path) {
ui.addNotification(null, E('p', {}, [ _('Enter a path first.') ]), 'error');
return;
}
if (self._paths.indexOf(path) !== -1) {
ui.addNotification(null, E('p', {}, [ _('%s is already in the backup set.').format(path) ]), 'info');
return;
}
reason = gbPathReject(path);
if (reason) {
ui.addNotification(null, E('p', {}, [ reason ]), 'error');
return;
}
self.setAddBusy(true);
return self.commitPaths(self._paths.concat([ path ]), path).then(function(ok) {
if (ok && input)
input.value = '';
self.setAddBusy(false);
});
},
handleAddFromAudit: function(ev, path) {
var self = this;
var reason = gbPathReject(path);
if (reason) {
ui.addNotification(null, E('p', {}, [ reason ]), 'error');
return;
}
return self.commitPaths(self._paths.concat([ path ]), path);
},
handleRemovePath: function(ev, path) {
var self = this;
var idx = self._paths.indexOf(path);
var next;
if (idx === -1)
return;
next = self._paths.slice(0, idx).concat(self._paths.slice(idx + 1));
return self.commitPaths(next);
}
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,48 @@
{
"admin/system/gitbackup": {
"title": "Git Backup",
"action": {
"type": "firstchild"
},
"depends": {
"acl": [ "luci-app-gitbackup" ],
"uci": { "gitbackup": true }
}
},
"admin/system/gitbackup/overview": {
"title": "Overview",
"order": 1,
"action": {
"type": "view",
"path": "gitbackup/overview"
}
},
"admin/system/gitbackup/settings": {
"title": "Settings",
"order": 2,
"action": {
"type": "view",
"path": "gitbackup/settings"
}
},
"admin/system/gitbackup/paths": {
"title": "Paths",
"order": 3,
"action": {
"type": "view",
"path": "gitbackup/paths"
}
},
"admin/system/gitbackup/history": {
"title": "History / Restore",
"order": 4,
"action": {
"type": "view",
"path": "gitbackup/history"
}
}
}
@@ -0,0 +1,36 @@
{
"luci-app-gitbackup": {
"description": "Grant UCI and rpcd access for luci-app-gitbackup",
"read": {
"uci": ["gitbackup"],
"ubus": {
"luci.gitbackup": [
"status",
"log",
"config_diff",
"pubkey",
"list_paths",
"history",
"audit_paths",
"validate_cron"
]
}
},
"write": {
"uci": ["gitbackup"],
"ubus": {
"luci.gitbackup": [
"run",
"diff",
"keygen",
"test",
"hostkey",
"set_secret",
"set_paths",
"restore",
"card"
]
}
}
}
}