This commit is contained in:
2025-11-14 12:11:44 +08:00
commit 39ebf61572
88 changed files with 9999 additions and 0 deletions

8
internal/server/doc.go Normal file
View File

@@ -0,0 +1,8 @@
// Package server hosts the Fiber HTTP service, request middleware chain, and
// hub registry glue that wires Host/port resolution into proxy handlers.
// Phase 1 focuses on a single binary that bootstraps Fiber, attaches logging
// and error middlewares, injects the HubRegistry built from config, and exposes
// router constructors that other packages (cmd/any-hub, proxy) can reuse.
// Future phases may extend this package with TLS, metrics endpoints, or admin
// surfaces, so keep exports narrow and accept explicit dependencies.
package server

View File

@@ -0,0 +1,77 @@
package server
import (
"net"
"net/http"
"net/textproto"
"time"
"github.com/any-hub/any-hub/internal/config"
)
// Shared HTTP transport tunings复用长连接并集中配置超时。
var defaultTransport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
}
// NewUpstreamClient 返回共享 http.Client用于所有上游请求。
func NewUpstreamClient(cfg *config.Config) *http.Client {
timeout := 30 * time.Second
if cfg != nil && cfg.Global.UpstreamTimeout.DurationValue() > 0 {
timeout = cfg.Global.UpstreamTimeout.DurationValue()
}
return &http.Client{
Timeout: timeout,
Transport: defaultTransport.Clone(),
}
}
// hopByHopHeaders 定义 RFC 7230 中禁止代理转发的头部。
var hopByHopHeaders = map[string]struct{}{
"Connection": {},
"Keep-Alive": {},
"Proxy-Authenticate": {},
"Proxy-Authorization": {},
"Te": {},
"Trailer": {},
"Transfer-Encoding": {},
"Upgrade": {},
"Proxy-Connection": {}, // 非标准字段,但部分代理仍使用
}
// CopyHeaders 将 src 中允许透传的头复制到 dst自动忽略 hop-by-hop 字段。
func CopyHeaders(dst, src http.Header) {
for key, values := range src {
if isHopByHopHeader(key) {
continue
}
for _, value := range values {
dst.Add(key, value)
}
}
}
func isHopByHopHeader(key string) bool {
canonical := textproto.CanonicalMIMEHeaderKey(key)
if _, ok := hopByHopHeaders[canonical]; ok {
return true
}
return false
}
// IsHopByHopHeader reports whether the header should be stripped by proxies.
func IsHopByHopHeader(key string) bool {
return isHopByHopHeader(key)
}

View File

@@ -0,0 +1,45 @@
package server
import (
"net/http"
"testing"
"time"
"github.com/any-hub/any-hub/internal/config"
)
func TestNewUpstreamClientUsesConfigTimeout(t *testing.T) {
cfg := &config.Config{
Global: config.GlobalConfig{
UpstreamTimeout: config.Duration(45 * time.Second),
},
}
client := NewUpstreamClient(cfg)
if client.Timeout != 45*time.Second {
t.Fatalf("expected timeout 45s, got %s", client.Timeout)
}
}
func TestCopyHeadersSkipsHopByHop(t *testing.T) {
src := http.Header{}
src.Add("Connection", "keep-alive")
src.Add("Keep-Alive", "timeout=5")
src.Add("X-Test-Header", "1")
src.Add("x-test-header", "2")
dst := http.Header{}
CopyHeaders(dst, src)
if _, exists := dst["Connection"]; exists {
t.Fatalf("connection header should not be copied")
}
if _, exists := dst["Keep-Alive"]; exists {
t.Fatalf("keep-alive header should not be copied")
}
got := dst.Values("X-Test-Header")
if len(got) != 2 {
t.Fatalf("expected 2 values, got %v", got)
}
}

View File

@@ -0,0 +1,152 @@
package server
import (
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/any-hub/any-hub/internal/config"
)
// HubRoute 将 Hub 配置与派生属性(如缓存 TTL、解析后的 Upstream/Proxy URL
// 聚合在一起,供路由/代理层直接复用,避免重复解析配置。
type HubRoute struct {
// Config 是用户在 config.toml 中声明的 Hub 字段副本,避免外部修改。
Config config.HubConfig
// ListenPort 记录当前 CLI 监听端口,方便日志/转发头输出。
ListenPort int
// CacheTTL 是对当前 Hub 生效的 TTL若 Hub 未覆盖则等于全局值。
CacheTTL time.Duration
// UpstreamURL/ProxyURL 在构造 Registry 时提前解析完成,便于后续请求快速复用。
UpstreamURL *url.URL
ProxyURL *url.URL
}
// HubRegistry 提供 Host/Host:port 到 HubRoute 的查询能力,所有 Hub 共享同一个监听端口。
type HubRegistry struct {
routes map[string]*HubRoute
ordered []*HubRoute
}
// NewHubRegistry 根据配置构建 Host/端口映射。调用方应在启动阶段创建一次并复用。
func NewHubRegistry(cfg *config.Config) (*HubRegistry, error) {
if cfg == nil {
return nil, errors.New("config is nil")
}
registry := &HubRegistry{
routes: make(map[string]*HubRoute, len(cfg.Hubs)),
}
if len(cfg.Hubs) == 0 {
return registry, nil
}
for _, hub := range cfg.Hubs {
normalizedHost := normalizeDomain(hub.Domain)
if normalizedHost == "" {
return nil, fmt.Errorf("invalid domain for hub %s", hub.Name)
}
if _, exists := registry.routes[normalizedHost]; exists {
return nil, fmt.Errorf("duplicate domain mapping detected for %s", normalizedHost)
}
route, err := buildHubRoute(cfg, hub)
if err != nil {
return nil, err
}
registry.routes[normalizedHost] = route
registry.ordered = append(registry.ordered, route)
}
return registry, nil
}
// Lookup 根据 Host 或 Host:port 查找 HubRoute。
func (r *HubRegistry) Lookup(host string) (*HubRoute, bool) {
if r == nil {
return nil, false
}
normalizedHost, _ := normalizeHost(host)
if normalizedHost == "" {
return nil, false
}
route, ok := r.routes[normalizedHost]
return route, ok
}
// List 返回当前注册的 HubRoute 列表(按配置定义的顺序),用于调试或 /status 输出。
func (r *HubRegistry) List() []HubRoute {
if r == nil || len(r.ordered) == 0 {
return nil
}
result := make([]HubRoute, len(r.ordered))
for i, route := range r.ordered {
result[i] = *route
}
return result
}
func buildHubRoute(cfg *config.Config, hub config.HubConfig) (*HubRoute, error) {
upstreamURL, err := url.Parse(hub.Upstream)
if err != nil {
return nil, fmt.Errorf("invalid upstream for hub %s: %w", hub.Name, err)
}
var proxyURL *url.URL
if hub.Proxy != "" {
proxyURL, err = url.Parse(hub.Proxy)
if err != nil {
return nil, fmt.Errorf("invalid proxy for hub %s: %w", hub.Name, err)
}
}
return &HubRoute{
Config: hub,
ListenPort: cfg.Global.ListenPort,
CacheTTL: cfg.EffectiveCacheTTL(hub),
UpstreamURL: upstreamURL,
ProxyURL: proxyURL,
}, nil
}
func normalizeDomain(domain string) string {
host, _ := normalizeHost(domain)
return host
}
func normalizeHost(raw string) (string, int) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", 0
}
host := raw
port := 0
if strings.Contains(raw, ":") {
if h, p, err := net.SplitHostPort(raw); err == nil {
host = h
if parsedPort, err := strconv.Atoi(p); err == nil {
port = parsedPort
}
} else if idx := strings.LastIndex(raw, ":"); idx > -1 && strings.Count(raw[idx+1:], ":") == 0 {
if parsedPort, err := strconv.Atoi(raw[idx+1:]); err == nil {
host = raw[:idx]
port = parsedPort
}
}
}
host = strings.TrimSuffix(host, ".")
host = strings.ToLower(host)
return host, port
}

View File

@@ -0,0 +1,120 @@
package server
import (
"testing"
"time"
"github.com/any-hub/any-hub/internal/config"
)
func TestHubRegistryLookupByHost(t *testing.T) {
cfg := &config.Config{
Global: config.GlobalConfig{
ListenPort: 5000,
CacheTTL: config.Duration(2 * time.Hour),
},
Hubs: []config.HubConfig{
{
Name: "docker",
Domain: "docker.hub.local",
Type: "docker",
Upstream: "https://registry-1.docker.io",
EnableHeadCheck: true,
},
{
Name: "npm",
Domain: "npm.hub.local",
Type: "npm",
Upstream: "https://registry.npmjs.org",
CacheTTL: config.Duration(30 * time.Minute),
},
},
}
registry, err := NewHubRegistry(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
route, ok := registry.Lookup("docker.hub.local")
if !ok {
t.Fatalf("expected docker route")
}
if route.Config.Name != "docker" {
t.Errorf("wrong hub returned: %s", route.Config.Name)
}
if route.CacheTTL != cfg.EffectiveCacheTTL(route.Config) {
t.Errorf("cache ttl mismatch: got %s", route.CacheTTL)
}
if route.UpstreamURL.String() != "https://registry-1.docker.io" {
t.Errorf("unexpected upstream URL: %s", route.UpstreamURL)
}
if route.ProxyURL != nil {
t.Errorf("expected nil proxy")
}
if route.ListenPort != cfg.Global.ListenPort {
t.Fatalf("route listen port mismatch: %d", route.ListenPort)
}
if got := len(registry.List()); got != 2 {
t.Fatalf("expected 2 routes in list, got %d", got)
}
}
func TestHubRegistryParsesHostHeaderPort(t *testing.T) {
cfg := &config.Config{
Global: config.GlobalConfig{
ListenPort: 5000,
CacheTTL: config.Duration(time.Hour),
},
Hubs: []config.HubConfig{
{
Name: "docker",
Domain: "docker.hub.local",
Type: "docker",
Upstream: "https://registry-1.docker.io",
},
},
}
registry, err := NewHubRegistry(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := registry.Lookup("docker.hub.local:6000"); !ok {
t.Fatalf("expected lookup to ignore host header port")
}
}
func TestHubRegistryRejectsDuplicateDomains(t *testing.T) {
cfg := &config.Config{
Global: config.GlobalConfig{
ListenPort: 5000,
CacheTTL: config.Duration(time.Hour),
},
Hubs: []config.HubConfig{
{
Name: "docker",
Domain: "docker.hub.local",
Type: "docker",
Upstream: "https://registry-1.docker.io",
},
{
Name: "docker-alt",
Domain: "docker.hub.local",
Type: "docker",
Upstream: "https://mirror.registry-1.docker.io",
},
},
}
if _, err := NewHubRegistry(cfg); err == nil {
t.Fatalf("expected duplicate domain error")
}
}

163
internal/server/router.go Normal file
View File

@@ -0,0 +1,163 @@
package server
import (
"errors"
"fmt"
"strings"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/recover"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
)
// ProxyHandler describes the component responsible for proxying requests to
// the upstream Hub. It allows injecting fake handlers during tests.
type ProxyHandler interface {
Handle(fiber.Ctx, *HubRoute) error
}
// ProxyHandlerFunc adapts a function to the ProxyHandler interface.
type ProxyHandlerFunc func(fiber.Ctx, *HubRoute) error
// Handle makes ProxyHandlerFunc satisfy ProxyHandler.
func (f ProxyHandlerFunc) Handle(c fiber.Ctx, route *HubRoute) error {
return f(c, route)
}
// AppOptions controls how the Fiber application should behave on a specific port.
type AppOptions struct {
Logger *logrus.Logger
Registry *HubRegistry
Proxy ProxyHandler
ListenPort int
}
const (
contextKeyRoute = "_anyhub_route"
contextKeyRequestID = "_anyhub_request_id"
)
// NewApp builds a Fiber application with Host/port routing middleware and
// structured error handling.
func NewApp(opts AppOptions) (*fiber.App, error) {
if opts.Logger == nil {
return nil, errors.New("logger is required")
}
if opts.Registry == nil {
return nil, errors.New("hub registry is required")
}
if opts.Proxy == nil {
return nil, errors.New("proxy handler is required")
}
if opts.ListenPort <= 0 {
return nil, fmt.Errorf("invalid listen port: %d", opts.ListenPort)
}
app := fiber.New(fiber.Config{
CaseSensitive: true,
})
app.Use(recover.New())
app.Use(requestContextMiddleware(opts))
app.All("/*", func(c fiber.Ctx) error {
route, _ := getRouteFromContext(c)
if route == nil {
return renderHostUnmapped(c, opts.Logger, "", opts.ListenPort)
}
return opts.Proxy.Handle(c, route)
})
return app, nil
}
// requestContextMiddleware 负责生成请求 ID并基于 Host/Host:port 查找 HubRoute。
func requestContextMiddleware(opts AppOptions) fiber.Handler {
return func(c fiber.Ctx) error {
reqID := uuid.NewString()
c.Locals(contextKeyRequestID, reqID)
c.Set("X-Request-ID", reqID)
rawHost := strings.TrimSpace(getHostHeader(c))
route, ok := opts.Registry.Lookup(rawHost)
if !ok {
return renderHostUnmapped(c, opts.Logger, rawHost, opts.ListenPort)
}
if err := ensureRouterHubType(route); err != nil {
return renderTypeUnsupported(c, opts.Logger, route, err)
}
c.Locals(contextKeyRoute, route)
return c.Next()
}
}
func renderHostUnmapped(c fiber.Ctx, logger *logrus.Logger, host string, port int) error {
fields := logrus.Fields{
"action": "host_lookup",
"host": host,
"port": port,
}
logger.WithFields(fields).Warn("host unmapped")
if host != "" {
c.Set("X-Any-Hub-Host", host)
}
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "host_unmapped",
})
}
func getHostHeader(c fiber.Ctx) string {
if raw := c.Request().Header.Peek(fiber.HeaderHost); len(raw) > 0 {
return string(raw)
}
return c.Hostname()
}
func getRouteFromContext(c fiber.Ctx) (*HubRoute, bool) {
if value := c.Locals(contextKeyRoute); value != nil {
if route, ok := value.(*HubRoute); ok {
return route, true
}
}
return nil, false
}
// RequestID returns the request identifier stored by the router middleware.
func RequestID(c fiber.Ctx) string {
if value := c.Locals(contextKeyRequestID); value != nil {
if reqID, ok := value.(string); ok {
return reqID
}
}
return ""
}
func ensureRouterHubType(route *HubRoute) error {
switch route.Config.Type {
case "docker":
return nil
case "npm":
return nil
case "go":
return nil
default:
return fmt.Errorf("unsupported hub type: %s", route.Config.Type)
}
}
func renderTypeUnsupported(c fiber.Ctx, logger *logrus.Logger, route *HubRoute, err error) error {
fields := logrus.Fields{
"action": "hub_type_check",
"hub": route.Config.Name,
"hub_type": route.Config.Type,
"error": "hub_type_unsupported",
}
logger.WithFields(fields).Error(err.Error())
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{
"error": "hub_type_unsupported",
})
}

View File

@@ -0,0 +1,118 @@
package server
import (
"bytes"
"io"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v3"
"github.com/sirupsen/logrus"
"github.com/any-hub/any-hub/internal/config"
)
func TestRouterRoutesRequestWhenHostMatches(t *testing.T) {
app := newTestApp(t, 5000)
req := httptest.NewRequest("GET", "http://docker.hub.local/v2/", nil)
req.Host = "docker.hub.local"
req.Header.Set("Host", "docker.hub.local")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test failed: %v", err)
}
if resp.StatusCode != fiber.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 204 status, got %d (body=%s, hostHeader=%s)", resp.StatusCode, string(body), resp.Header.Get("X-Any-Hub-Host"))
}
if app.storage.routeName != "docker" {
t.Fatalf("expected docker route, got %s", app.storage.routeName)
}
if reqID := resp.Header.Get("X-Request-ID"); reqID == "" {
t.Fatalf("expected X-Request-ID header to be set")
}
}
func TestRouterReturns404WhenHostUnknown(t *testing.T) {
app := newTestApp(t, 5000)
req := httptest.NewRequest("GET", "http://unknown.local/v2/", nil)
req.Host = "unknown.local"
req.Header.Set("Host", "unknown.local")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test failed: %v", err)
}
if resp.StatusCode != fiber.StatusNotFound {
t.Fatalf("expected 404 status, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !bytes.Contains(body, []byte(`"host_unmapped"`)) {
t.Fatalf("expected host_unmapped error, got %s", string(body))
}
}
type testApp struct {
*fiber.App
storage *proxyRecorder
}
func newTestApp(t *testing.T, port int) *testApp {
t.Helper()
cfg := &config.Config{
Global: config.GlobalConfig{
ListenPort: port,
CacheTTL: config.Duration(3600),
},
Hubs: []config.HubConfig{
{
Name: "docker",
Domain: "docker.hub.local",
Type: "docker",
Upstream: "https://registry-1.docker.io",
},
},
}
registry, err := NewHubRegistry(cfg)
if err != nil {
t.Fatalf("failed to create registry: %v", err)
}
if _, ok := registry.Lookup("docker.hub.local"); !ok {
t.Fatalf("registry lookup failed for docker")
}
logger := logrus.New()
logger.SetOutput(io.Discard)
recorder := &proxyRecorder{}
app, err := NewApp(AppOptions{
Logger: logger,
Registry: registry,
Proxy: recorder,
ListenPort: port,
})
if err != nil {
t.Fatalf("failed to create app: %v", err)
}
return &testApp{App: app, storage: recorder}
}
type proxyRecorder struct {
lastRoute *HubRoute
routeName string
}
func (p *proxyRecorder) Handle(c fiber.Ctx, route *HubRoute) error {
p.lastRoute = route
p.routeName = route.Config.Name
return c.SendStatus(fiber.StatusNoContent)
}