diff --git a/docs/content/routing/providers/kubernetes-gateway.md b/docs/content/routing/providers/kubernetes-gateway.md index 4649cf5ca..a40e18b63 100644 --- a/docs/content/routing/providers/kubernetes-gateway.md +++ b/docs/content/routing/providers/kubernetes-gateway.md @@ -251,39 +251,51 @@ Kubernetes cluster before creating `HTTPRoute` objects. requestRedirect: # [27] scheme: https # [28] statusCode: 301 # [29] + - type: RequestHeaderModifier # [30] + requestHeaderModifier: # [31] + set: + - name: X-Foo + value: Bar + add: + - name: X-Bar + value: Foo + remove: + - X-Baz ``` -| Ref | Attribute | Description | -|------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [1] | `parentRefs` | References the resources (usually Gateways) that a Route wants to be attached to. | -| [2] | `name` | Name of the referent. | -| [3] | `namespace` | Namespace of the referent. When unspecified (or empty string), this refers to the local namespace of the Route. | -| [4] | `sectionName` | Name of a section within the target resource (the Listener name). | -| [5] | `hostnames` | A set of hostname that should match against the HTTP Host header to select a HTTPRoute to process the request. | -| [6] | `rules` | A list of HTTP matchers, filters and actions. | -| [7] | `matches` | Conditions used for matching the rule against incoming HTTP requests. Each match is independent, i.e. this rule will be matched if **any** one of the matches is satisfied. | -| [8] | `path` | An HTTP request path matcher. If this field is not specified, a default prefix match on the "/" path is provided. | -| [9] | `type` | Type of match against the path Value (supported types: `Exact`, `Prefix`). | -| [10] | `value` | The value of the HTTP path to match against. | -| [11] | `headers` | Conditions to select a HTTP route by matching HTTP request headers. | -| [12] | `name` | Name of the HTTP header to be matched. | -| [13] | `value` | Value of HTTP Header to be matched. | -| [14] | `backendRefs` | Defines the backend(s) where matching requests should be sent. | -| [15] | `name` | The name of the referent service. | -| [16] | `weight` | The proportion of traffic forwarded to a targetRef, computed as weight/(sum of all weights in targetRefs). | -| [17] | `port` | The port of the referent service. | -| [18] | `group` | Group is the group of the referent. Only `traefik.io` and `gateway.networking.k8s.io` values are supported. | -| [19] | `kind` | Kind is kind of the referent. Only `TraefikService` and `Service` values are supported. | -| [20] | `filters` | Defines the filters (middlewares) applied to the route. | -| [21] | `type` | Defines the type of filter; ExtensionRef is used for configuring custom HTTP filters. | -| [22] | `extensionRef` | Configuration of the custom HTTP filter. | -| [23] | `group` | Group of the kubernetes object to reference. | -| [24] | `kind` | Kind of the kubernetes object to reference. | -| [25] | `name` | Name of the kubernetes object to reference. | -| [26] | `type` | Defines the type of filter; RequestRedirect redirects a request to another location. | -| [27] | `requestRedirect` | Configuration of redirect filter. | -| [28] | `scheme` | Scheme is the scheme to be used in the value of the Location header in the response. | -| [29] | `statusCode` | StatusCode is the HTTP status code to be used in response. | +| Ref | Attribute | Description | +|------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [1] | `parentRefs` | References the resources (usually Gateways) that a Route wants to be attached to. | +| [2] | `name` | Name of the referent. | +| [3] | `namespace` | Namespace of the referent. When unspecified (or empty string), this refers to the local namespace of the Route. | +| [4] | `sectionName` | Name of a section within the target resource (the Listener name). | +| [5] | `hostnames` | A set of hostname that should match against the HTTP Host header to select a HTTPRoute to process the request. | +| [6] | `rules` | A list of HTTP matchers, filters and actions. | +| [7] | `matches` | Conditions used for matching the rule against incoming HTTP requests. Each match is independent, i.e. this rule will be matched if **any** one of the matches is satisfied. | +| [8] | `path` | An HTTP request path matcher. If this field is not specified, a default prefix match on the "/" path is provided. | +| [9] | `type` | Type of match against the path Value (supported types: `Exact`, `Prefix`). | +| [10] | `value` | The value of the HTTP path to match against. | +| [11] | `headers` | Conditions to select a HTTP route by matching HTTP request headers. | +| [12] | `name` | Name of the HTTP header to be matched. | +| [13] | `value` | Value of HTTP Header to be matched. | +| [14] | `backendRefs` | Defines the backend(s) where matching requests should be sent. | +| [15] | `name` | The name of the referent service. | +| [16] | `weight` | The proportion of traffic forwarded to a targetRef, computed as weight/(sum of all weights in targetRefs). | +| [17] | `port` | The port of the referent service. | +| [18] | `group` | Group is the group of the referent. Only `traefik.io` and `gateway.networking.k8s.io` values are supported. | +| [19] | `kind` | Kind is kind of the referent. Only `TraefikService` and `Service` values are supported. | +| [20] | `filters` | Defines the filters (middlewares) applied to the route. | +| [21] | `type` | Defines the type of filter; ExtensionRef is used for configuring custom HTTP filters. | +| [22] | `extensionRef` | Configuration of the custom HTTP filter. | +| [23] | `group` | Group of the kubernetes object to reference. | +| [24] | `kind` | Kind of the kubernetes object to reference. | +| [25] | `name` | Name of the kubernetes object to reference. | +| [26] | `type` | Defines the type of filter; RequestRedirect redirects a request to another location. | +| [27] | `requestRedirect` | Configuration of redirect filter. | +| [28] | `scheme` | Scheme is the scheme to be used in the value of the Location header in the response. | +| [29] | `statusCode` | StatusCode is the HTTP status code to be used in response. | +| [30] | `type` | Defines the type of filter; RequestHeaderModifier modifies request headers. | +| [31] | `requestHeaderModifier` | Configuration of RequestHeaderModifier filter. | ### Kind: `TCPRoute` diff --git a/internal/gendoc.go b/internal/gendoc.go index deb7757ee..67eb64003 100644 --- a/internal/gendoc.go +++ b/internal/gendoc.go @@ -207,7 +207,13 @@ func clean(element any) { var svcFieldNames []string for i := range valueSvcRoot.NumField() { - svcFieldNames = append(svcFieldNames, valueSvcRoot.Type().Field(i).Name) + field := valueSvcRoot.Type().Field(i) + // do not create empty node for hidden config. + if field.Tag.Get("file") == "-" && field.Tag.Get("kv") == "-" && field.Tag.Get("label") == "-" { + continue + } + + svcFieldNames = append(svcFieldNames, field.Name) } sort.Strings(svcFieldNames) diff --git a/pkg/config/dynamic/middlewares.go b/pkg/config/dynamic/middlewares.go index a9be47306..6fbe2a0f5 100644 --- a/pkg/config/dynamic/middlewares.go +++ b/pkg/config/dynamic/middlewares.go @@ -39,6 +39,9 @@ type Middleware struct { GrpcWeb *GrpcWeb `json:"grpcWeb,omitempty" toml:"grpcWeb,omitempty" yaml:"grpcWeb,omitempty" export:"true"` Plugin map[string]PluginConf `json:"plugin,omitempty" toml:"plugin,omitempty" yaml:"plugin,omitempty" export:"true"` + + // Gateway API HTTPRoute filters middlewares. + RequestHeaderModifier *RequestHeaderModifier `json:"requestHeaderModifier,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` } // +k8s:deepcopy-gen=true @@ -673,3 +676,12 @@ type TLSClientCertificateSubjectDNInfo struct { // Users holds a list of users. type Users []string + +// +k8s:deepcopy-gen=true + +// RequestHeaderModifier holds the request header modifier configuration. +type RequestHeaderModifier struct { + Set map[string]string `json:"set,omitempty"` + Add map[string]string `json:"add,omitempty"` + Remove []string `json:"remove,omitempty"` +} diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index f04de4275..63b159382 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -859,6 +859,11 @@ func (in *Middleware) DeepCopyInto(out *Middleware) { (*out)[key] = *val.DeepCopy() } } + if in.RequestHeaderModifier != nil { + in, out := &in.RequestHeaderModifier, &out.RequestHeaderModifier + *out = new(RequestHeaderModifier) + (*in).DeepCopyInto(*out) + } return } @@ -1067,6 +1072,41 @@ func (in *ReplacePathRegex) DeepCopy() *ReplacePathRegex { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RequestHeaderModifier) DeepCopyInto(out *RequestHeaderModifier) { + *out = *in + if in.Set != nil { + in, out := &in.Set, &out.Set + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Add != nil { + in, out := &in.Add, &out.Add + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Remove != nil { + in, out := &in.Remove, &out.Remove + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RequestHeaderModifier. +func (in *RequestHeaderModifier) DeepCopy() *RequestHeaderModifier { + if in == nil { + return nil + } + out := new(RequestHeaderModifier) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResponseForwarding) DeepCopyInto(out *ResponseForwarding) { *out = *in diff --git a/pkg/middlewares/headermodifier/request_header_modifier.go b/pkg/middlewares/headermodifier/request_header_modifier.go new file mode 100644 index 000000000..3b197c2d5 --- /dev/null +++ b/pkg/middlewares/headermodifier/request_header_modifier.go @@ -0,0 +1,56 @@ +package headermodifier + +import ( + "context" + "net/http" + + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "github.com/traefik/traefik/v3/pkg/middlewares" + "go.opentelemetry.io/otel/trace" +) + +const typeName = "RequestHeaderModifier" + +// requestHeaderModifier is a middleware used to modify the headers of an HTTP request. +type requestHeaderModifier struct { + next http.Handler + name string + + set map[string]string + add map[string]string + remove []string +} + +// NewRequestHeaderModifier creates a new request header modifier middleware. +func NewRequestHeaderModifier(ctx context.Context, next http.Handler, config dynamic.RequestHeaderModifier, name string) (http.Handler, error) { + logger := middlewares.GetLogger(ctx, name, typeName) + logger.Debug().Msg("Creating middleware") + + return &requestHeaderModifier{ + next: next, + name: name, + set: config.Set, + add: config.Add, + remove: config.Remove, + }, nil +} + +func (r *requestHeaderModifier) GetTracingInformation() (string, string, trace.SpanKind) { + return r.name, typeName, trace.SpanKindUnspecified +} + +func (r *requestHeaderModifier) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + for headerName, headerValue := range r.set { + req.Header.Set(headerName, headerValue) + } + + for headerName, headerValue := range r.add { + req.Header.Add(headerName, headerValue) + } + + for _, headerName := range r.remove { + req.Header.Del(headerName) + } + + r.next.ServeHTTP(rw, req) +} diff --git a/pkg/middlewares/headermodifier/request_header_modifier_test.go b/pkg/middlewares/headermodifier/request_header_modifier_test.go new file mode 100644 index 000000000..60c446ecc --- /dev/null +++ b/pkg/middlewares/headermodifier/request_header_modifier_test.go @@ -0,0 +1,121 @@ +package headermodifier + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "github.com/traefik/traefik/v3/pkg/testhelpers" +) + +func TestRequestHeaderModifier(t *testing.T) { + testCases := []struct { + desc string + config dynamic.RequestHeaderModifier + requestHeaders http.Header + expectedHeaders http.Header + }{ + { + desc: "no config", + config: dynamic.RequestHeaderModifier{}, + expectedHeaders: map[string][]string{}, + }, + { + desc: "set header", + config: dynamic.RequestHeaderModifier{ + Set: map[string]string{"Foo": "Bar"}, + }, + expectedHeaders: map[string][]string{"Foo": {"Bar"}}, + }, + { + desc: "set header with existing headers", + config: dynamic.RequestHeaderModifier{ + Set: map[string]string{"Foo": "Bar"}, + }, + requestHeaders: map[string][]string{"Foo": {"Baz"}, "Bar": {"Foo"}}, + expectedHeaders: map[string][]string{"Foo": {"Bar"}, "Bar": {"Foo"}}, + }, + { + desc: "set multiple headers with existing headers", + config: dynamic.RequestHeaderModifier{ + Set: map[string]string{"Foo": "Bar", "Bar": "Foo"}, + }, + requestHeaders: map[string][]string{"Foo": {"Baz"}, "Bar": {"Foobar"}}, + expectedHeaders: map[string][]string{"Foo": {"Bar"}, "Bar": {"Foo"}}, + }, + { + desc: "add header", + config: dynamic.RequestHeaderModifier{ + Add: map[string]string{"Foo": "Bar"}, + }, + expectedHeaders: map[string][]string{"Foo": {"Bar"}}, + }, + { + desc: "add header with existing headers", + config: dynamic.RequestHeaderModifier{ + Add: map[string]string{"Foo": "Bar"}, + }, + requestHeaders: map[string][]string{"Foo": {"Baz"}, "Bar": {"Foo"}}, + expectedHeaders: map[string][]string{"Foo": {"Baz", "Bar"}, "Bar": {"Foo"}}, + }, + { + desc: "add multiple headers with existing headers", + config: dynamic.RequestHeaderModifier{ + Add: map[string]string{"Foo": "Bar", "Bar": "Foo"}, + }, + requestHeaders: map[string][]string{"Foo": {"Baz"}, "Bar": {"Foobar"}}, + expectedHeaders: map[string][]string{"Foo": {"Baz", "Bar"}, "Bar": {"Foobar", "Foo"}}, + }, + { + desc: "remove header", + config: dynamic.RequestHeaderModifier{ + Remove: []string{"Foo"}, + }, + expectedHeaders: map[string][]string{}, + }, + { + desc: "remove header with existing headers", + config: dynamic.RequestHeaderModifier{ + Remove: []string{"Foo"}, + }, + requestHeaders: map[string][]string{"Foo": {"Baz"}, "Bar": {"Foo"}}, + expectedHeaders: map[string][]string{"Bar": {"Foo"}}, + }, + { + desc: "remove multiple headers with existing headers", + config: dynamic.RequestHeaderModifier{ + Remove: []string{"Foo", "Bar"}, + }, + requestHeaders: map[string][]string{"Foo": {"Bar"}, "Bar": {"Foo"}, "Baz": {"Bar"}}, + expectedHeaders: map[string][]string{"Baz": {"Bar"}}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + var gotHeaders http.Header + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeaders = r.Header + }) + + handler, err := NewRequestHeaderModifier(context.Background(), next, test.config, "foo-request-header-modifier") + require.NoError(t, err) + + req := testhelpers.MustNewRequest(http.MethodGet, "http://localhost", nil) + if test.requestHeaders != nil { + req.Header = test.requestHeaders + } + + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + + assert.Equal(t, test.expectedHeaders, gotHeaders) + }) + } +} diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_request_header_modifier.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_request_header_modifier.yml new file mode 100644 index 000000000..d5cc3d0b6 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_request_header_modifier.yml @@ -0,0 +1,58 @@ +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: # Use GatewayClass defaults for listener definition. + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + kinds: + - kind: HTTPRoute + group: gateway.networking.k8s.io + namespaces: + from: Same + +--- +kind: HTTPRoute +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: http-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + hostnames: + - "example.org" + rules: + - backendRefs: + - name: whoami + port: 80 + weight: 1 + kind: Service + group: "" + filters: + - type: RequestHeaderModifier + requestHeaderModifier: + set: + - name: X-Foo + value: Bar + add: + - name: X-Bar + value: Foo + remove: + - X-Baz diff --git a/pkg/provider/kubernetes/gateway/kubernetes.go b/pkg/provider/kubernetes/gateway/kubernetes.go index 01401d750..4c355c3a5 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes.go +++ b/pkg/provider/kubernetes/gateway/kubernetes.go @@ -1921,6 +1921,11 @@ func (p *Provider) loadMiddlewares(listener gatev1.Listener, namespace string, p } middlewares[name] = middleware + + case gatev1.HTTPRouteFilterRequestHeaderModifier: + middlewareName := provider.Normalize(fmt.Sprintf("%s-%s-%d", prefix, strings.ToLower(string(filter.Type)), i)) + middlewares[middlewareName] = createRequestHeaderModifier(filter.RequestHeaderModifier) + default: // As per the spec: // https://gateway-api.sigs.k8s.io/api-types/httproute/#filters-optional @@ -1950,6 +1955,28 @@ func (p *Provider) loadHTTPRouteFilterExtensionRef(namespace string, extensionRe return filterFunc(string(extensionRef.Name), namespace) } +// createRequestHeaderModifier does not enforce/check the configuration, +// as the spec indicates that either the webhook or CEL (since v1.0 GA Release) should enforce that. +func createRequestHeaderModifier(filter *gatev1.HTTPHeaderFilter) *dynamic.Middleware { + sets := map[string]string{} + for _, header := range filter.Set { + sets[string(header.Name)] = header.Value + } + + adds := map[string]string{} + for _, header := range filter.Add { + adds[string(header.Name)] = header.Value + } + + return &dynamic.Middleware{ + RequestHeaderModifier: &dynamic.RequestHeaderModifier{ + Set: sets, + Add: adds, + Remove: filter.Remove, + }, + } +} + func createRedirectRegexMiddleware(scheme string, filter *gatev1.HTTPRequestRedirectFilter) (*dynamic.Middleware, error) { // Use the HTTPRequestRedirectFilter scheme if defined. filterScheme := scheme diff --git a/pkg/provider/kubernetes/gateway/kubernetes_test.go b/pkg/provider/kubernetes/gateway/kubernetes_test.go index f3c6f8c00..64f7c7f96 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes_test.go +++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go @@ -1517,6 +1517,75 @@ func TestLoadHTTPRoutes(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Simple HTTPRoute, request header modifier", + paths: []string{"services.yml", "httproute/filter_request_header_modifier.yml"}, + entryPoints: map[string]Entrypoint{"web": { + Address: ":80", + }}, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4": { + EntryPoints: []string{"web"}, + Service: "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-wrr", + Rule: "Host(`example.org`) && PathPrefix(`/`)", + RuleSyntax: "v3", + Middlewares: []string{"default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-requestheadermodifier-0"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-requestheadermodifier-0": { + RequestHeaderModifier: &dynamic.RequestHeaderModifier{ + Set: map[string]string{"X-Foo": "Bar"}, + Add: map[string]string{"X-Bar": "Foo"}, + Remove: []string{"X-Baz"}, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-wrr": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "default-whoami-80", + Weight: func(i int) *int { return &i }(1), + }, + }, + }, + }, + "default-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Simple HTTPRoute, redirect HTTP to HTTPS", paths: []string{"services.yml", "httproute/filter_http_to_https.yml"}, diff --git a/pkg/server/middleware/middlewares.go b/pkg/server/middleware/middlewares.go index 0955fbcae..2217dc58a 100644 --- a/pkg/server/middleware/middlewares.go +++ b/pkg/server/middleware/middlewares.go @@ -21,6 +21,7 @@ import ( "github.com/traefik/traefik/v3/pkg/middlewares/contenttype" "github.com/traefik/traefik/v3/pkg/middlewares/customerrors" "github.com/traefik/traefik/v3/pkg/middlewares/grpcweb" + "github.com/traefik/traefik/v3/pkg/middlewares/headermodifier" "github.com/traefik/traefik/v3/pkg/middlewares/headers" "github.com/traefik/traefik/v3/pkg/middlewares/inflightreq" "github.com/traefik/traefik/v3/pkg/middlewares/ipallowlist" @@ -384,6 +385,16 @@ func (b *Builder) buildConstructor(ctx context.Context, middlewareName string) ( } } + // Gateway API HTTPRoute filters middlewares. + if config.RequestHeaderModifier != nil { + if middleware != nil { + return nil, badConf + } + middleware = func(next http.Handler) (http.Handler, error) { + return headermodifier.NewRequestHeaderModifier(ctx, next, *config.RequestHeaderModifier, middlewareName) + } + } + if middleware == nil { return nil, fmt.Errorf("invalid middleware %q configuration: invalid middleware type or middleware does not exist", middlewareName) } diff --git a/webui/src/components/_commons/PanelMiddlewares.vue b/webui/src/components/_commons/PanelMiddlewares.vue index 4b07ad2d9..4b2c24678 100644 --- a/webui/src/components/_commons/PanelMiddlewares.vue +++ b/webui/src/components/_commons/PanelMiddlewares.vue @@ -1491,6 +1491,61 @@ + + + +
+
+
+ Set +
+ + {{ key }}: {{ val }} + +
+
+
+ + +
+
+
+ Add +
+ + {{ key }}: {{ val }} + +
+
+
+ + +
+
+
+ Remove +
+ + {{ name }} + +
+
+