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:
Claude
2026-08-15 14:28:57 +00:00
parent 558fc3e75b
commit 4717c3a080
3 changed files with 323 additions and 2 deletions
+50 -1
View File
@@ -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