#!/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" 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 stop panelctl remove [--keep-volumes] panelctl list panelctl show Examples: panelctl init whoami whoami.srazka.com 18080 true panelctl deploy whoami EOF } fail() { echo "error: $*" >&2 exit 1 } ensure_base_dirs() { mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" } validate_name() { local name="$1" [[ "${name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "invalid name '${name}' (use lowercase slug)" } validate_domain() { local domain="$1" [[ "${domain}" =~ ^[A-Za-z0-9.-]+$ ]] || fail "invalid domain '${domain}'" [[ "${domain}" == *.* ]] || fail "domain must include a dot" } 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}" } app_route_file() { local name="$1" 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 # Avoid inherited docker/podman remote host env from external callers. 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" "${APP_DOMAIN}" 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}" "${APP_ROUTE_FILE}" echo "rendered route ${APP_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 cmd_render_route "${name}" if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1 | systemd-cat -t panelctl -p info; then echo "Deployment failed for app '${name}'" | systemd-cat -t panelctl -p err fail "compose up failed" fi maybe_reload_caddy echo "Successfully deployed app '${name}'" | systemd-cat -t panelctl -p info echo "deployed 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_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. if [[ -f "${APP_ROUTE_FILE}" ]]; then local tmp tmp="$(mktemp)" sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${APP_ROUTE_FILE}" >"${tmp}" || true mv "${tmp}" "${APP_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_list() { ensure_base_dirs local found=0 for mf in "${APPS_DIR}"/*.env; do [[ -e "${mf}" ]] || continue found=1 # shellcheck disable=SC1090 source "${mf}" echo "${APP_NAME} ${APP_DOMAIN} ${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 [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" ;; stop) [[ $# -eq 2 ]] || fail "usage: panelctl stop " cmd_stop "$2" ;; remove) [[ $# -ge 2 ]] || fail "usage: panelctl remove [--keep-volumes]" cmd_remove "$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 "$@"