#!/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" ENV_DIR="${STATE_DIR}/env" BACKUPS_DIR="${BASE_DIR}/backups" # Set by load_app: compose file arguments, and the app's environment variables # as KEY=VALUE words (passed to compose via env(1), never sourced). COMPOSE_ARGS=() APP_ENV_ARGS=() APP_ENV_KEYS=() 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 } ' validate_route_entry() { local entry="$1" # Format: domain|upstream[/path] or domain|upstream (path is optional) IFS='|' read -r domain upstream path <<< "${entry}" [[ -n "${domain}" ]] || fail "empty domain in route entry '${entry}'" [[ -n "${upstream}" ]] || fail "empty upstream in route entry '${entry}'" validate_single_domain "${domain}" # Validate upstream has a port local upstream_port="${upstream##*:}" [[ "${upstream_port}" =~ ^[0-9]+$ ]] || fail "upstream '${upstream}' missing numeric port in route entry '${entry}'" validate_port "${upstream_port}" if [[ -n "${path}" ]]; then [[ "${path}" == /* ]] || fail "path '${path}' must start with / in route entry '${entry}'" fi } validate_routes() { local routes_str="$1" IFS=',' read -ra entries <<< "${routes_str}" [[ ${#entries[@]} -ge 1 ]] || fail "at least one route is required" for entry in "${entries[@]}"; do entry="$(echo "${entry}" | xargs)" validate_route_entry "${entry}" done } usage() { cat <<'EOF' panelctl - minimal app panel helper Usage: panelctl init "|[,...]" [auth] panelctl set-routes "|[,...]" 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 volume-clear panelctl validate-compose panelctl list panelctl show Each route is a domain|upstream pair. Upstream is host:port. Multiple routes are comma-separated: panelctl init myapp "app.example.com|127.0.0.1:18080,api.example.com|127.0.0.1:18081" true Wildcard domains are supported (requires DNS challenge in Caddy): panelctl init myapp "*.example.com|127.0.0.1:18080" true Examples: panelctl init whoami "whoami.reudy.net|127.0.0.1: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" } # The routes file is shared by all apps and rewritten read-modify-write, so # concurrent panelctl runs (the API handles requests in parallel) must take turns. routes_lock() { exec 9>"${ROUTES_DIR}/.routes.lock" if command -v flock >/dev/null 2>&1; then flock -w 30 9 || fail "timed out waiting for the routes file lock" fi } routes_unlock() { exec 9>&- } load_app() { local name="$1" local manifest manifest="$(app_manifest "${name}")" [[ -f "${manifest}" ]] || fail "app '${name}' does not exist" # shellcheck disable=SC1090 source "${manifest}" # Backward compat: migrate old APP_DOMAIN/APP_PORT/APP_UPSTREAM to APP_ROUTES if [[ -z "${APP_ROUTES:-}" && -n "${APP_DOMAIN:-}" ]]; then local upstream="${APP_UPSTREAM:-127.0.0.1:${APP_PORT:-18080}}" local routes="" 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 "${routes}" ]]; then routes="${routes},${d}|${upstream}" else routes="${d}|${upstream}" fi done APP_ROUTES="${routes}" fi load_app_env "${name}" } app_env_file() { echo "${ENV_DIR}/$1.env" } # Generated compose override that passes the app's variables into every service. app_env_override() { echo "${STACKS_DIR}/$1/.panel-env.yaml" } load_app_env() { local name="$1" local file line key override file="$(app_env_file "${name}")" APP_ENV_ARGS=() APP_ENV_KEYS=() if [[ -f "${file}" ]]; then while IFS= read -r line || [[ -n "${line}" ]]; do [[ -z "${line}" || "${line}" == \#* || "${line}" != *=* ]] && continue key="${line%%=*}" [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue APP_ENV_ARGS+=("${line}") APP_ENV_KEYS+=("${key}") done <"${file}" fi COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}") override="$(app_env_override "${name}")" if [[ -f "${override}" ]]; then COMPOSE_ARGS+=(-f "${override}") fi } # (Re)generate the env override before containers are created. Variables are # always available for ${VAR} interpolation; with APP_ENV_INJECT (default true) # every service also receives them. Bare keys make compose read the values from # its own environment, so values never have to be quoted into YAML. prepare_env_override() { local name="$1" local override services svc key tmp override="$(app_env_override "${name}")" if [[ ${#APP_ENV_KEYS[@]} -eq 0 || "${APP_ENV_INJECT:-true}" != "true" ]]; then rm -f "${override}" COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}") return fi services="$(run_compose -f "${APP_COMPOSE_FILE}" config --services 2>/dev/null)" \ || fail "could not list compose services to pass environment variables (is the compose file valid?)" tmp="$(mktemp)" { echo "# Generated by panelctl from the app's environment variables. Do not edit." echo "services:" while IFS= read -r svc; do [[ "${svc}" =~ ^[A-Za-z0-9._-]+$ ]] || continue printf ' "%s":\n environment:\n' "${svc}" for key in "${APP_ENV_KEYS[@]}"; do printf ' - %s\n' "${key}" done done <<<"${services}" } >"${tmp}" install -m 0640 "${tmp}" "${override}" rm -f "${tmp}" COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}" -f "${override}") } 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}" env "${APP_ENV_ARGS[@]}" "${podman_bin}" compose "$@" return fi env "${APP_ENV_ARGS[@]}" "${compose}" "$@" } write_default_compose() { local name="$1" local routes="$2" local stack_dir local volume_dir stack_dir="$(app_stack_dir "${name}")" volume_dir="$(app_volume_dir "${name}")" # Use first route's upstream port for the default compose mapping local first_route="${routes%%,*}" local first_upstream="${first_route#*|}" local container_port="${first_upstream##*:}" cat >"${stack_dir}/compose.yaml" <"${manifest}" <"${tmp}" || true else printf "" >"${tmp}" fi { printf "# route:%s:start\n" "${name}" IFS=',' read -ra route_entries <<< "${APP_ROUTES}" for entry in "${route_entries[@]}"; do entry="$(echo "${entry}" | xargs)" IFS='|' read -r domain upstream path <<< "${entry}" # If upstream is empty (no second pipe), this is the old format if [[ -z "${upstream}" ]]; then upstream="${path}" path="" fi if [[ -n "${path}" ]]; then if [[ -n "${auth_block}" ]]; then printf "%s {\n%s reverse_proxy %s %s\n}\n" "${domain}" "${auth_block}" "${path}" "${upstream}" else printf "%s {\n reverse_proxy %s %s\n}\n" "${domain}" "${path}" "${upstream}" fi else if [[ -n "${auth_block}" ]]; then printf "%s {\n%s reverse_proxy %s\n}\n" "${domain}" "${auth_block}" "${upstream}" else printf "%s {\n reverse_proxy %s\n}\n" "${domain}" "${upstream}" fi fi done printf "# route:%s:end\n" "${name}" } >>"${tmp}" install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" rm -f "${tmp}" routes_unlock log info "rendered route ${route_file}" } cmd_deploy() { local name="$1" validate_name "${name}" load_app "${name}" log info "Starting deployment for app '${name}'" cmd_render_route "${name}" prepare_env_override "${name}" # Capture compose output so callers (the web UI) can show why a deploy failed, # and still forward it to the journal. local output if ! output="$(run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1)"; then printf '%s\n' "${output}" | systemd-cat -t panelctl -p err 2>/dev/null || true printf '%s\n' "${output}" >&2 log err "Deployment failed for app '${name}'" fail "compose up failed" fi printf '%s\n' "${output}" | systemd-cat -t panelctl -p info 2>/dev/null || true printf '%s\n' "${output}" log info "Successfully deployed app '${name}'" } cmd_restart() { local name="$1" validate_name "${name}" load_app "${name}" log info "Restarting app '${name}'" run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" prepare_env_override "${name}" if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 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 "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" log info "stopped app '${name}'" } cmd_status() { local name="$1" validate_name "${name}" load_app "${name}" run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null || \ run_compose "${COMPOSE_ARGS[@]}" 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 "${COMPOSE_ARGS[@]}" 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 "${COMPOSE_ARGS[@]}" config >/dev/null 2>&1; then log info "compose file is valid" else local output output="$(run_compose "${COMPOSE_ARGS[@]}" 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 "${COMPOSE_ARGS[@]}" down --remove-orphans 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 routes_lock 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}" rm -f "${tmp}" routes_unlock fi rm -f "$(app_manifest "${name}")" "$(app_env_file "${name}")" rm -rf "${APP_STACK_DIR}" if [[ "${keep_volumes}" != "--keep-volumes" ]]; then rm -rf "${APP_VOLUME_DIR}" fi log info "removed app '${name}'" } cmd_set_routes() { local name="$1" local routes="$2" local manifest validate_name "${name}" validate_routes "${routes}" manifest="$(app_manifest "${name}")" [[ -f "${manifest}" ]] || fail "app '${name}' does not exist" # Update APP_ROUTES in the manifest file, strip old fields, preserve others local tmp tmp="$(mktemp)" local found_routes=false while IFS= read -r line; do case "${line}" in APP_ROUTES=*) printf 'APP_ROUTES="%s"\n' "${routes}" >> "${tmp}" found_routes=true ;; APP_DOMAIN=*|APP_DOMAINS=*|APP_PORT=*|APP_UPSTREAM=*) # Strip old format fields ;; *) printf '%s\n' "${line}" >> "${tmp}" ;; esac done < "${manifest}" if ! "${found_routes}"; then printf 'APP_ROUTES="%s"\n' "${routes}" >> "${tmp}" fi install -m 0664 "${tmp}" "${manifest}" # Re-render Caddy routes (auto-reloads via systemd.path watcher) cmd_render_route "${name}" log info "updated routes for app '${name}'. Edit compose file if new ports need exposing." } 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 "${COMPOSE_ARGS[@]}" 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 "${COMPOSE_ARGS[@]}" 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 "${COMPOSE_ARGS[@]}" 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}" load_app "${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_volume_clear() { local name="$1" validate_name "${name}" load_app "${name}" log info "clearing volume data for app '${name}'" run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true local data_dir="${APP_VOLUME_DIR}/data" if [[ -d "${data_dir}" ]]; then rm -rf "${data_dir:?}"/* rm -rf "${data_dir:?}"/.[!.]* 2>/dev/null || true fi mkdir -p "${APP_VOLUME_DIR}/data" log info "volume data cleared for app '${name}'" } 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 "${COMPOSE_ARGS[@]}" 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_inspect_volumes() { local name="$1" validate_name "${name}" load_app "${name}" echo "default|${APP_VOLUME_DIR}/data" local podman_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 [[ -n "${podman_bin}" ]]; then "${podman_bin}" volume ls --filter label=com.docker.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true "${podman_bin}" volume ls --filter label=io.podman.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true # grep exits 1 when there are no named volumes; that is not an error. fi | sort -u | grep -v '^$' || true } cmd_list() { ensure_base_dirs local found=0 for mf in "${APPS_DIR}"/*.env; do [[ -e "${mf}" ]] || continue found=1 # shellcheck disable=SC1090 source /dev/null # reset any leftover variables unset APP_REPO_URL APP_REPO_BRANCH APP_REPO_DIR 2>/dev/null || true source "${mf}" # Backward compat: build APP_ROUTES from old format local routes="${APP_ROUTES:-}" if [[ -z "${routes}" && -n "${APP_DOMAIN:-}" ]]; then local upstream="${APP_UPSTREAM:-127.0.0.1:${APP_PORT:-18080}}" 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 "${routes}" ]]; then routes="${routes},${d}|${upstream}" else routes="${d}|${upstream}" fi done fi # Show abbreviated: first route's domain + upstream, and count local first_route="${routes%%,*}" local route_count=1 if [[ "${routes}" == *","* ]]; then route_count="$(( $(grep -o ',' <<< "${routes}" | wc -l) + 1 ))" fi local repo_info="${APP_REPO_URL:-}" echo "${APP_NAME} ${first_route} routes=${route_count} auth=${APP_AUTH_PROTECTED} ${repo_info}" 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 3 ]] || fail "usage: panelctl init [auth]" cmd_init "$2" "$3" "${4:-true}" ;; set-routes) [[ $# -eq 3 ]] || fail "usage: panelctl set-routes " cmd_set_routes "$2" "$3" ;; 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" ;; volume-clear) [[ $# -eq 2 ]] || fail "usage: panelctl volume-clear " cmd_volume_clear "$2" ;; inspect-volumes) [[ $# -eq 2 ]] || fail "usage: panelctl inspect-volumes " cmd_inspect_volumes "$2" ;; 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 "$@"