Unverified Commit 3424852a authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(registry): 写入改走鉴权网关,构建先扫描再推送

parent 8f684ced
Loading
Loading
Loading
Loading
+117 −0
Changes for cmd/felis/registrygate.go: 117 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"path/filepath"
	"strings"
	"syscall"
	"time"

	"felis.lolicon.best/internal/imagepush"
	"felis.lolicon.best/internal/registrygate"
)

// cmdRegistryGate is the sidecar entrypoint in the registry pod: it owns the
// registry port (and the loopback hostPort containerd pulls through), lets reads
// through anonymously, and forwards writes to the loopback-only registry:2 only
// for an authenticated principal allowed to write that repository. See
// internal/registrygate for the policy.
//
// Tokens are files under --auth-dir, one per principal (platform, build), mounted
// from the registry-auth Secret. A missing file disables that principal: writes
// fail closed while every pull keeps working, which is the right way round for a
// registry the running workloads depend on.
func cmdRegistryGate(args []string, _, stderr io.Writer) int {
	fs := flag.NewFlagSet("registry-gate", flag.ContinueOnError)
	fs.SetOutput(stderr)
	listen := fs.String("listen", ":5000", "address the gate serves the registry API on")
	upstream := fs.String("upstream", "http://127.0.0.1:5001", "the loopback registry the gate forwards to")
	authDir := fs.String("auth-dir", "/etc/felis-registry-auth", "directory holding one token file per principal")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	target, err := url.Parse(*upstream)
	if err != nil || target.Scheme == "" || target.Host == "" {
		fmt.Fprintf(stderr, "felis registry-gate: bad --upstream %q\n", *upstream)
		return 2
	}
	log := slog.New(slog.NewTextHandler(stderr, nil))
	tokens := map[string]string{}
	for _, p := range []string{registrygate.PrincipalPlatform, registrygate.PrincipalBuild} {
		b, err := os.ReadFile(filepath.Join(*authDir, p))
		tok := strings.TrimSpace(string(b))
		if err != nil || tok == "" {
			log.Warn("registry principal disabled: no token", "principal", p, "dir", *authDir)
			continue
		}
		tokens[p] = tok
	}

	srv := &http.Server{
		Addr:              *listen,
		Handler:           registrygate.New(target, tokens, log),
		ReadHeaderTimeout: 10 * time.Second,
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	go func() {
		<-ctx.Done()
		shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		_ = srv.Shutdown(shutdown)
	}()
	log.Info("registry gate listening", "addr", *listen, "upstream", target.String(), "principals", len(tokens))
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		fmt.Fprintf(stderr, "felis registry-gate: %v\n", err)
		return 1
	}
	return 0
}

// cmdPushImage is the build Job's publish step. It runs after Kaniko built the
// image into a tarball (--no-push) and Trivy passed that tarball, and it is the
// only container of the build pod that holds the registry credential — the one
// executing the untrusted Dockerfile never sees it.
func cmdPushImage(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("push-image", flag.ContinueOnError)
	fs.SetOutput(stderr)
	tarPath := fs.String("tar", "", "image tarball Kaniko wrote with --tar-path")
	ref := fs.String("ref", "", "host/repository:tag to publish it as")
	scheme := fs.String("scheme", "http", "registry scheme: http for the in-cluster registry, https otherwise")
	if err := fs.Parse(args); err != nil {
		return 2
	}
	if *tarPath == "" || *ref == "" {
		fmt.Fprintln(stderr, "felis push-image: --tar and --ref are required")
		return 2
	}
	if *scheme != "http" && *scheme != "https" {
		fmt.Fprintf(stderr, "felis push-image: bad --scheme %q\n", *scheme)
		return 2
	}
	user := os.Getenv("FELIS_REGISTRY_USERNAME")
	pass := os.Getenv("FELIS_REGISTRY_PASSWORD")
	if user == "" || pass == "" {
		fmt.Fprintln(stderr, "felis push-image: FELIS_REGISTRY_USERNAME/FELIS_REGISTRY_PASSWORD are empty — the registry refuses anonymous writes")
		return 2
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	p := &imagepush.Pusher{Scheme: *scheme, Username: user, Password: pass, Log: stderr}
	digest, err := p.Push(ctx, *tarPath, *ref)
	if err != nil {
		fmt.Fprintf(stderr, "felis push-image: %v\n", err)
		return 1
	}
	fmt.Fprintln(stdout, digest)
	return 0
}
+4 −0
Changes for cmd/felis/run.go: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -20,6 +20,8 @@ Commands:
  backup            Archive a world into the backup store and record it (internal Job entrypoint)
  files             List/read/write one file in a stopped server's world (internal Job entrypoint)
  fetch-context     Fetch and extract a submission's build context (internal Job entrypoint)
  push-image        Push a scanned image tarball to the registry (internal Job entrypoint)
  registry-gate     Authorize registry writes in front of registry:2 (internal sidecar entrypoint)
  manifests         Render the control-plane RBAC + NetworkPolicy install bundle as YAML
  apply             Create a MinecraftServer CRD (direct K8s write; use -f server.json)
  setup             Run host bootstrap + first-run setup console (TUI; requires root/sudo)
@@ -50,6 +52,8 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
	"backup":           cmdBackup,
	"files":            cmdFiles,
	"fetch-context":    cmdFetchContext,
	"push-image":       cmdPushImage,
	"registry-gate":    cmdRegistryGate,
	"manifests":        cmdManifests,
	"apply":            cmdApply,
	"setup":            cmdSetup,
+1 −1
Changes for internal/api/logstream.go: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -299,7 +299,7 @@ func (k *K8sLogStreamer) StreamLogs(ctx context.Context, name string) (io.ReadCl
// the build-id label and follows the kaniko container's log — the build/push
// output an admin watches live as a build runs. It deliberately does NOT reuse
// K8sLogStreamer's PodRunning filter: a Pod running its kaniko *initContainer* is
// Phase=Pending (the main trivy container has not started), so a running filter
// Phase=Pending (the trivy scan and the push container have not started), so a running filter
// would never match a live build. Trivy's CRITICAL-CVE verdict is the admission
// gate, surfaced via the build status (handleGetBuild), not through this stream.
//
+14 −12
Changes for internal/build/build.go: 14 added lines, 12 removed lines.
Original line number Diff line number Diff line
// Package build implements the image build subsystem (spec §16) — "the
// platform's biggest security surface". A SysAdmin uploads a Dockerfile and a
// context tarball; felis-api starts an in-cluster Kaniko Job that builds and
// pushes to the internal registry, after which a Trivy scan gates admission to
// the image whitelist.
// context tarball; felis-api starts an in-cluster Job in which Kaniko builds the
// image into a tarball, Trivy scans that tarball, and only a clean image is
// pushed to the internal registry and admitted to the image whitelist.
//
// Trust model (spec §16, §22): we trust the SysAdmin at the *ingress* (only an
// admin through Zero Trust may submit a build) but never trust the *Dockerfile
// at runtime* — an arbitrary Dockerfile is build-time RCE whose victim is the
// cluster, not the uploader. So the build Pod runs with a deliberately weak
// service account in an isolated namespace that can only push to the registry
// service account in an isolated namespace that can only reach the registry
// and cannot touch the minecraft namespace, the felis database, or the K8s API
// (spec §21). Those isolation guarantees live in the Job/NetworkPolicy specs
// (jobspec.go) and are asserted by unit tests, since no cluster runs here.
//
// The Trivy gate is enforced as the build Pod's *exit code*: a kaniko
// initContainer builds and pushes, then a trivy container scans the pushed ref
// with `--exit-code 1 --severity CRITICAL`. Therefore "Job Succeeded" is
// equivalent to "pushed AND no CRITICAL CVE". felis-api observes the Job phase
// initContainer builds into a tarball (--no-push), a trivy initContainer scans it
// with `--exit-code 1 --severity CRITICAL`, and only then does the push container
// — the one holding the registry credential — publish it. Therefore "Job
// Succeeded" is equivalent to "no CRITICAL CVE AND pushed", and a rejected image
// never reaches the registry. felis-api observes the Job phase
// and performs the database writes — the build Pod itself never has database
// credentials (the weak-SA red line). On success the image is admitted to
// image_whitelist with enabled=true (recording added_by); on failure the build
@@ -70,11 +72,11 @@ const (
	JobUnknown JobPhase = iota
	JobPending
	JobRunning
	// JobSucceeded means kaniko pushed AND trivy found no CRITICAL CVE — the
	// scan gate passed (spec §16).
	// JobSucceeded means trivy found no CRITICAL CVE AND the image was pushed —
	// the scan gate passed (spec §16).
	JobSucceeded
	// JobFailed means kaniko failed OR trivy found a CRITICAL CVE — the build
	// is rejected and nothing is admitted.
	// JobFailed means kaniko failed, trivy found a CRITICAL CVE, or the push
	// failed — the build is rejected and nothing is admitted.
	JobFailed
)

@@ -390,7 +392,7 @@ func (b *Builder) Get(ctx context.Context, id string) (*Build, error) {
// translation (spec §16). A terminal build is returned unchanged (idempotent).
//
//   - JobSucceeded → status=succeeded AND the image is admitted to the whitelist
//     with enabled=true (kaniko pushed and trivy found no CRITICAL CVE).
//     with enabled=true (trivy found no CRITICAL CVE and the push landed).
//   - JobFailed / JobUnknown → status=failed, nothing admitted (a CRITICAL CVE
//     surfaces here as a failed Job, since trivy runs with --exit-code 1).
//   - JobPending / JobRunning → no change.
+30 −0
Changes for internal/build/build_test.go: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -217,6 +217,36 @@ func TestSubmitRejectsExternalRegistryTarget(t *testing.T) {
	}
}

// The platform's own images and the scanner's DB mirrors live under felis/ and
// mirror/; the registry gate refuses the build principal there, and Validate turns
// that into a 400 before a Job spends minutes building an image it cannot push.
func TestValidateRejectsReservedRepos(t *testing.T) {
	cfg := Config{RegistryURL: "registry.felis.svc:5000"}
	for _, ref := range []string{
		"registry.felis.svc:5000/felis/felis:v0.1.0",
		"registry.felis.svc:5000/felis:latest",
		"registry.felis.svc:5000/felis",
		"registry.felis.svc:5000/mirror/trivy-db:2",
	} {
		req := goodRequest()
		req.ImageRef = ref
		if err := Validate(req, cfg); !errors.Is(err, ErrInvalid) {
			t.Errorf("Validate(%q) = %v, want ErrInvalid", ref, err)
		}
	}
	for _, ref := range []string{
		"registry.felis.svc:5000/user-uploads/s1:latest",
		"registry.felis.svc:5000/felis-pack:1",
		"registry.felis.svc:5000/builds/felis:1",
	} {
		req := goodRequest()
		req.ImageRef = ref
		if err := Validate(req, cfg); err != nil {
			t.Errorf("Validate(%q) = %v, want accepted", ref, err)
		}
	}
}

func TestSubmitRejectsEmptyAndOversizeDockerfile(t *testing.T) {
	b, _, _ := newBuilder()
	req := goodRequest()
Loading