#!/usr/bin/env bash # # Felis one-line bootstrap installer. # # curl -fsSL /deploy/bootstrap.sh | sudo bash # # Brings a fresh single-node Linux host from nothing to a running Felis control # plane: it installs whatever is missing (picking dnf or apt by OS), provisions a # swap file on tiny hosts, then configures Docker, k3s and PostgreSQL, builds and # imports the felis image, runs database migrations and applies the rendered # install bundle (CRD + namespaces + RBAC + NetworkPolicies + control-plane # Deployments + in-cluster registry). # # By design it stops short of serving the web panel. After it finishes you run # `felis setup` on the host (a TUI) to create the Owner account; the SysAdmin web # surface only unlocks once Web Zero-Trust is configured. See deploy/README.md. # # The script is idempotent: re-running it converges rather than duplicating, and # generated secrets are persisted to /etc/felis/secrets.env so reruns reuse them. # # Tunables (export before running to override the demo defaults): # FELIS_REPO_URL git URL to build from (default: the upstream repo) # FELIS_REF branch/tag/sha (default: main) # FELIS_IMAGE local image tag (default: felis:demo — never :latest) # FELIS_ROOT_DOMAIN deployment root domain (default: .nip.io) # FELIS_EGRESS_MODE loadbalancer|nodeport (default: nodeport — no MetalLB on a demo box) set -euo pipefail # --------------------------------------------------------------------------- # Configuration & constants # --------------------------------------------------------------------------- FELIS_REPO_URL="${FELIS_REPO_URL:-https://github.com/MliroLirrorsIngenuity/Felis.git}" FELIS_REF="${FELIS_REF:-main}" FELIS_IMAGE="${FELIS_IMAGE:-felis:demo}" FELIS_EGRESS_MODE="${FELIS_EGRESS_MODE:-nodeport}" CONTROL_NS="felis" MINECRAFT_NS="minecraft" BUILD_NS="felis-build" POD_CIDR="10.42.0.0/16" # k3s default cluster CIDR DB_NAME="felis" DB_USER="felis" REGISTRY_URL="registry.felis.svc:5000" STATE_DIR="/etc/felis" SECRETS_ENV="${STATE_DIR}/secrets.env" SRC_DIR="/opt/felis/src" HOST_BIN="/usr/local/bin/felis" # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- log() { printf '\033[1;36m[felis]\033[0m %s\n' "$*"; } ok() { printf '\033[1;32m[ ok ]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; } kube() { k3s kubectl "$@"; } # --------------------------------------------------------------------------- # 0. Privilege & host facts # --------------------------------------------------------------------------- if [ "$(id -u)" -ne 0 ]; then log "re-executing under sudo" exec sudo -E bash "$0" "$@" fi detect_os() { [ -r /etc/os-release ] || die "cannot read /etc/os-release; unsupported host" # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-unknown}" OS_VERSION="${VERSION_ID:-unknown}" if command -v apt-get >/dev/null 2>&1; then PKG="apt" elif command -v dnf >/dev/null 2>&1; then PKG="dnf" elif command -v yum >/dev/null 2>&1; then PKG="yum" else die "no supported package manager (apt/dnf/yum) found on ${OS_ID} ${OS_VERSION}" fi log "host: ${PRETTY_NAME:-$OS_ID $OS_VERSION} (package manager: ${PKG})" } detect_node_ip() { NODE_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')" [ -n "${NODE_IP:-}" ] || NODE_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" [ -n "${NODE_IP:-}" ] || die "could not determine this host's primary IPv4 address" FELIS_ROOT_DOMAIN="${FELIS_ROOT_DOMAIN:-${NODE_IP}.nip.io}" log "node IP: ${NODE_IP} root domain: ${FELIS_ROOT_DOMAIN}" } pkg_install() { case "$PKG" in apt) DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" ;; dnf) dnf install -y "$@" ;; yum) yum install -y "$@" ;; esac } pkg_refresh_once() { [ -n "${_PKG_REFRESHED:-}" ] && return 0 case "$PKG" in apt) DEBIAN_FRONTEND=noninteractive apt-get update -y ;; dnf|yum) : ;; # dnf/yum refresh metadata on demand esac _PKG_REFRESHED=1 } # --------------------------------------------------------------------------- # 1. Swap — k3s + Postgres + a Go build will OOM on a <2 GiB box without it # --------------------------------------------------------------------------- ensure_swap() { local mem_kb swap_kb mem_kb="$(awk '/^MemTotal:/{print $2}' /proc/meminfo)" swap_kb="$(awk '/^SwapTotal:/{print $2}' /proc/meminfo)" if [ "${swap_kb:-0}" -gt 0 ]; then ok "swap already present ($((swap_kb/1024)) MiB)" return 0 fi if [ "${mem_kb:-0}" -ge 2097152 ]; then ok "RAM $((mem_kb/1024)) MiB is sufficient; skipping swap" return 0 fi log "low RAM ($((mem_kb/1024)) MiB) and no swap — creating a 2 GiB swap file" if ! fallocate -l 2G /swapfile 2>/dev/null; then dd if=/dev/zero of=/swapfile bs=1M count=2048 status=none fi chmod 600 /swapfile mkswap /swapfile >/dev/null swapon /swapfile grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab ok "2 GiB swap active" } # --------------------------------------------------------------------------- # 2. Base packages # --------------------------------------------------------------------------- install_base() { pkg_refresh_once pkg_install curl ca-certificates git openssl ok "base tools present" } # --------------------------------------------------------------------------- # 3. Docker (used only to build & export the felis image; k3s uses containerd) # --------------------------------------------------------------------------- install_docker() { if command -v docker >/dev/null 2>&1; then ok "docker already installed" else log "installing docker via get.docker.com" curl -fsSL https://get.docker.com | sh fi systemctl enable --now docker ok "docker running" } # --------------------------------------------------------------------------- # 4. k3s — single node, trimmed for RAM. NetworkPolicy stays ENABLED on purpose: # Felis's minecraft fence (default-deny + allow-rcon/allow-game) is a core # security claim, so we must NOT pass --disable-network-policy. # --------------------------------------------------------------------------- install_k3s() { if command -v k3s >/dev/null 2>&1; then ok "k3s already installed" else log "installing k3s (no traefik/servicelb/metrics-server)" curl -sfL https://get.k3s.io | \ INSTALL_K3S_EXEC="--disable traefik --disable servicelb --disable metrics-server --write-kubeconfig-mode 644" \ sh - fi systemctl enable --now k3s export KUBECONFIG=/etc/rancher/k3s/k3s.yaml log "waiting for the node to become Ready" local i for i in $(seq 1 60); do if kube get nodes 2>/dev/null | grep -q ' Ready '; then ok "k3s node Ready" return 0 fi sleep 5 done kube get nodes || true die "k3s node did not become Ready in time" } # --------------------------------------------------------------------------- # 5. Source + image build + host binary + containerd import # --------------------------------------------------------------------------- fetch_source() { if [ -n "${FELIS_SKIP_FETCH:-}" ]; then [ -d "$SRC_DIR" ] || die "FELIS_SKIP_FETCH set but ${SRC_DIR} does not exist" ok "skipping fetch; using pre-staged source at ${SRC_DIR}" return 0 fi if [ -d "${SRC_DIR}/.git" ]; then log "updating source in ${SRC_DIR}" git -C "$SRC_DIR" fetch --depth 1 origin "$FELIS_REF" git -C "$SRC_DIR" checkout -f FETCH_HEAD else log "cloning ${FELIS_REPO_URL} (${FELIS_REF})" mkdir -p "$(dirname "$SRC_DIR")" git clone --depth 1 --branch "$FELIS_REF" "$FELIS_REPO_URL" "$SRC_DIR" 2>/dev/null \ || git clone "$FELIS_REPO_URL" "$SRC_DIR" fi ok "source ready at ${SRC_DIR}" } build_image() { systemctl start docker log "building ${FELIS_IMAGE} (this compiles the Go binary; first run is slow)" docker build -t "$FELIS_IMAGE" "$SRC_DIR" log "extracting the felis binary onto the host (${HOST_BIN})" local cid cid="$(docker create "$FELIS_IMAGE")" docker cp "${cid}:/usr/local/bin/felis" "$HOST_BIN" docker rm "$cid" >/dev/null chmod 0755 "$HOST_BIN" log "importing ${FELIS_IMAGE} into k3s containerd" docker save "$FELIS_IMAGE" | k3s ctr images import - # Reclaim the ~150 MiB the docker daemon holds; reruns restart it on demand. systemctl stop docker docker.socket 2>/dev/null || true ok "image built, binary on host, image imported" } # --------------------------------------------------------------------------- # 6. PostgreSQL on the host (apt/dnf). felis-api pods reach it at :5432; # migrations run from the host binary against 127.0.0.1. # --------------------------------------------------------------------------- install_postgres() { if command -v psql >/dev/null 2>&1 && systemctl list-unit-files 2>/dev/null | grep -q '^postgresql'; then ok "postgresql already installed" else log "installing postgresql" case "$PKG" in apt) pkg_install postgresql ;; dnf) pkg_install postgresql-server postgresql ;; yum) pkg_install postgresql-server postgresql ;; esac fi # RHEL-family ships an uninitialised data dir. if [ "$PKG" != "apt" ] && [ ! -f /var/lib/pgsql/data/PG_VERSION ]; then log "initialising postgresql data directory" if command -v postgresql-setup >/dev/null 2>&1; then postgresql-setup --initdb || /usr/bin/postgresql-setup initdb fi fi systemctl enable --now postgresql ok "postgresql running" } configure_postgres() { local cfg hba cfg="$(sudo -u postgres psql -tAc 'SHOW config_file;' 2>/dev/null || true)" hba="$(sudo -u postgres psql -tAc 'SHOW hba_file;' 2>/dev/null || true)" [ -n "$cfg" ] && [ -n "$hba" ] || die "could not query postgresql config/hba file paths" # Listen on all interfaces (applied on restart). ALTER SYSTEM is idempotent. sudo -u postgres psql -v ON_ERROR_STOP=1 -c "ALTER SYSTEM SET listen_addresses = '*';" >/dev/null # Allow the host loopback, the pod CIDR, and the node IP (covers SNAT either way). local line for line in \ "host ${DB_NAME} ${DB_USER} 127.0.0.1/32 scram-sha-256" \ "host ${DB_NAME} ${DB_USER} ${POD_CIDR} scram-sha-256" \ "host ${DB_NAME} ${DB_USER} ${NODE_IP}/32 scram-sha-256" ; do grep -qF "$line" "$hba" || echo "$line" >> "$hba" done # Role + database (idempotent), and (re)set the password to our generated one. sudo -u postgres psql -v ON_ERROR_STOP=1 </dev/null DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${DB_USER}') THEN CREATE ROLE ${DB_USER} LOGIN PASSWORD '${DB_PASSWORD}'; END IF; END \$\$; ALTER ROLE ${DB_USER} WITH LOGIN PASSWORD '${DB_PASSWORD}'; SQL if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1; then sudo -u postgres createdb -O "$DB_USER" "$DB_NAME" fi systemctl restart postgresql ok "postgresql configured (listen=*, role/db '${DB_NAME}', pg_hba opened to pods)" } # --------------------------------------------------------------------------- # 7. Secrets + felis.toml (pod variant reaches Postgres at the node IP; host # variant at 127.0.0.1 for migrations) # --------------------------------------------------------------------------- load_or_make_secrets() { mkdir -p "$STATE_DIR" chmod 0700 "$STATE_DIR" if [ -f "$SECRETS_ENV" ]; then # shellcheck disable=SC1090 . "$SECRETS_ENV" ok "reusing persisted secrets from ${SECRETS_ENV}" fi DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -hex 24)}" SERVICE_TOKEN="${SERVICE_TOKEN:-$(openssl rand -hex 32)}" SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}" umask 077 cat > "$SECRETS_ENV" < "$target" < 127.0.0.1)" "$HOST_BIN" migrate up -config "${STATE_DIR}/felis.host.toml" ok "migrations applied" } deploy_bundle() { export KUBECONFIG=/etc/rancher/k3s/k3s.yaml write_felis_toml "${STATE_DIR}/felis.pod.toml" "${NODE_IP}" log "applying MinecraftServer CRD" kube apply -f "${SRC_DIR}/deploy/crd/" log "ensuring namespaces" local ns for ns in "$CONTROL_NS" "$MINECRAFT_NS" "$BUILD_NS"; do kube create namespace "$ns" --dry-run=client -o yaml | kube apply -f - done log "provisioning felis-config + felis-service-token secrets (out-of-band, never in the bundle)" kube -n "$CONTROL_NS" create secret generic felis-config \ --from-file=felis.toml="${STATE_DIR}/felis.pod.toml" \ --dry-run=client -o yaml | kube apply -f - kube -n "$CONTROL_NS" create secret generic felis-service-token \ --from-literal=token="${SERVICE_TOKEN}" \ --dry-run=client -o yaml | kube apply -f - log "rendering + applying the control-plane bundle" "$HOST_BIN" manifests \ --felis-image "$FELIS_IMAGE" \ --velocity-cidr "${NODE_IP}/32" \ | kube apply -f - log "waiting for control-plane rollouts" local d for d in $(kube -n "$CONTROL_NS" get deploy -o name); do kube -n "$CONTROL_NS" rollout status "$d" --timeout=180s || warn "rollout not complete: $d" done } # --------------------------------------------------------------------------- # 9. Summary # --------------------------------------------------------------------------- summary() { export KUBECONFIG=/etc/rancher/k3s/k3s.yaml echo ok "Felis control plane deployed." echo kube -n "$CONTROL_NS" get pods -o wide || true echo log "Web is intentionally NOT enabled yet." log "Next: run 'sudo felis setup' on this host to create the Owner account." log "The SysAdmin web surface unlocks only after Web Zero-Trust is configured." echo } main() { detect_os detect_node_ip ensure_swap install_base load_or_make_secrets install_docker install_k3s fetch_source build_image install_postgres configure_postgres run_migrations deploy_bundle summary } main "$@"