Loading cmd/felis/registrygate.go 0 → 100644 +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 } cmd/felis/run.go +4 −0 Changes for cmd/felis/run.go: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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) Loading Loading @@ -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, Loading internal/api/logstream.go +1 −1 Changes for internal/api/logstream.go: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -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. // Loading internal/build/build.go +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 Loading Loading @@ -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 ) Loading Loading @@ -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. Loading internal/build/build_test.go +30 −0 Changes for internal/build/build_test.go: 30 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading
cmd/felis/registrygate.go 0 → 100644 +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 }
cmd/felis/run.go +4 −0 Changes for cmd/felis/run.go: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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) Loading Loading @@ -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, Loading
internal/api/logstream.go +1 −1 Changes for internal/api/logstream.go: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -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. // Loading
internal/build/build.go +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 Loading Loading @@ -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 ) Loading Loading @@ -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. Loading
internal/build/build_test.go +30 −0 Changes for internal/build/build_test.go: 30 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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