Nimbin[12]?Web & Tools / Git-Server / commits / af9ed16

Git-Server git · main

gitsync + this read-only git browser

git php bash apache self-hosted · first commit 2026-10-05 · last commit 2026-10-06 (3 days ago) · synced 3 days ago

PHP 77.1% CSS 11.4% Markdown 8.6% Makefile 2.9%
git clone https://git.christianimmanuel.de/web-tools/Git-Server.gitwget https://git.christianimmanuel.de/web-tools/Git-Server/archive/Git-Server.tar.gz

Fix root backup stuff

Christian Immanuel · 2026-10-06 09:44 · af9ed16490e83db63f5266b5cf3fca2b21d1051c

 README.md     |  15 +++++---
 cron/gitsync  |   3 ++
 gitsync       | 119 ++++++++++++++++++++++++++++++++++++++++++++++------------
 web/index.php |   2 +-
 4 files changed, 110 insertions(+), 29 deletions(-)

diff --git a/README.md b/README.md
index 4593501..0bd5eda 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ Only `git-upload-pack` is exposed, so clones work and pushes do not.
 ## Laptop
 
 ```sh
-sudo make install   # /usr/bin/gitsync
+su -c 'make install'   # /usr/bin/gitsync
 make config         # ~/.config/gitsync/gitsync.conf (only if missing)
 make config-update  # overwrite it with the shipped config (.bak is kept)
 gitsync             # publish
@@ -72,7 +72,7 @@ REMOTE_DIR=/var/www/html/git.christianimmanuel.de
 make ssh-key        # creates ~/.ssh/gitsync_ed25519 and prints the server-side commands (user git-local)
 make cron           # crontab entry: 12:00 daily, only if the server answers a ping
 make uncron         # remove it
-sudo make cron-daily  # alternative without a user crontab: /etc/cron.daily/gitsync (edit USER= in cron/gitsync first)
+su -c 'make cron-daily'  # alternative without a user crontab: /etc/cron.daily/gitsync (edit USER= in cron/gitsync first)
 ```
 
 Set `HOST=git-local@…` and `SSH_KEY=~/.ssh/gitsync_ed25519` in the config (default). The upload runs with `BatchMode`, so it never hangs on a password prompt.
@@ -113,6 +113,7 @@ sudo htpasswd -cB /var/www/html/git.christianimmanuel.de/.htpasswd NAME    # -c
 ```
 
 Fetch with `wget --user=NAME --ask-password URL` or `git clone https://NAME@git.christianimmanuel.de/private/cat/name.git`.
+Apache enforces this in `-common.conf`; `index.php` additionally checks the login itself against `.htpasswd` (bcrypt), so pages, raw files and tarballs stay closed even with an outdated vhost.
 
 ### lfs-backup config
 
@@ -125,9 +126,13 @@ Publish exactly the files an `lfs-backup` config lists, always private:
 
 - Layout on the site: `SECTION/path`, e.g. `user-shell/.bashrc`, `system-boot/etc/fstab`.
 - Globs, directories, symlinks and `!` exclusions work like in `lfs-backup`.
-- Secret files (private keys, `psk=`/`password=` lines) are left out and listed. `-f` takes them.
-- gitsync runs as your user: files only root can read (`[root:*]`, host keys) are left out and listed.
-Apache enforces this in `-common.conf`; `index.php` additionally checks the login itself against `.htpasswd` (bcrypt), so pages, raw files and tarballs stay closed even with an outdated vhost.
+- Secret files (private keys, `psk=`/`password=` lines) are left out and listed. `-f` takes them, but only from your own files.
+
+Files only root can read (`[root:*]`, `/etc/rc.d/rc.iptables`, …) are collected as root:
+
+- `su -c 'make cron-daily'`: the daily job collects them first (`gitsync --collect-root USER`, no password), into `/var/cache/gitsync/USER.tar` (yours, mode 600).
+- In a terminal, gitsync asks for the root password (`su`) when that is missing or older than a day. `gitsync -r` forces it. Enter skips.
+- Root only reads a `backup.conf` owned by root, and never hands out secret files.
 
 ## Scripts collection
 
diff --git a/cron/gitsync b/cron/gitsync
index 7fb0c6c..134b610 100755
--- a/cron/gitsync
+++ b/cron/gitsync
@@ -7,6 +7,9 @@ HOST=162.19.227.194
 RETRIES=6        # attempts
 WAIT=600         # seconds between attempts (10 min)
 
+# root-only files of backup.conf projects ([root:*], [system:*]) -> /var/cache/gitsync/USER.tar
+/usr/bin/gitsync --collect-root "$USER" -q || echo "gitsync: collecting root-only files failed" >&2
+
 i=0
 while [ "$i" -lt "$RETRIES" ]; do
     if ping -c1 -W5 "$HOST" >/dev/null 2>&1 && su - "$USER" -c '/usr/bin/gitsync -q'; then
diff --git a/gitsync b/gitsync
index b6852ec..b611dab 100755
--- a/gitsync
+++ b/gitsync
@@ -3,10 +3,11 @@
 # 100% Vibecode but tested.
 set -eu
 
-VERSION="1.10.1"
+VERSION="1.11.0"
 CONF="${GITSYNC_CONF:-$HOME/.config/gitsync/gitsync.conf}"
 STAGE="${GITSYNC_STAGE:-$HOME/.cache/gitsync}"
-DRYRUN=0 LOCAL=0 VERBOSE=0 FORCE=0 LIST=0 QUIET=0 SSH_KEY=""
+DRYRUN=0 LOCAL=0 VERBOSE=0 FORCE=0 LIST=0 QUIET=0 ROOTNOW=0 SU_TRIED=0 SSH_KEY="" COLLECT_ROOT=""
+SELF=$(readlink -f "$0")
 # always the real git binary, never a shell alias/function/wrapper (GIT= in config overrides)
 GIT=$(command -v /usr/bin/git || command -v /bin/git || echo git)
 # build junk never published from plain (non-git) directories; EXCLUDE= in the config adds more
@@ -24,6 +25,8 @@ gitsync $VERSION - sync repos listed in $CONF
   gitsync -f         publish even if the secret scan finds something
   gitsync -v         verbose rsync/git output
   gitsync -q         quiet (only warnings; for cron)
+  gitsync -r         collect root-only backup.conf files now (asks for the root password)
+  gitsync --collect-root USER   as root (cron): collect them for USER
   gitsync -h         this help
   gitsync -V         version
 
@@ -46,15 +49,20 @@ q()    { [ "$VERBOSE" = 1 ] && echo "" || echo "-q"; }
 slug() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-//; s/-$//'; }
 trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }
 
-case "${1:-}" in --version|-V) echo "gitsync $VERSION"; exit 0 ;; --help) usage; exit 0 ;; esac
-while getopts "nlcfvqh" o; do
+case "${1:-}" in
+  --version|-V) echo "gitsync $VERSION"; exit 0 ;; --help) usage; exit 0 ;;
+  --collect-root) COLLECT_ROOT="${2:-}"; [ -n "$COLLECT_ROOT" ] || die "usage: gitsync --collect-root USER"; shift 2 ;;
+esac
+while getopts "nlcfvqrh" o; do
   case $o in
-    n) DRYRUN=1 ;; l) LOCAL=1 ;; c) LIST=1 ;; f) FORCE=1 ;; v) VERBOSE=1 ;; q) QUIET=1 ;;
+    n) DRYRUN=1 ;; r) ROOTNOW=1 ;; l) LOCAL=1 ;; c) LIST=1 ;; f) FORCE=1 ;; v) VERBOSE=1 ;; q) QUIET=1 ;;
     h) usage; exit 0 ;; *) usage; exit 1 ;;
   esac
 done
-[ -f "$CONF" ] || die "no config at $CONF (see gitsync.conf example)"
-log "config: $CONF"
+if [ -z "$COLLECT_ROOT" ]; then
+  [ -f "$CONF" ] || die "no config at $CONF (see gitsync.conf example)"
+  log "config: $CONF"
+fi
 
 # --- secret scan -------------------------------------------------------------
 # content patterns (case-insensitive ERE) and file name patterns
@@ -136,35 +144,44 @@ sync_plain() { # src dst desc tags
 # --- lfs-backup projects:  !NAME < /etc/pkgusr/backup.conf ------------------
 # Publishes the files a backup.conf lists, privately. Layout: SECTION/path,
 # e.g. user-shell/.bashrc, system-boot/etc/fstab. Secret files are left out (-f takes them).
+# [root:*] and [system:*] files are collected as root (cron, or su in a terminal) into
+# CACHE_DIR/USER.tar; the normal run takes them from there and adds the [user:*] files itself.
+CACHE_DIR=/var/cache/gitsync
 is_secret() { # file
-  printf '%s\n' "$1" | grep -qiE "$SECRET_FILES" && return 0
+  printf '%s\n' "$1" | grep -qiE -e "$SECRET_FILES" -e '(^|/)ssh_host_[a-z0-9]+_key$' && return 0
   grep -qiIE -e "$SECRET_RE" -e '^[[:space:]]*(psk|password|private_key_passwd)[[:space:]]*=' "$1" 2>/dev/null
 }
+has_tty() { [ -t 2 ] && { : </dev/tty; } 2>/dev/null; }
+cache_fresh() { # tarball backup.conf: younger than a day and newer than the config
+  [ -r "$1" ] && [ "$1" -nt "$2" ] && [ $(( $(date +%s) - $(stat -c %Y "$1") )) -lt 86400 ]
+}
 
-sync_backup() { # backup.conf dst desc tags
-  local conf="$1" dst="$2" line sec="" home="" rhome p m f rel pat d skip n=0 ex=() secrets=() unread=()
+collect_backup() { # conf dst kinds(all|user|rootsys) -> appends to B_SECRETS / B_UNREAD
+  local conf="$1" dst="$2" kinds="$3" line sec="" home="" rhome p m f rel pat d skip ex=() force="$FORCE"
+  [ "$kinds" = rootsys ] && force=0   # the root side never hands out secrets
   rhome=$(getent passwd root | cut -d: -f6); rhome="${rhome:-/root}"
   while IFS= read -r line || [ -n "$line" ]; do
     line=$(trim "$line"); [ "${line:0:1}" = "!" ] && ex+=("${line#!}")
   done < "$conf"
-  rm -rf "$dst"; mkdir -p "$dst"
   shopt -s dotglob
   while IFS= read -r line || [ -n "$line" ]; do
     line=$(trim "$line")
     case "$line" in
       ''|\#*|\!*) continue ;;
-      \[user:*\])   sec="user-${line:6:-1}";   home="$HOME" ;;
+      \[user:*\])   sec="user-${line:6:-1}";   home="$HOME"
+                    [ "$kinds" = rootsys ] && sec="" ;;
       \[root:*\])   sec="root-${line:6:-1}";   home="$rhome"
-                    [ -r "$rhome" ] && [ -x "$rhome" ] || unread+=("[root:${line:6:-1}]") ;;
-      \[system:*\]) sec="system-${line:8:-1}"; home="" ;;
+                    if [ "$kinds" = user ]; then sec=""
+                    elif ! { [ -r "$rhome" ] && [ -x "$rhome" ]; }; then B_UNREAD+=("[root:${line:6:-1}]"); sec=""; fi ;;
+      \[system:*\]) sec="system-${line:8:-1}"; home=""
+                    [ "$kinds" = user ] && sec="" ;;
       \[*)          warn "$(basename "$conf"): unknown section $line"; sec="" ;;
       *)
         [ -n "$sec" ] || continue
-        case "$sec" in root-*) [ -r "$rhome" ] && [ -x "$rhome" ] || continue ;; esac
         p="$line"; [ -n "$home" ] && p="${p/#\~/$home}"
         m=$(compgen -G "$p" || true)
         if [ -z "$m" ]; then
-          d=$(dirname "$p"); [ -d "$d" ] && { [ -r "$d" ] && [ -x "$d" ] || unread+=("$line"); }
+          d=$(dirname "$p"); [ -d "$d" ] && { [ -r "$d" ] && [ -x "$d" ] || B_UNREAD+=("$line"); }
           continue
         fi
         while IFS= read -r m; do
@@ -177,25 +194,81 @@ sync_backup() { # backup.conf dst desc tags
               [[ "$f" == $pat ]] && { skip=1; break; }
             done
             [ "$skip" = 1 ] && continue
-            if [ ! -r "$f" ]; then unread+=("$f"); continue; fi
-            if [ "$FORCE" = 0 ] && is_secret "$f"; then secrets+=("$f"); continue; fi
+            if [ ! -r "$f" ]; then B_UNREAD+=("$f"); continue; fi
+            if [ "$force" = 0 ] && is_secret "$f"; then B_SECRETS+=("$f"); continue; fi
             rel="${f#"$home"/}"; rel="${rel#/}"
             mkdir -p "$dst/$sec/$(dirname "$rel")"
-            cp -L --preserve=timestamps "$f" "$dst/$sec/$rel" && n=$((n + 1))
+            cp -L --preserve=timestamps "$f" "$dst/$sec/$rel"
           done < <(find -L "$m" -type f -print0 2>/dev/null)
         done <<< "$m" ;;
     esac
   done < "$conf"
   shopt -u dotglob
-  [ "${#secrets[@]}" -gt 0 ] && warn "$(basename "$dst"): ${#secrets[@]} secret file(s) left out (-f takes them): $(printf '%s\n' "${secrets[@]}" | sed "s|^$HOME/|~/|" | paste -sd' ' -)"
-  [ "${#unread[@]}" -gt 0 ] && warn "$(basename "$dst"): not readable as $(id -un), left out: $(printf '%s\n' "${unread[@]}" | sed "s|^$HOME/|~/|" | paste -sd' ' -)"
-  [ "$VERBOSE" = 1 ] && log "$(basename "$dst"): $n files"
+}
+
+report_backup() { # name hint
+  [ "$QUIET" = 1 ] || [ "${#B_SECRETS[@]}" = 0 ] || warn "$1: ${#B_SECRETS[@]} secret file(s) left out: $(printf '%s\n' "${B_SECRETS[@]}" | sed "s|^$HOME/|~/|" | paste -sd' ' -)"
+  [ "${#B_UNREAD[@]}" = 0 ] || warn "$1: not readable as $(id -un), left out: $(printf '%s\n' "${B_UNREAD[@]}" | sed "s|^$HOME/|~/|" | paste -sd' ' -)$2"
+}
+
+sync_backup() { # conf dst desc tags name
+  local conf="$1" dst="$2" name="$5" cache="$CACHE_DIR/$(id -un).tar" kinds=all
+  B_SECRETS=() B_UNREAD=()
+  rm -rf "$dst"; mkdir -p "$dst"
+  if [ "$(id -u)" != 0 ] && [ "$SU_TRIED" = 0 ] && has_tty && { [ "$ROOTNOW" = 1 ] || ! cache_fresh "$cache" "$conf"; }; then
+    SU_TRIED=1
+    log "root password to collect the root-only files (enter = skip)"
+    su root -c "$(printf '%q' "$SELF") --collect-root $(printf '%q' "$(id -un)")" </dev/tty || warn "su failed, root-only files left out"
+  fi
+  if cache_fresh "$cache" "$conf" && tar -C "$dst" --strip-components=1 -xf "$cache" "$name" 2>/dev/null; then
+    kinds=user; log "  root/system files collected $(date -r "$cache" '+%F %H:%M')"
+  fi
+  collect_backup "$conf" "$dst" "$kinds"
+  report_backup "$name" "$([ "$kinds" = all ] && echo ' (cron collects them as root; in a terminal: gitsync -r)')"
   write_meta "$dst/.gitsync.meta" "$3" "$4" "" \
     "$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | head -n1 | cut -d. -f1)" \
     "$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | tail -n1 | cut -d. -f1)" \
     "$(find "$dst" -type f -printf '%s %p\n' | lang_stats)"
 }
 
+owned_by_root() { # path: root owned, not writable by group/others
+  local s; s=$(stat -c '%u %a' "$1") || return 1
+  [ "${s%% *}" = 0 ] && [ $(( 8#${s##* } & 8#022 )) = 0 ]
+}
+
+collect_root() { # USER: as root, collect [root:*]/[system:*] files of USER's backup.conf projects
+  local u="$1" uhome conf line path bconf name n=0
+  [ "$(id -u)" = 0 ] || die "--collect-root must run as root"
+  uhome=$(getent passwd "$u" | cut -d: -f6); [ -n "$uhome" ] || die "no such user: $u"
+  conf="$uhome/.config/gitsync/gitsync.conf"; [ -f "$conf" ] || die "no config at $conf"
+  [ -L "$CACHE_DIR" ] && die "$CACHE_DIR is a symlink"
+  install -d -m 755 -o root -g root "$CACHE_DIR"
+  owned_by_root "$CACHE_DIR" || die "$CACHE_DIR must be owned by root and not writable by others"
+  CR_TMP=$(mktemp -d); trap 'rm -rf "$CR_TMP"' EXIT
+  B_SECRETS=() B_UNREAD=()
+  while IFS= read -r line || [ -n "$line" ]; do
+    line="${line%%#*}"; line=$(trim "$line"); line="${line#!}"
+    path=$(trim "${line%%|*}"); [[ "$path" == *"<"* ]] || continue
+    bconf=$(trim "${path#*<}"); bconf="${bconf/#\~/$uhome}"; name=$(trim "${path%%<*}")
+    [ -n "$name" ] || name=$(basename "$bconf" .conf)
+    [[ "$name" =~ ^[A-Za-z0-9_+-][A-Za-z0-9._+-]*$ ]] || { warn "bad name: $name"; continue; }
+    bconf=$(readlink -f "$bconf" || true)
+    # the file list must come from root, not from the user's config
+    if [ ! -f "$bconf" ] || ! owned_by_root "$bconf" || ! owned_by_root "$(dirname "$bconf")"; then
+      warn "$bconf: not owned by root (or writable by others), skipped"; continue
+    fi
+    mkdir -p "$CR_TMP/$name"; collect_backup "$bconf" "$CR_TMP/$name" rootsys; n=$((n + 1))
+  done < "$conf"
+  report_backup "root" ""
+  if [ "$n" = 0 ]; then rm -f "$CACHE_DIR/$u.tar"; log "no backup.conf projects for $u"; return 0; fi
+  (cd "$CR_TMP" && tar -cf "$CACHE_DIR/.$u.tar.new" -- *)
+  chown "$u" "$CACHE_DIR/.$u.tar.new"; chmod 600 "$CACHE_DIR/.$u.tar.new"
+  mv -f "$CACHE_DIR/.$u.tar.new" "$CACHE_DIR/$u.tar"
+  log "root-only files for $u: $CACHE_DIR/$u.tar"
+}
+
+[ -n "$COLLECT_ROOT" ] && { collect_root "$COLLECT_ROOT"; exit 0; }
+
 HOST="" REMOTE_DIR="" SCRIPTS="" cat="" cslug="" BLOCKED=0
 mkdir -p "$STAGE"
 : > "$STAGE/.categories.new"
@@ -230,7 +303,7 @@ while IFS= read -r line || [ -n "$line" ]; do
         if [ "$LIST" = 1 ]; then printf '%-22s %-45s %-40s %s  [private, backup.conf]\n' "[$cat]" "$name < $bconf" "$desc" "$tags"; continue; fi
         dest="$STAGE/.private/$cslug/$name"; mkdir -p "$STAGE/.private/$cslug"
         log "$cat / $name (backup.conf, private)"
-        [ "$DRYRUN" = 1 ] || sync_backup "$bconf" "$dest" "$desc" "$tags"
+        [ "$DRYRUN" = 1 ] || sync_backup "$bconf" "$dest" "$desc" "$tags" "$name"
         KEEP+=("$dest"); continue
       fi
       path="${path/#\~/$HOME}"
diff --git a/web/index.php b/web/index.php
index 47e36b3..f6de421 100644
--- a/web/index.php
+++ b/web/index.php
@@ -1,7 +1,7 @@
 <?php
 declare(strict_types=1);
 /* git.christianimmanuel.de - read-only git browser. 100% Vibecode but tested. Works in lynx. */
-const VERSION = '1.10.1';
+const VERSION = '1.11.0';
 const SITE = 'Nimbin[12]?';
 const LEGAL = 'https://christianimmanuel.de';
 $ROOT   = __DIR__ . '/repos';