Files
ubuntu-post-install/services/garage-webui.sh
T
Claude 4717c3a080 Add garage-webui: browse Garage's buckets/objects like Backblaze's console
User's actual question: Backblaze B2's web console lets them browse a
bucket as folders/files; Garage has no equivalent by default, so after
switching an additional backup mirror from Backblaze-only to also
target local Garage, they had no way to visually confirm data landed
there the way they could on Backblaze. "S3 storage is opaque, you
can't browse it" was true of Garage's *own* CLI, but wrong as a
blanket statement — Backblaze's browsability comes from a client (its
web console) layered on top of the same kind of object storage, and
Garage has an actively-maintained equivalent (khairul169/garage-webui,
1.1k stars, "integrated objects/bucket browser") that gives the same
experience against Garage's S3 API.

services/garage-webui.sh (new): standard service-template Docker
service. Requires an existing services/garage.sh install (checks for
$DOCKER_DIR/garage/.env, errors with instructions if missing — this
is a browser for an existing instance, not a replacement). Reaches
Garage over host.docker.internal (both containers' ports are already
published to the host — simpler and more robust than trying to join
garage's own Compose-project-scoped default network by name). Has its
own login (AUTH_USER_PASS, bcrypt via a throwaway `docker run --rm
httpd:alpine htpasswd` — same $ -> $$ escaping services/wg-easy.sh
already uses for its own bcrypt PASSWORD_HASH, verified here against a
real docker compose config run: unescaped, Compose tries to interpolate
$2y$05... as variable references and silently corrupts the value with
a "not set" warning; escaped, it passes through intact with no
warning), so it doesn't need Authelia gating by default.

Prerequisite fix in services/garage.sh: its admin API (bucket/key
management, object listing — the thing garage-webui talks to) has
been running with zero authentication since this service was first
built, because admin_token was never set in garage.toml. Nothing in
this repo called that API before now, so it went unnoticed; adding a
real consumer is what surfaced it. Fixed: generate admin_token
(openssl rand -base64 32) alongside the existing rpc_secret, persist
GARAGE_ADMIN_TOKEN/GARAGE_ADMIN_PORT to .env for garage-webui to read
locally (never sent over SSH, unlike the S3 credentials backup.sh
reads remotely). Update mode backfills admin_token into an existing
garage.toml (+ restarts just the garage container to apply it) for
anyone who installed before this change, same backfill-not-break
approach as the GARAGE_S3_API_PORT fix from the previous commit.

Verified: bash -n on both files; docker compose config against real
Docker Compose for both the primary garage.toml/.env generation (with
the new admin_token/GARAGE_ADMIN_PORT fields) and the new
garage-webui docker-compose.yml; the bcrypt-escaping behavior
specifically (proved via a minimal repro that unescaped $ corrupts
the value with a warning, escaped does not); the admin_token/
GARAGE_ADMIN_PORT Update-mode backfill logic against old- and
new-style .env/garage.toml fixtures, including idempotency (running
it twice adds nothing a second time); and the credential-parsing
regexes in garage-webui.sh against both a complete .env fixture and
an old one missing the new fields (confirms the "run garage's Update
first" error path actually triggers rather than proceeding with
blanks).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-15 14:28:57 +00:00

273 lines
10 KiB
Bash

#!/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