diff --git a/README.md b/README.md index 40e44bd..4acbd8f 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | | `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) | -| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | +| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | diff --git a/services/garage-webui.sh b/services/garage-webui.sh new file mode 100644 index 0000000..8079ee0 --- /dev/null +++ b/services/garage-webui.sh @@ -0,0 +1,272 @@ +#!/bin/bash +# services/garage-webui.sh — Web UI for browsing/managing an existing Garage +# instance's buckets and objects (folders/files view, the same kind of thing +# Backblaze's own web console gives you for a B2 bucket). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash garage-webui.sh +# (Docker must already be installed when run standalone; requires +# services/garage.sh already installed on the SAME machine — this reads +# that install's admin API port/token straight out of its .env) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } + + _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _COMMON="$_SELF_DIR/../lib/common.sh" + + if [[ -f "$_COMMON" ]]; then + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } + log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } + log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } + log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } + + require_docker() { + command -v docker &>/dev/null || { + log_error "Docker not found. Install it first:" + log_error " curl -fsSL https://get.docker.com | sudo sh" + return 1 + } + docker compose version &>/dev/null || { + log_error "Docker Compose plugin missing:" + log_error " sudo apt-get install -y docker-compose-plugin" + return 1 + } + } + + ensure_docker_dir_ownership() { + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true + } + + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + prompt_text() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_reinstall_mode() { + local _var="$1" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='cancel'"; return; } + echo "" + echo " 1) Update — refresh the image only, leave config/data as-is" + echo " 2) Full reinstall — wipe and reconfigure from scratch" + echo " 3) Cancel — leave the existing install untouched" + read -r -p " Choice [3]: " _r + case "$_r" in + 1) eval "$_var='update'" ;; + 2) eval "$_var='fresh'" ;; + *) eval "$_var='cancel'" ;; + esac + } + + configure_caddy_for_service() { CADDY_SERVICE_CONFIGURED=false; } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" + ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" + DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + DRY_RUN="${DRY_RUN:-false}" + UNATTENDED="${UNATTENDED:-false}" + SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service garage-webui utilities "Web UI for browsing/managing an existing Garage instance's buckets and objects" 3909 + +install_garage-webui() { + require_docker || return 1 + + local GARAGE_DIR="$DOCKER_DIR/garage" + local DIR="$DOCKER_DIR/garage-webui" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would check for an existing services/garage.sh install at $GARAGE_DIR" + echo "[DRY-RUN] Would create $DIR with docker-compose.yml + .env" + echo "[DRY-RUN] Would auto-scan for a free web UI port" + echo "[DRY-RUN] Would generate an admin username/password and bcrypt-hash it (requires Docker)" + return 0 + fi + + if [[ ! -f "$GARAGE_DIR/.env" ]]; then + log_error "No Garage install found at $GARAGE_DIR — this is a browser for an" + log_error "existing Garage instance, not a replacement for it. Install Garage" + log_error "first: sudo ./setup.sh garage" + return 1 + fi + + local _garage_env + _garage_env="$(cat "$GARAGE_DIR/.env")" + local GARAGE_ADMIN_PORT GARAGE_ADMIN_TOKEN GARAGE_S3_API_PORT + GARAGE_ADMIN_PORT="$(echo "$_garage_env" | sed -nE "s/^GARAGE_ADMIN_PORT=([0-9]+)\$/\1/p")" + GARAGE_ADMIN_TOKEN="$(echo "$_garage_env" | sed -nE "s/^GARAGE_ADMIN_TOKEN='?([^']*)'?\$/\1/p")" + GARAGE_S3_API_PORT="$(echo "$_garage_env" | sed -nE "s/^GARAGE_S3_API_PORT=([0-9]+)\$/\1/p")" + + if [ -z "$GARAGE_ADMIN_PORT" ] || [ -z "$GARAGE_ADMIN_TOKEN" ] || [ -z "$GARAGE_S3_API_PORT" ]; then + log_error "Garage is installed at $GARAGE_DIR but its .env is missing the admin" + log_error "API port/token this needs (added in a newer version of services/garage.sh)." + log_error "Re-run Garage's own installer first to backfill it, then rerun this:" + log_error " sudo ./setup.sh garage (choose \"Update\" when prompted)" + return 1 + fi + + if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then + local MODE="" + prompt_reinstall_mode MODE + case "$MODE" in + update) + log_info "Refreshing the Garage Web UI image only — existing login is left as-is." + ( cd "$DIR" && docker compose pull && docker compose up -d ) \ + && log_success "Garage Web UI refreshed" \ + || log_warning "Refresh failed — check: docker compose -f $DIR/docker-compose.yml logs" + return 0 + ;; + cancel) + log_info "Leaving the existing install as-is." + return 0 + ;; + fresh) ;; # fall through to the full install flow below + esac + fi + + echo "" + echo "═══════════════════════════════════════════════════════" + echo " GARAGE WEB UI — browse/manage Garage's buckets and objects" + echo "═══════════════════════════════════════════════════════" + echo "" + echo " Connecting to the Garage instance at $GARAGE_DIR." + echo "" + + local WEB_PORT="3909" + find_free_port WEB_PORT "$WEB_PORT" + + local WEBUI_USER="" + prompt_text " Admin username:" "admin" WEBUI_USER + WEBUI_USER="${WEBUI_USER:-admin}" + + local WEBUI_PASSWORD + WEBUI_PASSWORD="$(generate_password 24)" + + log_info "Generating bcrypt password hash (requires Docker)..." + local _auth_line + _auth_line="$(docker run --rm httpd:alpine htpasswd -nbB "$WEBUI_USER" "$WEBUI_PASSWORD" 2>/dev/null)" + if [ -z "$_auth_line" ]; then + log_error "Couldn't generate a bcrypt hash (needs to pull httpd:alpine) — aborting." + return 1 + fi + # Compose re-parses its own YAML for $VAR/${VAR} interpolation, so a + # literal $ from the bcrypt hash (embedded directly into + # docker-compose.yml below) has to be doubled or Compose treats it as + # the start of a variable reference. Same fix services/wg-easy.sh uses + # for its own bcrypt PASSWORD_HASH. + local _auth_escaped="${_auth_line//\$/\$\$}" + + mkdir -p "$DIR" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + cat > docker-compose.yml << COMPOSE +name: garage-webui + +services: + garage-webui: + image: khairul169/garage-webui:1.1.0 + container_name: garage-webui + restart: unless-stopped + env_file: .env + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + API_BASE_URL: "http://host.docker.internal:${GARAGE_ADMIN_PORT}" + S3_ENDPOINT_URL: "http://host.docker.internal:${GARAGE_S3_API_PORT}" + API_ADMIN_KEY: "${GARAGE_ADMIN_TOKEN}" + AUTH_USER_PASS: "${_auth_escaped}" + ports: + - "${WEB_PORT}:3909" +COMPOSE + + cat > .env << ENV +TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} + +# Plain-text password — only for your own reference (the container itself +# is only ever given the bcrypt hash, baked into docker-compose.yml). +GARAGE_WEBUI_USER='${WEBUI_USER}' +GARAGE_WEBUI_PASSWORD='${WEBUI_PASSWORD}' +ENV + chmod 600 .env + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + + log_info "Starting Garage Web UI..." + docker compose up -d || { log_error "Failed to start Garage Web UI — check: docker compose logs"; return 1; } + + configure_caddy_for_service "Garage Web UI" "$WEB_PORT" "garage-admin" + + write_readme "$DIR" << MD +# Garage Web UI + +Browser-based admin UI for an existing Garage instance ($GARAGE_DIR) — bucket +and object browser (see folders/files the way Backblaze's own web console +shows a B2 bucket), cluster health, and key management. + +## Login +- URL: http://localhost:${WEB_PORT} +- Username: \`$WEBUI_USER\` +- Password: see \`.env\` (\`GARAGE_WEBUI_PASSWORD\`) + +## Manage +\`\`\`bash +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose up -d +\`\`\` + +This talks to Garage's admin API (\`GARAGE_ADMIN_PORT\`/\`GARAGE_ADMIN_TOKEN\` in +$GARAGE_DIR/.env) and S3 API over \`host.docker.internal\`, both on this same +machine — nothing here is sent over the network to any other box. +MD + + echo "" + log_success "Garage Web UI ready." + echo " URL: http://localhost:${WEB_PORT}" + echo " Username: $WEBUI_USER" + echo " Password: $WEBUI_PASSWORD (saved in $DIR/.env)" + echo "" +} + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_garage-webui diff --git a/services/garage.sh b/services/garage.sh index d40ccda..6fb7e88 100644 --- a/services/garage.sh +++ b/services/garage.sh @@ -134,7 +134,7 @@ install_garage() { if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $DIR with garage.toml + docker-compose.yml + .env" echo "[DRY-RUN] Would auto-scan for free S3 API / RPC / admin ports" - echo "[DRY-RUN] Would generate an RPC secret and persist it (never regenerated on update)" + echo "[DRY-RUN] Would generate an RPC secret and an admin API token, persisted (never regenerated on update)" echo "[DRY-RUN] Would run the one-time cluster init: layout assign/apply, bucket create, key create" echo "[DRY-RUN] Would print the endpoint/bucket/access-key/secret for Kopia's sync-to s3" return 0 @@ -146,6 +146,7 @@ install_garage() { case "$MODE" in update) log_info "Refreshing the Garage image only — existing data, config, and keys are left as-is." + local _GARAGE_NEEDS_RESTART_FOR_ADMIN_TOKEN=0 # .env fields added to this script after someone's initial # install (GARAGE_S3_API_PORT, added so services/backup.sh # can read it remotely) never get backfilled by Update on @@ -164,9 +165,42 @@ install_garage() { log_info "Backfilled GARAGE_S3_API_PORT=${_existing_port} into .env (added in a newer version of this script; services/backup.sh needs it to read this instance remotely)." fi fi + if [[ -f "$DIR/.env" ]] && ! grep -q '^GARAGE_ADMIN_PORT=' "$DIR/.env"; then + local _existing_admin_port + _existing_admin_port="$(grep -oE '"[0-9]+:[0-9]+"' "$DIR/docker-compose.yml" 2>/dev/null | sed -n 3p | tr -d '"' | cut -d: -f1)" + if [ -n "$_existing_admin_port" ]; then + echo "GARAGE_ADMIN_PORT=${_existing_admin_port}" >> "$DIR/.env" + log_info "Backfilled GARAGE_ADMIN_PORT=${_existing_admin_port} into .env." + fi + fi + # Older installs' garage.toml predates admin_token, meaning + # this instance's admin API (bucket/key management, object + # listing — published to the host, not just the internal + # Docker network) has been running with no authentication + # at all. Add one now rather than leaving it open — nothing + # in this repo talked to that API before services/ + # garage-webui.sh, so there's no existing authenticated + # caller this could break. + if [[ -f "$DIR/garage.toml" ]] && ! grep -q '^admin_token' "$DIR/garage.toml"; then + local _new_admin_token + _new_admin_token="$(openssl rand -base64 32)" + printf 'admin_token = "%s"\n' "$_new_admin_token" >> "$DIR/garage.toml" + if grep -q '^GARAGE_ADMIN_TOKEN=' "$DIR/.env"; then + sed -i "s#^GARAGE_ADMIN_TOKEN=.*#GARAGE_ADMIN_TOKEN='${_new_admin_token}'#" "$DIR/.env" + else + echo "GARAGE_ADMIN_TOKEN='${_new_admin_token}'" >> "$DIR/.env" + fi + log_info "Garage's admin API had no auth token — added one and will restart to apply it." + _GARAGE_NEEDS_RESTART_FOR_ADMIN_TOKEN=1 + fi ( cd "$DIR" && docker compose pull && docker compose up -d ) \ && log_success "Garage image refreshed" \ || log_warning "Refresh failed — check: docker compose -f $DIR/docker-compose.yml logs" + if [ "${_GARAGE_NEEDS_RESTART_FOR_ADMIN_TOKEN:-0}" = 1 ]; then + ( cd "$DIR" && docker compose restart garage ) \ + && log_success "Admin API now requires GARAGE_ADMIN_TOKEN from .env." \ + || log_warning "Restart failed — apply the new admin_token manually: docker compose -f $DIR/docker-compose.yml restart garage" + fi return 0 ;; cancel) @@ -213,6 +247,15 @@ install_garage() { local RPC_SECRET RPC_SECRET="$(openssl rand -hex 32)" + # Without admin_token, Garage's admin API (bucket/key management, + # metrics, object listing) is open to anyone who can reach ADMIN_PORT + # — and that port is published to the host, not just the internal + # Docker network. Nothing in this repo talked to that API before, so + # this went unnoticed; services/garage-webui.sh is the first consumer, + # so it's the point this needed locking down. + local ADMIN_TOKEN + ADMIN_TOKEN="$(openssl rand -base64 32)" + cat > garage.toml << TOML metadata_dir = "/meta" data_dir = "/data" @@ -231,6 +274,7 @@ root_domain = ".s3.garage.localhost" [admin] api_bind_addr = "[::]:${ADMIN_PORT}" +admin_token = "${ADMIN_TOKEN}" TOML cat > docker-compose.yml << COMPOSE @@ -260,6 +304,11 @@ TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} # this instance as a Kopia sync-to s3 mirror target — keep this key name # stable, other scripts depend on it. GARAGE_S3_API_PORT=${S3_API_PORT} + +# Read by services/garage-webui.sh (same host only — never sent over SSH) +# to reach this instance's admin API for its bucket/object browser. +GARAGE_ADMIN_PORT=${ADMIN_PORT} +GARAGE_ADMIN_TOKEN='${ADMIN_TOKEN}' ENV chmod 600 .env