#!/usr/bin/env bash # ───────────────────────────────────────────────────────────── # Caddy Manager – gum-powered TUI # SSH target : root@192.168.0.17 # Sites dir : ~/sites # ───────────────────────────────────────────────────────────── set -uo pipefail SSH_TARGET="root@192.168.0.17" SITES_DIR="~/sites" RELOAD_CMD="docker exec -w /etc/caddy caddy caddy reload" # ── Colour palette ──────────────────────────────────────────── ORANGE="#FF6B00" ORANGE_DIM="#CC5500" GREY_DARK="#2A2A2A" GREY_MID="#4A4A4A" GREY_LIGHT="#AAAAAA" WHITE="#F5F5F5" # ── Dependency check ───────────────────────────────────────── if ! command -v gum &>/dev/null; then echo "ERROR: 'gum' is not installed." exit 1 fi if ! command -v ssh &>/dev/null; then echo "ERROR: 'ssh' is not installed." exit 1 fi # ── Helpers ─────────────────────────────────────────────────── draw_title() { clear gum style \ --foreground "$ORANGE" \ --border-foreground "$ORANGE" \ --border double \ --align center \ --width 50 \ --margin "1 2" \ --padding "1 4" \ --bold \ "⚙ CADDY MANAGER ⚙" \ "" \ "$(gum style --foreground "$GREY_LIGHT" --faint "$(date '+%a %d %b %Y %H:%M')")" } ssh_run() { # Run a command on the remote host ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_TARGET" "$@" } reload_caddy() { gum spin \ --spinner dot \ --title "Reloading Caddy..." \ --title.foreground "$ORANGE" \ -- ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_TARGET" "$RELOAD_CMD" } press_enter() { echo "" gum style --foreground "$GREY_LIGHT" "Press Enter to return to the menu..." read -r } # ── Option 1 – Add a new config ─────────────────────────────── add_config() { draw_title gum style \ --foreground "$ORANGE" \ --bold \ --margin "0 2" \ " ADD NEW SITE CONFIG" echo "" # Service name (used as filename) SERVICE=$(gum input \ --prompt " Service name (filename): " \ --prompt.foreground "$ORANGE" \ --placeholder "e.g. nextcloud" \ --width 40 \ --cursor.foreground "$ORANGE") [[ -z "$SERVICE" ]] && { gum style --foreground "red" " Aborted – no service name given."; press_enter; return; } # Domain – type subdomain only, suffix appended automatically SUBDOMAIN=$(gum input \ --prompt " Subdomain: " \ --prompt.foreground "$ORANGE" \ --placeholder "e.g. homarr (will become homarr.marlow.quest)" \ --width 50 \ --cursor.foreground "$ORANGE") [[ -z "$SUBDOMAIN" ]] && { gum style --foreground "red" " Aborted – no domain given."; press_enter; return; } # If they typed a full domain (contains a dot) use as-is, otherwise append suffix if [[ "$SUBDOMAIN" == *.* ]]; then DOMAIN="$SUBDOMAIN" else DOMAIN="${SUBDOMAIN}.marlow.quest" fi gum style --foreground "$GREY_LIGHT" --margin "0 2" " → ${DOMAIN}" echo "" # Upstream IP:port – pre-filled with common subnet prefix UPSTREAM=$(gum input \ --prompt " Upstream IP:port: " \ --prompt.foreground "$ORANGE" \ --value "192.168.0." \ --width 40 \ --cursor.foreground "$ORANGE") [[ -z "$UPSTREAM" ]] && { gum style --foreground "red" " Aborted – no upstream given."; press_enter; return; } # TinyAuth? echo "" gum style --foreground "$GREY_LIGHT" --margin "0 2" " Include TinyAuth middleware?" USE_TINYAUTH=$(gum choose \ --cursor "▶ " \ --cursor.foreground "$ORANGE" \ --selected.foreground "$ORANGE" \ --header " Select an option:" \ --header.foreground "$GREY_LIGHT" \ "Yes" "No") # Build the Caddy config block if [[ "$USE_TINYAUTH" == "Yes" ]]; then CONFIG_BLOCK="${DOMAIN} { import tinyauth reverse_proxy ${UPSTREAM} }" else CONFIG_BLOCK="${DOMAIN} { reverse_proxy ${UPSTREAM} }" fi # Preview echo "" gum style \ --foreground "$ORANGE" \ --border normal \ --border-foreground "$GREY_MID" \ --padding "1 2" \ --margin "0 2" \ "$CONFIG_BLOCK" echo "" gum confirm \ --prompt.foreground "$ORANGE" \ --selected.background "$ORANGE" \ --selected.foreground "$GREY_DARK" \ " Write ${SERVICE}.conf to ${SITES_DIR} and reload Caddy?" \ || { gum style --foreground "$GREY_LIGHT" " Cancelled."; press_enter; return; } # Write file via SSH heredoc ssh_run "cat > ${SITES_DIR}/${SERVICE}.conf" </dev/null || true) # Remove blank entries CLEAN_FILES=() for f in "${FILES[@]+"${FILES[@]}"}"; do [[ -n "$f" ]] && CLEAN_FILES+=("$f") done if [[ ${#CLEAN_FILES[@]} -eq 0 ]]; then gum style --foreground "$GREY_LIGHT" --margin "0 2" \ " No config files found in ${SITES_DIR}/" press_enter return fi # Parse each file individually — build display arrays and a lookup map T_NAME=() T_DOMAIN=() T_UPSTREAM=() T_TINYAUTH=() for FILE in "${CLEAN_FILES[@]}"; do FILE_CONTENT=$(ssh_run "cat ${SITES_DIR}/${FILE}" 2>/dev/null || true) DOMAIN=$(echo "$FILE_CONTENT" \ | grep -v '^\s*#' | grep -v '^\s*$' | grep -v '^\s' \ | grep '\.' | head -1 | awk '{print $1}' | tr -d '{}' || true) [[ -z "$DOMAIN" ]] && DOMAIN="—" UPSTREAM=$(echo "$FILE_CONTENT" \ | grep -i 'reverse_proxy' | awk '{print $2}' | head -1 || true) [[ -z "$UPSTREAM" ]] && UPSTREAM="—" if echo "$FILE_CONTENT" | grep -qi 'tinyauth' 2>/dev/null; then TA="yes" else TA="no" fi T_NAME+=("$FILE") T_DOMAIN+=("$DOMAIN") T_UPSTREAM+=("$UPSTREAM") T_TINYAUTH+=("$TA") done # Write CSV to temp file (gum table needs a real fd, not a pipe) TMPCSV=$(mktemp /tmp/caddy-XXXXXX.csv) for i in "${!T_NAME[@]}"; do printf '%s,%s,%s,%s\n' \ "${T_NAME[$i]}" "${T_DOMAIN[$i]}" "${T_UPSTREAM[$i]}" "${T_TINYAUTH[$i]}" \ >> "$TMPCSV" done gum style --foreground "$GREY_LIGHT" --margin "0 2" \ " ↑↓ navigate · Enter to view full config · Esc to go back" echo "" SELECTED=$(gum table \ --columns "FILE,DOMAIN,UPSTREAM,TINYAUTH" \ --widths "26,30,22,9" \ --height 18 \ --border.foreground "$ORANGE" \ --header.foreground "$ORANGE" \ --selected.foreground "$GREY_DARK" \ --selected.background "$ORANGE" \ < "$TMPCSV" || true) rm -f "$TMPCSV" [[ -z "$SELECTED" ]] && return # Extract filename from first CSV column CHOICE=$(echo "$SELECTED" | cut -d',' -f1 | xargs) # ── Show full file contents ──────────────────────────── clear gum style \ --foreground "$ORANGE" \ --bold \ --margin "1 2 0 2" \ " ▸ ${CHOICE}" echo "" CONTENT=$(ssh_run "cat ${SITES_DIR}/${CHOICE}" 2>/dev/null || true) [[ -z "$CONTENT" ]] && CONTENT="(unable to read file)" gum style \ --foreground "$WHITE" \ --border normal \ --border-foreground "$GREY_MID" \ --padding "1 2" \ --margin "0 2" \ "$CONTENT" echo "" press_enter done } # ── Option 3 – Delete a config ──────────────────────────────── delete_config() { draw_title gum style \ --foreground "$ORANGE" \ --bold \ --margin "0 2" \ " DELETE SITE CONFIG" echo "" # Fetch file list mapfile -t FILES < <(ssh_run "ls ${SITES_DIR}/" 2>/dev/null | sort) if [[ ${#FILES[@]} -eq 0 ]]; then gum style --foreground "$GREY_LIGHT" --margin "0 2" " No config files found in ${SITES_DIR}/" press_enter return fi CHOICE=$(printf '%s\n' "${FILES[@]}" "── Cancel ──" | \ gum choose \ --cursor "▶ " \ --cursor.foreground "$ORANGE" \ --selected.foreground "red" \ --header " Select a config to delete:" \ --header.foreground "$GREY_LIGHT" \ --height 15) [[ "$CHOICE" == "── Cancel ──" || -z "$CHOICE" ]] && { gum style --foreground "$GREY_LIGHT" " Cancelled."; press_enter; return; } echo "" # Show contents before confirming CONTENT=$(ssh_run "cat ${SITES_DIR}/${CHOICE}" 2>/dev/null || echo "(unable to read file)") gum style \ --foreground "$GREY_LIGHT" \ --border normal \ --border-foreground "red" \ --padding "1 2" \ --margin "0 2" \ "$CONTENT" echo "" gum confirm \ --prompt.foreground "$ORANGE" \ --selected.background "$ORANGE" \ --selected.foreground "$GREY_DARK" \ " Permanently delete ${CHOICE} and reload Caddy?" \ || { gum style --foreground "$GREY_LIGHT" " Cancelled."; press_enter; return; } ssh_run "rm -f ${SITES_DIR}/${CHOICE}" echo "" gum style --foreground "green" " ✔ Deleted ${SITES_DIR}/${CHOICE}" reload_caddy && gum style --foreground "green" " ✔ Caddy reloaded successfully." \ || gum style --foreground "red" " ✗ Caddy reload failed – check logs." press_enter } # ── Main menu loop ──────────────────────────────────────────── while true; do draw_title MENU_CHOICE=$(gum choose \ --cursor "▶ " \ --cursor.foreground "$ORANGE" \ --selected.foreground "$ORANGE" \ --header " What would you like to do?" \ --header.foreground "$GREY_LIGHT" \ --height 6 \ " 1 Add a new site config" \ " 2 List / view site configs" \ " 3 Delete a site config" \ " ✕ Exit") case "$MENU_CHOICE" in *"1"*) add_config ;; *"2"*) list_configs ;; *"3"*) delete_config ;; *"✕"*|"") clear; exit 0 ;; esac done