Compare commits

...
17 Commits
Author SHA1 Message Date
jeremy 7acdb761d6 zsh: detect eza --hyperlink capability, fix 'cannot take a value' on Debian's older eza
Newer eza (clap) treats --hyperlink as taking an optional value, so the
oh-my-zsh plugin's bare --hyperlink eats the next path arg (la / ->
invalid value '/'). The previous fix glued --hyperlink=always to avoid
that. But Debian ships an older eza where --hyperlink is a pure boolean
that rejects a value, so --hyperlink=always errors with 'Flag
--hyperlink cannot take a value'.

Detect the installed eza's behavior via 'eza --help | grep <WHEN>':
- newer eza: use --hyperlink=always (glued, avoids path-eating)
- older eza: use bare --hyperlink (boolean, doesn't eat tokens so the
  original bug doesn't apply)
Apply the chosen form to every eza alias. Both paths keep hyperlinks on
and paths working.
2026-07-29 12:15:06 -04:00
jeremy 18af4de0c0 zsh: add github binary fallbacks for bat/zoxide/delta (fixes RHEL/Alma without EPEL/cargo)
On RHEL-family distros without EPEL enabled and without cargo, the PM
loop fails for bat/zoxide/git-delta and there was no fallback, so they
ended up skipped/FAIL. Now, after the PM/cargo attempts fail, download
the latest static release tarball from GitHub instead, matching the
existing pattern for eza/fastfetch/starship/lazydocker.

- Add _gh_latest_tag(): resolve latest release tag from the
  /releases/latest redirect (no GitHub API, no rate limits)
- Add _gh_install_bin(): build the asset URL from a versioned template,
  download, extract, and cp the named binary into ~/.local/bin
- Handle per-repo tag conventions: bat/zoxide tags are 'v0.x' while
  delta tags are bare '0.x'; keep the raw tag for the download path and
  strip the optional 'v' only for asset-filename substitution
- Add target_musl/target_gnu per-arch (x86_64/aarch64/armv7l)
- bat/zoxide use static musl builds (no glibc deps); delta ships gnu-only
- Verbose 'gh installing <bin> (<tag> from <repo>)' / FAIL lines
- Bump ZSHRC_BOOTSTRAP_VERSION 3 -> 4 so existing boxes re-run the
  bootstrap and pick up the new fallbacks

Verified end-to-end: bat 0.26.1, zoxide 0.10.0, delta 0.19.2 all
download, extract, install, and run from a temp HOME.
2026-07-29 12:11:31 -04:00
jeremy 0281b06154 zsh: make bootstrap verbose + timeout PM availability checks (fix hang on AlmaLinux)
The bootstrap silently suppressed all output, so the user saw the banner
then an apparent hang. Root cause on AlmaLinux: the cold 'dnf list
available' metadata check could stall for minutes on a slow/unreachable
dnf mirror, and nothing was visible or bounded.

- Wrap apt/dnf/pacman availability checks in 'timeout 20' so a slow
  mirror fails through to the next installer instead of hanging
- Remove >/dev/null 2>&1 suppression so real installs (cargo compiles,
  dnf progress) are visible as progress, not an apparent hang
- Emit a one-line status per step:
    ok <tool>      already present
    ...  <tool>    checking package managers
    dnf  <tool>    not available (or metadata check timed out)
    curl <tool>    installing from binary
    cargo <tool>   (may take a few minutes to compile)
    git  <name>    cloning
    FAIL <tool>    could not install; skipping
- Apply the same timeout+verbose pattern to the git-delta block
- Add progress + FAIL lines to the fastfetch/eza/github clones
2026-07-29 11:55:14 -04:00
jeremy 9343715696 zsh: print a user-facing banner when the bootstrap fires
The bootstrap silently installs tools for ~30s+ on a version bump or
first login, which looked like an unexplained hang. Now prints a
banner explaining what is happening, that it may take a minute, and
that subsequent runs are a no-op. The box is built dynamically so the
right edge stays aligned regardless of the version string length.
Also clarifies the trigger comment in zshrc.
2026-07-29 11:35:43 -04:00
jeremy 10caebad29 zsh: add zoxide/bat/delta, starship prompt config, extra history options; rewrite README
Bootstrap (zshrc-bootstrap.zsh):
- Add bat and zoxide to the cross-distro install loop
- Add git-delta install block (package is 'git-delta' on every PM but
  binary is 'delta', mirroring the starship/lazydocker pattern)
- Debian/Ubuntu compat: symlink batcat->bat and fdfind->fd into
  ~/.local/bin so zshrc integrations work under both names
- Bump ZSHRC_BOOTSTRAP_VERSION 2 -> 3 so existing machines re-run the
  bootstrap and auto-install the new tools

zshrc:
- Add setopts HIST_VERIFY, EXTENDED_HISTORY, AUTO_PUSHD,
  PUSHD_IGNORE_DUPS, PUSHD_SILENT
- Self-configuring tool integrations (all behind command -v guards):
  - zoxide: eval $(zoxide init zsh) for frecent 'z'/'zi' dir jumping
  - bat: alias cat->bat, BAT_THEME, colored MANPAGER via bat
  - delta: GIT_PAGER=delta for prettier git diff/log/show
- Extend the interactive auto-update block to also sync starship.toml
  to ~/.config/starship.toml (overwrite-if-changed, validated the
  same way as zshrc); keep the reproducible/self-configuring design
- Inverted hyperlink fix already landed in prior commit; no change
  here

starship.toml (new):
- Two-line prompt: directory + git status + cmd duration on line 1,
  arrow (red on error) on line 2; Nerd Font symbols, SSH host/user,
  Python/Node/Rust/Go/Java/Docker context blocks
- Validated clean with starship 1.21.1 (uses [nodejs], not [node])

README.md:
- Full rewrite: documents the design philosophy, all files, deps,
  integrations, history options, aliases, keybindings, the eza
  hyperlink workaround rationale, auto-update behavior, per-machine
  overrides, env vars, and helper functions
2026-07-29 11:30:49 -04:00
jeremy c4a804e760 zsh: fix eza --hyperlink eating path argument (e.g. 'la /')
The oh-my-zsh eza plugin adds a bare --hyperlink flag as the last tail
option. eza's --hyperlink takes an optional value, so clap swallows the
next token (the path) -> 'la /' fails with 'invalid value /'.

- Drop the 'hyperlink' zstyle so the plugin stops adding the bare flag
- After oh-my-zsh loads, append the glued --hyperlink=always form to
  each eza alias; the glued form cannot eat the next token, so paths
  work and hyperlinks stay on
2026-07-29 11:09:12 -04:00
jeremy eed5879a25 zsh: harden bootstrap against network failures and unsafe paths
- Add --connect-timeout 5 --max-time 30 to all curl calls (starship,
  lazydocker, fastfetch, eza) so an unreachable/slow host aborts instead
  of hanging the new shell during a bootstrap version bump
- Add -f/--fail to curl calls so a 502/HTML error page is not saved as
  a tarball or piped into sh
- Guard fastfetch extract: only cp/chmod when $ffdir is non-empty,
  preventing cp of '/usr/bin/fastfetch' and glob against '/' when
  curl/tar fails
- Only run fastfetch/eza copy steps when both curl and tar succeed;
  clean up temp tarballs in both success and failure paths
- git clone now uses GIT_TERMINAL_PROMPT=0 and http.lowSpeedLimit/
  lowSpeedTime so a stalled clone aborts after ~10s of no progress
  and an unexpected credential prompt cannot block startup
- Drop invalid -y flag from 'dnf list available' availability check
  (it is a global dnf option, not a list subcommand arg) which could
  cause the check to fail and skip the dnf path
2026-07-29 10:53:17 -04:00
jeremy e01bb35043 zsh: add --fail to curl calls, fix tmux config re-download every startup
- Add -f/--fail to all curl calls so HTTP error pages (502/404) are not
  written to temp files and mistaken for valid config/scripts
- Fix tmux check at line 49: was testing the directory path with -f
  (always true when dir exists) causing tmux.conf to re-download on
  every shell start; now checks for the actual conf files in both
  tmux and byobu config dirs
2026-07-29 10:52:52 -04:00
jeremy 52bca5ce38 zsh: add --max-time to curl calls to prevent hanging on slow connections 2026-07-29 10:35:18 -04:00
jeremy b805357f53 netbird-install-update: add --help option with usage, options, and examples 2026-07-29 10:34:46 -04:00
jeremy d2ca6c6787 Add pacman support for Arch Linux systems
- Detect netbird installed via pacman
- Remove netbird packages using pacman -Rns when replacing with binary install
- Update README to document pacman support
2026-07-25 22:49:39 -04:00
jeremy 376fac0665 netbird-install-update: add install/update script with remote deployment and auto-update timers
- Detects Netbird installation method (binary vs package manager)
- Replaces package manager installs with binary for consistency
- Supports single-host and multi-host SSH deployment
- Adds systemd timer for daily auto-updates with persistent scheduling
- Handles sudo password automation via --password flag or hosts file
- Connection-safe updates using background execution to survive SSH disconnects
- Fixes SELinux contexts and binary permissions automatically
- Provides detailed deployment summaries with per-host action tracking
- Includes SSH timeout handling to prevent hanging on unreachable hosts
- Color-coded output with icons for better readability
2026-07-25 22:42:44 -04:00
jeremy e54d77893a docker-stacks-update: fix --list freeze + unbound variable
- Add missing 'shift' in --list case (caused infinite loop)
- Initialize color vars to empty strings before tput block
  (set -u errored when tput unavailable or non-TTY)
2026-07-17 06:32:12 -04:00
jeremy 8617450eb9 docker-stacks-update: add -s/--stack to target specific stacks
Usage:  docker-stacks-update -s stack_a -s stack_b

Only matching running stacks are processed. Shows a helpful message
with available stacks if no match is found.
2026-07-17 06:26:29 -04:00
jeremy 0afc798ee6 docker-stacks-update: add --help, fix over-reporting updates
- Add -h/--help usage with options
- Add argument parsing with unknown-option error
- Fix update detection: 'Pulled' appears for all services even when
  up-to-date; only 'Downloaded newer image for' is reliable
2026-07-17 06:25:39 -04:00
jeremy 84e7dfb2b7 docker-stacks-backup: move success msg after restart, add visible status for each step
- Show 'Stopping containers...' before docker compose down
- Show 'Starting containers...' before docker compose up
- Show 'Dumping named volumes...' during volume backup
- Move 'Backup complete' message to after stack restart
2026-07-17 06:18:08 -04:00
jeremy f2b17647f4 docker-stacks-backup: add init command, output formatting, progress indicators, and bugfixes
New features:
  - init command: inject default x-backup config into compose files
  - --dir flag: scan a custom directory for stacks
  - --to flag: override restore target path

Bugfixes:
  - resolve_docker_volume: no longer double-prefixes volume names
  - metadata has_volumes now correctly tracks volume state
  - warn_named_volumes message now accurate about backup-volumes setting

Output formatting:
  - Terminal colors via tput (auto-disabled on non-TTY)
  - Unicode symbols: ✓ ● ℹ ⚠ ✗ ◇
  - Section headers with ━━━ separators
  - Colored verify results (green OK, red FAIL, yellow SKIP)
  - Cleaner interactive menus with colored prompts

Progress indicators (no dependencies):
  - tar --checkpoint shows live byte progress during backup/restore
  - Graceful fallback when checkpoint unavailable or non-TTY
2026-07-17 06:13:49 -04:00
12 changed files with 1957 additions and 208 deletions
+1
View File
@@ -11,6 +11,7 @@ A collection of utility scripts for Linux desktop and server administration.
| [`docker-stacks-backup/`](docker-stacks-backup/) | Backup & restore Docker Compose stacks — named volumes, bind mounts, pre/post hooks, retention, integrity verification | | [`docker-stacks-backup/`](docker-stacks-backup/) | Backup & restore Docker Compose stacks — named volumes, bind mounts, pre/post hooks, retention, integrity verification |
| [`docker-stacks-update/`](docker-stacks-update/) | Discover running Compose stacks, pull latest images, and restart only those that changed | | [`docker-stacks-update/`](docker-stacks-update/) | Discover running Compose stacks, pull latest images, and restart only those that changed |
| [`input-remapper-switcher/`](input-remapper-switcher/) | Auto-switch [input-remapper](https://github.com/sezanzeb/input-remapper) presets per-application via Hyprland socket events | | [`input-remapper-switcher/`](input-remapper-switcher/) | Auto-switch [input-remapper](https://github.com/sezanzeb/input-remapper) presets per-application via Hyprland socket events |
| [`netbird-install-update/`](netbird-install-update/) | Install, update, and manage Netbird across single or multiple Linux hosts — detects install method, replaces package-manager installs with binary, supports SSH remote deployment, auto-update timers, and sudo password automation |
| [`proxmox-backup/`](proxmox-backup/) | Backup & restore Proxmox VE configuration (local or over SSH) with integrity verification | | [`proxmox-backup/`](proxmox-backup/) | Backup & restore Proxmox VE configuration (local or over SSH) with integrity verification |
| [`proxmox-cloudimg/`](proxmox-cloudimg/) | Download, customize, and create Proxmox VE VM templates from official cloud images | | [`proxmox-cloudimg/`](proxmox-cloudimg/) | Download, customize, and create Proxmox VE VM templates from official cloud images |
| [`zsh/`](zsh/) | Zsh configuration (`.zshrc`), dependency bootstrap installer, and themed tmux config | | [`zsh/`](zsh/) | Zsh configuration (`.zshrc`), dependency bootstrap installer, and themed tmux config |
+59 -2
View File
@@ -60,7 +60,9 @@ The script finds stacks in three ways:
|------|---------------| |------|---------------|
| `--all` | Every subdirectory of the **script's directory** containing a compose file | | `--all` | Every subdirectory of the **script's directory** containing a compose file |
| `--running` | All running compose projects **anywhere on the filesystem** by reading `ConfigFiles` from `docker compose ls --format json` | | `--running` | All running compose projects **anywhere on the filesystem** by reading `ConfigFiles` from `docker compose ls --format json` |
| `--stack <spec>` | A specific stack by name (relative to the script's directory) or by absolute path | | `--stack <spec>` | A specific stack by name (relative to the **current directory**) or by absolute path |
Add `--dir <path>` to any command to override the default scan directory (script dir for backup/restore, current dir for init).
### External Stacks ### External Stacks
@@ -76,6 +78,23 @@ This is useful for ad-hoc backups of stacks that aren't under your main stacks d
## Usage ## Usage
### Init
```bash
# Inject default x-backup config into all stacks in the current directory
./docker-stack-backup.sh init
# Or specify a directory to scan (no cd needed)
./docker-stack-backup.sh init --dir /mnt/data/stacks
# Or target specific stacks
./docker-stack-backup.sh init --running
./docker-stack-backup.sh init --stack random_stack
./docker-stack-backup.sh init --stack /srv/docker/nextcloud
./docker-stack-backup.sh init -s stack_a -s stack_b
./docker-stack-backup.sh init --dry-run --verbose # preview changes
```
### Backup ### Backup
```bash ```bash
@@ -94,6 +113,9 @@ This is useful for ad-hoc backups of stacks that aren't under your main stacks d
./docker-stack-backup.sh backup -s stack_a -s stack_b ./docker-stack-backup.sh backup -s stack_a -s stack_b
./docker-stack-backup.sh backup --stack stack_a,stack_b ./docker-stack-backup.sh backup --stack stack_a,stack_b
# Scan a specific directory for stacks instead of the script's directory
./docker-stack-backup.sh backup --dir /srv/docker --all
# With a custom backup destination # With a custom backup destination
./docker-stack-backup.sh backup --all --backup-dir /mnt/nfs/backups ./docker-stack-backup.sh backup --all --backup-dir /mnt/nfs/backups
@@ -128,6 +150,9 @@ After selecting stacks, the script re-executes via `sudo` with your selections a
# Restore an external stack by path # Restore an external stack by path
./docker-stack-backup.sh restore --stack /srv/docker/nextcloud ./docker-stack-backup.sh restore --stack /srv/docker/nextcloud
# Restore to a different location (overrides the original path)
./docker-stack-backup.sh restore --stack random_stack --to /new/location/random_stack
# Restore from a specific backup directory # Restore from a specific backup directory
./docker-stack-backup.sh restore --stack random_stack --backup-dir /mnt/nfs/backups ./docker-stack-backup.sh restore --stack random_stack --backup-dir /mnt/nfs/backups
``` ```
@@ -149,6 +174,38 @@ After selecting backups, the script re-executes via `sudo` with the resolved sta
When restoring over an existing stack directory, the current directory is briefly renamed to `<stack>.pre-restore-<timestamp>` as a safety net during extraction. Once the restore completes successfully, the pre-restore copy is removed — the backup archive is the authoritative restore point. Any stale pre-restore directories from previous failed restores are cleaned up automatically. When restoring over an existing stack directory, the current directory is briefly renamed to `<stack>.pre-restore-<timestamp>` as a safety net during extraction. Once the restore completes successfully, the pre-restore copy is removed — the backup archive is the authoritative restore point. Any stale pre-restore directories from previous failed restores are cleaned up automatically.
### Init (Inject Default Config)
```bash
# Inject default x-backup config into all stacks in the current directory
./docker-stack-backup.sh init
# Or target running/external stacks
./docker-stack-backup.sh init --running
./docker-stack-backup.sh init --stack /srv/docker/nextcloud
```
Scans each compose file (relative to the **current directory** by default, or use `--dir` to point elsewhere) and injects a default `x-backup` block at the top:
```yaml
x-backup:
stop: true
backup-volumes: true
# pre-hook: ""
# post-hook: ""
# pre-restore-hook: ""
# post-restore-hook: ""
# exclude:
# - path/to/exclude
# retention: 7
```
Active defaults (`stop: true`, `backup-volumes: true`) are uncommented. Optional fields are commented out as a reference — uncomment and adjust as needed.
If the compose file already has an `x-backup:` block, it is removed first so the result is always a clean standard config. This makes `init` idempotent and safe to re-run at any time.
Does not require root by default — reads and writes compose files as the current user. If a compose file is owned by another user (e.g. root), the script automatically escalates with `sudo` for the write.
### Verify ### Verify
```bash ```bash
@@ -383,7 +440,7 @@ Or as a cron job on the host (in root's crontab, or use `sudo`):
## Requirements ## Requirements
- **Root access** — backup and restore require root because `tar --same-permissions --preserve-permissions` only preserves UID/GID ownership when running as root. The script automatically re-executes via `sudo` when needed. Read-only commands (`list`, `verify`, and the interactive menus) run without elevation. - **Root access** — backup and restore require root because `tar --same-permissions --preserve-permissions` only preserves UID/GID ownership when running as root. The script automatically re-executes via `sudo` when needed. Read-only commands (`list`, `verify`, and the interactive menus) run without elevation. The `init` command also runs without root by default, but auto-escalates with `sudo` if the compose file isn't writable by the current user.
When run via `sudo`, the backup archives (`.tar.gz` and `.meta` files) are automatically `chown`ed back to the original user so you can list, copy, or delete them without root. When run via `sudo`, the backup archives (`.tar.gz` and `.meta` files) are automatically `chown`ed back to the original user so you can list, copy, or delete them without root.
+272 -88
View File
@@ -13,13 +13,29 @@ TARGET_STACKS=()
MODE="all" # all|running MODE="all" # all|running
HAS_FLAGS=false HAS_FLAGS=false
ORIG_ARGS=() ORIG_ARGS=()
TARGET_DIR=""
RESTORE_TARGET=""
# --- Helper Functions -------------------------------------------------------- # --- Output Formatting --------------------------------------------------------
log() { if $VERBOSE; then echo "[INFO] $*"; fi; } # Terminal colors (auto-disabled when not a TTY or tput unavailable)
warn() { echo "[WARN] $*" >&2; } if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
err() { echo "[ERROR] $*" >&2; exit 1; } BOLD=$(tput bold 2>/dev/null || true)
dry() { if $DRY_RUN; then echo "[DRY-RUN] $*"; else "$@"; fi; } RED=$(tput setaf 1 2>/dev/null || true)
GREEN=$(tput setaf 2 2>/dev/null || true)
YELLOW=$(tput setaf 3 2>/dev/null || true)
BLUE=$(tput setaf 4 2>/dev/null || true)
MAGENTA=$(tput setaf 5 2>/dev/null || true)
CYAN=$(tput setaf 6 2>/dev/null || true)
RESET=$(tput sgr0 2>/dev/null || true)
fi
log() { $VERBOSE && echo " ${BLUE}${RESET} $*" || true; }
warn() { echo " ${YELLOW}${RESET} $*" >&2; }
err() { echo " ${RED}${RESET} $*" >&2; exit 1; }
dry() { if $DRY_RUN; then echo " ${MAGENTA}${RESET} $*"; else "$@"; fi; }
success() { echo " ${GREEN}${RESET} $*"; }
header() { echo ""; echo " ${BOLD}${CYAN}━━━ $* ━━━${RESET}"; echo ""; }
# When run via sudo, chown files back to the original user # When run via sudo, chown files back to the original user
fix_owner() { fix_owner() {
@@ -55,21 +71,25 @@ Commands:
With no flags, shows an interactive menu. With no flags, shows an interactive menu.
restore Restore one or more stacks from a backup. restore Restore one or more stacks from a backup.
With no --stack flag, shows an interactive menu. With no --stack flag, shows an interactive menu.
init Inject default x-backup config into compose files
that don't already have one.
list List available stacks and backups list List available stacks and backups
verify Verify backup integrity (checks sha256 hash) verify Verify backup integrity (checks sha256 hash)
Options: Options:
-s, --stack <name> Target a specific stack (repeatable, or comma-separated). -s, --stack <name> Target a specific stack (repeatable, or comma-separated).
Can be a name (relative to stacks dir) or absolute path. Can be a name (relative to current directory) or absolute path.
-a, --all Target all stacks found in stacks directory -a, --all Target all stacks found in stacks directory
-r, --running Target only currently running compose projects wherever -r, --running Target only currently running compose projects wherever
they live (discovers compose file paths automatically) they live (discovers compose file paths automatically)
-d, --backup-dir <dir> Backup directory (default: ${_DEFAULT_BACKUP_DIR}) -d, --backup-dir <dir> Backup directory (default: ${_DEFAULT_BACKUP_DIR})
--dir <dir> Scan this directory for stacks (overrides default discovery dir)
--to <path> Override restore target path (use with restore --stack)
-n, --dry-run Show what would be done without making changes -n, --dry-run Show what would be done without making changes
-v, --verbose Verbose output -v, --verbose Verbose output
-h, --help Show this help message -h, --help Show this help message
Must be run as root (preserves file ownership in backups and restores). Must be run as root for backup/restore (preserves file ownership). The init and list commands do not require root.
Compose x-backup extensions (add to compose.yaml): Compose x-backup extensions (add to compose.yaml):
x-backup: x-backup:
@@ -89,6 +109,9 @@ Examples:
$(basename "$0") backup --stack random_stack --stack another_stack $(basename "$0") backup --stack random_stack --stack another_stack
$(basename "$0") backup -s random_stack -v $(basename "$0") backup -s random_stack -v
$(basename "$0") restore --stack random_stack -d /path/to/backups $(basename "$0") restore --stack random_stack -d /path/to/backups
$(basename "$0") restore --stack random_stack --to /new/location/random_stack
$(basename "$0") init
$(basename "$0") init --running
$(basename "$0") list $(basename "$0") list
EOF EOF
exit 0 exit 0
@@ -106,7 +129,7 @@ require_root() {
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
return return
fi fi
echo "Elevating to root via sudo..." echo " ${MAGENTA}${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
exec sudo "$0" "${ORIG_ARGS[@]}" exec sudo "$0" "${ORIG_ARGS[@]}"
} }
@@ -183,7 +206,7 @@ resolve_target_stacks() {
if [ ${#explicit[@]} -gt 0 ]; then if [ ${#explicit[@]} -gt 0 ]; then
for spec in "${explicit[@]}"; do for spec in "${explicit[@]}"; do
stack_spec_to_path "$stacks_dir" "$spec" stack_spec_to_path "${INITIAL_DIR:-$stacks_dir}" "$spec"
done done
return return
fi fi
@@ -328,9 +351,9 @@ apply_retention() {
if [ "$count" -gt "$retention" ]; then if [ "$count" -gt "$retention" ]; then
local to_delete local to_delete
to_delete="$((count - retention))" to_delete="$((count - retention))"
log "Retention=$retention, pruning $to_delete old backup(s) for '$stack_name'" log "Retention=${retention}, pruning ${to_delete} old backup(s) for '${stack_name}'"
find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | sort | head -n "$to_delete" | while IFS= read -r f; do find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | sort | head -n "$to_delete" | while IFS= read -r f; do
log " Pruning: $(basename "$f")" echo " ${MAGENTA}${RESET} Pruning: $(basename "$f")"
dry rm -f "$f" "${f}.meta" dry rm -f "$f" "${f}.meta"
done done
fi fi
@@ -394,12 +417,12 @@ warn_named_volumes() {
vols="$(detect_named_volumes "$compose_file")" vols="$(detect_named_volumes "$compose_file")"
[ -z "$vols" ] && return [ -z "$vols" ] && return
echo " [!] Named volumes detected (not backed up by default):" echo " ${YELLOW}${RESET} Named volumes detected but ${BOLD}backup-volumes is false${RESET}, skipping:"
for v in $vols; do for v in $vols; do
echo " - $v" echo " ${YELLOW}${RESET} ${CYAN}${RESET} $v"
done done
echo " These store data inside Docker's storage area, not on the host filesystem." echo " ${YELLOW}${RESET} These store data inside Docker's storage area, not on the host filesystem."
echo " Set 'x-backup.backup-volumes: false' to skip them." echo " ${YELLOW}${RESET} Set ${BOLD}x-backup.backup-volumes: true${RESET} to include them."
} }
# Resolve the actual Docker volume name for a compose volume reference. # Resolve the actual Docker volume name for a compose volume reference.
@@ -407,7 +430,11 @@ warn_named_volumes() {
resolve_docker_volume() { resolve_docker_volume() {
local stack_name="$1" local stack_name="$1"
local vol_name="$2" local vol_name="$2"
echo "${stack_name}_${vol_name}" if [[ "$vol_name" == "${stack_name}_"* ]]; then
echo "$vol_name"
else
echo "${stack_name}_${vol_name}"
fi
} }
# Back up named Docker volumes into a temporary directory. # Back up named Docker volumes into a temporary directory.
@@ -438,7 +465,7 @@ backup_named_volumes() {
if docker volume inspect "$docker_vol" >/dev/null 2>&1; then if docker volume inspect "$docker_vol" >/dev/null 2>&1; then
log "Dumping named volume '$docker_vol'..." log "Dumping named volume '$docker_vol'..."
if $DRY_RUN; then if $DRY_RUN; then
echo "[DRY-RUN] docker run --rm -v ${docker_vol}:/_src alpine tar -czf - -C /_src . > $volumes_dir/${vol}.tar.gz" echo " ${MAGENTA}${RESET} docker run --rm -v ${docker_vol}:/_src alpine tar -czf - -C /_src . > $volumes_dir/${vol}.tar.gz"
else else
docker run --rm -v "${docker_vol}:/_src" alpine tar -czf - -C /_src . > "$volumes_dir/${vol}.tar.gz" 2>/dev/null || \ docker run --rm -v "${docker_vol}:/_src" alpine tar -czf - -C /_src . > "$volumes_dir/${vol}.tar.gz" 2>/dev/null || \
warn "Failed to dump named volume '$docker_vol'" warn "Failed to dump named volume '$docker_vol'"
@@ -467,8 +494,8 @@ restore_named_volumes() {
log "Restoring named volume '$docker_vol'..." log "Restoring named volume '$docker_vol'..."
if $DRY_RUN; then if $DRY_RUN; then
echo "[DRY-RUN] docker volume create $docker_vol" echo " ${MAGENTA}${RESET} docker volume create $docker_vol"
echo "[DRY-RUN] cat $dump | docker run -i --rm -v ${docker_vol}:/_dst alpine tar -xzf - -C /_dst" echo " ${MAGENTA}${RESET} cat $dump | docker run -i --rm -v ${docker_vol}:/_dst alpine tar -xzf - -C /_dst"
else else
docker volume rm "$docker_vol" 2>/dev/null || true docker volume rm "$docker_vol" 2>/dev/null || true
docker volume create "$docker_vol" >/dev/null || { warn "Failed to create volume '$docker_vol'"; continue; } docker volume create "$docker_vol" >/dev/null || { warn "Failed to create volume '$docker_vol'"; continue; }
@@ -483,7 +510,7 @@ restore_named_volumes() {
rm -rf "$volumes_dir" rm -rf "$volumes_dir"
if $any_restored; then if $any_restored; then
echo " -> Named volumes restored" success "Named volumes restored"
fi fi
} }
@@ -521,7 +548,7 @@ stop_stack() {
compose_file="$(resolve_compose_file "$stack_path")" compose_file="$(resolve_compose_file "$stack_path")"
if stack_is_running "$stack_path"; then if stack_is_running "$stack_path"; then
log "Stopping stack '$stack_name' ($stack_path)..." echo " ${CYAN}${RESET} Stopping containers..."
if [ -n "$compose_file" ]; then if [ -n "$compose_file" ]; then
dry docker compose -f "$compose_file" --project-directory "$stack_path" down 2>/dev/null || \ dry docker compose -f "$compose_file" --project-directory "$stack_path" down 2>/dev/null || \
dry docker compose -p "$stack_name" down 2>/dev/null || \ dry docker compose -p "$stack_name" down 2>/dev/null || \
@@ -544,7 +571,7 @@ start_stack() {
compose_file="$(resolve_compose_file "$stack_path")" compose_file="$(resolve_compose_file "$stack_path")"
if [ -n "$compose_file" ]; then if [ -n "$compose_file" ]; then
log "Starting stack '$stack_name' ($stack_path)..." echo " ${CYAN}${RESET} Starting containers..."
dry docker compose -f "$compose_file" --project-directory "$stack_path" up -d 2>/dev/null || \ dry docker compose -f "$compose_file" --project-directory "$stack_path" up -d 2>/dev/null || \
warn "Failed to start stack '$stack_name'" warn "Failed to start stack '$stack_name'"
fi fi
@@ -552,6 +579,12 @@ start_stack() {
# --- Backup ------------------------------------------------------------------ # --- Backup ------------------------------------------------------------------
# Resolve the real path if the directory involves symlinks
_resolve_dir() {
local dir="$1"
(cd -P "$dir" 2>/dev/null && pwd) || echo "$dir"
}
backup_stack() { backup_stack() {
require_root require_root
local TIMESTAMP local TIMESTAMP
@@ -564,10 +597,16 @@ backup_stack() {
local backup_file="$backup_dir/${stack_name}_${TIMESTAMP}.tar.gz" local backup_file="$backup_dir/${stack_name}_${TIMESTAMP}.tar.gz"
local archive_root local archive_root
archive_root="$(dirname "$stack_path")" archive_root="$(dirname "$stack_path")"
archive_root="$(_resolve_dir "$archive_root")"
local archive_basename local archive_basename
archive_basename="$(basename "$stack_path")" archive_basename="$(basename "$stack_path")"
local _backup_size=""
echo "Backing up stack '$stack_name' ($stack_path) -> $backup_file" header "Backup: ${stack_name}"
echo " ${CYAN}${RESET} Source: ${BOLD}${stack_path}${RESET}"
echo " ${CYAN}${RESET} Archive: ${backup_file##*/}"
echo ""
# Load x-backup config # Load x-backup config
load_x_backup_config "$stack_path" load_x_backup_config "$stack_path"
@@ -581,13 +620,11 @@ backup_stack() {
# Dump named Docker volumes (opt-out via x-backup.backup-volumes: false) # Dump named Docker volumes (opt-out via x-backup.backup-volumes: false)
local compose_file local compose_file
compose_file="$(resolve_compose_file "$stack_path")" compose_file="$(resolve_compose_file "$stack_path")"
local volumes_dumped=false
if [ "$XB_BACKUP_VOLUMES" = "false" ]; then if [ "$XB_BACKUP_VOLUMES" = "false" ]; then
warn_named_volumes "$compose_file" "$stack_name" warn_named_volumes "$compose_file" "$stack_name"
else else
log "Dumping named volumes..." echo " ${CYAN}${RESET} Dumping named volumes..."
backup_named_volumes "$stack_path" "$stack_name" "$compose_file" backup_named_volumes "$stack_path" "$stack_name" "$compose_file"
volumes_dumped=true
fi fi
# Pre-backup hook # Pre-backup hook
@@ -600,30 +637,46 @@ backup_stack() {
dry mkdir -p "$backup_dir" dry mkdir -p "$backup_dir"
fix_owner "$backup_dir" 2>/dev/null || true fix_owner "$backup_dir" 2>/dev/null || true
if $DRY_RUN; then if $DRY_RUN; then
echo "[DRY-RUN] tar -czf $backup_file --same-permissions --preserve-permissions $XB_EXCLUDE -C $archive_root $archive_basename" echo " ${MAGENTA}${RESET} tar -czf $backup_file --same-permissions --preserve-permissions $XB_EXCLUDE -C $archive_root $archive_basename"
else else
# shellcheck disable=SC2086 # shellcheck disable=SC2086
tar -czf "$backup_file" --same-permissions --preserve-permissions $XB_EXCLUDE -C "$archive_root" "$archive_basename" if [ -t 1 ] && tar --help 2>/dev/null | grep -q -- --checkpoint; then
local cr=$'\r'
local clr=$'\033[K'
echo -n " ${CYAN}${RESET} Compressing..."
tar -czf "$backup_file" \
--checkpoint=500 \
--checkpoint-action="ttyout=${cr} ${CYAN}${RESET} %T${clr}" \
--same-permissions --preserve-permissions \
$XB_EXCLUDE -C "$archive_root" "$archive_basename"
echo -ne "\r\033[K\n" 2>/dev/null || true
else
echo " ${CYAN}${RESET} Compressing..."
tar -czf "$backup_file" --same-permissions --preserve-permissions $XB_EXCLUDE -C "$archive_root" "$archive_basename"
fi
fix_owner "$backup_file" fix_owner "$backup_file"
echo " -> Backup complete: $(du -h "$backup_file" | cut -f1)" _backup_size="$(du -h "$backup_file" | cut -f1)"
fi fi
# Capture volume state before cleanup
local has_volumes="false"
[ -d "$stack_path/_volumes" ] && has_volumes="true"
# Clean up temporary volume dumps # Clean up temporary volume dumps
if $volumes_dumped && [ -d "$stack_path/_volumes" ]; then if [ "$has_volumes" = "true" ]; then
dry rm -rf "$stack_path/_volumes" dry rm -rf "$stack_path/_volumes"
fi fi
# Write metadata — used by the interactive restore menu and for # Write metadata — used by the interactive restore menu and for
# identifying backups without extracting the archive. # identifying backups without extracting the archive.
if ! $DRY_RUN; then if ! $DRY_RUN; then
local has_volumes="false"
[ -d "$stack_path/_volumes" ] && has_volumes="true"
local archive_sha256 archive_size local archive_sha256 archive_size
archive_sha256="$(sha256sum "$backup_file" | cut -d' ' -f1)" archive_sha256="$(sha256sum "$backup_file" | cut -d' ' -f1)"
archive_size="$(stat -c%s "$backup_file" 2>/dev/null || stat -f%z "$backup_file" 2>/dev/null || echo "0")" archive_size="$(stat -c%s "$backup_file" 2>/dev/null || stat -f%z "$backup_file" 2>/dev/null || echo "0")"
local resolved_path="${archive_root}/${archive_basename}"
cat > "${backup_file}.meta" <<EOF cat > "${backup_file}.meta" <<EOF
stack=${stack_name} stack=${stack_name}
path=${stack_path} path=${resolved_path}
date=${TIMESTAMP} date=${TIMESTAMP}
compose=$(basename "${compose_file:-compose.yaml}") compose=$(basename "${compose_file:-compose.yaml}")
volumes=${has_volumes} volumes=${has_volumes}
@@ -645,6 +698,10 @@ EOF
else else
start_stack "$stack_path" start_stack "$stack_path"
fi fi
if ! $DRY_RUN && [ -n "$_backup_size" ]; then
success "Backup complete: ${BOLD}${_backup_size}${RESET}"
fi
} }
# --- Interactive Restore Menu ------------------------------------------------ # --- Interactive Restore Menu ------------------------------------------------
@@ -704,7 +761,7 @@ interactive_restore_menu() {
done done
if [ ${#names[@]} -eq 0 ]; then if [ ${#names[@]} -eq 0 ]; then
echo "No backups found in $backup_dir" echo " ${YELLOW}${RESET} No backups found in ${backup_dir}"
exit 0 exit 0
fi fi
@@ -712,20 +769,19 @@ interactive_restore_menu() {
IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS
echo "" echo ""
echo "Available backups:" echo " ${BOLD}${CYAN}━━━ Available Backups ━━━${RESET}"
echo "------------------"
local i local i
for i in "${!names[@]}"; do for i in "${!names[@]}"; do
local idx=$((i + 1)) local idx=$((i + 1))
printf " %2d) %-20s %s\n" "$idx" "${names[$i]}" "${stack_date[${names[$i]}]:-}" printf " ${CYAN}%2d)${RESET} ${BOLD}%-20s${RESET} %s\n" "$idx" "${names[$i]}" "${stack_date[${names[$i]}]:-}"
done done
echo " all) Restore all stacks" echo " ${CYAN}all)${RESET} Restore all stacks"
echo " q) Cancel" echo " ${YELLOW}q)${RESET} Cancel"
echo "" echo ""
read -r -p "Select stacks to restore (numbers, 'all', or 'q'): " selection read -r -p " ${CYAN}${RESET} Select stacks to restore (numbers, 'all', or 'q'): " selection
[ -z "$selection" ] && echo "Cancelled." && exit 0 [ -z "$selection" ] && echo " ${YELLOW}${RESET} Cancelled." && exit 0
# Normalise: handle "all" # Normalise: handle "all"
if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then
@@ -736,7 +792,7 @@ interactive_restore_menu() {
fi fi
if [ "$selection" = "q" ]; then if [ "$selection" = "q" ]; then
echo "Cancelled." echo " ${YELLOW}${RESET} Cancelled."
exit 0 exit 0
fi fi
@@ -756,13 +812,13 @@ interactive_restore_menu() {
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
selected_stacks+=("${names[$((seen_idx - 1))]}") selected_stacks+=("${names[$((seen_idx - 1))]}")
else else
echo "Invalid selection: $seen_idx (out of range)" echo " ${YELLOW}${RESET} Invalid selection: ${seen_idx} (out of range)"
fi fi
fi fi
done done
if [ ${#selected_stacks[@]} -eq 0 ]; then if [ ${#selected_stacks[@]} -eq 0 ]; then
echo "No valid stacks selected." echo " ${YELLOW}${RESET} No valid stacks selected."
exit 0 exit 0
fi fi
@@ -786,13 +842,13 @@ interactive_restore_menu() {
done done
if [ ${#stack_flags[@]} -eq 0 ]; then if [ ${#stack_flags[@]} -eq 0 ]; then
echo "No valid stacks selected." echo " ${YELLOW}${RESET} No valid stacks selected."
exit 0 exit 0
fi fi
# Elevate and re-exec with the selected stacks # Elevate and re-exec with the selected stacks
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "Elevating to root via sudo..." echo " ${MAGENTA}${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
exec sudo "$0" restore --backup-dir "$backup_dir" "${stack_flags[@]}" exec sudo "$0" restore --backup-dir "$backup_dir" "${stack_flags[@]}"
fi fi
@@ -818,7 +874,7 @@ verify_backup() {
fi fi
if [ -z "$files" ]; then if [ -z "$files" ]; then
echo "No backups found." echo " ${YELLOW}${RESET} No backups found."
return return
fi fi
@@ -829,7 +885,7 @@ verify_backup() {
local meta_file="${f}.meta" local meta_file="${f}.meta"
if [ ! -f "$meta_file" ]; then if [ ! -f "$meta_file" ]; then
echo " SKIP $basename_f (no .meta file)" echo " ${YELLOW}${RESET} ${YELLOW}SKIP${RESET} ${basename_f} (no .meta file)"
continue continue
fi fi
@@ -838,13 +894,13 @@ verify_backup() {
stored_size="$(grep '^size=' "$meta_file" | head -1 | sed 's/^size=//')" stored_size="$(grep '^size=' "$meta_file" | head -1 | sed 's/^size=//')"
if [ -z "$stored_hash" ]; then if [ -z "$stored_hash" ]; then
echo " SKIP $basename_f (no sha256 in .meta — legacy backup)" echo " ${YELLOW}${RESET} ${YELLOW}SKIP${RESET} ${basename_f} (no sha256 in .meta — legacy backup)"
continue continue
fi fi
# Check file still exists # Check file still exists
if [ ! -f "$f" ]; then if [ ! -f "$f" ]; then
echo " FAIL $basename_f (file missing)" echo " ${RED}${RESET} ${RED}FAIL${RESET} ${basename_f} (file missing)"
exit_code=1 exit_code=1
continue continue
fi fi
@@ -853,7 +909,7 @@ verify_backup() {
local actual_size local actual_size
actual_size="$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo "0")" actual_size="$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo "0")"
if [ "$actual_size" != "$stored_size" ]; then if [ "$actual_size" != "$stored_size" ]; then
echo " FAIL $basename_f (size mismatch: $actual_size vs $stored_size)" echo " ${RED}${RESET} ${RED}FAIL${RESET} ${basename_f} (size mismatch: ${actual_size} vs ${stored_size})"
exit_code=1 exit_code=1
continue continue
fi fi
@@ -862,12 +918,12 @@ verify_backup() {
local actual_hash local actual_hash
actual_hash="$(sha256sum "$f" | cut -d' ' -f1)" actual_hash="$(sha256sum "$f" | cut -d' ' -f1)"
if [ "$actual_hash" != "$stored_hash" ]; then if [ "$actual_hash" != "$stored_hash" ]; then
echo " FAIL $basename_f (sha256 mismatch)" echo " ${RED}${RESET} ${RED}FAIL${RESET} ${basename_f} (sha256 mismatch)"
exit_code=1 exit_code=1
else else
local human_size local human_size
human_size="$(echo "$stored_size" | numfmt --to=iec 2>/dev/null || echo "${stored_size}B")" human_size="$(echo "$stored_size" | numfmt --to=iec 2>/dev/null || echo "${stored_size}B")"
echo " OK $basename_f ($human_size)" echo " ${GREEN}${RESET} ${GREEN}OK${RESET} ${basename_f} (${human_size})"
fi fi
done done
@@ -929,25 +985,24 @@ interactive_backup_menu() {
done done
if [ ${#names[@]} -eq 0 ]; then if [ ${#names[@]} -eq 0 ]; then
echo "No stacks found." echo " ${YELLOW}${RESET} No stacks found."
exit 0 exit 0
fi fi
echo "" echo ""
echo "Available stacks:" echo " ${BOLD}${CYAN}━━━ Available Stacks ━━━${RESET}"
echo "-----------------"
local i local i
for i in "${!names[@]}"; do for i in "${!names[@]}"; do
local idx=$((i + 1)) local idx=$((i + 1))
printf " %2d) %-20s %s\n" "$idx" "${names[$i]}" "${labels[$i]}" printf " ${CYAN}%2d)${RESET} ${BOLD}%-20s${RESET} %s\n" "$idx" "${names[$i]}" "${labels[$i]}"
done done
echo " all) Back up all stacks" echo " ${CYAN}all)${RESET} Back up all stacks"
echo " q) Cancel" echo " ${YELLOW}q)${RESET} Cancel"
echo "" echo ""
read -r -p "Select stacks to back up (numbers, 'all', or 'q'): " selection read -r -p " ${CYAN}${RESET} Select stacks to back up (numbers, 'all', or 'q'): " selection
[ -z "$selection" ] && echo "Cancelled." && exit 0 [ -z "$selection" ] && echo " ${YELLOW}${RESET} Cancelled." && exit 0
if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then
selection="" selection=""
@@ -957,7 +1012,7 @@ interactive_backup_menu() {
fi fi
if [ "$selection" = "q" ]; then if [ "$selection" = "q" ]; then
echo "Cancelled." echo " ${YELLOW}${RESET} Cancelled."
exit 0 exit 0
fi fi
@@ -975,13 +1030,13 @@ interactive_backup_menu() {
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
selected_paths+=("${paths[$((seen_idx - 1))]}") selected_paths+=("${paths[$((seen_idx - 1))]}")
else else
echo "Invalid selection: $seen_idx (out of range)" echo " ${YELLOW}${RESET} Invalid selection: ${seen_idx} (out of range)"
fi fi
fi fi
done done
if [ ${#selected_paths[@]} -eq 0 ]; then if [ ${#selected_paths[@]} -eq 0 ]; then
echo "No valid stacks selected." echo " ${YELLOW}${RESET} No valid stacks selected."
exit 0 exit 0
fi fi
@@ -991,7 +1046,7 @@ interactive_backup_menu() {
for spath in "${selected_paths[@]}"; do for spath in "${selected_paths[@]}"; do
stack_flags+=("--stack" "$spath") stack_flags+=("--stack" "$spath")
done done
echo "Elevating to root via sudo..." echo " ${MAGENTA}${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
exec sudo "$0" backup --backup-dir "$backup_dir" "${stack_flags[@]}" exec sudo "$0" backup --backup-dir "$backup_dir" "${stack_flags[@]}"
fi fi
@@ -1010,12 +1065,14 @@ restore_stack() {
local stacks_dir="$1" local stacks_dir="$1"
local backup_dir="$2" local backup_dir="$2"
local stack_path="$3" local stack_path="$3"
local restore_path="${4:-$stack_path}"
local stack_name local stack_name
stack_name="$(stack_spec_to_name "$stack_path")" stack_name="$(stack_spec_to_name "$stack_path")"
local archive_root local archive_root
archive_root="$(dirname "$stack_path")" archive_root="$(dirname "$restore_path")"
archive_root="$(_resolve_dir "$archive_root")"
local archive_basename local archive_basename
archive_basename="$(basename "$stack_path")" archive_basename="$(basename "$restore_path")"
# Find the latest backup for this stack # Find the latest backup for this stack
local backup_file local backup_file
@@ -1025,7 +1082,11 @@ restore_stack() {
err "No backup found for stack '$stack_name' in $backup_dir" err "No backup found for stack '$stack_name' in $backup_dir"
fi fi
echo "Restoring stack '$stack_name' ($stack_path) from $backup_file" header "Restore: ${stack_name}"
echo " ${CYAN}${RESET} Target: ${BOLD}${restore_path}${RESET}"
echo " ${CYAN}${RESET} Backup: ${backup_file##*/}"
echo ""
# Get list of files in backup to confirm it's the right one # Get list of files in backup to confirm it's the right one
if $VERBOSE; then if $VERBOSE; then
@@ -1070,20 +1131,32 @@ restore_stack() {
# Restore # Restore
if $DRY_RUN; then if $DRY_RUN; then
echo "[DRY-RUN] tar -xzf $backup_file --same-permissions --preserve-permissions -C $archive_root" echo " ${MAGENTA}${RESET} tar -xzf $backup_file --same-permissions --preserve-permissions -C $archive_root"
else else
if [ -d "$stack_path" ]; then if [ -d "$stack_path" ]; then
local existing_backup="${stack_path}.pre-restore-${TIMESTAMP}" local existing_backup="${stack_path}.pre-restore-${TIMESTAMP}"
log "Moving existing '$stack_name' to $existing_backup" log "Moving existing '$stack_name' to $existing_backup"
mv "$stack_path" "$existing_backup" mv "$stack_path" "$existing_backup"
fi fi
tar -xzf "$backup_file" --same-permissions --preserve-permissions -C "$archive_root" if [ -t 1 ] && tar --help 2>/dev/null | grep -q -- --checkpoint; then
local cr=$'\r'
local clr=$'\033[K'
echo -n " ${CYAN}${RESET} Extracting..."
tar -xzf "$backup_file" \
--checkpoint=500 \
--checkpoint-action="ttyout=${cr} ${CYAN}${RESET} %T${clr}" \
--same-permissions --preserve-permissions -C "$archive_root"
echo -ne "\r\033[K\n" 2>/dev/null || true
else
echo " ${CYAN}${RESET} Extracting..."
tar -xzf "$backup_file" --same-permissions --preserve-permissions -C "$archive_root"
fi
# Restore succeeded — remove the pre-restore safety copy since the # Restore succeeded — remove the pre-restore safety copy since the
# backup archive is the authoritative restore point. # backup archive is the authoritative restore point.
if [ -n "${existing_backup:-}" ] && [ -d "$existing_backup" ]; then if [ -n "${existing_backup:-}" ] && [ -d "$existing_backup" ]; then
dry rm -rf "$existing_backup" dry rm -rf "$existing_backup"
fi fi
echo " -> Restore complete" success "Restore complete"
# Restore named Docker volumes from the _volumes/ dir inside the archive # Restore named Docker volumes from the _volumes/ dir inside the archive
restore_named_volumes "$stack_path" "$stack_name" restore_named_volumes "$stack_path" "$stack_name"
fi fi
@@ -1102,14 +1175,88 @@ restore_stack() {
fi fi
} }
# --- Init (inject default x-backup config) -----------------------------------
_remove_x_backup() {
awk '
/^x-backup:/ { skip=1; next }
skip && /^[a-zA-Z_-][a-zA-Z0-9_-]*:/ { skip=0 }
skip { next }
{ print }
' "$1"
}
_init_inject_header() {
local header="$1"
local file="$2"
local cp_cmd="${3:-cp}"
local tmp
tmp="$(mktemp)"
if grep -q '^---' "$file" 2>/dev/null; then
awk -v h="$header" '!done && /^---/ { print; print ""; print h; done=1; next } 1' "$file" > "$tmp"
else
{ echo "$header"; echo ""; cat "$file"; } > "$tmp"
fi
$cp_cmd "$tmp" "$file" && rm -f "$tmp"
}
init_stack() {
local stack_path="$1"
local compose_file
compose_file="$(resolve_compose_file "$stack_path")"
local stack_name
stack_name="$(basename "$stack_path")"
[ -z "$compose_file" ] && { warn "No compose file in '$stack_name'"; return; }
local has_xb=false
grep -q '^x-backup:' "$compose_file" 2>/dev/null && has_xb=true
local sudo_write=""
[ -f "$compose_file" ] && [ ! -w "$compose_file" ] && sudo_write="sudo"
local cp_cmd="${sudo_write:+sudo }cp"
local header
header='x-backup:
stop: true
backup-volumes: true
# pre-hook: ""
# post-hook: ""
# pre-restore-hook: ""
# post-restore-hook: ""
# exclude:
# - path/to/exclude
# retention: 7'
if $DRY_RUN; then
local verb="inject"
$has_xb && verb="replace"
local maybe_sudo=""
[ -n "$sudo_write" ] && maybe_sudo=" (with sudo)"
echo " ${MAGENTA}${RESET} Init: would ${verb} x-backup config in ${BOLD}${compose_file}${RESET}${maybe_sudo}"
return
fi
if $has_xb; then
local tmp
tmp="$(mktemp)"
_remove_x_backup "$compose_file" > "$tmp"
_init_inject_header "$header" "$tmp"
$cp_cmd "$tmp" "$compose_file" && rm -f "$tmp"
echo " ${GREEN}${RESET} ${BOLD}${stack_name}${RESET} x-backup config replaced"
else
_init_inject_header "$header" "$compose_file" "$cp_cmd"
echo " ${GREEN}${RESET} ${BOLD}${stack_name}${RESET} x-backup config injected"
fi
}
# --- List -------------------------------------------------------------------- # --- List --------------------------------------------------------------------
list_stacks_and_backups() { list_stacks_and_backups() {
local stacks_dir="$1" local stacks_dir="$1"
local backup_dir="$2" local backup_dir="$2"
echo "=== Stacks ===" echo " ${BOLD}${CYAN}━━━ Stacks ━━━${RESET}"
# Collect all seen stack dirs to avoid duplicates
local -A seen local -A seen
# 1) Running stacks (discovered wherever they live) # 1) Running stacks (discovered wherever they live)
@@ -1132,9 +1279,9 @@ list_stacks_and_backups() {
local config_tags local config_tags
config_tags="$(format_xb_tags)" config_tags="$(format_xb_tags)"
if [ "$canon" != "$stacks_dir/$run_name" ]; then if [ "$canon" != "$stacks_dir/$run_name" ]; then
echo " - $run_name ($run_path) $label$config_tags" echo " ${CYAN}${RESET} ${BOLD}${run_name}${RESET} ${BLUE}(${run_path})${RESET} ${label}${config_tags}"
else else
echo " - $run_name $label$config_tags" echo " ${CYAN}${RESET} ${BOLD}${run_name}${RESET} ${label}${config_tags}"
fi fi
has_running=true has_running=true
done <<< "$running" done <<< "$running"
@@ -1156,18 +1303,18 @@ list_stacks_and_backups() {
load_x_backup_config "$dir" load_x_backup_config "$dir"
local config_tags local config_tags
config_tags="$(format_xb_tags)" config_tags="$(format_xb_tags)"
echo " - $stack$label$config_tags" echo " ${CYAN}${RESET} ${BOLD}${stack}${RESET}${label}${config_tags}"
has_local=true has_local=true
fi fi
fi fi
done done
if ! $has_running && ! $has_local; then if ! $has_running && ! $has_local; then
echo " No stacks found." echo " ${YELLOW}${RESET} No stacks found."
fi fi
echo "" echo ""
echo "=== Backups ===" echo " ${BOLD}${CYAN}━━━ Backups ━━━${RESET}"
if [ -d "$backup_dir" ]; then if [ -d "$backup_dir" ]; then
local backup_count=0 local backup_count=0
for f in "$backup_dir"/*.tar.gz; do for f in "$backup_dir"/*.tar.gz; do
@@ -1180,18 +1327,20 @@ list_stacks_and_backups() {
mpath="$(grep '^path=' "${f}.meta" | head -1 | sed 's/^path=//')" mpath="$(grep '^path=' "${f}.meta" | head -1 | sed 's/^path=//')"
mvols="$(grep '^volumes=' "${f}.meta" | head -1 | sed 's/^volumes=//')" mvols="$(grep '^volumes=' "${f}.meta" | head -1 | sed 's/^volumes=//')"
mhash="$(grep '^sha256=' "${f}.meta" | head -1 | sed 's/^sha256=//')" mhash="$(grep '^sha256=' "${f}.meta" | head -1 | sed 's/^sha256=//')"
[ -n "$mhash" ] && meta_info=" sha256:${mhash:0:12}.." [ -n "$mhash" ] && meta_info="${meta_info} ${BLUE}sha256:${mhash:0:12}..${RESET}"
[ -n "$mpath" ] && [ "$mpath" != "$(dirname "$f")/$sname" ] && meta_info="$meta_info ($mpath)" [ -n "$mpath" ] && [ "$mpath" != "$(dirname "$f")/$sname" ] && meta_info="${meta_info} ${CYAN}(${mpath})${RESET}"
[ "$mvols" = "true" ] && meta_info="$meta_info [volumes]" [ "$mvols" = "true" ] && meta_info="${meta_info} ${GREEN}[volumes]${RESET}"
fi fi
echo " $(basename "$f") ($(du -h "$f" | cut -f1)) -> $sname$meta_info" local fsize
fsize="$(du -h "$f" | cut -f1)"
echo " ${CYAN}${RESET} ${BOLD}$(basename "$f")${RESET} ${BLUE}${fsize}${RESET}${sname}${meta_info}"
backup_count=$((backup_count + 1)) backup_count=$((backup_count + 1))
done done
if [ "$backup_count" -eq 0 ]; then if [ "$backup_count" -eq 0 ]; then
echo " No backups found in $backup_dir" echo " ${YELLOW}${RESET} No backups found in ${backup_dir}"
fi fi
else else
echo " No backups directory found at $backup_dir" echo " ${YELLOW}${RESET} No backups directory found at ${backup_dir}"
fi fi
} }
@@ -1211,7 +1360,7 @@ main() {
# Parse arguments # Parse arguments
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
backup|restore|list|verify) backup|restore|init|list|verify)
ACTION="$1" ACTION="$1"
shift shift
;; ;;
@@ -1234,6 +1383,18 @@ main() {
HAS_FLAGS=true HAS_FLAGS=true
shift shift
;; ;;
--dir)
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
TARGET_DIR="$2"
HAS_FLAGS=true
shift 2
;;
--to)
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
RESTORE_TARGET="$2"
HAS_FLAGS=true
shift 2
;;
-d|--backup-dir) -d|--backup-dir)
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
backup_dir="$2" backup_dir="$2"
@@ -1260,11 +1421,32 @@ main() {
usage usage
fi fi
if [ -n "$TARGET_DIR" ]; then
stacks_dir="$TARGET_DIR"
fi
if [ ! -d "$stacks_dir" ]; then if [ ! -d "$stacks_dir" ]; then
err "Stacks directory not found: $stacks_dir" err "Stacks directory not found: $stacks_dir"
fi fi
case "$ACTION" in case "$ACTION" in
init)
local scan_dir="${TARGET_DIR:-$INITIAL_DIR}"
if ! $HAS_FLAGS; then
MODE="all"
fi
local stacks
stacks="$(resolve_target_stacks "$scan_dir" "$MODE" "${TARGET_STACKS[@]}")"
if [ -z "$stacks" ]; then
echo " ${YELLOW}${RESET} No stacks found."
exit 0
fi
for stack in $stacks; do
local spath
spath="$(stack_spec_to_path "$scan_dir" "$stack")"
init_stack "$spath"
done
;;
list) list)
list_stacks_and_backups "$stacks_dir" "$backup_dir" list_stacks_and_backups "$stacks_dir" "$backup_dir"
;; ;;
@@ -1278,7 +1460,8 @@ main() {
local stacks local stacks
stacks="$(resolve_target_stacks "$stacks_dir" "$MODE" "${TARGET_STACKS[@]}")" stacks="$(resolve_target_stacks "$stacks_dir" "$MODE" "${TARGET_STACKS[@]}")"
if [ -z "$stacks" ]; then if [ -z "$stacks" ]; then
echo "No stacks found in '$stacks_dir'. Either cd there, pass --stack /path, or use -d to set the backup directory." echo " ${YELLOW}${RESET} No stacks found in ${BOLD}${stacks_dir}${RESET}."
echo " ${CYAN}${RESET} Either cd there, pass ${BOLD}--stack /path${RESET}, or use ${BOLD}-d${RESET} to set the backup directory."
exit 0 exit 0
fi fi
local stack local stack
@@ -1299,8 +1482,9 @@ main() {
fi fi
for spec in "${TARGET_STACKS[@]}"; do for spec in "${TARGET_STACKS[@]}"; do
local spath local spath
spath="$(stack_spec_to_path "$stacks_dir" "$spec")" spath="$(stack_spec_to_path "${INITIAL_DIR:-$stacks_dir}" "$spec")"
restore_stack "$stacks_dir" "$backup_dir" "$spath" || warn "Restore failed for '$spath'" local restore_path="${RESTORE_TARGET:-$spath}"
restore_stack "$stacks_dir" "$backup_dir" "$spath" "$restore_path" || warn "Restore failed for '$spath'"
done done
;; ;;
verify) verify)
+164 -69
View File
@@ -9,15 +9,21 @@
set -euo pipefail set -euo pipefail
# ── Terminal colours (disabled when not on a terminal) ────────────── # ── Terminal colours ──────────────────────────────────────────────
if [[ -t 1 ]]; then BOLD=""; RED=""; GREEN=""; YELLOW=""; CYAN=""; RESET=""
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m' if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' BOLD=$(tput bold 2>/dev/null || true)
else RED=$(tput setaf 1 2>/dev/null || true)
GREEN=''; YELLOW=''; RED=''; CYAN=''; BOLD=''; NC='' GREEN=$(tput setaf 2 2>/dev/null || true)
YELLOW=$(tput setaf 3 2>/dev/null || true)
CYAN=$(tput setaf 6 2>/dev/null || true)
RESET=$(tput sgr0 2>/dev/null || true)
fi fi
# ── State ─────────────────────────────────────────────────────────── # ── State ───────────────────────────────────────────────────────────
TARGET_STACKS=() # user-specified stacks (empty = all)
LIST_ONLY=false # --list flag
all_names=() # pre-filter stack names (for error messages)
updated_names=() # stack names that were updated updated_names=() # stack names that were updated
updated_detail=() # parallel: "stack: service1, service2" updated_detail=() # parallel: "stack: service1, service2"
unchanged_names=() # stack names already up to date unchanged_names=() # stack names already up to date
@@ -25,24 +31,58 @@ orphaned_names=() # stacks whose compose files are gone removed
failed_names=() # stack names that errored failed_names=() # stack names that errored
# ── Helpers ───────────────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────────────
info() { echo -e "${CYAN}::${NC} $*"; } log() { echo -e " ${CYAN}${RESET} $*"; }
ok() { echo -e " ${GREEN}${NC} $*"; } ok() { echo -e " ${GREEN}${RESET} $*"; }
warn() { echo -e " ${YELLOW}~${NC} $*"; } warn() { echo -e " ${YELLOW}${RESET} $*"; }
fail() { echo -e " ${RED}${NC} $*"; } fail() { echo -e " ${RED}${RESET} $*"; }
header() { echo ""; echo -e " ${BOLD}${CYAN}━━━ $* ━━━${RESET}"; echo ""; }
# ── Usage ─────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $(basename "$0") [options]
Pull latest images and restart all running Docker Compose stacks
that have updates.
Options:
-h, --help Show this help message
-l, --list List running stacks and exit
-s, --stack <name> Target a specific stack (repeatable)
No options = update all running stacks.
EOF
exit 0
}
# ── Parse arguments ──────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
-l|--list) LIST_ONLY=true; shift ;;
-s|--stack)
shift
[[ $# -eq 0 ]] && { echo -e " ${RED}${RESET} Option --stack requires a name." >&2; exit 1; }
TARGET_STACKS+=("$1")
shift
;;
-*)
echo -e " ${RED}${RESET} Unknown option: $1" >&2; usage ;;
*)
echo -e " ${RED}${RESET} Unexpected argument: $1" >&2; usage ;;
esac
done
# ── Pre-flight ────────────────────────────────────────────────────── # ── Pre-flight ──────────────────────────────────────────────────────
if ! command -v docker &>/dev/null; then if ! command -v docker &>/dev/null; then
echo -e "${RED}Error: 'docker' not found in PATH.${NC}" >&2 echo -e "${RED}${RESET} Error: 'docker' not found in PATH." >&2
exit 1 exit 1
fi fi
echo -e "${BOLD}Docker Compose Stack Updater${NC}" header "Docker Compose Stack Updater"
echo -e "${BOLD}────────────────────────────${NC}"
echo
# ── Discover stacks ───────────────────────────────────────────────── # ── Discover stacks ─────────────────────────────────────────────────
info "Scanning for running Docker Compose stacks ..." log "Scanning for running Docker Compose stacks ..."
echo
stack_json=$(docker compose ls --format json 2>/dev/null || true) stack_json=$(docker compose ls --format json 2>/dev/null || true)
@@ -53,7 +93,7 @@ fi
# Parse JSON with jq (required). # Parse JSON with jq (required).
if ! command -v jq &>/dev/null; then if ! command -v jq &>/dev/null; then
echo -e "${RED}Error: 'jq' is required. Install it first.${NC}" >&2 echo -e "${RED}${RESET} Error: 'jq' is required. Install it first." >&2
exit 1 exit 1
fi fi
@@ -61,16 +101,73 @@ readarray -t names < <(echo "$stack_json" | jq -r '.[].Name')
readarray -t configs < <(echo "$stack_json" | jq -r '.[].ConfigFiles') readarray -t configs < <(echo "$stack_json" | jq -r '.[].ConfigFiles')
total=${#names[@]} total=${#names[@]}
echo " Found ${total} running stack(s):" echo " ${CYAN}${RESET} Found ${BOLD}${total}${RESET} running stack(s):"
printf " • %s\n" "${names[@]}" for name in "${names[@]}"; do
echo echo " ${CYAN}·${RESET} ${name}"
done
echo ""
if $LIST_ONLY; then
exit 0
fi
# ── Spinner for long operations ─────────────────────────────────────
_spin_kill=""
_spin_start() {
local msg="$1"
local -a ch=('-' '\' '|' '/')
local i=0
(
while true; do
echo -ne "\r ${CYAN}${ch[i]}${RESET} ${msg} "
i=$(( (i+1) % 4 ))
sleep 0.15
done
) &
_spin_kill=$!
disown 2>/dev/null || true
}
_spin_stop() {
[[ -z "$_spin_kill" ]] && return
kill "$_spin_kill" 2>/dev/null || true
wait "$_spin_kill" 2>/dev/null || true
_spin_kill=""
echo -ne "\r\033[K"
}
# ── Filter stacks if --stack was given ───────────────────────────────
if [[ ${#TARGET_STACKS[@]} -gt 0 ]]; then
all_names=("${names[@]}")
filtered_names=()
filtered_configs=()
for i in "${!names[@]}"; do
for t in "${TARGET_STACKS[@]}"; do
if [[ "${names[$i]}" == "$t" ]]; then
filtered_names+=("${names[$i]}")
filtered_configs+=("${configs[$i]}")
break
fi
done
done
names=("${filtered_names[@]}")
configs=("${filtered_configs[@]}")
if [[ ${#names[@]} -eq 0 ]]; then
warn "No running stacks match the requested name(s)."
echo " ${CYAN}${RESET} Running stacks: ${all_names[*]}"
exit 0
fi
echo " ${CYAN}${RESET} Targeting ${BOLD}${#names[@]}${RESET} of ${BOLD}${total}${RESET} running stack(s)"
echo ""
fi
# ── Process each stack ────────────────────────────────────────────── # ── Process each stack ──────────────────────────────────────────────
for i in "${!names[@]}"; do for i in "${!names[@]}"; do
stack="${names[$i]}" stack="${names[$i]}"
cfg_str="${configs[$i]}" cfg_str="${configs[$i]}"
echo -e "${BOLD}── ${stack}${NC}" echo -e " ${BOLD}${CYAN}──── ${stack}${RESET}"
# Support multi-file projects (ConfigFiles is comma-separated). # Support multi-file projects (ConfigFiles is comma-separated).
IFS=',' read -ra cfg_files <<< "$cfg_str" IFS=',' read -ra cfg_files <<< "$cfg_str"
@@ -95,14 +192,15 @@ for i in "${!names[@]}"; do
if [[ ${#missing_files[@]} -gt 0 ]]; then if [[ ${#missing_files[@]} -gt 0 ]]; then
warn "Orphaned stack — compose file(s) missing:" warn "Orphaned stack — compose file(s) missing:"
for m in "${missing_files[@]}"; do for m in "${missing_files[@]}"; do
echo " ${m}" echo " ${m}"
done done
info "Tearing down orphaned stack ..." _spin_start "Tearing down orphaned stack..."
if docker compose -p "$stack" down 2>/dev/null; then if docker compose -p "$stack" down 2>/dev/null; then
ok "${stack} — orphaned stack removed" _spin_stop; ok "${stack} — orphaned stack removed"
else else
_spin_stop
# Fall back to direct container / network cleanup by project label. # Fall back to direct container / network cleanup by project label.
info "Fallback: removing containers by project label" log "Fallback: removing containers by project label..."
containers=$(docker container ls -q --filter label=com.docker.compose.project="$stack") || true containers=$(docker container ls -q --filter label=com.docker.compose.project="$stack") || true
if [[ -n "$containers" ]]; then if [[ -n "$containers" ]]; then
docker container rm -f $containers 2>/dev/null || true docker container rm -f $containers 2>/dev/null || true
@@ -114,49 +212,43 @@ for i in "${!names[@]}"; do
ok "${stack} — orphaned containers removed" ok "${stack} — orphaned containers removed"
fi fi
orphaned_names+=("$stack") orphaned_names+=("$stack")
echo; continue echo ""; continue
fi fi
info "Pulling latest images ..." # Pull with spinner (capture output for parsing later).
_spin_start "Pulling latest images..."
pull_out=$(docker compose "${compose_args[@]}" pull 2>&1) || { pull_out=$(docker compose "${compose_args[@]}" pull 2>&1) || {
_spin_stop
fail "Image pull failed for ${stack}" fail "Image pull failed for ${stack}"
echo "$pull_out" | head -5 echo "$pull_out" | head -5 | while IFS= read -r line; do echo " ${line}"; done
failed_names+=("$stack") failed_names+=("$stack")
echo; continue echo ""; continue
} }
_spin_stop
# Always run up -d after a successful pull (idempotent when nothing # Recreate containers with spinner.
# changed). _spin_start "Recreating containers..."
info "Recreating containers …"
docker compose "${compose_args[@]}" up -d 2>&1 || { docker compose "${compose_args[@]}" up -d 2>&1 || {
_spin_stop
fail "Container recreation failed for ${stack}" fail "Container recreation failed for ${stack}"
failed_names+=("$stack"); echo; continue failed_names+=("$stack"); echo ""; continue
} }
_spin_stop
# Determine which services (if any) received new images by parsing the # Determine which services (if any) received new images by parsing the
# pull output. Only the pull output is authoritative — up -d restarts # pull output. Only the pull output is authoritative — up -d restarts
# are idempotent and tell us nothing about whether images changed. # are idempotent and tell us nothing about whether images changed.
# #
# Patterns matched (TTY and non-TTY alike): # The only reliable indicator of an actual image update is the
# " ✔ svc Pulled" / "svc Pulled" # "Downloaded newer image for <service>" string. The "Pulled" status
# "Downloaded newer image for svc" # appears even when the image is already up to date (Docker shows
# "Pulled" for the manifest check), so we ignore it.
updated_svc=() updated_svc=()
while IFS= read -r line; do while IFS= read -r line; do
# "Downloaded newer image for svc" — unambiguous. if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\ \"]+) ]]; then
if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\"]+) ]]; then updated_svc+=("${BASH_REMATCH[1]}")
updated_svc+=("${BASH_REMATCH[1]}"); continue
fi fi
# "Pulled" not "Already up to date"
[[ "$line" =~ Already\ up\ to\ date ]] && continue
[[ ! "$line" =~ Pulled ]] && continue
# Extract the word before "Pulled". Strip leading " ✔ " if
# present, then read the first field.
cleaned="${line#*✔ }"
read -r svc _ <<< "$cleaned"
[[ -n "$svc" && "$svc" != "Pulled" ]] && updated_svc+=("$svc")
done <<< "$pull_out" done <<< "$pull_out"
# Deduplicate while preserving order. # Deduplicate while preserving order.
@@ -170,49 +262,52 @@ for i in "${!names[@]}"; do
done done
if [[ ${#deduped[@]} -gt 0 ]]; then if [[ ${#deduped[@]} -gt 0 ]]; then
ok "${stack} — updated: ${deduped[*]}" ok "${stack} — updated: ${BOLD}${deduped[*]}${RESET}"
updated_names+=("$stack") updated_names+=("$stack")
updated_detail+=("${stack}: ${deduped[*]}") updated_detail+=("${stack}: ${deduped[*]}")
else else
info "${stack} — already up to date" log "${stack} — already up to date"
unchanged_names+=("$stack") unchanged_names+=("$stack")
fi fi
echo echo ""
done done
# ── Summary ───────────────────────────────────────────────────────── # ── Summary ─────────────────────────────────────────────────────────
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}" header "Summary"
echo -e "${BOLD} Summary${NC}"
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}"
echo
total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} )) total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} ))
echo " Total stacks found: ${total_found}" echo " ${CYAN}${RESET} Stacks processed: ${BOLD}${total_found}${RESET}"
echo echo ""
if [[ ${#updated_names[@]} -gt 0 ]]; then if [[ ${#updated_names[@]} -gt 0 ]]; then
echo -e " ${GREEN}${NC} ${BOLD}Updated (${#updated_names[@]})${NC}" echo -e " ${GREEN}${RESET} ${BOLD}Updated (${#updated_names[@]})${RESET}"
for d in "${updated_detail[@]}"; do for d in "${updated_detail[@]}"; do
echo " ${d}" echo " ${d}"
done done
echo echo ""
fi fi
if [[ ${#unchanged_names[@]} -gt 0 ]]; then if [[ ${#unchanged_names[@]} -gt 0 ]]; then
echo -e " ${YELLOW}${NC} ${BOLD}Unchanged (${#unchanged_names[@]})${NC}" echo -e " ${CYAN}${RESET} ${BOLD}Already up to date (${#unchanged_names[@]})${RESET}"
printf " %s\n" "${unchanged_names[@]}" for s in "${unchanged_names[@]}"; do
echo echo " ${CYAN}·${RESET} ${s}"
done
echo ""
fi fi
if [[ ${#orphaned_names[@]} -gt 0 ]]; then if [[ ${#orphaned_names[@]} -gt 0 ]]; then
echo -e " ${RED}${NC} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${NC}" echo -e " ${YELLOW}${RESET} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${RESET}"
printf " %s\n" "${orphaned_names[@]}" for s in "${orphaned_names[@]}"; do
echo echo " ${YELLOW}·${RESET} ${s}"
done
echo ""
fi fi
if [[ ${#failed_names[@]} -gt 0 ]]; then if [[ ${#failed_names[@]} -gt 0 ]]; then
echo -e " ${RED}${NC} ${BOLD}Failed (${#failed_names[@]})${NC}" echo -e " ${RED}${RESET} ${BOLD}Failed (${#failed_names[@]})${RESET}"
printf " %s\n" "${failed_names[@]}" for s in "${failed_names[@]}"; do
echo echo " ${RED}·${RESET} ${s}"
done
echo ""
fi fi
+445
View File
@@ -0,0 +1,445 @@
# Netbird Install/Update Script
A comprehensive bash script for installing, updating, and managing Netbird across Linux systems. Supports local execution, single-host remote deployment, and multi-host fleet management.
## Features
- **Smart Installation**: Automatically detects and handles different Netbird installation methods
- **Package Manager Replacement**: Converts package manager installations to binary installs for better control
- **Connection-Safe Updates**: Background update process survives SSH disconnections during Netbird service restarts
- **Auto-Update Timer**: Systemd timer for daily automatic updates with persistent scheduling
- **Remote Deployment**: Deploy to single hosts or entire fleets via SSH with timeout handling
- **Detailed Summary**: Multi-host deployments show which hosts succeeded/failed and what action was performed
- **SELinux Compatible**: Automatically fixes SELinux contexts on RHEL/Fedora systems
- **No Artifacts**: Clean execution with no leftover files or logs
## Requirements
- Linux operating system
- Root/sudo access
- curl
- SSH client (for remote deployment)
- Internet connection to download Netbird
## Installation
Make the script executable:
```bash
chmod +x netbird-install-update.sh
```
## Usage
### Local Execution
Install or update Netbird on the local machine:
```bash
./netbird-install-update.sh
```
Install with automatic daily updates:
```bash
./netbird-install-update.sh --timer
```
Install with custom update schedule:
```bash
./netbird-install-update.sh --timer --time "03:00"
```
### Single Host Remote Deployment
Deploy to a remote host via SSH:
```bash
./netbird-install-update.sh --ssh user@hostname
```
Deploy with sudo password (non-interactive):
```bash
./netbird-install-update.sh --ssh user@hostname --password "your_sudo_password"
```
Deploy with auto-update timer enabled:
```bash
./netbird-install-update.sh --ssh user@hostname --timer
```
Deploy with custom update schedule:
```bash
./netbird-install-update.sh --ssh user@hostname --timer --time "03:00"
```
### Multi-Host Fleet Deployment
Deploy to multiple hosts using a hosts file:
```bash
./netbird-install-update.sh --file hosts.txt
```
Deploy with default sudo password for all hosts:
```bash
./netbird-install-update.sh --file hosts.txt --password "your_sudo_password"
```
Deploy with auto-update timer to all hosts:
```bash
./netbird-install-update.sh --file hosts.txt --timer
```
Deploy with custom update schedule to all hosts:
```bash
./netbird-install-update.sh --file hosts.txt --timer --time "03:00"
```
#### Hosts File Format
Create a text file with one hostname or IP address per line. Empty lines and comments (starting with `#`) are ignored.
**Optional: Include sudo password**
You can optionally include the sudo password after the hostname (separated by space). Host-specific passwords override the default password provided via `--password`.
```bash
# Production servers (with host-specific sudo passwords)
prod-web-01.example.com MySudoPassword123
prod-web-02.example.com MySudoPassword456
prod-db-01.example.com MySudoPassword789
# Development servers (will use default password from --password flag)
dev-app-01.example.com
dev-app-02.example.com
# IP addresses also work
192.168.1.100
10.0.0.50 MyPassword
```
**Password Precedence**:
1. Host-specific password in hosts file (highest priority)
2. Default password from `--password` flag
3. Interactive sudo prompt (if no password provided)
**Security Warning**: Storing passwords in plain text is not recommended for production environments. Consider using SSH key authentication with passwordless sudo instead.
**Requirements**:
- If a password is provided (via hosts file or `--password` flag), `sshpass` must be installed on the local machine
- If no password is provided, you'll be prompted to enter the sudo password interactively for each host
See `hosts.example` for a template.
## Command-Line Options
| Option | Description |
|--------|-------------|
| `--ssh <host>` | Deploy to a single remote host via SSH |
| `--file <path>` | Deploy to multiple hosts listed in a file |
| `--hosts-file <path>` | Alias for `--file` |
| `--timer` | Install systemd timer for auto-updates |
| `--time <schedule>` | Set update schedule (default: "daily") |
| `--password, -p <password>` | Default sudo password for remote hosts |
The `--time` option accepts systemd calendar event format. Examples:
- `daily` - Run once per day at midnight (default)
- `03:00` - Run at 3:00 AM daily
- `Mon,Fri 02:30` - Run at 2:30 AM on Monday and Friday
- `hourly` - Run every hour
- `weekly` - Run once per week
The `--password` option provides a default sudo password for remote deployments. This can be overridden per-host in the hosts file.
## How It Works
### Installation Detection
The script detects three installation states:
1. **Not Installed**: Netbird binary not found in PATH
2. **Package Manager Install**: Installed via apt, yum, dnf, zypper, pacman, or rpm-ostree
3. **Binary Install**: Installed via the official curl-based installer
Detection is performed by:
- Checking `/etc/netbird/install.conf` (authoritative source)
- Querying package managers (dpkg, rpm)
- Falling back to binary install detection
### Installation Flow
**Fresh Installation (Not Installed)**:
1. Downloads official Netbird install script
2. Runs binary-only installation (no GUI, no package manager)
3. Ensures proper ownership and permissions
4. Fixes SELinux contexts if applicable
**Package Manager Replacement**:
1. Downloads official Netbird install script
2. Schedules background replacement (3-second delay)
3. Stops Netbird service
4. Removes package manager installation
5. Runs binary installation
6. Ensures proper ownership, permissions, and SELinux contexts
**Binary Update**:
1. Compares current version with latest release
2. If outdated, schedules background update (3-second delay)
3. Runs official update process
4. Ensures proper ownership, permissions, and SELinux contexts
### Connection-Safe Updates
When updating over SSH, the script uses a detached background process to avoid disconnection issues:
```bash
setsid bash -c 'sleep 3 && [update commands]' </dev/null >/var/log/netbird-update.log 2>&1 &
```
The 3-second delay allows the SSH session to complete cleanly before the Netbird service is stopped and restarted. The update continues even if your connection drops.
### Auto-Update Timer
When `--timer` is specified, the script creates a systemd timer that:
- Runs on a configurable schedule (default: daily at midnight with random 0-10 minute delay)
- Uses `Persistent=true` to catch up on missed runs after system boot
- Logs output to `/var/log/netbird-update.log`
- Automatically updates existing timers if already installed
The schedule can be customized with the `--time` option. Examples:
- `--time "daily"` - Run once per day at midnight (default)
- `--time "03:00"` - Run at 3:00 AM daily
- `--time "Mon,Fri 02:30"` - Run at 2:30 AM on Monday and Friday
- `--time "hourly"` - Run every hour
- `--time "weekly"` - Run once per week
The timer consists of two systemd units:
**netbird-update.service**:
```ini
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'curl -fsSL https://pkgs.netbird.io/install.sh | UPDATE_NETBIRD=true sh && chown root:root /usr/bin/netbird 2>/dev/null || true && chmod +x /usr/bin/netbird 2>/dev/null || true && restorecon -v /usr/bin/netbird 2>/dev/null || true'
```
**netbird-update.timer**:
```ini
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=600
[Install]
WantedBy=timers.target
```
## Logging
All background operations log to `/var/log/netbird-update.log`:
- Package manager replacement operations
- In-place updates
- Systemd timer executions
Check the log to monitor update progress or troubleshoot issues:
```bash
sudo tail -f /var/log/netbird-update.log
```
## Troubleshooting
### Netbird Service Fails to Start
If the Netbird service fails with "Permission denied" errors:
```bash
# Check binary permissions
ls -la /usr/bin/netbird
# Fix ownership and permissions
sudo chown root:root /usr/bin/netbird
sudo chmod +x /usr/bin/netbird
# Fix SELinux context (RHEL/Fedora)
sudo restorecon -v /usr/bin/netbird
# Restart service
sudo systemctl restart netbird
```
### Check Update Status
```bash
# Check current version
netbird version
# Check service status
sudo systemctl status netbird
# View update logs
sudo cat /var/log/netbird-update.log
# Check timer status (if installed)
systemctl status netbird-update.timer
```
### Manual Update
If you need to manually trigger an update:
```bash
./netbird-install-update.sh
```
Or use the official Netbird update method:
```bash
curl -fsSL https://pkgs.netbird.io/install.sh | sudo UPDATE_NETBIRD=true sh
```
### Timer Management
```bash
# Check timer status
systemctl status netbird-update.timer
# View timer schedule
systemctl list-timers netbird-update.timer
# Manually trigger timer
sudo systemctl start netbird-update.service
# Disable timer
sudo systemctl disable --now netbird-update.timer
# Remove timer completely
sudo systemctl disable --now netbird-update.timer
sudo rm /etc/systemd/system/netbird-update.service
sudo rm /etc/systemd/system/netbird-update.timer
sudo systemctl daemon-reload
```
## Why Binary Install?
The script converts package manager installations to binary installs because:
1. **Better Control**: Direct binary management allows precise version control
2. **Simpler Updates**: Binary updates don't require package manager configuration
3. **Consistency**: Same installation method across all Linux distributions
4. **Official Support**: Binary install is the officially recommended method by Netbird
## Remote Deployment Details
When deploying remotely:
1. Script copies itself to the remote host via `scp`
2. Executes remotely with `sudo` via `ssh -t` (terminal allocation for sudo password prompt)
3. Passes through additional flags (like `--timer`)
4. Cleans up the remote copy after execution
The remote deployment continues even if the local script is interrupted.
**Important Requirements**:
- **SSH Authentication**: Remote deployment requires SSH key authentication. Password-based SSH authentication is not supported.
- **Sudo Access**: The script will prompt for the sudo password on each remote host. For fully automated deployments, configure passwordless sudo on the remote hosts (e.g., add a sudoers rule like `username ALL=(ALL) NOPASSWD: /bin/bash`).
### SSH Timeout and Error Handling
The script includes a 30-second timeout for SSH connections to prevent hanging on unreachable hosts. If a host is unreachable or the connection times out, the script will:
- Mark the host as failed in the deployment summary
- Continue to the next host in the list
- Report the failure at the end
### Deployment Summary
After multi-host deployment, the script provides a detailed summary showing:
- **Total successful and failed deployments**
- **List of successful hosts** with the action performed:
- `fresh install` - Netbird was not installed and has been installed
- `pkg replacement` - Netbird was installed via package manager and converted to binary install
- `updated` - Netbird was updated to a newer version
- `up to date` - Netbird was already at the latest version
- **List of failed hosts** that could not be reached or had errors
Example output:
```
╔═══════════════════════════════════════════════════════════════╗
║ ✓ Deployment Summary
╠═══════════════════════════════════════════════════════════════╣
║ ✓ Successful: 3
║ ✗ Failed: 1
╚═══════════════════════════════════════════════════════════════╝
Successful Hosts:
✓ host-01.example.com (updated)
✓ host-02.example.com (fresh install)
✓ host-03.example.com (up to date)
Failed Hosts:
✗ host-04.example.com
```
## Exit Codes
- `0`: Success
- `1`: Error (file not found, SSH failure, etc.)
## Compatibility
### Tested Package Managers
- apt (Debian/Ubuntu)
- yum (RHEL/CentOS 7)
- dnf (Fedora/RHEL 8+/CentOS 8+)
- zypper (openSUSE)
- pacman (Arch Linux)
- rpm-ostree (Fedora Silverblue)
### SELinux Support
Automatically handles SELinux contexts on systems with SELinux enabled (RHEL, Fedora, CentOS). The `restorecon` command is safely ignored on systems without SELinux.
## Security Considerations
- Script requires root/sudo access
- Downloads official Netbird installer from `https://pkgs.netbird.io/install.sh`
- All operations are logged to `/var/log/netbird-update.log`
- No credentials are stored or transmitted
- Temporary files are cleaned up automatically
## Limitations
- Linux only (no macOS or Windows support)
- Requires SSH key authentication for remote deployment (password authentication is not supported)
- Background updates cannot be cancelled once started
## License
This script is provided as-is for managing Netbird installations. Netbird itself is subject to its own license terms.
## Contributing
Contributions are welcome! Please ensure:
- Script syntax is valid (`bash -n script.sh`)
- Changes are tested on multiple Linux distributions
- SELinux compatibility is maintained
- No artifacts are left behind after execution
## Support
For Netbird-specific issues, refer to the [official Netbird documentation](https://docs.netbird.io).
For script-specific issues, check the troubleshooting section or review the logs at `/var/log/netbird-update.log`.
+17
View File
@@ -0,0 +1,17 @@
# Example hosts file for netbird-install-update.sh
# Format: hostname [sudo_password]
# One host per line, comments start with #
# Host-specific passwords override --password flag
# Production servers (with host-specific sudo passwords)
prod-web-01.jeremy.skynet MySudoPassword123
prod-web-02.jeremy.skynet MySudoPassword123
prod-db-01.jeremy.skynet MySudoPassword123
# Development servers (will use --password flag or prompt interactively)
dev-app-01.jeremy.skynet
dev-app-02.jeremy.skynet
# You can also use IP addresses
# 192.168.1.100
# 10.0.0.50 MyPassword
+478
View File
@@ -0,0 +1,478 @@
#!/usr/bin/env bash
set -euo pipefail
# Color codes for output formatting
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m' # No Color
# Icons
ICON_SUCCESS="✓"
ICON_ERROR="✗"
ICON_INFO=""
ICON_WARNING="⚠"
ICON_ARROW="→"
ICON_DOWNLOAD="⬇"
ICON_UPLOAD="⬆"
ICON_INSTALL="📦"
ICON_UPDATE="🔄"
ICON_DEPLOY="🚀"
ICON_TIMER="⏰"
ICON_CHECK="🔍"
ICON_CONFIG="⚙"
ICON_NETWORK="🌐"
ICON_VERSION="📋"
ICON_LOG="📝"
ICON_HOST="🖥"
SSH_HOST=""
HOSTS_FILE=""
INSTALL_TIMER=false
TIMER_TIME="daily"
DEFAULT_PASSWORD=""
REMOTE_ARGS=()
SSH_TIMEOUT=30
# Arrays to track deployment results
declare -a SUCCESS_HOSTS=()
declare -a FAILED_HOSTS=()
declare -a HOST_ACTIONS=()
# Helper functions for formatted output
print_header() {
echo -e "\n${BLUE}${BOLD}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}${BOLD}$1${NC}"
echo -e "${BLUE}${BOLD}╚═══════════════════════════════════════════════════════════════╝${NC}\n"
}
print_info() {
echo -e " ${BLUE}${ICON_INFO}${NC} $1"
}
print_success() {
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1"
}
print_error() {
echo -e " ${RED}${ICON_ERROR}${NC} $1"
}
print_warning() {
echo -e " ${YELLOW}${ICON_WARNING}${NC} $1"
}
print_status() {
echo -e " ${CYAN}${ICON_ARROW}${NC} $1"
}
print_step() {
echo -e " ${DIM}${NC} $1"
}
print_usage() {
cat <<EOF
Usage: netbird-install-update.sh [options]
Install, update, and manage Netbird across single or multiple Linux hosts.
Options:
-h, --help Show this help message and exit
--ssh <host> Deploy to a single host via SSH
--hosts-file, --file <f> Deploy to multiple hosts from a file
--timer Install a systemd auto-update timer
--time <schedule> Timer schedule (systemd OnCalendar format, default: daily)
-p, --password <pass> Sudo password for remote hosts (used with --ssh or --hosts-file)
Hosts file format (one per line):
hostname_or_ip [sudo_password]
Examples:
# Local install/update
sudo ./netbird-install-update.sh
# Deploy to a single host via SSH
./netbird-install-update.sh --ssh user@hostname
# Deploy to multiple hosts from a file
./netbird-install-update.sh --hosts-file hosts.txt
# Deploy with auto-update timer
./netbird-install-update.sh --ssh user@hostname --timer --time "weekly"
# Multi-host deployment with default sudo password
./netbird-install-update.sh --hosts-file hosts.txt --password "mypass"
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
print_usage
;;
--ssh)
SSH_HOST="$2"
shift 2
;;
--hosts-file|--file)
HOSTS_FILE="$2"
shift 2
;;
--timer)
INSTALL_TIMER=true
REMOTE_ARGS+=("--timer")
shift
;;
--time)
TIMER_TIME="$2"
REMOTE_ARGS+=("--time" "$2")
shift 2
;;
--password|-p)
DEFAULT_PASSWORD="$2"
shift 2
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
deploy_to_host() {
local host="$1"
local sudo_pass="${2:-}"
echo -e "${CYAN}${BOLD}┌─────────────────────────────────────────────────────────────┐${NC}"
echo -e "${CYAN}${BOLD}${NC} ${ICON_DEPLOY} ${YELLOW}${host}${NC}"
echo -e "${CYAN}${BOLD}└─────────────────────────────────────────────────────────────┘${NC}"
REMOTE_SCRIPT="/tmp/.netbird-install-update-$$"
REMOTE_ACTION_FILE="/tmp/.netbird-action-$$"
LOCAL_ACTION_FILE="/tmp/.netbird-deploy-action-$$"
print_step "Copying script..."
if ! scp -o ConnectTimeout=$SSH_TIMEOUT -o BatchMode=yes "$0" "$host:$REMOTE_SCRIPT" 2>&1; then
print_error "Failed to copy script to $host (timeout or connection error)"
return 1
fi
print_step "Executing script..."
# Build SSH command based on whether we have a password
if [ -n "$sudo_pass" ]; then
# Write password to remote temp file using printf to handle special characters
# Use base64 to safely transfer the password
local encoded_pass=$(echo -n "$sudo_pass" | base64)
if ! ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "echo '$encoded_pass' | base64 -d > /tmp/.netbird-pass-$$ && chmod 600 /tmp/.netbird-pass-$$" 2>&1; then
print_error "Failed to setup password file on $host"
return 1
fi
# Execute script with sudo, reading password from file
if ! ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "sudo -S bash $REMOTE_SCRIPT ${REMOTE_ARGS[*]} < /tmp/.netbird-pass-$$" 2>&1; then
print_error "Failed to execute script on $host"
ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "rm -f $REMOTE_SCRIPT $REMOTE_ACTION_FILE /tmp/.netbird-pass-$$" 2>/dev/null || true
return 1
fi
# Clean up password file
ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "rm -f /tmp/.netbird-pass-$$" 2>/dev/null || true
else
# Interactive sudo with terminal
if ! ssh -o ConnectTimeout=$SSH_TIMEOUT -o BatchMode=yes -t "$host" "sudo bash $REMOTE_SCRIPT ${REMOTE_ARGS[*]}" 2>&1; then
print_error "Failed to execute script on $host"
ssh -o ConnectTimeout=$SSH_TIMEOUT -o BatchMode=yes "$host" "rm -f $REMOTE_SCRIPT $REMOTE_ACTION_FILE" 2>/dev/null || true
return 1
fi
fi
# Read the action from the remote action file and write to local action file
if action_output=$(ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "cat $REMOTE_ACTION_FILE 2>/dev/null" < /dev/null 2>&1); then
echo "$action_output" > "$LOCAL_ACTION_FILE"
else
echo "unknown" > "$LOCAL_ACTION_FILE"
fi
# Clean up remote files
ssh -o ConnectTimeout=$SSH_TIMEOUT -o StrictHostKeyChecking=no "$host" "rm -f $REMOTE_SCRIPT $REMOTE_ACTION_FILE" < /dev/null 2>/dev/null || true
print_success "Completed: $host"
return 0
}
if [ -n "$HOSTS_FILE" ]; then
if [ ! -f "$HOSTS_FILE" ]; then
print_error "Hosts file not found: $HOSTS_FILE"
exit 1
fi
print_header "${ICON_NETWORK} Multi-Host Deployment"
print_info "Reading hosts from: ${YELLOW}$HOSTS_FILE${NC}"
print_info "SSH timeout: ${YELLOW}${SSH_TIMEOUT}s${NC}"
echo ""
SUCCESS_COUNT=0
FAIL_COUNT=0
ACTION_FILE="/tmp/.netbird-deploy-action-$$"
# Use file descriptor 3 to read hosts file (prevents SSH from consuming stdin)
exec 3< "$HOSTS_FILE"
while IFS= read -r line <&3 || [ -n "$line" ]; do
# Skip empty lines and comments
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
# Trim whitespace
line=$(echo "$line" | xargs)
# Parse hostname and optional password
host=$(echo "$line" | awk '{print $1}')
sudo_pass=$(echo "$line" | awk '{print $2}')
# Use default password if no host-specific password is provided
if [ -z "$sudo_pass" ] && [ -n "$DEFAULT_PASSWORD" ]; then
sudo_pass="$DEFAULT_PASSWORD"
fi
# Run deployment (output streams to terminal)
if deploy_to_host "$host" "$sudo_pass"; then
# Read the action from the temp file
action="unknown"
if [ -f "$ACTION_FILE" ]; then
action=$(cat "$ACTION_FILE")
rm -f "$ACTION_FILE"
fi
SUCCESS_HOSTS+=("$host")
HOST_ACTIONS+=("$action")
((SUCCESS_COUNT++)) || true
else
FAILED_HOSTS+=("$host")
((FAIL_COUNT++)) || true
fi
echo ""
done
exec 3<&-
# Print detailed summary
echo -e "\n${GREEN}${BOLD}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}${BOLD}${NC} ${ICON_SUCCESS} ${GREEN}${BOLD}Deployment Summary${NC}"
echo -e "${GREEN}${BOLD}╠═══════════════════════════════════════════════════════════════╣${NC}"
echo -e "${GREEN}${BOLD}${NC} ${GREEN}${ICON_SUCCESS}${NC} Successful: ${BOLD}$SUCCESS_COUNT${NC}"
echo -e "${GREEN}${BOLD}${NC} ${RED}${ICON_ERROR}${NC} Failed: ${BOLD}$FAIL_COUNT${NC}"
echo -e "${GREEN}${BOLD}╚═══════════════════════════════════════════════════════════════╝${NC}"
# Show successful hosts with actions
if [ ${#SUCCESS_HOSTS[@]} -gt 0 ]; then
echo -e "\n${GREEN}${BOLD}Successful Hosts:${NC}"
for i in "${!SUCCESS_HOSTS[@]}"; do
host="${SUCCESS_HOSTS[$i]}"
action="${HOST_ACTIONS[$i]}"
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${YELLOW}$host${NC} ${DIM}($action)${NC}"
done
fi
# Show failed hosts
if [ ${#FAILED_HOSTS[@]} -gt 0 ]; then
echo -e "\n${RED}${BOLD}Failed Hosts:${NC}"
for host in "${FAILED_HOSTS[@]}"; do
echo -e " ${RED}${ICON_ERROR}${NC} ${YELLOW}$host${NC}"
done
fi
echo ""
exit 0
fi
if [ -n "$SSH_HOST" ]; then
print_header "${ICON_DEPLOY} Single Host Deployment"
ACTION_FILE="/tmp/.netbird-deploy-action-$$"
if deploy_to_host "$SSH_HOST" "$DEFAULT_PASSWORD"; then
# Read and display the action
if [ -f "$ACTION_FILE" ]; then
action=$(cat "$ACTION_FILE")
rm -f "$ACTION_FILE"
print_info "Action performed: ${YELLOW}$action${NC}"
fi
fi
echo ""
exit 0
fi
if [ "$EUID" -ne 0 ]; then
print_warning "Elevating to root..."
exec sudo "$0" "$@"
fi
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
detect_install() {
if ! command -v netbird &>/dev/null; then
echo "none"
return
fi
if [ -f /etc/netbird/install.conf ]; then
local pm
pm=$(grep -oP 'package_manager=\K.*' /etc/netbird/install.conf 2>/dev/null || true)
if [ "$pm" = "bin" ]; then
echo "bin"
return
elif [ -n "$pm" ]; then
echo "pkg:$pm"
return
fi
fi
if dpkg -s netbird &>/dev/null; then
echo "pkg:apt"
return
fi
if rpm -q netbird &>/dev/null; then
if command -v dnf &>/dev/null; then
echo "pkg:dnf"
else
echo "pkg:yum"
fi
return
fi
if pacman -Q netbird &>/dev/null; then
echo "pkg:pacman"
return
fi
echo "bin"
}
install_timer() {
print_header "${ICON_TIMER} Installing Auto-Update Timer"
if systemctl is-active --quiet netbird-update.timer; then
print_warning "Updating existing timer..."
systemctl stop netbird-update.timer
systemctl disable netbird-update.timer
fi
cat > /etc/systemd/system/netbird-update.service <<'UNIT'
[Unit]
Description=Netbird Auto-Update
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'curl -fsSL https://pkgs.netbird.io/install.sh | UPDATE_NETBIRD=true sh && chown root:root /usr/bin/netbird 2>/dev/null || true && chmod +x /usr/bin/netbird 2>/dev/null || true && restorecon -v /usr/bin/netbird 2>/dev/null || true && systemctl restart netbird 2>/dev/null || true'
StandardOutput=append:/var/log/netbird-update.log
StandardError=append:/var/log/netbird-update.log
UNIT
cat > /etc/systemd/system/netbird-update.timer <<UNIT
[Unit]
Description=Run Netbird update daily
[Timer]
OnCalendar=$TIMER_TIME
Persistent=true
RandomizedDelaySec=600
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now netbird-update.timer
print_success "Timer installed successfully"
print_info "Schedule: ${YELLOW}$TIMER_TIME${NC} (with random 0-10 minute delay)"
print_info "Logs: ${YELLOW}/var/log/netbird-update.log${NC}"
}
print_header "${ICON_INSTALL} NetBird Install/Update"
print_status "${ICON_DOWNLOAD} Downloading Netbird install script..."
curl -fsSL -o "$WORK_DIR/install.sh" https://pkgs.netbird.io/install.sh
chmod +x "$WORK_DIR/install.sh"
print_success "Download complete"
print_status "${ICON_CHECK} Detecting installation state..."
state=$(detect_install)
print_info "Detected: ${YELLOW}$state${NC}"
echo ""
case "$state" in
none)
print_header "${ICON_INSTALL} Fresh Installation"
print_info "Netbird not found. Installing..."
USE_BIN_INSTALL=true SKIP_UI_APP=true "$WORK_DIR/install.sh"
chown root:root /usr/bin/netbird 2>/dev/null || true
restorecon -v /usr/bin/netbird 2>/dev/null || true
systemctl restart netbird 2>/dev/null || true
print_success "Netbird installed successfully"
echo "fresh install" > /tmp/.netbird-action-$$
;;
pkg:*)
print_header "${ICON_UPDATE} Package Manager Replacement"
pm="${state#pkg:}"
print_info "Netbird installed via ${YELLOW}$pm${NC}"
print_info "Replacing with binary install..."
cat > /tmp/.netbird_replace.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
sleep 3
systemctl stop netbird 2>/dev/null || true
case "$1" in
apt) apt-get remove -y netbird netbird-ui 2>/dev/null || true ;;
yum) yum remove -y netbird netbird-ui 2>/dev/null || true ;;
dnf) dnf remove -y netbird netbird-ui 2>/dev/null || true ;;
zypper) zypper remove -y netbird netbird-ui 2>/dev/null || true ;;
rpm-ostree) rpm-ostree uninstall -y netbird netbird-ui 2>/dev/null || true ;;
pacman) pacman -Rns --noconfirm netbird netbird-ui 2>/dev/null || true ;;
esac
USE_BIN_INSTALL=true SKIP_UI_APP=true bash /tmp/.netbird_install.sh
chown root:root /usr/bin/netbird 2>/dev/null || true
chmod +x /usr/bin/netbird 2>/dev/null || true
restorecon -v /usr/bin/netbird 2>/dev/null || true
systemctl restart netbird 2>/dev/null || true
rm -f /tmp/.netbird_replace.sh /tmp/.netbird_install.sh
EOF
chmod +x /tmp/.netbird_replace.sh
cp "$WORK_DIR/install.sh" /tmp/.netbird_install.sh
setsid bash /tmp/.netbird_replace.sh "$pm" </dev/null >/var/log/netbird-update.log 2>&1 &
print_warning "Replacement scheduled in background"
print_info "Connection may drop momentarily"
print_info "Check ${YELLOW}/var/log/netbird-update.log${NC} for progress"
echo "pkg replacement" > /tmp/.netbird-action-$$
;;
bin)
print_header "${ICON_VERSION} Binary Update Check"
if [ ! -f /etc/netbird/install.conf ]; then
mkdir -p /etc/netbird
echo "package_manager=bin" > /etc/netbird/install.conf
fi
current=$(netbird version 2>/dev/null || echo "unknown")
latest=$(curl -fsSL https://pkgs.netbird.io/releases/latest 2>/dev/null | grep -oP '"tag_name":\s*"v?\K[^"]+' || echo "unknown")
print_info "Current version: ${YELLOW}$current${NC}"
print_info "Latest version: ${YELLOW}$latest${NC}"
echo ""
if [ "$current" = "$latest" ] && [ "$current" != "unknown" ]; then
print_success "Netbird is up to date"
echo "up to date" > /tmp/.netbird-action-$$
else
print_info "Updating Netbird: ${YELLOW}$current${NC}${GREEN}$latest${NC}"
cp "$WORK_DIR/install.sh" /tmp/.netbird_update.sh
setsid bash -c 'sleep 3 && bash /tmp/.netbird_update.sh --update && chown root:root /usr/bin/netbird 2>/dev/null || true && chmod +x /usr/bin/netbird 2>/dev/null || true && restorecon -v /usr/bin/netbird 2>/dev/null || true && systemctl restart netbird 2>/dev/null || true && rm -f /tmp/.netbird_update.sh' \
</dev/null >/var/log/netbird-update.log 2>&1 &
print_warning "Update scheduled in background"
print_info "Connection may drop momentarily"
print_info "Check ${YELLOW}/var/log/netbird-update.log${NC} for progress"
echo "updated" > /tmp/.netbird-action-$$
fi
;;
esac
if $INSTALL_TIMER; then
install_timer
fi
echo ""
+16
View File
@@ -0,0 +1,16 @@
# Test SSH remote execution
echo "=== Testing SSH remote execution ==="
echo ""
echo "Usage examples:"
echo " ./netbird-install-update.sh # Run locally"
echo " ./netbird-install-update.sh --timer # Run locally with auto-update timer"
echo " ./netbird-install-update.sh --ssh user@host # Deploy to remote host"
echo " ./netbird-install-update.sh --ssh user@host --timer # Deploy to remote host with timer"
echo ""
echo "The -ssh flag will:"
echo "1. Copy this script to the remote host via scp"
echo "2. Execute it remotely with sudo"
echo "3. Pass through any other flags (like --timer)"
echo "4. Clean up the remote copy after execution"
echo ""
echo "Script is ready for testing!"
+126 -6
View File
@@ -1,12 +1,132 @@
# Zsh Config # Zsh Config
A reproducible, self-configuring Zsh setup managed from a remote git repo. The `zshrc` auto-updates itself and the Starship prompt config on every interactive shell start, and a versioned bootstrap script installs all missing dependencies across distros. Drop `~/.zshrc` in place once and every machine stays in sync automatically.
## Design
- **Reproducible** — one `zshrc` drives an identical environment across every machine and distro.
- **Self-configuring** — tool integrations are guarded by `command -v`, so the shell works degraded-but-fine when a tool is absent, and lights up automatically once the bootstrap installs it.
- **Self-updating** — on each interactive shell start, `zshrc` and `starship.toml` are re-pulled from the repo and applied if changed (downloaded files are validated with `zsh -n` before replacing anything, so a 502 error page or corrupted content can't break the shell).
- **Network-resilient** — all remote calls use `--fail` + `--connect-timeout`/`--max-time`, so an unreachable server silently no-ops instead of hanging the shell.
## Files ## Files
### `zshrc` | File | Description |
Main Zsh configuration file. Sets up Oh My Zsh, plugins (git, sudo, eza, fzf, starship, etc.), history options, aliases, and auto-updates itself from a remote source. Before replacing `~/.zshrc`, the downloaded file is validated with `zsh -n` to ensure it's syntactically valid — this prevents error pages or corrupted content from breaking the shell. Also bootstraps tmux config and runs a fastfetch system info display on interactive shells. |------|-------------|
| [`zshrc`](zshrc) | Main config. Path setup, history options, oh-my-zsh + plugins, eza theme, tool integrations, aliases, and the auto-update logic. Sources `~/.zshrc.local` at the end for per-machine overrides. |
| [`zshrc-bootstrap.zsh`](zshrc-bootstrap.zsh) | One-shot setup script run by `zshrc` when the bootstrap version changes. Installs all dependencies across distros and clones oh-my-zsh + custom plugins. |
| [`starship.toml`](starship.toml) | Starship prompt config — two-line prompt with directory, git status, command duration, Nerd Font symbols, and an error indicator. Synced to `~/.config/starship.toml` by the auto-update logic. |
| [`tmux.conf`](tmux.conf) | Tmux config with GitHub Dark theming, Vim-style pane navigation (`h/j/k/l`), TPM plugin manager, Nerd Font auto-install, and a built-in cheatsheet (`prefix + ?`). Installed to `~/.config/tmux/tmux.conf` on first shell start. |
### `zshrc-bootstrap.zsh` ## Getting Started
One-shot setup script executed by `zshrc`. Installs missing dependencies (starship, lazydocker, fastfetch, eza, fzf, etc.) via the system package manager (brew, apt, dnf, pacman) or cargo, clones Oh My Zsh and custom plugins (zsh-autosuggestions, fast-syntax-highlighting, fzf-tab). Runs only when the bootstrap version changes.
### `tmux.conf` Point `~/.zshrc` at the remote `zshrc`:
Tmux configuration with GitHub Dark theming, Nerd Font auto-install, TPM plugin manager, pane/window keybindings (Vim-style navigation with `h/j/k/l`), status bar showing session info, battery, online status, and a built-in cheatsheet (bound to `prefix + ?`).
```bash
curl -fsSL https://git.jeremymcclure.com/jeremy/scripts/raw/branch/master/zsh/zshrc -o ~/.zshrc && exec zsh
```
On first start, `zshrc` bootstraps missing dependencies (oh-my-zsh, plugins, and all CLI tools below) over the next shell starts. To force a re-bootstrap (e.g., after adding new tool installs to the bootstrap script), the bootstrap version is bumped in `zshrc` and every machine re-runs it automatically — or run manually:
```bash
BOOTSTRAP=true exec zsh
```
## Dependencies
All installed automatically by the bootstrap across brew / apt / dnf / pacman / cargo:
| Tool | Purpose |
|------|---------|
| starship | Prompt (configured via `starship.toml`) |
| fzf | Fuzzy finder — powers `fzf-tab` completion and `zi` |
| eza | `ls` replacement with icons, git status, hyperlinks |
| bat | `cat` replacement + colored man pages |
| zoxide | Frecency directory jumping (`z`, `zi`) |
| git-delta | Pretty `git diff`/`log`/`show` pager |
| fastfetch | System info on shell start |
| lazydocker | TUI for Docker |
| rsync / tmux / git | Core utilities |
Debian/Ubuntu renames handled: `batcat``bat`, `fdfind``fd` (symlinked into `~/.local/bin`).
## Oh-My-Zsh Plugins
`git`, `sudo`, `extract`, `eza`, `history`, `kitty`, `docker`, `docker-compose`, `archlinux`, `encode64`, `universalarchive`, `zsh-autosuggestions`, `fast-syntax-highlighting`, `fzf`, `fzf-tab`, `systemd`, `vscode`, `rsync`, `starship`.
## Tool Integrations
Each is enabled only when the binary is present (`command -v`):
- **zoxide** — `z <substring>` jumps to frecency-ranked dirs; `zi` opens the fzf picker. `cd` still works.
- **bat** — `alias cat='bat'`, `BAT_THEME=Monokai Extended`, and `MANPAGER` set so `man` pages are colored and paginated by bat.
- **delta** — `GIT_PAGER=delta` so `git diff`/`log`/`show` render side-by-side with syntax highlighting. (Not aliased to `diff` — delta is a pager reading diff input on stdin, not a `diff a b` replacement.)
## History & Shell Options
`SHARE_HISTORY`, `HIST_IGNORE_DUPS`, `HIST_IGNORE_ALL_DUPS`, `HIST_REDUCE_BLANKS`, `HIST_FCNTL_LOCK`, `HIST_VERIFY`, `EXTENDED_HISTORY`, `AUTO_CD`, `AUTO_LIST`, `INTERACTIVE_COMMENTS`, `AUTO_PUSHD`, `PUSHD_IGNORE_DUPS`, `PUSHD_SILENT`.
`AUTO_PUSHD` maintains an automatic directory stack, so `cd -<TAB>` and `dirs` let you navigate recent locations without `pushd`/`popd` muscle memory.
## Aliases
| Alias | Action |
|-------|--------|
| `c` | `clear` |
| `q` | `exit` |
| `open-ports` | `ss -tulpn \| grep LISTEN` |
| `nbstat` | Netbird peer status as a TSV table |
| `yeet` | `yay -Rcs` (remove + deps + config on Arch) |
## Keybindings
| Shortcut | Action |
|----------|--------|
| `Ctrl+R` | fzf history search (via `fzf` plugin) |
| `Ctrl+T` | fzf file finder |
| `Alt+C` | fzf cd |
| `Tab` | `fzf-tab` completion menu |
| `Esc` twice | Toggle `sudo` prefix (via `sudo` plugin) |
## Eza Hyperlinks — Implementation Note
`zshrc` does **not** set the oh-my-zsh `eza` plugin's `hyperlink` zstyle (the plugin's bare `--hyperlink` causes issues). Instead, after oh-my-zsh loads, the aliases are rewritten to append the correct hyperlink flag for the installed eza version:
- **Newer eza** (help shows `--hyperlink [<WHEN>]`): the oh-my-zsh plugin's bare `--hyperlink` is treated by clap as taking the *next token* as its optional value, so `la /` fails with `invalid value '/'`. We append the glued `--hyperlink=always` form, which can't eat a following path argument.
- **Older eza** (e.g. Debian's, where `--hyperlink` is a pure boolean): `--hyperlink=always` is rejected with `Flag --hyperlink cannot take a value`. We detect this via `eza --help` and fall back to the bare `--hyperlink`, which is safe here because boolean flags don't consume the next token (the path-eating bug doesn't apply).
Detection runs once at shell start via `eza --help | grep '<WHEN>'`; the right form is applied to every eza alias. Hyperlinks stay on and paths keep working across eza versions.
## Auto-Update Behavior
On every interactive shell start:
1. **zshrc** — re-downloaded, validated with `zsh -n`, and applied via `exec zsh` only if it differs from `~/.zshrc`.
2. **starship.toml** — re-downloaded and moved to `~/.config/starship.toml` if changed (applied live on the next prompt render; no shell reload needed).
3. **tmux.conf** — downloaded once on first shell start if `~/.config/tmux/tmux.conf` and `~/.config/byobu/.tmux.conf` are both absent.
4. **bootstrap** — runs (`zshrc-bootstrap.zsh`) when `ZSHRC_BOOTSTRAP_VERSION` in `zshrc` doesn't match `~/.config/zsh/.bootstrapped`.
All downloads use `--fail` so a 502/HTML error page is never written over a working config, and `--connect-timeout`/`--max-time` so an unreachable server never hangs startup.
## Per-Machine Overrides
`~/.zshrc.local` is sourced at the very end of `zshrc` (created empty if missing). Put machine-specific aliases, env vars, or PATH additions there — they survive repo updates since `zshrc` doesn't manage that file.
## Environment Variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `ZSHRC_GIT` | `.../scripts/raw/branch/master/zsh/` | Base URL for all remote config files |
| `ZSHRC_BOOTSTRAP_VERSION` | `4` | Bump to force a re-bootstrap on all machines |
| `FFENABLED` | (unset → enabled) | Set to `false` to disable the fastfetch system info on shell start |
| `BAT_THEME` | `Monokai Extended` | Override before the bat block to use a different bat theme |
| `GIT_PAGER` | `delta` (when installed) | Override to use a different git pager |
## Helper Functions
```bash
update_zshrc # Manually re-pull and reload the zshrc
fix_btopbg # Disable btop theme_background (workaround for transparency)
install_opencode # Install the opencode CLI
BOOTSTRAP=true exec zsh # Force a re-bootstrap
```
+101
View File
@@ -0,0 +1,101 @@
# Starship prompt configuration - managed by the zshrc repo.
# Local edits to ~/.config/starship.toml are overwritten on the next interactive
# shell start (reproducible/self-configuring design). Uses Nerd Font symbols
# (JetBrainsMono Nerd Font is installed by the tmux config).
command_timeout = 1000
add_newline = true
# Two-line prompt:
# line 1: directory + git status + command duration
# line 2: the input arrow (red on error)
format = """
$directory\
$git_branch\
$git_status\
$cmd_duration\
$line_break\
$character\
"""
[character]
success_symbol = "[➜](bold green)"
error_symbol = "[✗](bold red)"
vimcmd_symbol = "[V](bold green)"
[directory]
truncation_length = 3
truncate_to_repo = true
style = "bold blue"
read_only = " 󰌾"
read_only_style = "red"
repo_root_style = "bold cyan"
[git_branch]
symbol = " "
style = "bold purple"
format = "[$symbol$branch]($style) "
[git_status]
style = "bold red"
conflicted = "=${count}"
ahead = "⇡${count}"
behind = "⇣${count}"
diverged = "⇕↑${ahead_count}↓${behind_count}"
untracked = "?${count}"
stashed = " *${count}"
modified = " !${count}"
staged = " +${count}"
renamed = " »${count}"
deleted = " ✘${count}"
format = '([$all_status$ahead_behind]($style) )'
[cmd_duration]
min_time = 2000
format = "took [$duration](bold yellow) "
[status]
disabled = false
format = '[$symbol]($style)'
symbol = "✗ "
success_symbol = ""
style = "bold red"
[username]
show_always = false
style_user = "bold yellow"
style_root = "bold red"
format = "[$user]($style)@"
[hostname]
ssh_only = true
style = "bold green"
format = "[$hostname]($style) "
[python]
symbol = " "
format = '[${symbol}${pyenv_prefix}(${version})(\($virtualenv\))]($style) '
[nodejs]
symbol = " "
format = "[$symbol($version)]($style) "
[rust]
symbol = " "
format = "[$symbol($version)]($style) "
[golang]
symbol = " "
format = "[$symbol($version)]($style) "
[java]
symbol = " "
format = "[$symbol($version)]($style) "
[docker_context]
symbol = " "
format = "[$symbol$context]($style) "
[shell]
disabled = true
style = "bold cyan"
+74 -10
View File
@@ -6,7 +6,7 @@ export ZSHRC_GIT="https://git.jeremymcclure.com/jeremy/scripts/raw/branch/master
export ZSHRC_URL="$ZSHRC_GIT/zshrc" export ZSHRC_URL="$ZSHRC_GIT/zshrc"
export ZSHRC_BOOTSTRAP_URL="$ZSHRC_GIT/zshrc-bootstrap.zsh" export ZSHRC_BOOTSTRAP_URL="$ZSHRC_GIT/zshrc-bootstrap.zsh"
export ZSHRC_BOOTSTRAP_VERSION="2" export ZSHRC_BOOTSTRAP_VERSION="4"
export ZSHRC_BOOTSTRAP="$ZSH_CONFIG/.bootstrapped" export ZSHRC_BOOTSTRAP="$ZSH_CONFIG/.bootstrapped"
[[ -d "$HOME/Scripts" ]] && path+=("$HOME/Scripts") [[ -d "$HOME/Scripts" ]] && path+=("$HOME/Scripts")
@@ -16,7 +16,7 @@ export ZSHRC_BOOTSTRAP="$ZSH_CONFIG/.bootstrapped"
update_zshrc(){ update_zshrc(){
local tmp=/tmp/.zshrc.$$ local tmp=/tmp/.zshrc.$$
curl -sSL --connect-timeout 5 "$ZSHRC_URL" -o "$tmp" 2>/dev/null || return curl -fsSL --connect-timeout 5 --max-time 10 "$ZSHRC_URL" -o "$tmp" 2>/dev/null || return
if zsh -n "$tmp" 2>/dev/null && ! cmp -s "$tmp" "$HOME/.zshrc" 2>/dev/null; then if zsh -n "$tmp" 2>/dev/null && ! cmp -s "$tmp" "$HOME/.zshrc" 2>/dev/null; then
echo "Updating zshrc..." echo "Updating zshrc..."
mv "$tmp" "$HOME/.zshrc" && echo "Updated. Reloading..." && exec zsh mv "$tmp" "$HOME/.zshrc" && echo "Updated. Reloading..." && exec zsh
@@ -32,10 +32,13 @@ install_opencode(){
curl -fsSL https://opencode.ai/install | bash curl -fsSL https://opencode.ai/install | bash
} }
# Bootstrap: download and run once per bootstrap version # Bootstrap: download and run once per bootstrap version. Installs missing CLI
# tools and oh-my-zsh/plugins across distros; prints a banner so the user knows
# what the activity is. Re-runs only when ZSHRC_BOOTSTRAP_VERSION is bumped or
# BOOTSTRAP=true is set.
if [[ ! -f "$ZSHRC_BOOTSTRAP" || "$(cat "$ZSHRC_BOOTSTRAP" 2>/dev/null)" != "$ZSHRC_BOOTSTRAP_VERSION" || "$BOOTSTRAP" == "true" ]]; then if [[ ! -f "$ZSHRC_BOOTSTRAP" || "$(cat "$ZSHRC_BOOTSTRAP" 2>/dev/null)" != "$ZSHRC_BOOTSTRAP_VERSION" || "$BOOTSTRAP" == "true" ]]; then
tmp=/tmp/.zshrc_bootstrap.$$ tmp=/tmp/.zshrc_bootstrap.$$
curl -sSL --connect-timeout 5 -o "$tmp" "$ZSHRC_BOOTSTRAP_URL" 2>/dev/null curl -fsSL --connect-timeout 5 --max-time 10 -o "$tmp" "$ZSHRC_BOOTSTRAP_URL" 2>/dev/null
if [[ -f "$tmp" ]]; then if [[ -f "$tmp" ]]; then
zsh "$tmp" 2>/dev/null zsh "$tmp" 2>/dev/null
echo "$ZSHRC_BOOTSTRAP_VERSION" > "$ZSHRC_BOOTSTRAP" echo "$ZSHRC_BOOTSTRAP_VERSION" > "$ZSHRC_BOOTSTRAP"
@@ -46,13 +49,13 @@ fi
TMUXCONF="$HOME/.config/tmux" TMUXCONF="$HOME/.config/tmux"
BYOBUCONF="$HOME/.config/byobu" BYOBUCONF="$HOME/.config/byobu"
TMUXREMOTE="$ZSHRC_GIT/tmux.conf" TMUXREMOTE="$ZSHRC_GIT/tmux.conf"
if [ ! -f "$TMUXCONF" ]; then if [ ! -f "$TMUXCONF/tmux.conf" ] && [ ! -f "$BYOBUCONF/.tmux.conf" ]; then
# Create a secure temporary file # Create a secure temporary file
TEMP_TMUXCONF=$(mktemp) TEMP_TMUXCONF=$(mktemp)
# Download to temp file. # Download to temp file.
# The '&&' ensures the 'mv' only runs if curl succeeds (exit code 0). # The '&&' ensures the 'mv' only runs if curl succeeds (exit code 0).
if curl -sSL --connect-timeout 5 -o "$TEMP_TMUXCONF" "$TMUXREMOTE"; then if curl -fsSL --connect-timeout 5 --max-time 10 -o "$TEMP_TMUXCONF" "$TMUXREMOTE"; then
mkdir -p "$TMUXCONF" "$BYOBUCONF" mkdir -p "$TMUXCONF" "$BYOBUCONF"
cp "$TEMP_TMUXCONF" "$TMUXCONF/tmux.conf" cp "$TEMP_TMUXCONF" "$TMUXCONF/tmux.conf"
mv "$TEMP_TMUXCONF" "$BYOBUCONF/.tmux.conf" mv "$TEMP_TMUXCONF" "$BYOBUCONF/.tmux.conf"
@@ -76,6 +79,11 @@ setopt HIST_FCNTL_LOCK
setopt AUTO_CD setopt AUTO_CD
setopt AUTO_LIST setopt AUTO_LIST
setopt INTERACTIVE_COMMENTS setopt INTERACTIVE_COMMENTS
setopt HIST_VERIFY
setopt EXTENDED_HISTORY
setopt AUTO_PUSHD
setopt PUSHD_IGNORE_DUPS
setopt PUSHD_SILENT
ZSH_THEME="" ZSH_THEME=""
ENABLE_CORRECTION="false" ENABLE_CORRECTION="false"
@@ -112,7 +120,10 @@ zstyle ':omz:plugins:eza' 'icons' yes
zstyle ':omz:plugins:eza' 'color-scale' all zstyle ':omz:plugins:eza' 'color-scale' all
zstyle ':omz:plugins:eza' 'color-scale-mode' fixed zstyle ':omz:plugins:eza' 'color-scale-mode' fixed
zstyle ':omz:plugins:eza' 'size-prefix' si zstyle ':omz:plugins:eza' 'size-prefix' si
zstyle ':omz:plugins:eza' 'hyperlink' yes # NOTE: 'hyperlink' is intentionally NOT set here. The oh-my-zsh eza plugin adds
# a bare "--hyperlink" flag which eza (clap) treats as taking the next token as
# its optional value, so `la /` fails with "invalid value '/'". We re-enable
# hyperlinks safely below via the glued `--hyperlink=always` form.
zstyle ':completion:*' menu select zstyle ':completion:*' menu select
zstyle ':completion:*' matcher-list 'm:{a-z}={A-Za-z}' zstyle ':completion:*' matcher-list 'm:{a-z}={A-Za-z}'
@@ -121,21 +132,74 @@ zstyle ':completion:*' special-dirs true
source $OHMYZSH/oh-my-zsh.sh source $OHMYZSH/oh-my-zsh.sh
# Re-enable eza hyperlinks, picking the flag form the installed eza supports.
# Newer eza: --hyperlink takes an optional value; the oh-my-zsh plugin's bare
# --hyperlink would eat the following path arg (e.g. `la /` -> invalid value '/'),
# so we use the glued --hyperlink=always form.
# Older eza (e.g. Debian's): --hyperlink is a pure boolean and rejects a value,
# so --hyperlink=always errors; the bare --hyperlink is correct and safe there
# (boolean flags don't consume the next token, so the path-eating bug doesn't
# apply).
_hl_flag="--hyperlink"
if command -v eza >/dev/null && eza --help 2>&1 | grep -q -- '--hyperlink.*<WHEN>'; then
_hl_flag="--hyperlink=always"
fi
for _ea in la ldot lD lDD ll ls lsd lsdl lS lT; do
(( $+aliases[$_ea] )) && alias "$_ea"="${aliases[$_ea]} $_hl_flag"
done
unset _ea _hl_flag
# --- Tool integrations (self-configuring: enabled only when the tool exists) ---
# zoxide: frecency-based directory jumping — `z <substring>` / `zi` (fzf picker).
# Replaces nothing; `cd` still works. Auto-installed by the bootstrap.
command -v zoxide >/dev/null && eval "$(zoxide init zsh)" 2>/dev/null
# bat: colored `cat` and colored man pages. bat/batcat handled by bootstrap.
if command -v bat >/dev/null; then
alias cat='bat'
export BAT_THEME="${BAT_THEME:-Monokai Extended}"
export MANPAGER="sh -c 'col -bx | bat -l man -p'"
export MANROFFOPT='-c'
fi
# delta: prettier git diffs as git's pager (git respects GIT_PAGER). Auto-installed
# by the bootstrap. Not aliased to `diff` since delta reads diff input on stdin,
# not two file arguments — GIT_PAGER is the correct integration.
command -v delta >/dev/null && export GIT_PAGER='delta'
alias c='clear' alias c='clear'
alias q="exit" alias q="exit"
alias nbstat=$'netbird status --json | jq -r \'.peers.details[]? | [(.hostname // .fqdn), .netbirdIp, .status] | @tsv\' | column -t -s $\'\\t\'' alias nbstat=$'netbird status --json | jq -r \'.peers.details[]? | [(.hostname // .fqdn), .netbirdIp, .status] | @tsv\' | column -t -s $\'\\t\''
alias open-ports="ss -tulpn | grep LISTEN" alias open-ports="ss -tulpn | grep LISTEN"
alias yeet='yay -Rcs' alias yeet='yay -Rcs'
# Auto-update: check every shell start, silently fail if server unreachable # Auto-update self and managed configs: checked every interactive shell start,
# silently skipped if the server is unreachable. Keeps zshrc + starship.toml in
# sync across machines (reproducible/self-configuring); only reloads the shell
# when the zshrc itself changes (starship applies its config live per render).
if [[ -o interactive ]]; then if [[ -o interactive ]]; then
# zshrc (reload shell if changed)
tmp=/tmp/.zshrc.$$ tmp=/tmp/.zshrc.$$
curl -sSL --connect-timeout 3 "$ZSHRC_URL" -o "$tmp" 2>/dev/null if curl -fsSL --connect-timeout 3 --max-time 8 "$ZSHRC_URL" -o "$tmp" 2>/dev/null \
if [[ -f "$tmp" ]] && zsh -n "$tmp" 2>/dev/null && ! cmp -s "$tmp" "$HOME/.zshrc" 2>/dev/null; then && [[ -f "$tmp" ]] && zsh -n "$tmp" 2>/dev/null \
&& ! cmp -s "$tmp" "$HOME/.zshrc" 2>/dev/null; then
echo "Updating zshrc..." echo "Updating zshrc..."
mv "$tmp" "$HOME/.zshrc" && exec zsh mv "$tmp" "$HOME/.zshrc" && exec zsh
fi fi
rm -f "$tmp" rm -f "$tmp"
# starship prompt config (overwrite if changed; applied live on next render)
if command -v starship >/dev/null; then
stmp=$(mktemp)
if curl -fsSL --connect-timeout 3 --max-time 8 "$ZSHRC_GIT/starship.toml" -o "$stmp" 2>/dev/null \
&& [[ -f "$stmp" ]] && ! cmp -s "$stmp" "$HOME/.config/starship.toml" 2>/dev/null; then
mkdir -p "$HOME/.config"
mv "$stmp" "$HOME/.config/starship.toml"
else
rm -f "$stmp"
fi
fi
fi fi
[[ -f $HOME/.zshrc.local ]] || touch $HOME/.zshrc.local [[ -f $HOME/.zshrc.local ]] || touch $HOME/.zshrc.local
+202 -31
View File
@@ -2,71 +2,238 @@ export ZSH_CONFIG=$HOME/.config/zsh
export OHMYZSH=$ZSH_CONFIG/oh-my-zsh export OHMYZSH=$ZSH_CONFIG/oh-my-zsh
export OHMYZSH_CUSTOM=$OHMYZSH/custom export OHMYZSH_CUSTOM=$OHMYZSH/custom
# Bootstrap banner — built dynamically so the box stays aligned regardless
# of the version string length or terminal width.
_bootstrap_banner() {
local inner=61 # inner width (between the vertical bars)
local pad line
local title="Zsh bootstrap - one-time environment setup (v${ZSHRC_BOOTSTRAP_VERSION})"
local rule=$(printf '═%.0s' {1..$inner})
local mid=$(printf '─%.0s' {1..$inner})
# left-pad the title by 2 spaces so it doesn't hug the left bar
title=" $title"
pad=$(( inner - ${#title} ))
(( pad < 0 )) && pad=0
echo ""
echo "${rule}"
printf '║%s%*s║\n' "$title" "$pad" ""
echo "${mid}"
while IFS= read -r line; do
line=" $line"
pad=$(( inner - ${#line} ))
(( pad < 0 )) && pad=0
printf '║%s%*s║\n' "$line" "$pad" ""
done <<'EOF'
This runs on first login or when the bootstrap version
changes. It installs missing CLI tools and clones oh-my-zsh
+ plugins across distros. Network calls time out after
~30s so an unreachable host skips silently. The first run
may take a minute; subsequent runs are a no-op.
EOF
echo "${rule}"
echo ""
}
_bootstrap_banner
unfunction _bootstrap_banner
# GitHub-release binary fallback. Used for tools not packaged in a distro's
# repos (e.g. bat/zoxide/git-delta on RHEL-family without EPEL, or no cargo).
# Resolves the latest release tag via the /releases/latest redirect (no API,
# no rate limits), builds the asset URL from a template, extracts, and copies
# the named binary into ~/.local/bin. Args:
# $1 owner/repo $2 asset template (%v = version, no leading v)
# $3 binary name $4 arch suffix to substitute into %a in the template
_gh_latest_tag() {
local repo="$1"
curl -fsIL --connect-timeout 5 --max-time 20 "https://github.com/$repo/releases/latest" 2>/dev/null \
| grep -i '^location:' | tail -1 \
| sed -E 's#.*tag/([^[:space:]"/]+).*#\1#'
}
_gh_install_bin() {
local repo="$1" tmpl="$2" bin="$3"
local rawtag ver asset ext tmp found
rawtag=$(_gh_latest_tag "$repo")
if [[ -z "$rawtag" ]]; then
echo " FAIL could not resolve latest tag for $repo"; return 1
fi
ver="${rawtag#v}" # strip optional leading v for asset-filename substitution
asset="${tmpl//\%v/$ver}"
ext="${asset##*.}"
echo " gh installing $bin ($rawtag from $repo)"
tmp=$(mktemp -d)
if curl -fsSL --connect-timeout 5 --max-time 60 -o "$tmp/pkg.$ext" \
"https://github.com/$repo/releases/download/$rawtag/$asset" \
&& { case "$ext" in
gz) tar -xzf "$tmp/pkg.$ext" -C "$tmp" 2>/dev/null ;;
*) return 1 ;;
esac; }; then
found=$(find "$tmp" -type f -name "$bin" ! -name "*.*" | head -1)
if [[ -n "$found" ]]; then
cp "$found" "$HOME/.local/bin/$bin"
chmod +x "$HOME/.local/bin/$bin"
rm -rf "$tmp"; return 0
fi
fi
rm -rf "$tmp"
echo " FAIL could not download/extract $bin from $repo"; return 1
}
touch ~/.hushlogin touch ~/.hushlogin
mkdir -p "$HOME/.local/bin" mkdir -p "$HOME/.local/bin"
mkdir -p "$ZSH_CONFIG" mkdir -p "$ZSH_CONFIG"
path+=("$HOME/.local/bin") path+=("$HOME/.local/bin")
for p in git starship fzf eza rsync lazydocker fastfetch tmux; do # Package availability checks can hang on slow/unreachable mirrors (cold dnf
command -v $p >/dev/null && continue # metadata, apt update, pacman -Sy). Wrap them in a 20s timeout so the bootstrap
# never stalls the shell; a timed-out check just falls through to the next PM
# or to the binary installers below. Verbose output so real builds (cargo) and
# slow installs are visible as progress instead of an apparent hang.
for p in git starship fzf eza rsync lazydocker fastfetch tmux bat zoxide; do
if command -v $p >/dev/null; then
echo " ok $p (already installed)"
continue
fi
echo " ... $p (checking package managers)"
if command -v brew >/dev/null; then if command -v brew >/dev/null; then
echo "Installing $p using brew" && brew install $p && continue echo " brew installing $p" && brew install $p && continue
fi fi
if command -v apt >/dev/null && apt-cache show $p >/dev/null 2>&1; then if command -v apt >/dev/null; then
echo "Installing $p using Apt" && sudo apt install $p -y && continue if timeout 20 apt-cache show $p >/dev/null 2>&1; then
echo " apt installing $p" && sudo apt install -y $p && continue
else
echo " apt $p not available (or metadata check timed out)"
fi
fi fi
if command -v dnf >/dev/null && dnf list available -y $p >/dev/null 2>&1; then if command -v dnf >/dev/null; then
echo "Installing $p using dnf" && sudo dnf install $p -y && continue if timeout 20 dnf list available $p >/dev/null 2>&1; then
echo " dnf installing $p" && sudo dnf install -y $p && continue
else
echo " dnf $p not available (or metadata check timed out)"
fi
fi fi
if command -v pacman >/dev/null && pacman -Si $p >/dev/null 2>&1; then if command -v pacman >/dev/null; then
echo "Installing $p using pacman" && sudo pacman -Sy --noconfirm $p && continue if timeout 20 pacman -Si $p >/dev/null 2>&1; then
echo " pacman installing $p" && sudo pacman -Sy --noconfirm $p && continue
else
echo " pacman $p not available (or metadata check timed out)"
fi
fi fi
if command -v cargo >/dev/null; then if command -v cargo >/dev/null; then
echo "Installing $p using cargo" && cargo install $p --locked && continue echo " cargo installing $p (may take a few minutes to compile)" && cargo install $p --locked && continue
fi fi
echo " FAIL could not install $p by any method; skipping"
done done
if ! command -v starship >/dev/null; then if ! command -v starship >/dev/null; then
echo "Installing starship binary..." && \ echo " curl installing starship binary"
curl -sSL https://starship.rs/install.sh | sh -s -- -b ~/.local/bin -y >/dev/null 2>&1 curl -fsSL --connect-timeout 5 --max-time 30 https://starship.rs/install.sh | sh -s -- -b ~/.local/bin -y
fi fi
if ! command -v lazydocker >/dev/null; then if ! command -v lazydocker >/dev/null; then
echo "Installing lazydocker binary..." && \ echo " curl installing lazydocker binary"
curl -sSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | \ curl -fsSL --connect-timeout 5 --max-time 30 https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | \
DIR=$HOME/.local/bin bash >/dev/null 2>&1 DIR=$HOME/.local/bin bash
fi
# Debian/Ubuntu ships bat as 'batcat' and fd as 'fdfind'; symlink to the
# canonical names so the zshrc integrations (which call bat/fd) work everywhere.
if ! command -v bat >/dev/null && command -v batcat >/dev/null; then
ln -sf "$(command -v batcat)" "$HOME/.local/bin/bat"
fi
if ! command -v fd >/dev/null && command -v fdfind >/dev/null; then
ln -sf "$(command -v fdfind)" "$HOME/.local/bin/fd"
fi
# git-delta: the package is named 'git-delta' on every package manager but the
# binary is 'delta' (name mismatch vs the install loop above), so install via the
# same PM cascade using the real package name. Used by zshrc as GIT_PAGER.
if ! command -v delta >/dev/null; then
echo " ... delta (checking package managers, package name is 'git-delta')"
if command -v brew >/dev/null; then
echo " brew installing git-delta" && brew install git-delta
elif command -v apt >/dev/null && timeout 20 apt-cache show git-delta >/dev/null 2>&1; then
echo " apt installing git-delta" && sudo apt install -y git-delta
elif command -v dnf >/dev/null && timeout 20 dnf list available git-delta >/dev/null 2>&1; then
echo " dnf installing git-delta" && sudo dnf install -y git-delta
elif command -v pacman >/dev/null && timeout 20 pacman -Si git-delta >/dev/null 2>&1; then
echo " pacman installing git-delta" && sudo pacman -Sy --noconfirm git-delta
elif command -v cargo >/dev/null; then
echo " cargo installing git-delta (may take a few minutes to compile)" && cargo install git-delta --locked
else
echo " FAIL could not install delta; skipping"
fi
fi fi
arch=$(uname -m) arch=$(uname -m)
target_musl=""; target_gnu=""
case "$arch" in case "$arch" in
x86_64) target_fast="linux-amd64" && target_eza="x86_64-unknown-linux-gnu" ;; x86_64) target_fast="linux-amd64" && target_eza="x86_64-unknown-linux-gnu" \
aarch64|arm64) target_fast="linux-aarch64" && target_eza="aarch64-unknown-linux-gnu" ;; && target_musl="x86_64-unknown-linux-musl" && target_gnu="x86_64-unknown-linux-gnu" ;;
armv7l) target_fast="linux-armv7l" && target_eza="armv7-unknown-linux-gnueabihf" ;; aarch64|arm64) target_fast="linux-aarch64" && target_eza="aarch64-unknown-linux-gnu" \
&& target_musl="aarch64-unknown-linux-musl" && target_gnu="aarch64-unknown-linux-gnu" ;;
armv7l) target_fast="linux-armv7l" && target_eza="armv7-unknown-linux-gnueabihf" \
&& target_musl="armv7-unknown-linux-musleabihf" && target_gnu="armv7-unknown-linux-gnueabihf" ;;
esac esac
if [[ -n $target_fast && -n $target_eza ]]; then if [[ -n $target_fast && -n $target_eza ]]; then
if ! command -v fastfetch >/dev/null; then if ! command -v fastfetch >/dev/null; then
curl -sSL -o /tmp/fastfetch.tar.gz "https://github.com/fastfetch-cli/fastfetch/releases/latest/download/fastfetch-${target_fast}.tar.gz" echo " curl installing fastfetch (github release tarball)"
tar -xzf /tmp/fastfetch.tar.gz -C /tmp if curl -fsSL --connect-timeout 5 --max-time 30 -o /tmp/fastfetch.tar.gz \
ffdir=$(find /tmp -maxdepth 1 -type d -name 'fastfetch*' | head -1) "https://github.com/fastfetch-cli/fastfetch/releases/latest/download/fastfetch-${target_fast}.tar.gz" \
cp "$ffdir/usr/bin/fastfetch" "$HOME/.local/bin/" && tar -xzf /tmp/fastfetch.tar.gz -C /tmp 2>/dev/null; then
chmod +x "$HOME/.local/bin/fastfetch" ffdir=$(find /tmp -maxdepth 1 -type d -name 'fastfetch*' | head -1)
mkdir -p "$HOME/.local/share/fastfetch" if [[ -n "$ffdir" ]]; then
cp -r "$ffdir/usr/share/fastfetch/"* "$HOME/.local/share/fastfetch/" cp "$ffdir/usr/bin/fastfetch" "$HOME/.local/bin/"
rm -rf "$ffdir" /tmp/fastfetch.tar.gz chmod +x "$HOME/.local/bin/fastfetch"
mkdir -p "$HOME/.local/share/fastfetch"
cp -r "$ffdir/usr/share/fastfetch/"* "$HOME/.local/share/fastfetch/"
fi
rm -rf "$ffdir" /tmp/fastfetch.tar.gz
else
rm -f /tmp/fastfetch.tar.gz
echo " FAIL could not download/extract fastfetch; skipping"
fi
fi fi
if ! command -v eza >/dev/null; then if ! command -v eza >/dev/null; then
curl -sSL -o /tmp/eza.tar.gz "https://github.com/eza-community/eza/releases/latest/download/eza_${target_eza}.tar.gz" echo " curl installing eza (github release tarball)"
tar -xzf /tmp/eza.tar.gz -C "$HOME/.local/bin/" if curl -fsSL --connect-timeout 5 --max-time 30 -o /tmp/eza.tar.gz \
chmod +x "$HOME/.local/bin/eza" "https://github.com/eza-community/eza/releases/latest/download/eza_${target_eza}.tar.gz" \
&& tar -xzf /tmp/eza.tar.gz -C "$HOME/.local/bin/" 2>/dev/null; then
chmod +x "$HOME/.local/bin/eza"
else
rm -f /tmp/eza.tar.gz
echo " FAIL could not download/extract eza; skipping"
fi
rm -f /tmp/eza.tar.gz rm -f /tmp/eza.tar.gz
fi fi
fi fi
# GitHub-release binary fallbacks for tools often missing from distro repos
# (e.g. bat/zoxide/git-delta on RHEL/Alma without EPEL, or boxes with no cargo).
# Tried after the PM/cargo loop above already failed for these. Static musl
# builds preferred so there are no glibc dependencies; delta ships gnu-only.
if ! command -v bat >/dev/null && [[ -n "$target_musl" ]]; then
_gh_install_bin "sharkdp/bat" "bat-v%v-$target_musl.tar.gz" "bat"
fi
if ! command -v zoxide >/dev/null && [[ -n "$target_musl" ]]; then
_gh_install_bin "ajeetdsouza/zoxide" "zoxide-%v-$target_musl.tar.gz" "zoxide"
fi
if ! command -v delta >/dev/null && [[ -n "$target_gnu" ]]; then
_gh_install_bin "dandavison/delta" "delta-%v-$target_gnu.tar.gz" "delta"
fi
mkdir -p "$HOME/.config/zsh" mkdir -p "$HOME/.config/zsh"
[ ! -d $OHMYZSH ] && git clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git $OHMYZSH if [ ! -d "$OHMYZSH" ]; then
echo " git cloning oh-my-zsh"
GIT_TERMINAL_PROMPT=0 git -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=10 \
clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git $OHMYZSH || \
echo " FAIL could not clone oh-my-zsh"
fi
for plug in \ for plug in \
"zsh-autosuggestions|https://github.com/zsh-users/zsh-autosuggestions" \ "zsh-autosuggestions|https://github.com/zsh-users/zsh-autosuggestions" \
@@ -76,7 +243,11 @@ do
name="${plug%%|*}" name="${plug%%|*}"
url="${plug##*|}" url="${plug##*|}"
dir="$OHMYZSH_CUSTOM/plugins/$name" dir="$OHMYZSH_CUSTOM/plugins/$name"
[[ ! -d "$dir" ]] && git clone "$url" "$dir" 2>/dev/null if [[ ! -d "$dir" ]]; then
echo " git cloning $name"
GIT_TERMINAL_PROMPT=0 git -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=10 \
clone "$url" "$dir" 2>/dev/null || echo " FAIL could not clone $name"
fi
done done
# Cleanup # Cleanup