#!/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 [auth] panelctl render-route panelctl deploy panelctl restart panelctl stop panelctl status panelctl logs [--tail N] panelctl remove [--keep-volumes] panelctl backup panelctl list-backups panelctl restore panelctl validate-compose panelctl list panelctl show 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 } log() { local level="${1:-info}" local msg="${2:-}" echo "${msg}" | systemd-cat -t panelctl -p "${level}" 2>/dev/null || true } 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" <"${manifest}" <"${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}" install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" log info "rendered route ${route_file}" } # maybe_reload_caddy() { # # Try Caddy admin API first (no root required) # if curl -sf -X POST http://localhost:2019/config/ >/dev/null 2>&1; then # log info "reloaded caddy via admin API" # return # fi # # Validate config if caddy binary is available # 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 # # Fall back to systemctl (sudo if needed) # if [[ "${EUID}" -eq 0 ]]; then # systemctl reload caddy && log info "reloaded caddy" # elif sudo -n /run/current-system/sw/bin/systemctl reload caddy 2>/dev/null; then # log info "reloaded caddy via sudo" # else # log warning "caddy reload requires root; run: sudo systemctl reload caddy" # fi # } cmd_deploy() { local name="$1" validate_name "${name}" load_app "${name}" log info "Starting deployment for app '${name}'" 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 log err "Deployment failed for app '${name}'" fail "compose up failed" fi maybe_reload_caddy log info "Successfully deployed app '${name}'" log info "deployed app '${name}'" } cmd_restart() { local name="$1" validate_name "${name}" load_app "${name}" log info "Restarting app '${name}'" 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 log info "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" log info "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 || \ log info "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 || log info "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 log info "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 install -m 0664 -o reudy -g panelroutes "${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 log info "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 log info "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 log info "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)" log info "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 log info "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 log info "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" log info "restored '${name}' from $(basename "${norm_path}")" log info "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 log info "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 [auth]" cmd_init "$2" "$3" "$4" "${5:-true}" ;; render-route) [[ $# -eq 2 ]] || fail "usage: panelctl render-route " cmd_render_route "$2" ;; deploy) [[ $# -eq 2 ]] || fail "usage: panelctl deploy " cmd_deploy "$2" ;; restart) [[ $# -eq 2 ]] || fail "usage: panelctl restart " cmd_restart "$2" ;; stop) [[ $# -eq 2 ]] || fail "usage: panelctl stop " cmd_stop "$2" ;; status) [[ $# -eq 2 ]] || fail "usage: panelctl status " cmd_status "$2" ;; logs) [[ $# -ge 2 ]] || fail "usage: panelctl logs [--tail N]" cmd_logs "$2" "${@:3}" ;; validate-compose) [[ $# -eq 2 ]] || fail "usage: panelctl validate-compose " cmd_validate_compose "$2" ;; remove) [[ $# -ge 2 ]] || fail "usage: panelctl remove [--keep-volumes]" cmd_remove "$2" "${3:-}" ;; backup) [[ $# -eq 2 ]] || fail "usage: panelctl backup " cmd_backup "$2" ;; list-backups) [[ $# -eq 2 ]] || fail "usage: panelctl list-backups " cmd_list_backups "$2" ;; restore) [[ $# -eq 3 ]] || fail "usage: panelctl restore " cmd_restore "$2" "$3" ;; list) [[ $# -eq 1 ]] || fail "usage: panelctl list" cmd_list ;; show) [[ $# -eq 2 ]] || fail "usage: panelctl show " cmd_show "$2" ;; ""|-h|--help|help) usage ;; *) fail "unknown command '${cmd}'" ;; esac } main "$@"