diff --git a/README.md b/README.md index 150e1a8..e865528 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,10 @@ These scripts automate the deployment of personal infrastructure components. The | [`gitea-runner/install.sh`](gitea-runner/) | Gitea Act Runner on Alpine LXC (Proxmox) | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea-runner/install.sh)"` | | [`openbao/install.sh`](openbao/) | OpenBao secrets manager on Alpine LXC (Proxmox) | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/openbao/install.sh)"` | | [`ferretdb/install.sh`](ferretdb/) | FerretDB (MongoDB-compatible) on Debian LXC (Proxmox) | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/ferretdb/install.sh)"` | +| [`komodo/install.sh`](komodo/) | Komodo (Docker + MongoDB) on Alpine VM | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/komodo/install.sh)"` | ### Requirements -- Fresh Debian 12/13 installation (proxy, netlab) or Proxmox VE host (gitea-runner, openbao, ferretdb) -- User with sudo privileges (do not run as root) — except gitea-runner, openbao and ferretdb which run as root on Proxmox +- Fresh Debian 12/13 installation (proxy, netlab) or Proxmox VE host (gitea-runner, openbao, ferretdb) or Alpine VM (komodo) +- User with sudo privileges (do not run as root) — except gitea-runner, openbao, ferretdb, and komodo which run as root - Internet access diff --git a/komodo/README.md b/komodo/README.md new file mode 100644 index 0000000..98c4df0 --- /dev/null +++ b/komodo/README.md @@ -0,0 +1,94 @@ +# Komodo + +Automated installation script for [Komodo](https://komo.do) — Docker + a MongoDB-backed Core + Periphery stack — running inside an **Alpine Linux VM**. + +Follows the upstream [MongoDB quick start](https://komo.do/docs/setup/mongo): MongoDB stores all +resource configuration, audit logs, users, and system state. The Core exposes the API/UI on +loopback only; [Tailscale](https://tailscale.com) (running on the VM) acts as the reverse +proxy and terminates TLS via tailnet certificates. + +### Why a VM, not an LXC? + +Komodo Periphery binds `/var/run/docker.sock` and `/proc` from the host into a container so it +can manage other containers and report system stats. In an unprivileged LXC this clashes with +the cgroup / docker-in-docker constraints Proxmox imposes. A regular VM keeps it boring — the +script can just run `apk add docker` and let dockerd own the kernel namespace. + +### Features + +| Context | Action | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Fresh Alpine VM (no `/opt/komodo/compose.env`) | Enables the `community` repo, installs Docker + Tailscale, generates secrets, writes compose files, pulls + ups | +| Re-run on an existing install (`/opt/komodo/compose.env` already there) | Preserves the generated secrets, re-pulls the latest images, and recreates the stack | + +### Requirements + +- Alpine Linux **VM** (`/etc/alpine-release` must exist), not an LXC +- Internet access from the VM +- Run as **root** (enforced) + +### Usage + +#### Install / update + +```bash +bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/komodo/install.sh)" +``` + +The script prints the generated `KOMODO_DATABASE_PASSWORD`, `KOMODO_WEBHOOK_SECRET`, and +`KOMODO_JWT_SECRET` at the end. They are also written to `/opt/komodo/compose.env` +(`chmod 600`, root-only). + +The initial admin user is `admin` / `changeme` — change it from the UI immediately after first +login. + +#### Customisation + +Every parameter is exposed as an environment variable: + +| Variable | Default | Description | +| -------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `KOMODO_DIR` | `/opt/komodo` | Where the compose files and env file live | +| `KOMODO_HOSTNAME` | `komodo` | Hostname used when running `tailscale up` | +| `KOMODO_LISTEN_ADDR` | `127.0.0.1:9120` | Host-side bind for Core's HTTP listener. Loopback by default — Tailscale fronts it. | +| `KOMODO_HOST` | `https://komodo.taila5ad8.ts.net` | Written verbatim into `compose.env`; used by Komodo for OAuth / webhook URL suggestions. Set this to whatever your tailnet (or external reverse proxy) is. | +| `TS_AUTHKEY` | _(unset)_ | Pre-auth key (generate at ). If unset, finish `tailscale up` manually inside the VM. | + +All other Komodo / Periphery variables are written with the upstream defaults — edit +`/opt/komodo/compose.env` if you need to tune them, then: + +```bash +cd /opt/komodo +docker compose --env-file compose.env -f mongo.compose.yaml up -d +``` + +#### Tailscale reverse proxy + +Komodo Core binds to `127.0.0.1:9120` only — Tailscale (running on the VM, **not** in a +container) is the reverse proxy and terminates TLS via tailnet certificates. + +If `TS_AUTHKEY` was supplied at install time, the script runs `tailscale up` and +`tailscale serve --bg --https=443 http://127.0.0.1:9120` automatically. Komodo then becomes +reachable at `https://..ts.net` (matching `KOMODO_HOST`). + +Otherwise, finish setup manually inside the VM: + +```bash +tailscale up --ssh --hostname komodo +tailscale serve --bg --https=443 http://127.0.0.1:9120 +tailscale status # confirms the tailnet FQDN +``` + +> HTTPS in `tailscale serve` requires HTTPS to be enabled on your tailnet +> (Admin console → DNS → HTTPS Certificates). + +### Architecture + +- **OS**: Alpine VM with the `community` apk repo enabled (required for `docker`) +- **Engine**: `docker` + `docker-cli-compose` (the `docker compose` v2 plugin), OpenRC service `docker` enabled at boot +- **Stack**: `mongo`, `ghcr.io/moghtech/komodo-core:2`, `ghcr.io/moghtech/komodo-periphery:2` — see [upstream `mongo.compose.yaml`](https://komo.do/docs/setup/mongo) +- **Compose files**: `/opt/komodo/mongo.compose.yaml` + `/opt/komodo/compose.env` (`chmod 600`) +- **Network**: Core's `ports:` mapping is rewritten to `${KOMODO_LISTEN_ADDR}:9120` so only the loopback (and Tailscale serve) can reach it; Periphery has no host port +- **Reverse proxy**: Tailscale runs natively on the VM (not in a container) and publishes the loopback listener via `tailscale serve --https=443` +- **Secrets**: `KOMODO_DATABASE_PASSWORD`, `KOMODO_WEBHOOK_SECRET`, `KOMODO_JWT_SECRET` are auto-generated (`openssl rand -hex …`) on first install and preserved across re-runs +- **Logs**: Docker `json-file` driver capped at `max-size=10m`, `max-file=3` per container (`/etc/docker/daemon.json`) diff --git a/komodo/install.sh b/komodo/install.sh new file mode 100755 index 0000000..4ebc2cb --- /dev/null +++ b/komodo/install.sh @@ -0,0 +1,463 @@ +#!/bin/bash +# install.sh - Komodo (Docker + MongoDB) on an Alpine VM +# Usage (from inside the Alpine VM, as root): +# bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/komodo/install.sh)" +# +# Unlike the openbao/ferretdb scripts, this is targeted at an Alpine *VM*, not +# an LXC: there is no Proxmox-host orchestration mode. The script just installs +# Docker, pulls Komodo (Core + Periphery + MongoDB) via docker compose, and +# fronts it on the tailnet via `tailscale serve --https=443`. +# +# Re-running the script is safe: existing /opt/komodo/compose.env secrets are +# preserved, and `docker compose up -d --pull always` upgrades the images. + +set -euo pipefail + +# --- Config (override via environment) --- +KOMODO_DIR="${KOMODO_DIR:-/opt/komodo}" +KOMODO_HOSTNAME="${KOMODO_HOSTNAME:-komodo}" +# Komodo Core publishes its HTTP listener on this address — kept on loopback +# only; the tailnet reaches it through `tailscale serve`. +KOMODO_LISTEN_ADDR="${KOMODO_LISTEN_ADDR:-127.0.0.1:9120}" +# `KOMODO_HOST` ends up in the compose.env verbatim. Default is the tailnet +# FQDN you asked for; override to point Komodo at a different reverse proxy. +KOMODO_HOST="${KOMODO_HOST:-https://komodo.taila5ad8.ts.net}" +# Optional: pre-authorise the VM's Tailscale non-interactively. +# Generate at https://login.tailscale.com/admin/settings/keys +TS_AUTHKEY="${TS_AUTHKEY:-}" + +# --- Colors --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Logs go to stderr so callers can safely use $(fn) without capturing log noise. +log_info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; } +log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + log_error "This script must be run as root (current uid: $(id -u))." + exit 1 + fi +} + +require_alpine() { + if [[ ! -f /etc/alpine-release ]]; then + log_error "This script targets Alpine Linux (no /etc/alpine-release found)." + exit 1 + fi +} + +# Hex secret of the requested byte length. +gen_secret() { + local bytes="${1:-32}" + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex "$bytes" + else + head -c "$bytes" /dev/urandom | od -An -tx1 | tr -d ' \n' + fi +} + +# ============================================================ +# Docker — Alpine install per https://wiki.alpinelinux.org/wiki/Docker +# ============================================================ +install_docker() { + log_info "Enabling the Alpine 'community' repository..." + # The community repo holds the docker package. The default repositories file + # carries a commented `community` line; uncomment it (idempotent). + sed -i -E 's|^#(.*/community)$|\1|' /etc/apk/repositories + + log_info "Updating package index..." + apk update >/dev/null + + log_info "Installing Docker + Compose plugin..." + # docker-cli-compose provides `docker compose` (v2 plugin), which is what the + # Komodo upstream docs assume. openrc is normally already on the VM but + # listing it explicitly keeps this script self-contained. + apk add --no-cache docker docker-cli-compose openrc >/dev/null + + log_info "Enabling dockerd at boot..." + rc-update add docker default >/dev/null + + if ! rc-service docker status >/dev/null 2>&1; then + log_info "Starting dockerd..." + rc-service docker start >/dev/null + else + log_info "dockerd already running." + fi + + # Sanity check — fail fast if the daemon never came up. + local tries=0 + until docker info >/dev/null 2>&1; do + tries=$((tries + 1)) + if (( tries > 15 )); then + log_error "dockerd did not become ready after 15s — check /var/log/docker.log" + exit 1 + fi + sleep 1 + done +} + +# ============================================================ +# Tailscale — install and front Komodo with `tailscale serve` +# ============================================================ +install_tailscale() { + if command -v tailscale >/dev/null 2>&1; then + log_info "Tailscale already installed." + return 0 + fi + log_info "Installing Tailscale from the Alpine community repo..." + apk add --no-cache tailscale >/dev/null + rc-update add tailscale default >/dev/null 2>&1 || true + rc-service tailscale start >/dev/null 2>&1 \ + || log_warn "tailscaled failed to start — check /var/log/tailscaled.log" +} + +configure_tailscale_proxy() { + if ! command -v tailscale >/dev/null 2>&1; then + log_warn "tailscale CLI not found, skipping reverse-proxy setup." + return 0 + fi + + local backend_state + backend_state=$(tailscale status --json 2>/dev/null | jq -r '.BackendState // "unknown"') + if [[ "$backend_state" != "Running" ]]; then + if [[ -n "$TS_AUTHKEY" ]]; then + log_info "Bringing Tailscale up with provided auth key..." + tailscale up --authkey "$TS_AUTHKEY" --ssh --hostname "$KOMODO_HOSTNAME" \ + || log_warn "tailscale up failed — run it manually inside the VM." + else + log_warn "Tailscale not authenticated and TS_AUTHKEY was not supplied." + log_warn "Finish setup with: tailscale up --ssh --hostname ${KOMODO_HOSTNAME}" + log_warn "Then publish Komodo with: tailscale serve --bg --https=443 http://${KOMODO_LISTEN_ADDR}" + return 0 + fi + fi + + if tailscale serve status 2>/dev/null | grep -q "${KOMODO_LISTEN_ADDR}"; then + log_info "Tailscale serve already publishes http://${KOMODO_LISTEN_ADDR}." + else + log_info "Publishing Komodo on the tailnet via 'tailscale serve' (HTTPS:443)..." + tailscale serve --bg --https=443 "http://${KOMODO_LISTEN_ADDR}" \ + || log_warn "tailscale serve failed — enable HTTPS on your tailnet and retry." + fi +} + +# ============================================================ +# Komodo — compose files in $KOMODO_DIR +# ============================================================ + +# Pulls the existing value of $1 out of compose.env (if any). Used so re-runs +# preserve the password and secrets generated the first time around. +read_env_var() { + local key="$1" + local env_file="${KOMODO_DIR}/compose.env" + [[ -f "$env_file" ]] || return 0 + awk -F= -v k="$key" '$1 == k { sub(/^[^=]*=/, ""); gsub(/^"|"$/, ""); print; exit }' "$env_file" +} + +write_compose_files() { + log_info "Provisioning ${KOMODO_DIR}..." + mkdir -p "$KOMODO_DIR" + chmod 750 "$KOMODO_DIR" + + # Reuse existing secrets if compose.env was written by a previous run. + local db_password webhook_secret jwt_secret + db_password="$(read_env_var KOMODO_DATABASE_PASSWORD || true)" + webhook_secret="$(read_env_var KOMODO_WEBHOOK_SECRET || true)" + jwt_secret="$(read_env_var KOMODO_JWT_SECRET || true)" + + if [[ -z "$db_password" ]]; then + db_password="$(gen_secret 24)" + log_info "Generated KOMODO_DATABASE_PASSWORD." + else + log_info "Preserving existing KOMODO_DATABASE_PASSWORD from compose.env." + fi + if [[ -z "$webhook_secret" ]]; then + webhook_secret="$(gen_secret 32)" + log_info "Generated KOMODO_WEBHOOK_SECRET." + else + log_info "Preserving existing KOMODO_WEBHOOK_SECRET from compose.env." + fi + if [[ -z "$jwt_secret" ]]; then + jwt_secret="$(gen_secret 32)" + log_info "Generated KOMODO_JWT_SECRET." + else + log_info "Preserving existing KOMODO_JWT_SECRET from compose.env." + fi + + # Stash the resolved values so the install summary can print them. + KOMODO_DB_PASSWORD_RESOLVED="$db_password" + KOMODO_WEBHOOK_SECRET_RESOLVED="$webhook_secret" + KOMODO_JWT_SECRET_RESOLVED="$jwt_secret" + + # mongo.compose.yaml is upstream's reference (https://komo.do/docs/setup/mongo) + # with one change: Core's host port is bound to ${KOMODO_LISTEN_ADDR} instead + # of every interface, so only the loopback (and Tailscale serve) can reach it. + cat > "${KOMODO_DIR}/mongo.compose.yaml" < "${KOMODO_DIR}/compose.env" </dev/null + + log_info "Starting the Komodo stack..." + (cd "$KOMODO_DIR" && docker compose --env-file ./compose.env -f mongo.compose.yaml up -d) >/dev/null +} + +# ============================================================ +# Log hygiene + console auto-login (repo conventions) +# ============================================================ +configure_log_hygiene() { + # Bound the journal-equivalent — Alpine uses OpenRC, so we cap Docker's + # per-container logs via daemon.json (json-file driver, default). + log_info "Capping per-container Docker log size..." + mkdir -p /etc/docker + cat > /etc/docker/daemon.json <<'EOF' +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } +} +EOF + # Reload so the cap applies to subsequent container starts. Existing + # containers keep their previous log driver until recreated. + rc-service docker reload >/dev/null 2>&1 \ + || rc-service docker restart >/dev/null 2>&1 || true +} + +configure_console_autologin() { + log_info "Enabling console auto-login on tty1..." + # Alpine's busybox getty does not support --autologin; use util-linux's agetty. + apk add --no-cache agetty >/dev/null 2>&1 || apk add --no-cache util-linux >/dev/null + sed -i '/^tty1::/d' /etc/inittab + echo 'tty1::respawn:/sbin/agetty --autologin root --noclear 38400 tty1' >> /etc/inittab + kill -HUP 1 2>/dev/null || true + pkill -KILL -f '(getty|agetty).*tty1' 2>/dev/null || true +} + +write_motd() { + log_info "Configuring MOTD..." + # Quoted heredoc: every variable in the body is resolved at login time, not + # install time. + cat > /etc/profile.d/00-komodo.sh <<'MOTD' +KOMODO_DIR="${KOMODO_DIR:-/opt/komodo}" +KOMODO_HOST=$(awk -F= '$1 == "KOMODO_HOST" { print $2; exit }' "${KOMODO_DIR}/compose.env" 2>/dev/null) +[ -z "$KOMODO_HOST" ] && KOMODO_HOST="https://" + +TS_FQDN=$(tailscale status --json 2>/dev/null | awk -F'"' ' + /"Self"/ { in_self=1 } + in_self && /"DNSName"/ { gsub(/\.$/, "", $4); print $4; exit } +') +[ -z "$TS_FQDN" ] && TS_FQDN="$(hostname).ts.net" + +CORE_STATE=$(docker inspect -f '{{.State.Status}}' komodo-core-1 2>/dev/null || echo "missing") +PERI_STATE=$(docker inspect -f '{{.State.Status}}' komodo-periphery-1 2>/dev/null || echo "missing") +MONGO_STATE=$(docker inspect -f '{{.State.Status}}' komodo-mongo-1 2>/dev/null || echo "missing") + +echo "" +echo " _ __ _ " +echo "| |/ /___ _ __ ___ __| | ___ " +echo "| ' // _ \\| '_ \` _ \\ / _\` |/ _ \\ " +echo "| . \\ (_) | | | | | | (_| | (_) |" +echo "|_|\\_\\___/|_| |_| |_|\\__,_|\\___/ " +echo "" +echo "Komodo (Docker + MongoDB)" +echo "─────────────────────────────────────────" +echo "Access:" +echo " • Local : http://127.0.0.1:9120" +echo " • Tailnet : ${KOMODO_HOST}" +echo "" +echo "Containers:" +echo " • core : ${CORE_STATE}" +echo " • periphery : ${PERI_STATE}" +echo " • mongo : ${MONGO_STATE}" +echo "" +echo "Useful commands:" +echo " cd ${KOMODO_DIR}" +echo " docker compose --env-file compose.env -f mongo.compose.yaml ps" +echo " docker compose --env-file compose.env -f mongo.compose.yaml logs -f core" +echo " docker compose --env-file compose.env -f mongo.compose.yaml pull && \\" +echo " docker compose --env-file compose.env -f mongo.compose.yaml up -d" +echo "─────────────────────────────────────────" +echo "" +MOTD + chmod +x /etc/profile.d/00-komodo.sh +} + +# ============================================================ +# Main +# ============================================================ +main() { + require_root + require_alpine + + log_info "=== Komodo — installation ===" + + # Base tooling for the rest of the script (jq is used to read tailscale status, + # curl/ca-certificates for image pulls / future tailscale install). + apk add --no-cache bash curl jq ca-certificates openssl >/dev/null + + install_docker + install_tailscale + write_compose_files + bring_up_stack + configure_log_hygiene + configure_console_autologin + configure_tailscale_proxy + write_motd + + echo "" + log_info "=========================================" + log_info "Komodo installation complete!" + log_info "=========================================" + echo "" + echo " Local URL : http://${KOMODO_LISTEN_ADDR}" + echo " Tailnet URL : ${KOMODO_HOST}" + echo " Compose dir : ${KOMODO_DIR}" + echo "" + echo "Initial admin (change immediately via the UI):" + echo " username : admin" + echo " password : changeme" + echo "" + echo "Generated secrets (also in ${KOMODO_DIR}/compose.env, root-only):" + echo " KOMODO_DATABASE_PASSWORD : ${KOMODO_DB_PASSWORD_RESOLVED}" + echo " KOMODO_WEBHOOK_SECRET : ${KOMODO_WEBHOOK_SECRET_RESOLVED}" + echo " KOMODO_JWT_SECRET : ${KOMODO_JWT_SECRET_RESOLVED}" + echo "" +} + +main "$@"