Ctrl K
ComunidadGuías…

builder.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package aastro

import (
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/textproto"
	"strings"

	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"
	"github.com/prometheus/client_golang/prometheus"
	"go.opentelemetry.io/otel/sdk/resource"
	"go.uber.org/zap"

	"github.com/voidrunner3074/aastro/internal/circuitbreaker"
	"github.com/voidrunner3074/aastro/internal/metric"
	"github.com/voidrunner3074/aastro/internal/otelcommon"
	"github.com/voidrunner3074/aastro/internal/ratelimit"
	"github.com/voidrunner3074/aastro/internal/tlsutil"
	"github.com/voidrunner3074/aastro/internal/tracing"
)

type RoutingConfigSet struct {
	Routing        RoutingConfig
	Service        ServiceConfig
	ServiceVersion string // injected via ldflags
	Metrics        MetricsConfig
	Tracing        TracingConfig
}

type RouterBundle struct {
	Router         *Router
	TLSRegistry    *tlsutil.Registry
	MeterProvider  otelcommon.Provider
	TracerProvider otelcommon.Provider
	PromRegistry   *prometheus.Registry // nil unless metrics.exporter == "prometheus"
}

func NewRouter(ctx context.Context, cfgSet RoutingConfigSet, log *zap.Logger) (RouterBundle, error) {
	tracing.InstallPropagator() // Sets the propagator even if tracing is disabled

	res, err := otelcommon.NewResource(ctx, cfgSet.Service.Name, cfgSet.ServiceVersion)
	if err != nil {
		return RouterBundle{}, fmt.Errorf("build otel resource: %w", err)
	}

	meterProvider, promRegistry, err := initMetrics(ctx, cfgSet.Metrics, res)
	if err != nil {
		return RouterBundle{}, fmt.Errorf("init metrics: %w", err)
	}

	metrics, err := metric.New()
	if err != nil {
		return RouterBundle{}, fmt.Errorf("init metric instruments: %w", err)
	}

	tracerProvider, err := initTracing(ctx, cfgSet.Tracing, res)
	if err != nil {
		return RouterBundle{}, fmt.Errorf("init tracing: %w", err)
	}

	routing := cfgSet.Routing

	router := initMinimalRouter(len(routing.Flows), metrics, log)
	router.rateLimiter, err = initRateLimiter(routing.RateLimiter)
	if err != nil {
		return RouterBundle{}, fmt.Errorf("init rate limiter: %w", err)
	}

	trustedProxies, err := parseTrustedProxies(cfgSet.Routing.TrustedProxies)
	if err != nil {
		return RouterBundle{}, fmt.Errorf("parse trusted proxies: %w", err)
	}

	router.trustedProxies = trustedProxies

	tlsRegistry := tlsutil.NewRegistry()

	for _, fcfg := range routing.Flows {
		compiledFlow, compileErr := compileFlow(fcfg, trustedProxies, metrics, tlsRegistry, log)
		if compileErr != nil {
			return RouterBundle{}, fmt.Errorf("compile flow %q: %w", fcfg.Path, compileErr)
		}

		router.flows = append(router.flows, compiledFlow)
	}

	router.registerFlows()

	return RouterBundle{
		Router:         router,
		TLSRegistry:    tlsRegistry,
		MeterProvider:  meterProvider,
		PromRegistry:   promRegistry,
		TracerProvider: tracerProvider,
	}, nil
}

func (r *Router) registerFlows() {
	r.chiRouter.NotFound(func(w http.ResponseWriter, req *http.Request) {
		r.metrics.IncFailedRequestsTotal(metric.FailReasonNoMatchedFlow)
		r.log.Error("no flow matched", zap.String("request_uri", req.URL.RequestURI()))

		http.NotFound(w, req)
	})

	for i := range r.flows {
		f := &r.flows[i]

		middlewares := make([]func(http.Handler) http.Handler, 0, len(f.middlewares))
		for _, m := range f.middlewares {
			middlewares = append(middlewares, m.Handler)
		}

		r.chiRouter.With(middlewares...).Method(
			f.method,
			f.path,
			r.newFlowHandler(f),
		)
	}
}

func initMinimalRouter(routesCount int, metrics *metric.Metrics, log *zap.Logger) *Router {
	return &Router{
		chiRouter: chi.NewMux(),
		scatter: &defaultScatter{
			log:     log.Named("scatter"),
			metrics: metrics,
		},
		aggregator:  &defaultAggregator{},
		flows:       make([]flow, 0, routesCount),
		log:         log,
		metrics:     metrics,
		rateLimiter: nil,
	}
}

func initMetrics(ctx context.Context, cfg MetricsConfig, res *resource.Resource) (otelcommon.Provider, *prometheus.Registry, error) {
	if !cfg.Enabled {
		return otelcommon.NewNopProvider(), nil, nil
	}

	switch cfg.Exporter {
	case "prometheus":
		provider, reg, err := metric.NewOtelPrometheus(res)
		if err != nil {
			return nil, nil, fmt.Errorf("init prometheus metrics: %w", err)
		}

		return provider, reg, nil

	case "otlp":
		provider, err := metric.NewOtelOTLP(ctx, cfg.OTLP.Endpoint, cfg.OTLP.Insecure, cfg.OTLP.Interval, res)
		if err != nil {
			return nil, nil, fmt.Errorf("init otlp metrics: %w", err)
		}

		return provider, nil, nil
	default:
		return nil, nil, fmt.Errorf("unsupported metrics exporter: %q", cfg.Exporter)
	}
}

func initTracing(ctx context.Context, cfg TracingConfig, res *resource.Resource) (otelcommon.Provider, error) {
	if !cfg.Enabled {
		return otelcommon.NewNopProvider(), nil
	}

	switch cfg.Exporter {
	case "otlp":
		tracerProvider, err := tracing.NewOtelOTLP(
			ctx,
			cfg.OTLP.Endpoint,
			cfg.OTLP.Insecure,
			cfg.OTLP.Interval,
			cfg.SamplingRatio,
			res,
		)
		if err != nil {
			return nil, fmt.Errorf("init otlp tracing: %w", err)
		}

		return tracerProvider, nil
	default:
		return nil, fmt.Errorf("unsupported tracing exporter: %q", cfg.Exporter)
	}
}

func initRateLimiter(cfg RateLimiterConfig) (*ratelimit.RateLimit, error) {
	if !cfg.Enabled {
		return nil, nil //nolint:nilnil // it is ok for optional modules
	}

	rl := ratelimit.New(cfg.Config)
	if err := rl.Start(); err != nil {
		return nil, fmt.Errorf("start rate limiter: %w", err)
	}

	return rl, nil
}

func parseTrustedProxies(proxies []string) ([]*net.IPNet, error) {
	result := make([]*net.IPNet, 0, len(proxies))

	for _, proxy := range proxies {
		_, ipnet, err := net.ParseCIDR(proxy)
		if err != nil {
			return nil, fmt.Errorf("parse trusted proxy CIDR: %w", err)
		}

		result = append(result, ipnet)
	}

	return result, nil
}

func compileFlow(cfg FlowConfig, trustedProxies []*net.IPNet, metrics *metric.Metrics, tlsRegistry *tlsutil.Registry, log *zap.Logger) (flow, error) {
	upstreams, err := initUpstreams(cfg.Upstreams, trustedProxies, metrics, tlsRegistry, log)
	if err != nil {
		return flow{}, fmt.Errorf("init upstreams: %w", err)
	}

	if cfg.Streaming && len(upstreams) != 1 {
		return flow{}, fmt.Errorf(
			"streaming flow '%s' must have exactly one upstream, got %d",
			cfg.Path, len(upstreams),
		)
	}

	// Aggregation only applies when the aggregator is actually reached: a flow
	// with exactly one upstream is proxied directly (see Router.dispatch) and
	// never sees it, streaming or not.
	var aggregationParams aggregation

	if !cfg.Streaming && len(upstreams) > 1 {
		var initErr error

		aggregationParams, initErr = initAggregation(*cfg.Aggregation, upstreams)
		if initErr != nil {
			return flow{}, fmt.Errorf("init aggregation: %w", initErr)
		}
	}

	plugins, err := initPlugins(cfg.Plugins, log)
	if err != nil {
		return flow{}, fmt.Errorf("init plugins: %w", err)
	}

	middlewares, err := initMiddlewares(cfg.Middlewares, log)
	if err != nil {
		return flow{}, fmt.Errorf("init middlewares: %w", err)
	}

	return flow{
		path:        cfg.Path,
		method:      cfg.Method,
		aggregation: aggregationParams,
		upstreams:   upstreams,
		plugins:     plugins,
		middlewares: middlewares,
		streaming:   cfg.Streaming,
	}, nil
}

func initAggregation(cfg AggregationConfig, upstreams []upstream) (aggregation, error) {
	strategy, err := compileStrategy(cfg.Strategy)
	if err != nil {
		return aggregation{}, err
	}

	agg := aggregation{
		bestEffort:        cfg.BestEffort,
		strategy:          strategy,
		conflictPolicy:    conflictPolicyOverwrite, // default, value used only for merge strategy
		preferredUpstream: -1,                      // default, value used only for merge strategy
	}

	if strategy != strategyMerge {
		return agg, nil
	}

	conflict, err := compileConflictPolicy(cfg.OnConflict.Policy)
	if err != nil {
		return aggregation{}, err
	}

	agg.conflictPolicy = conflict

	if conflict == conflictPolicyPrefer {
		if cfg.OnConflict.Upstream == "" {
			return aggregation{}, errors.New("no upstream specified for on_conflict prefer policy")
		}

		idx, found := searchUpstream(cfg.OnConflict.Upstream, upstreams)
		if !found {
			return aggregation{}, errors.New("preferred upstream for on_conflict policy does not exist")
		}

		agg.preferredUpstream = idx
	}

	return agg, nil
}

func searchUpstream(name string, upstreams []upstream) (int, bool) {
	for i, u := range upstreams {
		if u.name() == name {
			return i, true
		}
	}

	return -1, false
}

func compileStrategy(s string) (aggregationStrategy, error) {
	switch s {
	case "array":
		return strategyArray, nil
	case "merge":
		return strategyMerge, nil
	case "namespace":
		return strategyNamespace, nil
	default:
		return 0, fmt.Errorf("unknown aggregation strategy: %q", s)
	}
}

func compileConflictPolicy(p string) (conflictPolicy, error) {
	switch p {
	case "overwrite":
		return conflictPolicyOverwrite, nil
	case "error":
		return conflictPolicyError, nil
	case "first":
		return conflictPolicyFirst, nil
	case "prefer":
		return conflictPolicyPrefer, nil
	default:
		return 0, fmt.Errorf("unknown aggregation conflict policy: %q", p)
	}
}

func initUpstreams(cfgs []UpstreamConfig, trustedProxies []*net.IPNet, metrics *metric.Metrics, tlsRegistry *tlsutil.Registry, log *zap.Logger) ([]upstream, error) {
	upstreams := make([]upstream, 0, len(cfgs))

	for _, cfg := range cfgs {
		u, err := buildUpstream(cfg, trustedProxies, metrics, tlsRegistry, log)
		if err != nil {
			return nil, fmt.Errorf("build upstream: %w", err)
		}

		upstreams = append(upstreams, u)
	}

	return upstreams, nil
}

func buildUpstream(cfg UpstreamConfig, trustedProxies []*net.IPNet, metrics *metric.Metrics, tlsRegistry *tlsutil.Registry, log *zap.Logger) (upstream, error) {
	tlsCfg, err := buildUpstreamTLSConfig(cfg.TLS, cfg.Transport.HTTP2, tlsRegistry)
	if err != nil {
		return nil, fmt.Errorf("build TLS config: %w", err)
	}

	transport := buildUpstreamTransport(cfg, tlsCfg)

	if cfg.TLS.InsecureSkipVerify {
		log.Warn("TLS verification disabled for upstream",
			zap.String("upstream", cfg.Name),
			zap.Strings("hosts", cfg.Hosts),
		)
	}

	client := &http.Client{Transport: transport, Timeout: cfg.Timeout}
	streamClient := &http.Client{Transport: transport}

	if !cfg.Policy.FollowRedirects {
		client.CheckRedirect = noRedirect
		streamClient.CheckRedirect = noRedirect
	}

	return &httpUpstream{
		cfg:            buildUpstreamConfig(cfg, trustedProxies),
		state:          buildUpstreamState(cfg.Hosts),
		circuitBreaker: buildCircuitBreaker(cfg.Policy.CircuitBreakerConfig),
		metrics:        metrics,
		log:            log,
		client:         client,
		streamClient:   streamClient,
	}, nil
}

func noRedirect(*http.Request, []*http.Request) error {
	return http.ErrUseLastResponse
}

func buildUpstreamConfig(cfg UpstreamConfig, trustedProxies []*net.IPNet) upstreamConfig {
	name := cfg.Name
	if name == "" {
		name = makeUpstreamName(cfg.Method, cfg.Hosts)
	}

	return upstreamConfig{
		id:             uuid.NewString(),
		name:           name,
		hosts:          cfg.Hosts,
		path:           cfg.Path,
		method:         cfg.Method,
		timeout:        cfg.Timeout,
		forwardHeaders: cfg.ForwardHeaders,
		forwardQueries: cfg.ForwardQueries,
		forwardParams:  cfg.ForwardParams,
		trustedProxies: trustedProxies,
		lbMode:         lbMode(cfg.Policy.LoadBalancingConfig.Mode),
		policy:         buildUpstreamPolicy(cfg.Policy),
	}
}

func buildUpstreamState(hosts []string) upstreamState {
	return upstreamState{
		currentHostIdx:    0,
		activeConnections: make([]int64, len(hosts)),
	}
}

func buildUpstreamPolicy(cfg PolicyConfig) upstreamPolicy {
	// Canonicalized so a config entry matches regardless of how it's cased:
	// filterHeaders looks these up against http.Header, which net/http
	// always stores in net/textproto canonical form.
	headerBlacklist := make(map[string]struct{}, len(cfg.HeaderBlacklist))
	for _, h := range cfg.HeaderBlacklist {
		headerBlacklist[textproto.CanonicalMIMEHeaderKey(h)] = struct{}{}
	}

	return upstreamPolicy{
		headerBlacklist:     headerBlacklist,
		requireBody:         cfg.RequireBody,
		maxResponseBodySize: cfg.MaxResponseBodySize,
		retry: retryPolicy{
			maxRetries:      cfg.RetryConfig.MaxRetries,
			retryOnStatuses: cfg.RetryConfig.RetryOnStatuses,
			backoffDelay:    cfg.RetryConfig.BackoffDelay,
		},
	}
}

func buildCircuitBreaker(cfg CircuitBreakerConfig) *circuitbreaker.CircuitBreaker {
	if !cfg.Enabled {
		return nil
	}

	return circuitbreaker.New(cfg.MaxFailures, cfg.ResetTimeout)
}

func buildUpstreamTransport(cfg UpstreamConfig, tlsCfg *tls.Config) *http.Transport {
	t := &http.Transport{
		MaxIdleConns:        cfg.Transport.MaxIdleConns,
		MaxIdleConnsPerHost: cfg.Transport.MaxIdleConnsPerHost,
		IdleConnTimeout:     cfg.Transport.IdleConnTimeout,
		TLSClientConfig:     tlsCfg,
	}

	if cfg.Transport.HTTP2 == "off" {
		protocols := new(http.Protocols)
		protocols.SetHTTP1(true)
		t.Protocols = protocols

		return t
	}

	t.ForceAttemptHTTP2 = true

	if tlsCfg != nil {
		protocols := new(http.Protocols)
		protocols.SetHTTP1(true)
		protocols.SetHTTP2(true)
		t.Protocols = protocols
	}

	return t
}

func buildUpstreamTLSConfig(cfg TLSConfig, http2 string, reg *tlsutil.Registry) (*tls.Config, error) {
	if !cfg.Enabled {
		return nil, nil //nolint:nilnil // its ok here
	}

	minVer, err := tlsutil.ParseVersion(cfg.MinVersion)
	if err != nil {
		return nil, err
	}

	reloader, err := tlsutil.NewReloader(tlsutil.ReloaderConfig{
		CertFile:           cfg.CertFile,
		CAFile:             cfg.CAFile,
		KeyFile:            cfg.KeyFile,
		ServerName:         cfg.ServerName,
		MinVersion:         minVer,
		InsecureSkipVerify: cfg.InsecureSkipVerify,
		DisableHTTP2:       http2 == "off",
	})
	if err != nil {
		return nil, err
	}

	reg.Register(reloader)

	return reloader.ClientConfig(), nil
}

// makeUpstreamName returns the upstream name made up of its method and hosts separated by a hyphen.
func makeUpstreamName(method string, hosts []string) string {
	sb := strings.Builder{}

	sb.WriteString(strings.ToLower(method))
	sb.WriteString("-")

	for i, host := range hosts {
		sb.WriteString(strings.ToLower(host))

		if i != len(hosts)-1 {
			sb.WriteString("-")
		}
	}

	return sb.String()
}