#!/usr/bin/env bash # # Kubaros installer # curl -fsSL https://install.kubaros.io | sudo bash # # Installs the Kubaros appliance (Docker Compose stack of pre-built GHCR images) # inside a dedicated management VM. It NEVER touches the Proxmox host and does # not modify unrelated system configuration. Idempotent: re-running updates the # stack in place. # # The config files (docker-compose.yml, Caddyfile, kubaros-update.sh) are served # statically from Plesk at https://install.kubaros.io. The application itself # ships as pre-built container images on GitHub Container Registry (ghcr.io). # set -euo pipefail KUBAROS_DIR="${KUBAROS_DIR:-/opt/kubaros}" KUBAROS_VERSION="${KUBAROS_VERSION:-latest}" # Where the static config files live (Plesk). Override for testing if needed. INSTALL_BASE="${KUBAROS_INSTALL_BASE:-https://install.kubaros.io}" # GHCR org that hosts the images. Override with KUBAROS_IMAGE_PREFIX if needed. IMAGE_PREFIX="${KUBAROS_IMAGE_PREFIX:-ghcr.io/phigi87}" # GitHub repo (owner/name) whose Releases drive the in-app self-updater. RELEASES_REPO="${KUBAROS_RELEASES_REPO:-PhiGi87/kubaros}" # Optional GitHub token (only needed for private repos / higher rate limits). GITHUB_TOKEN="${KUBAROS_GITHUB_TOKEN:-}" # Release channel: stable (default, all users) | beta (opt-in pre-releases) | # edge (your internal test VM only — newest commit on every push). UPDATE_CHANNEL="${KUBAROS_UPDATE_CHANNEL:-stable}" # GitHub username for GHCR login — required ONLY to pull PRIVATE images (e.g. an # internal test/edge build). Pair with KUBAROS_GITHUB_TOKEN (a PAT with # read:packages). Leave empty for public images. GITHUB_USER="${KUBAROS_GITHUB_USER:-}" # Provisioning mode: keep "true" (dry-run / simulated) until real Proxmox/Talos # is wired & verified on this appliance. Set KUBAROS_DRY_RUN=false for live infra. DRY_RUN="${KUBAROS_DRY_RUN:-true}" # Public domain for a real, browser-trusted Let's Encrypt certificate (e.g. # kubaros.provider.example). When set, the appliance binds the STANDARD ports # 80+443 and Caddy obtains an ACME cert automatically. Requires: a DNS A/AAAA # record pointing at this host, and ports 80+443 reachable from the internet. # Leave empty to access by IP with a self-signed cert on ports 8080/8443. DOMAIN="${KUBAROS_DOMAIN:-}" # Email used for the Let's Encrypt account (only when KUBAROS_DOMAIN is set). ACME_EMAIL="${KUBAROS_ACME_EMAIL:-}" # The Kubaros WEBSITE (license server) this appliance activates its license # against. The provider enters their KBRS- key in the console; the appliance # validates it at /api/portal/license/validate. Defaults to kubaros.io. LICENSE_SERVER="${KUBAROS_LICENSE_SERVER:-https://kubaros.io}" # Phone-home telemetry to the license server (license status, update checks, # install statistics). Only infrastructure metadata, no personal data. DSGVO # opt-out: set KUBAROS_TELEMETRY=off. TELEMETRY="${KUBAROS_TELEMETRY:-on}" c_blue="\033[1;34m"; c_green="\033[1;32m"; c_yellow="\033[1;33m"; c_red="\033[1;31m"; c_reset="\033[0m" info() { echo -e "${c_blue}==>${c_reset} $*"; } ok() { echo -e "${c_green}✓${c_reset} $*"; } warn() { echo -e "${c_yellow}!${c_reset} $*"; } err() { echo -e "${c_red}✗${c_reset} $*" >&2; } require_root() { if [[ "${EUID}" -ne 0 ]]; then err "Please run as root (sudo)."; exit 1 fi } check_os() { if [[ ! -f /etc/os-release ]]; then err "Unsupported OS (no /etc/os-release)."; exit 1; fi . /etc/os-release case "${ID}" in debian|ubuntu) ok "Detected ${PRETTY_NAME}" ;; *) warn "Untested OS '${ID}'. Debian/Ubuntu is recommended. Continuing." ;; esac } ensure_prereqs() { # Base tools the installer itself needs (the host python3 is NOT required — # keys are generated with openssl). Auto-installs the few missing packages. local missing=() for c in curl openssl; do command -v "$c" >/dev/null 2>&1 || missing+=("$c"); done if [[ ${#missing[@]} -eq 0 ]]; then return; fi info "Installing prerequisites: ${missing[*]}..." if command -v apt-get >/dev/null 2>&1; then apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl openssl ca-certificates elif command -v dnf >/dev/null 2>&1; then dnf install -y curl openssl ca-certificates elif command -v yum >/dev/null 2>&1; then yum install -y curl openssl ca-certificates else err "Could not auto-install ${missing[*]} (no apt/dnf/yum). Install them and re-run."; exit 1 fi ok "Prerequisites installed." } ensure_docker() { if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then ok "Docker & Compose present." return fi # Auto-install: a piped 'curl | bash' install can't read interactive input, # so we install Docker automatically (override with KUBAROS_SKIP_DOCKER=1). if [[ "${KUBAROS_SKIP_DOCKER:-0}" == "1" ]]; then err "Docker / Compose not found and KUBAROS_SKIP_DOCKER=1. Install Docker and re-run."; exit 1 fi info "Docker / Compose not found — installing automatically via get.docker.com..." curl -fsSL https://get.docker.com | sh || warn "get.docker.com script failed; trying distribution packages..." # Fallback for distros not yet covered by the convenience script (e.g. a very # fresh Debian 13 / trixie): install Docker + the Compose v2 plugin from apt. if ! (command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1); then if command -v apt-get >/dev/null 2>&1; then info "Installing Docker from distribution packages (apt)..." apt-get update -qq || true DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker.io docker-compose-v2 \ || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker.io docker-compose-plugin \ || true elif command -v dnf >/dev/null 2>&1; then dnf install -y docker docker-compose-plugin || true fi fi systemctl enable --now docker >/dev/null 2>&1 || true if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then ok "Docker installed." else err "Docker installation failed. Install Docker Engine + the Compose plugin manually and re-run."; exit 1 fi } gen_secret() { openssl rand -hex 32; } gen_fernet() { # A Fernet key is url-safe base64 of 32 random bytes. Generate it with openssl # so it is always valid even when the host python3 lacks the 'cryptography' # module (otherwise the backend crashes: "Fernet key must be 32 url-safe # base64-encoded bytes"). openssl rand -base64 32 | tr '+/' '-_' } setup_env() { mkdir -p "${KUBAROS_DIR}/state" cd "${KUBAROS_DIR}" local host_ip; host_ip="$(hostname -I 2>/dev/null | awk '{print $1}')" # Compute network/TLS settings based on whether a public domain is provided. local site_addr base_url http_port https_port if [[ -n "${DOMAIN}" ]]; then site_addr="${DOMAIN}"; base_url="https://${DOMAIN}" http_port="80"; https_port="443" else site_addr=":443"; [[ -n "${host_ip}" ]] && site_addr="https://${host_ip}" base_url="https://${host_ip}:8443" http_port="8080"; https_port="8443" fi if [[ -f .env ]]; then ok "Existing configuration kept (${KUBAROS_DIR}/.env)." # Add newer keys on upgrade (never touches existing secrets/passwords). grep -q '^KUBAROS_INSTALL_BASE=' .env || echo "KUBAROS_INSTALL_BASE=\"${INSTALL_BASE}\"" >> .env grep -q '^KUBAROS_SITE_ADDRESS=' .env || echo "KUBAROS_SITE_ADDRESS=\"${site_addr}\"" >> .env grep -q '^KUBAROS_ACME_EMAIL=' .env || echo "KUBAROS_ACME_EMAIL=\"${ACME_EMAIL}\"" >> .env grep -q '^KUBAROS_DOMAIN=' .env || echo "KUBAROS_DOMAIN=\"${DOMAIN}\"" >> .env grep -q '^KUBAROS_LICENSE_SERVER=' .env || echo "KUBAROS_LICENSE_SERVER=\"${LICENSE_SERVER}\"" >> .env grep -q '^KUBAROS_TELEMETRY=' .env || echo "KUBAROS_TELEMETRY=\"${TELEMETRY}\"" >> .env # If a domain is explicitly provided on re-run, switch the relevant settings # (lets the operator move from IP access to a trusted domain by re-running). if [[ -n "${DOMAIN}" ]]; then set_env_kv KUBAROS_DOMAIN "${DOMAIN}" set_env_kv KUBAROS_ACME_EMAIL "${ACME_EMAIL}" set_env_kv KUBAROS_SITE_ADDRESS "${site_addr}" set_env_kv KUBAROS_BASE_URL "${base_url}" set_env_kv KUBAROS_HTTP_PORT "${http_port}" set_env_kv KUBAROS_HTTPS_PORT "${https_port}" info "Configured domain ${DOMAIN} (Let's Encrypt on ports 80/443)." fi return fi info "Generating secure configuration..." cat > .env <> .env fi } # Generate /opt/kubaros/Caddyfile from .env. Domain => Let's Encrypt; otherwise # Caddy's internal self-signed CA. Generated locally so the TLS mode is always # syntactically correct (this is NOT fetched from the install source). write_caddyfile() { cd "${KUBAROS_DIR}" local domain email site_addr domain="$(grep '^KUBAROS_DOMAIN=' .env 2>/dev/null | cut -d'"' -f2)" email="$(grep '^KUBAROS_ACME_EMAIL=' .env 2>/dev/null | cut -d'"' -f2)" site_addr="$(grep '^KUBAROS_SITE_ADDRESS=' .env 2>/dev/null | cut -d'"' -f2)" [[ -z "${site_addr}" ]] && site_addr=":443" if [[ -n "${domain}" ]]; then info "Writing Caddyfile for ${domain} (Let's Encrypt)..." { printf '{\n' [[ -n "${email}" ]] && printf '\temail %s\n' "${email}" printf '}\n\n%s {\n\tencode gzip\n\n\thandle /api/* {\n\t\treverse_proxy backend:8001\n\t}\n\n\thandle {\n\t\treverse_proxy frontend:3000\n\t}\n}\n' "${domain}" } > Caddyfile else info "Writing Caddyfile for ${site_addr} (self-signed internal CA)..." printf '{\n\tlocal_certs\n\tauto_https disable_redirects\n}\n\n%s {\n\tencode gzip\n\n\thandle /api/* {\n\t\treverse_proxy backend:8001\n\t}\n\n\thandle {\n\t\treverse_proxy frontend:3000\n\t}\n}\n' "${site_addr}" > Caddyfile fi ok "Caddyfile written." } install_cli() { # Install a global 'kubaros' helper so the operator can run # `kubaros update|restart|logs|status|...` from anywhere. info "Installing the 'kubaros' command..." cat > /usr/local/bin/kubaros <<'CLI' #!/usr/bin/env bash # Kubaros appliance control CLI. set -euo pipefail KUBAROS_DIR="${KUBAROS_DIR:-/opt/kubaros}" cd "${KUBAROS_DIR}" 2>/dev/null || { echo "Kubaros is not installed at ${KUBAROS_DIR}." >&2; exit 1; } base="$(grep '^KUBAROS_INSTALL_BASE=' .env 2>/dev/null | cut -d'"' -f2)" base="${base:-https://install.kubaros.io}" # Echo the latest STABLE semver tag published for the backend image on GHCR, # or empty if it can't be determined. This is the authoritative "what can I # actually pull" source — it never reports a tag that doesn't exist. _ghcr_latest() { local prefix repo tok auth="" prefix="$(grep '^KUBAROS_IMAGE_PREFIX=' .env 2>/dev/null | cut -d'"' -f2)" prefix="${prefix:-ghcr.io/phigi87}" repo="${prefix#https://}"; repo="${repo#ghcr.io/}"; repo="${repo%/}/kubaros-backend" local ghuser ghtoken ghuser="$(grep '^KUBAROS_GITHUB_USER=' .env 2>/dev/null | cut -d'"' -f2)" ghtoken="$(grep '^KUBAROS_GITHUB_TOKEN=' .env 2>/dev/null | cut -d'"' -f2)" [[ -n "${ghuser}" && -n "${ghtoken}" ]] && auth="-u ${ghuser}:${ghtoken}" tok="$(curl -fsSL ${auth} "https://ghcr.io/token?scope=repository:${repo}:pull" 2>/dev/null \ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')" [[ -z "${tok}" ]] && return 0 curl -fsSL -H "Authorization: Bearer ${tok}" "https://ghcr.io/v2/${repo}/tags/list" 2>/dev/null \ | tr ',][' '\n\n\n' \ | sed -n 's/.*"\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)".*/\1/p' \ | sort -V | tail -1 } cmd="${1:-help}"; shift || true case "${cmd}" in check) cur="$(grep '^KUBAROS_VERSION=' .env 2>/dev/null | cut -d'"' -f2)"; cur="${cur:-latest}" latest="$(_ghcr_latest)" echo "Installed version : ${cur}" echo "Latest available : ${latest:-unknown (could not reach ghcr.io)}" if [[ -n "${latest}" && "${cur}" != "${latest}" ]]; then echo; echo "→ An update is available. Apply it with: kubaros update" elif [[ -n "${latest}" ]]; then echo; echo "✓ You are on the latest version." fi ;; update|upgrade) # `kubaros update` -> update to the latest published version # `kubaros update --check` -> only report (no changes); alias of `kubaros check` # `kubaros update X.Y.Z` -> pin a specific version # `kubaros update latest` -> follow the rolling 'latest' tag check_only=false; target="" for a in "$@"; do case "${a}" in --check|-c) check_only=true ;; --*) ;; # ignore unknown flags *) target="${a}" ;; esac done cur="$(grep '^KUBAROS_VERSION=' .env 2>/dev/null | cut -d'"' -f2)"; cur="${cur:-latest}" latest="$(_ghcr_latest)" if ${check_only}; then echo "Installed version : ${cur}" echo "Latest available : ${latest:-unknown (could not reach ghcr.io)}" [[ -n "${latest}" && "${cur}" != "${latest}" ]] && { echo; echo "→ Run 'kubaros update' to install ${latest}."; } exit 0 fi # Refresh stack files (compose + host updater) from the install source. The # Caddyfile is generated by the installer (TLS depends on your domain/IP) and # is intentionally left as-is — re-run the installer to change domain/TLS. for f in docker-compose.yml kubaros-update.sh; do if curl -fsSL "${base}/${f}" -o "${f}.new" 2>/dev/null; then mv "${f}.new" "${f}"; else rm -f "${f}.new"; echo "warn: could not refresh ${f} from ${base} (keeping current)"; fi done chmod +x kubaros-update.sh 2>/dev/null || true newver="${target:-${latest:-latest}}" prev="${cur}" if grep -q '^KUBAROS_VERSION=' .env; then sed -i "s|^KUBAROS_VERSION=.*|KUBAROS_VERSION=\"${newver}\"|" .env; else echo "KUBAROS_VERSION=\"${newver}\"" >> .env; fi echo "==> Updating to ${newver}..." if docker compose pull; then docker compose up -d echo "✓ Kubaros updated to ${newver}." else echo "✗ Could not pull images for '${newver}' (the tag may not exist at ghcr.io)." >&2 if [[ "${newver}" != "latest" && -n "${latest}" && "${newver}" != "${latest}" ]]; then echo "→ Retrying with the latest published version (${latest})..." >&2 sed -i "s|^KUBAROS_VERSION=.*|KUBAROS_VERSION=\"${latest}\"|" .env if docker compose pull && docker compose up -d; then echo "✓ Kubaros updated to ${latest}."; exit 0; fi fi # Revert the pin so the stack keeps running on the previous version. sed -i "s|^KUBAROS_VERSION=.*|KUBAROS_VERSION=\"${prev}\"|" .env docker compose up -d || true echo "✗ Update aborted; still on ${prev}. Run 'kubaros check' to see available versions." >&2 exit 1 fi ;; restart) docker compose restart "$@" ;; start|up) docker compose up -d ;; stop|down) docker compose down ;; status|ps) docker compose ps ;; logs) docker compose logs -f "$@" ;; pull) docker compose pull ;; config) "${EDITOR:-nano}" "${KUBAROS_DIR}/.env" ;; url) grep '^KUBAROS_BASE_URL=' .env | cut -d'"' -f2 ;; version) grep '^KUBAROS_VERSION=' .env | cut -d'"' -f2 ;; help|--help|-h|*) cat </dev/null 2>&1; then warn "systemd not found — host updater not installed (CLI updates still work)." return fi info "Installing host self-updater (systemd path unit)..." cat > /etc/systemd/system/kubaros-update.service < /etc/systemd/system/kubaros-update.path </dev/null 2>&1 || warn "Could not enable kubaros-update.path." ok "Host self-updater installed." } ghcr_login() { # Authenticate to GHCR — required ONLY for PRIVATE images (e.g. an internal # test/edge build). No-op for public packages. Reads the token from the # generated .env so re-runs and the host updater stay consistent. cd "${KUBAROS_DIR}" local user token user="$(grep '^KUBAROS_GITHUB_USER=' .env 2>/dev/null | cut -d'"' -f2)" token="$(grep '^KUBAROS_GITHUB_TOKEN=' .env 2>/dev/null | cut -d'"' -f2)" if [[ -n "${token}" && -n "${user}" ]]; then info "Logging in to ghcr.io for private images (user: ${user})..." if echo "${token}" | docker login ghcr.io -u "${user}" --password-stdin >/dev/null 2>&1; then ok "Authenticated to ghcr.io." else warn "GHCR login failed — public images still pull, private ones will not." fi fi } start_stack() { cd "${KUBAROS_DIR}" info "Pulling images and starting the stack..." docker compose pull docker compose up -d ok "Kubaros is starting." } print_summary() { cd "${KUBAROS_DIR}" local url; url="$(grep KUBAROS_BASE_URL .env | cut -d'"' -f2)" echo ok "Kubaros (${KUBAROS_VERSION}) installed." echo -e " Console: ${c_green}${url}${c_reset}" echo -e " Open the URL and complete the setup wizard: create your admin account," echo -e " connect & validate your Proxmox, and register your public IPs." echo -e " Images: ${IMAGE_PREFIX}/kubaros-{backend,frontend}:${KUBAROS_VERSION}" echo -e " Data dir: ${KUBAROS_DIR}" echo -e " Manage: ${c_green}kubaros status${c_reset} · ${c_green}kubaros update${c_reset} · ${c_green}kubaros logs${c_reset} · ${c_green}kubaros restart${c_reset}" echo -e " Update: re-run this installer any time, or run ${c_green}kubaros update${c_reset}." echo } main() { echo -e "${c_blue}Kubaros installer${c_reset} (${KUBAROS_VERSION})" require_root check_os ensure_prereqs ensure_docker setup_env fetch_stack write_caddyfile install_cli install_host_updater ghcr_login start_stack print_summary } main "$@"