fix(bootstrap): keep a nano host's listen address and mode on re-run

Re-running the installer is how a nano host updates. That re-run reset
FELIS_NANO_LISTEN to 127.0.0.1:8081, so a proxy on another machine lost
its endpoint and every login through it failed. It also offered the
full control plane as the default, which on a nano host means k3s and
Postgres nobody asked for.

The listen address is now settled by resolve_nano_listen, the first
step of main, so the later checks see the result. The operator's value
wins, then the -listen argument of the installed felis-nano unit, then
loopback. The install mode defaults to nano, at the prompt and without
a terminal, when the felis-nano unit exists and the full install's
bootstrap.done marker does not. Only the full install writes that
marker.

The harness reads back the unit it wrote earlier, and checks the mode
default on a nano-only host, a host with the full install, and a fresh
host.
This commit is contained in:
flyemoji committed 2026-09-22 13:51:52 +09:00
1 parent 17b4396460
commit 515c4a6496
2 files changed
+89 -10

No files matched your search

+31 -10
View File
@@ -27,9 +27,11 @@
# 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_INSTALL_MODE full|nano — skip the prompt (default: ask on a tty, else full; nano
# instead on a host that runs felis-nano and no full install)
# FELIS_NANO_LISTEN listen addr for `felis nano` (default: the address an installed
# felis-nano already uses, else 127.0.0.1:8081 — loopback only; set a
# private-network IP to serve an off-host proxy)
# FELIS_LEGACY_FORWARDING_SERVERS comma-separated backends that receive their identity
# through the handshake address instead of modern forwarding
# (default: legacy18). Read once at Velocity start, so changing it
@@ -102,7 +104,9 @@ INSTALL_MODE="${FELIS_INSTALL_MODE:-}"
# 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}"
# Left empty here: resolve_nano_listen applies that default only after an existing unit's
# address has had its say.
FELIS_NANO_LISTEN="${FELIS_NANO_LISTEN:-}"
# Backends that take their forwarded identity through the handshake address instead of
# proxy-wide modern forwarding. See write_velocity_service for why a protocol-47 backend
# needs this. Overridable because adding a second 1.8 backend otherwise means editing this
@@ -2196,12 +2200,17 @@ prompt_install_mode() {
*) die "FELIS_INSTALL_MODE must be 'full' or 'nano', got: ${INSTALL_MODE}" ;;
esac
# A felis-nano unit with no full install beside it makes this re-run a nano update;
# defaulting to full there would put k3s and Postgres on a host that asked for neither.
local def=full n=1 reply
if [ -e "$NANO_SERVICE" ] && [ ! -e "$BOOTSTRAP_DONE" ]; then def=nano n=2; fi
# 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.
# take the default.
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)"
INSTALL_MODE="$def"
log "no terminal for a prompt; defaulting to a ${def} install (set FELIS_INSTALL_MODE=full or nano to override)"
return 0
fi
@@ -2209,12 +2218,12 @@ prompt_install_mode() {
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): '
printf 'Choose [1/2] (default %s): ' "$n"
IFS= read -r reply </dev/tty || reply=""
case "$reply" in
""|1|full|Felis|felis) INSTALL_MODE="full"; break ;;
"") INSTALL_MODE="$def"; break ;;
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
@@ -2331,6 +2340,17 @@ EOF
ok "wrote nano config template ${target} (edit it to add your Yggdrasil sources)"
}
# resolve_nano_listen settles FELIS_NANO_LISTEN: the operator's value, else the address the
# installed felis-nano unit listens on, else loopback. Re-running this script is how a nano
# host updates, and without the middle step that re-run moved an off-host proxy's endpoint
# back to 127.0.0.1, so every login through it failed.
resolve_nano_listen() {
if [ -z "$FELIS_NANO_LISTEN" ] && [ -r "$NANO_SERVICE" ]; then
FELIS_NANO_LISTEN="$(sed -n 's/^ExecStart=.* -listen \([^ ]*\).*$/\1/p' "$NANO_SERVICE")"
fi
FELIS_NANO_LISTEN="${FELIS_NANO_LISTEN:-127.0.0.1:8081}"
}
nano_listen_is_loopback() {
case "${FELIS_NANO_LISTEN%:*}" in
127.*|localhost|::1|"[::1]") return 0 ;;
@@ -2429,6 +2449,7 @@ main_nano() {
}
main() {
resolve_nano_listen
validate_settings
detect_os
prompt_install_mode
+58
View File
@@ -251,6 +251,64 @@ esac
out="$(run_nano_service 0)"
expect "a unit that stays up is reported as started" "OK: felis-nano.service enabled and started" "$out"
# --- a re-run on a nano host keeps what that host is --------------------------------------
# Re-running the installer is how a nano host updates. It must not move the endpoint an
# off-host proxy points at, nor default a nano-only host to the full control plane. The unit
# read back here is the one install_nano_service wrote above.
rblock="$(awk '/^resolve_nano_listen\(\) \{/,/^}/' "$BS")"
[ -n "$rblock" ] || { echo "FAIL: no resolve_nano_listen found in $BS"; exit 1; }
[ "$(printf '%s\n' "$rblock" | wc -l)" -lt 20 ] \
|| { echo "FAIL: the extracted block is not the function -- did its closing brace move?"; exit 1; }
run_listen() { # env-value unit-path
FELIS_NANO_LISTEN="$1" NANO_SERVICE="$2" bash -c "$rblock"'
resolve_nano_listen
printf "LISTEN: %s\n" "$FELIS_NANO_LISTEN"'
}
expect "a re-run keeps the unit's listen address" "LISTEN: 127.0.0.1:25580" \
"$(run_listen '' "$sdir/felis-nano.service")"
expect "the operator's address beats the unit's" "LISTEN: 10.0.0.5:8081" \
"$(run_listen 10.0.0.5:8081 "$sdir/felis-nano.service")"
expect "a first install listens on loopback" "LISTEN: 127.0.0.1:8081" \
"$(run_listen '' "$sdir/absent.service")"
expect "a first install takes the operator's address" "LISTEN: 10.0.0.5:8081" \
"$(run_listen 10.0.0.5:8081 "$sdir/absent.service")"
pblock="$(awk '/^prompt_install_mode\(\) \{/,/^}/' "$BS")"
[ -n "$pblock" ] || { echo "FAIL: no prompt_install_mode found in $BS"; exit 1; }
[ "$(printf '%s\n' "$pblock" | wc -l)" -lt 60 ] \
|| { echo "FAIL: the extracted block is not the function -- did its closing brace move?"; exit 1; }
# Only the no-terminal path can run unattended, and with a terminal attached the prompt
# would sit waiting on it. setsid drops the controlling terminal, as cloud-init and CI have.
notty=""
if (: </dev/tty) 2>/dev/null; then
if command -v setsid >/dev/null 2>&1; then notty=setsid; else notty=skip; fi
fi
run_mode() { # unit-path done-marker-path [FELIS_INSTALL_MODE]
INSTALL_MODE="${3:-}" NANO_SERVICE="$1" BOOTSTRAP_DONE="$2" $notty bash -c '
die() { printf "DIE: %s\n" "$*"; exit 1; }
log() { printf "LOG: %s\n" "$*"; }
'"$pblock"'
prompt_install_mode </dev/null
printf "MODE: %s\n" "$INSTALL_MODE"' 2>&1
}
if [ "$notty" = skip ]; then
echo "SKIP install-mode default: a terminal is attached and there is no setsid to drop it"
else
: > "$sdir/bootstrap.done"
expect "a nano-only host re-runs as nano" "MODE: nano" \
"$(run_mode "$sdir/felis-nano.service" "$sdir/absent.done")"
expect "a host with the full install re-runs as full" "MODE: full" \
"$(run_mode "$sdir/felis-nano.service" "$sdir/bootstrap.done")"
expect "a fresh host defaults to full" "MODE: full" \
"$(run_mode "$sdir/absent.service" "$sdir/absent.done")"
fi
# ---------------------------------------------------------------------------------------
if [ "$fails" -eq 0 ]; then
echo "ALL PASS"