fix(nano): make the installer work on EL10 and stop serving hasJoined to the world

Deploying `felis nano` to a real Rocky Linux 10 host surfaced four defects that
no local check could see. Fixed together because they all sit on the same path
from `curl|bash` to a running felis-nano.service.

* docker killed the nano install on EL10. `acquire_nano_binary` pulled in
  docker purely to build the binary; on Rocky 10.2 the docker-ce el10 rpms
  install but dockerd refuses to start, so the install died at
  `systemctl enable --now docker`. nano needs one static binary, not an image,
  so the docker dependency is gone: fetch_source -> install_go_toolchain
  (pinned FELIS_GO_VERSION, default 1.26.4, amd64/arm64) -> build_nano_binary.

* the built binary could not be exec'd by systemd (203/EXEC). The Go linker
  renames its output out of $TMPDIR, and a same-filesystem rename carries the
  source SELinux label, so `go build -o /usr/local/bin/felis` produced a binary
  labelled user_tmp_t rather than bin_t. root is unconfined and could run it by
  hand, which is what made this look fine, but the DynamicUser service could
  not. build_nano_binary now stages the output and installs it as a fresh file
  so the policy type transition labels it bin_t, with restorecon as a belt.

* re-running the installer did not converge. `systemctl enable --now` is a
  no-op on an already-active unit, so a rebuilt binary was installed while the
  old process kept running. Now enable + restart.

* the Velocity wiring comment in cmd/felis/nano.go was wrong. authlib appends
  /session/minecraft/hasJoined itself, so -Dmojang.sessionserver takes the base
  URL only, as the installer has always printed.

Also bind to loopback by default. hasJoined is unauthenticated by protocol --
authlib speaks the vanilla sessionserver dialect and sends no token -- so a
public bind is an open auth relay: anyone can point their own proxy at it and
spend this host's egress IP on Mojang until Mojang rate-limits it and the
operator's own players stop getting in. It is not an identity bypass (a caller
still needs a serverId hash bound to their own server key, which the upstream
Yggdrasil validates), but it is someone else's traffic on your address.
FELIS_NANO_LISTEN and the -listen flag now default to 127.0.0.1:8081, which a
same-host Velocity reaches unchanged; serving an off-host proxy is an explicit
opt-in. configure_nano_firewall no longer opens a port for a loopback bind, and
summary_nano prints the real bind address plus the relay warning.

Verified on the target host: installs with no docker present, service active,
binary labelled bin_t, `ss` shows LISTEN 127.0.0.1:8081, an external request is
unreachable, and an in-host request returns 204 with the login logged.

BREAKING CHANGE: felis nano defaults to 127.0.0.1:8081 instead of 0.0.0.0:8081.
A Velocity proxy on another machine must now set FELIS_NANO_LISTEN (or -listen)
to a reachable address, and should allow that port only from the proxy's IP.
This commit is contained in:
flyemoji committed 2026-07-13 10:30:18 +09:00
1 parent 87aa9ea625
commit d417efc8bf
2 files changed
+110 -20

No files matched your search

+10 -3
View File
@@ -5,8 +5,11 @@ package main
// full Felis control plane (no k3s, no Postgres, no DB). It reads [[auth_source]] from // full Felis control plane (no k3s, no Postgres, no DB). It reads [[auth_source]] from
// felis.toml, leads with Mojang as the code-owned identity anchor (正版优先), and serves the // felis.toml, leads with Mojang as the code-owned identity anchor (正版优先), and serves the
// vanilla sessionserver hasJoined endpoint. Point Velocity at it with // vanilla sessionserver hasJoined endpoint. Point Velocity at it with
// -Dmojang.sessionserver=http://<this-host>:8081/session/minecraft/hasJoined // -Dmojang.sessionserver=http://127.0.0.1:8081
// and authlib verifies logins against Mojang plus every configured third-party source. // — the base URL only: authlib appends /session/minecraft/hasJoined itself. Then it
// verifies logins against Mojang plus every configured third-party source. Serving a
// proxy on another host means binding off-loopback with -listen; see the flag below for
// why that is an explicit opt-in and not the default.
// //
// This is the no-database delivery of the identical brain `felis api` mounts through its // This is the no-database delivery of the identical brain `felis api` mounts through its
// route table (internal/api.HasJoinedHandler). `felis setup --nano` / the bootstrap nano // route table (internal/api.HasJoinedHandler). `felis setup --nano` / the bootstrap nano
@@ -35,7 +38,11 @@ func cmdNano(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("nano", flag.ContinueOnError) fs := flag.NewFlagSet("nano", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml (reads [[auth_source]])") cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml (reads [[auth_source]])")
listen := fs.String("listen", ":8081", "listen address for the hasJoined endpoint") // Loopback default: hasJoined carries no auth token (authlib speaks the vanilla
// sessionserver protocol), so a public bind is an open auth relay — anyone can point
// their proxy at it and spend this host's egress IP on Mojang. A same-host Velocity
// reaches 127.0.0.1; serving an off-host proxy is an explicit -listen opt-in.
listen := fs.String("listen", "127.0.0.1:8081", "listen address for the hasJoined endpoint")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
} }
+100 -17
View File
@@ -28,7 +28,9 @@
# #
# Tunables (export before running to override the demo defaults): # 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_INSTALL_MODE full|nano — skip the prompt (default: ask on a tty, else full)
# FELIS_NANO_LISTEN listen addr for `felis nano` (default: 0.0.0.0:8081; nano mode) # 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_REPO_URL git URL to build from (raw script mode only)
# FELIS_REF branch/tag/sha (raw script mode only) # FELIS_REF branch/tag/sha (raw script mode only)
# FELIS_IMAGE local image tag (default: felis:demo — never :latest) # FELIS_IMAGE local image tag (default: felis:demo — never :latest)
@@ -48,7 +50,13 @@ FELIS_IMAGE="${FELIS_IMAGE:-felis:demo}"
FELIS_EGRESS_MODE="${FELIS_EGRESS_MODE:-nodeport}" FELIS_EGRESS_MODE="${FELIS_EGRESS_MODE:-nodeport}"
FELIS_PANEL_NODEPORT="${FELIS_PANEL_NODEPORT:-30443}" FELIS_PANEL_NODEPORT="${FELIS_PANEL_NODEPORT:-30443}"
INSTALL_MODE="${FELIS_INSTALL_MODE:-}" INSTALL_MODE="${FELIS_INSTALL_MODE:-}"
FELIS_NANO_LISTEN="${FELIS_NANO_LISTEN:-0.0.0.0:8081}" # 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}}" PKG_LOCK_TIMEOUT="${PKG_LOCK_TIMEOUT:-${APT_LOCK_TIMEOUT:-900}}"
APT_LOCK_TIMEOUT="${APT_LOCK_TIMEOUT:-$PKG_LOCK_TIMEOUT}" APT_LOCK_TIMEOUT="${APT_LOCK_TIMEOUT:-$PKG_LOCK_TIMEOUT}"
@@ -68,6 +76,7 @@ PANEL_TLS_CERT="${STATE_DIR}/panel-tls.crt"
PANEL_TLS_KEY="${STATE_DIR}/panel-tls.key" PANEL_TLS_KEY="${STATE_DIR}/panel-tls.key"
SRC_DIR="/opt/felis/src" SRC_DIR="/opt/felis/src"
HOST_BIN="/usr/local/bin/felis" HOST_BIN="/usr/local/bin/felis"
GOROOT_DIR="/usr/local/go"
NANO_SERVICE="/etc/systemd/system/felis-nano.service" NANO_SERVICE="/etc/systemd/system/felis-nano.service"
K3S_BIN_DIR="${K3S_BIN_DIR:-/usr/local/bin}" K3S_BIN_DIR="${K3S_BIN_DIR:-/usr/local/bin}"
K3S_BIN="${K3S_BIN_DIR}/k3s" K3S_BIN="${K3S_BIN_DIR}/k3s"
@@ -407,6 +416,7 @@ install_base() {
pkg_refresh_once pkg_refresh_once
command -v curl >/dev/null 2>&1 || packages+=(curl) 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 if ! bootstrap_from_tui && ! command -v git >/dev/null 2>&1; then
packages+=(git) packages+=(git)
fi fi
@@ -1090,22 +1100,69 @@ prompt_install_mode() {
log "install mode: ${INSTALL_MODE}" 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() { acquire_nano_binary() {
if bootstrap_from_tui; then if bootstrap_from_tui; then
install_embedded_binary install_embedded_binary
return 0 return 0
fi fi
# Raw curl|bash: no binary yet. Build it from source with docker (the full # Raw curl|bash: no binary yet. Build it straight from source with a pinned Go
# path's proven build), then reclaim docker's RAM. ponytail: reuses the docker # toolchain — nano needs one static binary, not an image, so dragging in docker
# build rather than shipping a Go≥1.26 toolchain installer; a nano host that must # (as the full control-plane path does) buys nothing and costs a daemon that must
# stay docker-free would need a prebuilt-release download — add when asked. # start. It does not start on EL10: the docker-ce el10 rpms install but dockerd
install_docker # fails, which used to kill the whole nano install at `systemctl enable --now docker`.
fetch_source fetch_source
systemctl start docker install_go_toolchain
build_image_from_source build_nano_binary
verify_image_starts
systemctl stop docker docker.socket 2>/dev/null || true
ok "felis binary on host at ${HOST_BIN}"
} }
write_nano_config() { write_nano_config() {
@@ -1129,7 +1186,20 @@ EOF
ok "wrote nano config template ${target} (edit it to add your Yggdrasil sources)" 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() { 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 command -v firewall-cmd >/dev/null 2>&1 || return 0
systemctl is-active --quiet firewalld || return 0 systemctl is-active --quiet firewalld || return 0
local port="${FELIS_NANO_LISTEN##*:}" local port="${FELIS_NANO_LISTEN##*:}"
@@ -1161,21 +1231,34 @@ ProtectHome=yes
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now felis-nano 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})" ok "felis-nano.service enabled and started (listen ${FELIS_NANO_LISTEN})"
} }
summary_nano() { summary_nano() {
local port="${FELIS_NANO_LISTEN##*:}" local port="${FELIS_NANO_LISTEN##*:}" host
if nano_listen_is_loopback; then host="127.0.0.1"; else host="${NODE_IP}"; fi
echo echo
ok "Felis-nano deployed." ok "Felis-nano deployed."
echo echo
systemctl --no-pager --full status felis-nano 2>/dev/null | head -n 6 || true systemctl --no-pager --full status felis-nano 2>/dev/null | head -n 6 || true
echo echo
log "hasJoined endpoint: http://${NODE_IP}:${port}/session/minecraft/hasJoined" log "hasJoined endpoint: http://${host}:${port}/session/minecraft/hasJoined"
log "Point Velocity at it — add to the proxy JVM startup flags:" log "Point Velocity at it — add to the proxy JVM startup flags:"
log " -Dmojang.sessionserver=http://${NODE_IP}:${port}" log " -Dmojang.sessionserver=http://${host}:${port}"
log " (Velocity on THIS host? use http://127.0.0.1:${port} instead — no firewall hop)" log " (base URL only — authlib appends the path itself)"
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 "Then edit ${STATE_DIR}/felis.toml to add your [[auth_source]] roots and run:"
log " sudo systemctl restart felis-nano" log " sudo systemctl restart felis-nano"
log "Follow live login traffic with: sudo journalctl -u felis-nano -f" log "Follow live login traffic with: sudo journalctl -u felis-nano -f"