Unverified Commit 7819e5de authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(netpol): 锁定游戏服出站并为 registry 加入站围栏

parent 3424852a
Loading
Loading
Loading
Loading
+10 −1
Changes for cmd/felis/manifests.go: 10 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -54,6 +54,10 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
	fs.Var(&velocityCIDRs, "velocity-cidr", "CIDR of a Velocity proxy host allowed to reach game port 25565 (repeatable, REQUIRED)")
	var packageCIDRs multiFlag
	fs.Var(&packageCIDRs, "package-cidr", "CIDR of a package mirror build Pods may reach (repeatable; default none = no internet egress)")
	var serverDenyCIDRs multiFlag
	fs.Var(&serverDenyCIDRs, "server-egress-deny-cidr", "extra CIDR game server pods may never reach, e.g. the node's public address (repeatable)")
	var serverAllowCIDRs multiFlag
	fs.Var(&serverAllowCIDRs, "server-egress-allow-cidr", "private CIDR game server pods may reach despite the private-range block, e.g. a LAN database (repeatable)")
	if err := fs.Parse(args); err != nil {
		return 2
	}
@@ -74,7 +78,9 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
			"(the felis-api/operator Deployments run it and it is passed through as FELIS_IMAGE, e.g. --felis-image registry.felis.svc:5000/felis:v1)")
		return 2
	}
	for _, cidr := range append(append([]string{}, velocityCIDRs...), packageCIDRs...) {
	allCIDRs := append(append([]string{}, velocityCIDRs...), packageCIDRs...)
	allCIDRs = append(append(allCIDRs, serverDenyCIDRs...), serverAllowCIDRs...)
	for _, cidr := range allCIDRs {
		if _, _, err := net.ParseCIDR(cidr); err != nil {
			fmt.Fprintf(stderr, "felis manifests: invalid CIDR %q: %v\n", cidr, err)
			return 2
@@ -148,6 +154,9 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
		ArchiveLocalPath:   *archiveLocalPath,
		VelocityCIDRs:      []string(velocityCIDRs),
		PackageSourceCIDRs: []string(packageCIDRs),

		ServerEgressDenyCIDRs:  []string(serverDenyCIDRs),
		ServerEgressAllowCIDRs: []string(serverAllowCIDRs),
	})
	if err != nil {
		fmt.Fprintf(stderr, "felis manifests: render: %v\n", err)
+89 −2
Changes for deploy/bootstrap.sh: 89 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -299,6 +299,7 @@ die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }

TEMP_PATHS=()
DOCKER_CONTAINERS=()
REGISTRY_DOCKER_CONFIG=""
PKG_TIMERS_TO_RESTORE=()

on_error() {
@@ -359,6 +360,40 @@ apply_felis_config_secrets() {
    --dry-run=client -o yaml | kube apply -f -
}

# The registry gate reads one token file per principal from felis-registry-auth
# (registry namespace = control namespace); a build Job's push container reads the
# build principal's username/password from felis-registry-push in the build
# namespace. Values go through 0600 temp files, never kubectl's argv.
apply_registry_secrets() {
  local dir
  dir="$(umask 077; mktemp -d)"
  remember_temp "$dir"
  printf '%s' "$REGISTRY_PLATFORM_TOKEN" > "${dir}/platform"
  printf '%s' "$REGISTRY_BUILD_TOKEN" > "${dir}/build"
  printf '%s' build > "${dir}/username"
  kube -n "$CONTROL_NS" create secret generic felis-registry-auth \
    --from-file=platform="${dir}/platform" \
    --from-file=build="${dir}/build" \
    --dry-run=client -o yaml | kube apply -f -
  kube -n "$BUILD_NS" create secret generic felis-registry-push \
    --from-file=username="${dir}/username" \
    --from-file=password="${dir}/build" \
    --dry-run=client -o yaml | kube apply -f -
  rm -rf -- "$dir"
}

# node_global_cidrs prints one host-length CIDR per global address on this node.
# Game server egress already excludes every private range; this adds the node's
# public addresses, which would otherwise let a server dial the panel NodePort,
# SSH, or anything else the host serves on them.
node_global_cidrs() {
  command -v ip >/dev/null 2>&1 || return 0
  ip -o addr show scope global 2>/dev/null | awk '
    $3 == "inet"  { split($4, a, "/"); print a[1] "/32" }
    $3 == "inet6" { split($4, a, "/"); print a[1] "/128" }
  ' | sort -u
}

as_postgres() {
  if command -v runuser >/dev/null 2>&1; then
    runuser -u postgres -- "$@"
@@ -933,6 +968,30 @@ import_registry_image() {
  systemctl stop docker docker.socket 2>/dev/null || true
}

# The registry pod runs registry:2 and, as its registry-gate sidecar, the felis
# image — neither of which can be pulled from the registry they make up. A kubelet
# image GC that collected either would leave the registry, and every pull through
# it, dead until someone re-imported by hand. containerd reports an image labelled
# io.cri-containerd.pinned=pinned as pinned over CRI, and kubelet's image GC never
# removes a pinned image. Older felis/felis tags are unpinned first, so upgrades
# do not pile up pinned images forever.
pin_registry_images() {
  local ref
  while read -r ref; do
    case "$ref" in
      "$FELIS_IMAGE") ;;
      */felis/felis:*) k3s_cmd ctr images label "$ref" io.cri-containerd.pinned= >/dev/null 2>&1 || true ;;
    esac
  done < <(k3s_cmd ctr images ls -q 2>/dev/null || true)
  for ref in "$FELIS_IMAGE" docker.io/library/registry:2; do
    if k3s_cmd ctr images label "$ref" io.cri-containerd.pinned=pinned >/dev/null 2>&1; then
      ok "pinned ${ref} in containerd (exempt from kubelet image GC)"
    else
      warn "could not pin ${ref} in containerd: if the kubelet's image GC collects it, the registry pod cannot restart until it is re-imported (docs/troubleshooting.md §8e)"
    fi
  done
}

# ---------------------------------------------------------------------------
# 5. Source/binary + image build + containerd import
# ---------------------------------------------------------------------------
@@ -2042,6 +2101,12 @@ load_or_make_secrets() {
  # the username — i.e. anyone could join as anyone, the Owner included. Same value on
  # the proxy (forwarding.secret) and in every backend pod (felis-forwarding-secret).
  FORWARDING_SECRET="${FORWARDING_SECRET:-$(openssl rand -hex 32)}"
  # Registry write credentials, one per principal the registry gate knows
  # (internal/registrygate): platform pushes the installer's own images and the
  # Trivy DB mirrors, build is what a build Job's push container presents and may
  # never write under felis/ or mirror/. Reads stay anonymous.
  REGISTRY_PLATFORM_TOKEN="${REGISTRY_PLATFORM_TOKEN:-$(openssl rand -hex 32)}"
  REGISTRY_BUILD_TOKEN="${REGISTRY_BUILD_TOKEN:-$(openssl rand -hex 32)}"
  (
    umask 077
    cat > "$SECRETS_ENV" <<EOF
@@ -2049,6 +2114,8 @@ DB_PASSWORD=${DB_PASSWORD}
SERVICE_TOKEN=${SERVICE_TOKEN}
SESSION_SECRET=${SESSION_SECRET}
FORWARDING_SECRET=${FORWARDING_SECRET}
REGISTRY_PLATFORM_TOKEN=${REGISTRY_PLATFORM_TOKEN}
REGISTRY_BUILD_TOKEN=${REGISTRY_BUILD_TOKEN}
EOF
  )
  chmod 0600 "$SECRETS_ENV"
@@ -2324,7 +2391,7 @@ deploy_bundle() {
    kube create namespace "$ns" --dry-run=client -o yaml | kube apply -f -
  done

  log "provisioning felis-config + felis-service-token + felis-forwarding-secret + panel TLS secrets (out-of-band, never in the bundle)"
  log "provisioning felis-config + felis-service-token + felis-forwarding-secret + registry credentials + panel TLS secrets (out-of-band, never in the bundle)"
  apply_felis_config_secrets
  apply_literal_secret "$CONTROL_NS" felis-service-token token "$SERVICE_TOKEN"
  # The build namespace needs the same token: the build Job's fetch initContainer
@@ -2337,6 +2404,7 @@ deploy_bundle() {
  # forwarding mode is one proxy-wide setting — a backend that does not speak it is not
  # "less secure", it is unjoinable.
  apply_literal_secret "$CONTROL_NS" felis-forwarding-secret secret "$FORWARDING_SECRET"
  apply_registry_secrets
  kube -n "$CONTROL_NS" create secret tls felis-api-tls \
    --cert="$PANEL_TLS_CERT" \
    --key="$PANEL_TLS_KEY" \
@@ -2348,6 +2416,10 @@ deploy_bundle() {
    --panel-node-port "$FELIS_PANEL_NODEPORT"
    --velocity-cidr "${NODE_IP}/32"
  )
  local cidr
  while read -r cidr; do
    [ -n "$cidr" ] && manifest_args+=(--server-egress-deny-cidr "$cidr")
  done < <(node_global_cidrs)
  # Backups are on by default (the renderer's own default names felis-backups); an emptied
  # FELIS_BACKUP_PVC asks for the no-backup shape explicitly, and a custom name must be
  # passed through or the api would advertise a PVC the bundle never created.
@@ -2422,10 +2494,22 @@ push_image_to_registry() {
  esac
  log "mirroring ${ref} into the internal registry"
  docker tag "$ref" "$push_ref" || die "could not tag ${ref} as ${push_ref} — is docker healthy?"
  docker push "$push_ref" || die "could not mirror ${ref} into the internal registry — check the registry Deployment/pod and its PVC"
  docker --config "$REGISTRY_DOCKER_CONFIG" push "$push_ref" || die "could not mirror ${ref} into the internal registry — check the registry Deployment/pod (both the registry and registry-gate containers) and its PVC"
  docker rmi "$push_ref" >/dev/null 2>&1 || true
}

# registry_docker_login logs a throwaway docker config into the registry gate as
# the platform principal: writes are refused anonymously, and this identity is
# the only one allowed under felis/. The config lives in a 0700 temp dir that the
# EXIT trap removes, so the token never lands in root's ~/.docker.
registry_docker_login() {
  REGISTRY_DOCKER_CONFIG="$(umask 077; mktemp -d)"
  remember_temp "$REGISTRY_DOCKER_CONFIG"
  printf '%s' "$REGISTRY_PLATFORM_TOKEN" | docker --config "$REGISTRY_DOCKER_CONFIG" \
    login --username platform --password-stdin "$REGISTRY_PUSH_HOST" >/dev/null \
    || die "could not log in to the internal registry at ${REGISTRY_PUSH_HOST} as platform — check the registry-gate container's log and the felis-registry-auth Secret"
}

# Every image this installer builds is hosted in the registry, so the copies it
# imported into containerd are a first-boot cache, not the only copy: kubelet
# re-pulls from the registry after any image GC. Runs AFTER deploy_bundle — the
@@ -2439,6 +2523,7 @@ push_image_to_registry() {
push_images_to_registry() {
  local img
  systemctl start docker
  registry_docker_login
  for img in "$FELIS_IMAGE" "$FELIS_LIMBO_IMAGE" "$FELIS_LOBBY_IMAGE" "$FELIS_PAPER_IMAGE"; do
    [ -n "$img" ] || continue
    push_image_to_registry "$img"
@@ -2871,6 +2956,8 @@ main() {
    fetch_source
  fi
  build_image
  # After build_image imported the felis image: the registry pod's gate runs it.
  pin_registry_images
  build_game_stack
  install_postgres
  configure_postgres
+82 −5
Changes for deploy/bootstrap_test.sh: 82 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -670,6 +670,7 @@ run_bundle_flags() { # backup-pvc worlds-host-path
    kube() { cat; }
    myManifests() { printf "%s\n" "$@"; }
    setfacl() { printf "SETFACL %s\n" "$*"; }
    node_global_cidrs() { printf "203.0.113.7/32\n2001:db8::7/128\n"; }
    run_bundle() {
    '"$mblock"'
    }
@@ -685,6 +686,27 @@ esac

out="$(run_bundle_flags '' '')"
expect "an emptied FELIS_BACKUP_PVC is the explicit no-backup shape" "--backup-pvc=" "$out"
# Game server egress excludes every private range already; the node's own public
# addresses must be excluded too or a server can dial the panel NodePort on them.
expect "every global node address is denied to game server egress (v4)" "--server-egress-deny-cidr
203.0.113.7/32" "$out"
expect "every global node address is denied to game server egress (v6)" "--server-egress-deny-cidr
2001:db8::7/128" "$out"

ngblock="$(awk '/^node_global_cidrs\(\) \{/,/^}/' "$BS")"
[ -n "$ngblock" ] || { echo "FAIL: no node_global_cidrs found in $BS"; exit 1; }
out="$(bash -c '
  ip() {
    printf "2: eth0    inet 203.0.113.7/24 brd 203.0.113.255 scope global eth0\\       valid_lft forever\n"
    printf "2: eth0    inet6 2001:db8::7/64 scope global dynamic\\       valid_lft 86000sec\n"
  }
  '"$ngblock"'
  node_global_cidrs')"
expect "a global v4 address becomes a /32" "203.0.113.7/32" "$out"
expect "a global v6 address becomes a /128" "2001:db8::7/128" "$out"
case "$out" in
  */24*|*/64*) echo "FAIL: node_global_cidrs must deny the address, not its whole subnet"; fails=$((fails + 1)) ;;
esac

out="$(run_bundle_flags felis-backups /var/lib/rancher/k3s/storage)"
expect "enabling retention passes the worlds root" "--worlds-host-path
@@ -758,7 +780,7 @@ pblock="$(awk '/^push_image_to_registry\(\) \{/,/^}/' "$BS")"

run_push() { # ref [docker-push-exit]
  REF="$1" PUSH_EXIT="${2:-0}" \
  REGISTRY_URL=registry.felis.svc:5000 REGISTRY_PUSH_HOST=127.0.0.1:5000 \
  REGISTRY_URL=registry.felis.svc:5000 REGISTRY_PUSH_HOST=127.0.0.1:5000 REGISTRY_DOCKER_CONFIG=/cfg \
  bash -c '
    log() { printf "LOG: %s\n" "$*"; }
    warn() { printf "WARN: %s\n" "$*"; }
@@ -766,9 +788,9 @@ run_push() { # ref [docker-push-exit]
    ok() { :; }
    systemctl() { :; }
    docker() {
      case "$1" in
        push) printf "DOCKER %s\n" "$*"; return "$PUSH_EXIT" ;;
        *) printf "DOCKER %s\n" "$*" ;;
      printf "DOCKER %s\n" "$*"
      case " $* " in
        *" push "*) return "$PUSH_EXIT" ;;
      esac
    }
    '"$pblock"'
@@ -778,7 +800,7 @@ run_push() { # ref [docker-push-exit]
out="$(run_push registry.felis.svc:5000/felis/felis:demo)"
expect "a registry ref is re-tagged onto the node loopback endpoint" \
  "DOCKER tag registry.felis.svc:5000/felis/felis:demo 127.0.0.1:5000/felis/felis:demo" "$out"
expect "and pushed to exactly that endpoint" "DOCKER push 127.0.0.1:5000/felis/felis:demo" "$out"
expect "and pushed to exactly that endpoint, with the platform login" "DOCKER --config /cfg push 127.0.0.1:5000/felis/felis:demo" "$out"

out="$(run_push registry.felis.svc:50000/felis/felis:demo)"
expect "a ref outside the registry is refused with a warning" "WARN: not mirroring" "$out"
@@ -798,15 +820,70 @@ out="$(
  FELIS_IMAGE=a FELIS_LIMBO_IMAGE=b FELIS_LOBBY_IMAGE=c FELIS_PAPER_IMAGE=d bash -c '
    systemctl() { printf "SYSTEMCTL %s\n" "$*"; }
    push_image_to_registry() { printf "PUSH %s\n" "$1"; }
    registry_docker_login() { printf "LOGIN\n"; }
    '"$wiblock"'
    push_images_to_registry'
)"
expect "the batch logs in to the registry gate before pushing" "SYSTEMCTL start docker
LOGIN
PUSH a" "$out"
starts="$(printf '%s\n' "$out" | grep -c 'SYSTEMCTL start docker')"
stops="$(printf '%s\n' "$out" | grep -c 'SYSTEMCTL stop docker')"
[ "$starts" = 1 ] && [ "$stops" = 1 ] && [ "$(printf '%s\n' "$out" | grep -c '^PUSH')" = 4 ] \
  && echo "PASS the batch wraps all four pushes in ONE docker start/stop" \
  || { echo "FAIL: expected 1 start / 1 stop / 4 pushes, got:"; printf '%s\n' "$out"; fails=$((fails + 1)); }

# The registry refuses anonymous writes, and the platform token must never reach
# docker's argv (ps) or root's ~/.docker: stdin into a throwaway --config dir.
lblock="$(awk '/^registry_docker_login\(\) \{/,/^}/' "$BS")"
[ -n "$lblock" ] || { echo "FAIL: no registry_docker_login found in $BS"; exit 1; }
calls="$(mktemp)"
out="$(
  CALLS="$calls" REGISTRY_PLATFORM_TOKEN=s3cret REGISTRY_PUSH_HOST=127.0.0.1:5000 bash -c '
    die() { printf "DIE: %s\n" "$*"; exit 1; }
    remember_temp() { printf "TEMP %s\n" "$1"; }
    docker() { printf "DOCKER %s STDIN=%s\n" "$*" "$(cat)" >>"$CALLS"; }
    '"$lblock"'
    registry_docker_login
    rm -rf "$REGISTRY_DOCKER_CONFIG"'
)$(printf '\n'; cat "$calls")"
rm -f "$calls"
expect "the installer logs in as the platform principal via stdin" "login --username platform --password-stdin 127.0.0.1:5000 STDIN=s3cret" "$out"
case "$(printf '%s\n' "$out" | grep '^DOCKER')" in
  *"DOCKER --config /"*) echo "PASS the login writes a throwaway docker config" ;;
  *) echo "FAIL: registry_docker_login must use a --config temp dir, got: $out"; fails=$((fails + 1)) ;;
esac
case "$out" in
  *"--password s3cret"*|*"-p s3cret"*) echo "FAIL: the registry token reached docker argv"; fails=$((fails + 1)) ;;
esac
expect "the throwaway config is registered for EXIT cleanup" "TEMP /" "$out"

# The registry pod's two images can only come from containerd's own store: pin
# both against kubelet image GC, and unpin a previous felis tag.
pnblock="$(awk '/^pin_registry_images\(\) \{/,/^}/' "$BS")"
[ -n "$pnblock" ] || { echo "FAIL: no pin_registry_images found in $BS"; exit 1; }
calls="$(mktemp)"
out="$(
  CALLS="$calls" FELIS_IMAGE=registry.felis.svc:5000/felis/felis:v2 bash -c '
    ok() { printf "OK: %s\n" "$*"; }
    warn() { printf "WARN: %s\n" "$*"; }
    k3s_cmd() {
      case "$*" in
        "ctr images ls -q") printf "registry.felis.svc:5000/felis/felis:v1\nregistry.felis.svc:5000/felis/felis:v2\ndocker.io/library/registry:2\nregistry.felis.svc:5000/felis/limbo:demo\n" ;;
        *) printf "CTR %s\n" "$*" >>"$CALLS" ;;
      esac
    }
    '"$pnblock"'
    pin_registry_images'
)$(printf '\n'; cat "$calls")"
rm -f "$calls"
expect "the running felis image is pinned" "CTR ctr images label registry.felis.svc:5000/felis/felis:v2 io.cri-containerd.pinned=pinned" "$out"
expect "registry:2 is pinned" "CTR ctr images label docker.io/library/registry:2 io.cri-containerd.pinned=pinned" "$out"
expect "a previous felis tag is unpinned" "CTR ctr images label registry.felis.svc:5000/felis/felis:v1 io.cri-containerd.pinned=" "$out"
case "$out" in
  *"limbo:demo io.cri"*) echo "FAIL: only the registry pod's images may be pinned or unpinned"; fails=$((fails + 1)) ;;
esac

# --- the registry's own image must not be re-pulled on every run --------------------------
iblock="$(awk '/^import_registry_image\(\) \{/,/^}/' "$BS")"
[ -n "$iblock" ] || { echo "FAIL: no import_registry_image found in $BS"; exit 1; }
+8 −5
Changes for deploy/limbo/README.md: 8 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -143,11 +143,14 @@ set them by hand:
  pod's internal port 8081. That Service is deliberately separate from the external
  NodePort `felis-api` (443) so the no-Zero-Trust internal face is never published on
  a node's external IP.
- **NetworkPolicy:** none is required today — neither the minecraft-namespace egress
  nor the control-namespace ingress is policy-locked, so the login pod's call to the
  API internal port is reachable. If a future deployment adds a minecraft egress lock
  or a control-namespace ingress fence, it must also open the login-pod →
  felis-api-internal (8081) path.
- **NetworkPolicy:** the minecraft namespace is egress-locked
  (`felis-server-egress`: DNS plus the public internet, every private range
  excluded), so the internal API is unreachable from a game server by default.
  `felis-login-to-internal-api` opens exactly the login pod → felis-api (8081) path,
  selecting on the reserved `login` name AND the setup-owned
  `felis.lolicon.best/system-role=login` label the operator copies onto the pod — the
  same pair that decides who receives `FELIS_SERVICE_TOKEN`, so a user server cannot
  match it by picking a name.

The Velocity gate/lobby wiring is printed by `felis setup` and enforces the
invariant: fresh connections hit `login` first, and only an authenticated release
+8 −8
Changes for docs/deferred-seams.md: 8 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -60,9 +60,9 @@ A grep across `*.md` and `*.go` returns both sets; only the Go ones are seams.
  (recipe in docs/troubleshooting.md §8e); `trivy_java_db_repository` does the
  same for the Java DB, which Trivy fetches so soon as the scanned image contains
  a jar — i.e. for every real modpack build. Left unset on an egress-locked box
  the scan step fails closed — Kaniko pushes, Trivy exits on the DB download —
  which is the correct fail direction but leaves the build unfinished, so the
  mirrors are part of a production build install.
  the scan step fails closed — Trivy exits on the DB download before anything is
  pushed — which is the correct fail direction but leaves every build unfinished,
  so the mirrors are part of a production build install.

## Built; only its I/O is unverifiable from this repo

@@ -132,8 +132,8 @@ worth revisiting.

## Recorded outside the code

- `deploy/limbo/README.md:139` — no NetworkPolicy locks the minecraft-namespace
  egress or the control-namespace ingress today, which is why the login pod reaches
  `felis-api-internal:8081`. This is a conditional obligation rather than a seam: if
  a future deployment adds either lock, it must also open that path. Spec v4.1 §21
  asks for those policies; `cmd/felis/manifests.go` renders the game-port one.
- The minecraft-namespace egress is locked (`felis-server-egress`, DNS plus the
  public internet with every private range and the node's own global addresses
  excluded) and `felis-login-to-internal-api` opens the one platform path a game pod
  needs — login → felis-api:8081. Any new in-cluster service a game server must call
  needs its own allow policy next to that one (`internal/platform/netpol.go`).
Loading