fix(bootstrap): open the nano port to the proxy alone

For a non-loopback bind, configure_nano_firewall opened the nano port
in firewalld to every source, while the summary told the operator to
restrict it to the proxy. hasJoined takes no token, so on a public
host that port is an auth relay anyone can point a proxy at, spending
this host's Mojang egress until Mojang rate-limits it and the
operator's own players stop getting in.

A new FELIS_NANO_PROXY_CIDR names the proxy. With it, firewalld gets
one rich rule that admits the port from that source only, ipv4 or
ipv6 by the address given. Without it, no port is opened and the
summary prints the rule to add. A re-run closes the port an earlier
installer opened to every source. A rule for a previous
FELIS_NANO_PROXY_CIDR is not tracked and stays until removed by hand.
Hosts without firewalld are handled as before.

The value goes into the rule text, so it is checked up front for an
address with one prefix length and nothing else. firewalld's own
parser accepts both rule forms and refuses an ipv6 address under the
ipv4 family. The harness covers the rule for each family, the
closed-by-default case, the re-run cleanup, the loopback case and the
CIDR check.
This commit is contained in:
flyemoji committed 2026-09-22 14:51:49 +09:00
1 parent 99c31c1d4e
commit 0faec2b02a
2 files changed
+117 -10

No files matched your search

+41 -8
View File
@@ -35,6 +35,9 @@
# FELIS_NANO_LISTEN listen addr for `felis nano` (default: the address an installed # 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 # felis-nano already uses, else 127.0.0.1:8081 — loopback only; set a
# private-network IP to serve an off-host proxy) # private-network IP to serve an off-host proxy)
# FELIS_NANO_PROXY_CIDR the proxy allowed to reach a non-loopback nano bind, as an address
# with a prefix length (for example 10.0.0.7/32). firewalld opens the
# port to that source only; unset, it opens nothing
# FELIS_LEGACY_FORWARDING_SERVERS comma-separated backends that receive their identity # FELIS_LEGACY_FORWARDING_SERVERS comma-separated backends that receive their identity
# through the handshake address instead of modern forwarding # through the handshake address instead of modern forwarding
# (default: legacy18). Read once at Velocity start, so changing it # (default: legacy18). Read once at Velocity start, so changing it
@@ -112,6 +115,7 @@ INSTALL_MODE="${FELIS_INSTALL_MODE:-}"
# Left empty here: resolve_nano_listen applies that default only after an existing unit's # Left empty here: resolve_nano_listen applies that default only after an existing unit's
# address has had its say. # address has had its say.
FELIS_NANO_LISTEN="${FELIS_NANO_LISTEN:-}" FELIS_NANO_LISTEN="${FELIS_NANO_LISTEN:-}"
FELIS_NANO_PROXY_CIDR="${FELIS_NANO_PROXY_CIDR:-}"
# Backends that take their forwarded identity through the handshake address instead of # 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 # 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 # needs this. Overridable because adding a second 1.8 backend otherwise means editing this
@@ -465,11 +469,23 @@ validate_listen() {
esac esac
} }
# The value lands inside a firewalld rich rule, so anything but address characters and one
# prefix length is refused here rather than handed to firewall-cmd.
validate_cidr() {
case "$2" in
"") return 0 ;;
*[!0-9A-Fa-f.:/]*|*/*/*|*/|/*) ;;
*/[0-9]*) return 0 ;;
esac
die "$1 must be an address with a prefix length (for example 10.0.0.7/32 or fd00::7/128), got: $2"
}
validate_settings() { validate_settings() {
validate_timeout PKG_LOCK_TIMEOUT "$PKG_LOCK_TIMEOUT" validate_timeout PKG_LOCK_TIMEOUT "$PKG_LOCK_TIMEOUT"
validate_timeout APT_LOCK_TIMEOUT "$APT_LOCK_TIMEOUT" validate_timeout APT_LOCK_TIMEOUT "$APT_LOCK_TIMEOUT"
validate_nodeport FELIS_PANEL_NODEPORT "$FELIS_PANEL_NODEPORT" validate_nodeport FELIS_PANEL_NODEPORT "$FELIS_PANEL_NODEPORT"
validate_listen FELIS_NANO_LISTEN "$FELIS_NANO_LISTEN" validate_listen FELIS_NANO_LISTEN "$FELIS_NANO_LISTEN"
validate_cidr FELIS_NANO_PROXY_CIDR "$FELIS_NANO_PROXY_CIDR"
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2428,9 +2444,21 @@ configure_nano_firewall() {
fi 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##*:}" family=ipv4
log "opening firewalld port ${port}/tcp for felis-nano" # hasJoined takes no token, so the port is opened to the proxy alone. Earlier installers
firewall-cmd --permanent --add-port="${port}/tcp" # opened it to every source, and a re-run must not leave that behind. A rule for a previous
# FELIS_NANO_PROXY_CIDR is not tracked; it stays until removed by hand.
if firewall-cmd --permanent --query-port="${port}/tcp" >/dev/null 2>&1; then
log "closing firewalld port ${port}/tcp, which an earlier install opened to every source"
firewall-cmd --permanent --remove-port="${port}/tcp"
fi
if [ -n "$FELIS_NANO_PROXY_CIDR" ]; then
case "$FELIS_NANO_PROXY_CIDR" in *:*) family=ipv6 ;; esac
log "opening firewalld port ${port}/tcp to ${FELIS_NANO_PROXY_CIDR} only"
firewall-cmd --permanent --add-rich-rule="rule family=\"${family}\" source address=\"${FELIS_NANO_PROXY_CIDR}\" port port=\"${port}\" protocol=\"tcp\" accept"
else
warn "no FELIS_NANO_PROXY_CIDR, so firewalld keeps ${port}/tcp closed; the summary shows how to admit your proxy"
fi
firewall-cmd --reload firewall-cmd --reload
} }
@@ -2489,12 +2517,17 @@ summary_nano() {
log "Bound to loopback: reachable from Velocity on THIS host, and from nowhere else." log "Bound to loopback: reachable from Velocity on THIS host, and from nowhere else."
log "Proxy on another machine? Re-run with the address on the sudo line (sudo drops" log "Proxy on another machine? Re-run with the address on the sudo line (sudo drops"
log "exported variables):" log "exported variables):"
log " curl -fsSL <raw-url>/deploy/bootstrap.sh | sudo FELIS_NANO_LISTEN=<private-ip>:${port} bash" log " curl -fsSL <raw-url>/deploy/bootstrap.sh | sudo FELIS_NANO_LISTEN=<private-ip>:${port} FELIS_NANO_PROXY_CIDR=<proxy-ip>/32 bash"
log "and allow ${port}/tcp ONLY from that proxy — hasJoined takes no auth token, so an" log "firewalld then admits ${port}/tcp ONLY from that proxy — hasJoined takes no auth token,"
log "internet-facing one is a free auth relay burning your Mojang egress IP." log "so an internet-facing one is a free auth relay burning your Mojang egress IP."
elif [ -n "$FELIS_NANO_PROXY_CIDR" ]; then
log "Bound to ${FELIS_NANO_LISTEN}. firewalld, where it runs, admits ${port}/tcp only from"
log "${FELIS_NANO_PROXY_CIDR}; any other firewall in front of this host must do the same."
else else
log "WARNING: bound to ${FELIS_NANO_LISTEN} — hasJoined takes no auth token, so restrict" log "WARNING: bound to ${FELIS_NANO_LISTEN} with no FELIS_NANO_PROXY_CIDR. hasJoined takes no auth"
log "${port}/tcp to your proxy's source IP or anyone can relay their logins through you." log "token, so admit ${port}/tcp from your proxy alone, or anyone can relay their logins"
log "through you. firewalld, where it runs, keeps the port closed until you add:"
log " firewall-cmd --permanent --add-rich-rule='rule family=\"ipv4\" source address=\"<proxy-ip>/32\" port port=\"${port}\" protocol=\"tcp\" accept' && firewall-cmd --reload"
fi 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"
+76 -2
View File
@@ -313,8 +313,8 @@ kblock="$(awk '/^nano_listen_is_loopback\(\) \{/,/^}/' "$BS")"
[ "$(printf '%s\n' "$kblock" | wc -l)" -lt 10 ] \ [ "$(printf '%s\n' "$kblock" | wc -l)" -lt 10 ] \
|| { echo "FAIL: the extracted block is not the function -- did its closing brace move?"; exit 1; } || { echo "FAIL: the extracted block is not the function -- did its closing brace move?"; exit 1; }
run_summary() { # listen run_summary() { # listen [proxy-cidr]
FELIS_NANO_LISTEN="$1" NODE_IP=203.0.113.9 STATE_DIR=/etc/felis bash -c ' FELIS_NANO_LISTEN="$1" FELIS_NANO_PROXY_CIDR="${2:-}" NODE_IP=203.0.113.9 STATE_DIR=/etc/felis bash -c '
ok() { printf "OK: %s\n" "$*"; } ok() { printf "OK: %s\n" "$*"; }
log() { printf "LOG: %s\n" "$*"; } log() { printf "LOG: %s\n" "$*"; }
systemctl() { :; } systemctl() { :; }
@@ -352,6 +352,80 @@ ndefault="$(run_listen '' "$sdir/absent.service")"
ndefault="${ndefault#LISTEN: }" ndefault="${ndefault#LISTEN: }"
expect "the default listen address (${ndefault:-empty}) is loopback" LOOPBACK "$(run_loopback "$ndefault")" expect "the default listen address (${ndefault:-empty}) is loopback" LOOPBACK "$(run_loopback "$ndefault")"
# --- firewalld admits the proxy alone ---------------------------------------------------
# hasJoined takes no token, so a routable bind is opened only to FELIS_NANO_PROXY_CIDR, never
# to every source, and a re-run closes the port an earlier installer opened to everyone.
cblock="$(awk '/^validate_cidr\(\) \{/,/^}/' "$BS")"
[ -n "$cblock" ] || { echo "FAIL: no validate_cidr found in $BS"; exit 1; }
[ "$(printf '%s\n' "$cblock" | wc -l)" -lt 15 ] \
|| { echo "FAIL: the extracted block is not the function -- did its closing brace move?"; exit 1; }
check_cidr() { # value
bash -c 'die() { printf "DIE: %s\n" "$*"; exit 1; }
'"$cblock"'
validate_cidr FELIS_NANO_PROXY_CIDR "$1" && echo VALID' _ "$1" 2>&1
}
for v in 10.0.0.7 10.0.0.7/ /32 10.0.0.0/8/9 10.0.0.7/x '10.0.0.7/32 port' '10.0.0.7/32"'; do
expect "proxy CIDR <$v> is refused" "DIE: FELIS_NANO_PROXY_CIDR" "$(check_cidr "$v")"
done
for v in '' 10.0.0.7/32 192.168.0.0/24 fd00::7/128; do
expect "proxy CIDR <$v> is accepted" VALID "$(check_cidr "$v")"
done
fblock="$(awk '/^configure_nano_firewall\(\) \{/,/^}/' "$BS")"
[ -n "$fblock" ] || { echo "FAIL: no configure_nano_firewall found in $BS"; exit 1; }
[ "$(printf '%s\n' "$fblock" | wc -l)" -lt 40 ] \
|| { echo "FAIL: the extracted block is not the function -- did its closing brace move?"; exit 1; }
run_fw() { # listen proxy-cidr port-already-open(0|1)
FELIS_NANO_LISTEN="$1" FELIS_NANO_PROXY_CIDR="$2" OPEN="$3" bash -c '
ok() { printf "OK: %s\n" "$*"; }
log() { printf "LOG: %s\n" "$*"; }
warn() { printf "WARN: %s\n" "$*"; }
systemctl() { return 0; }
firewall-cmd() {
case "$*" in *--query-port=*) [ "$OPEN" = 1 ]; return ;; esac
printf "FW: %s\n" "$*"
}
'"$kblock"'
'"$fblock"'
configure_nano_firewall' 2>&1
}
no_blanket_port() { # label output
case "$2" in
*--add-port*) echo "FAIL $1: the port was opened to every source:"; echo "$2"; fails=$((fails + 1)) ;;
*) echo "PASS $1" ;;
esac
}
out="$(run_fw 0.0.0.0:8081 10.0.0.7/32 0)"
expect "a proxy CIDR opens the port to that source alone" \
'FW: --permanent --add-rich-rule=rule family="ipv4" source address="10.0.0.7/32" port port="8081" protocol="tcp" accept' "$out"
no_blanket_port "a proxy CIDR never opens the port to every source" "$out"
expect "an IPv6 proxy CIDR gets an ipv6 rule" 'rule family="ipv6" source address="fd00::7/128"' \
"$(run_fw '[::]:8081' fd00::7/128 0)"
out="$(run_fw 0.0.0.0:8081 '' 0)"
expect "no proxy CIDR says the port stays closed" "WARN: no FELIS_NANO_PROXY_CIDR" "$out"
no_blanket_port "no proxy CIDR opens nothing" "$out"
case "$out" in
*--add-rich-rule*) echo "FAIL no proxy CIDR must add no rule:"; echo "$out"; fails=$((fails + 1)) ;;
*) echo "PASS no proxy CIDR adds no rule" ;;
esac
expect "a re-run closes the port an earlier install opened to everyone" "FW: --permanent --remove-port=8081/tcp" \
"$(run_fw 0.0.0.0:8081 10.0.0.7/32 1)"
case "$(run_fw 127.0.0.1:8081 10.0.0.7/32 1)" in
*FW:*) echo "FAIL a loopback bind must leave firewalld alone"; fails=$((fails + 1)) ;;
*) echo "PASS a loopback bind leaves firewalld alone" ;;
esac
expect "a routable bind with no proxy CIDR is warned about" "WARNING: bound to 10.0.0.5:8081 with no FELIS_NANO_PROXY_CIDR" \
"$(run_summary 10.0.0.5:8081)"
expect "a routable bind with a proxy CIDR names it" "admits 8081/tcp only from" \
"$(run_summary 10.0.0.5:8081 10.0.0.7/32)"
pblock="$(awk '/^prompt_install_mode\(\) \{/,/^}/' "$BS")" pblock="$(awk '/^prompt_install_mode\(\) \{/,/^}/' "$BS")"
[ -n "$pblock" ] || { echo "FAIL: no prompt_install_mode found in $BS"; exit 1; } [ -n "$pblock" ] || { echo "FAIL: no prompt_install_mode found in $BS"; exit 1; }
[ "$(printf '%s\n' "$pblock" | wc -l)" -lt 60 ] \ [ "$(printf '%s\n' "$pblock" | wc -l)" -lt 60 ] \