From 3a2802c8a116eb4e8a73b5d0f777c75f71ef0a82 Mon Sep 17 00:00:00 2001 From: wweir Date: Fri, 27 Sep 2024 15:20:19 +0800 Subject: [PATCH 1/2] feat: optimize config --- etc/config.go | 41 ++++++++++---------- etc/contatto.toml | 97 ++++++++++++++++++++++++++++++++--------------- etc/contatto.yaml | 51 ++++++++++++++++--------- proxy.go | 44 +++++++++------------ 4 files changed, 140 insertions(+), 93 deletions(-) diff --git a/etc/config.go b/etc/config.go index 7aa786c..8616dd2 100644 --- a/etc/config.go +++ b/etc/config.go @@ -21,10 +21,9 @@ import ( var ( Branch, Version, Date string - RegM = map[string]*Registry{} - RegHostM = map[string]*Registry{} - RuleHostM = map[string]*MirrorRule{} - DockerAuth = map[string]*dockerAuth{} + RegM = map[string]*Registry{} + RegHostM = map[string]*Registry{} + RuleHostM = map[string]*MirrorRule{} OnMissing func(any) (string, error) bufferPool = sync.Pool{ @@ -43,15 +42,15 @@ var Config struct { type Registry struct { Name string - Host string + Endpoint string Insecure bool DockerConfigFile string - UserName string + User string Password string } type dockerAuth struct { - UserName string + User string Password string } @@ -63,21 +62,20 @@ func InitConfig(file string) error { } defer f.Close() - var decodeFn func(any) error + decodeM := map[string]any{} switch filepath.Ext(file) { case ".json": - decodeFn = json.NewDecoder(f).Decode + err = json.NewDecoder(f).Decode(&decodeM) case ".toml": - decodeFn = toml.NewDecoder(f).Decode + err = toml.NewDecoder(f).Decode(&decodeM) case ".yaml", ".yml": - decodeFn = yaml.NewDecoder(f).Decode + err = yaml.NewDecoder(f).Decode(&decodeM) } - - decodeM := map[string]any{} - if err := decodeFn(&decodeM); err != nil { + if err != nil { slog.Error("failed to decode config", "err", err) return err } + decoder, _ := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ DecodeHook: func(f reflect.Type, t reflect.Type, data interface{}) (interface{}, error) { if f.Kind() != reflect.String || t.Kind() != reflect.String { @@ -87,6 +85,9 @@ func InitConfig(file string) error { }, TagName: "json", Result: &Config, + MatchName: func(mapKey, fieldName string) bool { + return strings.EqualFold(strings.ReplaceAll(mapKey, "_", ""), fieldName) + }, }) if err := decoder.Decode(decodeM); err != nil { slog.Error("failed to decode config", "err", err) @@ -123,15 +124,15 @@ func InitConfig(file string) error { RegHostM = map[string]*Registry{} for i, reg := range Config.Registries { RegM[reg.Name] = &Config.Registries[i] - RegHostM[reg.Host] = &Config.Registries[i] - if reg.DockerConfigFile != "" && reg.UserName == "" { - user, password, err := readAuthFromDockerConfig(reg.DockerConfigFile, reg.Host) + RegHostM[reg.Endpoint] = &Config.Registries[i] + if reg.DockerConfigFile != "" && reg.User == "" { + user, password, err := readAuthFromDockerConfig(reg.DockerConfigFile, reg.Endpoint) if err != nil { slog.Error("failed to read auth from docker config", "err", err) return err } - Config.Registries[i].UserName = user + Config.Registries[i].User = user Config.Registries[i].Password = password } } @@ -150,7 +151,7 @@ func InitConfig(file string) error { return err } - RuleHostM[src.Host] = &Config.MirrorRules[i] + RuleHostM[src.Endpoint] = &Config.MirrorRules[i] } return nil @@ -238,7 +239,7 @@ func readAuthFromDockerConfig(configFile, registryHost string) (user, password s type MirrorRule struct { RawRegName string - MirrorPathTpl string // rendering a image path: /wweir/alpine:latest + MirrorPathTpl string // rendering a image path: /docker-hub/alpine:latest mirrorPathTpl *template.Template } diff --git a/etc/contatto.toml b/etc/contatto.toml index cc325f6..c09f335 100644 --- a/etc/contatto.toml +++ b/etc/contatto.toml @@ -1,32 +1,69 @@ -# 服务端口配置 addr = "127.0.0.1:8080" -# Docker 用户配置文件,一般是 $HOME/.docker/config.json,留空则不从 docker 配置中读取用户名和密码 mirror_registry = "local-registry" -on_missing = """ -ssh sh -c ' -docker pull {{.raw}} -docker tag {{.raw}} {{.mirror}} -docker push {{.mirror}} -docker rmi {{.raw}} {{.mirror}}' -""" - -# 镜像仓库配置MirrorRegistry = "swr"# 镜像仓库, 应当设置为自主可控的镜像仓库地址 -[[Registries]] -Name = "local-registry" -# mirror registry 地址 -# Host = "localhost:5000" -# 从 docker 配置文件中读取用户名和密码 -DockerConfigFile = "${HOME}/.docker/config.json" -## 使用指定的用户名和密码进行身份验证,优先级高于 Docker 存储的令牌 -# UserName = "my_username_1" -# Password = "my_password_1" - - -# 镜像规则配置 -[[Registries]] -Name = "docker-hub" -Host = "docker.io" - -[[MirrorRules]] -RawRegName = "docker-hub" -MirrorPathTpl = "docker-hub/{{.Project}}-{{.Repo}}:{{.Tag}}" +## The cmd will be executed when the image is not found in the mirror registry. +## Available parameters: {{.raw}} {{.mirror}} +## {{.Raw.Registry}} {{.Raw.Project}} {{.Raw.Repo}} {{.Raw.Tag}} +## {{.Mirror.Registry}} {{.Mirror.Project}} {{.Mirror.Repo}} {{.Mirror.Tag}} +# on_missing = """ +# ssh sh -c \ +# 'docker pull {{.raw}} && docker tag {{.raw}} {{.mirror}} && docker push {{.mirror}}' +# """ + +[[registries]] +name = "local-registry" +endpoint = "localhost:5000" +# user = "my_username_1" +# password = "my_password_1" +## Retrieve authentication information from the Docker configuration file if the username and password are not specified. +# docker_config_file = "${HOME}/.docker/config.json" + + +## mirror rules +[[registries]] +name = "docker-hub" +endpoint = "docker.io" +[[mirror_rules]] +raw_reg_name = "docker-hub" +mirror_path_tpl = "docker-hub/{{.Project}}-{{.Repo}}:{{.Tag}}" + +[[registries]] +name = "gcr" +endpoint = "gcr.io" +[[mirror_rules]] +raw_reg_name = "gcr" +mirror_path_tpl = "gcr/{{.Project}}-{{.Repo}}:{{.Tag}}" + +[[registries]] +name = "ghcr" +endpoint = "ghcr.io" +[[mirror_rules]] +raw_reg_name = "ghcr" +mirror_path_tpl = "ghcr/{{.Project}}-{{.Repo}}:{{.Tag}}" + +[[registries]] +name = "k8s-gcr" +endpoint = "k8s.gcr.io" +[[mirror_rules]] +raw_reg_name = "k8s-gcr" +mirror_path_tpl = "k8s-gcr/{{.Project}}-{{.Repo}}:{{.Tag}}" + +[[registries]] +name = "reg-k8s" +endpoint = "registry.k8s.io" +[[mirror_rules]] +raw_reg_name = "reg-k8s" +mirror_path_tpl = "reg-k8s/{{.Project}}-{{.Repo}}:{{.Tag}}" + +[[registries]] +name = "quay" +endpoint = "quay.io" +[[mirror_rules]] +raw_reg_name = "quay" +mirror_path_tpl = "quay/{{.Project}}-{{.Repo}}:{{.Tag}}" + +[[registries]] +name = "nvcr" +endpoint = "nvcr.io" +[[mirror_rules]] +raw_reg_name = "nvcr" +mirror_path_tpl = "nvcr/{{.Project}}-{{.Repo}}:{{.Tag}}" diff --git a/etc/contatto.yaml b/etc/contatto.yaml index 1e8fe4c..d9165c8 100644 --- a/etc/contatto.yaml +++ b/etc/contatto.yaml @@ -1,17 +1,34 @@ -Addr: "127.0.0.1:8080" -MirrorRegistry: local-registry -OnMissing: | - ssh sh -c ' - docker pull {{.raw}} - docker tag {{.raw}} {{.mirror}} - docker push {{.mirror}} - docker rmi {{.raw}} {{.mirror}}' -Registries: - - Name: local-registry - Host: "localhost:5000" - DockerConfigFile: ${HOME}/.docker/config.json - - Name: docker-hub - Host: docker.io -MirrorRules: - - RawRegName: docker-hub - MirrorPathTpl: "wweir/{{.Project}}-{{.Repo}}:{{.Tag}}" +addr: 127.0.0.1:8080 +mirror_registry: local-registry +registries: + - name: local-registry + endpoint: localhost:5000 + - name: docker-hub + endpoint: docker.io + - name: gcr + endpoint: gcr.io + - name: ghcr + endpoint: ghcr.io + - name: k8s-gcr + endpoint: k8s.gcr.io + - name: reg-k8s + endpoint: registry.k8s.io + - name: quay + endpoint: quay.io + - name: nvcr + endpoint: nvcr.io +mirror_rules: + - raw_reg_name: docker-hub + mirror_path_tpl: docker-hub/{{.Project}}-{{.Repo}}:{{.Tag}} + - raw_reg_name: gcr + mirror_path_tpl: gcr/{{.Project}}-{{.Repo}}:{{.Tag}} + - raw_reg_name: ghcr + mirror_path_tpl: ghcr/{{.Project}}-{{.Repo}}:{{.Tag}} + - raw_reg_name: k8s-gcr + mirror_path_tpl: k8s-gcr/{{.Project}}-{{.Repo}}:{{.Tag}} + - raw_reg_name: reg-k8s + mirror_path_tpl: reg-k8s/{{.Project}}-{{.Repo}}:{{.Tag}} + - raw_reg_name: quay + mirror_path_tpl: quay/{{.Project}}-{{.Repo}}:{{.Tag}} + - raw_reg_name: nvcr + mirror_path_tpl: nvcr/{{.Project}}-{{.Repo}}:{{.Tag}} diff --git a/proxy.go b/proxy.go index 328bbd2..061dc9c 100644 --- a/proxy.go +++ b/proxy.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "log/slog" "net/http" "net/http/httputil" @@ -51,7 +50,7 @@ func (c *ProxyCmd) Run() error { rule, ok := etc.RuleHostM[host] if !ok { // no mapping rule, directly forward to the registry if reg, ok := etc.RegHostM[host]; ok { - r.Out.URL.Host = reg.Host + r.Out.URL.Host = reg.Endpoint if reg.Insecure { r.Out.URL.Scheme = "http" } else { @@ -67,16 +66,16 @@ func (c *ProxyCmd) Run() error { { // rewrite host, scheme, query dstReg := etc.RegM[etc.Config.MirrorRegistry] - dstPat.Registry = dstReg.Host + dstPat.Registry = dstReg.Endpoint if dstReg.Insecure { r.Out.URL.Scheme = "http" } else { r.Out.URL.Scheme = "https" } - r.Out.Host = dstReg.Host - r.Out.URL.Host = dstReg.Host - query.Set("ns", dstReg.Host) + r.Out.Host = dstReg.Endpoint + r.Out.URL.Host = dstReg.Endpoint + query.Set("ns", dstReg.Endpoint) r.Out.URL.RawQuery = query.Encode() } @@ -105,10 +104,17 @@ func (c *ProxyCmd) Run() error { // the first time to access the registry, do a HEAD request to get the auth method if _, ok := c.firstAttach.LoadOrStore(r.Out.Host, struct{}{}); !ok { - resp, err := http.Head(r.Out.URL.String()) + path, query := r.Out.URL.Path, r.Out.URL.RawQuery + r.Out.URL.Path, r.Out.URL.RawQuery = "/v2/", "" + firstAttachURL := r.Out.URL.String() + r.Out.URL.Path, r.Out.URL.RawQuery = path, query + + resp, err := http.Get(firstAttachURL) if err != nil { - slog.Warn("failed to head", "url", r.Out.URL.String(), "err", err) + slog.Warn("failed to request", "url", firstAttachURL, "err", err) } else if resp.StatusCode == 401 { + defer resp.Body.Close() + slog.Debug("first attach", "url", firstAttachURL) if err := authorizer.AddResponses(context.Background(), []*http.Response{resp}); err != nil { slog.Error("failed to add responses", "err", err) } @@ -124,6 +130,7 @@ func (c *ProxyCmd) Run() error { proxy.ModifyResponse = func(w *http.Response) error { switch w.StatusCode { case 401: + slog.Info("auth failed", "url", w.Request.URL.String()) if err := authorizer.AddResponses(context.Background(), []*http.Response{w}); err != nil { slog.Error("failed to add responses", "err", err) } @@ -142,6 +149,7 @@ func (c *ProxyCmd) Run() error { slog.Info("mirror image not exist, run on missing command", "cmd", cmdline) go func() { + slog.Info("on missing command running", "cmd", cmdline) startTime := time.Now() cmd := exec.Command("sh", "-c", cmdline) out, err := cmd.CombinedOutput() @@ -149,14 +157,9 @@ func (c *ProxyCmd) Run() error { slog.Error("failed to run on missing command", "output", out, "err", err) return } - slog.Info("on missing command finished", "cost", time.Since(startTime)) + slog.Info("on missing command finished", "took", time.Since(startTime)) }() } - - // cmd := exec.Command("sh", "-c", "") - // if err := cmd.Run(); err != nil { - // slog.Error("failed to pull image", "err", err) - // } default: slog.Info(w.Request.Method, "url", w.Request.URL.String(), "status", w.StatusCode) } @@ -167,19 +170,8 @@ func (c *ProxyCmd) Run() error { } func (c *ProxyCmd) AuthCreds(host string) (string, string, error) { - slog.Info("read auth creds", "registry", host) - reg := etc.RegHostM[host] - if reg.UserName != "" && reg.Password != "" { - return reg.UserName, reg.Password, nil - } - - if auth, ok := etc.DockerAuth[host]; ok { - return auth.UserName, auth.Password, nil - } - - return "", "", fmt.Errorf( - "registry (%s) user/password not set and docker config file not set", host) + return reg.User, reg.Password, nil } type ImagePattern struct { From c4f8b5fb6e9abd90f5e1c4f0688c37f35bd9acdf Mon Sep 17 00:00:00 2001 From: wweir Date: Sun, 29 Sep 2024 13:17:51 +0800 Subject: [PATCH 2/2] feat: rewrite config --- Makefile | 4 +- etc/config.go | 208 +++++++++------------------------------------ etc/contatto.toml | 82 +++++------------- etc/contatto.yaml | 50 ++++------- etc/mirror_rule.go | 69 +++++++++++++++ etc/registry.go | 85 ++++++++++++++++++ go.mod | 1 + go.sum | 2 + main.go | 14 ++- proxy.go | 201 ++++++++++++++++++++++++------------------- 10 files changed, 360 insertions(+), 356 deletions(-) create mode 100644 etc/mirror_rule.go create mode 100644 etc/registry.go diff --git a/Makefile b/Makefile index 73b78fc..4503760 100644 --- a/Makefile +++ b/Makefile @@ -8,12 +8,12 @@ test: ${GO} test ./... build: test - ${GO} build -ldflags "\ + ${GO} build -trimpath -ldflags "\ -X main.version=$(shell git describe --tags --always) \ -X main.date=$(shell date +%Y-%m-%d)" \ -o bin/contatto . run: build - ./bin/contatto proxy -c contatto.toml + ./bin/contatto proxy --debug -c contatto.toml clean: rm -f ./bin/contatto diff --git a/etc/config.go b/etc/config.go index 8616dd2..5243e1b 100644 --- a/etc/config.go +++ b/etc/config.go @@ -1,64 +1,34 @@ package etc import ( - "bytes" - "encoding/base64" "encoding/json" + "fmt" "log/slog" "os" "path/filepath" "reflect" "regexp" "strings" - "sync" - "text/template" "github.com/go-viper/mapstructure/v2" "github.com/pelletier/go-toml/v2" "gopkg.in/yaml.v3" ) -var ( - Branch, Version, Date string +var Branch, Version, Date string - RegM = map[string]*Registry{} - RegHostM = map[string]*Registry{} - RuleHostM = map[string]*MirrorRule{} - - OnMissing func(any) (string, error) - bufferPool = sync.Pool{ - New: func() any { return bytes.NewBuffer(nil) }, - } -) - -var Config struct { - Addr string - MirrorRegistry string - OnMissing string - onMissingTpl *template.Template - Registries []Registry - MirrorRules []MirrorRule -} - -type Registry struct { - Name string - Endpoint string - Insecure bool +type config struct { + Addr string DockerConfigFile string - User string - Password string + BaseRule MirrorRule + Registry map[string]*Registry + Rule map[string]*MirrorRule } -type dockerAuth struct { - User string - Password string -} - -func InitConfig(file string) error { +func ReadConfig(file string) (*config, error) { f, err := os.Open(file) if err != nil { - slog.Error("open config file", "file", file, "err", err) - return err + return nil, err } defer f.Close() @@ -72,101 +42,75 @@ func InitConfig(file string) error { err = yaml.NewDecoder(f).Decode(&decodeM) } if err != nil { - slog.Error("failed to decode config", "err", err) - return err + return nil, fmt.Errorf("decode config: %w", err) } + c := config{} decoder, _ := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ DecodeHook: func(f reflect.Type, t reflect.Type, data interface{}) (interface{}, error) { if f.Kind() != reflect.String || t.Kind() != reflect.String { return data, nil } - return ReadSHEnv(data.(string)) + return c.ReadSHEnv(data.(string)) }, TagName: "json", - Result: &Config, + Result: &c, MatchName: func(mapKey, fieldName string) bool { return strings.EqualFold(strings.ReplaceAll(mapKey, "_", ""), fieldName) }, }) if err := decoder.Decode(decodeM); err != nil { - slog.Error("failed to decode config", "err", err) - return err + return nil, fmt.Errorf("mapstructure config: %w", err) } - slog.Info("Starting with config", "branch", Branch, "version", Version, "date", Date, "config", Config) + slog.Info("Starting with config", "branch", Branch, "version", Version, "date", Date, "config", c) - Config.OnMissing = strings.TrimSpace(Config.OnMissing) - if Config.OnMissing != "" { - Config.onMissingTpl, err = template.New(".").Parse(Config.OnMissing) - if err != nil { - slog.Error("failed to parse on missing", "err", err) - return err + for host, registry := range c.Registry { + if registry.registry == "" { + registry.registry = host } - - OnMissing = func(param any) (string, error) { - buf := bufferPool.Get().(*bytes.Buffer) - defer func() { - buf.Reset() - bufferPool.Put(buf) - }() - - if err := Config.onMissingTpl.Execute(buf, param); err != nil { - return "", err - } - - return buf.String(), nil + if registry.Alias != "" { + c.Registry[registry.Alias] = registry } - } - RegM = map[string]*Registry{} - RegHostM = map[string]*Registry{} - for i, reg := range Config.Registries { - RegM[reg.Name] = &Config.Registries[i] - RegHostM[reg.Endpoint] = &Config.Registries[i] - if reg.DockerConfigFile != "" && reg.User == "" { - user, password, err := readAuthFromDockerConfig(reg.DockerConfigFile, reg.Endpoint) - if err != nil { - slog.Error("failed to read auth from docker config", "err", err) - return err - } - - Config.Registries[i].User = user - Config.Registries[i].Password = password - } + if err := c.BaseRule.ParseTemplate(); err != nil { + return nil, fmt.Errorf("parse base rule: %w", err) } - - RuleHostM = map[string]*MirrorRule{} - for i, rule := range Config.MirrorRules { - src, ok := RegM[rule.RawRegName] - if !ok { - slog.Error("config registry not found", "reg", rule.RawRegName) - return err + for registry, rule := range c.Rule { + if _, ok := c.Registry[registry]; !ok { + c.Registry[registry] = &Registry{registry: registry} } - Config.MirrorRules[i].mirrorPathTpl, err = template.New(".").Parse(rule.MirrorPathTpl) - if err != nil { - slog.Error("failed to parse mirror path template", "err", err) - return err + if rule.MirrorRegistry == "" { + rule.MirrorRegistry = c.BaseRule.MirrorRegistry } - RuleHostM[src.Endpoint] = &Config.MirrorRules[i] + if err := rule.ParseTemplate(); err != nil { + return nil, fmt.Errorf("parse rule: %w", err) + } + if rule.PathTpl == "" { + rule.pathTpl = c.BaseRule.pathTpl + } + if rule.OnMissingTpl == "" { + rule.onMissingTpl = c.BaseRule.onMissingTpl + } } - return nil + return &c, nil } var envRe = regexp.MustCompile(`\$\{([a-zA-Z0-9_]+)\}`) -func ReadSHEnv(value string) (string, error) { +func (c *config) ReadSHEnv(value string) (string, error) { idxPairs := envRe.FindAllStringIndex(value, -1) if len(idxPairs) == 0 { return value, nil } + newValue := "" for _, idxPair := range idxPairs { - if readBeforeByte(value, idxPair[0]) == '$' { + if c.readBeforeByte(value, idxPair[0]) == '$' { newValue += value[:idxPair[0]] + value[idxPair[0]+1:idxPair[1]] continue } @@ -180,79 +124,9 @@ func ReadSHEnv(value string) (string, error) { return newValue + value[lastIdx:], nil } -func readBeforeByte(value string, idx int) byte { +func (c *config) readBeforeByte(value string, idx int) byte { if idx == 0 { return 0 } return value[idx-1] } - -// https://github.com/docker/cli/blob/a18c896928828eca5eb91e816f009268fe0cd995/cli/config/configfile/file.go#L232 -func readAuthFromDockerConfig(configFile, registryHost string) (user, password string, err error) { - f, err := os.Open(configFile) - if err != nil { - slog.Error("open docker config file", "file", configFile, "err", err) - return "", "", err - } - defer f.Close() - - var dockerConfig struct { - Auths map[string]struct { - Auth string `json:"auth"` - } `json:"auths"` - } - - de := json.NewDecoder(f) - if err := de.Decode(&dockerConfig); err != nil { - slog.Error("failed to decode docker config", "err", err) - return "", "", err - } - - auth, ok := dockerConfig.Auths[registryHost] - if !ok { - slog.Error("registry not found in docker config", "registry", registryHost) - return "", "", err - } - - authStr := auth.Auth - decLen := base64.StdEncoding.DecodedLen(len(authStr)) - decoded := make([]byte, decLen) - authByte := []byte(authStr) - n, err := base64.StdEncoding.Decode(decoded, authByte) - if err != nil { - slog.Error("failed to decode auth", "registry", registryHost, "err", err) - return "", "", err - } - if n > decLen { - slog.Error("something went wrong decoding auth config", "registry", registryHost) - return "", "", err - } - - userName, password, ok := strings.Cut(string(decoded), ":") - if !ok || userName == "" { - slog.Error("failed to parse auth", "registry", registryHost, "err", err) - return "", "", err - } - - return userName, strings.Trim(password, "\x00"), nil -} - -type MirrorRule struct { - RawRegName string - MirrorPathTpl string // rendering a image path: /docker-hub/alpine:latest - mirrorPathTpl *template.Template -} - -func (r *MirrorRule) RenderMirrorPath(param any) (string, error) { - buf := bufferPool.Get().(*bytes.Buffer) - defer func() { - buf.Reset() - bufferPool.Put(buf) - }() - - if err := r.mirrorPathTpl.Execute(buf, param); err != nil { - return "", err - } - - return buf.String(), nil -} diff --git a/etc/contatto.toml b/etc/contatto.toml index c09f335..743a810 100644 --- a/etc/contatto.toml +++ b/etc/contatto.toml @@ -1,69 +1,29 @@ addr = "127.0.0.1:8080" -mirror_registry = "local-registry" +## Retrieve authentication information from the Docker configuration file if the username and password are not specified. +docker_config_file = "${HOME}/.docker/config.json" + +# if the field in rule is empty, use the value in base_rule. +[base_rule] +mirror_registry = "mirror-registry" +## Available parameters in template: {{.raw}} {{.mirror}} +## {{.Raw.Registry}} {{.Raw.Alias}} {{.Raw.Project}} {{.Raw.Repo}} {{.Raw.Tag}} +## {{.Mirror.Registry}} {{.Mirror.Alias}} {{.Mirror.Project}} {{.Mirror.Repo}} {{.Mirror.Tag}} +path_tpl = "{{.Registry}}/{{.Project}}-{{.Repo}}:{{.Tag}}" ## The cmd will be executed when the image is not found in the mirror registry. -## Available parameters: {{.raw}} {{.mirror}} -## {{.Raw.Registry}} {{.Raw.Project}} {{.Raw.Repo}} {{.Raw.Tag}} -## {{.Mirror.Registry}} {{.Mirror.Project}} {{.Mirror.Repo}} {{.Mirror.Tag}} -# on_missing = """ -# ssh sh -c \ +# on_missing_tpl = """ +# ssh -- \ # 'docker pull {{.raw}} && docker tag {{.raw}} {{.mirror}} && docker push {{.mirror}}' # """ -[[registries]] -name = "local-registry" -endpoint = "localhost:5000" +[registry.":5000"] +alias = "mirror-registry" # user = "my_username_1" # password = "my_password_1" -## Retrieve authentication information from the Docker configuration file if the username and password are not specified. -# docker_config_file = "${HOME}/.docker/config.json" - - -## mirror rules -[[registries]] -name = "docker-hub" -endpoint = "docker.io" -[[mirror_rules]] -raw_reg_name = "docker-hub" -mirror_path_tpl = "docker-hub/{{.Project}}-{{.Repo}}:{{.Tag}}" - -[[registries]] -name = "gcr" -endpoint = "gcr.io" -[[mirror_rules]] -raw_reg_name = "gcr" -mirror_path_tpl = "gcr/{{.Project}}-{{.Repo}}:{{.Tag}}" - -[[registries]] -name = "ghcr" -endpoint = "ghcr.io" -[[mirror_rules]] -raw_reg_name = "ghcr" -mirror_path_tpl = "ghcr/{{.Project}}-{{.Repo}}:{{.Tag}}" - -[[registries]] -name = "k8s-gcr" -endpoint = "k8s.gcr.io" -[[mirror_rules]] -raw_reg_name = "k8s-gcr" -mirror_path_tpl = "k8s-gcr/{{.Project}}-{{.Repo}}:{{.Tag}}" - -[[registries]] -name = "reg-k8s" -endpoint = "registry.k8s.io" -[[mirror_rules]] -raw_reg_name = "reg-k8s" -mirror_path_tpl = "reg-k8s/{{.Project}}-{{.Repo}}:{{.Tag}}" - -[[registries]] -name = "quay" -endpoint = "quay.io" -[[mirror_rules]] -raw_reg_name = "quay" -mirror_path_tpl = "quay/{{.Project}}-{{.Repo}}:{{.Tag}}" -[[registries]] -name = "nvcr" -endpoint = "nvcr.io" -[[mirror_rules]] -raw_reg_name = "nvcr" -mirror_path_tpl = "nvcr/{{.Project}}-{{.Repo}}:{{.Tag}}" +[rule."docker.io"] +[rule."gcr.io"] +[rule."ghcr.io"] +[rule."k8s.gcr.io"] +[rule."registry.k8s.io"] +[rule."quay.io"] +[rule."nvcr.io"] diff --git a/etc/contatto.yaml b/etc/contatto.yaml index d9165c8..8f12b98 100644 --- a/etc/contatto.yaml +++ b/etc/contatto.yaml @@ -1,34 +1,16 @@ -addr: 127.0.0.1:8080 -mirror_registry: local-registry -registries: - - name: local-registry - endpoint: localhost:5000 - - name: docker-hub - endpoint: docker.io - - name: gcr - endpoint: gcr.io - - name: ghcr - endpoint: ghcr.io - - name: k8s-gcr - endpoint: k8s.gcr.io - - name: reg-k8s - endpoint: registry.k8s.io - - name: quay - endpoint: quay.io - - name: nvcr - endpoint: nvcr.io -mirror_rules: - - raw_reg_name: docker-hub - mirror_path_tpl: docker-hub/{{.Project}}-{{.Repo}}:{{.Tag}} - - raw_reg_name: gcr - mirror_path_tpl: gcr/{{.Project}}-{{.Repo}}:{{.Tag}} - - raw_reg_name: ghcr - mirror_path_tpl: ghcr/{{.Project}}-{{.Repo}}:{{.Tag}} - - raw_reg_name: k8s-gcr - mirror_path_tpl: k8s-gcr/{{.Project}}-{{.Repo}}:{{.Tag}} - - raw_reg_name: reg-k8s - mirror_path_tpl: reg-k8s/{{.Project}}-{{.Repo}}:{{.Tag}} - - raw_reg_name: quay - mirror_path_tpl: quay/{{.Project}}-{{.Repo}}:{{.Tag}} - - raw_reg_name: nvcr - mirror_path_tpl: nvcr/{{.Project}}-{{.Repo}}:{{.Tag}} +addr: "127.0.0.1:8080" +docker_config_file: "${HOME}/.docker/config.json" +base_rule: + mirror_registry: mirror-registry + path_tpl: "{{.Registry}}/{{.Project}}-{{.Repo}}:{{.Tag}}" +registry: + ":5000": + alias: mirror-registry +rule: + docker.io: {} + gcr.io: {} + ghcr.io: {} + k8s.gcr.io: {} + registry.k8s.io: {} + quay.io: {} + nvcr.io: {} diff --git a/etc/mirror_rule.go b/etc/mirror_rule.go new file mode 100644 index 0000000..0660570 --- /dev/null +++ b/etc/mirror_rule.go @@ -0,0 +1,69 @@ +package etc + +import ( + "bytes" + "sync" + "text/template" +) + +type MirrorRule struct { + MirrorRegistry string + PathTpl string // rendering a image path: /docker-hub/alpine:latest + pathTpl *template.Template + OnMissingTpl string + onMissingTpl *template.Template +} + +func (r *MirrorRule) ParseTemplate() (err error) { + if r.PathTpl != "" { + r.pathTpl, err = template.New("path").Parse(r.PathTpl) + if err != nil { + return err + } + } + + if r.OnMissingTpl != "" { + r.onMissingTpl, err = template.New("on_missing").Parse(r.OnMissingTpl) + if err != nil { + return err + } + } + + return nil +} + +func (r *MirrorRule) RenderMirrorPath(param any) (string, error) { + buf := bufferPool.Get().(*bytes.Buffer) + defer func() { + buf.Reset() + bufferPool.Put(buf) + }() + + if err := r.pathTpl.Execute(buf, param); err != nil { + return "", err + } + + return buf.String(), nil +} + +var bufferPool = sync.Pool{ + New: func() any { return bytes.NewBuffer(nil) }, +} + +func (r *MirrorRule) RenderOnMissingCmd(param any) (string, error) { + if r == nil || r.onMissingTpl == nil { + return "", nil + } + + buf := bufferPool.Get().(*bytes.Buffer) + defer func() { + buf.Reset() + bufferPool.Put(buf) + }() + + if err := r.onMissingTpl.Execute(buf, param); err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/etc/registry.go b/etc/registry.go new file mode 100644 index 0000000..0ac7e8b --- /dev/null +++ b/etc/registry.go @@ -0,0 +1,85 @@ +package etc + +import ( + "encoding/base64" + "encoding/json" + "log/slog" + "os" + "strings" +) + +type Registry struct { + registry string + Alias string + Insecure bool + User string + Password string +} + +func (r *Registry) Scheme() string { + if r.Insecure { + return "http" + } + return "https" +} + +func (r *Registry) Host() string { + if r.registry != "" { + return r.registry + } + return "docker.io" +} + +// https://github.com/docker/cli/blob/a18c896928828eca5eb91e816f009268fe0cd995/cli/config/configfile/file.go#L232 +func (r *Registry) ReadAuthFromDockerConfig(configFile string) (user, password string, err error) { + if r.User != "" { + return r.User, r.Password, nil + } + + f, err := os.Open(configFile) + if err != nil { + slog.Error("open docker config file", "file", configFile, "err", err) + return "", "", err + } + defer f.Close() + + var dockerConfig struct { + Auths map[string]struct { + Auth string `json:"auth"` + } `json:"auths"` + } + + de := json.NewDecoder(f) + if err := de.Decode(&dockerConfig); err != nil { + slog.Error("failed to decode docker config", "err", err) + return "", "", err + } + + auth, ok := dockerConfig.Auths[r.registry] + if !ok { + slog.Error("registry not found in docker config", "registry", r.registry) + return "", "", err + } + + authStr := auth.Auth + decLen := base64.StdEncoding.DecodedLen(len(authStr)) + decoded := make([]byte, decLen) + authByte := []byte(authStr) + n, err := base64.StdEncoding.Decode(decoded, authByte) + if err != nil { + slog.Error("failed to decode auth", "registry", r.registry, "err", err) + return "", "", err + } + if n > decLen { + slog.Error("something went wrong decoding auth config", "registry", r.registry) + return "", "", err + } + + userName, password, ok := strings.Cut(string(decoded), ":") + if !ok || userName == "" { + slog.Error("failed to parse auth", "registry", r.registry, "err", err) + return "", "", err + } + + return userName, strings.Trim(password, "\x00"), nil +} diff --git a/go.mod b/go.mod index ce943a3..ef2cfa2 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/containerd/containerd/v2 v2.0.0-rc.4 github.com/go-viper/mapstructure/v2 v2.1.0 github.com/julienschmidt/httprouter v1.3.0 + github.com/lmittmann/tint v1.0.5 github.com/pelletier/go-toml/v2 v2.2.3 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index fbe174b..e2e5997 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw= +github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= diff --git a/main.go b/main.go index a8c41bf..dea61de 100644 --- a/main.go +++ b/main.go @@ -3,21 +3,27 @@ package main import ( "fmt" "log" + "log/slog" + "os" "github.com/alecthomas/kong" + "github.com/lmittmann/tint" "github.com/wweir/contatto/etc" ) var cli struct { + Debug bool `help:"debug mode"` + Install *InstallCmd `cmd:"" help:"install contatto"` Proxy *ProxyCmd `cmd:"" help:"run as registry proxy"` } -func init() { - log.SetFlags(log.Lshortfile | log.LstdFlags) -} - func main() { + slog.SetDefault(slog.New(tint.NewHandler(os.Stderr, &tint.Options{ + AddSource: true, + Level: slog.LevelDebug, + }))) + ctx := kong.Parse(&cli, kong.UsageOnError(), kong.Description(fmt.Sprintf(`Contatto %s (%s %s)`, etc.Version, etc.Branch, etc.Date)), diff --git a/proxy.go b/proxy.go index 061dc9c..3b1a789 100644 --- a/proxy.go +++ b/proxy.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "log/slog" "net/http" "net/http/httputil" @@ -22,11 +23,16 @@ type ProxyCmd struct { } func (c *ProxyCmd) Run() error { - if err := etc.InitConfig(c.Config); err != nil { + config, err := etc.ReadConfig(c.Config) + if err != nil { + slog.Error("failed to read config", "err", err) return err } - authorizer := docker.NewDockerAuthorizer(docker.WithAuthCreds(c.AuthCreds), + authorizer := docker.NewDockerAuthorizer( + docker.WithAuthCreds(func(host string) (string, string, error) { + return config.Registry[host].ReadAuthFromDockerConfig(config.DockerConfigFile) + }), docker.WithFetchRefreshToken(func(ctx context.Context, refreshToken string, req *http.Request) { slog.Info("fetch refresh token", "refreshToken", refreshToken, "url", req.URL.String()) })) @@ -47,142 +53,159 @@ func (c *ProxyCmd) Run() error { host = "docker.io" } - rule, ok := etc.RuleHostM[host] + log := slog.With("raw_reg", host) + + rule, ok := config.Rule[host] if !ok { // no mapping rule, directly forward to the registry - if reg, ok := etc.RegHostM[host]; ok { - r.Out.URL.Host = reg.Endpoint + if reg, ok := config.Registry[host]; ok { + r.Out.URL.Host = reg.Host() if reg.Insecure { r.Out.URL.Scheme = "http" } else { r.Out.URL.Scheme = "https" } } - slog.Warn("no mapping rule", "url", r.Out.URL.String()) + log.Warn("no mapping rule") return } - srcPat := &ImagePattern{Registry: host} - dstPat := &ImagePattern{} - - { // rewrite host, scheme, query - dstReg := etc.RegM[etc.Config.MirrorRegistry] - dstPat.Registry = dstReg.Endpoint - - if dstReg.Insecure { - r.Out.URL.Scheme = "http" - } else { - r.Out.URL.Scheme = "https" - } - r.Out.Host = dstReg.Endpoint - r.Out.URL.Host = dstReg.Endpoint - query.Set("ns", dstReg.Endpoint) - r.Out.URL.RawQuery = query.Encode() + // rewrite host, scheme, query + dstReg := config.Registry[rule.MirrorRegistry] + r.Out.URL.Scheme = dstReg.Scheme() + r.Out.Host = dstReg.Host() + r.Out.URL.Host = r.Out.Host + query.Set("ns", r.Out.Host) + r.Out.URL.RawQuery = query.Encode() + + // rewrite path, follow the mapping rule + _, ps, _ := router.Lookup(r.Out.Method, r.Out.URL.Path) + if len(ps) == 0 { + log.Error("rewrite missing", "method", r.Out.Method, "url", r.Out.URL.String(), "ps", ps) + return } - { // rewrite path, follow the mapping rule - _, ps, _ := router.Lookup(r.Out.Method, r.Out.URL.Path) - if len(ps) == 0 { - slog.Error("rewrite missing", "method", r.Out.Method, "url", r.Out.URL.String(), "ps", ps) - } + srcImage := &ImagePattern{Registry: host, Alias: config.Registry[host].Alias} + dstImage := &ImagePattern{Registry: dstReg.Host(), Alias: dstReg.Alias} - srcPat.ParseParams(ps) - mirrorPath, err := rule.RenderMirrorPath(srcPat) - if err != nil { - slog.Error("failed to render mirror path", "err", err) - return - } - - dstPat.ParseImage(r.Out.Host + "/" + mirrorPath) - r.Out.URL.Path = strings.Replace(r.Out.URL.Path, srcPat.Project, dstPat.Project, 1) - r.Out.URL.Path = strings.Replace(r.Out.URL.Path, srcPat.Repo, dstPat.Repo, 1) - r.Out.URL.Path = strings.Replace(r.Out.URL.Path, srcPat.Tag, dstPat.Tag, 1) - - r.Out.Header.Set("Contatto-Raw-Image", srcPat.String()) - r.Out.Header.Set("Contatto-Mirror-Image", dstPat.String()) - slog.Info("proxy", "raw", srcPat, "mirror", dstPat) + srcImage.ParseParams(ps) + mirrorPath, err := rule.RenderMirrorPath(srcImage) + if err != nil { + log.Error("failed to render mirror path", "err", err) + return } - // the first time to access the registry, do a HEAD request to get the auth method - if _, ok := c.firstAttach.LoadOrStore(r.Out.Host, struct{}{}); !ok { - path, query := r.Out.URL.Path, r.Out.URL.RawQuery - r.Out.URL.Path, r.Out.URL.RawQuery = "/v2/", "" - firstAttachURL := r.Out.URL.String() - r.Out.URL.Path, r.Out.URL.RawQuery = path, query + dstImage.ParseImage(r.Out.Host + "/" + mirrorPath) + r.Out.URL.Path = strings.Replace(r.Out.URL.Path, srcImage.Project, dstImage.Project, 1) + r.Out.URL.Path = strings.Replace(r.Out.URL.Path, srcImage.Repo, dstImage.Repo, 1) + r.Out.URL.Path = strings.Replace(r.Out.URL.Path, srcImage.Tag, dstImage.Tag, 1) - resp, err := http.Get(firstAttachURL) + r.Out.Header.Set("Contatto-Raw-Image", srcImage.String()) + r.Out.Header.Set("Contatto-Mirror-Image", dstImage.String()) + log.Info("proxy", "mirror", dstImage) + + // add auth header + if _, ok := c.firstAttach.LoadOrStore(dstImage.String(), struct{}{}); !ok { + u := *r.Out.URL + u.Path, u.RawQuery = "/v2/", "" + resp, err := http.Get(u.String()) if err != nil { - slog.Warn("failed to request", "url", firstAttachURL, "err", err) - } else if resp.StatusCode == 401 { + log.Error("failed to get", "err", err) + } else { defer resp.Body.Close() - slog.Debug("first attach", "url", firstAttachURL) - if err := authorizer.AddResponses(context.Background(), []*http.Response{resp}); err != nil { - slog.Error("failed to add responses", "err", err) + if resp.StatusCode == 401 { + authorizer.AddResponses(r.Out.Context(), []*http.Response{resp}) } } } - - // add auth header - if err := authorizer.Authorize(context.Background(), r.Out); err != nil { - slog.Error("failed to authorize", "err", err) + ctx := docker.ContextWithAppendPullRepositoryScope(r.Out.Context(), dstImage.Project+"/"+dstImage.Repo) + if err := authorizer.Authorize(ctx, r.Out); err != nil { + log.Error("failed to authorize", "err", err) return } } proxy.ModifyResponse = func(w *http.Response) error { switch w.StatusCode { case 401: - slog.Info("auth failed", "url", w.Request.URL.String()) - if err := authorizer.AddResponses(context.Background(), []*http.Response{w}); err != nil { + slog.Debug("auth failed", "url", w.Request.URL.String()) + if err := authorizer.AddResponses(w.Request.Context(), []*http.Response{w}); err != nil { slog.Error("failed to add responses", "err", err) } + + c.RetryToRewriteResp(w, "auth", func(req *http.Request) (*http.Response, error) { + if err := authorizer.Authorize(req.Context(), req); err != nil { + return nil, fmt.Errorf("failed to authorize: %w", err) + } + return http.DefaultClient.Do(req) + }) + case 404: - var raw, mirror ImagePattern - raw.ParseImage(w.Request.Header.Get("Contatto-Raw-Image")) - mirror.ParseImage(w.Request.Header.Get("Contatto-Mirror-Image")) - if etc.OnMissing != nil { - cmdline, err := etc.OnMissing(map[string]any{ - "Raw": raw, "Mirror": mirror, "raw": raw.String(), "mirror": mirror.String(), - }) + raw := (&ImagePattern{}).ParseImage(w.Request.Header.Get("Contatto-Raw-Image")) + raw.Alias = config.Registry[raw.Registry].Alias + mirror := (&ImagePattern{}).ParseImage(w.Request.Header.Get("Contatto-Mirror-Image")) + mirror.Alias = config.Registry[mirror.Registry].Alias + + log := slog.With("raw_reg", raw.Registry) + rule := config.Rule[raw.Registry] + cmdline, err := rule.RenderOnMissingCmd(map[string]any{ + "Raw": raw, "Mirror": mirror, "raw": raw.String(), "mirror": mirror.String(), + }) + if err != nil { + log.Error("failed to render on missing command", "err", err) + return nil + } + + if cmdline != "" { + log.Info("mirror image not exist, run on missing command", "cmd", cmdline) + startTime := time.Now() + cmd := exec.Command("sh", "-c", cmdline) + out, err := cmd.CombinedOutput() if err != nil { - slog.Error("failed to render on missing command", "err", err) - return err + log.Error("failed to run on missing command", "output", string(out), "err", err) + return nil } - slog.Info("mirror image not exist, run on missing command", "cmd", cmdline) - - go func() { - slog.Info("on missing command running", "cmd", cmdline) - startTime := time.Now() - cmd := exec.Command("sh", "-c", cmdline) - out, err := cmd.CombinedOutput() - if err != nil { - slog.Error("failed to run on missing command", "output", out, "err", err) - return - } - slog.Info("on missing command finished", "took", time.Since(startTime)) - }() + log.Info("on missing command finished", "took", time.Since(startTime)) + + c.RetryToRewriteResp(w, "on_missing", http.DefaultClient.Do) } + default: slog.Info(w.Request.Method, "url", w.Request.URL.String(), "status", w.StatusCode) } return nil } - return http.ListenAndServe(etc.Config.Addr, proxy) + return http.ListenAndServe(config.Addr, proxy) } -func (c *ProxyCmd) AuthCreds(host string) (string, string, error) { - reg := etc.RegHostM[host] - return reg.User, reg.Password, nil +func (c *ProxyCmd) RetryToRewriteResp(w *http.Response, reason string, do func(req *http.Request) (*http.Response, error)) { + log := slog.With("reason", reason) + startTime := time.Now() + + req := w.Request.Clone(w.Request.Context()) + req.RequestURI = "" + resp, err := do(req) + if err != nil { + log.Error("failed to retry request", "err", err, "took", time.Since(startTime)) + return + } + + w.StatusCode = resp.StatusCode + w.Status = resp.Status + w.Body = resp.Body + + log.Info("retry to rewrite response", "url", req.URL.String(), "took", time.Since(startTime)) } type ImagePattern struct { Registry string + Alias string Project string Repo string Tag string } // docker.io/library/alpine:latest -func (p *ImagePattern) ParseImage(image string) { +func (p *ImagePattern) ParseImage(image string) *ImagePattern { sepSlashFirst := strings.IndexByte(image, '/') p.Registry = image[:sepSlashFirst] sepSlashLast := strings.LastIndexByte(image, '/') @@ -190,9 +213,10 @@ func (p *ImagePattern) ParseImage(image string) { sepColonIdx := strings.LastIndexByte(image, ':') p.Repo = image[sepSlashLast+1 : sepColonIdx] p.Tag = image[sepColonIdx+1:] + return p } -func (p *ImagePattern) ParseParams(params httprouter.Params) { +func (p *ImagePattern) ParseParams(params httprouter.Params) *ImagePattern { for _, param := range params { switch param.Key { case "project": @@ -203,6 +227,7 @@ func (p *ImagePattern) ParseParams(params httprouter.Params) { p.Tag = param.Value } } + return p } func (p *ImagePattern) String() string {