From 091cb6d8bffae041e9919e47459a64ee0da443b7 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Tue, 27 Apr 2021 16:05:37 -0400 Subject: [PATCH] Add new `experimental-image-proxy` hidden command This imports the code from https://github.com/cgwalters/container-image-proxy First, assume one is operating on a codebase that isn't Go, but wants to interact with container images - we can't just include the Go containers/image library. The primary intended use case of this is for things like [ostree-containers](https://github.com/ostreedev/ostree-rs-ext/issues/18) where we're using container images to encapsulate host operating system updates, but we don't want to involve the [containers/image](github.com/containers/image/) storage layer. Vendoring the containers/image stack in another project is a large lift; the stripped binary for this proxy standalone weighs in at 16M (I'm sure the lack of LTO and the overall simplicity of the Go compiler is a large factor). Anyways, I'd like to avoid shipping another copy. This command is marked as experimental, and hidden. The goal is just to use it from the ostree stack for now, ideally shipping at least in CentOS 9 Stream relatively soon. We can (and IMO should) change and improve it later. A lot more discussion in https://github.com/cgwalters/container-image-proxy/issues/1 --- cmd/skopeo/main.go | 1 + cmd/skopeo/proxy.go | 265 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 cmd/skopeo/proxy.go diff --git a/cmd/skopeo/main.go b/cmd/skopeo/main.go index 85a80e1e65..9072087187 100644 --- a/cmd/skopeo/main.go +++ b/cmd/skopeo/main.go @@ -84,6 +84,7 @@ func createApp() (*cobra.Command, *globalOptions) { copyCmd(&opts), deleteCmd(&opts), inspectCmd(&opts), + proxyCmd(&opts), layersCmd(&opts), loginCmd(&opts), logoutCmd(&opts), diff --git a/cmd/skopeo/proxy.go b/cmd/skopeo/proxy.go new file mode 100644 index 0000000000..8de43c85f8 --- /dev/null +++ b/cmd/skopeo/proxy.go @@ -0,0 +1,265 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/containers/image/v5/image" + "github.com/containers/image/v5/manifest" + "github.com/containers/image/v5/pkg/blobinfocache" + "github.com/containers/image/v5/transports/alltransports" + "github.com/containers/image/v5/types" + "github.com/opencontainers/go-digest" + "github.com/spf13/cobra" +) + +type proxyHandler struct { + imageref string + sysctx *types.SystemContext + cache types.BlobInfoCache + imgsrc *types.ImageSource + img *types.Image + shutdown bool +} + +func (h *proxyHandler) ensureImage() error { + if h.img != nil { + return nil + } + imgRef, err := alltransports.ParseImageName(h.imageref) + if err != nil { + return err + } + imgsrc, err := imgRef.NewImageSource(context.Background(), h.sysctx) + if err != nil { + return err + } + img, err := image.FromUnparsedImage(context.Background(), h.sysctx, image.UnparsedInstance(imgsrc, nil)) + if err != nil { + return fmt.Errorf("failed to load image: %w", err) + } + h.img = &img + h.imgsrc = &imgsrc + return nil +} + +func (h *proxyHandler) implManifest(w http.ResponseWriter, r *http.Request) error { + if err := h.ensureImage(); err != nil { + return err + } + + _, err := io.Copy(io.Discard, r.Body) + if err != nil { + return err + } + ctx := context.TODO() + rawManifest, _, err := (*h.img).Manifest(ctx) + if err != nil { + return err + } + digest, err := manifest.Digest(rawManifest) + if err != nil { + return err + } + w.Header().Add("Manifest-Digest", digest.String()) + + ociManifest, err := manifest.OCI1FromManifest(rawManifest) + if err != nil { + return err + } + ociSerialized, err := ociManifest.Serialize() + if err != nil { + return err + } + + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(ociSerialized))) + w.WriteHeader(200) + _, err = io.Copy(w, bytes.NewReader(ociSerialized)) + if err != nil { + return err + } + return nil +} + +func (h *proxyHandler) implBlob(w http.ResponseWriter, r *http.Request, digestStr string) error { + if err := h.ensureImage(); err != nil { + return err + } + + _, err := io.Copy(io.Discard, r.Body) + if err != nil { + return err + } + + ctx := context.TODO() + d, err := digest.Parse(digestStr) + if err != nil { + return err + } + blobr, blobSize, err := (*h.imgsrc).GetBlob(ctx, types.BlobInfo{Digest: d, Size: -1}, h.cache) + if err != nil { + return err + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", blobSize)) + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(200) + verifier := d.Verifier() + tr := io.TeeReader(blobr, verifier) + _, err = io.Copy(w, tr) + if err != nil { + return err + } + if !verifier.Verified() { + return fmt.Errorf("Corrupted blob, expecting %s", d.String()) + } + return nil +} + +// ServeHTTP handles two requests: +// +// GET /manifest +// GET /blobs/ +// POST /quit +func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + if r.URL.Path == "/quit" { + w.Header().Set("Content-Length", "0") + w.WriteHeader(200) + h.shutdown = true + return + } + } + + if r.Method != http.MethodGet { + w.Header().Set("Content-Length", "0") + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if r.URL.Path == "" || !strings.HasPrefix(r.URL.Path, "/") { + w.Header().Set("Content-Length", "0") + w.WriteHeader(http.StatusBadRequest) + return + } + + var err error + if err != nil { + + } + + if r.URL.Path == "/manifest" { + err = h.implManifest(w, r) + } else if strings.HasPrefix(r.URL.Path, "/blobs/") { + blob := filepath.Base(r.URL.Path) + err = h.implBlob(w, r, blob) + } else { + w.Header().Set("Content-Length", "0") + w.WriteHeader(http.StatusBadRequest) + return + } + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } +} + +type SockResponseWriter struct { + out io.Writer + headers http.Header +} + +func (rw SockResponseWriter) Header() http.Header { + return rw.headers +} + +func (rw SockResponseWriter) Write(buf []byte) (int, error) { + return rw.out.Write(buf) +} + +func (rw SockResponseWriter) WriteHeader(statusCode int) { + rw.out.Write([]byte(fmt.Sprintf("HTTP/1.1 %d OK\r\n", statusCode))) + rw.headers.Write(rw.out) + rw.out.Write([]byte("\r\n")) +} + +type proxyOptions struct { + global *globalOptions + quiet bool + sockFd int + portNum int +} + +func proxyCmd(global *globalOptions) *cobra.Command { + opts := proxyOptions{global: global} + cmd := &cobra.Command{ + Use: "experimental-image-proxy [command options] IMAGE", + Short: "Interactive proxy for fetching container images (EXPERIMENTAL)", + Long: `Run skopeo as a proxy, supporting HTTP requests to fetch manifests and blobs.`, + RunE: commandAction(opts.run), + Args: cobra.ExactArgs(1), + // Not stabilized yet + Hidden: true, + Example: `skopeo proxy --sockfd 3`, + } + adjustUsage(cmd) + flags := cmd.Flags() + flags.IntVar(&opts.sockFd, "sockfd", -1, "Serve on opened socket pair") + return cmd +} + +// Implementation of podman experimental-image-proxy +func (opts *proxyOptions) run(args []string, stdout io.Writer) error { + sysCtx := opts.global.newSystemContext() + + handler := &proxyHandler{ + imageref: args[0], + sysctx: sysCtx, + cache: blobinfocache.DefaultCache(sysCtx), + } + var buf *bufio.ReadWriter + if opts.sockFd != -1 { + fd := os.NewFile(uintptr(opts.sockFd), "sock") + buf = bufio.NewReadWriter(bufio.NewReader(fd), bufio.NewWriter(fd)) + } else { + buf = bufio.NewReadWriter(bufio.NewReader(os.Stdin), bufio.NewWriter(os.Stdout)) + } + + for { + req, err := http.ReadRequest(buf.Reader) + if err != nil { + if err == io.EOF { + return nil + } + return err + } + resp := SockResponseWriter{ + out: buf, + headers: make(map[string][]string), + } + handler.ServeHTTP(resp, req) + err = buf.Flush() + if err != nil { + return err + } + + if handler.shutdown { + break + } + } + + if handler.img != nil { + if err := (*handler.imgsrc).Close(); err != nil { + return err + } + } + + return nil +}