feat(bootstrap): game stack 按 lock 文件固定构建并校验 sha256,基础镜像按 digest 固定,JRE 固定补丁版本

This commit is contained in:
Lemon-miaow committed 2026-09-25 00:24:02 +08:00
1 parent aace66e8d3
commit 82545548e7
13 files changed
+632 -94

No files matched your search

+223 -47
View File
@@ -46,6 +46,14 @@
# proxy instead of the stock download (default: unset, stock).
# FELIS_VELOCITY_FORK_JAR_SHA256 expected sha256 of that jar. REQUIRED whenever the jar
# above is set; the install refuses on a mismatch.
# FELIS_GAME_STACK pinned|latest — which Limbo, Paper, LuckPerms and Velocity builds to
# install (default: pinned, the builds deploy/game-stack.lock names,
# each checked against its sha256). latest resolves upstream's newest
# builds on every run; the Minecraft version then follows Limbo's CI.
# FELIS_JRE_VERSION Temurin feature version for the proxy (default: 25, whose build and
# digests are pinned; a rerun moves an installer-managed JRE to the
# pinned build). Another feature version is checked against the
# digest Adoptium's API publishes for it.
# FELIS_GO_VERSION Go toolchain used to build the nano binary (default: 1.26.4)
# FELIS_GO_SHA256 sha256 of that version's linux tarball for this host's architecture.
# REQUIRED for a non-default FELIS_GO_VERSION; the default's is pinned.
@@ -239,12 +247,17 @@ FELIS_LOBBY_IMAGE="${FELIS_LOBBY_IMAGE:-${REGISTRY_URL}/felis/lobby:demo}"
# server — forwarding is applied by the operator's init-forwarding initContainer, so it
# needs no secret. Seeded recommended in 0019_recommended_paper.sql.
FELIS_PAPER_IMAGE="${FELIS_PAPER_IMAGE:-${REGISTRY_URL}/felis/paper:demo}"
# The Velocity MINOR is pinned, not discovered. PaperMC's Fill v3 groups velocity
# builds by version group, and "newest across all groups" today means 4.0.0-SNAPSHOT —
# an UNRELEASED proxy (the 4.0.0 group has zero published builds) that needs a Java 25
# runtime. Crossing a major is a deliberate code change, so we track the newest BUILD of
# a pinned minor and let a human move the pin.
FELIS_VELOCITY_VERSION="${FELIS_VELOCITY_VERSION:-3.5.1}"
# The Velocity build comes from deploy/game-stack.lock (VELOCITY_VERSION and its jar digest).
# Setting FELIS_VELOCITY_VERSION to another version, or FELIS_GAME_STACK=latest, installs
# the newest BUILD of that minor instead. The minor itself is never discovered: PaperMC's
# Fill v3 groups velocity builds by version group, and "newest across all groups" can mean
# an unreleased 4.x SNAPSHOT that needs another runtime. Crossing a major is a deliberate
# code change.
FELIS_VELOCITY_VERSION="${FELIS_VELOCITY_VERSION:-}"
VELOCITY_LATEST_MINOR="3.5.1"
# pinned installs the builds deploy/game-stack.lock names (resolve_game_jars); latest asks
# upstream for its newest ones, which is how that lock file gets refreshed.
FELIS_GAME_STACK="${FELIS_GAME_STACK:-pinned}"
# Path to a Felis-Legacy Velocity fork build, installed as the proxy in place of the
# stock download. Unset — the default — changes nothing.
#
@@ -275,6 +288,14 @@ FELIS_VELOCITY_FORK_JAR_SHA256="${FELIS_VELOCITY_FORK_JAR_SHA256:-}"
# a lottery across four package managers — a tarball is one code path everywhere (same
# reasoning as install_go_toolchain).
FELIS_JRE_VERSION="${FELIS_JRE_VERSION:-25}"
# The JRE the proxy runs on is unpacked as root and carries every player session, so the
# default feature version is pinned to one build and the sha256 Adoptium publishes for each
# architecture. Move the three together (the Adoptium API lists them:
# /v3/assets/latest/25/hotspot?image_type=jre&os=linux).
JRE_PINNED_FEATURE="25"
JRE_PINNED_RELEASE="25.0.4.1+1"
JRE_PINNED_SHA256_X64="1731a34baadec5479258ea0202e4d5d865d2efeee60cb0c7d7eb056fe96ca219"
JRE_PINNED_SHA256_AARCH64="34828cbb93ed31c281c84ecb31ddab655d11a802f263c1fc019d42e9e0230fed"
# The port the proxy listens on: the ONLY Minecraft port players ever touch. Backends
# are ClusterIP-only, verify the modern-forwarding HMAC, and use NetworkPolicy to limit
# non-node ingress to the declared proxy CIDRs.
@@ -671,6 +692,10 @@ validate_settings() {
validate_listen FELIS_NANO_LISTEN "$FELIS_NANO_LISTEN"
validate_cidr FELIS_NANO_PROXY_CIDR "$FELIS_NANO_PROXY_CIDR"
validate_offsite_settings
case "$FELIS_GAME_STACK" in
pinned|latest) ;;
*) die "FELIS_GAME_STACK must be pinned or latest (got '${FELIS_GAME_STACK}')" ;;
esac
}
# validate_offsite_settings checks the FELIS_OFFSITE_* inputs before anything is
@@ -1198,7 +1223,7 @@ github_api() {
# can never disagree about what "latest" means.
github_latest_tag() {
local json tag
# Fetch first, filter second — the SIGPIPE reason documented on resolve_game_jars.
# Fetch first, filter second — the SIGPIPE reason documented on resolve_latest_game_jars.
json="$(github_api "repos/$(repo_slug)/releases/latest")" || return 1
tag="$(printf '%s' "$json" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' || true)"
tag="${tag%%$'\n'*}"
@@ -1236,7 +1261,7 @@ felis_asset_arch() {
# reachable this way — fetch releases/assets/<id> with the JSON Accept if that is ever needed.
github_asset_id() {
local tag="$1" name="$2" json id
# Fetch first, filter second — the SIGPIPE reason documented on resolve_game_jars.
# Fetch first, filter second — the SIGPIPE reason documented on resolve_latest_game_jars.
json="$(github_api "repos/$(repo_slug)/releases/tags/${tag}")" || return 1
id="$(printf '%s' "$json" | tr -d '\n' | tr '{' '\n' \
| grep "\"name\":[[:space:]]*\"${name}\"" \
@@ -1685,11 +1710,6 @@ game_stack_source() {
ok "game-stack sources unpacked to ${GAME_STACK_DIR}"
}
# resolve_game_jars pins Limbo and Paper to the SAME Minecraft version. LOOHP/Limbo
# speaks exactly one protocol per build, so the login gate dictates the version and Paper
# follows — a client that can pass the gate must also be able to reach the lobby.
# MC_VERSION is read off Limbo's CI artifact name (Limbo-<limbo-ver>-<mc-ver>.jar), which
# is the only place the pairing is published.
# meta_get prints a small metadata document. curl's --retry covers transient HTTP
# statuses and timeouts; a TLS handshake cut mid-way (exit 35, seen against Fill over
# a flaky IPv6 path) is outside its retry set, so the outer loop retries every failure
@@ -1710,8 +1730,71 @@ meta_get() {
return 1
}
# resolve_game_jars sets the artifacts the three game images and the proxy are built from:
# the builds deploy/game-stack.lock names (FELIS_GAME_STACK=pinned, the default), or
# upstream's newest ones (latest). Pinned is what makes an install reproducible: every host
# installing one release gets the same login gate, lobby and proxy, each download is
# checked against the lock's sha256, and a rerun's docker builds hit their cache, so
# restart_existing_system_servers leaves the login and lobby pods running.
resolve_game_jars() {
local ci="https://ci.loohpjames.com/job/Limbo/lastSuccessfulBuild" meta file base rest paper
if [ "$FELIS_GAME_STACK" = "latest" ]; then
resolve_latest_game_jars
return 0
fi
load_game_stack_lock "${GAME_STACK_DIR}/deploy/game-stack.lock"
ok "Limbo ${LIMBO_VERSION} + Paper on Minecraft ${MC_VERSION}, LuckPerms and Velocity ${VELOCITY_VERSION}: the builds game-stack.lock pins"
}
GAME_STACK_LOCK_KEYS="MC_VERSION LIMBO_VERSION LIMBO_JAR_URL LIMBO_JAR_SHA256 LIMBO_SCHEM_URL LIMBO_SCHEM_SHA256 PAPER_JAR_URL PAPER_JAR_SHA256 LUCKPERMS_JAR_URL LUCKPERMS_JAR_SHA256 VELOCITY_VERSION VELOCITY_JAR_URL VELOCITY_JAR_SHA256"
# load_game_stack_lock reads the lock's KEY=value lines into the globals of the same names.
# It never sources the file: only the keys above are accepted, every one has to be set, the
# values are limited to URL and version characters (they reach docker build-args), and each
# *_SHA256 has to be a sha256.
load_game_stack_lock() {
local file="$1" line key value
[ -f "$file" ] || die "no game-stack lock at ${file}; FELIS_GAME_STACK=latest resolves upstream's newest builds instead"
for key in $GAME_STACK_LOCK_KEYS; do printf -v "$key" '%s' ""; done
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in ''|'#'*) continue ;; esac
key="${line%%=*}"
value="${line#*=}"
[ "$key" != "$line" ] || die "${file}: not a KEY=value line: ${line}"
case " ${GAME_STACK_LOCK_KEYS} " in
*" ${key} "*) ;;
*) die "${file}: unknown key ${key}" ;;
esac
case "$value" in
''|*[!A-Za-z0-9._:/+%-]*) die "${file}: ${key} has an unexpected value: ${value}" ;;
esac
printf -v "$key" '%s' "$value"
done < "$file"
for key in $GAME_STACK_LOCK_KEYS; do
[ -n "${!key}" ] || die "${file} does not set ${key}"
case "$key" in
*_SHA256) [[ "${!key}" =~ ^[0-9a-f]{64}$ ]] || die "${file}: ${key} is not a lowercase sha256" ;;
esac
done
}
# url_sha256 prints the sha256 of what $1 serves.
url_sha256() {
local sum
sum="$(curl -fsSL --retry 5 --retry-delay 2 -A "felis-bootstrap (+https://github.com/FelisMC/Felis)" "$1" | sha256sum)" || return 1
printf '%s\n' "${sum%% *}"
}
# resolve_latest_game_jars pins Limbo and Paper to the SAME Minecraft version. LOOHP/Limbo
# speaks exactly one protocol per build, so the login gate dictates the version and Paper
# follows — a client that can pass the gate must also be able to reach the lobby.
# MC_VERSION is read off Limbo's CI artifact name (Limbo-<limbo-ver>-<mc-ver>.jar), which
# is the only place the pairing is published.
#
# Limbo's CI and LuckPerms publish no digest, so latest hashes their downloads as it finds
# them: the image builds still check that they receive those bytes, but nothing vouches for
# the bytes themselves. That is what the lock file adds.
resolve_latest_game_jars() {
local ci="https://ci.loohpjames.com/job/Limbo/lastSuccessfulBuild" meta build file base rest paper
log "resolving the newest LOOHP/Limbo CI build"
# Fetch first, filter second: `curl | grep | head` dies of SIGPIPE under `set -o pipefail`
# the moment head closes the pipe early. Same shape everywhere below.
@@ -1720,6 +1803,13 @@ resolve_game_jars() {
file="$(printf '%s' "$meta" | grep -o 'Limbo-[0-9A-Za-z._-]*\.jar' || true)"
file="${file%%$'\n'*}"
[ -n "$file" ] || die "no Limbo jar in the LOOHP/Limbo CI artifact list"
# The numbered build, so the jar hashed below is the jar the image build downloads even
# if CI finishes another build in between.
build="$(printf '%s' "$meta" | grep -o '"number":[0-9]*' || true)"
build="${build%%$'\n'*}"
build="${build#*:}"
[ -n "$build" ] || die "no build number in the LOOHP/Limbo CI metadata"
ci="https://ci.loohpjames.com/job/Limbo/${build}"
base="${file%.jar}" # Limbo-2026.0.2-ALPHA-26.2
MC_VERSION="${base##*-}" # 26.2
@@ -1742,7 +1832,16 @@ resolve_game_jars() {
log "resolving the newest LuckPerms build"
LUCKPERMS_JAR_URL="$(luckperms_latest_jar)" \
|| die "could not resolve a LuckPerms build (metadata.luckperms.net is down or flapping); the lobby needs it for the panel's permission controls"
ok "Limbo ${LIMBO_VERSION} + Paper, both on Minecraft ${MC_VERSION}; LuckPerms resolved"
log "hashing the Limbo and LuckPerms downloads (their upstreams publish no digest)"
LIMBO_JAR_SHA256="$(url_sha256 "$LIMBO_JAR_URL")" || die "could not download ${LIMBO_JAR_URL}"
LIMBO_SCHEM_SHA256="$(url_sha256 "$LIMBO_SCHEM_URL")" || die "could not download ${LIMBO_SCHEM_URL}"
LUCKPERMS_JAR_SHA256="$(url_sha256 "$LUCKPERMS_JAR_URL")" || die "could not download ${LUCKPERMS_JAR_URL}"
VELOCITY_VERSION="$VELOCITY_LATEST_MINOR"
VELOCITY_JAR_URL=""
VELOCITY_JAR_SHA256=""
ok "Limbo ${LIMBO_VERSION} (CI build ${build}) + Paper, both on Minecraft ${MC_VERSION}; LuckPerms resolved"
warn "FELIS_GAME_STACK=latest: these are upstream's builds as of now, not the ones this release pins"
}
# luckperms_latest_jar prints the download URL of the current LuckPerms Bukkit build.
@@ -1792,7 +1891,9 @@ build_game_stack() {
log "building ${FELIS_LIMBO_IMAGE} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})"
docker build -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \
--build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" \
--build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \
--build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \
--build-arg LIMBO_SCHEM_SHA256="$LIMBO_SCHEM_SHA256" \
--build-arg LIMBO_VERSION="$LIMBO_VERSION" \
-t "$FELIS_LIMBO_IMAGE" "$GAME_STACK_DIR"
@@ -1801,6 +1902,7 @@ build_game_stack() {
--build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \
--build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
--build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \
--build-arg LUCKPERMS_JAR_SHA256="$LUCKPERMS_JAR_SHA256" \
-t "$FELIS_LOBBY_IMAGE" "$GAME_STACK_DIR"
# Plain Paper, same MC_VERSION and PAPER_JAR_URL (no new dependency). Forwarding is the
@@ -1985,29 +2087,84 @@ pin_via_block_connections() {
}
install_jre() {
local arch url
if [ -x "${JRE_DIR}/bin/java" ]; then
ok "JRE already installed at ${JRE_DIR}"
return 0
fi
local arch want url release json
case "$(uname -m)" in
x86_64|amd64) arch="x64" ;;
aarch64|arm64) arch="aarch64" ;;
*) die "no Temurin JRE build for architecture $(uname -m); pre-stage one at ${JRE_DIR}" ;;
x86_64|amd64) arch="x64"; want="$JRE_PINNED_SHA256_X64" ;;
aarch64|arm64) arch="aarch64"; want="$JRE_PINNED_SHA256_AARCH64" ;;
*)
if [ -x "${JRE_DIR}/bin/java" ]; then
ok "JRE already installed at ${JRE_DIR}"
return 0
fi
die "no Temurin JRE build for architecture $(uname -m); pre-stage one at ${JRE_DIR}"
;;
esac
url="https://api.adoptium.net/v3/binary/latest/${FELIS_JRE_VERSION}/ga/linux/${arch}/jre/hotspot/normal/eclipse"
log "installing Temurin ${FELIS_JRE_VERSION} JRE (${arch}) to ${JRE_DIR}"
local tmp
if [ "$FELIS_JRE_VERSION" = "$JRE_PINNED_FEATURE" ]; then
release="$JRE_PINNED_RELEASE"
url="https://github.com/adoptium/temurin${JRE_PINNED_FEATURE}-binaries/releases/download/jdk-${release/+/%2B}/OpenJDK${JRE_PINNED_FEATURE}U-jre_${arch}_linux_hotspot_${release/+/_}.tar.gz"
else
# Another feature version is installed once and then left alone; its digest is the
# one Adoptium's API publishes next to the link.
if [ -x "${JRE_DIR}/bin/java" ]; then
ok "JRE already installed at ${JRE_DIR}"
return 0
fi
json="$(meta_get "https://api.adoptium.net/v3/assets/latest/${FELIS_JRE_VERSION}/hotspot?architecture=${arch}&image_type=jre&os=linux&vendor=eclipse")" \
|| die "could not ask the Adoptium API for a Temurin ${FELIS_JRE_VERSION} JRE"
url="$(printf '%s' "$json" | grep -o '"link": *"[^"]*\.tar\.gz"' || true)"
url="${url%%$'\n'*}"
url="${url%\"}"
url="${url##*\"}"
want="$(printf '%s' "$json" | grep -o '"checksum": *"[0-9a-f]\{64\}"' || true)"
want="${want%%$'\n'*}"
want="${want%\"}"
want="${want##*\"}"
release="$(printf '%s' "$json" | grep -o '"release_name": *"jdk-[^"]*"' || true)"
release="${release%%$'\n'*}"
release="${release%\"}"
release="${release##*\"jdk-}"
[ -n "$url" ] && [ -n "$want" ] && [ -n "$release" ] \
|| die "the Adoptium API lists no Temurin ${FELIS_JRE_VERSION} JRE for linux/${arch}"
fi
# A rerun moves the installer's own JRE to the pinned build, which is how a JRE security
# release reaches the proxy: bump the pin, rerun, and install_velocity_service restarts
# the proxy because the JRE's release file changed. A JRE someone else put here (another
# vendor, or pre-staged for an architecture Temurin does not build) is left alone.
if [ -x "${JRE_DIR}/bin/java" ]; then
if grep -qxF "IMPLEMENTOR_VERSION=\"Temurin-${release}\"" "${JRE_DIR}/release" 2>/dev/null; then
ok "Temurin ${release} JRE already installed at ${JRE_DIR}"
return 0
fi
if ! grep -qxF 'IMPLEMENTOR="Eclipse Adoptium"' "${JRE_DIR}/release" 2>/dev/null; then
ok "JRE at ${JRE_DIR} is not a Temurin build this installer put there; left as is"
return 0
fi
log "moving the proxy's JRE to Temurin ${release}"
fi
log "installing Temurin ${release} JRE (${arch}) to ${JRE_DIR}"
local tmp have
tmp="$(mktemp -d)"
remember_temp "$tmp"
curl -fsSL "$url" -o "${tmp}/jre.tar.gz" || die "failed to download the Temurin JRE: ${url}"
mkdir -p "$JRE_DIR"
curl -fsSL --retry 5 --retry-delay 2 "$url" -o "${tmp}/jre.tar.gz" \
|| die "failed to download the Temurin JRE: ${url}"
have="$(sha256sum <"${tmp}/jre.tar.gz" | cut -d' ' -f1)"
[ "$have" = "$want" ] \
|| die "Temurin ${release} JRE (${arch}) hashes to ${have}, expected ${want}; refusing to install it"
# Unpacked beside the live one and swapped in with two renames, so a failed unpack leaves
# the proxy's runtime untouched. The running proxy keeps the files it has open.
rm -rf "${JRE_DIR}.new" "${JRE_DIR}.old"
mkdir -p "${JRE_DIR}.new"
# The tarball has a single versioned top-level directory (jdk-25+36-jre/); strip it so
# the path in the systemd unit never carries a build number.
tar -C "$JRE_DIR" --strip-components=1 -xzf "${tmp}/jre.tar.gz" || die "failed to unpack the JRE"
[ -x "${JRE_DIR}/bin/java" ] || die "unpacked JRE has no bin/java"
ok "JRE at ${JRE_DIR}/bin/java"
tar -C "${JRE_DIR}.new" --strip-components=1 -xzf "${tmp}/jre.tar.gz" || die "failed to unpack the JRE"
[ -x "${JRE_DIR}.new/bin/java" ] || die "unpacked JRE has no bin/java"
[ ! -e "$JRE_DIR" ] || mv "$JRE_DIR" "${JRE_DIR}.old"
mv "${JRE_DIR}.new" "$JRE_DIR"
rm -rf "${JRE_DIR}.old"
ok "JRE at ${JRE_DIR}/bin/java (Temurin ${release})"
}
install_velocity() {
@@ -2039,22 +2196,18 @@ install_velocity() {
log "installing the Felis-Legacy Velocity fork from ${FELIS_VELOCITY_FORK_JAR} (sha256 ${have})"
atomic_install_file "$FELIS_VELOCITY_FORK_JAR" "${VELOCITY_DIR}/velocity.jar" 0644 root root
else
log "resolving the newest Velocity ${FELIS_VELOCITY_VERSION} build"
resolved="$(papermc_latest_jar velocity "$FELIS_VELOCITY_VERSION")" \
|| die "no Velocity build for ${FELIS_VELOCITY_VERSION} (override with FELIS_VELOCITY_VERSION)"
url="${resolved% *}"
want="${resolved##* }"
log "downloading Velocity ${FELIS_VELOCITY_VERSION}"
tmp="$(mktemp "${VELOCITY_DIR}/.velocity.jar.XXXXXX")"
remember_temp "$tmp"
curl -fsSL "$url" -o "$tmp" || die "failed to download Velocity: ${url}"
# The same gate the Via plugins and the fork jar pass: this jar is the proxy every
# player connects through, and Fill already promised its digest in the URL — a
# truncated or tampered download becomes a refusal here, not a proxy that won't boot.
have="$(sha256sum <"$tmp" | cut -d' ' -f1)"
[ "$have" = "$want" ] \
|| die "Velocity ${FELIS_VELOCITY_VERSION} checksum mismatch: got ${have}, expected ${want}"
atomic_install_file "$tmp" "${VELOCITY_DIR}/velocity.jar" 0644 root root
local version="${FELIS_VELOCITY_VERSION:-${VELOCITY_VERSION:-$VELOCITY_LATEST_MINOR}}"
if [ "$FELIS_GAME_STACK" = "pinned" ] && [ "$version" = "${VELOCITY_VERSION:-}" ]; then
url="$VELOCITY_JAR_URL"
want="$VELOCITY_JAR_SHA256"
else
log "resolving the newest Velocity ${version} build"
resolved="$(papermc_latest_jar velocity "$version")" \
|| die "no Velocity build for ${version} (override with FELIS_VELOCITY_VERSION)"
url="${resolved% *}"
want="${resolved##* }"
fi
stage_velocity_jar "$url" "$want" "$version"
fi
install_via_plugins
@@ -2063,6 +2216,29 @@ install_velocity() {
configure_velocity_firewall
}
# stage_velocity_jar installs the stock proxy from $1 unless velocity.jar already hashes to
# $2, so a rerun of the same build neither downloads nor touches the file the proxy runs.
stage_velocity_jar() {
local url="$1" want="$2" version="$3" have="" tmp
[ -f "${VELOCITY_DIR}/velocity.jar" ] && have="$(sha256sum <"${VELOCITY_DIR}/velocity.jar" | cut -d' ' -f1)"
if [ "$have" = "$want" ]; then
ok "Velocity ${version} already staged"
return 0
fi
log "downloading Velocity ${version}"
tmp="$(mktemp "${VELOCITY_DIR}/.velocity.jar.XXXXXX")"
remember_temp "$tmp"
curl -fsSL --retry 5 --retry-delay 2 "$url" -o "$tmp" || die "failed to download Velocity: ${url}"
# The same gate the Via plugins and the fork jar pass: this jar is the proxy every
# player connects through, and the lock file (or Fill's content-addressed URL) names its
# digest — a truncated or tampered download becomes a refusal here, not a proxy that
# won't boot.
have="$(sha256sum <"$tmp" | cut -d' ' -f1)"
[ "$have" = "$want" ] \
|| die "Velocity ${version} checksum mismatch: got ${have}, expected ${want}"
atomic_install_file "$tmp" "${VELOCITY_DIR}/velocity.jar" 0644 root root
}
# felis_internal_ip echoes the felis-api-internal Service ClusterIP. Cluster DNS does not
# resolve from the host, but a Service ClusterIP DOES route from the node (kube-proxy programs
# the host netns) — the same trick the on-node break-glass console uses. The internal face is