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:
2 files changed
+110
-20
No files matched your search
+10
-3
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user