Files
Felis/cmd/felis/registrygate.go

165 lines
6.2 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net"
"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, prune),
// 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.
//
// --maint-listen is the GC sidecar's read-only handshake (registrygate.MaintHandler).
// It has no authentication, so it must name a loopback address; --maint-dir keeps
// an open window across a gate restart.
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")
maintListen := fs.String("maint-listen", "", "loopback address for the GC sidecar's read-only handshake (empty disables it)")
maintDir := fs.String("maint-dir", "", "directory that keeps an open read-only window across a gate restart")
quiet := fs.Duration("maint-quiet", registrygate.DefaultQuiet, "how long writes must be idle before a read-only window is granted")
dataDir := fs.String("data-dir", "", "the registry's storage root, mounted read-only, for the manifest index (empty disables it)")
if err := fs.Parse(args); err != nil {
return 2
}
if *maintListen != "" && !loopbackAddr(*maintListen) {
fmt.Fprintf(stderr, "felis registry-gate: --maint-listen %q must be a loopback address: the handshake has no authentication\n", *maintListen)
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 registrygate.Principals {
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
}
gate := registrygate.New(target, tokens, log)
gate.SetQuiet(*quiet)
gate.DataDir = *dataDir
if *maintDir != "" {
if err := gate.SetMaintenanceState(registrygate.MaintStatePath(*maintDir)); err != nil {
// A corrupt file must not keep the registry from serving pulls.
log.Warn("ignoring the saved read-only window", "err", err)
}
}
srv := &http.Server{
Addr: *listen,
Handler: gate,
ReadHeaderTimeout: 10 * time.Second,
}
var maint *http.Server
if *maintListen != "" {
maint = &http.Server{Addr: *maintListen, Handler: gate.MaintHandler(), ReadHeaderTimeout: 10 * time.Second}
go func() {
if err := maint.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("maintenance listener stopped; garbage collection cannot get a read-only window", "err", err)
}
}()
}
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)
if maint != nil {
_ = maint.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
}
// loopbackAddr reports whether a host:port listen address binds loopback only.
func loopbackAddr(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// 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
}