Merge pull request #342 from outis1one/claude/ionos-script-integration-x32ofw

Add garage-webui: browse Garage's buckets/objects like Backblaze's co…
This commit is contained in:
Outis
2026-08-15 20:29:10 -04:00
committed by GitHub
3 changed files with 323 additions and 2 deletions
+1 -1
View File
@@ -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` |
+272
View File
@@ -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
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