diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 79d5b32..34b443a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,6 +19,9 @@ jobs: - uses: actions/checkout@v4 - name: build matrix run: | + mkdir -p etc + mv conf/contatto.toml etc/contatto.toml + make build GO='GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go' tar czvf contatto-linux-amd64.tar.gz ./bin/ ./etc/contatto.toml make clean diff --git a/etc/config.go b/conf/config.go similarity index 90% rename from etc/config.go rename to conf/config.go index 5243e1b..1db9e9a 100644 --- a/etc/config.go +++ b/conf/config.go @@ -1,4 +1,4 @@ -package etc +package conf import ( "encoding/json" @@ -17,7 +17,7 @@ import ( var Branch, Version, Date string -type config struct { +type Config struct { Addr string DockerConfigFile string BaseRule MirrorRule @@ -25,7 +25,7 @@ type config struct { Rule map[string]*MirrorRule } -func ReadConfig(file string) (*config, error) { +func ReadConfig(file string) (*Config, error) { f, err := os.Open(file) if err != nil { return nil, err @@ -45,7 +45,7 @@ func ReadConfig(file string) (*config, error) { return nil, fmt.Errorf("decode config: %w", err) } - c := config{} + 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 { @@ -69,7 +69,10 @@ func ReadConfig(file string) (*config, error) { if registry.registry == "" { registry.registry = host } - if registry.Alias != "" { + + if registry.Alias == "" { + registry.Alias = host + } else { c.Registry[registry.Alias] = registry } } @@ -102,7 +105,7 @@ func ReadConfig(file string) (*config, error) { var envRe = regexp.MustCompile(`\$\{([a-zA-Z0-9_]+)\}`) -func (c *config) 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 @@ -124,7 +127,7 @@ func (c *config) ReadSHEnv(value string) (string, error) { return newValue + value[lastIdx:], nil } -func (c *config) readBeforeByte(value string, idx int) byte { +func (c *Config) readBeforeByte(value string, idx int) byte { if idx == 0 { return 0 } diff --git a/etc/contatto.toml b/conf/contatto.toml similarity index 100% rename from etc/contatto.toml rename to conf/contatto.toml diff --git a/etc/contatto.yaml b/conf/contatto.yaml similarity index 100% rename from etc/contatto.yaml rename to conf/contatto.yaml diff --git a/etc/mirror_rule.go b/conf/mirror_rule.go similarity index 98% rename from etc/mirror_rule.go rename to conf/mirror_rule.go index 0660570..d3cf29c 100644 --- a/etc/mirror_rule.go +++ b/conf/mirror_rule.go @@ -1,4 +1,4 @@ -package etc +package conf import ( "bytes" diff --git a/etc/registry.go b/conf/registry.go similarity index 99% rename from etc/registry.go rename to conf/registry.go index 0ac7e8b..bdfbf89 100644 --- a/etc/registry.go +++ b/conf/registry.go @@ -1,4 +1,4 @@ -package etc +package conf import ( "encoding/base64" diff --git a/main.go b/main.go index dea61de..6fd2682 100644 --- a/main.go +++ b/main.go @@ -8,11 +8,12 @@ import ( "github.com/alecthomas/kong" "github.com/lmittmann/tint" - "github.com/wweir/contatto/etc" + "github.com/wweir/contatto/conf" ) var cli struct { - Debug bool `help:"debug mode"` + Config string `short:"c" required:"" default:"/etc/contatto.toml"` + Debug bool `help:"debug mode"` Install *InstallCmd `cmd:"" help:"install contatto"` Proxy *ProxyCmd `cmd:"" help:"run as registry proxy"` @@ -26,9 +27,14 @@ func main() { ctx := kong.Parse(&cli, kong.UsageOnError(), - kong.Description(fmt.Sprintf(`Contatto %s (%s %s)`, etc.Version, etc.Branch, etc.Date)), + kong.Description(fmt.Sprintf(`Contatto %s (%s %s)`, conf.Version, conf.Branch, conf.Date)), ) - if err := ctx.Run(); err != nil { + + config, err := conf.ReadConfig(cli.Config) + if err != nil { + log.Fatalln("failed to read config:", err) + } + if err := ctx.Run(config); err != nil { log.Fatalf("run failed: %v\n", err) } } diff --git a/proxy.go b/proxy.go index fbac503..1659f77 100644 --- a/proxy.go +++ b/proxy.go @@ -1,7 +1,6 @@ package main import ( - "context" "fmt" "log/slog" "net/http" @@ -13,28 +12,17 @@ import ( "github.com/containerd/containerd/v2/core/remotes/docker" "github.com/julienschmidt/httprouter" - "github.com/wweir/contatto/etc" + "github.com/wweir/contatto/conf" ) type ProxyCmd struct { - Config string `short:"c" required:"" default:"/etc/contatto/config.toml"` - - firstAttach sync.Map + firstRequest sync.Map } -func (c *ProxyCmd) Run() error { - config, err := etc.ReadConfig(c.Config) - if err != nil { - slog.Error("failed to read config", "err", err) - return err - } - +func (c *ProxyCmd) Run(config *conf.Config) error { 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()) })) router := httprouter.New() @@ -50,10 +38,11 @@ func (c *ProxyCmd) Run() error { query := r.In.URL.Query() host := query.Get("ns") if host == "" { + // for docker mirror, use docker.io as default registry host = "docker.io" } - log := slog.With("raw_reg", host) + slog := slog.With("raw_reg", host) rule, ok := config.Rule[host] if !ok { // no mapping rule, directly forward to the registry @@ -65,11 +54,11 @@ func (c *ProxyCmd) Run() error { r.Out.URL.Scheme = "https" } } - log.Warn("no mapping rule") + slog.Warn("no mapping rule") return } - // rewrite host, scheme, query + // rewrite host, scheme, query values dstReg := config.Registry[rule.MirrorRegistry] r.Out.URL.Scheme = dstReg.Scheme() r.Out.Host = dstReg.Host() @@ -77,13 +66,11 @@ func (c *ProxyCmd) Run() error { query.Set("ns", r.Out.Host) r.Out.URL.RawQuery = query.Encode() - // rewrite path, follow the mapping rule + // rewrite path and tag, rendering path template _, ps, _ := router.Lookup(r.Out.Method, r.Out.URL.Path) if len(ps) == 0 { - switch r.Out.URL.Path { - case "/v2/": - default: - log.Error("rewrite missing", "method", r.Out.Method, "url", r.Out.URL.String(), "ps", ps) + if r.Out.URL.Path != "/v2/" { + slog.Error("rewrite missing", "method", r.Out.Method, "url", r.Out.URL.String(), "ps", ps) } return } @@ -94,26 +81,29 @@ func (c *ProxyCmd) Run() error { srcImage.ParseParams(ps) mirrorPath, err := rule.RenderMirrorPath(srcImage) if err != nil { - log.Error("failed to render mirror path", "err", err) + slog.Error("failed to render mirror path", "err", err) return } - 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) + if srcImage.Tag != "" { + r.Out.URL.Path = strings.Replace(r.Out.URL.Path, srcImage.Tag, dstImage.Tag, 1) - r.Out.Header.Set("Contatto-Raw-Image", srcImage.String()) - r.Out.Header.Set("Contatto-Mirror-Image", dstImage.String()) - log.Info("proxy", "mirror", dstImage) + r.Out.Header.Set("Contatto-Raw-Image", srcImage.String()) + r.Out.Header.Set("Contatto-Mirror-Image", dstImage.String()) + + slog.Info("proxy", "mirror", dstImage) + } // add auth header - if _, ok := c.firstAttach.LoadOrStore(dstImage.String(), struct{}{}); !ok { + if _, ok := c.firstRequest.LoadOrStore(dstImage.String(), struct{}{}); !ok { u := *r.Out.URL u.Path, u.RawQuery = "/v2/", "" resp, err := http.Get(u.String()) if err != nil { - log.Error("failed to get", "err", err) + slog.Error("failed to get", "err", err) } else { defer resp.Body.Close() if resp.StatusCode == 401 { @@ -121,14 +111,16 @@ func (c *ProxyCmd) Run() error { } } } + 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) + slog.Error("failed to authorize", "err", err) return } } proxy.ModifyResponse = func(w *http.Response) error { switch w.StatusCode { + case 200, 307: case 401: slog.Debug("auth failed", "url", w.Request.URL.String()) if err := authorizer.AddResponses(w.Request.Context(), []*http.Response{w}); err != nil { @@ -143,31 +135,38 @@ func (c *ProxyCmd) Run() error { }) case 404: - raw := (&ImagePattern{}).ParseImage(w.Request.Header.Get("Contatto-Raw-Image")) + rawStr := w.Request.Header.Get("Contatto-Raw-Image") + mirrorStr := w.Request.Header.Get("Contatto-Mirror-Image") + if rawStr == "" || mirrorStr == "" { + slog.Debug("missing image header", "url", w.Request.URL.String()) + return nil + } + + raw := (&ImagePattern{}).ParseImage(rawStr) raw.Alias = config.Registry[raw.Registry].Alias - mirror := (&ImagePattern{}).ParseImage(w.Request.Header.Get("Contatto-Mirror-Image")) + mirror := (&ImagePattern{}).ParseImage(mirrorStr) mirror.Alias = config.Registry[mirror.Registry].Alias - log := slog.With("raw_reg", raw.Registry) + slog := 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) + slog.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) + slog.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 { - log.Error("failed to run on missing command", "output", string(out), "err", err) + slog.Error("failed to run on missing command", "output", string(out), "err", err) return nil } - log.Info("on missing command finished", "took", time.Since(startTime)) + slog.Info("on missing command finished", "took", time.Since(startTime)) c.RetryToRewriteResp(w, "on_missing", http.DefaultClient.Do) } @@ -182,14 +181,11 @@ func (c *ProxyCmd) Run() error { } 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)) + slog.Warn("failed to retry request", "reason", reason, "err", err) return } @@ -197,7 +193,7 @@ func (c *ProxyCmd) RetryToRewriteResp(w *http.Response, reason string, do func(r w.Status = resp.Status w.Body = resp.Body - log.Info("retry to rewrite response", "url", req.URL.String(), "took", time.Since(startTime)) + slog.Info("rewrite response", "reason", reason, "url", req.URL.String()) } type ImagePattern struct {