Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement reverse image proxy #17

Merged
merged 2 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 3 additions & 16 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,9 @@ issues:
linters:
disable-all: true
enable:
- dupl
- errcheck
- exportloopref
- goconst
- gocyclo
- gofmt
- goimports
- gosimple
- govet
- ginkgolinter
- ineffassign
- lll
- misspell
- nakedret
- prealloc
- staticcheck
- typecheck
- unconvert
- unparam
- goimports
- importas
- unused
7 changes: 6 additions & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ func main() {
var secureMetrics bool
var enableHTTP2 bool
var ipxeServerAddr string
var imageProxyServerAddr string
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
flag.StringVar(&ipxeServerAddr, "ipxe-server-address", ":8082", "The address the ipxe-server binds to.")
flag.StringVar(&imageProxyServerAddr, "image-proxy-server-address", ":8083", "The address the image-proxy-server binds to.")
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
"Enable leader election for controller manager. "+
"Enabling this will ensure there is only one active controller manager.")
Expand Down Expand Up @@ -164,7 +166,10 @@ func main() {
}

setupLog.Info("starting ipxe-server")
go ipxeserver.RunServer(ipxeServerAddr, mgr.GetClient(), serverLog.WithName("ipxeserver"))
go ipxeserver.RunIPXEServer(ipxeServerAddr, mgr.GetClient(), serverLog.WithName("ipxeserver"))

setupLog.Info("starting image-proxy-server")
go ipxeserver.RunImageProxyServer(imageProxyServerAddr, mgr.GetClient(), serverLog.WithName("imageproxyserver"))

setupLog.Info("starting manager")
if err := mgr.Start(ctx); err != nil {
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/go-logr/logr v1.4.1
github.com/onsi/ginkgo/v2 v2.17.1
github.com/onsi/gomega v1.32.0
github.com/opencontainers/image-spec v1.1.0
k8s.io/api v0.29.3
k8s.io/apimachinery v0.29.3
k8s.io/client-go v0.29.3
Expand Down Expand Up @@ -40,6 +41,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.18.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch/v5 v5.8.0 h1:lRj6N9Nci7MvzrXuX6HFzU8XjmhPiXPlsKEy1u0KQro=
github.com/evanphx/json-patch/v5 v5.8.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
Expand Down Expand Up @@ -60,7 +59,6 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
Expand All @@ -80,6 +78,10 @@ github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8
github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs=
github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk=
github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand All @@ -93,7 +95,6 @@ github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGy
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
Expand All @@ -109,7 +110,6 @@ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
Expand Down
216 changes: 216 additions & 0 deletions server/imageproxyserver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package server

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"strings"

"github.com/go-logr/logr"
ociimage "github.com/opencontainers/image-spec/specs-go/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)

const (
ghcrIOKey = "ghcr.io/"
imageKey = "imageName"
layerKey = "layerName"
versionKey = "version"
)

type TokenResponse struct {
Token string `json:"token"`
}

type ImageDetails struct {
OCIImageName string
RepositoryName string
LayerName string
Version string
}

func RunImageProxyServer(imageProxyServerAddr string, k8sClient client.Client, log logr.Logger) {
http.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) {
imageDetails, err := parseImageURL(r.URL.Query())
if err != nil {
http.Error(w, "Resource Not Found", http.StatusNotFound)
log.Info("Error: Failed to parse the image url", "URL", r.URL.Path, "Error", err)
return
}

if strings.HasPrefix(imageDetails.OCIImageName, ghcrIOKey) {
handleGHCR(w, r, &imageDetails, log)
} else {
http.Error(w, "Bad Request", http.StatusBadRequest)
log.Info("Unsupported registry")
}
})

log.Info("Starting image proxy server", "address", imageProxyServerAddr)
if err := http.ListenAndServe(imageProxyServerAddr, nil); err != nil {
log.Error(err, "failed to start image proxy server")
panic(err)
}
}

func handleGHCR(w http.ResponseWriter, r *http.Request, imageDetails *ImageDetails, log logr.Logger) {
log.Info("Processing Image Proxy request", "method", r.Method, "path", r.URL.Path)

bearerToken, err := imageDetails.getBearerToken()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
log.Info("Error: Failed to obtain the bearer token", "error", err)
return
}

digest, err := imageDetails.getLayerDigest(bearerToken)
if err != nil {
http.Error(w, "Resource Not Found", http.StatusNotFound)
log.Info("Error: Failed to obtain layer digest", "error", err)
return
}

targetURL := fmt.Sprintf("https://ghcr.io/v2/%s/blobs/%s", imageDetails.RepositoryName, digest)
proxyURL, _ := url.Parse(targetURL)

proxy := &httputil.ReverseProxy{
Director: imageDetails.modifyDirector(proxyURL, bearerToken, digest),
ModifyResponse: modifyProxyResponse(bearerToken),
}

r.URL.Host = proxyURL.Host
r.URL.Scheme = proxyURL.Scheme
r.Host = proxyURL.Host

proxy.ServeHTTP(w, r)
}

func (imageDetails ImageDetails) getBearerToken() (string, error) {
url := fmt.Sprintf("https://ghcr.io/token?scope=repository:%s:pull", imageDetails.RepositoryName)
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}

var tokenResponse TokenResponse
if err := json.Unmarshal(body, &tokenResponse); err != nil {
return "", err
}

return tokenResponse.Token, nil
}

func modifyProxyResponse(bearerToken string) func(*http.Response) error {
return func(resp *http.Response) error {
resp.Header.Set("Authorization", "Bearer "+bearerToken)
if resp.StatusCode == http.StatusTemporaryRedirect {
location, err := resp.Location()
if err != nil {
return err
}

client := &http.Client{}
redirectReq, err := http.NewRequest("GET", location.String(), nil)
if err != nil {
return err
}

copyHeaders(resp.Request.Header, redirectReq.Header)

redirectResp, err := client.Do(redirectReq)
if err != nil {
return err
}

replaceResponse(resp, redirectResp)
}
return nil
}
}

func copyHeaders(source http.Header, destination http.Header) {
for name, values := range source {
for _, value := range values {
destination.Add(name, value)
}
}
}

func replaceResponse(originalResp, redirectResp *http.Response) {
for name, values := range redirectResp.Header {
for _, value := range values {
originalResp.Header.Set(name, value)
}
}
originalResp.Body = redirectResp.Body
originalResp.StatusCode = redirectResp.StatusCode
}

func parseImageURL(queries url.Values) (imageDetails ImageDetails, err error) {
ociImageName := queries.Get(imageKey)
layerName := queries.Get(layerKey)
version := queries.Get(versionKey)
repositoryName := strings.TrimPrefix(ociImageName, ghcrIOKey)

if ociImageName == "" || layerName == "" || version == "" {
return ImageDetails{}, fmt.Errorf("missing required query parameters 'image' or 'layer' or 'version'")
}

return ImageDetails{
OCIImageName: ociImageName,
RepositoryName: repositoryName,
LayerName: layerName,
Version: version,
}, nil
}

func (ImageDetails ImageDetails) modifyDirector(proxyURL *url.URL, bearerToken string, digest string) func(*http.Request) {
return func(req *http.Request) {
req.URL.Scheme = proxyURL.Scheme
req.URL.Host = proxyURL.Host
req.URL.Path = fmt.Sprintf("/v2/%s/blobs/%s", ImageDetails.RepositoryName, digest)
req.Header.Set("Authorization", "Bearer "+bearerToken)
}
}

func (imageDetails ImageDetails) getLayerDigest(token string) (string, error) {
url := fmt.Sprintf("https://ghcr.io/v2/%s/manifests/%s", imageDetails.RepositoryName, imageDetails.Version)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("http request to fetch manifest failed %w", err)
}

req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("http client connection failed %w", err)
}
defer resp.Body.Close()

var manifest ociimage.Manifest
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
return "", fmt.Errorf("unable to decode the manifest %w", err)
}

for _, layer := range manifest.Layers {
if strings.Contains(layer.MediaType, imageDetails.LayerName) {
return string(layer.Digest), nil
}
}

return "", fmt.Errorf("%s layer not found in the manifest", imageDetails.LayerName)
}
6 changes: 4 additions & 2 deletions server/httpserver.go → server/ipxeserver.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package server

import (
Expand All @@ -13,7 +16,6 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
)

// IPXEData is the struct that will hold our template variables.
type IPXETemplateData struct {
KernelURL string
InitrdURL string
Expand All @@ -22,7 +24,7 @@ type IPXETemplateData struct {
IpxeServerURL string
}

func RunServer(ipxeServerAddr string, k8sClient client.Client, log logr.Logger) {
func RunIPXEServer(ipxeServerAddr string, k8sClient client.Client, log logr.Logger) {
http.HandleFunc("/ipxe", func(w http.ResponseWriter, r *http.Request) {
handleIPXE(w, r, k8sClient, ipxeServerAddr, log)
})
Expand Down
Loading