Files
Felis/deploy/bootstrap.sh
T
flyemoji fd062882ed feat(nano): give a Mojang player's name back to them, by prefixing the squatter
A premium player and a third-party player sharing a username could not both be
online. Whichever logged in second was kicked with "You are already connected to
this proxy!" -- even though the UUID rewrite had already made them two distinct
players on the backend. Velocity's player registry is keyed on the NAME (lowercased),
not the UUID, so two identities holding one name are one player as far as the proxy
is concerned, and the reclaim invariant the rewrite buys is invisible to it.

The fix needs no plugin and no state, because Velocity honours the name in the
hasJoined RESPONSE rather than pinning the one the client sent at login-start --
established by a real login, not by reading the source. So the multiplexer hands
back a different name and the collision is simply gone.

A third-party player whose name belongs to a Mojang account now joins as
PREFIX_name (LS_steve). Everyone else keeps their own name: the rename fires only
on an actual collision, decided by asking api.mojang.com whether the name is
registered. The name's owner is never the one renamed, which is 正版优先 falling out
for free -- the identity source is never rewritten, so there is no policy to encode
and no 30-day hold to track.

The premium-name answer is cached asymmetrically, because the two directions have
very different costs. "Taken" is nearly permanent (Mojang does not recycle names) and
is trusted for a day; "free" can stop being true the moment someone buys that name,
and a stale "free" leaves a squatter holding a name its real owner has just bought,
so it is trusted for ten minutes. A lookup that fails with nothing cached fails
CLOSED -- assume premium, rename the third-party player: a Mojang outage must not
become an opportunity to hold someone else's name, and being wrong that way costs a
cosmetic prefix while being wrong the other way bounces the name's owner off the
proxy. The lookup gets its own 2s client rather than sharing the 5s auth client,
since it is a SECOND Mojang round-trip on a login that already spent one.

prefix is a required, unique, 1-4 character config field rather than something
derived from the tag, because it is player-visible and no derivation can know that
"littleskin" is meant to read LS. Two sources sharing a prefix would rewrite their
same-named players onto one name, so uniqueness is enforced case-insensitively --
the proxy folds case, and LS/ls would collide there while reading as distinct here.

Also close a pre-existing hole on the path this touches: a third-party source's
profile name was relayed verbatim, so a hostile or sloppy Yggdrasil root could put
"§4admin", an empty string, or 200 characters straight into the proxy's player list.
The name is now checked against the Minecraft username charset and a bad one is a 204,
the same way a bad UUID already was.

Verified end to end on the deploy host (Velocity 3.5.1 + Paper 26.2), both branches:

  premium FLYEMOJ1     -> 195fadbd-f72e-4b9b-9f8f-f92586fe16ad, name unchanged
  LittleSkin FLYEMOJ1  -> LS_FLYEMOJ1, f1b7b6ae-f250-348a-b069-a2ec0fcae668
  both online at once, zero "already connected" rejections
  LittleSkin FelisNyaTest01 -> joins as FelisNyaTest01, no prefix, UUID still v3

The last line is the one that matters: an ordinary third-party player collides with
nobody and keeps their name, while the rewrite that keeps identities apart still ran.
Paper's "LS_FLYEMOJ1 (formerly known as li_FLYEMOJ1) joined the game" is the other
half of it -- the rename moved the player's display name and their playerdata came
along untouched, because every server-side key is the UUID and the UUID does not
depend on the name.

Known ceiling, left alone deliberately: two players of one source whose names agree
on their first 16-len(prefix)-1 characters truncate onto the same in-game name, and a
prefixed name may itself happen to be a premium name. Both cost an "already connected"
bounce, not an identity -- the UUID rewrite does not depend on the name at all.

BREAKING CHANGE: every [[auth_source]] now requires prefix = "XX" (1-4 letters or
digits, unique across sources). An existing nano felis.toml without it fails to load
with an error naming the field, rather than silently keeping the collision.
2026-07-13 13:00:54 +09:00

1318 lines
44 KiB
Bash

#!/usr/bin/env bash
#
# Felis one-line bootstrap installer.
#
# curl -fsSL <raw-url>/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 apt/dnf/yum/zypper/pacman 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).
#
# The recommended entrypoint is now `sudo felis setup`, which wraps this
# bootstrap in a TUI and then continues to the Owner/edge setup. This script
# remains usable directly for raw host provisioning.
#
# At the start it asks what to install:
# [1] Felis — the full control plane described above.
# [2] Felis-nano — ONLY the Yggdrasil hasJoined multiplexer (`felis nano`) as a
# systemd service: no k3s, no Postgres, no bundle. For a
# third-party server operator who just wants multi-Yggdrasil
# auth federation. Preselect non-interactively with
# FELIS_INSTALL_MODE=nano.
#
# 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_INSTALL_MODE full|nano — skip the prompt (default: ask on a tty, else full)
# FELIS_NANO_LISTEN listen addr for `felis nano` (default: 127.0.0.1:8081 — loopback
# only; set a private-network IP to serve an off-host proxy)
# FELIS_GO_VERSION Go toolchain used to build the nano binary (default: 1.26.4)
# FELIS_REPO_URL git URL to build from (raw script mode only)
# FELIS_REF branch/tag/sha (raw script mode only)
# FELIS_IMAGE local image tag (default: felis:demo — never :latest)
# FELIS_ROOT_DOMAIN deployment root domain (default: <node-ip>.nip.io)
# FELIS_PANEL_NODEPORT local HTTPS panel/API NodePort (default: 30443)
# FELIS_EGRESS_MODE loadbalancer|nodeport (default: nodeport — no MetalLB on a demo box)
# PKG_LOCK_TIMEOUT seconds to wait for package-manager locks (default: 900)
# APT_LOCK_TIMEOUT legacy alias for PKG_LOCK_TIMEOUT
set -Eeuo 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}"
FELIS_PANEL_NODEPORT="${FELIS_PANEL_NODEPORT:-30443}"
INSTALL_MODE="${FELIS_INSTALL_MODE:-}"
# Loopback by default: hasJoined is an unauthenticated endpoint by protocol (authlib
# sends no token), so a public bind is a free auth relay — anyone can point their own
# proxy at it and spend YOUR egress IP on Mojang, until Mojang rate-limits you and your
# own players stop getting in. Same-host Velocity reaches 127.0.0.1 fine; a proxy on
# another machine must opt in explicitly with FELIS_NANO_LISTEN=<private-ip>:8081.
FELIS_NANO_LISTEN="${FELIS_NANO_LISTEN:-127.0.0.1:8081}"
FELIS_GO_VERSION="${FELIS_GO_VERSION:-1.26.4}"
PKG_LOCK_TIMEOUT="${PKG_LOCK_TIMEOUT:-${APT_LOCK_TIMEOUT:-900}}"
APT_LOCK_TIMEOUT="${APT_LOCK_TIMEOUT:-$PKG_LOCK_TIMEOUT}"
CONTROL_NS="felis"
MINECRAFT_NS="minecraft"
BUILD_NS="felis-build"
POD_CIDR="10.42.0.0/16" # k3s default cluster CIDR
SERVICE_CIDR="10.43.0.0/16" # k3s default service CIDR
DB_NAME="felis"
DB_USER="felis"
REGISTRY_URL="registry.felis.svc:5000"
STATE_DIR="/etc/felis"
SECRETS_ENV="${STATE_DIR}/secrets.env"
BOOTSTRAP_DONE="${STATE_DIR}/bootstrap.done"
PANEL_TLS_CERT="${STATE_DIR}/panel-tls.crt"
PANEL_TLS_KEY="${STATE_DIR}/panel-tls.key"
SRC_DIR="/opt/felis/src"
HOST_BIN="/usr/local/bin/felis"
GOROOT_DIR="/usr/local/go"
NANO_SERVICE="/etc/systemd/system/felis-nano.service"
K3S_BIN_DIR="${K3S_BIN_DIR:-/usr/local/bin}"
K3S_BIN="${K3S_BIN_DIR}/k3s"
APT_LOCK_FILES=(
/var/lib/dpkg/lock-frontend
/var/lib/dpkg/lock
/var/cache/apt/archives/lock
/var/lib/apt/lists/lock
)
APT_BACKGROUND_TIMERS=(
apt-daily.timer
apt-daily-upgrade.timer
)
APT_BACKGROUND_SERVICES=(
apt-daily.service
apt-daily-upgrade.service
unattended-upgrades.service
)
DNF_BACKGROUND_TIMERS=(
dnf-makecache.timer
dnf-automatic.timer
)
DNF_BACKGROUND_SERVICES=(
dnf-makecache.service
dnf-automatic.service
)
YUM_BACKGROUND_TIMERS=(
yum-cron.timer
)
YUM_BACKGROUND_SERVICES=(
yum-cron.service
)
ZYPPER_BACKGROUND_TIMERS=(
packagekit-background.timer
)
ZYPPER_BACKGROUND_SERVICES=(
packagekit.service
)
# ---------------------------------------------------------------------------
# Clean PATH (sudo may strip /usr/local/bin)
# ---------------------------------------------------------------------------
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export PATH
# ---------------------------------------------------------------------------
# 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; }
TEMP_PATHS=()
DOCKER_CONTAINERS=()
PKG_TIMERS_TO_RESTORE=()
on_error() {
local line="$1" code="$2"
warn "bootstrap failed near line ${line} (exit ${code})"
}
cleanup() {
local id path unit
for unit in "${PKG_TIMERS_TO_RESTORE[@]-}"; do
[ -n "$unit" ] || continue
systemctl start "$unit" >/dev/null 2>&1 || true
done
if command -v docker >/dev/null 2>&1; then
for id in "${DOCKER_CONTAINERS[@]-}"; do
[ -n "$id" ] && docker rm "$id" >/dev/null 2>&1 || true
done
fi
for path in "${TEMP_PATHS[@]-}"; do
[ -n "$path" ] && rm -rf -- "$path" || true
done
}
remember_temp() { TEMP_PATHS+=("$1"); }
remember_container() { DOCKER_CONTAINERS+=("$1"); }
trap 'on_error "$LINENO" "$?"' ERR
trap cleanup EXIT
k3s_cmd() { [ -x "$K3S_BIN" ] || die "k3s binary not found at ${K3S_BIN}"; "$K3S_BIN" "$@"; }
kube() { k3s_cmd kubectl "$@"; }
as_postgres() {
if command -v runuser >/dev/null 2>&1; then
runuser -u postgres -- "$@"
else
sudo -u postgres "$@"
fi
}
bootstrap_from_tui() {
[ "${FELIS_BOOTSTRAP_FROM_TUI:-}" = "1" ]
}
pause_package_background_timers() {
command -v systemctl >/dev/null 2>&1 || return 0
local active=0 timers=() services=() unit
case "${PKG:-}" in
apt) timers=("${APT_BACKGROUND_TIMERS[@]}"); services=("${APT_BACKGROUND_SERVICES[@]}") ;;
dnf) timers=("${DNF_BACKGROUND_TIMERS[@]}"); services=("${DNF_BACKGROUND_SERVICES[@]}") ;;
yum) timers=("${YUM_BACKGROUND_TIMERS[@]}"); services=("${YUM_BACKGROUND_SERVICES[@]}") ;;
zypper) timers=("${ZYPPER_BACKGROUND_TIMERS[@]}"); services=("${ZYPPER_BACKGROUND_SERVICES[@]}") ;;
*) return 0 ;;
esac
for unit in "${timers[@]}"; do
if systemctl is-active --quiet "$unit"; then
PKG_TIMERS_TO_RESTORE+=("$unit")
active=1
fi
done
if [ "$active" -eq 1 ]; then
log "pausing package-manager timers during bootstrap: ${PKG_TIMERS_TO_RESTORE[*]}"
systemctl stop "${PKG_TIMERS_TO_RESTORE[@]}" || warn "could not stop package-manager timers; package operations may need to wait"
fi
for unit in "${services[@]}"; do
if systemctl is-active --quiet "$unit"; then
log "stopping package-manager background service during bootstrap: ${unit}"
systemctl stop "$unit" || warn "could not stop ${unit}; package operations may need to wait"
fi
done
}
pkg_lock_files() {
case "${PKG:-}" in
apt) printf '%s\n' "${APT_LOCK_FILES[@]}" ;;
dnf|yum)
printf '%s\n' \
/var/lib/rpm/.rpm.lock \
/var/lib/dnf/rpmdb_lock.pid \
/var/cache/dnf/metadata_lock.pid \
/run/dnf.pid \
/var/run/dnf.pid
;;
zypper)
printf '%s\n' \
/var/lib/rpm/.rpm.lock \
/run/zypp.pid \
/var/run/zypp.pid
;;
pacman) printf '%s\n' /var/lib/pacman/db.lck ;;
esac
}
pkg_lock_process_names() {
case "${PKG:-}" in
dnf) printf '%s\n' dnf dnf5 rpm ;;
yum) printf '%s\n' yum rpm ;;
zypper) printf '%s\n' zypper rpm ;;
pacman) printf '%s\n' pacman ;;
esac
}
pkg_busy_pids() {
local file file_count name
{
if command -v fuser >/dev/null 2>&1; then
local files=()
file_count=0
while IFS= read -r file; do
if [ -e "$file" ]; then
files+=("$file")
file_count=$((file_count + 1))
fi
done < <(pkg_lock_files)
[ "$file_count" -eq 0 ] || fuser "${files[@]}" 2>/dev/null | tr ' ' '\n'
fi
if command -v pgrep >/dev/null 2>&1; then
while IFS= read -r name; do
[ -n "$name" ] && pgrep -x "$name" 2>/dev/null || true
done < <(pkg_lock_process_names)
fi
} | awk 'NF && !seen[$1]++'
}
pkg_lock_busy() {
[ -n "$(pkg_busy_pids)" ]
}
pkg_lock_holders() {
local pids
pids="$(pkg_busy_pids | paste -sd, - || true)"
[ -n "$pids" ] || return 0
ps -o pid=,comm= -p "$pids" 2>/dev/null | awk '{$1=$1; print}' | paste -sd ';' -
}
wait_for_pkg_locks() {
local deadline holders next_notice
deadline=$((SECONDS + PKG_LOCK_TIMEOUT))
next_notice=0
while pkg_lock_busy; do
if [ "$SECONDS" -ge "$next_notice" ]; then
holders="$(pkg_lock_holders)"
if [ -n "$holders" ]; then
log "waiting for ${PKG} package locks to clear (timeout ${PKG_LOCK_TIMEOUT}s; holders: ${holders})"
else
log "waiting for ${PKG} package locks to clear (timeout ${PKG_LOCK_TIMEOUT}s)"
fi
next_notice=$((SECONDS + 30))
fi
[ "$SECONDS" -lt "$deadline" ] || die "${PKG} package manager is still busy after ${PKG_LOCK_TIMEOUT}s; wait for the current package operation to finish, then retry"
sleep 5
done
}
apt_get() {
wait_for_pkg_locks
DEBIAN_FRONTEND=noninteractive apt-get \
-o DPkg::Lock::Timeout="$PKG_LOCK_TIMEOUT" \
"$@"
}
validate_timeout() {
local name="$1" value="$2"
case "$value" in
''|*[!0-9]*) die "${name} must be a non-negative integer (seconds), got: ${value}" ;;
esac
}
validate_nodeport() {
local name="$1" value="$2"
case "$value" in
''|*[!0-9]*) die "${name} must be a Kubernetes NodePort integer, got: ${value}" ;;
esac
if [ "$value" -lt 30000 ] || [ "$value" -gt 32767 ]; then
die "${name} must be in Kubernetes NodePort range 30000-32767, got: ${value}"
fi
}
validate_settings() {
validate_timeout PKG_LOCK_TIMEOUT "$PKG_LOCK_TIMEOUT"
validate_timeout APT_LOCK_TIMEOUT "$APT_LOCK_TIMEOUT"
validate_nodeport FELIS_PANEL_NODEPORT "$FELIS_PANEL_NODEPORT"
}
# ---------------------------------------------------------------------------
# 0. Privilege & host facts
# ---------------------------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
if [ -r "$0" ]; then
log "re-executing under sudo"
exec sudo -E bash "$0" "$@"
fi
die "must run as root (for a piped installer, use: curl -fsSL <url> | sudo bash)"
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}"
OS_ID_LIKE="${ID_LIKE:-}"
OS_CODENAME="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}"
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"
elif command -v zypper >/dev/null 2>&1; then
PKG="zypper"
elif command -v pacman >/dev/null 2>&1; then
PKG="pacman"
else
die "no supported package manager (apt/dnf/yum/zypper/pacman) 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) apt_get install -y "$@" ;;
dnf) wait_for_pkg_locks; dnf install -y "$@" ;;
yum) wait_for_pkg_locks; yum install -y "$@" ;;
zypper) wait_for_pkg_locks; zypper --non-interactive install -y "$@" ;;
pacman) wait_for_pkg_locks; pacman -S --noconfirm --needed "$@" ;;
esac
}
pkg_refresh_once() {
[ -n "${_PKG_REFRESHED:-}" ] && return 0
case "$PKG" in
apt) apt_get update -y ;;
dnf|yum) : ;; # dnf/yum refresh metadata on demand
zypper) wait_for_pkg_locks; zypper --non-interactive refresh ;;
pacman) wait_for_pkg_locks; pacman -Syu --noconfirm ;;
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() {
local packages=(ca-certificates openssl)
pkg_refresh_once
command -v curl >/dev/null 2>&1 || packages+=(curl)
command -v tar >/dev/null 2>&1 || packages+=(tar)
if ! bootstrap_from_tui && ! command -v git >/dev/null 2>&1; then
packages+=(git)
fi
pkg_install "${packages[@]}"
ok "base tools present"
}
install_cloudflared() {
if command -v cloudflared >/dev/null 2>&1; then
ok "cloudflared already installed"
return 0
fi
local machine arch url tmp
machine="$(uname -m)"
case "$machine" in
x86_64|amd64) arch="amd64" ;;
aarch64|arm64) arch="arm64" ;;
armv7l|armv6l) arch="arm" ;;
*) die "unsupported architecture for cloudflared: ${machine}" ;;
esac
url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}"
tmp="$(mktemp)"
remember_temp "$tmp"
log "installing cloudflared (${arch})"
curl -fsSL "$url" -o "$tmp"
install -m 0755 "$tmp" /usr/local/bin/cloudflared
rm -f "$tmp"
ok "cloudflared installed ($(cloudflared --version | head -n 1))"
}
# ---------------------------------------------------------------------------
# 3. Docker (used only to build & export the felis image; k3s uses containerd)
# ---------------------------------------------------------------------------
docker_apt_repo_os() {
case "$OS_ID" in
debian|ubuntu)
printf '%s\n' "$OS_ID"
;;
*)
die "Docker apt repository is not configured for ${OS_ID} ${OS_VERSION}"
;;
esac
}
install_docker_apt() {
local arch keyring repo_os
repo_os="$(docker_apt_repo_os)"
[ -n "$OS_CODENAME" ] || die "cannot determine apt codename for ${OS_ID} ${OS_VERSION}"
arch="$(dpkg --print-architecture)"
keyring="/etc/apt/keyrings/docker.asc"
log "installing docker apt repository"
install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${repo_os}/gpg" -o "$keyring" \
|| die "failed to download Docker GPG key for ${repo_os}"
chmod a+r "$keyring"
cat > /etc/apt/sources.list.d/docker.list <<EOF
deb [arch=${arch} signed-by=${keyring}] https://download.docker.com/linux/${repo_os} ${OS_CODENAME} stable
EOF
apt_get update -y
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
}
docker_rpm_repo_url() {
case "$OS_ID" in
fedora)
printf '%s\n' "https://download.docker.com/linux/fedora/docker-ce.repo"
;;
rhel)
printf '%s\n' "https://download.docker.com/linux/rhel/docker-ce.repo"
;;
centos|almalinux|rocky)
printf '%s\n' "https://download.docker.com/linux/centos/docker-ce.repo"
;;
*)
case " ${OS_ID_LIKE} " in
*" rhel "*|*" centos "*)
printf '%s\n' "https://download.docker.com/linux/centos/docker-ce.repo"
;;
*)
die "Docker rpm repository is not configured for ${OS_ID} ${OS_VERSION}"
;;
esac
;;
esac
}
install_docker_rpm() {
local repo_file repo_url
repo_file="/etc/yum.repos.d/docker-ce.repo"
repo_url="$(docker_rpm_repo_url)"
log "installing docker rpm repository"
mkdir -p "$(dirname "$repo_file")"
curl -fsSL "$repo_url" -o "$repo_file" \
|| die "failed to download Docker repo file: ${repo_url}"
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
}
install_docker_zypper() {
log "installing docker from the distribution repositories"
pkg_install docker
}
install_docker_pacman() {
log "installing docker from the Arch repositories"
pkg_install docker docker-buildx
}
install_docker() {
if command -v docker >/dev/null 2>&1; then
ok "docker already installed"
else
case "$PKG" in
apt) install_docker_apt ;;
dnf|yum) install_docker_rpm ;;
zypper) install_docker_zypper ;;
pacman) install_docker_pacman ;;
*) die "Docker installation is not supported with package manager: ${PKG}" ;;
esac
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.
# On SUSE-family hosts firewalld ships active by default; open the required
# rules rather than disabling the firewall.
# ---------------------------------------------------------------------------
configure_k3s_firewall() {
command -v firewall-cmd >/dev/null 2>&1 || return 0
systemctl is-active --quiet firewalld || return 0
log "configuring firewalld for k3s"
firewall-cmd --permanent --add-port=6443/tcp
firewall-cmd --permanent --add-port="${FELIS_PANEL_NODEPORT}/tcp"
firewall-cmd --permanent --zone=trusted --add-source="$POD_CIDR"
firewall-cmd --permanent --zone=trusted --add-source="$SERVICE_CIDR"
firewall-cmd --reload
}
install_k3s() {
configure_k3s_firewall
if [ -x "$K3S_BIN" ]; then
ok "k3s already installed at ${K3S_BIN}"
else
log "installing k3s into ${K3S_BIN_DIR} (no traefik/servicelb/metrics-server)"
curl -sfL https://get.k3s.io | \
INSTALL_K3S_BIN_DIR="$K3S_BIN_DIR" \
INSTALL_K3S_EXEC="--disable traefik --disable servicelb --disable metrics-server --write-kubeconfig-mode 644" \
sh -
fi
[ -x "$K3S_BIN" ] || die "k3s installation completed but ${K3S_BIN} is missing"
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/binary + image build + 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}"
}
install_embedded_binary() {
local src
src="${FELIS_BOOTSTRAP_BINARY:-}"
[ -n "$src" ] || die "FELIS_BOOTSTRAP_BINARY is not set; cannot install the embedded setup binary"
[ -x "$src" ] || die "FELIS_BOOTSTRAP_BINARY is not executable: ${src}"
mkdir -p "$(dirname "$HOST_BIN")"
if [ "$(readlink -f "$src")" != "$(readlink -f "$HOST_BIN" 2>/dev/null || true)" ]; then
log "installing current felis binary onto the host (${HOST_BIN})"
install -m 0755 "$src" "$HOST_BIN"
else
ok "host binary already installed at ${HOST_BIN}"
fi
}
build_image_from_binary() {
local tmp
tmp="$(mktemp -d)"
remember_temp "$tmp"
cp "$HOST_BIN" "${tmp}/felis"
cat > "${tmp}/Dockerfile" <<'EOF'
FROM gcr.io/distroless/base-debian12:nonroot
ENV PATH=/usr/local/bin:/usr/bin:/bin
COPY felis /usr/local/bin/felis
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/felis"]
EOF
chmod 0755 "${tmp}/felis"
log "building ${FELIS_IMAGE} from the current felis binary"
docker build -t "$FELIS_IMAGE" "$tmp"
rm -rf "$tmp"
}
verify_image_starts() {
log "verifying ${FELIS_IMAGE} starts"
docker run --rm --user 1000:1000 --entrypoint /usr/local/bin/felis "$FELIS_IMAGE" help >/dev/null
}
build_image_from_source() {
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")"
remember_container "$cid"
docker cp "${cid}:/usr/local/bin/felis" "$HOST_BIN"
docker rm "$cid" >/dev/null
chmod 0755 "$HOST_BIN"
}
build_image() {
systemctl start docker
if bootstrap_from_tui; then
build_image_from_binary
else
build_image_from_source
fi
verify_image_starts
log "importing ${FELIS_IMAGE} into k3s containerd"
remove_k3s_image "$FELIS_IMAGE"
docker save "$FELIS_IMAGE" | k3s_cmd 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"
}
remove_k3s_image() {
local image="$1"
k3s_cmd ctr images rm "$image" >/dev/null 2>&1 || true
case "$image" in
*/*) ;;
*) k3s_cmd ctr images rm "docker.io/library/${image}" >/dev/null 2>&1 || true ;;
esac
}
# ---------------------------------------------------------------------------
# 6. PostgreSQL on the host. felis-api pods reach it at <node-ip>:5432;
# migrations run from the host binary against 127.0.0.1.
# ---------------------------------------------------------------------------
write_pg_hba_block() {
local hba="$1" tmp tmp_new node_cidr
node_cidr="${NODE_IP}/32"
tmp="$(mktemp)"
tmp_new="${tmp}.new"
remember_temp "$tmp"
remember_temp "$tmp_new"
awk \
-v db="$DB_NAME" \
-v user="$DB_USER" \
-v pod="$POD_CIDR" \
-v node="$node_cidr" '
$0 == "# BEGIN FELIS MANAGED HBA" { skip = 1; next }
$0 == "# END FELIS MANAGED HBA" { skip = 0; next }
skip { next }
# Clean up rules appended by older bootstrap versions.
$1 == "host" && $2 == db && $3 == user && $5 == "scram-sha-256" &&
($4 == "127.0.0.1/32" || $4 == pod || $4 == node) { next }
{ print }
' "$hba" > "$tmp"
{
printf "# BEGIN FELIS MANAGED HBA\n"
printf "# Felis rules must precede distro defaults such as 127.0.0.1 ident.\n"
printf "host %s %s 127.0.0.1/32 scram-sha-256\n" "$DB_NAME" "$DB_USER"
printf "host %s %s %s scram-sha-256\n" "$DB_NAME" "$DB_USER" "$POD_CIDR"
printf "host %s %s %s scram-sha-256\n" "$DB_NAME" "$DB_USER" "$node_cidr"
printf "# END FELIS MANAGED HBA\n"
printf "\n"
cat "$tmp"
} > "$tmp_new"
cat "$tmp_new" > "$hba"
rm -f "$tmp" "$tmp_new"
}
postgres_data_dir() {
case "$PKG" in
pacman) printf '%s\n' /var/lib/postgres/data ;;
*) printf '%s\n' /var/lib/pgsql/data ;;
esac
}
init_postgres_data_dir() {
local data_dir
data_dir="$(postgres_data_dir)"
[ "$PKG" = "apt" ] && return 0
[ -f "${data_dir}/PG_VERSION" ] && return 0
log "initialising postgresql data directory at ${data_dir}"
if command -v postgresql-setup >/dev/null 2>&1; then
postgresql-setup --initdb || /usr/bin/postgresql-setup initdb
elif command -v initdb >/dev/null 2>&1; then
install -d -o postgres -g postgres -m 0700 "$data_dir"
as_postgres initdb -D "$data_dir"
else
die "cannot initialise postgresql data directory: postgresql-setup/initdb not found"
fi
}
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 ;;
zypper) pkg_install postgresql-server postgresql ;;
pacman) pkg_install postgresql ;;
esac
fi
init_postgres_data_dir
systemctl enable --now postgresql
ok "postgresql running"
}
configure_postgres() {
local cfg hba
cfg="$(as_postgres psql -tAc 'SHOW config_file;' 2>/dev/null || true)"
hba="$(as_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.
as_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 before broader distro defaults.
write_pg_hba_block "$hba"
# Role + database (idempotent), and (re)set the password to our generated one.
as_postgres psql -v ON_ERROR_STOP=1 <<SQL >/dev/null
SET password_encryption = 'scram-sha-256';
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 ! as_postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1; then
as_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" <<EOF
DB_PASSWORD=${DB_PASSWORD}
SERVICE_TOKEN=${SERVICE_TOKEN}
SESSION_SECRET=${SESSION_SECRET}
EOF
)
chmod 0600 "$SECRETS_ENV"
}
ensure_panel_tls_cert() {
mkdir -p "$STATE_DIR"
chmod 0700 "$STATE_DIR"
if [ -s "$PANEL_TLS_CERT" ] && [ -s "$PANEL_TLS_KEY" ]; then
ok "panel TLS certificate already present"
return 0
fi
local cn conf
cn="op.console.${FELIS_ROOT_DOMAIN}"
conf="$(mktemp)"
remember_temp "$conf"
cat > "$conf" <<EOF
[req]
default_bits = 2048
distinguished_name = dn
x509_extensions = v3_req
prompt = no
[dn]
CN = ${cn}
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = op.console.${FELIS_ROOT_DOMAIN}
DNS.2 = console.${FELIS_ROOT_DOMAIN}
DNS.3 = localhost
IP.1 = 127.0.0.1
IP.2 = ${NODE_IP}
EOF
log "generating self-signed panel TLS certificate"
openssl req -x509 -newkey rsa:2048 -sha256 -days 825 -nodes \
-keyout "$PANEL_TLS_KEY" \
-out "$PANEL_TLS_CERT" \
-subj "/CN=${cn}" \
-config "$conf" >/dev/null 2>&1
chmod 0600 "$PANEL_TLS_KEY"
chmod 0644 "$PANEL_TLS_CERT"
ok "panel TLS certificate ready (${PANEL_TLS_CERT})"
}
write_felis_toml() {
local target="$1" db_host="$2"
cat > "$target" <<EOF
# Generated by deploy/bootstrap.sh — do not edit by hand; rerun the installer.
[server]
listen = "0.0.0.0:8080"
root_domain = "${FELIS_ROOT_DOMAIN}"
[database]
url = "postgres://${DB_USER}:${DB_PASSWORD}@${db_host}:5432/${DB_NAME}?sslmode=disable"
[k8s]
namespace = "${MINECRAFT_NS}"
egress_mode = "${FELIS_EGRESS_MODE}"
[registry]
url = "${REGISTRY_URL}"
build_namespace = "${BUILD_NS}"
[archive]
store = "tarLocal"
local_path = "/var/lib/felis/archives"
[auth]
admin_hostname = "op.console.${FELIS_ROOT_DOMAIN}"
panel_hostname = "console.${FELIS_ROOT_DOMAIN}"
EOF
}
ensure_default_config() {
local target="${STATE_DIR}/felis.toml" backup
if [ -L "$target" ] && [ "$(readlink "$target")" = "${STATE_DIR}/felis.host.toml" ]; then
ok "default host config already points at ${STATE_DIR}/felis.host.toml"
return 0
fi
if [ -e "$target" ] || [ -L "$target" ]; then
if bootstrap_from_tui; then
backup="${target}.bak.$(date -u +%Y%m%d%H%M%S).$$"
warn "replacing existing ${target}; backup saved at ${backup}"
mv "$target" "$backup"
ln -s "${STATE_DIR}/felis.host.toml" "$target"
ok "default host config: ${target} -> ${STATE_DIR}/felis.host.toml"
return 0
fi
warn "leaving existing ${target}; setup can use -config ${STATE_DIR}/felis.host.toml if needed"
return 0
fi
ln -s "${STATE_DIR}/felis.host.toml" "$target"
ok "default host config: ${target} -> ${STATE_DIR}/felis.host.toml"
}
# ---------------------------------------------------------------------------
# 8. Migrate + deploy bundle
# ---------------------------------------------------------------------------
run_migrations() {
write_felis_toml "${STATE_DIR}/felis.host.toml" "127.0.0.1"
ensure_default_config
log "running database migrations (host binary -> 127.0.0.1)"
"$HOST_BIN" migrate up -config "${STATE_DIR}/felis.host.toml"
ok "migrations applied"
}
deploy_bundle() {
local had_api=0 had_operator=0
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
write_felis_toml "${STATE_DIR}/felis.pod.toml" "${NODE_IP}"
kube -n "$CONTROL_NS" get deployment felis-api >/dev/null 2>&1 && had_api=1
kube -n "$CONTROL_NS" get deployment felis-operator >/dev/null 2>&1 && had_operator=1
log "applying MinecraftServer CRD"
if bootstrap_from_tui; then
"$HOST_BIN" bootstrap-assets crd | kube apply -f -
else
kube apply -f "${SRC_DIR}/deploy/crd/"
fi
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 + panel TLS 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 -
kube -n "$CONTROL_NS" create secret tls felis-api-tls \
--cert="$PANEL_TLS_CERT" \
--key="$PANEL_TLS_KEY" \
--dry-run=client -o yaml | kube apply -f -
log "rendering + applying the control-plane bundle"
"$HOST_BIN" manifests \
--felis-image "$FELIS_IMAGE" \
--panel-node-port "$FELIS_PANEL_NODEPORT" \
--velocity-cidr "${NODE_IP}/32" \
| kube apply -f -
restart_existing_control_plane "$had_api" "$had_operator"
log "waiting for control-plane rollouts"
local d
for d in $(kube -n "$CONTROL_NS" get deploy -o name); do
if ! kube -n "$CONTROL_NS" rollout status "$d" --timeout=180s; then
diagnose_rollout "$d"
die "control-plane rollout did not complete: ${d}"
fi
done
}
restart_existing_control_plane() {
local had_api="$1" had_operator="$2"
[ "$had_api$had_operator" != "00" ] || return 0
log "restarting existing control-plane deployments to pick up ${FELIS_IMAGE}"
[ "$had_api" = "1" ] && kube -n "$CONTROL_NS" rollout restart deployment/felis-api
[ "$had_operator" = "1" ] && kube -n "$CONTROL_NS" rollout restart deployment/felis-operator
}
diagnose_rollout() {
local deploy="$1" name selector pod
name="${deploy##*/}"
warn "rollout not complete: ${deploy}"
kube -n "$CONTROL_NS" describe "$deploy" || true
case "$name" in
felis-api) selector='app.kubernetes.io/name=felis,app.kubernetes.io/component=api' ;;
felis-operator) selector='app.kubernetes.io/name=felis,app.kubernetes.io/component=operator' ;;
registry) selector='app.kubernetes.io/name=felis,app.kubernetes.io/component=registry' ;;
*) selector='' ;;
esac
[ -n "$selector" ] || return 0
kube -n "$CONTROL_NS" get pods -l "$selector" -o wide || true
for pod in $(kube -n "$CONTROL_NS" get pods -l "$selector" -o name 2>/dev/null); do
warn "pod detail: ${pod}"
kube -n "$CONTROL_NS" describe "$pod" || true
warn "recent logs: ${pod}"
kube -n "$CONTROL_NS" logs "$pod" --all-containers --tail=120 || true
kube -n "$CONTROL_NS" logs "$pod" --all-containers --previous --tail=120 || true
done
}
mark_bootstrap_done() {
date -u +%Y-%m-%dT%H:%M:%SZ > "$BOOTSTRAP_DONE"
chmod 0644 "$BOOTSTRAP_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 "Panel URL: https://${NODE_IP}:${FELIS_PANEL_NODEPORT}"
log "DNS alias (if your resolver supports it): https://op.console.${FELIS_ROOT_DOMAIN}:${FELIS_PANEL_NODEPORT}"
log "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit."
if [ "${FELIS_BOOTSTRAP_FROM_TUI:-}" = "1" ]; then
log "Returning to the setup console to create the Owner account and verify panel access."
else
log "Next: run 'sudo felis setup' on this host to create the Owner account."
fi
log "Use 'sudo felis breakGlass' only for emergency local Owner recovery/reset."
echo
}
# ---------------------------------------------------------------------------
# 10. Felis-nano install path — the auth multiplexer only: felis binary + a
# minimal [[auth_source]] config + a systemd unit running `felis nano`.
# No k3s, no Postgres, no control-plane bundle. Chosen at the top-of-run
# prompt (or FELIS_INSTALL_MODE=nano).
# ---------------------------------------------------------------------------
prompt_install_mode() {
case "$INSTALL_MODE" in
full|nano) log "install mode: ${INSTALL_MODE} (from FELIS_INSTALL_MODE)"; return 0 ;;
"") ;;
*) die "FELIS_INSTALL_MODE must be 'full' or 'nano', got: ${INSTALL_MODE}" ;;
esac
# No override: ask on the controlling terminal. Under `curl | sudo bash` stdin
# is the script, so we must read /dev/tty, not stdin. No tty (CI/cloud-init) →
# default to a full install.
if [ ! -r /dev/tty ]; then
INSTALL_MODE="full"
log "no terminal for a prompt; defaulting to a full Felis install (set FELIS_INSTALL_MODE=nano to override)"
return 0
fi
printf '\n'
printf 'What do you want to install on this host?\n'
printf ' [1] Felis — full control plane (k3s + Postgres + panel; orchestrates Minecraft servers)\n'
printf ' [2] Felis-nano — auth multiplexer only (federates Mojang + third-party Yggdrasil; no k3s/DB)\n'
local reply
while :; do
printf 'Choose [1/2] (default 1): '
IFS= read -r reply </dev/tty || reply=""
case "$reply" in
""|1|full|Felis|felis) INSTALL_MODE="full"; break ;;
2|nano|felis-nano|Felis-nano) INSTALL_MODE="nano"; break ;;
*) printf 'Please enter 1 or 2.\n' ;;
esac
done
log "install mode: ${INSTALL_MODE}"
}
install_go_toolchain() {
local arch tarball url
if [ -x "${GOROOT_DIR}/bin/go" ] && "${GOROOT_DIR}/bin/go" version | grep -q "go${FELIS_GO_VERSION} "; then
ok "go ${FELIS_GO_VERSION} already installed at ${GOROOT_DIR}"
return 0
fi
case "$(uname -m)" in
x86_64|amd64) arch="amd64" ;;
aarch64|arm64) arch="arm64" ;;
*) die "no Go toolchain build for architecture $(uname -m); set FELIS_GO_VERSION or pre-stage ${GOROOT_DIR}" ;;
esac
tarball="go${FELIS_GO_VERSION}.linux-${arch}.tar.gz"
url="https://go.dev/dl/${tarball}"
log "installing Go ${FELIS_GO_VERSION} (${arch}) to ${GOROOT_DIR}"
curl -fsSL "$url" -o "/tmp/${tarball}" || die "failed to download the Go toolchain: ${url}"
rm -rf "$GOROOT_DIR"
tar -C "$(dirname "$GOROOT_DIR")" -xzf "/tmp/${tarball}" || die "failed to unpack ${tarball}"
rm -f "/tmp/${tarball}"
ok "go toolchain at ${GOROOT_DIR}/bin/go"
}
build_nano_binary() {
local staged="${SRC_DIR}/.felis-nano-build"
log "building felis from source (${SRC_DIR})"
( cd "$SRC_DIR" \
&& PATH="${GOROOT_DIR}/bin:${PATH}" CGO_ENABLED=0 go build -trimpath -o "$staged" ./cmd/felis ) \
|| die "go build ./cmd/felis failed"
# The whole point of this path is the nano subcommand; a binary without it would
# only surface as a crash-looping felis-nano.service, so fail loudly here instead.
"$staged" -h 2>&1 | grep -qw nano || die "built binary has no 'nano' subcommand"
# Stage-then-install, never `go build -o ${HOST_BIN}` directly: the Go linker renames
# its output out of $TMPDIR, and a same-filesystem rename CARRIES THE SOURCE SELinux
# label — the binary lands in /usr/local/bin still labelled user_tmp_t. Root (being
# unconfined) can still run it, so it looks fine by hand, but the DynamicUser service
# cannot exec it and felis-nano dies with 203/EXEC. Creating the file fresh at the
# destination lets the policy's type transition label it bin_t; restorecon is the belt.
mkdir -p "$(dirname "$HOST_BIN")"
rm -f "$HOST_BIN"
install -m 0755 "$staged" "$HOST_BIN"
rm -f "$staged"
command -v restorecon >/dev/null 2>&1 && restorecon "$HOST_BIN" >/dev/null 2>&1 || true
ok "felis binary on host at ${HOST_BIN}"
}
acquire_nano_binary() {
if bootstrap_from_tui; then
install_embedded_binary
return 0
fi
# Raw curl|bash: no binary yet. Build it straight from source with a pinned Go
# toolchain — nano needs one static binary, not an image, so dragging in docker
# (as the full control-plane path does) buys nothing and costs a daemon that must
# start. It does not start on EL10: the docker-ce el10 rpms install but dockerd
# fails, which used to kill the whole nano install at `systemctl enable --now docker`.
fetch_source
install_go_toolchain
build_nano_binary
}
write_nano_config() {
local target="${STATE_DIR}/felis.toml"
mkdir -p "$STATE_DIR"
if [ -e "$target" ]; then
ok "config already present at ${target}; leaving it (edit it to add [[auth_source]] roots)"
return 0
fi
cat > "$target" <<'EOF'
# Felis-nano — Yggdrasil hasJoined multiplexer.
# Mojang is always the first (identity) source, added in code — do NOT list it here.
# Add each third-party Yggdrasil root below (priority = order). url is the FULL
# hasJoined endpoint. After editing: sudo systemctl restart felis-nano
#
# prefix is required, 1-4 letters/digits, unique per source. A player of this source
# whose name belongs to a Mojang account joins as PREFIX_name (LS_steve) instead —
# otherwise the proxy, which keys its player list on the NAME, refuses to have both
# online at once ("You are already connected to this proxy!"). Everyone else keeps
# their own name.
#
# [[auth_source]]
# tag = "littleskin"
# prefix = "LS"
# url = "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined"
EOF
chmod 0644 "$target"
ok "wrote nano config template ${target} (edit it to add your Yggdrasil sources)"
}
nano_listen_is_loopback() {
case "${FELIS_NANO_LISTEN%:*}" in
127.*|localhost|::1|"[::1]") return 0 ;;
*) return 1 ;;
esac
}
configure_nano_firewall() {
# A loopback bind is unreachable from off-host by construction, so opening the port
# would advertise a hole nothing answers on. Only punch it for a routable bind.
if nano_listen_is_loopback; then
ok "nano listens on ${FELIS_NANO_LISTEN} (loopback); no firewall port opened"
return 0
fi
command -v firewall-cmd >/dev/null 2>&1 || return 0
systemctl is-active --quiet firewalld || return 0
local port="${FELIS_NANO_LISTEN##*:}"
log "opening firewalld port ${port}/tcp for felis-nano"
firewall-cmd --permanent --add-port="${port}/tcp"
firewall-cmd --reload
}
install_nano_service() {
# DynamicUser: no static account, ephemeral UID. nano writes nothing (logs go to
# journald) and only reads the world-readable felis.toml, so strict sandboxing fits.
cat > "$NANO_SERVICE" <<EOF
[Unit]
Description=Felis-nano Yggdrasil hasJoined multiplexer
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=${HOST_BIN} nano -config ${STATE_DIR}/felis.toml -listen ${FELIS_NANO_LISTEN}
Restart=on-failure
RestartSec=5
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable felis-nano
# restart, not `enable --now`: on a re-run the service is already active and --now would
# leave the OLD binary running against the NEW unit. Converge means converge.
systemctl restart felis-nano
ok "felis-nano.service enabled and started (listen ${FELIS_NANO_LISTEN})"
}
summary_nano() {
local port="${FELIS_NANO_LISTEN##*:}" host
if nano_listen_is_loopback; then host="127.0.0.1"; else host="${NODE_IP}"; fi
echo
ok "Felis-nano deployed."
echo
systemctl --no-pager --full status felis-nano 2>/dev/null | head -n 6 || true
echo
log "hasJoined endpoint: http://${host}:${port}/session/minecraft/hasJoined"
log "Point Velocity at it — add to the proxy JVM startup flags (between java and -jar):"
log " -Dmojang.sessionserver=http://${host}:${port}/session/minecraft/hasJoined"
log " (the FULL endpoint URL, path included — Velocity's default for this property is"
log " the full https://sessionserver.mojang.com/session/minecraft/hasJoined)"
if nano_listen_is_loopback; then
log "Bound to loopback: reachable from Velocity on THIS host, and from nowhere else."
log "Proxy on another machine? Re-run with FELIS_NANO_LISTEN=<private-ip>:${port} and"
log "allow ${port}/tcp ONLY from that proxy — hasJoined takes no auth token, so an"
log "internet-facing one is a free auth relay burning your Mojang egress IP."
else
log "WARNING: bound to ${FELIS_NANO_LISTEN} — hasJoined takes no auth token, so restrict"
log "${port}/tcp to your proxy's source IP or anyone can relay their logins through you."
fi
log "Then edit ${STATE_DIR}/felis.toml to add your [[auth_source]] roots and run:"
log " sudo systemctl restart felis-nano"
log "Follow live login traffic with: sudo journalctl -u felis-nano -f"
echo
}
main_nano() {
detect_node_ip
install_base
acquire_nano_binary
write_nano_config
install_nano_service
configure_nano_firewall
summary_nano
}
main() {
validate_settings
detect_os
prompt_install_mode
if [ "$INSTALL_MODE" = "nano" ]; then
main_nano
return
fi
pause_package_background_timers
detect_node_ip
ensure_swap
install_base
install_cloudflared
load_or_make_secrets
ensure_panel_tls_cert
install_docker
install_k3s
if bootstrap_from_tui; then
install_embedded_binary
else
fetch_source
fi
build_image
install_postgres
configure_postgres
run_migrations
deploy_bundle
mark_bootstrap_done
summary
}
main "$@"