diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index b9bfbab..cf599e2 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -35,6 +35,9 @@ # 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_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 # through the handshake address instead of modern forwarding # (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 # address has had its say. 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 # 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 @@ -465,11 +469,23 @@ validate_listen() { 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_timeout PKG_LOCK_TIMEOUT "$PKG_LOCK_TIMEOUT" validate_timeout APT_LOCK_TIMEOUT "$APT_LOCK_TIMEOUT" validate_nodeport FELIS_PANEL_NODEPORT "$FELIS_PANEL_NODEPORT" 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 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" + local port="${FELIS_NANO_LISTEN##*:}" family=ipv4 + # hasJoined takes no token, so the port is opened to the proxy alone. Earlier installers + # 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 } @@ -2489,12 +2517,17 @@ summary_nano() { 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 "exported variables):" - log " curl -fsSL /deploy/bootstrap.sh | sudo FELIS_NANO_LISTEN=:${port} bash" - log "and 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." + log " curl -fsSL /deploy/bootstrap.sh | sudo FELIS_NANO_LISTEN=:${port} FELIS_NANO_PROXY_CIDR=/32 bash" + log "firewalld then admits ${port}/tcp ONLY from that proxy — hasJoined takes no auth token," + 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 - 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." + log "WARNING: bound to ${FELIS_NANO_LISTEN} with no FELIS_NANO_PROXY_CIDR. hasJoined takes no auth" + 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=\"/32\" port port=\"${port}\" protocol=\"tcp\" accept' && firewall-cmd --reload" fi log "Then edit ${STATE_DIR}/felis.toml to add your [[auth_source]] roots and run:" log " sudo systemctl restart felis-nano" diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index 0157bbf..8a727e2 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -313,8 +313,8 @@ kblock="$(awk '/^nano_listen_is_loopback\(\) \{/,/^}/' "$BS")" [ "$(printf '%s\n' "$kblock" | wc -l)" -lt 10 ] \ || { echo "FAIL: the extracted block is not the function -- did its closing brace move?"; exit 1; } -run_summary() { # listen - FELIS_NANO_LISTEN="$1" NODE_IP=203.0.113.9 STATE_DIR=/etc/felis bash -c ' +run_summary() { # listen [proxy-cidr] + 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" "$*"; } log() { printf "LOG: %s\n" "$*"; } systemctl() { :; } @@ -352,6 +352,80 @@ ndefault="$(run_listen '' "$sdir/absent.service")" ndefault="${ndefault#LISTEN: }" 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")" [ -n "$pblock" ] || { echo "FAIL: no prompt_install_mode found in $BS"; exit 1; } [ "$(printf '%s\n' "$pblock" | wc -l)" -lt 60 ] \