Merge branch v2.11 into v3.2

This commit is contained in:
romain 2024-11-20 14:08:24 +01:00
commit ca5b70e196
22 changed files with 190 additions and 68 deletions

View file

@ -62,7 +62,7 @@ _(But if you'd rather configure some of your routes manually, Traefik supports t
- Provides HTTPS to your microservices by leveraging [Let's Encrypt](https://letsencrypt.org) (wildcard certificates support) - Provides HTTPS to your microservices by leveraging [Let's Encrypt](https://letsencrypt.org) (wildcard certificates support)
- Circuit breakers, retry - Circuit breakers, retry
- See the magic through its clean web UI - See the magic through its clean web UI
- Websocket, HTTP/2, gRPC ready - WebSocket, HTTP/2, gRPC ready
- Provides metrics (Rest, Prometheus, Datadog, Statsd, InfluxDB 2.X) - Provides metrics (Rest, Prometheus, Datadog, Statsd, InfluxDB 2.X)
- Keeps access logs (JSON, CLF) - Keeps access logs (JSON, CLF)
- Fast - Fast

View file

@ -1,7 +1,7 @@
# Security Policy # Security Policy
You can join our security mailing list to be aware of the latest announcements from our security team. You can join our security mailing list to be aware of the latest announcements from our security team.
You can subscribe sending a mail to security+subscribe@traefik.io or on [the online viewer](https://groups.google.com/a/traefik.io/forum/#!forum/security). You can subscribe by sending an email to security+subscribe@traefik.io or on [the online viewer](https://groups.google.com/a/traefik.io/forum/#!forum/security).
Reported vulnerabilities can be found on [cve.mitre.org](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=traefik). Reported vulnerabilities can be found on [cve.mitre.org](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=traefik).

View file

@ -92,7 +92,7 @@ For development purposes, you can specify which tests to run by using (only work
Create `tailscale.secret` file in `integration` directory. Create `tailscale.secret` file in `integration` directory.
This file need to contains a [Tailscale auth key](https://tailscale.com/kb/1085/auth-keys) This file needs to contain a [Tailscale auth key](https://tailscale.com/kb/1085/auth-keys)
(an ephemeral, but reusable, one is recommended). (an ephemeral, but reusable, one is recommended).
Add this section to your tailscale ACLs to auto-approve the routes for the Add this section to your tailscale ACLs to auto-approve the routes for the

View file

@ -15,13 +15,13 @@ Let's see how.
### General ### General
This [documentation](../../ "Link to the official Traefik documentation") is built with [MkDocs](https://mkdocs.org/ "Link to website of MkDocs"). This [documentation](../../ "Link to the official Traefik documentation") is built with [MkDocs](https://mkdocs.org/ "Link to the website of MkDocs").
### Method 1: `Docker` and `make` ### Method 1: `Docker` and `make`
Please make sure you have the following requirements installed: Please make sure you have the following requirements installed:
- [Docker](https://www.docker.com/ "Link to website of Docker") - [Docker](https://www.docker.com/ "Link to the website of Docker")
You can build the documentation and test it locally (with live reloading), using the `docs-serve` target: You can build the documentation and test it locally (with live reloading), using the `docs-serve` target:
@ -51,7 +51,7 @@ $ make docs-build
Please make sure you have the following requirements installed: Please make sure you have the following requirements installed:
- [Python](https://www.python.org/ "Link to website of Python") - [Python](https://www.python.org/ "Link to the website of Python")
- [pip](https://pypi.org/project/pip/ "Link to the website of pip on PyPI") - [pip](https://pypi.org/project/pip/ "Link to the website of pip on PyPI")
```bash ```bash

View file

@ -32,7 +32,7 @@ The contributor should also meet one or several of the following requirements:
including those of other maintainers and contributors. including those of other maintainers and contributors.
- The contributor is active on Traefik Community forums - The contributor is active on Traefik Community forums
or other technical forums/boards such as K8S slack, Reddit, StackOverflow, hacker news. or other technical forums/boards, such as K8S Slack, Reddit, StackOverflow, and Hacker News.
Any existing active maintainer can create an issue to discuss promoting a contributor to maintainer. Any existing active maintainer can create an issue to discuss promoting a contributor to maintainer.
Other maintainers can vote on the issue, and if the quorum is reached, the contributor is promoted to maintainer. Other maintainers can vote on the issue, and if the quorum is reached, the contributor is promoted to maintainer.

View file

@ -17,7 +17,7 @@ or the list of [confirmed bugs](https://github.com/traefik/traefik/labels/kind%2
## How We Prioritize ## How We Prioritize
We wish we could review every pull request right away, but because it's a time consuming operation, it's not always possible. We wish we could review every pull request right away, but because it's a time-consuming operation, it's not always possible.
The PRs we are able to handle the fastest are: The PRs we are able to handle the fastest are:
@ -130,7 +130,7 @@ This label can be used when:
Traefik Proxy is made by the community for the community, Traefik Proxy is made by the community for the community,
as such the goal is to engage the community to make Traefik the best reverse proxy available. as such the goal is to engage the community to make Traefik the best reverse proxy available.
Part of this goal is maintaining a lean codebase and ensuring code velocity. Part of this goal is maintaining a lean codebase and ensuring code velocity.
unfortunately, this means that sometimes we will not be able to merge a pull request. Unfortunately, this means that sometimes we will not be able to merge a pull request.
Because we respect the work you did, you will always be told why we are closing your pull request. Because we respect the work you did, you will always be told why we are closing your pull request.
If you do not agree with our decision, do not worry; closed pull requests are effortless to recreate, If you do not agree with our decision, do not worry; closed pull requests are effortless to recreate,

View file

@ -8,7 +8,7 @@ description: "Security is a key part of Traefik Proxy. Read the technical docume
## Security Advisories ## Security Advisories
We strongly advise you to join our mailing list to be aware of the latest announcements from our security team. We strongly advise you to join our mailing list to be aware of the latest announcements from our security team.
You can subscribe sending a mail to security+subscribe@traefik.io or on [the online viewer](https://groups.google.com/a/traefik.io/forum/#!forum/security). You can subscribe by sending an email to security+subscribe@traefik.io or on [the online viewer](https://groups.google.com/a/traefik.io/forum/#!forum/security).
## CVE ## CVE

View file

@ -658,4 +658,4 @@ Please check out the [entrypoint forwarded headers connection option configurati
### X-Forwarded-Prefix ### X-Forwarded-Prefix
In `v2.11.14`, the `X-Forwarded-Prefix` header is now handled like the other `X-Forwarded-*` headers: Traefik removes it when it's sent from an untrusted source. In `v2.11.14`, the `X-Forwarded-Prefix` header is now handled like the other `X-Forwarded-*` headers: Traefik removes it when it's sent from an untrusted source.
Please refer to the Forwarded headers [documentation](https://doc.traefik.io/traefik/routing/entrypoints/#forwarded-headers) for more details. Please refer to the Forwarded headers [documentation](../routing/entrypoints.md#forwarded-headers) for more details.

View file

@ -79,6 +79,20 @@ If the given format is unsupported, the default (CLF) is used instead.
<remote_IP_address> - <client_user_name_if_available> [<timestamp>] "<request_method> <request_path> <request_protocol>" <HTTP_status> <content-length> "<request_referrer>" "<request_user_agent>" <number_of_requests_received_since_Traefik_started> "<Traefik_router_name>" "<Traefik_server_URL>" <request_duration_in_ms>ms <remote_IP_address> - <client_user_name_if_available> [<timestamp>] "<request_method> <request_path> <request_protocol>" <HTTP_status> <content-length> "<request_referrer>" "<request_user_agent>" <number_of_requests_received_since_Traefik_started> "<Traefik_router_name>" "<Traefik_server_URL>" <request_duration_in_ms>ms
``` ```
```yaml tab="File (YAML)"
accessLog:
format: "json"
```
```toml tab="File (TOML)"
[accessLog]
format = "json"
```
```bash tab="CLI"
--accesslog.format=json
```
### `bufferingSize` ### `bufferingSize`
To write the logs in an asynchronous fashion, specify a `bufferingSize` option. To write the logs in an asynchronous fashion, specify a `bufferingSize` option.

View file

@ -70,7 +70,7 @@ And then define a routing configuration on Traefik itself with the
### `insecure` ### `insecure`
Enable the API in `insecure` mode, which means that the API will be available directly on the entryPoint named `traefik`. Enable the API in `insecure` mode, which means that the API will be available directly on the entryPoint named `traefik`, on path `/api`.
!!! info !!! info
If the entryPoint named `traefik` is not configured, it will be automatically created on port 8080. If the entryPoint named `traefik` is not configured, it will be automatically created on port 8080.

View file

@ -37,32 +37,15 @@ Start by enabling the dashboard by using the following option from [Traefik's AP
on the [static configuration](../getting-started/configuration-overview.md#the-static-configuration): on the [static configuration](../getting-started/configuration-overview.md#the-static-configuration):
```yaml tab="File (YAML)" ```yaml tab="File (YAML)"
api: api: {}
# Dashboard
#
# Optional
# Default: true
#
dashboard: true
``` ```
```toml tab="File (TOML)" ```toml tab="File (TOML)"
[api] [api]
# Dashboard
#
# Optional
# Default: true
#
dashboard = true
``` ```
```bash tab="CLI" ```bash tab="CLI"
# Dashboard --api=true
#
# Optional
# Default: true
#
--api.dashboard=true
``` ```
Then define a routing configuration on Traefik itself, Then define a routing configuration on Traefik itself,
@ -106,27 +89,47 @@ rule = "Host(`traefik.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/dashb
## Insecure Mode ## Insecure Mode
This mode is not recommended because it does not allow the use of security features. When _insecure_ mode is enabled, one can access the dashboard on the `traefik` port (default: `8080`) of the Traefik instance,
at the following URL: `http://<Traefik IP>:8080/dashboard/` (trailing slash is mandatory).
To enable the "insecure mode", use the following options from [Traefik's API](./api.md#insecure): This mode is **not** recommended because it does not allow security features.
For example, it is not possible to add an authentication middleware with this mode.
It should be used for testing purpose **only**.
To enable the _insecure_ mode, use the following options from [Traefik's API](./api.md#insecure):
```yaml tab="File (YAML)" ```yaml tab="File (YAML)"
api: api:
dashboard: true
insecure: true insecure: true
``` ```
```toml tab="File (TOML)" ```toml tab="File (TOML)"
[api] [api]
dashboard = true
insecure = true insecure = true
``` ```
```bash tab="CLI" ```bash tab="CLI"
--api.dashboard=true --api.insecure=true --api.insecure=true
``` ```
You can now access the dashboard on the port `8080` of the Traefik instance, ## Disable The Dashboard
at the following URL: `http://<Traefik IP>:8080/dashboard/` (trailing slash is mandatory).
By default, the dashboard is enabled when the API is enabled.
If necessary, the dashboard can be disabled by using the following option.
```yaml tab="File (YAML)"
api:
dashboard: false
```
```toml tab="File (TOML)"
[api]
dashboard = false
```
```bash tab="CLI"
--api.dashboard=false
```
{!traefik-for-business-applications.md!} {!traefik-for-business-applications.md!}

View file

@ -525,7 +525,7 @@ providers:
``` ```
```bash tab="CLI" ```bash tab="CLI"
--providers.consulcatalog.defaultRule=Host(`{{ .Name }}.{{ index .Labels \"customLabel\"}}`) --providers.consulcatalog.defaultRule='Host(`{{ .Name }}.{{ index .Labels "customLabel"}}`)'
# ... # ...
``` ```

View file

@ -455,7 +455,7 @@ providers:
``` ```
```bash tab="CLI" ```bash tab="CLI"
--providers.docker.defaultRule=Host(`{{ .Name }}.{{ index .Labels \"customLabel\"}}`) --providers.docker.defaultRule='Host(`{{ .Name }}.{{ index .Labels "customLabel"}}`)'
# ... # ...
``` ```

View file

@ -283,7 +283,7 @@ providers:
``` ```
```bash tab="CLI" ```bash tab="CLI"
--providers.ecs.defaultRule=Host(`{{ .Name }}.{{ index .Labels \"customLabel\"}}`) --providers.ecs.defaultRule='Host(`{{ .Name }}.{{ index .Labels "customLabel"}}`)'
# ... # ...
``` ```

View file

@ -432,7 +432,7 @@ providers:
``` ```
```bash tab="CLI" ```bash tab="CLI"
--providers.nomad.defaultRule="Host(`{{ .Name }}.{{ index .Labels \"customLabel\"}}`)" --providers.nomad.defaultRule='Host(`{{ .Name }}.{{ index .Labels "customLabel"}}`)'
# ... # ...
``` ```

View file

@ -503,7 +503,7 @@ providers:
``` ```
```bash tab="CLI" ```bash tab="CLI"
--providers.swarm.defaultRule=Host(`{{ .Name }}.{{ index .Labels \"customLabel\"}}`) --providers.swarm.defaultRule='Host(`{{ .Name }}.{{ index .Labels "customLabel"}}`)'
# ... # ...
``` ```

View file

@ -625,17 +625,17 @@ func createHTTPServer(ctx context.Context, ln net.Listener, configuration *stati
handler = contenttype.DisableAutoDetection(handler) handler = contenttype.DisableAutoDetection(handler)
debugConnection := os.Getenv(debugConnectionEnv) != ""
if debugConnection || (configuration.Transport != nil && (configuration.Transport.KeepAliveMaxTime > 0 || configuration.Transport.KeepAliveMaxRequests > 0)) {
handler = newKeepAliveMiddleware(handler, configuration.Transport.KeepAliveMaxRequests, configuration.Transport.KeepAliveMaxTime)
}
if withH2c { if withH2c {
handler = h2c.NewHandler(handler, &http2.Server{ handler = h2c.NewHandler(handler, &http2.Server{
MaxConcurrentStreams: uint32(configuration.HTTP2.MaxConcurrentStreams), MaxConcurrentStreams: uint32(configuration.HTTP2.MaxConcurrentStreams),
}) })
} }
debugConnection := os.Getenv(debugConnectionEnv) != ""
if debugConnection || (configuration.Transport != nil && (configuration.Transport.KeepAliveMaxTime > 0 || configuration.Transport.KeepAliveMaxRequests > 0)) {
handler = newKeepAliveMiddleware(handler, configuration.Transport.KeepAliveMaxRequests, configuration.Transport.KeepAliveMaxTime)
}
serverHTTP := &http.Server{ serverHTTP := &http.Server{
Handler: handler, Handler: handler,
ErrorLog: stdlog.New(logs.NoLevel(log.Logger, zerolog.DebugLevel), "", 0), ErrorLog: stdlog.New(logs.NoLevel(log.Logger, zerolog.DebugLevel), "", 0),

View file

@ -3,6 +3,7 @@ package server
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/tls"
"errors" "errors"
"io" "io"
"net" "net"
@ -17,6 +18,7 @@ import (
"github.com/traefik/traefik/v3/pkg/config/static" "github.com/traefik/traefik/v3/pkg/config/static"
tcprouter "github.com/traefik/traefik/v3/pkg/server/router/tcp" tcprouter "github.com/traefik/traefik/v3/pkg/server/router/tcp"
"github.com/traefik/traefik/v3/pkg/tcp" "github.com/traefik/traefik/v3/pkg/tcp"
"golang.org/x/net/http2"
) )
func TestShutdownHijacked(t *testing.T) { func TestShutdownHijacked(t *testing.T) {
@ -330,3 +332,53 @@ func TestKeepAliveMaxTime(t *testing.T) {
err = resp.Body.Close() err = resp.Body.Close()
require.NoError(t, err) require.NoError(t, err)
} }
func TestKeepAliveH2c(t *testing.T) {
epConfig := &static.EntryPointsTransport{}
epConfig.SetDefaults()
epConfig.KeepAliveMaxRequests = 1
entryPoint, err := NewTCPEntryPoint(context.Background(), "", &static.EntryPoint{
Address: ":0",
Transport: epConfig,
ForwardedHeaders: &static.ForwardedHeaders{},
HTTP2: &static.HTTP2Config{},
}, nil, nil)
require.NoError(t, err)
router, err := tcprouter.NewRouter()
require.NoError(t, err)
router.SetHTTPHandler(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusOK)
}))
conn, err := startEntrypoint(entryPoint, router)
require.NoError(t, err)
http2Transport := &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
return conn, nil
},
}
client := &http.Client{Transport: http2Transport}
resp, err := client.Get("http://" + entryPoint.listener.Addr().String())
require.NoError(t, err)
require.False(t, resp.Close)
err = resp.Body.Close()
require.NoError(t, err)
_, err = client.Get("http://" + entryPoint.listener.Addr().String())
require.Error(t, err)
// Unlike HTTP/1, where we can directly check `resp.Close`, HTTP/2 uses a different
// mechanism: it sends a GOAWAY frame when the connection is closing.
// We can only check the error type. The error received should be poll.ErrClosed from
// the `internal/poll` package, but we cannot directly reference the error type due to
// package restrictions. Since this error message ("use of closed network connection")
// is distinct and specific, we rely on its consistency, assuming it is stable and unlikely
// to change.
require.Contains(t, err.Error(), "use of closed network connection")
}

View file

@ -8,11 +8,6 @@ import (
"strings" "strings"
) )
type serviceManager interface {
BuildHTTP(rootCtx context.Context, serviceName string) (http.Handler, error)
LaunchHealthCheck(ctx context.Context)
}
// InternalHandlers is the internal HTTP handlers builder. // InternalHandlers is the internal HTTP handlers builder.
type InternalHandlers struct { type InternalHandlers struct {
api http.Handler api http.Handler
@ -21,11 +16,10 @@ type InternalHandlers struct {
prometheus http.Handler prometheus http.Handler
ping http.Handler ping http.Handler
acmeHTTP http.Handler acmeHTTP http.Handler
serviceManager
} }
// NewInternalHandlers creates a new InternalHandlers. // NewInternalHandlers creates a new InternalHandlers.
func NewInternalHandlers(next serviceManager, apiHandler, rest, metricsHandler, pingHandler, dashboard, acmeHTTP http.Handler) *InternalHandlers { func NewInternalHandlers(apiHandler, rest, metricsHandler, pingHandler, dashboard, acmeHTTP http.Handler) *InternalHandlers {
return &InternalHandlers{ return &InternalHandlers{
api: apiHandler, api: apiHandler,
dashboard: dashboard, dashboard: dashboard,
@ -33,14 +27,13 @@ func NewInternalHandlers(next serviceManager, apiHandler, rest, metricsHandler,
prometheus: metricsHandler, prometheus: metricsHandler,
ping: pingHandler, ping: pingHandler,
acmeHTTP: acmeHTTP, acmeHTTP: acmeHTTP,
serviceManager: next,
} }
} }
// BuildHTTP builds an HTTP handler. // BuildHTTP builds an HTTP handler.
func (m *InternalHandlers) BuildHTTP(rootCtx context.Context, serviceName string) (http.Handler, error) { func (m *InternalHandlers) BuildHTTP(rootCtx context.Context, serviceName string) (http.Handler, error) {
if !strings.HasSuffix(serviceName, "@internal") { if !strings.HasSuffix(serviceName, "@internal") {
return m.serviceManager.BuildHTTP(rootCtx, serviceName) return nil, nil
} }
internalHandler, err := m.get(serviceName) internalHandler, err := m.get(serviceName)

View file

@ -74,13 +74,12 @@ func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *s
} }
// Build creates a service manager. // Build creates a service manager.
func (f *ManagerFactory) Build(configuration *runtime.Configuration) *InternalHandlers { func (f *ManagerFactory) Build(configuration *runtime.Configuration) *Manager {
svcManager := NewManager(configuration.Services, f.observabilityMgr, f.routinesPool, f.transportManager, f.proxyBuilder)
var apiHandler http.Handler var apiHandler http.Handler
if f.api != nil { if f.api != nil {
apiHandler = f.api(configuration) apiHandler = f.api(configuration)
} }
return NewInternalHandlers(svcManager, apiHandler, f.restHandler, f.metricsHandler, f.pingHandler, f.dashboardHandler, f.acmeHTTPHandler) internalHandlers := NewInternalHandlers(apiHandler, f.restHandler, f.metricsHandler, f.pingHandler, f.dashboardHandler, f.acmeHTTPHandler)
return NewManager(configuration.Services, f.observabilityMgr, f.routinesPool, f.transportManager, f.proxyBuilder, internalHandlers)
} }

View file

@ -46,12 +46,18 @@ type ProxyBuilder interface {
Update(configs map[string]*dynamic.ServersTransport) Update(configs map[string]*dynamic.ServersTransport)
} }
// ServiceBuilder is a Service builder.
type ServiceBuilder interface {
BuildHTTP(rootCtx context.Context, serviceName string) (http.Handler, error)
}
// Manager The service manager. // Manager The service manager.
type Manager struct { type Manager struct {
routinePool *safe.Pool routinePool *safe.Pool
observabilityMgr *middleware.ObservabilityMgr observabilityMgr *middleware.ObservabilityMgr
transportManager httputil.TransportManager transportManager httputil.TransportManager
proxyBuilder ProxyBuilder proxyBuilder ProxyBuilder
serviceBuilders []ServiceBuilder
services map[string]http.Handler services map[string]http.Handler
configs map[string]*runtime.ServiceInfo configs map[string]*runtime.ServiceInfo
@ -60,12 +66,13 @@ type Manager struct {
} }
// NewManager creates a new Manager. // NewManager creates a new Manager.
func NewManager(configs map[string]*runtime.ServiceInfo, observabilityMgr *middleware.ObservabilityMgr, routinePool *safe.Pool, transportManager httputil.TransportManager, proxyBuilder ProxyBuilder) *Manager { func NewManager(configs map[string]*runtime.ServiceInfo, observabilityMgr *middleware.ObservabilityMgr, routinePool *safe.Pool, transportManager httputil.TransportManager, proxyBuilder ProxyBuilder, serviceBuilders ...ServiceBuilder) *Manager {
return &Manager{ return &Manager{
routinePool: routinePool, routinePool: routinePool,
observabilityMgr: observabilityMgr, observabilityMgr: observabilityMgr,
transportManager: transportManager, transportManager: transportManager,
proxyBuilder: proxyBuilder, proxyBuilder: proxyBuilder,
serviceBuilders: serviceBuilders,
services: make(map[string]http.Handler), services: make(map[string]http.Handler),
configs: configs, configs: configs,
healthCheckers: make(map[string]*healthcheck.ServiceHealthChecker), healthCheckers: make(map[string]*healthcheck.ServiceHealthChecker),
@ -85,6 +92,18 @@ func (m *Manager) BuildHTTP(rootCtx context.Context, serviceName string) (http.H
return handler, nil return handler, nil
} }
// Must be before we get configs to handle services without config.
for _, builder := range m.serviceBuilders {
handler, err := builder.BuildHTTP(rootCtx, serviceName)
if err != nil {
return nil, err
}
if handler != nil {
m.services[serviceName] = handler
return handler, nil
}
}
conf, ok := m.configs[serviceName] conf, ok := m.configs[serviceName]
if !ok { if !ok {
return nil, fmt.Errorf("the service %q does not exist", serviceName) return nil, fmt.Errorf("the service %q does not exist", serviceName)

View file

@ -450,6 +450,48 @@ func Test1xxResponses(t *testing.T) {
} }
} }
type serviceBuilderFunc func(ctx context.Context, serviceName string) (http.Handler, error)
func (s serviceBuilderFunc) BuildHTTP(ctx context.Context, serviceName string) (http.Handler, error) {
return s(ctx, serviceName)
}
type internalHandler struct{}
func (internalHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {}
func TestManager_ServiceBuilders(t *testing.T) {
var internalHandler internalHandler
manager := NewManager(map[string]*runtime.ServiceInfo{
"test@test": {
Service: &dynamic.Service{
LoadBalancer: &dynamic.ServersLoadBalancer{},
},
},
}, nil, nil, &TransportManager{
roundTrippers: map[string]http.RoundTripper{
"default@internal": http.DefaultTransport,
},
}, nil, serviceBuilderFunc(func(rootCtx context.Context, serviceName string) (http.Handler, error) {
if strings.HasSuffix(serviceName, "@internal") {
return internalHandler, nil
}
return nil, nil
}))
h, err := manager.BuildHTTP(context.Background(), "test@internal")
require.NoError(t, err)
assert.Equal(t, internalHandler, h)
h, err = manager.BuildHTTP(context.Background(), "test@test")
require.NoError(t, err)
assert.NotNil(t, h)
_, err = manager.BuildHTTP(context.Background(), "wrong@test")
assert.Error(t, err)
}
func TestManager_Build(t *testing.T) { func TestManager_Build(t *testing.T) {
testCases := []struct { testCases := []struct {
desc string desc string