panel/panelctl.sh
Jakub Dorfman e15298fd3d feat: add initial implementation of the frontend panel with app management features
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 23:53:17 +02:00

668 lines
17 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
BASE_DIR="${PANEL_BASE_DIR:-/var/lib/containers}"
STACKS_DIR="${BASE_DIR}/stacks"
VOLUMES_DIR="${BASE_DIR}/volumes"
ROUTES_DIR="${BASE_DIR}/routes"
STATE_DIR="${BASE_DIR}/state"
APPS_DIR="${STATE_DIR}/apps"
BACKUPS_DIR="${BASE_DIR}/backups"
FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
'
usage() {
cat <<'EOF'
panelctl - minimal app panel helper
Usage:
panelctl init <name> <domains> <port> [auth]
panelctl render-route <name>
panelctl deploy <name>
panelctl restart <name>
panelctl stop <name>
panelctl status <name>
panelctl logs <name> [--tail N]
panelctl remove <name> [--keep-volumes]
panelctl backup <name>
panelctl list-backups <name>
panelctl restore <name> <backup-file>
panelctl validate-compose <name>
panelctl list
panelctl show <name>
Domains can be comma-separated for multiple domains:
panelctl init myapp "app.example.com,www.example.com" 18080 true
Wildcard domains are supported (requires DNS challenge in Caddy):
panelctl init myapp "*.example.com" 18080 true
Examples:
panelctl init whoami whoami.srazka.com 18080 true
panelctl deploy whoami
panelctl restart whoami
panelctl status whoami
panelctl logs whoami --tail 50
panelctl backup whoami
panelctl list-backups whoami
panelctl restore whoami whoami-20260101-120000.zip
EOF
}
fail() {
echo "error: $*" >&2
exit 1
}
ensure_base_dirs() {
mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" "${BACKUPS_DIR}"
}
validate_name() {
local name="$1"
[[ "${name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "invalid name '${name}' (use lowercase slug)"
}
validate_single_domain() {
local domain="$1"
# Allow wildcard prefix *.
local check="${domain}"
if [[ "${check}" == \*.* ]]; then
check="${check#\*.}"
fi
[[ "${check}" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || fail "invalid domain '${domain}'"
[[ "${domain}" == *.* ]] || fail "domain '${domain}' must include a dot"
}
validate_domains() {
local domains_str="$1"
IFS=',' read -ra domains <<< "${domains_str}"
[[ ${#domains[@]} -ge 1 ]] || fail "at least one domain is required"
for d in "${domains[@]}"; do
d="$(echo "${d}" | xargs)" # trim whitespace
validate_single_domain "${d}"
done
}
validate_port() {
local port="$1"
[[ "${port}" =~ ^[0-9]+$ ]] || fail "port must be numeric"
(( port >= 1024 && port <= 65535 )) || fail "port must be in range 1024-65535"
}
app_manifest() {
local name="$1"
echo "${APPS_DIR}/${name}.env"
}
app_stack_dir() {
local name="$1"
echo "${STACKS_DIR}/${name}"
}
app_volume_dir() {
local name="$1"
echo "${VOLUMES_DIR}/${name}"
}
# All routes go into a single aggregate file that Caddy imports.
app_route_file() {
echo "${ROUTES_DIR}/routes.caddy"
}
load_app() {
local name="$1"
local manifest
manifest="$(app_manifest "${name}")"
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
# shellcheck disable=SC1090
source "${manifest}"
}
compose_command() {
local podman_bin=""
local podman_compose_bin=""
if command -v podman >/dev/null 2>&1; then
podman_bin="$(command -v podman)"
elif [[ -x /run/current-system/sw/bin/podman ]]; then
podman_bin="/run/current-system/sw/bin/podman"
fi
if command -v podman-compose >/dev/null 2>&1; then
podman_compose_bin="$(command -v podman-compose)"
elif [[ -x /run/current-system/sw/bin/podman-compose ]]; then
podman_compose_bin="/run/current-system/sw/bin/podman-compose"
fi
if [[ -n "${podman_bin}" ]] && "${podman_bin}" compose version >/dev/null 2>&1; then
echo "${podman_bin} compose"
return
fi
if [[ -n "${podman_compose_bin}" ]]; then
echo "${podman_compose_bin}"
return
fi
fail "no compose command available (need 'podman compose' or 'podman-compose')"
}
ensure_podman_runtime_env() {
local uid
uid="$(id -u)"
if [[ -z "${HOME:-}" ]]; then
HOME="$(getent passwd "${uid}" | cut -d: -f6 || true)"
export HOME
fi
if [[ -z "${XDG_RUNTIME_DIR:-}" ]]; then
XDG_RUNTIME_DIR="/run/user/${uid}"
export XDG_RUNTIME_DIR
fi
if [[ ! -d "${XDG_RUNTIME_DIR}" ]]; then
fail "XDG_RUNTIME_DIR '${XDG_RUNTIME_DIR}' does not exist for uid ${uid}. Ensure user runtime is available (e.g. loginctl enable-linger $(id -un))."
fi
if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" && -S "${XDG_RUNTIME_DIR}/bus" ]]; then
DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
export DBUS_SESSION_BUS_ADDRESS
fi
unset DOCKER_HOST
unset CONTAINER_HOST
}
run_compose() {
local compose
compose="$(compose_command)"
ensure_podman_runtime_env
if [[ "${compose}" == *" compose" ]]; then
local podman_bin="${compose% compose}"
"${podman_bin}" compose "$@"
return
fi
"${compose}" "$@"
}
write_default_compose() {
local name="$1"
local port="$2"
local stack_dir
local volume_dir
stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")"
cat >"${stack_dir}/compose.yaml" <<EOF
services:
app:
image: docker.io/traefik/whoami:latest
restart: unless-stopped
ports:
- "127.0.0.1:${port}:80"
volumes:
- ${volume_dir}/data:/data
EOF
}
write_manifest() {
local name="$1"
local domains="$2"
local port="$3"
local auth="$4"
local manifest
local stack_dir
local volume_dir
local route_file
manifest="$(app_manifest "${name}")"
stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")"
route_file="$(app_route_file)"
# First domain is the primary (used for APP_DOMAIN backward compat)
local primary_domain
IFS=',' read -ra domain_arr <<< "${domains}"
primary_domain="$(echo "${domain_arr[0]}" | xargs)"
cat >"${manifest}" <<EOF
APP_NAME="${name}"
APP_DOMAIN="${primary_domain}"
APP_DOMAINS="${domains}"
APP_PORT="${port}"
APP_UPSTREAM="127.0.0.1:${port}"
APP_AUTH_PROTECTED="${auth}"
APP_STACK_DIR="${stack_dir}"
APP_COMPOSE_FILE="${stack_dir}/compose.yaml"
APP_VOLUME_DIR="${volume_dir}"
APP_ROUTE_FILE="${route_file}"
EOF
}
cmd_init() {
local name="$1"
local domains="$2"
local port="$3"
local auth="${4:-true}"
validate_name "${name}"
validate_domains "${domains}"
validate_port "${port}"
[[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false"
ensure_base_dirs
local manifest
local stack_dir
local volume_dir
manifest="$(app_manifest "${name}")"
stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")"
[[ ! -f "${manifest}" ]] || fail "app '${name}' already exists"
mkdir -p "${stack_dir}" "${volume_dir}/data"
write_default_compose "${name}" "${port}"
write_manifest "${name}" "${domains}" "${port}" "${auth}"
cmd_render_route "${name}"
echo "initialized app '${name}'"
}
cmd_render_route() {
local name="$1"
validate_name "${name}"
load_app "${name}"
local auth_block=""
if [[ "${APP_AUTH_PROTECTED}" == "true" ]]; then
auth_block="${FORWARD_AUTH_BLOCK}"
fi
# Build domain list for Caddy block header.
local caddy_domains=""
local domains_str="${APP_DOMAINS:-${APP_DOMAIN}}"
IFS=',' read -ra domain_arr <<< "${domains_str}"
for d in "${domain_arr[@]}"; do
d="$(echo "${d}" | xargs)"
if [[ -n "${caddy_domains}" ]]; then
caddy_domains="${caddy_domains}, ${d}"
else
caddy_domains="${d}"
fi
done
local route_file
route_file="$(app_route_file)"
# Strip any existing block for this app from the aggregate file.
local tmp
tmp="$(mktemp)"
if [[ -f "${route_file}" ]]; then
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true
else
printf "" >"${tmp}"
fi
{
printf "# route:%s:start\n" "${name}"
printf "%s {\n" "${caddy_domains}"
if [[ -n "${auth_block}" ]]; then
printf "%s\n" "${auth_block}"
fi
printf " reverse_proxy %s\n" "${APP_UPSTREAM}"
printf "}\n"
printf "# route:%s:end\n" "${name}"
} >>"${tmp}"
mv "${tmp}" "${route_file}"
echo "rendered route ${route_file}"
}
maybe_reload_caddy() {
if [[ -x /run/current-system/sw/bin/caddy && -f /etc/caddy/Caddyfile ]]; then
/run/current-system/sw/bin/caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile || echo "warning: caddy validation failed" >&2
fi
if [[ "${EUID}" -eq 0 ]]; then
systemctl reload caddy
echo "reloaded caddy"
return
fi
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
sudo -n /run/current-system/sw/bin/systemctl reload caddy
echo "reloaded caddy via sudo"
return
fi
echo "caddy reload requires root; run: sudo systemctl reload caddy"
}
cmd_deploy() {
local name="$1"
validate_name "${name}"
load_app "${name}"
echo "Starting deployment for app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true
cmd_render_route "${name}"
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1 | systemd-cat -t panelctl -p info 2>/dev/null; then
echo "Deployment failed for app '${name}'" | systemd-cat -t panelctl -p err 2>/dev/null || true
fail "compose up failed"
fi
maybe_reload_caddy
echo "Successfully deployed app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true
echo "deployed app '${name}'"
}
cmd_restart() {
local name="$1"
validate_name "${name}"
load_app "${name}"
echo "Restarting app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true
run_compose -f "${APP_COMPOSE_FILE}" down || fail "compose down failed"
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1; then
fail "compose up failed during restart"
fi
echo "restarted app '${name}'"
}
cmd_stop() {
local name="$1"
validate_name "${name}"
load_app "${name}"
run_compose -f "${APP_COMPOSE_FILE}" down || fail "compose down failed"
echo "stopped app '${name}'"
}
cmd_status() {
local name="$1"
validate_name "${name}"
load_app "${name}"
run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \
run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \
echo "no containers running"
}
cmd_logs() {
local name="$1"
shift
validate_name "${name}"
load_app "${name}"
local tail_lines="100"
while [[ $# -gt 0 ]]; do
case "$1" in
--tail)
tail_lines="$2"
shift 2
;;
*)
shift
;;
esac
done
run_compose -f "${APP_COMPOSE_FILE}" logs --tail "${tail_lines}" 2>&1 || echo "no logs available"
}
cmd_validate_compose() {
local name="$1"
validate_name "${name}"
load_app "${name}"
if run_compose -f "${APP_COMPOSE_FILE}" config >/dev/null 2>&1; then
echo "compose file is valid"
else
local output
output="$(run_compose -f "${APP_COMPOSE_FILE}" config 2>&1 || true)"
fail "compose validation failed: ${output}"
fi
}
cmd_remove() {
local name="$1"
local keep_volumes="${2:-}"
validate_name "${name}"
load_app "${name}"
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
# Remove this app's block from the aggregate routes file.
local route_file
route_file="$(app_route_file)"
if [[ -f "${route_file}" ]]; then
local tmp
tmp="$(mktemp)"
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true
mv "${tmp}" "${route_file}"
fi
rm -f "$(app_manifest "${name}")"
rm -rf "${APP_STACK_DIR}"
if [[ "${keep_volumes}" != "--keep-volumes" ]]; then
rm -rf "${APP_VOLUME_DIR}"
fi
maybe_reload_caddy
echo "removed app '${name}'"
}
cmd_backup() {
local name="$1"
validate_name "${name}"
load_app "${name}"
ensure_base_dirs
local volume_dir
volume_dir="$(app_volume_dir "${name}")"
[[ -d "${volume_dir}" ]] || fail "volume directory '${volume_dir}' does not exist"
local timestamp
timestamp="$(date +%Y%m%d-%H%M%S)"
local backup_file="${BACKUPS_DIR}/${name}-${timestamp}.zip"
# Stop containers before backup for consistency
local was_running=false
if run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then
was_running=true
echo "stopping containers for consistent backup..."
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
fi
(cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed"
# Also include the compose file in the backup
local stack_dir
stack_dir="$(app_stack_dir "${name}")"
if [[ -f "${stack_dir}/compose.yaml" ]]; then
(cd "${stack_dir}" && zip -j "${backup_file}" compose.yaml) || true
fi
# Restart if it was running
if [[ "${was_running}" == "true" ]]; then
echo "restarting containers after backup..."
run_compose -f "${APP_COMPOSE_FILE}" up -d 2>/dev/null || true
fi
local size
size="$(du -h "${backup_file}" | cut -f1)"
echo "backup created: ${backup_file} (${size})"
}
cmd_list_backups() {
local name="$1"
validate_name "${name}"
ensure_base_dirs
local found=0
for bf in "${BACKUPS_DIR}/${name}"-*.zip; do
[[ -e "${bf}" ]] || continue
found=1
local fname size mtime
fname="$(basename "${bf}")"
size="$(du -h "${bf}" | cut -f1)"
mtime="$(stat -c '%Y' "${bf}" 2>/dev/null || stat -f '%m' "${bf}" 2>/dev/null || echo "0")"
echo "${fname} ${size} ${mtime}"
done
if [[ "${found}" -eq 0 ]]; then
echo "no backups found for '${name}'"
fi
}
cmd_restore() {
local name="$1"
local backup_file="$2"
validate_name "${name}"
load_app "${name}"
# Resolve backup file path
local full_path="${backup_file}"
if [[ ! -f "${full_path}" ]]; then
full_path="${BACKUPS_DIR}/${backup_file}"
fi
[[ -f "${full_path}" ]] || fail "backup file '${backup_file}' not found"
# Ensure it's a zip file within the backups directory
local norm_path
norm_path="$(realpath "${full_path}")"
local norm_backups
norm_backups="$(realpath "${BACKUPS_DIR}")"
[[ "${norm_path}" == "${norm_backups}"/* ]] || fail "backup file must be in the backups directory"
local volume_dir
volume_dir="$(app_volume_dir "${name}")"
# Stop containers before restore
echo "stopping containers for restore..."
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
# Clear existing volume data and extract backup
rm -rf "${volume_dir:?}"/*
mkdir -p "${volume_dir}"
(cd "${volume_dir}" && unzip -o "${norm_path}") || fail "unzip failed"
echo "restored '${name}' from $(basename "${norm_path}")"
echo "run 'panelctl deploy ${name}' to start the app"
}
cmd_list() {
ensure_base_dirs
local found=0
for mf in "${APPS_DIR}"/*.env; do
[[ -e "${mf}" ]] || continue
found=1
# shellcheck disable=SC1090
source "${mf}"
local domains="${APP_DOMAINS:-${APP_DOMAIN}}"
echo "${APP_NAME} ${domains} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}"
done
if [[ "${found}" -eq 0 ]]; then
echo "no apps found"
fi
}
cmd_show() {
local name="$1"
validate_name "${name}"
local mf
mf="$(app_manifest "${name}")"
[[ -f "${mf}" ]] || fail "app '${name}' does not exist"
cat "${mf}"
}
main() {
local cmd="${1:-}"
case "${cmd}" in
init)
[[ $# -ge 4 ]] || fail "usage: panelctl init <name> <domains> <port> [auth]"
cmd_init "$2" "$3" "$4" "${5:-true}"
;;
render-route)
[[ $# -eq 2 ]] || fail "usage: panelctl render-route <name>"
cmd_render_route "$2"
;;
deploy)
[[ $# -eq 2 ]] || fail "usage: panelctl deploy <name>"
cmd_deploy "$2"
;;
restart)
[[ $# -eq 2 ]] || fail "usage: panelctl restart <name>"
cmd_restart "$2"
;;
stop)
[[ $# -eq 2 ]] || fail "usage: panelctl stop <name>"
cmd_stop "$2"
;;
status)
[[ $# -eq 2 ]] || fail "usage: panelctl status <name>"
cmd_status "$2"
;;
logs)
[[ $# -ge 2 ]] || fail "usage: panelctl logs <name> [--tail N]"
cmd_logs "$2" "${@:3}"
;;
validate-compose)
[[ $# -eq 2 ]] || fail "usage: panelctl validate-compose <name>"
cmd_validate_compose "$2"
;;
remove)
[[ $# -ge 2 ]] || fail "usage: panelctl remove <name> [--keep-volumes]"
cmd_remove "$2" "${3:-}"
;;
backup)
[[ $# -eq 2 ]] || fail "usage: panelctl backup <name>"
cmd_backup "$2"
;;
list-backups)
[[ $# -eq 2 ]] || fail "usage: panelctl list-backups <name>"
cmd_list_backups "$2"
;;
restore)
[[ $# -eq 3 ]] || fail "usage: panelctl restore <name> <backup-file>"
cmd_restore "$2" "$3"
;;
list)
[[ $# -eq 1 ]] || fail "usage: panelctl list"
cmd_list
;;
show)
[[ $# -eq 2 ]] || fail "usage: panelctl show <name>"
cmd_show "$2"
;;
""|-h|--help|help)
usage
;;
*)
fail "unknown command '${cmd}'"
;;
esac
}
main "$@"