Git-Server git · main
gitsync + this read-only git browser
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.gzFix root backup stuff
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';