mirror of
https://github.com/sky22333/hubproxy.git
synced 2026-08-05 03:24:57 +08:00
450 lines
12 KiB
Go
450 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"hubproxy/config"
|
|
"hubproxy/utils"
|
|
)
|
|
|
|
type registryTarget struct {
|
|
Name string
|
|
Upstream string
|
|
AuthRealm string
|
|
AuthService string
|
|
AutoLibraryPrefix bool
|
|
}
|
|
|
|
const (
|
|
dockerHubName = "docker.io"
|
|
dockerHubUpstream = "https://registry-1.docker.io"
|
|
dockerHubAuthRealm = "https://auth.docker.io/token"
|
|
dockerHubAuthService = "registry.docker.io"
|
|
)
|
|
|
|
var hopByHopHeaders = map[string]struct{}{
|
|
"connection": {},
|
|
"keep-alive": {},
|
|
"proxy-authenticate": {},
|
|
"proxy-authorization": {},
|
|
"te": {},
|
|
"trailer": {},
|
|
"transfer-encoding": {},
|
|
"upgrade": {},
|
|
}
|
|
|
|
var forwardedRequestHeaders = []string{
|
|
"Authorization",
|
|
"Accept",
|
|
"Range",
|
|
"If-Range",
|
|
"If-Match",
|
|
"If-None-Match",
|
|
"If-Modified-Since",
|
|
"If-Unmodified-Since",
|
|
}
|
|
|
|
// InitDockerProxy is kept as the Docker proxy initialization hook. The online
|
|
// registry proxy is intentionally stateless and uses the shared HTTP client.
|
|
func InitDockerProxy() {}
|
|
|
|
func defaultRegistryTarget() registryTarget {
|
|
cfg := config.GetConfig()
|
|
if mapping, exists := cfg.Registries[dockerHubName]; exists && mapping.Enabled {
|
|
target := registryTargetFromMapping(dockerHubName, mapping)
|
|
target.AuthService = dockerHubAuthService
|
|
target.AutoLibraryPrefix = true
|
|
return target
|
|
}
|
|
|
|
return registryTarget{
|
|
Name: dockerHubName,
|
|
Upstream: dockerHubUpstream,
|
|
AuthRealm: dockerHubAuthRealm,
|
|
AuthService: dockerHubAuthService,
|
|
AutoLibraryPrefix: true,
|
|
}
|
|
}
|
|
|
|
func registryTargetFromMapping(name string, mapping config.RegistryMapping) registryTarget {
|
|
upstream := strings.TrimRight(strings.TrimSpace(mapping.Upstream), "/")
|
|
if upstream == "" {
|
|
upstream = name
|
|
}
|
|
if !strings.HasPrefix(upstream, "http://") && !strings.HasPrefix(upstream, "https://") {
|
|
upstream = "https://" + upstream
|
|
}
|
|
|
|
authRealm := strings.TrimSpace(mapping.AuthHost)
|
|
if authRealm == "" {
|
|
authRealm = strings.TrimPrefix(upstream, "https://")
|
|
authRealm = strings.TrimPrefix(authRealm, "http://")
|
|
}
|
|
if !strings.HasPrefix(authRealm, "http://") && !strings.HasPrefix(authRealm, "https://") {
|
|
authRealm = "https://" + authRealm
|
|
}
|
|
|
|
authService := strings.TrimPrefix(strings.TrimPrefix(upstream, "https://"), "http://")
|
|
|
|
return registryTarget{
|
|
Name: name,
|
|
Upstream: upstream,
|
|
AuthRealm: authRealm,
|
|
AuthService: authService,
|
|
AutoLibraryPrefix: false,
|
|
}
|
|
}
|
|
|
|
func resolveRegistryTarget(c *gin.Context, pathWithoutV2 string) (registryTarget, string) {
|
|
cfg := config.GetConfig()
|
|
|
|
if ns := strings.TrimSpace(c.Query("ns")); ns != "" {
|
|
if mapping, exists := cfg.Registries[ns]; exists && mapping.Enabled {
|
|
return registryTargetFromMapping(ns, mapping), pathWithoutV2
|
|
}
|
|
}
|
|
|
|
for domain, mapping := range cfg.Registries {
|
|
if mapping.Enabled && strings.HasPrefix(pathWithoutV2, domain+"/") {
|
|
return registryTargetFromMapping(domain, mapping), strings.TrimPrefix(pathWithoutV2, domain+"/")
|
|
}
|
|
}
|
|
|
|
return defaultRegistryTarget(), pathWithoutV2
|
|
}
|
|
|
|
func resolveTokenTarget(c *gin.Context) (registryTarget, bool) {
|
|
name := strings.Trim(strings.TrimSpace(c.Param("path")), "/")
|
|
if name == "" {
|
|
return defaultRegistryTarget(), true
|
|
}
|
|
|
|
if name == dockerHubName || name == "dockerhub" || name == "registry-1.docker.io" {
|
|
return defaultRegistryTarget(), true
|
|
}
|
|
|
|
cfg := config.GetConfig()
|
|
if mapping, exists := cfg.Registries[name]; exists && mapping.Enabled {
|
|
return registryTargetFromMapping(name, mapping), true
|
|
}
|
|
|
|
return registryTarget{}, false
|
|
}
|
|
|
|
// ProxyDockerRegistryGin proxies Docker Registry API v2 requests transparently.
|
|
func ProxyDockerRegistryGin(c *gin.Context) {
|
|
path := c.Request.URL.Path
|
|
|
|
if path == "/v2/" {
|
|
c.JSON(http.StatusOK, gin.H{})
|
|
return
|
|
}
|
|
|
|
if !strings.HasPrefix(path, "/v2/") {
|
|
c.String(http.StatusNotFound, "Docker Registry API v2 only")
|
|
return
|
|
}
|
|
|
|
handleRegistryRequest(c, path)
|
|
}
|
|
|
|
func handleRegistryRequest(c *gin.Context, path string) {
|
|
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
|
target, targetPath := resolveRegistryTarget(c, pathWithoutV2)
|
|
|
|
imageName, apiType, _ := parseRegistryPath(targetPath)
|
|
if imageName == "" || apiType == "" {
|
|
c.String(http.StatusBadRequest, "Invalid path format")
|
|
return
|
|
}
|
|
|
|
if target.AutoLibraryPrefix && !strings.Contains(imageName, "/") {
|
|
imageName = "library/" + imageName
|
|
targetPath = strings.TrimPrefix(targetPath, strings.TrimPrefix(imageName, "library/"))
|
|
targetPath = imageName + targetPath
|
|
}
|
|
|
|
accessName := imageName
|
|
if target.Name != dockerHubName {
|
|
accessName = target.Name + "/" + imageName
|
|
}
|
|
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(accessName); !allowed {
|
|
fmt.Printf("Docker image %s access denied: %s\n", accessName, reason)
|
|
c.String(http.StatusForbidden, reason)
|
|
return
|
|
}
|
|
|
|
proxyRegistryHTTP(c, target, "/v2/"+targetPath)
|
|
}
|
|
|
|
// parseRegistryPath parses a Docker Registry v2 path without the leading /v2/.
|
|
func parseRegistryPath(path string) (imageName, apiType, reference string) {
|
|
if idx := strings.Index(path, "/manifests/"); idx != -1 {
|
|
return path[:idx], "manifests", path[idx+len("/manifests/"):]
|
|
}
|
|
if idx := strings.Index(path, "/blobs/"); idx != -1 {
|
|
return path[:idx], "blobs", path[idx+len("/blobs/"):]
|
|
}
|
|
if idx := strings.Index(path, "/tags/list"); idx != -1 {
|
|
return path[:idx], "tags", "list"
|
|
}
|
|
return "", "", ""
|
|
}
|
|
|
|
// ProxyDockerAuthGin forwards Docker token requests, including client Basic
|
|
// credentials, to the selected upstream auth service.
|
|
func ProxyDockerAuthGin(c *gin.Context) {
|
|
target, ok := resolveTokenTarget(c)
|
|
if !ok {
|
|
c.String(http.StatusBadRequest, "Unknown registry target")
|
|
return
|
|
}
|
|
|
|
cacheable := c.GetHeader("Authorization") == "" && utils.IsTokenCacheEnabled() && c.Request.Method == http.MethodGet
|
|
cacheKey := utils.BuildTokenCacheKey(target.Name + ":" + c.Request.URL.RawQuery)
|
|
if cacheable {
|
|
if cachedToken := utils.GlobalCache.GetToken(cacheKey); cachedToken != "" {
|
|
utils.WriteTokenResponse(c, cachedToken)
|
|
return
|
|
}
|
|
}
|
|
|
|
authURL, err := buildAuthURL(target, c.Request.URL.RawQuery)
|
|
if err != nil {
|
|
c.String(http.StatusInternalServerError, "Failed to build auth request")
|
|
return
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, authURL, nil)
|
|
if err != nil {
|
|
c.String(http.StatusInternalServerError, "Failed to create auth request")
|
|
return
|
|
}
|
|
forwardSelectedRequestHeaders(req.Header, c.Request.Header)
|
|
|
|
resp, err := utils.GetGlobalHTTPClient().Do(req)
|
|
if err != nil {
|
|
c.String(http.StatusBadGateway, "Auth request failed")
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
c.String(http.StatusBadGateway, "Failed to read auth response")
|
|
return
|
|
}
|
|
|
|
copyResponseHeaders(c, resp.Header, target)
|
|
if cacheable && resp.StatusCode == http.StatusOK && len(body) > 0 {
|
|
utils.GlobalCache.SetToken(cacheKey, string(body), utils.ExtractTTLFromResponse(body))
|
|
}
|
|
|
|
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), body)
|
|
}
|
|
|
|
func buildAuthURL(target registryTarget, rawQuery string) (string, error) {
|
|
authURL, err := url.Parse(target.AuthRealm)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
query := authURL.Query()
|
|
query.Set("service", target.AuthService)
|
|
|
|
if rawQuery != "" {
|
|
incoming, err := url.ParseQuery(rawQuery)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for key, values := range incoming {
|
|
if strings.EqualFold(key, "service") {
|
|
continue
|
|
}
|
|
for _, value := range values {
|
|
if strings.EqualFold(key, "scope") && target.AutoLibraryPrefix {
|
|
value = addLibraryPrefixToScope(value)
|
|
}
|
|
query.Add(key, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
authURL.RawQuery = query.Encode()
|
|
return authURL.String(), nil
|
|
}
|
|
|
|
func addLibraryPrefixToScope(scope string) string {
|
|
parts := strings.Split(scope, ":")
|
|
if len(parts) != 3 || parts[0] != "repository" || strings.Contains(parts[1], "/") {
|
|
return scope
|
|
}
|
|
return "repository:library/" + parts[1] + ":" + parts[2]
|
|
}
|
|
|
|
func proxyRegistryHTTP(c *gin.Context, target registryTarget, upstreamPath string) {
|
|
targetURL := target.Upstream + upstreamPath
|
|
if c.Request.URL.RawQuery != "" {
|
|
targetURL += "?" + c.Request.URL.RawQuery
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, nil)
|
|
if err != nil {
|
|
c.String(http.StatusInternalServerError, "Failed to create registry request")
|
|
return
|
|
}
|
|
forwardSelectedRequestHeaders(req.Header, c.Request.Header)
|
|
|
|
resp, err := utils.GetGlobalHTTPClient().Do(req)
|
|
if err != nil {
|
|
c.String(http.StatusBadGateway, "Registry request failed")
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
copyResponseHeaders(c, resp.Header, target)
|
|
c.Status(resp.StatusCode)
|
|
|
|
if c.Request.Method == http.MethodHead {
|
|
return
|
|
}
|
|
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
|
fmt.Printf("Failed to stream registry response: %v\n", err)
|
|
}
|
|
}
|
|
|
|
func forwardSelectedRequestHeaders(dst http.Header, src http.Header) {
|
|
for _, name := range forwardedRequestHeaders {
|
|
for _, value := range src.Values(name) {
|
|
dst.Add(name, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func copyResponseHeaders(c *gin.Context, headers http.Header, target registryTarget) {
|
|
for name, values := range headers {
|
|
if shouldSkipResponseHeader(name) {
|
|
continue
|
|
}
|
|
|
|
for _, value := range values {
|
|
if strings.EqualFold(name, "WWW-Authenticate") {
|
|
value = rewriteAuthChallenge(value, target, publicBaseURL(c))
|
|
c.Header("WWW-Authenticate", value)
|
|
} else {
|
|
c.Header(name, value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func shouldSkipResponseHeader(name string) bool {
|
|
_, hopByHop := hopByHopHeaders[strings.ToLower(name)]
|
|
return hopByHop
|
|
}
|
|
|
|
func publicBaseURL(c *gin.Context) string {
|
|
proto := "http"
|
|
if c.Request.TLS != nil {
|
|
proto = "https"
|
|
}
|
|
if forwardedProto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); forwardedProto != "" {
|
|
proto = strings.Split(forwardedProto, ",")[0]
|
|
}
|
|
|
|
host := c.Request.Host
|
|
if host == "" {
|
|
cfg := config.GetConfig()
|
|
host = fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
|
if cfg.Server.Host == "0.0.0.0" {
|
|
host = fmt.Sprintf("localhost:%d", cfg.Server.Port)
|
|
}
|
|
}
|
|
|
|
return strings.TrimRight(proto+"://"+host, "/")
|
|
}
|
|
|
|
func rewriteAuthChallenge(authHeader string, target registryTarget, baseURL string) string {
|
|
if !strings.HasPrefix(strings.TrimSpace(strings.ToLower(authHeader)), "bearer ") {
|
|
return authHeader
|
|
}
|
|
|
|
scope := bearerParam(authHeader, "scope")
|
|
challenge := fmt.Sprintf(
|
|
`Bearer realm="%s/token/%s",service="%s"`,
|
|
baseURL,
|
|
escapeAuthParam(target.Name),
|
|
escapeAuthParam(target.AuthService),
|
|
)
|
|
if scope != "" {
|
|
challenge += fmt.Sprintf(`,scope="%s"`, escapeAuthParam(scope))
|
|
}
|
|
return challenge
|
|
}
|
|
|
|
func bearerParam(authHeader, paramName string) string {
|
|
input := strings.TrimSpace(authHeader)
|
|
if len(input) < len("Bearer ") || !strings.EqualFold(input[:len("Bearer ")], "Bearer ") {
|
|
return ""
|
|
}
|
|
input = strings.TrimSpace(input[len("Bearer "):])
|
|
|
|
for input != "" {
|
|
input = strings.TrimLeft(input, ", \t")
|
|
key, rest, found := strings.Cut(input, "=")
|
|
if !found {
|
|
return ""
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
rest = strings.TrimSpace(rest)
|
|
|
|
var value string
|
|
if strings.HasPrefix(rest, `"`) {
|
|
rest = rest[1:]
|
|
var b strings.Builder
|
|
escaped := false
|
|
end := -1
|
|
for i, r := range rest {
|
|
if escaped {
|
|
b.WriteRune(r)
|
|
escaped = false
|
|
continue
|
|
}
|
|
if r == '\\' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if r == '"' {
|
|
end = i + 1
|
|
break
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
if end == -1 {
|
|
return ""
|
|
}
|
|
value = b.String()
|
|
input = rest[end:]
|
|
} else {
|
|
value, input, _ = strings.Cut(rest, ",")
|
|
value = strings.TrimSpace(value)
|
|
}
|
|
|
|
if strings.EqualFold(key, paramName) {
|
|
return value
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func escapeAuthParam(value string) string {
|
|
value = strings.ReplaceAll(value, `\`, `\\`)
|
|
return strings.ReplaceAll(value, `"`, `\"`)
|
|
}
|