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
This commit is contained in:
@@ -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
|
||||
+50
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user